favoritter/internal/handler/handler_test.go

384 lines
10 KiB
Go
Raw Normal View History

// 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 ""
}