package check import ( "context" "errors" "os" "path/filepath" "strings" "testing" "time" "git.ravensburg.dev/cabele/opnsense-portal/internal/auth" "git.ravensburg.dev/cabele/opnsense-portal/internal/config" "git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense" ) type fakePinger struct { at time.Time err error } func (f *fakePinger) Ping(ctx context.Context) (time.Time, error) { return f.at, f.err } type fakeDir struct { dn string dnErr error lookup *auth.LookupResult lookErr error } func (f *fakeDir) ResolveGroupDN(ctx context.Context) (string, error) { return f.dn, f.dnErr } func (f *fakeDir) Lookup(ctx context.Context, u string) (*auth.LookupResult, error) { return f.lookup, f.lookErr } var checkNow = time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) func newChecker(t *testing.T, fw Pinger, dir DirectoryChecker) *Checker { t.Helper() tmp := t.TempDir() cfgPath := filepath.Join(tmp, "config.yaml") if err := os.WriteFile(cfgPath, []byte("x"), 0o600); err != nil { t.Fatal(err) } os.Chmod(cfgPath, 0o600) logDir := filepath.Join(tmp, "log") os.MkdirAll(logDir, 0o750) os.Chmod(logDir, 0o750) cfg := config.Defaults() cfg.Portal.TLSCert = filepath.Join(tmp, "portal.crt") cfg.Portal.TLSKey = filepath.Join(tmp, "portal.key") os.WriteFile(cfg.Portal.TLSCert, []byte("cert"), 0o644) os.WriteFile(cfg.Portal.TLSKey, []byte("key"), 0o600) os.Chmod(cfg.Portal.TLSKey, 0o600) cfg.OPNsense.URL = "https://fw01.firma.local" cfg.OPNsense.APIKey, cfg.OPNsense.APISecret = "k", "s" cfg.AD.Domain = "firma.local" cfg.AD.Servers = []string{"dc01.firma.local"} cfg.AD.BindUser, cfg.AD.BindPassword = "svc@firma.local", "pw" cfg.AD.VPNGroup = "VPN-Users" cfg.Logging.AuditLog = filepath.Join(logDir, "audit.log") return &Checker{Cfg: cfg, ConfigPath: cfgPath, FW: fw, Dir: dir, Now: func() time.Time { return checkNow }} } func report(t *testing.T, rep *Report) string { t.Helper() var b strings.Builder if _, err := rep.WriteTo(&b); err != nil { t.Fatal(err) } return b.String() } func TestAllChecksPass(t *testing.T) { c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=VPN-Users,DC=firma,DC=local"}) rep := c.Run(context.Background(), "") if !rep.OK() { t.Fatalf("alle Prüfungen sollten bestehen:\n%s", report(t, rep)) } out := report(t, rep) for _, want := range []string{"Konfiguration", "Dateirechte", "OPNsense", "Verzeichnisdienst", "Systemzeit"} { if !strings.Contains(out, want) { t.Errorf("Prüfpunkt %q fehlt:\n%s", want, out) } } } func TestFailedAPISecretIsNamed(t *testing.T) { c := newChecker(t, &fakePinger{err: opnsense.ErrUnauthorized}, &fakeDir{dn: "CN=VPN-Users,DC=firma,DC=local"}) rep := c.Run(context.Background(), "") if rep.OK() { t.Fatal("falsches Secret muss auffallen") } out := report(t, rep) if !strings.Contains(out, "✗") { t.Errorf("Ausgabe muss ein ✗ enthalten:\n%s", out) } if !strings.Contains(strings.ToLower(out), "zugangsdaten") { t.Errorf("Ursache muss benannt werden:\n%s", out) } } func TestMissingAPIPermissionIsDistinguished(t *testing.T) { c := newChecker(t, &fakePinger{err: opnsense.ErrForbidden}, &fakeDir{dn: "CN=x"}) rep := c.Run(context.Background(), "") out := report(t, rep) if !strings.Contains(out, "Client Export") { t.Errorf("fehlende Berechtigung muss den nötigen Privilegnamen nennen:\n%s", out) } } func TestUnreachableDCIsNamed(t *testing.T) { c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dnErr: errors.New("dc01.firma.local: connection refused")}) rep := c.Run(context.Background(), "") if rep.OK() { t.Fatal("nicht erreichbarer DC muss auffallen") } if out := report(t, rep); !strings.Contains(out, "dc01.firma.local") { t.Errorf("betroffener DC muss genannt werden:\n%s", out) } } func TestLooseConfigPermissionsAreDetected(t *testing.T) { c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"}) os.Chmod(c.ConfigPath, 0o644) rep := c.Run(context.Background(), "") if rep.OK() { t.Fatal("0644-Config muss auffallen") } if out := report(t, rep); !strings.Contains(out, "chmod") { t.Errorf("Reparaturbefehl muss genannt werden:\n%s", out) } } func TestLooseTLSKeyIsDetected(t *testing.T) { c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"}) os.Chmod(c.Cfg.Portal.TLSKey, 0o644) rep := c.Run(context.Background(), "") if rep.OK() { t.Fatal("world-readable TLS-Key muss auffallen") } } func TestMissingLogDirectoryIsDetected(t *testing.T) { c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"}) // Auch das übergeordnete Verzeichnis existiert nicht — das ist ein // echter Konfigurationsfehler, meist ein Tippfehler im Pfad. c.Cfg.Logging.AuditLog = "/gibt/es/nicht/audit.log" rep := c.Run(context.Background(), "") if rep.OK() { t.Fatal("fehlendes Log-Verzeichnis mit fehlendem Elternverzeichnis muss auffallen") } if out := report(t, rep); !strings.Contains(out, "/gibt/es/nicht") { t.Errorf("der beanstandete Pfad muss genannt werden:\n%s", out) } } func TestLogDirectoryIsAcceptedIfItCanBeCreated(t *testing.T) { // Der Audit-Logger legt sein Verzeichnis beim Start selbst an. Der Check // darf deshalb nicht strenger sein als die Laufzeit: existiert das // Elternverzeichnis, ist alles in Ordnung. c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=VPN-Users,DC=firma,DC=local"}) parent := filepath.Dir(filepath.Dir(c.Cfg.Logging.AuditLog)) c.Cfg.Logging.AuditLog = filepath.Join(parent, "nochnichtda", "audit.log") rep := c.Run(context.Background(), "") if !rep.OK() { t.Fatalf("noch nicht angelegtes Log-Verzeichnis darf nicht scheitern:\n%s", report(t, rep)) } if out := report(t, rep); !strings.Contains(out, "wird beim Start angelegt") { t.Errorf("der Hinweis auf die spätere Anlage fehlt:\n%s", out) } } func TestLogDirectoryWithLoosePermissionsStillFails(t *testing.T) { // Ein existierendes, zu offenes Verzeichnis bleibt ein Fehler. c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"}) os.Chmod(filepath.Dir(c.Cfg.Logging.AuditLog), 0o777) rep := c.Run(context.Background(), "") if rep.OK() { t.Fatal("0777-Log-Verzeichnis muss weiterhin auffallen") } } func TestClockSkewWarns(t *testing.T) { c := newChecker(t, &fakePinger{at: checkNow.Add(5 * time.Minute)}, &fakeDir{dn: "CN=x"}) rep := c.Run(context.Background(), "") out := report(t, rep) if !strings.Contains(out, "NTP") { t.Errorf("Zeitabweichung muss auf NTP hinweisen:\n%s", out) } if rep.OK() { t.Error("Zeitabweichung über der Schwelle muss als Fehler zählen") } } func TestSmallClockSkewIsFine(t *testing.T) { c := newChecker(t, &fakePinger{at: checkNow.Add(30 * time.Second)}, &fakeDir{dn: "CN=VPN-Users,DC=firma,DC=local"}) if rep := c.Run(context.Background(), ""); !rep.OK() { t.Fatalf("30 Sekunden Abweichung sind unkritisch:\n%s", report(t, rep)) } } func TestClockCheckSkippedWhenFirewallDown(t *testing.T) { // Ohne Antwort der Firewall lässt sich die Uhr nicht vergleichen; das darf // nicht als eigenständiger Zeitfehler dastehen. c := newChecker(t, &fakePinger{err: opnsense.ErrUnreachable}, &fakeDir{dn: "CN=x"}) rep := c.Run(context.Background(), "") out := report(t, rep) if !strings.Contains(out, "nicht prüfbar") { t.Errorf("Zeitprüfung muss als nicht prüfbar ausgewiesen werden:\n%s", out) } } func TestTestAuthShowsLookupResult(t *testing.T) { dir := &fakeDir{ dn: "CN=VPN-Users,DC=firma,DC=local", lookup: &auth.LookupResult{ DN: "CN=Max Mueller,OU=Users,DC=firma,DC=local", SAMAccountName: "mmueller", InVPNGroup: true}, } c := newChecker(t, &fakePinger{at: checkNow}, dir) rep := c.Run(context.Background(), "mmueller") out := report(t, rep) for _, want := range []string{"CN=Max Mueller", "mmueller", "VPN-Users"} { if !strings.Contains(out, want) { t.Errorf("Ausgabe von --test-auth enthält %q nicht:\n%s", want, out) } } } func TestTestAuthReportsMissingGroupMembership(t *testing.T) { dir := &fakeDir{ dn: "CN=VPN-Users,DC=firma,DC=local", lookup: &auth.LookupResult{DN: "CN=J Doe", SAMAccountName: "jdoe", InVPNGroup: false}, } c := newChecker(t, &fakePinger{at: checkNow}, dir) rep := c.Run(context.Background(), "jdoe") if rep.OK() { t.Fatal("fehlende Gruppenmitgliedschaft muss als Fehler gelten") } if out := report(t, rep); !strings.Contains(out, "NEIN") { t.Errorf("das Ergebnis muss deutlich benannt werden:\n%s", out) } } func TestTestAuthSkippedWhenEmpty(t *testing.T) { c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"}) rep := c.Run(context.Background(), "") if strings.Contains(report(t, rep), "Testanmeldung") { t.Error("ohne --test-auth darf kein Testanmelde-Punkt erscheinen") } } func TestReportOutputFormat(t *testing.T) { rep := &Report{Results: []Result{ {Name: "Konfiguration", OK: true}, {Name: "Dateirechte", OK: false, Detail: "config.yaml ist 0644"}, }} out := report(t, rep) if !strings.Contains(out, "✓ Konfiguration") { t.Errorf("Erfolgszeile falsch:\n%s", out) } if !strings.Contains(out, "✗ Dateirechte") || !strings.Contains(out, "config.yaml ist 0644") { t.Errorf("Fehlerzeile falsch:\n%s", out) } if rep.OK() { t.Error("ein ✗ muss OK() falsch machen") } } func TestInvalidConfigIsReported(t *testing.T) { c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"}) c.Cfg.AD.Servers = []string{"10.1.1.10"} // IP statt Hostname rep := c.Run(context.Background(), "") if rep.OK() { t.Fatal("ungültige Konfiguration muss auffallen") } if out := report(t, rep); !strings.Contains(out, "10.1.1.10") { t.Errorf("der beanstandete Wert muss genannt werden:\n%s", out) } }