// Copyright (c) 2026 Petr BalvĂ­n (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(), "`) 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 }