// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 package admin import ( "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "regexp" "strings" "testing" "sourcedock.dev/petrbalvin/volumen/internal/audit" "sourcedock.dev/petrbalvin/volumen/internal/backup" "sourcedock.dev/petrbalvin/volumen/internal/config" "sourcedock.dev/petrbalvin/volumen/internal/ratelimit" "sourcedock.dev/petrbalvin/volumen/internal/session" "sourcedock.dev/petrbalvin/volumen/internal/store" "sourcedock.dev/petrbalvin/volumen/internal/templates" "sourcedock.dev/petrbalvin/volumen/internal/tokens" "sourcedock.dev/petrbalvin/volumen/internal/users" ) var csrfRe = regexp.MustCompile(`name="_csrf" value="([a-f0-9]+)"`) type fixture struct { handler http.Handler admin *Admin users *users.Users store *session.Store storeObj *store.Store contentDir string events []string payloads []map[string]any } func newFixture(t *testing.T) *fixture { t.Helper() return newFixtureSeeded(t, true) } // newFixtureSeeded builds the same handler over an empty users file // when seeded is false: that is the first-run state, with no account // and the wizard serving the admin screen. func newFixtureSeeded(t *testing.T, seeded bool) *fixture { t.Helper() dir := t.TempDir() content := filepath.Join(dir, "posts") if err := os.MkdirAll(content, 0o755); err != nil { t.Fatalf("mkdir: %v", err) } cfg, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{ Port: config.PortUnset, ContentDir: content, UsersFile: filepath.Join(dir, "users.toml"), }) // Preview links are signed with the session key, so the fixture sets // one the way a real deployment does. cfg.Admin.SessionKey = strings.Repeat("k", 64) if err != nil { t.Fatalf("config: %v", err) } st := store.New(store.Options{ContentDir: content, DefaultLang: "en", RevisionLimit: 10}) usersObj := users.New(cfg.UsersFile) if seeded { if _, err := usersObj.Add("admin", "correct-horse-9", "admin"); err != nil { t.Fatalf("seed admin: %v", err) } } f := &fixture{storeObj: st, contentDir: content} a, err := New(Deps{ Config: cfg, Store: st, Users: usersObj, Templates: templates.New(filepath.Join(dir, "templates.toml")), Tokens: tokens.New(filepath.Join(dir, "tokens.toml")), Backup: backup.Options{ ContentDir: content, UsersFile: cfg.UsersFile, TemplatesFile: cfg.TemplatesFile(), TokensFile: cfg.TokensFile(), }, Audit: audit.New(""), LoginLim: ratelimit.NewLoginLimiter(), Sessions: session.New(strings.Repeat("k", 64), 0, false), Version: "0.0.0-test", PreviewKey: strings.Repeat("k", 64), OnEvent: func(event string, payload map[string]any) { f.events = append(f.events, event) f.payloads = append(f.payloads, payload) }, }) if err != nil { t.Fatalf("New: %v", err) } // Production mounts this handler inside the session middleware; the // fixture mirrors that so cookies round-trip. f.handler = a.deps.Sessions.Middleware(a.Handler()) f.admin = a f.users = usersObj f.store = a.deps.Sessions return f } func (f *fixture) do(t *testing.T, req *http.Request) *httptest.ResponseRecorder { t.Helper() rec := httptest.NewRecorder() f.handler.ServeHTTP(rec, req) return rec } func extractCSRF(t *testing.T, body string) string { t.Helper() m := csrfRe.FindStringSubmatch(body) if m == nil { t.Fatalf("no CSRF token in body:\n%s", body[:min(len(body), 500)]) } return m[1] } func sessionCookie(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie { t.Helper() for _, cookie := range rec.Result().Cookies() { if cookie.Name == session.CookieName { return cookie } } t.Fatal("no session cookie") return nil } func TestLoginPageRenders(t *testing.T) { f := newFixture(t) rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil)) if rec.Code != http.StatusOK { t.Fatalf("code = %d", rec.Code) } body := rec.Body.String() for _, want := range []string{"Sign in", "Volumen admin", "0.0.0-test"} { if !strings.Contains(body, want) { t.Fatalf("missing %q", want) } } if !strings.Contains(rec.Header().Get("Content-Type"), "text/html") { t.Fatalf("content-type = %q", rec.Header().Get("Content-Type")) } } func TestLoginRedirectsAuthenticatedUser(t *testing.T) { f := newFixture(t) cookie := login(t, f, "admin", "correct-horse-9") req := httptest.NewRequest(http.MethodGet, "/admin/login", nil) req.AddCookie(cookie) rec := f.do(t, req) if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" { t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location")) } } // login performs the full CSRF + credential flow and returns the // authenticated session cookie. func login(t *testing.T, f *fixture, username, secret string) *http.Cookie { t.Helper() get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil)) csrf := extractCSRF(t, get.Body.String()) cookie := sessionCookie(t, get) form := url.Values{"_csrf": {csrf}, "username": {username}, "password": {secret}} req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) rec := f.do(t, req) if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" { t.Fatalf("login failed: code=%d body=%s", rec.Code, rec.Body.String()) } return sessionCookie(t, rec) } func TestLoginRejectsWrongPassword(t *testing.T) { f := newFixture(t) get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil)) csrf := extractCSRF(t, get.Body.String()) cookie := sessionCookie(t, get) form := url.Values{"_csrf": {csrf}, "username": {"admin"}, "password": {"nope"}} req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) rec := f.do(t, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("code = %d", rec.Code) } if !strings.Contains(rec.Body.String(), "Invalid username or password.") { t.Fatal("error message missing") } } func TestLoginRejectsMissingCSRF(t *testing.T) { f := newFixture(t) get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil)) cookie := sessionCookie(t, get) form := url.Values{"username": {"admin"}, "password": {"correct-horse-9"}} req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) if rec := f.do(t, req); rec.Code != http.StatusForbidden { t.Fatalf("code = %d, want 403", rec.Code) } } func TestLoginRateLimitRendersCountdown(t *testing.T) { f := newFixture(t) get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil)) csrf := extractCSRF(t, get.Body.String()) cookie := sessionCookie(t, get) var last *httptest.ResponseRecorder for range 12 { form := url.Values{"_csrf": {csrf}, "username": {"admin"}, "password": {"wrong"}} req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) last = f.do(t, req) } if last.Code != http.StatusTooManyRequests { t.Fatalf("code = %d, want 429", last.Code) } if !strings.Contains(last.Body.String(), "Auto-unlock in") { t.Fatal("lockout message missing") } if !strings.Contains(last.Body.String(), `id="lockout-countdown"`) { t.Fatal("countdown element missing") } } func TestLogout(t *testing.T) { f := newFixture(t) cookie := login(t, f, "admin", "correct-horse-9") // Grab a fresh CSRF token via the session cookie's page. req := httptest.NewRequest(http.MethodGet, "/admin/login", nil) req.AddCookie(cookie) // Authenticated users are redirected; get the CSRF from the session // store directly instead. sess := f.store.Load(req) csrf := CSRFToken(sess) form := url.Values{"_csrf": {csrf}} logoutReq := httptest.NewRequest(http.MethodPost, "/admin/logout", strings.NewReader(form.Encode())) logoutReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") logoutReq.AddCookie(cookie) rec := f.do(t, logoutReq) if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" { t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location")) } } func TestBareAdminRedirects(t *testing.T) { f := newFixture(t) rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin", nil)) if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" { t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location")) } } // An /admin path no route claims meets the shell's own 404 page, not the // engine's bare text, and an anonymous visitor is still sent to the // sign-in screen first. func TestAdminNotFound(t *testing.T) { f := newFixture(t) anon := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/no-such-page", nil)) if anon.Code != http.StatusSeeOther || anon.Header().Get("Location") != "/admin/login" { t.Fatalf("anonymous: code=%d location=%q", anon.Code, anon.Header().Get("Location")) } cookie := login(t, f, "admin", "correct-horse-9") req := httptest.NewRequest(http.MethodGet, "/admin/no-such-page", nil) req.AddCookie(cookie) rec := f.do(t, req) if rec.Code != http.StatusNotFound { t.Fatalf("code = %d", rec.Code) } if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { t.Fatalf("content type = %q", ct) } body := rec.Body.String() if !strings.Contains(body, "Page not found") { t.Fatalf("body lacks the 404 heading:\n%s", body[:min(len(body), 500)]) } if !strings.Contains(body, `" must not be able to end // the script element the JSON literal is embedded in. func TestTemplatesJSONEscapesScriptClose(t *testing.T) { list := []tplOption{{ Name: "s", Title: "T", Slug: "s", Tags: []string{}, Body: `Use carefully`, }} out := string(templatesJSON(list)) if strings.Contains(out, "") { t.Fatalf("literal script close survived: %s", out) } if !strings.Contains(out, `\u003c/script>`) { t.Fatalf("expected unicode escapes: %s", out) } }