Files
volumen/internal/app/app_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

618 lines
20 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package app
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/preview"
"sourcedock.dev/petrbalvin/volumen/internal/store"
)
func newTestServer(t *testing.T) (*Server, http.Handler) {
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: -1,
ContentDir: content,
UsersFile: filepath.Join(dir, "users.toml"),
})
if err != nil {
t.Fatalf("config: %v", err)
}
cfg.Site.BaseURL = "https://site.example"
cfg.Server.Env = config.EnvProduction
cfg.Server.CookieSecure = true
cfg.Admin.SessionKey = strings.Repeat("s", 64)
st := store.New(store.Options{ContentDir: content, DefaultLang: "en", RevisionLimit: 10})
srv, err := New(cfg, st)
if err != nil {
t.Fatalf("New: %v", err)
}
return srv, srv.Handler()
}
// With no session_key in the config, production starts anyway: the
// server generates its secret into secret.key beside the users file,
// owner-only, and every later start reuses the same key so sessions
// survive a restart. An explicitly configured key still wins and is
// still length-checked.
func TestNewGeneratesSessionSecret(t *testing.T) {
dir := t.TempDir()
cfg, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
Port: -1,
ContentDir: filepath.Join(dir, "posts"),
UsersFile: filepath.Join(dir, "users.toml"),
})
if err != nil {
t.Fatalf("config: %v", err)
}
cfg.Server.Env = "production"
if err := os.MkdirAll(filepath.Join(dir, "posts"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if _, err := New(cfg, store.New(store.Options{ContentDir: filepath.Join(dir, "posts"), DefaultLang: "en", RevisionLimit: 10})); err != nil {
t.Fatalf("production with no session_key must generate the secret: %v", err)
}
raw, err := os.ReadFile(filepath.Join(dir, "secret.key"))
if err != nil {
t.Fatalf("read secret.key: %v", err)
}
key := strings.TrimSpace(string(raw))
if len(key) < 64 {
t.Fatalf("generated key length = %d", len(key))
}
info, err := os.Stat(filepath.Join(dir, "secret.key"))
if err != nil {
t.Fatalf("stat secret.key: %v", err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("secret.key mode = %v, want 0600", info.Mode().Perm())
}
// The second start reads the file back and keeps the same key.
cfg2, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
Port: -1,
ContentDir: filepath.Join(dir, "posts"),
UsersFile: filepath.Join(dir, "users.toml"),
})
if err != nil {
t.Fatalf("config: %v", err)
}
cfg2.Server.Env = "production"
secret, err := sessionSecret(cfg2)
if err != nil {
t.Fatalf("sessionSecret on the second start: %v", err)
}
if secret != key {
t.Fatal("the generated secret changed between starts")
}
// A configured override wins, and production still rejects it short.
cfg2.Admin.SessionKey = "short"
if _, err := sessionSecret(cfg2); err == nil {
t.Fatal("want error for a short production override")
}
}
// The generated secret.key signs preview links too, the way the
// configuration documents admin.session_key: a default deployment, with
// no key in the config, honours a preview token minted from the file,
// through the wired handler the browser talks to.
func TestGeneratedSecretSignsPreviewLinks(t *testing.T) {
dir := t.TempDir()
content := filepath.Join(dir, "posts")
if err := os.MkdirAll(content, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
draft := "+++\ntitle = \"Draft\"\nslug = \"draft\"\ndate = 2026-08-18\ndraft = true\n+++\n\nBody.\n"
if err := os.WriteFile(filepath.Join(content, "draft.md"), []byte(draft), 0o644); err != nil {
t.Fatalf("write draft: %v", err)
}
cfg, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
Port: -1,
ContentDir: content,
UsersFile: filepath.Join(dir, "users.toml"),
})
if err != nil {
t.Fatalf("config: %v", err)
}
cfg.Server.Env = "production"
cfg.Site.BaseURL = "https://site.example"
st := store.New(store.Options{ContentDir: content, DefaultLang: "en", RevisionLimit: 10})
srv, err := New(cfg, st)
if err != nil {
t.Fatalf("New: %v", err)
}
raw, err := os.ReadFile(filepath.Join(dir, "secret.key"))
if err != nil {
t.Fatalf("read secret.key: %v", err)
}
key := strings.TrimSpace(string(raw))
if key == "" {
t.Fatal("no secret.key was generated")
}
handler := srv.Handler()
token := preview.Token("draft", key, time.Now())
if token == "" {
t.Fatal("the generated key cannot sign a preview token")
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet,
"/api/volumen/posts/draft?preview_token="+token, nil))
if rec.Code != http.StatusOK {
t.Fatalf("preview through the wired handler = %d %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "Draft") {
t.Fatalf("the draft body did not reach the response: %s", rec.Body.String())
}
}
// Development starts without a writable location for the file too:
// the ephemeral fallback keeps the server usable, with the warning as
// the only signal.
func TestSessionSecretOverrideInDevelopment(t *testing.T) {
dir := t.TempDir()
cfg, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
Port: -1,
ContentDir: filepath.Join(dir, "posts"),
UsersFile: filepath.Join(dir, "users.toml"),
})
if err != nil {
t.Fatalf("config: %v", err)
}
cfg.Admin.SessionKey = "a-development-key"
secret, err := sessionSecret(cfg)
if err != nil {
t.Fatalf("development accepts a short override: %v", err)
}
if secret != "a-development-key" {
t.Fatalf("secret = %q", secret)
}
}
func TestRootRedirectsToAdmin(t *testing.T) {
_, handler := newTestServer(t)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
}
func TestHealthz(t *testing.T) {
srv, handler := newTestServer(t)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rec.Code != http.StatusOK {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
body := decode(t, rec)
if body["status"] != "ok" {
t.Fatalf("body = %v", body)
}
checks := body["checks"].(map[string]any)
if checks["content_dir"] != "ok" {
t.Fatalf("checks = %v", checks)
}
if checks["users_file"] == nil || checks["disk"] == nil {
t.Fatalf("checks = %v", checks)
}
if rec.Header().Get("Cache-Control") != "no-store" {
t.Fatal("healthz must not be cached")
}
// Missing content directory degrades the status.
if err := os.RemoveAll(srv.Config.ContentDir); err != nil {
t.Fatalf("remove: %v", err)
}
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("code = %d", rec.Code)
}
}
func decode(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("invalid JSON %q: %v", rec.Body.String(), err)
}
return out
}
func TestRobotsSitemapFavicon(t *testing.T) {
_, handler := newTestServer(t)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/robots.txt", nil))
if !strings.Contains(rec.Body.String(), "Sitemap: https://site.example/api/volumen/sitemap.xml") {
t.Fatalf("robots = %q", rec.Body.String())
}
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/sitemap.xml", nil))
if rec.Code != http.StatusMovedPermanently ||
rec.Header().Get("Location") != "/api/volumen/sitemap.xml" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/favicon.ico", nil))
if rec.Code != http.StatusOK ||
rec.Header().Get("Content-Type") != "image/svg+xml" {
t.Fatalf("code=%d type=%q", rec.Code, rec.Header().Get("Content-Type"))
}
if !strings.Contains(rec.Body.String(), "<svg") {
t.Fatal("favicon body is not SVG")
}
}
func TestMediaServing(t *testing.T) {
srv, handler := newTestServer(t)
mediaDir := filepath.Join(srv.Store.ContentDir, store.MediaDirName)
if err := os.MkdirAll(mediaDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(mediaDir, "pic.webp"), []byte("IIIIIIIIWEBP"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"></svg>`)
if err := os.WriteFile(filepath.Join(mediaDir, "figure.svg"), svg, 0o644); err != nil {
t.Fatalf("write: %v", err)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/media/pic.webp", nil))
if rec.Code != http.StatusOK || rec.Body.String() != "IIIIIIIIWEBP" {
t.Fatalf("code=%d body=%q", rec.Code, rec.Body.String())
}
if rec.Header().Get("Cache-Control") != "public, max-age=604800" {
t.Fatalf("cache-control = %q", rec.Header().Get("Cache-Control"))
}
// A raster image gets no document policy of its own.
if strings.Contains(rec.Header().Get("Content-Security-Policy"), "sandbox") {
t.Fatal("webp response carries a sandbox policy")
}
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/media/figure.svg", nil))
if rec.Code != http.StatusOK || rec.Body.String() != string(svg) {
t.Fatalf("svg code=%d body=%q", rec.Code, rec.Body.String())
}
if rec.Header().Get("Content-Type") != "image/svg+xml" {
t.Fatalf("svg content-type = %q", rec.Header().Get("Content-Type"))
}
// An SVG opened at its own URL is a document on this origin: the
// response must sandbox it.
csp := rec.Header().Get("Content-Security-Policy")
if !strings.Contains(csp, "sandbox") || !strings.Contains(csp, "default-src 'none'") {
t.Fatalf("svg content-security-policy = %q, want a sandboxed document", csp)
}
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/media/missing.webp", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d", rec.Code)
}
}
func TestNotFoundJSON(t *testing.T) {
_, handler := newTestServer(t)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/nope", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d", rec.Code)
}
if decode(t, rec)["error"] != "not_found" {
t.Fatalf("body = %s", rec.Body.String())
}
}
func TestSecurityHeadersOnAPI(t *testing.T) {
_, handler := newTestServer(t)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/volumen/site", nil))
if rec.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatal("security headers missing")
}
if strings.Contains(rec.Header().Get("Content-Security-Policy"), "nonce-") {
t.Fatal("nonce leaked onto API response")
}
if rec.Header().Get("Strict-Transport-Security") == "" {
t.Fatal("HSTS missing on secure deployment")
}
}
func TestAPIRateLimitHeaders(t *testing.T) {
srv, handler := newTestServer(t)
srv.Config.API.RateLimit = 2
srv.Config.API.RateLimitWindow = 60
handler = srv.Handler() // rebuild with the new limit
var limited bool
for range 5 {
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/volumen/site", nil))
if rec.Code == http.StatusTooManyRequests {
limited = true
body := decode(t, rec)
if body["error"] != "rate_limited" {
t.Fatalf("body = %v", body)
}
if rec.Header().Get("Retry-After") == "" {
t.Fatal("Retry-After missing")
}
break
}
if rec.Header().Get("X-RateLimit-Limit") != "2" {
t.Fatalf("limit header = %q", rec.Header().Get("X-RateLimit-Limit"))
}
}
if !limited {
t.Fatal("rate limit never triggered")
}
// Non-API routes are not limited.
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/robots.txt", nil))
if rec.Code != http.StatusOK {
t.Fatalf("robots blocked: %d", rec.Code)
}
}
func TestGzipThroughChain(t *testing.T) {
srv, handler := newTestServer(t)
// A large post body pushes the JSON response over the gzip threshold.
var b strings.Builder
b.WriteString("+++\ntitle = \"Big\"\nslug = \"big\"\ndate = 2026-01-01\nexcerpt = \"")
b.WriteString(strings.Repeat("x", 600))
b.WriteString("\"\n+++\nbody\n")
if err := os.WriteFile(filepath.Join(srv.Config.ContentDir, "big.md"), []byte(b.String()), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/volumen/posts/big", nil)
req.Header.Set("Accept-Encoding", "gzip")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Header().Get("Content-Encoding") != "gzip" {
t.Fatalf("content-encoding = %q (body %d bytes)",
rec.Header().Get("Content-Encoding"), rec.Body.Len())
}
if strconv.Itoa(rec.Body.Len()) == "0" {
t.Fatal("empty body")
}
}
func TestSessionCookieSecureFlag(t *testing.T) {
dir := t.TempDir()
cfg, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
Port: -1,
ContentDir: filepath.Join(dir, "posts"),
UsersFile: filepath.Join(dir, "users.toml"),
})
if err != nil {
t.Fatalf("config: %v", err)
}
if err := os.MkdirAll(filepath.Join(dir, "posts"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
// trust_proxy implies secure cookies even without cookie_secure.
cfg.Server.TrustProxy = true
srv, err := New(cfg, store.New(store.Options{ContentDir: filepath.Join(dir, "posts"), DefaultLang: "en", RevisionLimit: 10}))
if err != nil {
t.Fatalf("New: %v", err)
}
if !srv.Sessions.Secure() {
t.Fatal("trust_proxy must imply secure session cookies")
}
}
func TestMediaServedWithoutGzipBuffering(t *testing.T) {
srv, handler := newTestServer(t)
mediaDir := filepath.Join(srv.Store.ContentDir, store.MediaDirName)
if err := os.MkdirAll(mediaDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
// Repetitive content that gzip would shrink dramatically if applied.
payload := strings.Repeat("WEBPDATA", 2000)
if err := os.WriteFile(filepath.Join(mediaDir, "big.webp"), []byte(payload), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/media/big.webp", nil)
req.Header.Set("Accept-Encoding", "gzip")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
if enc := rec.Header().Get("Content-Encoding"); enc != "" {
t.Fatalf("media was compressed (%q); it must stream untouched", enc)
}
if rec.Body.Len() != len(payload) {
t.Fatalf("body length = %d, want %d", rec.Body.Len(), len(payload))
}
if rec.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatal("security headers missing on media responses")
}
}
// The backup export streams on its own branch: the archive arrives
// compressed by the backup writer alone, never re-wrapped by the gzip
// middleware, which also proves the response never waited in that
// wrapper's buffer.
func TestExportStreamsUnwrapped(t *testing.T) {
srv, handler := newTestServer(t)
if _, err := srv.Users.Add("admin", "correct-horse-battery", "admin"); err != nil {
t.Fatalf("Add: %v", err)
}
// Enough varied content that the archive crosses the gzip threshold
// and would engage the middleware were the export still flowing
// through it; repeated bytes compress away and prove nothing.
var b strings.Builder
b.WriteString("+++\ntitle = \"Big\"\nslug = \"big\"\ndate = 2026-01-01\nexcerpt = \"")
for i := range 400 {
b.WriteString(strconv.Itoa(i*7919+i*i) + " ")
}
b.WriteString("\"\n+++\nbody\n")
if err := os.WriteFile(filepath.Join(srv.Config.ContentDir, "big.md"), []byte(b.String()), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
login := func() *http.Cookie {
t.Helper()
form := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/admin/login", nil)
handler.ServeHTTP(form, req)
csrf := extractCSRF(t, form.Body.String())
cookies := form.Result().Cookies()
body := strings.NewReader("_csrf=" + url.QueryEscape(csrf) + "&username=admin&password=correct-horse-battery")
req = httptest.NewRequest(http.MethodPost, "/admin/login", body)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("login code = %d body=%s", rec.Code, rec.Body.String())
}
for _, c := range rec.Result().Cookies() {
if c.Name == "volumen_session" {
return c
}
}
t.Fatal("no session cookie after login")
return nil
}
req := httptest.NewRequest(http.MethodGet, "/admin/settings/export", nil)
req.Header.Set("Accept-Encoding", "gzip")
req.AddCookie(login())
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
if got := rec.Header().Get("Content-Type"); got != "application/gzip" {
t.Fatalf("content-type = %q", got)
}
if enc := rec.Header().Get("Content-Encoding"); enc != "" {
t.Fatalf("export re-compressed (%q); it must stream untouched", enc)
}
if rec.Body.Len() < 500 {
t.Fatalf("body %d bytes, too small to prove the bypass", rec.Body.Len())
}
if rec.Body.Bytes()[0] != 0x1f || rec.Body.Bytes()[1] != 0x8b {
t.Fatal("body does not start with the gzip magic bytes")
}
}
// Logout must clear the cookie through the handler built by app.New: the
// session services are wired there, and a hand-built Deps in a test would
// hide a missing one.
func TestLogoutThroughTheWiredHandler(t *testing.T) {
srv, handler := newTestServer(t)
if _, err := srv.Users.Add("admin", "correct-horse-battery", "admin"); err != nil {
t.Fatalf("Add: %v", err)
}
login := func() *http.Cookie {
t.Helper()
form := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/admin/login", nil)
handler.ServeHTTP(form, req)
csrf := extractCSRF(t, form.Body.String())
cookies := form.Result().Cookies()
body := strings.NewReader("_csrf=" + url.QueryEscape(csrf) + "&username=admin&password=correct-horse-battery")
req = httptest.NewRequest(http.MethodPost, "/admin/login", body)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("login code = %d body=%s", rec.Code, rec.Body.String())
}
for _, c := range rec.Result().Cookies() {
if c.Name == "volumen_session" {
return c
}
}
t.Fatal("no session cookie after login")
return nil
}
session := login()
// The dashboard is reachable while signed in.
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(session)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("dashboard code = %d", rec.Code)
}
csrf := extractCSRF(t, rec.Body.String())
// Logging out answers, clears the cookie and refuses the next request.
rec = httptest.NewRecorder()
body := strings.NewReader("_csrf=" + url.QueryEscape(csrf))
req = httptest.NewRequest(http.MethodPost, "/admin/logout", body)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(session)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("logout code = %d body=%s", rec.Code, rec.Body.String())
}
expired := false
for _, c := range rec.Result().Cookies() {
if c.Name == "volumen_session" && c.MaxAge < 0 {
expired = true
}
}
if !expired {
t.Fatal("logout did not expire the session cookie")
}
// A copy of the cookie taken before the logout stays valid until it
// expires, because the session lives entirely in the cookie. That is
// the documented trade-off of a signed cookie without server state,
// and why logging out expires the browser's copy rather than claiming
// to revoke it.
}
func extractCSRF(t *testing.T, body string) string {
t.Helper()
const marker = `name="_csrf" value="`
_, rest, ok := strings.Cut(body, marker)
if !ok {
t.Fatal("no CSRF token in the page")
}
token, _, ok := strings.Cut(rest, `"`)
if !ok {
t.Fatal("malformed CSRF token")
}
return token
}