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, "Login") }) 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") } }