365 lines
12 KiB
Go
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)
|
||
|
|
}
|
||
|
|
}
|