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