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:
Ole-Morten Duesund 2026-03-29 16:47:32 +02:00
commit 3a3b526a95
6 changed files with 866 additions and 15 deletions

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

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -23,6 +23,7 @@
{{define "content"}}
{{with .Data}}
{{$d := .}}
<article>
{{with .Fave}}
{{if .ImagePath}}
@ -54,7 +55,7 @@
<footer>
<small>Lagt til {{.CreatedAt.Format "02.01.2006"}}</small>
{{if $.IsOwner}}
{{if $d.IsOwner}}
<nav class="fave-actions">
<a href="{{basePath}}/faves/{{.ID}}/edit" role="button" class="outline">Rediger</a>
<button

View file

@ -1,5 +1,5 @@
{{define "head"}}
{{with .Data}}{{with .ProfileUser}}
{{with .Data}}{{$d := .}}{{with .ProfileUser}}
{{if eq .ProfileVisibility "public"}}
<meta property="og:title" content="{{.DisplayNameOrUsername}} sine favoritter">
<meta property="og:type" content="profile">
@ -16,6 +16,7 @@
{{define "content"}}
{{with .Data}}
{{$d := .}}
{{with .ProfileUser}}
<section class="profile-header">
{{if .AvatarPath}}
@ -31,14 +32,14 @@
</hgroup>
</section>
{{if not $.IsLimited}}
{{if not $d.IsLimited}}
{{if .Bio}}
<p>{{.Bio}}</p>
{{end}}
<p><small>Medlem siden {{.CreatedAt.Format "02.01.2006"}}</small></p>
{{if $.IsOwner}}
{{if $d.IsOwner}}
<p>
<a href="{{basePath}}/settings" role="button" class="outline">Rediger profil</a>
<a href="{{basePath}}/faves/new" role="button">+ Ny favoritt</a>
@ -46,13 +47,13 @@
{{end}}
<h2>
{{if $.IsOwner}}Favoritter{{else}}Offentlige favoritter{{end}}
<small>({{$.Total}})</small>
{{if $d.IsOwner}}Favoritter{{else}}Offentlige favoritter{{end}}
<small>({{$d.Total}})</small>
</h2>
{{if $.Faves}}
{{if $d.Faves}}
<div class="fave-grid" role="list">
{{range $.Faves}}
{{range $d.Faves}}
<article class="fave-card" role="listitem">
{{if .ImagePath}}
<img src="{{basePath}}/uploads/{{.ImagePath}}"
@ -78,21 +79,21 @@
{{end}}
</div>
{{if gt $.TotalPages 1}}
{{if gt $d.TotalPages 1}}
<nav aria-label="Sidenavigasjon">
<ul>
{{if gt $.Page 1}}
<li><a href="{{basePath}}/u/{{.Username}}?page={{subtract $.Page 1}}">← Forrige</a></li>
{{if gt $d.Page 1}}
<li><a href="{{basePath}}/u/{{.Username}}?page={{subtract $d.Page 1}}">← Forrige</a></li>
{{end}}
<li>Side {{$.Page}} av {{$.TotalPages}}</li>
{{if lt $.Page $.TotalPages}}
<li><a href="{{basePath}}/u/{{.Username}}?page={{add $.Page 1}}">Neste →</a></li>
<li>Side {{$d.Page}} av {{$d.TotalPages}}</li>
{{if lt $d.Page $d.TotalPages}}
<li><a href="{{basePath}}/u/{{.Username}}?page={{add $d.Page 1}}">Neste →</a></li>
{{end}}
</ul>
</nav>
{{end}}
{{else}}
{{if $.IsOwner}}
{{if $d.IsOwner}}
<p>Du har ingen favoritter ennå. <a href="{{basePath}}/faves/new">Legg til din første!</a></p>
{{else}}
<p>Ingen offentlige favoritter ennå.</p>