Files
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

952 lines
31 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package httpapi
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/preview"
"sourcedock.dev/petrbalvin/volumen/internal/store"
"sourcedock.dev/petrbalvin/volumen/internal/tokens"
)
type fixture struct {
handler http.Handler
store *store.Store
tokens *tokens.Store
events []string
}
func newFixture(t *testing.T, files map[string]string) *fixture {
t.Helper()
dir := t.TempDir()
content := filepath.Join(dir, "posts")
if err := os.MkdirAll(content, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
for name, body := range files {
path := filepath.Join(content, name)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
}
cfg, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
Host: "",
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.Admin.SessionKey = strings.Repeat("k", 64)
st := store.New(store.Options{ContentDir: content, DefaultLang: "en", RevisionLimit: 10})
f := &fixture{store: st, tokens: tokens.New(filepath.Join(dir, "tokens.toml"))}
f.handler = New(Deps{
Config: cfg,
Store: st,
Tokens: f.tokens,
PreviewKey: cfg.Admin.SessionKey,
OnEvent: func(event string, _ map[string]any) {
f.events = append(f.events, event)
},
})
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 decodeJSON(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
}
const helloFile = `+++
title = "Hello"
slug = "hello"
date = 2026-08-18
lang = "cs"
tags = ["go"]
+++
Hello **body**.
`
const draftFile = `+++
title = "Draft"
slug = "draft"
draft = true
+++
draft body
`
const scheduledFile = `+++
title = "Future"
slug = "future"
publish_at = 2999-01-01
+++
future body
`
func TestSiteAndETag(t *testing.T) {
f := newFixture(t, nil)
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/site", nil))
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := decodeJSON(t, rec)
if body["title"] != config.DefaultSiteTitle || body["base_url"] != "https://site.example" {
t.Fatalf("site = %v", body)
}
if rec.Header().Get("Access-Control-Allow-Origin") != "*" {
t.Fatal("CORS missing")
}
if !strings.Contains(rec.Header().Get("Cache-Control"), "max-age=60") {
t.Fatalf("cache-control = %q", rec.Header().Get("Cache-Control"))
}
etag := rec.Header().Get("ETag")
if etag == "" {
t.Fatal("ETag missing")
}
req2 := httptest.NewRequest(http.MethodGet, "/api/volumen/site", nil)
req2.Header.Set("If-None-Match", etag)
rec2 := f.do(t, req2)
if rec2.Code != http.StatusNotModified {
t.Fatalf("code = %d, want 304", rec2.Code)
}
if rec2.Header().Get("ETag") != etag {
t.Fatal("ETag lost on 304")
}
// Weak comparison: W/ prefix and lists still match.
req3 := httptest.NewRequest(http.MethodGet, "/api/volumen/site", nil)
req3.Header.Set("If-None-Match", "W/"+etag+", \"other\"")
if rec3 := f.do(t, req3); rec3.Code != http.StatusNotModified {
t.Fatalf("weak compare failed: %d", rec3.Code)
}
}
func TestPostsPaginationAndFilters(t *testing.T) {
f := newFixture(t, map[string]string{
"hello.md": helloFile,
"draft.md": draftFile,
"scheduled.md": scheduledFile,
})
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts", nil))
body := decodeJSON(t, rec)
if body["total"] != float64(1) {
t.Fatalf("total = %v", body["total"])
}
posts := body["posts"].([]any)
first := posts[0].(map[string]any)
if first["slug"] != "hello" {
t.Fatalf("post = %v", first)
}
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts?lang=cs&tag=go&q=hello", nil))
if decodeJSON(t, rec)["total"] != float64(1) {
t.Fatal("filters wrong")
}
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts?lang=en", nil))
if decodeJSON(t, rec)["total"] != float64(0) {
t.Fatal("lang filter wrong")
}
}
func TestPostsQueryValidation(t *testing.T) {
f := newFixture(t, nil)
for _, path := range []string{
"/api/volumen/posts?page=0",
"/api/volumen/posts?page=abc",
"/api/volumen/posts?limit=0",
"/api/volumen/posts?limit=101",
} {
rec := f.do(t, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("%s: code = %d, want 422", path, rec.Code)
}
body := decodeJSON(t, rec)
if body["error"] != "validation" || body["field"] == nil {
t.Fatalf("%s: body = %v", path, body)
}
}
}
func TestBatch(t *testing.T) {
f := newFixture(t, map[string]string{"hello.md": helloFile, "draft.md": draftFile})
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/batch", nil))
if decodeJSON(t, rec)["posts"] == nil {
t.Fatal("empty batch wrong")
}
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/batch?slugs=hello,draft,missing", nil))
body := decodeJSON(t, rec)
posts := body["posts"].([]any)
if len(posts) != 1 {
t.Fatalf("posts = %v", posts)
}
first := posts[0].(map[string]any)
if first["slug"] != "hello" || first["html"] == nil || first["meta"] == nil {
t.Fatalf("detail = %v", first)
}
if rec.Header().Get("ETag") == "" {
t.Fatal("ETag missing on batch")
}
}
func TestSinglePostAndAliases(t *testing.T) {
f := newFixture(t, map[string]string{
"hello.md": "+++\ntitle = \"Hi\"\nslug = \"new\"\naliases = [\"old\"]\n+++\nbody\n",
})
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/new", nil))
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
if decodeJSON(t, rec)["title"] != "Hi" {
t.Fatal("wrong post")
}
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/old", nil))
if rec.Code != http.StatusMovedPermanently ||
rec.Header().Get("Location") != "/api/volumen/posts/new" {
t.Fatalf("alias redirect: %d %q", rec.Code, rec.Header().Get("Location"))
}
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/nope", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d", rec.Code)
}
if decodeJSON(t, rec)["error"] != "not_found" {
t.Fatalf("body = %s", rec.Body.String())
}
}
func TestDraftAndScheduledHiddenUnlessPreview(t *testing.T) {
f := newFixture(t, map[string]string{"draft.md": draftFile, "scheduled.md": scheduledFile})
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/draft", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("draft visible: %d", rec.Code)
}
if decodeJSON(t, rec)["error"] != "draft" {
t.Fatalf("body = %s", rec.Body.String())
}
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/future", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("scheduled visible: %d", rec.Code)
}
// Valid preview token reveals the draft.
token := preview.Token("draft", strings.Repeat("k", 64), time.Now())
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/draft?preview_token="+token, nil))
if rec.Code != http.StatusOK {
t.Fatalf("preview failed: %d %s", rec.Code, rec.Body.String())
}
// Garbage token does not.
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/draft?preview_token=bogus", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("bogus token accepted: %d", rec.Code)
}
}
func TestTagsAndSeries(t *testing.T) {
f := newFixture(t, map[string]string{
"a.md": "+++\nslug = \"a\"\ntags = [\"go\"]\nseries = \"S\"\nseries_order = 1\ndate = 2026-01-01\n+++\nx\n",
"b.md": "+++\nslug = \"b\"\ntags = [\"go\"]\nseries = \"S\"\nseries_order = 2\ndate = 2026-01-02\n+++\nx\n",
})
body := decodeJSON(t, f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/tags", nil)))
tags := body["tags"].([]any)
if len(tags) != 1 || tags[0].(map[string]any)["name"] != "go" {
t.Fatalf("tags = %v", tags)
}
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/tags/go", nil))
if decodeJSON(t, rec)["total"] != float64(2) {
t.Fatal("tag posts wrong")
}
if rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/tags/none", nil)); rec.Code != http.StatusNotFound {
t.Fatalf("unknown tag: %d", rec.Code)
}
body = decodeJSON(t, f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/series", nil)))
series := body["series"].([]any)
if len(series) != 1 || series[0].(map[string]any)["name"] != "S" {
t.Fatalf("series = %v", series)
}
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/series/S", nil))
body = decodeJSON(t, rec)
if body["count"] != float64(2) {
t.Fatalf("series detail = %v", body)
}
if rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/series/none", nil)); rec.Code != http.StatusNotFound {
t.Fatalf("unknown series: %d", rec.Code)
}
}
const seriesFile = `+++
title = "Second"
slug = "second"
date = 2026-08-19
series = "S"
series_order = 1
tags = ["go"]
+++
Second body.`
func TestFeedsAndSitemap(t *testing.T) {
f := newFixture(t, map[string]string{"hello.md": helloFile, "second.md": seriesFile})
cases := map[string]string{
"/api/volumen/feed.xml": "application/rss+xml",
"/api/volumen/feed.atom": "application/atom+xml",
"/api/volumen/feed.json": "application/json",
"/api/volumen/sitemap.xml": "application/xml",
"/api/volumen/tags/go/feed.xml": "application/rss+xml",
"/api/volumen/tags/go/feed.atom": "application/atom+xml",
"/api/volumen/tags/go/feed.json": "application/json",
}
for path, contentType := range cases {
rec := f.do(t, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Fatalf("%s: code = %d", path, rec.Code)
}
if got := rec.Header().Get("Content-Type"); got != contentType {
t.Fatalf("%s: content-type = %q, want %q", path, got, contentType)
}
}
// JSON feed keeps literal characters.
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/feed.json", nil))
if strings.Contains(rec.Body.String(), `&`) {
t.Fatal("JSON feed HTML-escaped")
}
// Series feeds render the series, not the whole site, and name
// themselves in the self link.
for path, contentType := range map[string]string{
"/api/volumen/series/S/feed.xml": "application/rss+xml",
"/api/volumen/series/S/feed.atom": "application/atom+xml",
"/api/volumen/series/S/feed.json": "application/json",
} {
rec := f.do(t, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Fatalf("%s: code = %d", path, rec.Code)
}
if got := rec.Header().Get("Content-Type"); got != contentType {
t.Fatalf("%s: content-type = %q, want %q", path, got, contentType)
}
body := rec.Body.String()
if !strings.Contains(body, "second") {
t.Fatalf("%s does not carry the series post:\n%s", path, body)
}
if strings.Contains(body, "hello") {
t.Fatalf("%s carries a post outside the series:\n%s", path, body)
}
if !strings.Contains(body, "/api/volumen/series/S/feed.") {
t.Fatalf("%s does not name itself:\n%s", path, body)
}
}
// A tag feed carries the tagged posts and names itself; a tag feed
// for an unknown tag is a 404.
tagFeed := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/tags/go/feed.json", nil))
if body := tagFeed.Body.String(); !strings.Contains(body, "hello") ||
!strings.Contains(body, "/api/volumen/tags/go/feed.json") {
t.Fatalf("tag json feed = %s", body)
}
tagAtom := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/tags/go/feed.atom", nil))
if body := tagAtom.Body.String(); !strings.Contains(body, "/api/volumen/tags/go/feed.atom") {
t.Fatalf("tag atom feed does not name itself: %s", body)
}
for _, path := range []string{
"/api/volumen/series/none/feed.xml",
"/api/volumen/series/none/feed.atom",
"/api/volumen/series/none/feed.json",
"/api/volumen/tags/none/feed.json",
"/api/volumen/tags/none/feed.atom",
} {
if rec := f.do(t, httptest.NewRequest(http.MethodGet, path, nil)); rec.Code != http.StatusNotFound {
t.Fatalf("%s: code = %d", path, rec.Code)
}
}
}
func TestOptionsPreflight(t *testing.T) {
f := newFixture(t, nil)
rec := f.do(t, httptest.NewRequest(http.MethodOptions, "/api/volumen/posts", nil))
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
if rec.Header().Get("Access-Control-Allow-Origin") != "*" ||
!strings.Contains(rec.Header().Get("Access-Control-Allow-Methods"), "GET") {
t.Fatalf("headers = %v", rec.Header())
}
}
// An If-Match write is refused with 412 when the etag the client holds
// no longer names the stored state, and accepted when it does. The
// guard is opt-in: a write without the header stays unconditional.
// Frontmatter keys the engine does not consume pass through to the
// detail payload's fields object; a post without any omits the member.
func TestCustomFieldsPassThrough(t *testing.T) {
files := map[string]string{
"hello.md": helloFile,
"custom.md": `+++
title = "Custom"
slug = "custom"
date = 2026-08-18
[colour]
accent = "#0f0"
depths = [1, 2, 3]
[ratings]
good = 5
[[items]]
n = 1
+++`,
}
f := newFixture(t, files)
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/custom", nil))
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := decodeJSON(t, rec)
fields, ok := body["fields"].(map[string]any)
if !ok {
t.Fatalf("fields missing: %s", rec.Body.String())
}
colour, ok := fields["colour"].(map[string]any)
if !ok || colour["accent"] != "#0f0" {
t.Fatalf("colour = %v", fields["colour"])
}
if depths, _ := colour["depths"].([]any); len(depths) != 3 {
t.Fatalf("depths = %v", colour["depths"])
}
if ratings, _ := fields["ratings"].(map[string]any); ratings["good"] != float64(5) {
t.Fatalf("ratings = %v", fields["ratings"])
}
if items, _ := fields["items"].([]any); len(items) != 1 {
t.Fatalf("items = %v", fields["items"])
}
// A known key is never duplicated into fields.
if _, present := fields["title"]; present {
t.Fatalf("known key leaked into fields: %v", fields)
}
// A post without custom frontmatter carries no fields member.
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/hello", nil))
if strings.Contains(rec.Body.String(), `"fields"`) {
t.Fatalf("empty fields leaked: %s", rec.Body.String())
}
}
// A search ranks by relevance: a title hit leads, a tag that contains
// the query is now found at all, and a body-only mention trails; the
// date order alone would read the other way round.
func TestSearchRanksByRelevance(t *testing.T) {
searchPost := func(slug, title, tags, date, body string) string {
return fmt.Sprintf(`+++
title = %q
slug = %q
lang = "en"
date = %s
tags = [%q]
+++
%s
`, title, slug, date, tags, body)
}
files := map[string]string{
"title-hit.md": searchPost("title-hit", "WebP guide", "images", "2026-08-01", "nothing relevant here"),
"tag-hit.md": searchPost("tag-hit", "Unrelated one", "webp", "2026-08-02", "nothing relevant here"),
"body-hit.md": searchPost("body-hit", "Unrelated two", "images", "2026-08-03", "the webp format is lovely"),
}
f := newFixture(t, files)
req := httptest.NewRequest(http.MethodGet, "/api/volumen/posts?q=webp", nil)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
posts, ok := decodeJSON(t, rec)["posts"].([]any)
if !ok || len(posts) != 3 {
t.Fatalf("posts = %v", posts)
}
want := []string{"title-hit", "tag-hit", "body-hit"}
for i, slug := range want {
if got := posts[i].(map[string]any)["slug"]; got != slug {
t.Fatalf("rank %d = %v, want %q", i, got, slug)
}
}
}
func TestIfMatchGuardsWrites(t *testing.T) {
f := newFixture(t, map[string]string{"hello.md": helloFile})
_, raw, err := f.tokens.Create("full", nil)
if err != nil {
t.Fatalf("Create: %v", err)
}
auth := "Bearer " + raw
servedETag := func() string {
req := httptest.NewRequest(http.MethodGet, "/api/volumen/posts/hello", nil)
return f.do(t, req).Header().Get("ETag")
}
fresh := servedETag()
if fresh == "" {
t.Fatal("GET served no ETag")
}
put := func(ifMatch string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPut, "/api/volumen/posts/hello", strings.NewReader(`{"title":"Fresh"}`))
req.Header.Set("Authorization", auth)
if ifMatch != "" {
req.Header.Set("If-Match", ifMatch)
}
return f.do(t, req)
}
if rec := put(`"0000000000000000"`); rec.Code != http.StatusPreconditionFailed {
t.Fatalf("stale etag: code = %d body = %s", rec.Code, rec.Body.String())
}
if body := decodeJSON(t, put(`"0000000000000000"`)); body["error"] != "precondition_failed" {
t.Fatalf("body = %v", body)
}
rec := put(fresh)
if rec.Code != http.StatusOK {
t.Fatalf("fresh etag: code = %d body = %s", rec.Code, rec.Body.String())
}
stored := servedETag()
if rec.Header().Get("ETag") != stored {
t.Fatalf("response ETag %q does not name the stored state %q", rec.Header().Get("ETag"), stored)
}
if rec.Header().Get("ETag") == fresh {
t.Fatal("the etag survived an edit")
}
if rec := put("*"); rec.Code != http.StatusOK {
t.Fatalf("star etag: code = %d", rec.Code)
}
if rec := put(""); rec.Code != http.StatusOK {
t.Fatalf("no header: code = %d", rec.Code)
}
}
func TestIfMatchGuardsDelete(t *testing.T) {
f := newFixture(t, map[string]string{"hello.md": helloFile})
_, raw, err := f.tokens.Create("full", nil)
if err != nil {
t.Fatalf("Create: %v", err)
}
auth := "Bearer " + raw
req := httptest.NewRequest(http.MethodDelete, "/api/volumen/posts/hello", nil)
req.Header.Set("Authorization", auth)
req.Header.Set("If-Match", `"0000000000000000"`)
if rec := f.do(t, req); rec.Code != http.StatusPreconditionFailed {
t.Fatalf("stale etag: code = %d", rec.Code)
}
get := httptest.NewRequest(http.MethodGet, "/api/volumen/posts/hello", nil)
fresh := f.do(t, get).Header().Get("ETag")
req = httptest.NewRequest(http.MethodDelete, "/api/volumen/posts/hello", nil)
req.Header.Set("Authorization", auth)
req.Header.Set("If-Match", fresh)
if rec := f.do(t, req); rec.Code != http.StatusNoContent {
t.Fatalf("fresh etag: code = %d", rec.Code)
}
}
func TestWriteEndpointsRequireToken(t *testing.T) {
f := newFixture(t, map[string]string{"hello.md": helloFile})
rec := f.do(t, httptest.NewRequest(http.MethodPost, "/api/volumen/posts", strings.NewReader(`{}`)))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("code = %d", rec.Code)
}
if rec.Header().Get("WWW-Authenticate") != "Bearer" {
t.Fatal("WWW-Authenticate missing")
}
req := httptest.NewRequest(http.MethodPost, "/api/volumen/posts", strings.NewReader(`{}`))
req.Header.Set("Authorization", "Bearer vol_bogus")
if rec := f.do(t, req); rec.Code != http.StatusUnauthorized {
t.Fatalf("code = %d", rec.Code)
}
}
func TestWriteEndpointsScopeEnforced(t *testing.T) {
f := newFixture(t, nil)
// A token that carries only the delete scope may not write.
_, raw, err := f.tokens.Create("scoped", []string{"delete"})
if err != nil {
t.Fatalf("Create: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/volumen/posts", strings.NewReader(`{}`))
req.Header.Set("Authorization", "Bearer "+raw)
rec := f.do(t, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("code = %d", rec.Code)
}
body := decodeJSON(t, rec)
if message, _ := body["message"].(string); !strings.Contains(message, "write") {
t.Fatalf("body = %v", body)
}
}
func TestCreateUpdateDeletePost(t *testing.T) {
f := newFixture(t, nil)
_, raw, err := f.tokens.Create("full", nil)
if err != nil {
t.Fatalf("Create: %v", err)
}
// Invalid payload rejected.
req := httptest.NewRequest(http.MethodPost, "/api/volumen/posts",
strings.NewReader(`{"slug": "Upper"}`))
req.Header.Set("Authorization", "Bearer "+raw)
rec := f.do(t, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
// Valid create.
req = httptest.NewRequest(http.MethodPost, "/api/volumen/posts",
strings.NewReader(`{"slug": "created", "title": "Created", "body": "hello", "tags": ["go", "blog"]}`))
req.Header.Set("Authorization", "Bearer "+raw)
rec = f.do(t, req)
if rec.Code != http.StatusCreated {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
if decodeJSON(t, rec)["title"] != "Created" {
t.Fatal("wrong payload")
}
if len(f.events) != 1 || f.events[0] != "post.created" {
t.Fatalf("events = %v", f.events)
}
if f.store.Find("created", "") == nil {
t.Fatal("post not saved")
}
// Partial update keeps omitted fields.
req = httptest.NewRequest(http.MethodPut, "/api/volumen/posts/created",
strings.NewReader(`{"title": "Updated"}`))
req.Header.Set("Authorization", "Bearer "+raw)
rec = f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
p := f.store.Find("created", "")
// The body keeps the trailing newline the writer normalises, exactly
// a second round-trip must produce the same document.
if p.Title() != "Updated" || len(p.Tags()) != 2 || p.Body != "hello\n" {
t.Fatalf("title=%q tags=%v body=%q", p.Title(), p.Tags(), p.Body)
}
// Clearing a field with null.
req = httptest.NewRequest(http.MethodPut, "/api/volumen/posts/created",
strings.NewReader(`{"title": null}`))
req.Header.Set("Authorization", "Bearer "+raw)
if rec := f.do(t, req); rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
if f.store.Find("created", "").Title() != "" {
t.Fatal("title not cleared")
}
// Malformed types rejected.
req = httptest.NewRequest(http.MethodPut, "/api/volumen/posts/created",
strings.NewReader(`{"draft": "yes"}`))
req.Header.Set("Authorization", "Bearer "+raw)
if rec := f.do(t, req); rec.Code != http.StatusBadRequest {
t.Fatalf("code = %d", rec.Code)
}
// Unknown slug.
req = httptest.NewRequest(http.MethodPut, "/api/volumen/posts/ghost",
strings.NewReader(`{"title": "x"}`))
req.Header.Set("Authorization", "Bearer "+raw)
if rec := f.do(t, req); rec.Code != http.StatusNotFound {
t.Fatalf("code = %d", rec.Code)
}
// Invalid JSON body.
req = httptest.NewRequest(http.MethodPost, "/api/volumen/posts", strings.NewReader(`not json`))
req.Header.Set("Authorization", "Bearer "+raw)
if rec := f.do(t, req); rec.Code != http.StatusBadRequest {
t.Fatalf("code = %d", rec.Code)
}
// Delete.
req = httptest.NewRequest(http.MethodDelete, "/api/volumen/posts/created", nil)
req.Header.Set("Authorization", "Bearer "+raw)
rec = f.do(t, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("code = %d", rec.Code)
}
if f.store.Find("created", "") != nil {
t.Fatal("post not deleted")
}
if f.events[len(f.events)-1] != "post.deleted" {
t.Fatalf("events = %v", f.events)
}
req = httptest.NewRequest(http.MethodDelete, "/api/volumen/posts/created", nil)
req.Header.Set("Authorization", "Bearer "+raw)
if rec := f.do(t, req); rec.Code != http.StatusNotFound {
t.Fatalf("code = %d", rec.Code)
}
}
func TestUpdateRenameSoftDeletesOldFile(t *testing.T) {
f := newFixture(t, map[string]string{
"old.md": "+++\ntitle = \"Old\"\nslug = \"old\"\n+++\nbody\n",
})
_, raw, err := f.tokens.Create("full", nil)
if err != nil {
t.Fatalf("Create: %v", err)
}
req := httptest.NewRequest(http.MethodPut, "/api/volumen/posts/old",
strings.NewReader(`{"slug": "new-name"}`))
req.Header.Set("Authorization", "Bearer "+raw)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
if f.store.Find("new-name", "") == nil {
t.Fatal("renamed post missing")
}
if f.store.Find("old", "") != nil {
t.Fatal("old slug still resolves")
}
if f.store.TombstonePath("old") == "" {
t.Fatal("old file not soft-deleted")
}
if restored := f.store.Undelete("old"); restored == nil {
t.Fatal("rename not undoable")
}
}
func TestPreviewTokenShape(t *testing.T) {
now := time.Now()
// 32 hex characters plus an expiry stamp, stable for the same slug,
// secret and day.
token := preview.Token("hello", "secret", now)
if len(token) != 32+1+10 {
t.Fatalf("token = %q", token)
}
if token != preview.Token("hello", "secret", now) {
t.Fatal("token not stable")
}
if token == preview.Token("other", "secret", now) {
t.Fatal("token ignores slug")
}
if !preview.Valid(token, "hello", "secret", now) {
t.Fatal("fresh token rejected")
}
if preview.Valid(token, "hello", "secret", now.Add(preview.TTL+time.Hour)) {
t.Fatal("expired token accepted")
}
if preview.Valid(token, "hello", "other", now) {
t.Fatal("token accepted with the wrong key")
}
if preview.Token("hello", "", now) != "" {
t.Fatal("a token was minted without a session key")
}
}
func TestSeriesOrderBooleanRejected(t *testing.T) {
f := newFixture(t, map[string]string{"a.md": "+++\nslug = \"a\"\n+++\nx\n"})
_, raw, err := f.tokens.Create("full", nil)
if err != nil {
t.Fatalf("Create: %v", err)
}
req := httptest.NewRequest(http.MethodPut, "/api/volumen/posts/a",
strings.NewReader(`{"series_order": true}`))
req.Header.Set("Authorization", "Bearer "+raw)
if rec := f.do(t, req); rec.Code != http.StatusBadRequest {
t.Fatalf("code = %d", rec.Code)
}
}
func TestWriteEndpointsKeepRestrictiveCORS(t *testing.T) {
f := newFixture(t, nil)
_, raw, err := f.tokens.Create("full", nil)
if err != nil {
t.Fatalf("Create: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/volumen/posts",
strings.NewReader(`{"slug": "cors-check", "title": "T"}`))
req.Header.Set("Authorization", "Bearer "+raw)
req.Header.Set("Origin", "https://evil.example")
rec := f.do(t, req)
if rec.Code != http.StatusCreated {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://site.example" {
t.Fatalf("allow-origin = %q, want the configured base_url", got)
}
if methods := rec.Header().Get("Access-Control-Allow-Methods"); !strings.Contains(methods, "POST") {
t.Fatalf("allow-methods = %q", methods)
}
}
// An explicit null body clears the stored text, matching the merge
// contract every other field follows.
func TestUpdateClearsBodyWithNull(t *testing.T) {
f := newFixture(t, nil)
_, raw, _ := f.tokens.Create("full", nil)
req := httptest.NewRequest(http.MethodPost, "/api/volumen/posts",
strings.NewReader(`{"slug": "body-test", "title": "B", "body": "text"}`))
req.Header.Set("Authorization", "Bearer "+raw)
if rec := f.do(t, req); rec.Code != http.StatusCreated {
t.Fatalf("create code = %d, body = %s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodPut, "/api/volumen/posts/body-test",
strings.NewReader(`{"body": null}`))
req.Header.Set("Authorization", "Bearer "+raw)
if rec := f.do(t, req); rec.Code != http.StatusOK {
t.Fatalf("update code = %d, body = %s", rec.Code, rec.Body.String())
}
// The writer always ends the file with one newline, so an empty
// body reads back as exactly that.
if body := f.store.Find("body-test", "").Body; body != "\n" {
t.Fatalf("body = %q, want cleared", body)
}
}
// A tag list item that is not a string is rejected rather than coerced
// into a made-up tag such as "<nil>".
func TestUpdateRejectsNonStringTags(t *testing.T) {
f := newFixture(t, nil)
_, raw, _ := f.tokens.Create("full", nil)
req := httptest.NewRequest(http.MethodPost, "/api/volumen/posts",
strings.NewReader(`{"slug": "tag-test", "title": "T", "body": "x"}`))
req.Header.Set("Authorization", "Bearer "+raw)
if rec := f.do(t, req); rec.Code != http.StatusCreated {
t.Fatalf("create code = %d, body = %s", rec.Code, rec.Body.String())
}
for _, body := range []string{`{"tags": [null]}`, `{"tags": [3]}`, `{"tags": [true]}`} {
req = httptest.NewRequest(http.MethodPut, "/api/volumen/posts/tag-test", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+raw)
rec := f.do(t, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("body %s: code = %d", body, rec.Code)
}
if decodeJSON(t, rec)["error"] != "validation" {
t.Fatalf("body %s: envelope = %s", body, rec.Body.String())
}
}
if tags := f.store.Find("tag-test", "").Tags(); len(tags) != 0 {
t.Fatalf("tags = %v, want untouched", tags)
}
}
// A body over the limit is a 413, not a parse failure.
func TestWriteBodyOverTheLimitIs413(t *testing.T) {
f := newFixture(t, nil)
_, raw, _ := f.tokens.Create("full", nil)
big := strings.Repeat("x", maxWriteBody+1)
req := httptest.NewRequest(http.MethodPost, "/api/volumen/posts",
strings.NewReader(`{"slug": "big", "body": "`+big+`"}`))
req.Header.Set("Authorization", "Bearer "+raw)
rec := f.do(t, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
if decodeJSON(t, rec)["error"] != "payload_too_large" {
t.Fatalf("envelope = %s", rec.Body.String())
}
}
// A preview response is not publicly cacheable: the URL is only valid
// with the token, and a shared cache must not keep unpublished content.
func TestPreviewResponseIsNotPubliclyCacheable(t *testing.T) {
f := newFixture(t, map[string]string{"draft.md": draftFile})
token := preview.Token("draft", strings.Repeat("k", 64), time.Now())
req := httptest.NewRequest(http.MethodGet, "/api/volumen/posts/draft?preview_token="+token, nil)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("preview failed: %d %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control = %q, want no-store", got)
}
// The published detail stays publicly cacheable.
f2 := newFixture(t, map[string]string{"hello.md": helloFile})
rec = f2.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/hello", nil))
if got := rec2CacheControl(rec); got == "no-store" {
t.Fatalf("published detail carries %q", got)
}
}
func rec2CacheControl(rec *httptest.ResponseRecorder) string {
return rec.Header().Get("Cache-Control")
}
// Renaming through the API keeps a language that came from the file's
// directory: the new file lands in the same language subtree rather
// than in the content root.
func TestRenameKeepsDirectoryLanguage(t *testing.T) {
f := newFixture(t, map[string]string{
// No lang in the frontmatter: cs comes from the directory.
"cs/hello.md": "+++\ntitle = \"Hello\"\nslug = \"hello\"\n+++\nbody\n",
})
_, raw, _ := f.tokens.Create("full", nil)
req := httptest.NewRequest(http.MethodPut, "/api/volumen/posts/hello",
strings.NewReader(`{"slug": "hi"}`))
req.Header.Set("Authorization", "Bearer "+raw)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("rename code = %d, body = %s", rec.Code, rec.Body.String())
}
renamed := f.store.Find("hi", "")
if renamed == nil {
t.Fatal("renamed post missing")
}
if renamed.Lang() != "cs" {
t.Fatalf("lang = %q, want cs", renamed.Lang())
}
if want := filepath.Join(f.store.ContentDir, "cs", "hi.md"); renamed.Path != want {
t.Fatalf("path = %q, want %q", renamed.Path, want)
}
}