Item 0 of the roadmap's order of work, and it blocked everything after it: core
sat at 2965 of 3000 while the review scheduled four core-bound items, the first
of which — logging — wanted the whole remainder.
/robots.txt and /sitemap.xml are exact paths somebody else's software asks for by
name. They own no core concept and pass every test the architecture applies to a
feature; they lived in internal/web only because a feature could not own a route
until ADR-0081. internal/ext/discover/ now holds them. Core 2965 → 2913.
The seam gained one parameter to make it possible: a func() *content.Site, since a
sitemap must list what is served now and the index is swapped whole on every
rebuild (ADR-0077). A captured pointer would have frozen the site at startup —
which is the kind of bug that only shows up after a rebuild, in production.
The ceiling rises to 3400 as well as the move, because the move alone could not buy
the room. feed.go and web/extras.go cannot follow discover out: a feed lives at
/{section}/feed.xml and extras under a bundle's own URL, so both are resolver cases
while the seam mounts exact paths only. Raising by the minimum that unblocks one
item produces a ceiling nobody believes, so 3400 fits the View cluster with
headroom. HARNESS.md asks that a raise be read as evidence something belongs in
ext before evidence the number was small; both readings were true, so both actions
were taken.
web no longer reserves those two paths, so a clash between features is wire.go's:
it merges route maps in declaration order, keeps the earlier claim, logs the loser.
Verified — a site shipping root/robots.txt starts, serves the engine's robots.txt,
and logs the passthrough claim, where an unguarded mux.Handle would have panicked.
Evidence: robots.txt and sitemap.xml are byte-identical before and after the move
against the demo site (67 and 2701 bytes, cmp clean), and the sitemap keeps its
application/xml type.
One real cost, recorded in both places rather than hidden. internal/web's
visibility test asserted that a listing, a feed *and* a sitemap all hide
unpublished bundles — one property, one test, because all three share a Query. The
sitemap half moved to the feature instead of a web test importing ext, which would
invert the one-way layering the architecture gate enforces. That property is now
asserted twice, once per package owning a surface.
Three gates caught real mistakes on the way: the staged-tree check found a partial
stage where git rm had staged a deletion while the caller edits were unstaged, the
coupling gates demanded state.md and HARNESS.md, and the nesting advisory rejected
a closure that put the merge loop one level too deep — fixed by making it a plain
function rather than tolerated.
Extensions 6 → 7. Routing cases unmoved: exact paths are mux entries, never
resolver cases, which is what that counter's exclusion column already said.
13 files. Core 2913/3400, ext 2495/3500.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
105 lines
4.1 KiB
Go
105 lines
4.1 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{}, nil)
|
|
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{}, nil)
|
|
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 and a feed both 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.
|
|
//
|
|
// The sitemap was the third surface here until ADR-0085 moved it to internal/ext/discover. Its half of
|
|
// this test moved with it rather than reaching across the boundary: a web test importing a feature would
|
|
// invert the one-way layering the architecture gate enforces. The property is now asserted twice, once
|
|
// per package that owns a surface — a real cost of the move, recorded rather than hidden.
|
|
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, nil)
|
|
for _, path := range []string{"/art/", "/feed.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)
|
|
}
|
|
}
|
|
}
|
|
}
|