Initial commit
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
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
Assisted-by: GLM 5.3
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user