hide drafts and future-dated bundles; reveal them with -dev on
A draft is now not served at all, and neither is a bundle whose date has not arrived. The filter sits in `Site.Lookup` and `Site.Run`, which is every path to a bundle — so the files inside an unpublished bundle inherit its status for free, which is what ADR-0024 asks for and what the asset route was written to allow. A test asserts the 404 for the bundle *and* its picture, and that nothing leaks into a listing, a feed or a sitemap. The clock is read per request rather than at startup, so a scheduled post appears exactly when its date arrives with nothing to restart and nothing to invalidate. That first clock read created internal/content/clock.go, which is the only place `verify.sh` allows `time.Now` — a render that depends on the time is worth being able to find. `-dev on` reveals both and reparses the theme before each render. Deliberately not a bare boolean flag: turning unpublished work into public work should not be one fumbled argument away. A reload that fails to parse leaves the working template set in place, so a typo shows an error rather than replacing a good set with a broken one.
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
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(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(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(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user