Add an optional long-form "notes" text field to each favorite for reviews, thoughts, or extended descriptions. The field is stored in SQLite via a new migration (002_add_fave_notes.sql) and propagated through the entire stack: - Model: Notes field on Fave struct - Store: All SQL queries (Create, GetByID, Update, list methods, scanFaves) updated with notes column - Web handlers: Read/write notes in create, edit, update forms - API handlers: Notes in create, update, get, import request/response - Export: Notes included in both JSON and CSV exports - Import: Notes parsed from both JSON and CSV imports - Feed: Notes used as Atom feed item summary when present - Form template: New textarea between URL and image fields - Detail template: Display notes, enhanced og:description with cascade: notes (truncated) → URL → generic fallback text Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
384 lines
10 KiB
Go
384 lines
10 KiB
Go
// 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 ""
|
|
}
|