// 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 and CSRFProtection so authenticated tests work. chain := middleware.CSRFProtection(cfg)(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 "" }