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) <noreply@anthropic.com>
This commit is contained in:
parent
aa5ab6b415
commit
3a3b526a95
6 changed files with 866 additions and 15 deletions
384
internal/handler/handler_test.go
Normal file
384
internal/handler/handler_test.go
Normal file
|
|
@ -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 ""
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue