package portal import ( "context" "errors" "io" "strings" "testing" "time" "git.ravensburg.dev/cabele/opnsense-portal/internal/certmatch" "git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense" "git.ravensburg.dev/cabele/opnsense-portal/internal/web" ) // fakeFW zählt Aufrufe, damit Caching-Verhalten prüfbar wird. type fakeFW struct { providers []opnsense.Provider accounts map[string][]opnsense.Account providerCalls int accountCalls int exportCalls int lastExportArgs [3]string err error } func (f *fakeFW) Providers(ctx context.Context) ([]opnsense.Provider, error) { f.providerCalls++ return f.providers, f.err } func (f *fakeFW) Accounts(ctx context.Context, vpnID string) ([]opnsense.Account, error) { f.accountCalls++ if f.err != nil { return nil, f.err } return f.accounts[vpnID], nil } func (f *fakeFW) Export(ctx context.Context, vpnID, refID, format string) (*opnsense.ExportResult, error) { f.exportCalls++ f.lastExportArgs = [3]string{vpnID, refID, format} return &opnsense.ExportResult{ Filename: "von-der-firewall.ovpn", ContentType: "application/x-openvpn-profile", Body: io.NopCloser(strings.NewReader("client\n")), }, nil } var baseNow = time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) // newSourceAt baut eine Source mit einer verstellbaren Uhr. func newSourceAt(t *testing.T, fw *fakeFW, now *time.Time) *Source { t.Helper() m, err := certmatch.NewMatcher("{username}", "") if err != nil { t.Fatal(err) } return NewSource(fw, m, 2*time.Minute, func() time.Time { return *now }) } func newSource(t *testing.T, fw *fakeFW) *Source { t.Helper() now := baseNow return newSourceAt(t, fw, &now) } func standardFW() *fakeFW { return &fakeFW{ providers: []opnsense.Provider{ {VPNID: "1", Name: "VPN Homeoffice"}, {VPNID: "2", Name: "VPN Aussendienst"}, }, accounts: map[string][]opnsense.Account{ "1": { {RefID: "a1", CommonName: "mmueller", ValidTo: baseNow.AddDate(1, 0, 0)}, {RefID: "a2", CommonName: "jdoe", ValidTo: baseNow.AddDate(1, 0, 0)}, }, "2": { {RefID: "b1", CommonName: "mmueller", ValidTo: baseNow.AddDate(0, 0, 10)}, {RefID: "b2", CommonName: "mmueller", ValidTo: baseNow.AddDate(1, 0, 0), Revoked: true}, }, }, } } func TestEntriesForCollectsAcrossInstances(t *testing.T) { src := newSource(t, standardFW()) entries, err := src.EntriesFor(context.Background(), "mmueller") if err != nil { t.Fatalf("EntriesFor: %v", err) } if len(entries) != 2 { t.Fatalf("got %d Einträge, want 2: %+v", len(entries), entries) } names := map[string]bool{} for _, e := range entries { names[e.InstanceName] = true if e.CommonName != "mmueller" { t.Errorf("fremder CN in der Liste: %+v", e) } } if !names["VPN Homeoffice"] || !names["VPN Aussendienst"] { t.Errorf("Instanznamen fehlen: %+v", entries) } } func TestEntriesForExcludesRevokedAndForeign(t *testing.T) { src := newSource(t, standardFW()) entries, _ := src.EntriesFor(context.Background(), "mmueller") for _, e := range entries { if strings.Contains(e.Token, "b2") { t.Error("revoziertes Zertifikat darf nicht erscheinen") } if strings.Contains(e.Token, "a2") { t.Error("fremdes Zertifikat darf nicht erscheinen") } } } func TestAccountsAreNeverCached(t *testing.T) { fw := standardFW() src := newSource(t, fw) src.EntriesFor(context.Background(), "mmueller") first := fw.accountCalls src.EntriesFor(context.Background(), "mmueller") if fw.accountCalls <= first { t.Fatal("Accounts müssen bei jeder Anfrage frisch geholt werden (Revocation muss sofort greifen)") } } func TestProvidersAreCachedForTTL(t *testing.T) { fw := standardFW() now := baseNow src := newSourceAt(t, fw, &now) src.EntriesFor(context.Background(), "mmueller") src.EntriesFor(context.Background(), "mmueller") if fw.providerCalls != 1 { t.Fatalf("providerCalls = %d, want 1 (TTL-Cache)", fw.providerCalls) } now = now.Add(3 * time.Minute) src.EntriesFor(context.Background(), "mmueller") if fw.providerCalls != 2 { t.Fatalf("providerCalls = %d, want 2 (Cache abgelaufen)", fw.providerCalls) } } func TestExportRevalidatesOwnership(t *testing.T) { fw := standardFW() src := newSource(t, fw) // jdoes Zertifikat a2 gehört nicht zu mmueller. _, err := src.Export(context.Background(), "mmueller", "1", "a2", opnsense.FormatOVPN) if !errors.Is(err, web.ErrCertNotOwned) { t.Fatalf("err = %v, want ErrCertNotOwned", err) } if fw.exportCalls != 0 { t.Error("bei fehlender Zuordnung darf kein Export ausgelöst werden") } } func TestExportRejectsRevokedEvenIfPreviouslyListed(t *testing.T) { fw := standardFW() src := newSource(t, fw) // b2 gehört mmueller, ist aber revoziert. _, err := src.Export(context.Background(), "mmueller", "2", "b2", opnsense.FormatOVPN) if !errors.Is(err, web.ErrCertNotOwned) { t.Fatalf("err = %v, want ErrCertNotOwned", err) } if fw.exportCalls != 0 { t.Error("revoziertes Zertifikat darf keinen Export auslösen") } } func TestExportRejectsRevocationBetweenListingAndDownload(t *testing.T) { fw := standardFW() src := newSource(t, fw) // Erst auflisten: a1 ist dabei. entries, err := src.EntriesFor(context.Background(), "mmueller") if err != nil { t.Fatal(err) } var token string for _, e := range entries { if strings.HasSuffix(e.Token, ":a1") { token = e.Token } } if token == "" { t.Fatal("a1 muss zunächst gelistet sein") } // Jetzt wird a1 auf der Firewall revoziert — der Download muss scheitern, // obwohl der Benutzer den Eintrag noch auf der Seite sieht. fw.accounts["1"][0].Revoked = true _, err = src.Export(context.Background(), "mmueller", "1", "a1", opnsense.FormatOVPN) if !errors.Is(err, web.ErrCertNotOwned) { t.Fatalf("err = %v, want ErrCertNotOwned — Revocation muss sofort greifen", err) } if fw.exportCalls != 0 { t.Error("nach Revocation darf kein Export mehr ausgelöst werden") } } func TestExportSucceedsForOwnedCert(t *testing.T) { fw := standardFW() src := newSource(t, fw) st, err := src.Export(context.Background(), "mmueller", "1", "a1", opnsense.FormatOVPN) if err != nil { t.Fatalf("Export: %v", err) } defer st.Body.Close() if fw.lastExportArgs != [3]string{"1", "a1", opnsense.FormatOVPN} { t.Errorf("Export-Argumente = %v", fw.lastExportArgs) } if st.InstanceName != "VPN Homeoffice" || st.CommonName != "mmueller" { t.Errorf("Metadaten fehlen: %+v", st) } body, _ := io.ReadAll(st.Body) if string(body) != "client\n" { t.Errorf("Body = %q", body) } } func TestExportUsesOwnFilenameNotFirewalls(t *testing.T) { // Der Dateiname der Firewall wird bewusst nicht übernommen: er ist // nicht kontrolliert und könnte den Content-Disposition-Header aufbrechen. src := newSource(t, standardFW()) st, err := src.Export(context.Background(), "mmueller", "1", "a1", opnsense.FormatOVPN) if err != nil { t.Fatal(err) } defer st.Body.Close() if st.Filename == "von-der-firewall.ovpn" { t.Error("der von der Firewall gelieferte Dateiname darf nicht übernommen werden") } if st.Filename != "vpn-vpn-homeoffice-mmueller.ovpn" { t.Errorf("Filename = %q", st.Filename) } } func TestExportRejectsUnknownFormat(t *testing.T) { fw := standardFW() src := newSource(t, fw) if _, err := src.Export(context.Background(), "mmueller", "1", "a1", "beliebig"); err == nil { t.Fatal("unbekanntes Format muss abgelehnt werden") } if fw.exportCalls != 0 { t.Error("unbekanntes Format darf keinen Export auslösen") } } func TestBackendErrorIsWrapped(t *testing.T) { fw := standardFW() fw.err = opnsense.ErrUnreachable src := newSource(t, fw) if _, err := src.EntriesFor(context.Background(), "mmueller"); !errors.Is(err, web.ErrBackendUnavailable) { t.Fatalf("err = %v, want ErrBackendUnavailable", err) } } func TestPatternIsExposedForAudit(t *testing.T) { src := newSource(t, standardFW()) if got := src.Pattern("mmueller"); got != "mmueller" { t.Errorf("Pattern = %q", got) } } func TestSafeFilename(t *testing.T) { cases := map[string]string{ "VPN Homeoffice": "vpn-vpn-homeoffice-mmueller.ovpn", "Außendienst": "vpn-au-endienst-mmueller.ovpn", } for instance, want := range cases { if got := SafeFilename(instance, "mmueller", opnsense.FormatOVPN); got != want { t.Errorf("SafeFilename(%q) = %q, want %q", instance, got, want) } } if got := SafeFilename("VPN A", "u", opnsense.FormatViscosity); !strings.HasSuffix(got, ".visc.zip") { t.Errorf("Viscosity-Endung fehlt: %q", got) } // Kein Pfadtrenner, keine Anführungszeichen, kein CR/LF im Ergebnis — // sonst ließe sich der Content-Disposition-Header aufbrechen. dirty := SafeFilename("a\"/b\\c\r\nd", "u", opnsense.FormatOVPN) for _, bad := range []string{"/", "\\", "\"", "\r", "\n"} { if strings.Contains(dirty, bad) { t.Errorf("Dateiname %q enthält %q", dirty, bad) } } if got := SafeFilename("", "", opnsense.FormatOVPN); got == "" || strings.Contains(got, "--") { t.Errorf("leere Eingaben ergeben %q", got) } }