package web import ( "context" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "git.ravensburg.dev/cabele/opnsense-portal/internal/audit" "git.ravensburg.dev/cabele/opnsense-portal/internal/auth" ) // fakeAuth ist ein Authenticator-Double. type fakeAuth struct { id *auth.Identity err error // calls zählt Aufrufe, um Rate-Limiting-Verhalten zu prüfen. calls int } func (f *fakeAuth) Authenticate(ctx context.Context, u, p string) (*auth.Identity, error) { f.calls++ if f.err != nil { return nil, f.err } return f.id, nil } // recordAudit sammelt Events für Assertions. type recordAudit struct{ events []audit.Event } func (r *recordAudit) Log(e audit.Event) { r.events = append(r.events, e) } func (r *recordAudit) find(name string) (audit.Event, bool) { for _, e := range r.events { if e.Event == name { return e, true } } return audit.Event{}, false } // fakeCerts ist eine CertSource ohne Firewall. type fakeCerts struct { entries []CertEntry err error } func (f *fakeCerts) EntriesFor(ctx context.Context, username string) ([]CertEntry, error) { return f.entries, f.err } func (f *fakeCerts) Export(ctx context.Context, username, vpnID, refID, format string) (*ExportStream, error) { return nil, ErrCertNotOwned } func newTestServer(t *testing.T, d Deps) (*Server, *recordAudit) { t.Helper() rec := &recordAudit{} if d.Audit == nil { d.Audit = rec } else if r, ok := d.Audit.(*recordAudit); ok { rec = r } if d.Sessions == nil { d.Sessions = NewSessionStore(10*time.Minute, d.Clock) } if d.Limiter == nil { d.Limiter = NewLimiter(d.Clock) } if d.Certs == nil { d.Certs = &fakeCerts{} } if d.Renderer == nil { rn, err := NewRenderer(Brand{Title: "Testportal", Version: "test"}) if err != nil { t.Fatal(err) } d.Renderer = rn } // Tests sollen nicht an der Timing-Angleichung hängen. if d.MinLoginDuration == 0 { d.MinLoginDuration = time.Millisecond } srv, err := NewServer(d) if err != nil { t.Fatalf("NewServer: %v", err) } return srv, rec } func postForm(h http.Handler, path string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder { r := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.RemoteAddr = "10.1.20.34:5000" for _, c := range cookies { r.AddCookie(c) } rec := httptest.NewRecorder() h.ServeHTTP(rec, r) return rec } func sessionCookie(rec *httptest.ResponseRecorder) *http.Cookie { for _, c := range rec.Result().Cookies() { if c.Name == SessionCookieName && c.Value != "" { return c } } return nil } func TestLoginGETRendersForm(t *testing.T) { srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}}) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil)) if rec.Code != http.StatusOK { t.Fatalf("Code = %d", rec.Code) } if !strings.Contains(rec.Body.String(), S["login_submit"]) { t.Error("Anmeldeformular fehlt") } if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") { t.Errorf("Cache-Control = %q", cc) } } func TestLoginSuccessSetsHardenedCookieAndRedirects(t *testing.T) { fa := &fakeAuth{id: &auth.Identity{Username: "mmueller", Groups: []string{"VPN-Users"}}} srv, rec := newTestServer(t, Deps{Auth: fa}) resp := postForm(srv.Handler(), "/login", url.Values{"username": {"MMueller"}, "password": {"geheim"}}) if resp.Code != http.StatusSeeOther { t.Fatalf("Code = %d, want 303", resp.Code) } if loc := resp.Header().Get("Location"); loc != "/" { t.Errorf("Location = %q", loc) } c := sessionCookie(resp) if c == nil { t.Fatal("Session-Cookie fehlt") } if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteStrictMode || c.Path != "/" { t.Errorf("Cookie nicht gehärtet: %+v", c) } ev, ok := rec.find(audit.EventLoginSuccess) if !ok { t.Fatal("login_success fehlt im Audit-Log") } if ev.User != "mmueller" || ev.SrcIP != "10.1.20.34" || ev.Session == "" { t.Errorf("Audit-Event unvollständig: %+v", ev) } if strings.Contains(c.Value, ev.Session) { t.Error("Session-Feld darf kein Teil des echten Tokens sein") } } func TestLoginFailureShowsGenericMessage(t *testing.T) { fa := &fakeAuth{err: &auth.Error{Reason: auth.ReasonAccountDisabled}} srv, rec := newTestServer(t, Deps{Auth: fa}) resp := postForm(srv.Handler(), "/login", url.Values{"username": {"mmueller"}, "password": {"x"}}) if resp.Code != http.StatusUnauthorized { t.Fatalf("Code = %d, want 401", resp.Code) } body := resp.Body.String() if !strings.Contains(body, S["error_generic_login"]) { t.Error("generische Fehlermeldung fehlt") } if strings.Contains(body, "deaktiviert") || strings.Contains(body, auth.ReasonAccountDisabled) { t.Error("der Grund darf dem Benutzer nicht verraten werden") } ev, ok := rec.find(audit.EventLoginFailed) if !ok || ev.Reason != auth.ReasonAccountDisabled { t.Errorf("Audit-Reason = %+v", ev) } } func TestExpiredPasswordIsTheOnlySpecificMessage(t *testing.T) { fa := &fakeAuth{err: &auth.Error{Reason: auth.ReasonPasswordExpired}} srv, _ := newTestServer(t, Deps{Auth: fa}) resp := postForm(srv.Handler(), "/login", url.Values{"username": {"mmueller"}, "password": {"x"}}) if !strings.Contains(resp.Body.String(), S["error_password_expired"]) { t.Error("bei abgelaufenem Passwort muss die spezifische Meldung erscheinen") } } func TestUnknownUserIsNeverLoggedInClear(t *testing.T) { fa := &fakeAuth{err: &auth.Error{Reason: auth.ReasonUserNotFound}} srv, rec := newTestServer(t, Deps{Auth: fa}) // Klassischer Unfall: Passwort im Benutzernamenfeld. secret := "MeinGeheimesPasswort123" postForm(srv.Handler(), "/login", url.Values{"username": {secret}, "password": {"x"}}) ev, ok := rec.find(audit.EventLoginFailed) if !ok { t.Fatal("login_failed fehlt") } if ev.User != audit.UnknownUser { t.Errorf("User = %q, want %q", ev.User, audit.UnknownUser) } for _, e := range rec.events { if strings.Contains(e.User+e.Detail+e.Reason, secret) { t.Fatalf("Eingabe aus dem Benutzerfeld ist im Log gelandet: %+v", e) } } } func TestPasswordNeverAppearsInAnyEvent(t *testing.T) { fa := &fakeAuth{err: &auth.Error{Reason: auth.ReasonInvalidCredentials}} srv, rec := newTestServer(t, Deps{Auth: fa}) const pw = "Sup3rGeheim!" postForm(srv.Handler(), "/login", url.Values{"username": {"mmueller"}, "password": {pw}}) for _, e := range rec.events { if strings.Contains(e.User+e.Detail+e.Reason+e.Format+e.Pattern, pw) { t.Fatalf("Passwort im Audit-Log: %+v", e) } } } func TestRateLimitBlocksAndLogs(t *testing.T) { fa := &fakeAuth{err: &auth.Error{Reason: auth.ReasonInvalidCredentials}} srv, rec := newTestServer(t, Deps{Auth: fa}) form := url.Values{"username": {"mmueller"}, "password": {"falsch"}} for i := 0; i < 4; i++ { postForm(srv.Handler(), "/login", form) } before := fa.calls resp := postForm(srv.Handler(), "/login", form) if resp.Code != http.StatusTooManyRequests { t.Fatalf("Code = %d, want 429", resp.Code) } if fa.calls != before { t.Error("bei Rate-Limit darf das Verzeichnis nicht mehr befragt werden") } if resp.Header().Get("Retry-After") == "" { t.Error("Retry-After-Header fehlt") } if _, ok := rec.find(audit.EventRateLimited); !ok { t.Error("rate_limited fehlt im Audit-Log") } } func TestLogoutDestroysSession(t *testing.T) { fa := &fakeAuth{id: &auth.Identity{Username: "mmueller"}} store := NewSessionStore(10*time.Minute, nil) srv, rec := newTestServer(t, Deps{Auth: fa, Sessions: store}) login := postForm(srv.Handler(), "/login", url.Values{"username": {"m"}, "password": {"p"}}) cookie := sessionCookie(login) sess, ok := store.Get(cookie.Value) if !ok { t.Fatal("Session muss existieren") } out := postForm(srv.Handler(), "/logout", url.Values{"csrf_token": {sess.CSRF}}, cookie) if out.Code != http.StatusSeeOther { t.Fatalf("Code = %d, want 303", out.Code) } if _, ok := store.Get(cookie.Value); ok { t.Fatal("Session muss zerstört sein") } if _, ok := rec.find(audit.EventLogout); !ok { t.Error("logout fehlt im Audit-Log") } } func TestLogoutRequiresCSRF(t *testing.T) { fa := &fakeAuth{id: &auth.Identity{Username: "mmueller"}} store := NewSessionStore(10*time.Minute, nil) srv, _ := newTestServer(t, Deps{Auth: fa, Sessions: store}) login := postForm(srv.Handler(), "/login", url.Values{"username": {"m"}, "password": {"p"}}) cookie := sessionCookie(login) out := postForm(srv.Handler(), "/logout", url.Values{"csrf_token": {"falsch"}}, cookie) if out.Code != http.StatusForbidden { t.Fatalf("Code = %d, want 403", out.Code) } if _, ok := store.Get(cookie.Value); !ok { t.Error("bei CSRF-Fehler darf die Session nicht zerstört werden") } } func TestProtectedPageRedirectsWithoutSession(t *testing.T) { srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}}) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) if rec.Code != http.StatusSeeOther { t.Fatalf("Code = %d, want 303", rec.Code) } if loc := rec.Header().Get("Location"); !strings.HasPrefix(loc, "/login") { t.Errorf("Location = %q", loc) } } func TestExpiredSessionRedirectsWithHint(t *testing.T) { now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) clock := func() time.Time { return now } store := NewSessionStore(10*time.Minute, clock) fa := &fakeAuth{id: &auth.Identity{Username: "mmueller"}} srv, rec := newTestServer(t, Deps{Auth: fa, Sessions: store, Clock: clock}) login := postForm(srv.Handler(), "/login", url.Values{"username": {"m"}, "password": {"p"}}) cookie := sessionCookie(login) now = now.Add(11 * time.Minute) r := httptest.NewRequest(http.MethodGet, "/", nil) r.AddCookie(cookie) out := httptest.NewRecorder() srv.Handler().ServeHTTP(out, r) if out.Code != http.StatusSeeOther { t.Fatalf("Code = %d, want 303", out.Code) } if loc := out.Header().Get("Location"); !strings.Contains(loc, "expired") { t.Errorf("Location = %q, muss den Ablaufhinweis tragen", loc) } if _, ok := rec.find(audit.EventSessionExpired); !ok { t.Error("session_expired fehlt im Audit-Log") } } func TestLoginPageShowsExpiredHint(t *testing.T) { srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}}) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login?expired=1", nil)) if !strings.Contains(rec.Body.String(), S["error_session_expired"]) { t.Error("Ablaufhinweis fehlt auf der Anmeldeseite") } } func TestLoginTimingIsEqualisedAcrossReasons(t *testing.T) { // "Benutzer existiert nicht" und "Passwort falsch" müssen gleich lange // dauern, sonst verrät die Laufzeit die Existenz eines Kontos. const floor = 60 * time.Millisecond measure := func(reason string) time.Duration { srv, _ := newTestServer(t, Deps{ Auth: &fakeAuth{err: &auth.Error{Reason: reason}}, MinLoginDuration: floor, }) start := time.Now() postForm(srv.Handler(), "/login", url.Values{"username": {"x"}, "password": {"y"}}) return time.Since(start) } for _, reason := range []string{auth.ReasonUserNotFound, auth.ReasonInvalidCredentials} { if d := measure(reason); d < floor { t.Errorf("Reason %q antwortete in %v, Mindestdauer ist %v", reason, d, floor) } } }