Files
khosra/internal/web/visibility_test.go
T
Claude Opus 5andbdeshi 9100ce4876 notice content changes and rebuild without a restart
Polling lives in internal/ext/watch, per the human's call to keep core under its
ceiling rather than raise it a second time — which is what ADR-0041 said a second
raise would mean. It is a poller, deletable without trace, and core stayed at
2671/2800.

A settled change calls the same `rebuilder` that startup calls, because a reload
path that differs from the startup path is a reload path that drifts. The index is
an atomic.Pointer swapped whole, so a request reads the site that was current when
it arrived instead of one being rebuilt underneath it — the alternative, mutating in
place, is a data race with every in-flight request.

Names, sizes and modification times, not contents: reading every file to detect a
change costs more than the rebuild it triggers. Editor droppings are excluded,
because saving in vim writes a swap file, a backup and the number 4913, and each
would otherwise look like a change. A change must hold still for a moment first,
since one save is often several operations.

Verified against the running binary: a page 404s, the file appears, and five seconds
later it serves — one "site root changed" in the log. Then three droppings written
at once produced no rebuild at all.

Two warnings fired and were fixed rather than silenced: `runServe` gave up the
rebuild closure to `rebuilder`, and the fingerprint walk gave up its body to
`record`, where three exclusions read as a list instead of as nesting.

The Dockerfile ships the binary alone. The site root arrives as a volume and is
never copied in — it is somebody's content repository with its own history
(ADR-0011), so the image is the same for every site.
2026-07-31 14:00:53 +06:00

100 lines
3.7 KiB
Go

package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"khosra/internal/content"
"khosra/internal/render"
)
// unpublishedFS holds a draft, a future-dated bundle and a live one, each a directory bundle with a file.
func unpublishedFS() fstest.MapFS {
return fstest.MapFS{
"content/art/draft/index.md": {Data: []byte("---\ntitle: Draft\ndraft: true\n---\nx\n")},
"content/art/draft/one.jpg": {Data: []byte("secret bytes")},
"content/art/future/index.md": {Data: []byte("---\ntitle: Future\ndate: 2099-01-01\n---\nx\n")},
"content/art/future/two.jpg": {Data: []byte("not yet")},
"content/art/live/index.md": {Data: []byte("---\ntitle: Live\ndate: 2020-01-01\n---\nx\n")},
"content/art/live/three.jpg": {Data: []byte("fine")},
}
}
func TestNothingInsideAnUnpublishedBundleIsServed(t *testing.T) {
// ADR-0024: an unpublished bundle answers 404 for itself *and* for every file inside it, since 403 would
// confirm the work exists. The guard is the bundle lookup — which is why the asset route asks for the
// bundle before reading any bytes, and why this needed no second filter.
fsys := unpublishedFS()
bundles, err := content.Scan(fsys)
if err != nil {
t.Fatal(err)
}
r, err := render.New(nil, content.Settings{}, nil)
if err != nil {
t.Fatal(err)
}
hidden := Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
for path, want := range map[string]int{
"/art/draft/": http.StatusNotFound,
"/art/draft/one.jpg": http.StatusNotFound,
"/art/future/": http.StatusNotFound,
"/art/future/two.jpg": http.StatusNotFound,
"/art/live/": http.StatusOK,
"/art/live/three.jpg": http.StatusOK,
} {
rec := httptest.NewRecorder()
hidden.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != want {
t.Errorf("GET %s = %d, want %d", path, rec.Code, want)
}
if body := rec.Body.String(); strings.Contains(body, "secret bytes") || strings.Contains(body, "not yet") {
t.Fatalf("GET %s served bytes from an unpublished bundle", path)
}
}
// Revealing them is the only thing that changes the answer.
site := content.NewSite(bundles)
site.Reveal()
shown := Handler(Fixed(site), r, fsys, nil, content.Settings{})
for _, path := range []string{"/art/draft/", "/art/draft/one.jpg", "/art/future/", "/art/future/two.jpg"} {
rec := httptest.NewRecorder()
shown.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Errorf("dev mode: GET %s = %d, want 200", path, rec.Code)
}
}
}
func TestUnpublishedBundlesAreAbsentFromEverythingThatLists(t *testing.T) {
// A listing, a feed and a sitemap all go through the same Query, so hiding a draft in one place hides it
// everywhere. That is the property worth testing rather than each surface separately.
fsys := unpublishedFS()
fsys["content/art/live/index.md"] = &fstest.MapFile{Data: []byte("---\ntitle: Live\ndate: 2020-01-01\n---\nx\n")}
bundles, err := content.Scan(fsys)
if err != nil {
t.Fatal(err)
}
settings := content.Settings{Base: "https://khosra.example", Title: "Khosra"}
r, err := render.New(nil, settings, nil)
if err != nil {
t.Fatal(err)
}
h := Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, settings)
for _, path := range []string{"/art/", "/feed.xml", "/sitemap.xml"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
body := rec.Body.String()
if !strings.Contains(body, "live") && !strings.Contains(body, "Live") {
t.Errorf("GET %s lost the published bundle:\n%s", path, body)
}
for _, hidden := range []string{"draft", "future"} {
if strings.Contains(body, hidden) {
t.Errorf("GET %s leaked the %s bundle:\n%s", path, hidden, body)
}
}
}
}