opnsense-portal/internal/portal/certsource_test.go
Carsten Abele 7e23df9fec feat(portal): Zertifikatssicht mit serverseitig autorisiertem Streaming-Export
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
2026-08-14 09:27:31 +02:00

296 lines
8.8 KiB
Go

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)
}
}