Files
volumen/internal/admin/admin_test.go
petrbalvin f8ed33df83
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Initial commit
Assisted-by: GLM 5.3
2026-09-29 10:03:32 +02:00

365 lines
12 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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, `<html`) || !strings.Contains(body, `class="shell"`) {
t.Fatalf("body is not rendered in the shell")
}
post := httptest.NewRequest(http.MethodPost, "/admin/no-such-page", strings.NewReader(""))
post.AddCookie(cookie)
if rec := f.do(t, post); rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "<html") {
t.Fatalf("POST: code=%d", rec.Code)
}
}
func TestPasswordError(t *testing.T) {
if key, n := PasswordError("", 10, 1024); key != "New password cannot be empty." || n != 0 {
t.Fatalf("empty password = %q, %d", key, n)
}
if key, n := PasswordError("short", 10, 1024); key != "password.min" || n != 10 {
t.Fatalf("short password = %q, %d", key, n)
}
if key, n := PasswordError(strings.Repeat("x", 2000), 10, 1024); key != "password.max" || n != 1024 {
t.Fatalf("long password = %q, %d", key, n)
}
if key, n := PasswordError("password123", 10, 1024); key != "This password is too common." || n != 0 {
t.Fatalf("common password = %q, %d", key, n)
}
if key, n := PasswordError("a genuinely unique passphrase", 10, 1024); key != "" || n != 0 {
t.Fatalf("valid password = %q, %d", key, n)
}
}
func TestFirstUpper(t *testing.T) {
if got := firstUpper("petr", "?"); got != "P" {
t.Fatalf("got = %q", got)
}
if got := firstUpper("", "?"); got != "?" {
t.Fatalf("got = %q", got)
}
}
func TestHumanSize(t *testing.T) {
cases := map[int64]string{
0: "",
512: "1 kB",
10 * 1024: "10 kB",
1024 * 1024: "1.0 MB",
5 << 20: "5.0 MB",
1536 * 1024: "1.5 MB",
}
for in, want := range cases {
if got := humanSize(in); got != want {
t.Errorf("humanSize(%d) = %q, want %q", in, got, want)
}
}
}
// A post-template body containing "</script>" 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 <script>document.write("x")</script> carefully`,
}}
out := string(templatesJSON(list))
if strings.Contains(out, "</script>") {
t.Fatalf("literal script close survived: %s", out)
}
if !strings.Contains(out, `\u003c/script>`) {
t.Fatalf("expected unicode escapes: %s", out)
}
}