From 3a3b526a955da78b526ba86720b7a7f5a6221d27 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Sun, 29 Mar 2026 16:47:32 +0200 Subject: [PATCH] test: add comprehensive test suite (44 tests across 3 packages) Store tests (21 tests): - Session: create, validate, delete, delete-all, expiry - Signup requests: create, duplicate, list pending, approve (creates user with must-reset), reject, double-approve/reject - Existing: user CRUD, auth, fave CRUD, tags, pagination Middleware tests (9 tests): - Real IP extraction from trusted/untrusted proxies - Base path stripping (with prefix, empty prefix) - Rate limiter (per-IP, exhaustion, different IPs) - Panic recovery (returns 500) - Security headers (CSP, X-Frame-Options, etc.) - RequireLogin redirect - MustResetPasswordGuard (static path passthrough) Handler integration tests (14 tests): - Health endpoint - Login page rendering, successful login, wrong password - Fave list requires auth, works when authenticated - Private fave hidden from other users, visible to owner - Admin panel requires admin role, works for admin - Tag search endpoint - Global Atom feed - Public profile with display name - Limited profile hides bio Also fixes template bugs: profile.html and fave_detail.html used $.IsOwner which fails inside {{with}} blocks ($ = root PageData, not .Data map). Fixed with $d variable capture pattern. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/handler/handler_test.go | 384 +++++++++++++++++++++++++ internal/middleware/middleware_test.go | 211 ++++++++++++++ internal/store/session_test.go | 119 ++++++++ internal/store/signup_request_test.go | 135 +++++++++ web/templates/pages/fave_detail.html | 3 +- web/templates/pages/profile.html | 29 +- 6 files changed, 866 insertions(+), 15 deletions(-) create mode 100644 internal/handler/handler_test.go create mode 100644 internal/middleware/middleware_test.go create mode 100644 internal/store/session_test.go create mode 100644 internal/store/signup_request_test.go diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go new file mode 100644 index 0000000..f5d6769 --- /dev/null +++ b/internal/handler/handler_test.go @@ -0,0 +1,384 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package handler + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + + "kode.naiv.no/olemd/favoritter/internal/config" + "kode.naiv.no/olemd/favoritter/internal/database" + "kode.naiv.no/olemd/favoritter/internal/middleware" + "kode.naiv.no/olemd/favoritter/internal/render" + "kode.naiv.no/olemd/favoritter/internal/store" +) + +// testServer creates a fully wired test server with an in-memory database. +func testServer(t *testing.T) (*Handler, *http.ServeMux) { + t.Helper() + + db, err := database.Open(":memory:") + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := database.Migrate(db); err != nil { + t.Fatalf("migrate: %v", err) + } + t.Cleanup(func() { db.Close() }) + + // Use fast Argon2 params for tests. + store.Argon2Memory = 1024 + store.Argon2Time = 1 + + cfg := &config.Config{ + DBPath: ":memory:", + Listen: ":0", + UploadDir: t.TempDir(), + SiteName: "Test", + DevMode: false, // Use embedded templates in tests. + } + + users := store.NewUserStore(db) + sessions := store.NewSessionStore(db) + settings := store.NewSettingsStore(db) + faves := store.NewFaveStore(db) + tags := store.NewTagStore(db) + signupRequests := store.NewSignupRequestStore(db) + + renderer, err := render.New(cfg) + if err != nil { + t.Fatalf("renderer: %v", err) + } + + h := New(Deps{ + Config: cfg, + Users: users, + Sessions: sessions, + Settings: settings, + Faves: faves, + Tags: tags, + SignupRequests: signupRequests, + Renderer: renderer, + }) + + mux := h.Routes() + + // Wrap with SessionLoader so authenticated tests work. + chain := middleware.SessionLoader(sessions, users)(mux) + wrappedMux := http.NewServeMux() + wrappedMux.Handle("/", chain) + + return h, wrappedMux +} + +// loginUser creates a user and returns a session cookie for authenticated requests. +func loginUser(t *testing.T, h *Handler, username, password, role string) *http.Cookie { + t.Helper() + user, err := h.deps.Users.Create(username, password, role) + if err != nil { + t.Fatalf("create user %s: %v", username, err) + } + token, err := h.deps.Sessions.Create(user.ID) + if err != nil { + t.Fatalf("create session: %v", err) + } + return &http.Cookie{Name: "session", Value: token} +} + +func TestHealthEndpoint(t *testing.T) { + _, mux := testServer(t) + + req := httptest.NewRequest("GET", "/health", nil) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("health: got %d, want 200", rr.Code) + } + if !strings.Contains(rr.Body.String(), `"status":"ok"`) { + t.Errorf("health body = %q", rr.Body.String()) + } +} + +func TestLoginPageRendering(t *testing.T) { + _, mux := testServer(t) + + req := httptest.NewRequest("GET", "/login", nil) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("login page: got %d, want 200", rr.Code) + } + body := rr.Body.String() + if !strings.Contains(body, "Logg inn") { + t.Error("login page should contain 'Logg inn'") + } + if !strings.Contains(body, "csrf_token") { + t.Error("login page should contain CSRF token field") + } +} + +func TestLoginSuccess(t *testing.T) { + h, mux := testServer(t) + + // Create a user. + h.deps.Users.Create("testuser", "password123", "user") + + // Get CSRF token from login page. + getReq := httptest.NewRequest("GET", "/login", nil) + getRR := httptest.NewRecorder() + mux.ServeHTTP(getRR, getReq) + csrfCookie := extractCookie(getRR, "csrf_token") + + // POST login. + form := url.Values{ + "username": {"testuser"}, + "password": {"password123"}, + "csrf_token": {csrfCookie}, + } + req := httptest.NewRequest("POST", "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfCookie}) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusSeeOther { + t.Errorf("login POST: got %d, want 303", rr.Code) + } + + // Should have a session cookie. + sessionCookie := extractCookie(rr, "session") + if sessionCookie == "" { + t.Error("login should set session cookie") + } +} + +func TestLoginWrongPassword(t *testing.T) { + h, mux := testServer(t) + h.deps.Users.Create("testuser", "password123", "user") + + getReq := httptest.NewRequest("GET", "/login", nil) + getRR := httptest.NewRecorder() + mux.ServeHTTP(getRR, getReq) + csrfCookie := extractCookie(getRR, "csrf_token") + + form := url.Values{ + "username": {"testuser"}, + "password": {"wrongpassword"}, + "csrf_token": {csrfCookie}, + } + req := httptest.NewRequest("POST", "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfCookie}) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("wrong password: got %d, want 200 (re-render with flash)", rr.Code) + } + if !strings.Contains(rr.Body.String(), "Feil brukernavn eller passord") { + t.Error("should show error message") + } +} + +func TestFaveListRequiresLogin(t *testing.T) { + _, mux := testServer(t) + + req := httptest.NewRequest("GET", "/faves", nil) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusSeeOther { + t.Errorf("unauthenticated /faves: got %d, want 303 redirect", rr.Code) + } +} + +func TestFaveListAuthenticated(t *testing.T) { + h, mux := testServer(t) + cookie := loginUser(t, h, "testuser", "pass123", "user") + + req := httptest.NewRequest("GET", "/faves", nil) + req.AddCookie(cookie) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("authenticated /faves: got %d, want 200", rr.Code) + } + if !strings.Contains(rr.Body.String(), "Mine favoritter") { + t.Error("fave list page should contain 'Mine favoritter'") + } +} + +func TestPrivateFaveHiddenFromOthers(t *testing.T) { + h, mux := testServer(t) + + // User A creates a private fave. + userA, _ := h.deps.Users.Create("usera", "pass123", "user") + fave, _ := h.deps.Faves.Create(userA.ID, "Secret fave", "", "", "private") + + // User B tries to view it. + cookieB := loginUser(t, h, "userb", "pass123", "user") + + req := httptest.NewRequest("GET", "/faves/"+faveIDStr(fave.ID), nil) + req.AddCookie(cookieB) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusNotFound { + t.Errorf("private fave for other user: got %d, want 404", rr.Code) + } +} + +func TestPrivateFaveVisibleToOwner(t *testing.T) { + h, mux := testServer(t) + + userA, _ := h.deps.Users.Create("usera", "pass123", "user") + fave, _ := h.deps.Faves.Create(userA.ID, "My secret", "", "", "private") + + tokenA, _ := h.deps.Sessions.Create(userA.ID) + cookieA := &http.Cookie{Name: "session", Value: tokenA} + + req := httptest.NewRequest("GET", "/faves/"+faveIDStr(fave.ID), nil) + req.AddCookie(cookieA) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("own private fave: got %d, want 200", rr.Code) + } +} + +func TestAdminRequiresAdminRole(t *testing.T) { + h, mux := testServer(t) + cookie := loginUser(t, h, "regularuser", "pass123", "user") + + req := httptest.NewRequest("GET", "/admin", nil) + req.AddCookie(cookie) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Errorf("non-admin accessing /admin: got %d, want 403", rr.Code) + } +} + +func TestAdminDashboardForAdmin(t *testing.T) { + h, mux := testServer(t) + cookie := loginUser(t, h, "admin", "pass123", "admin") + + req := httptest.NewRequest("GET", "/admin", nil) + req.AddCookie(cookie) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("admin dashboard: got %d, want 200", rr.Code) + } + if !strings.Contains(rr.Body.String(), "Administrasjon") { + t.Error("admin dashboard should contain 'Administrasjon'") + } +} + +func TestTagSearchEndpoint(t *testing.T) { + h, mux := testServer(t) + + // Create some tags via faves. + user, _ := h.deps.Users.Create("testuser", "pass123", "user") + fave, _ := h.deps.Faves.Create(user.ID, "Test", "", "", "public") + h.deps.Tags.SetFaveTags(fave.ID, []string{"music", "movies", "manga"}) + + req := httptest.NewRequest("GET", "/tags/search?q=mu", nil) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("tag search: got %d, want 200", rr.Code) + } + body := rr.Body.String() + if !strings.Contains(body, "music") { + t.Error("tag search for 'mu' should include 'music'") + } +} + +func TestFeedGlobal(t *testing.T) { + h, mux := testServer(t) + + user, _ := h.deps.Users.Create("testuser", "pass123", "user") + h.deps.Faves.Create(user.ID, "Public fave", "", "", "public") + + req := httptest.NewRequest("GET", "/feed.xml", nil) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("global feed: got %d, want 200", rr.Code) + } + ct := rr.Header().Get("Content-Type") + if !strings.Contains(ct, "atom+xml") { + t.Errorf("content-type = %q, want atom+xml", ct) + } + if !strings.Contains(rr.Body.String(), "Public fave") { + t.Error("feed should contain fave title") + } +} + +func TestPublicProfile(t *testing.T) { + h, mux := testServer(t) + + user, _ := h.deps.Users.Create("testuser", "pass123", "user") + h.deps.Users.UpdateProfile(user.ID, "Test User", "Hello world", "public", "public") + + req := httptest.NewRequest("GET", "/u/testuser", nil) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("public profile: got %d, want 200", rr.Code) + } + body := rr.Body.String() + if !strings.Contains(body, "Test User") { + t.Error("profile should show display name") + } +} + +func TestLimitedProfileHidesBio(t *testing.T) { + h, mux := testServer(t) + + user, _ := h.deps.Users.Create("private", "pass123", "user") + h.deps.Users.UpdateProfile(user.ID, "Hidden User", "Secret bio", "limited", "public") + + req := httptest.NewRequest("GET", "/u/private", nil) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("limited profile: got %d, want 200", rr.Code) + } + body := rr.Body.String() + if strings.Contains(body, "Secret bio") { + t.Error("limited profile should not show bio") + } + if !strings.Contains(body, "begrenset synlighet") { + t.Error("limited profile should show visibility notice") + } +} + +func faveIDStr(n int64) string { + return strconv.FormatInt(n, 10) +} + +// extractCookie gets a cookie value from a response recorder. +func extractCookie(rr *httptest.ResponseRecorder, name string) string { + for _, c := range rr.Result().Cookies() { + if c.Name == name { + return c.Value + } + } + return "" +} diff --git a/internal/middleware/middleware_test.go b/internal/middleware/middleware_test.go new file mode 100644 index 0000000..c190db6 --- /dev/null +++ b/internal/middleware/middleware_test.go @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package middleware + +import ( + "net" + "net/http" + "net/http/httptest" + "testing" +) + +func TestRealIPFromTrustedProxy(t *testing.T) { + _, tailscale, _ := net.ParseCIDR("100.64.0.0/10") + trusted := []*net.IPNet{tailscale} + + handler := RealIP(trusted)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := RealIPFromContext(r.Context()) + w.Write([]byte(ip)) + })) + + // Request from trusted proxy with X-Forwarded-For. + req := httptest.NewRequest("GET", "/", nil) + req.RemoteAddr = "100.64.1.1:12345" + req.Header.Set("X-Forwarded-For", "203.0.113.50, 100.64.1.1") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Body.String() != "203.0.113.50" { + t.Errorf("real IP = %q, want %q", rr.Body.String(), "203.0.113.50") + } +} + +func TestRealIPFromUntrustedProxy(t *testing.T) { + _, localhost, _ := net.ParseCIDR("127.0.0.1/32") + trusted := []*net.IPNet{localhost} + + handler := RealIP(trusted)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := RealIPFromContext(r.Context()) + w.Write([]byte(ip)) + })) + + // Request from untrusted IP — X-Forwarded-For should be ignored. + req := httptest.NewRequest("GET", "/", nil) + req.RemoteAddr = "192.168.1.100:12345" + req.Header.Set("X-Forwarded-For", "spoofed-ip") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Body.String() != "192.168.1.100" { + t.Errorf("real IP = %q, want %q (should ignore XFF from untrusted)", rr.Body.String(), "192.168.1.100") + } +} + +func TestBasePathStripping(t *testing.T) { + handler := BasePath("/faves")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(r.URL.Path)) + })) + + tests := []struct { + path string + want string + }{ + {"/faves/", "/"}, + {"/faves/login", "/login"}, + {"/faves/u/test", "/u/test"}, + {"/other", "/other"}, // No prefix match — passed through unchanged. + } + + for _, tt := range tests { + req := httptest.NewRequest("GET", tt.path, nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Body.String() != tt.want { + t.Errorf("BasePath(%q) = %q, want %q", tt.path, rr.Body.String(), tt.want) + } + } +} + +func TestBasePathEmpty(t *testing.T) { + // Empty base path should be a no-op. + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(r.URL.Path)) + }) + handler := BasePath("")(inner) + + req := httptest.NewRequest("GET", "/login", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Body.String() != "/login" { + t.Errorf("empty base path: got %q, want /login", rr.Body.String()) + } +} + +func TestRateLimiter(t *testing.T) { + rl := NewRateLimiter(3) + handler := rl.Limit(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + for i := 0; i < 3; i++ { + req := httptest.NewRequest("POST", "/login", nil) + // RealIP middleware hasn't run, so RealIPFromContext returns "". + // The rate limiter falls back to RemoteAddr. + req.RemoteAddr = "192.168.1.1:1234" + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("request %d: got %d, want 200", i+1, rr.Code) + } + } + + // 4th request should be rate-limited. + req := httptest.NewRequest("POST", "/login", nil) + req.RemoteAddr = "192.168.1.1:1234" + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusTooManyRequests { + t.Errorf("rate-limited request: got %d, want 429", rr.Code) + } + + // Different IP should not be rate-limited. + req = httptest.NewRequest("POST", "/login", nil) + req.RemoteAddr = "10.0.0.1:1234" + rr = httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("different IP: got %d, want 200", rr.Code) + } +} + +func TestRecovery(t *testing.T) { + handler := Recovery(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + panic("test panic") + })) + + req := httptest.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusInternalServerError { + t.Errorf("panic recovery: got %d, want 500", rr.Code) + } +} + +func TestSecurityHeaders(t *testing.T) { + handler := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + headers := map[string]string{ + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "strict-origin-when-cross-origin", + } + for key, want := range headers { + got := rr.Header().Get(key) + if got != want { + t.Errorf("%s = %q, want %q", key, got, want) + } + } + + csp := rr.Header().Get("Content-Security-Policy") + if csp == "" { + t.Error("Content-Security-Policy header missing") + } +} + +func TestRequireLoginRedirects(t *testing.T) { + handler := RequireLogin("/faves")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + // No user in context — should redirect. + req := httptest.NewRequest("GET", "/settings", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusSeeOther { + t.Errorf("no user: got %d, want 303", rr.Code) + } + loc := rr.Header().Get("Location") + if loc != "/faves/login" { + t.Errorf("redirect location = %q, want /faves/login", loc) + } +} + +func TestMustResetPasswordGuard(t *testing.T) { + handler := MustResetPasswordGuard("")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + // No user — should pass through. + req := httptest.NewRequest("GET", "/faves", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("no user: got %d, want 200", rr.Code) + } + + // Static paths should always pass through even with must-reset user. + req = httptest.NewRequest("GET", "/static/css/style.css", nil) + rr = httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("static path: got %d, want 200", rr.Code) + } +} diff --git a/internal/store/session_test.go b/internal/store/session_test.go new file mode 100644 index 0000000..7371264 --- /dev/null +++ b/internal/store/session_test.go @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package store + +import ( + "testing" + "time" +) + +func TestSessionCreateAndValidate(t *testing.T) { + db := testDB(t) + users := NewUserStore(db) + sessions := NewSessionStore(db) + + Argon2Memory = 1024 + Argon2Time = 1 + defer func() { Argon2Memory = 65536; Argon2Time = 3 }() + + user, _ := users.Create("testuser", "password123", "user") + + token, err := sessions.Create(user.ID) + if err != nil { + t.Fatalf("create session: %v", err) + } + if len(token) != 64 { // 32 bytes hex-encoded + t.Errorf("token length = %d, want 64", len(token)) + } + + session, err := sessions.Validate(token) + if err != nil { + t.Fatalf("validate session: %v", err) + } + if session.UserID != user.ID { + t.Errorf("session user ID = %d, want %d", session.UserID, user.ID) + } +} + +func TestSessionValidateInvalidToken(t *testing.T) { + db := testDB(t) + sessions := NewSessionStore(db) + + _, err := sessions.Validate("nonexistent-token") + if err != ErrSessionNotFound { + t.Errorf("err = %v, want ErrSessionNotFound", err) + } +} + +func TestSessionDelete(t *testing.T) { + db := testDB(t) + users := NewUserStore(db) + sessions := NewSessionStore(db) + + Argon2Memory = 1024 + Argon2Time = 1 + defer func() { Argon2Memory = 65536; Argon2Time = 3 }() + + user, _ := users.Create("testuser", "password123", "user") + token, _ := sessions.Create(user.ID) + + err := sessions.Delete(token) + if err != nil { + t.Fatalf("delete session: %v", err) + } + + _, err = sessions.Validate(token) + if err != ErrSessionNotFound { + t.Errorf("after delete: err = %v, want ErrSessionNotFound", err) + } +} + +func TestSessionDeleteAllForUser(t *testing.T) { + db := testDB(t) + users := NewUserStore(db) + sessions := NewSessionStore(db) + + Argon2Memory = 1024 + Argon2Time = 1 + defer func() { Argon2Memory = 65536; Argon2Time = 3 }() + + user, _ := users.Create("testuser", "password123", "user") + token1, _ := sessions.Create(user.ID) + token2, _ := sessions.Create(user.ID) + + err := sessions.DeleteAllForUser(user.ID) + if err != nil { + t.Fatalf("delete all: %v", err) + } + + _, err = sessions.Validate(token1) + if err != ErrSessionNotFound { + t.Error("token1 should be deleted") + } + _, err = sessions.Validate(token2) + if err != ErrSessionNotFound { + t.Error("token2 should be deleted") + } +} + +func TestSessionExpiry(t *testing.T) { + db := testDB(t) + users := NewUserStore(db) + sessions := NewSessionStore(db) + sessions.SetLifetime(1 * time.Millisecond) + + Argon2Memory = 1024 + Argon2Time = 1 + defer func() { Argon2Memory = 65536; Argon2Time = 3 }() + + user, _ := users.Create("testuser", "password123", "user") + token, _ := sessions.Create(user.ID) + + // Wait for expiry. + time.Sleep(5 * time.Millisecond) + + _, err := sessions.Validate(token) + if err != ErrSessionNotFound { + t.Errorf("expired session: err = %v, want ErrSessionNotFound", err) + } +} diff --git a/internal/store/signup_request_test.go b/internal/store/signup_request_test.go new file mode 100644 index 0000000..1e85149 --- /dev/null +++ b/internal/store/signup_request_test.go @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package store + +import ( + "testing" +) + +func TestSignupRequestCreateAndList(t *testing.T) { + db := testDB(t) + requests := NewSignupRequestStore(db) + + Argon2Memory = 1024 + Argon2Time = 1 + defer func() { Argon2Memory = 65536; Argon2Time = 3 }() + + err := requests.Create("newuser", "password123") + if err != nil { + t.Fatalf("create request: %v", err) + } + + // Duplicate should fail. + err = requests.Create("newuser", "password456") + if err != ErrSignupRequestExists { + t.Errorf("duplicate: err = %v, want ErrSignupRequestExists", err) + } + + pending, err := requests.ListPending() + if err != nil { + t.Fatalf("list pending: %v", err) + } + if len(pending) != 1 { + t.Fatalf("pending count = %d, want 1", len(pending)) + } + if pending[0].Username != "newuser" { + t.Errorf("username = %q, want %q", pending[0].Username, "newuser") + } + if pending[0].Status != "pending" { + t.Errorf("status = %q, want pending", pending[0].Status) + } + + count, _ := requests.PendingCount() + if count != 1 { + t.Errorf("pending count = %d, want 1", count) + } +} + +func TestSignupRequestApprove(t *testing.T) { + db := testDB(t) + users := NewUserStore(db) + requests := NewSignupRequestStore(db) + + Argon2Memory = 1024 + Argon2Time = 1 + defer func() { Argon2Memory = 65536; Argon2Time = 3 }() + + // Create an admin to act as reviewer. + admin, _ := users.Create("admin", "adminpass", "admin") + + // Create a signup request. + requests.Create("newuser", "password123") + pending, _ := requests.ListPending() + requestID := pending[0].ID + + // Approve it. + err := requests.Approve(requestID, admin.ID) + if err != nil { + t.Fatalf("approve: %v", err) + } + + // The user should now exist with must_reset_password=1. + user, err := users.GetByUsername("newuser") + if err != nil { + t.Fatalf("get approved user: %v", err) + } + if !user.MustResetPassword { + t.Error("approved user should have must_reset_password=true") + } + + // The request should no longer be pending. + count, _ := requests.PendingCount() + if count != 0 { + t.Errorf("pending count after approve = %d, want 0", count) + } + + // The approved request should have the correct status. + sr, _ := requests.GetByID(requestID) + if sr.Status != "approved" { + t.Errorf("status = %q, want approved", sr.Status) + } + + // Double-approve should fail. + err = requests.Approve(requestID, admin.ID) + if err == nil { + t.Error("double approve should fail") + } +} + +func TestSignupRequestReject(t *testing.T) { + db := testDB(t) + users := NewUserStore(db) + requests := NewSignupRequestStore(db) + + Argon2Memory = 1024 + Argon2Time = 1 + defer func() { Argon2Memory = 65536; Argon2Time = 3 }() + + admin, _ := users.Create("admin", "adminpass", "admin") + requests.Create("rejectme", "password123") + pending, _ := requests.ListPending() + requestID := pending[0].ID + + err := requests.Reject(requestID, admin.ID) + if err != nil { + t.Fatalf("reject: %v", err) + } + + // Should not be in pending list. + count, _ := requests.PendingCount() + if count != 0 { + t.Errorf("pending count after reject = %d, want 0", count) + } + + // User should NOT have been created. + _, err = users.GetByUsername("rejectme") + if err != ErrUserNotFound { + t.Errorf("rejected user should not exist: err = %v", err) + } + + // Double-reject should fail. + err = requests.Reject(requestID, admin.ID) + if err != ErrSignupRequestNotFound { + t.Errorf("double reject: err = %v, want ErrSignupRequestNotFound", err) + } +} diff --git a/web/templates/pages/fave_detail.html b/web/templates/pages/fave_detail.html index ea1d68b..ae428df 100644 --- a/web/templates/pages/fave_detail.html +++ b/web/templates/pages/fave_detail.html @@ -23,6 +23,7 @@ {{define "content"}} {{with .Data}} + {{$d := .}}
{{with .Fave}} {{if .ImagePath}} @@ -54,7 +55,7 @@