feat(opnsense,certmatch): read-only Export-Client mit Streaming und CN-Zuordnung

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
This commit is contained in:
Carsten Abele 2026-08-14 09:15:53 +02:00
parent 99ee8758cc
commit e49882b8a8
9 changed files with 1280 additions and 0 deletions

View file

@ -0,0 +1,234 @@
package opnsense
import (
"context"
"encoding/base64"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func newTestClient(t *testing.T, h http.HandlerFunc) (*Client, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
c, err := New(Options{
BaseURL: srv.URL,
APIKey: "KEY",
APISecret: "SECRET",
Timeout: 2 * time.Second,
HTTPClient: srv.Client(),
})
if err != nil {
t.Fatalf("New: %v", err)
}
return c, srv
}
func TestProvidersSendsBasicAuthAndParsesMap(t *testing.T) {
var gotPath string
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
user, pass, ok := r.BasicAuth()
if !ok || user != "KEY" || pass != "SECRET" {
t.Errorf("BasicAuth = %q/%q ok=%v", user, pass, ok)
}
io.WriteString(w, `{
"1": {"vpnid":"1","name":"VPN Homeoffice"},
"2": {"vpnid":"2","name":"VPN Aussendienst"}
}`)
})
ps, err := c.Providers(context.Background())
if err != nil {
t.Fatalf("Providers: %v", err)
}
if gotPath != "/api/openvpn/export/providers" {
t.Errorf("Pfad = %q", gotPath)
}
if len(ps) != 2 {
t.Fatalf("got %d Provider, want 2", len(ps))
}
// Stabile Sortierung nach VPNID, damit die UI-Reihenfolge deterministisch ist.
if ps[0].VPNID != "1" || ps[0].Name != "VPN Homeoffice" {
t.Errorf("ps[0] = %+v", ps[0])
}
}
func TestProvidersFallsBackToMapKeyAsVPNID(t *testing.T) {
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, `{"7": {"name":"VPN Sieben"}}`)
})
ps, err := c.Providers(context.Background())
if err != nil {
t.Fatalf("Providers: %v", err)
}
if len(ps) != 1 || ps[0].VPNID != "7" {
t.Fatalf("ps = %+v, VPNID muss aus dem Map-Schlüssel kommen", ps)
}
}
func TestAccountsParsesRevokedAndExpiry(t *testing.T) {
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/accounts/1") {
t.Errorf("Pfad = %q", r.URL.Path)
}
io.WriteString(w, `{
"abc123": {"commonName":"mmueller","description":"Max","validTo":"2027-03-01","isRevoked":"0"},
"def456": {"commonName":"jdoe","validTo":"2027-03-01","isRevoked":"1"}
}`)
})
accs, err := c.Accounts(context.Background(), "1")
if err != nil {
t.Fatalf("Accounts: %v", err)
}
if len(accs) != 2 {
t.Fatalf("got %d Accounts, want 2", len(accs))
}
byCN := map[string]Account{}
for _, a := range accs {
byCN[a.CommonName] = a
}
if byCN["mmueller"].RefID != "abc123" || byCN["mmueller"].Revoked {
t.Errorf("mmueller = %+v", byCN["mmueller"])
}
if !byCN["jdoe"].Revoked {
t.Error("jdoe muss als revoziert erkannt werden")
}
if byCN["mmueller"].ValidTo.Year() != 2027 {
t.Errorf("ValidTo = %v", byCN["mmueller"].ValidTo)
}
}
func TestExportStreamsRawBody(t *testing.T) {
const cfg = "client\nremote fw01.firma.local 1194\n"
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", `attachment; filename="mmueller.ovpn"`)
io.WriteString(w, cfg)
})
res, err := c.Export(context.Background(), "1", "abc123", FormatOVPN)
if err != nil {
t.Fatalf("Export: %v", err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
if string(body) != cfg {
t.Errorf("Body = %q", body)
}
if res.Filename != "mmueller.ovpn" {
t.Errorf("Filename = %q", res.Filename)
}
}
func TestExportDecodesBase64JSONBody(t *testing.T) {
const cfg = "client\nremote fw01 1194\n"
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"status":"ok","filename":"x.ovpn","content":"`+
base64.StdEncoding.EncodeToString([]byte(cfg))+`"}`)
})
res, err := c.Export(context.Background(), "1", "abc123", FormatOVPN)
if err != nil {
t.Fatalf("Export: %v", err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
if string(body) != cfg {
t.Errorf("Body = %q, want dekodierte Konfiguration", body)
}
if res.Filename != "x.ovpn" {
t.Errorf("Filename = %q", res.Filename)
}
}
func TestUnauthorizedAndForbiddenAreDistinct(t *testing.T) {
for status, want := range map[int]error{
http.StatusUnauthorized: ErrUnauthorized,
http.StatusForbidden: ErrForbidden,
} {
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
})
_, err := c.Providers(context.Background())
if !errors.Is(err, want) {
t.Errorf("Status %d: err = %v, want %v", status, err, want)
}
}
}
func TestUnreachableIsWrapped(t *testing.T) {
c, srv := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {})
srv.Close() // Server abschalten, um Verbindungsfehler zu erzwingen
_, err := c.Providers(context.Background())
if !errors.Is(err, ErrUnreachable) {
t.Fatalf("err = %v, want ErrUnreachable", err)
}
}
func TestHTMLLoginPageIsRejected(t *testing.T) {
// OPNsense liefert bei ungültigem Key manchmal 200 mit HTML-Loginseite.
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
io.WriteString(w, "<html><body>Login</body></html>")
})
if _, err := c.Providers(context.Background()); err == nil {
t.Fatal("HTML-Antwort muss als Fehler erkannt werden")
}
}
func TestMissingPluginIsNamedInError(t *testing.T) {
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
_, err := c.Providers(context.Background())
if err == nil || !strings.Contains(err.Error(), "os-openvpn-client-export") {
t.Fatalf("404 muss auf das fehlende Plugin hinweisen, got: %v", err)
}
}
func TestPingReturnsServerTime(t *testing.T) {
want := time.Date(2026, 8, 14, 7, 32, 11, 0, time.UTC)
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Date", want.Format(http.TimeFormat))
io.WriteString(w, `{}`)
})
got, err := c.Ping(context.Background())
if err != nil {
t.Fatalf("Ping: %v", err)
}
if !got.Equal(want) {
t.Errorf("Ping-Zeit = %v, want %v", got, want)
}
}
func TestNewValidatesOptions(t *testing.T) {
if _, err := New(Options{BaseURL: "https://fw", APIKey: "k", APISecret: "s"}); err != nil {
t.Fatalf("Standardfall muss funktionieren: %v", err)
}
if _, err := New(Options{BaseURL: "", APIKey: "k", APISecret: "s"}); err == nil {
t.Fatal("leere BaseURL muss abgelehnt werden")
}
if _, err := New(Options{BaseURL: "https://fw", APIKey: "", APISecret: "s"}); err == nil {
t.Fatal("fehlender API-Key muss abgelehnt werden")
}
}
func TestExportRejectsEmptyIdentifiers(t *testing.T) {
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("bei leeren Bezeichnern darf kein Request abgehen")
})
if _, err := c.Export(context.Background(), "", "abc", FormatOVPN); err == nil {
t.Error("leere vpnid muss abgelehnt werden")
}
if _, err := c.Export(context.Background(), "1", "", FormatOVPN); err == nil {
t.Error("leere Zertifikatsreferenz muss abgelehnt werden")
}
}