// Tests for statusHandler's /ledgers/ branch (status.go). The three fleet // routes are exercised end-to-end through the Edge in edge_test.go // (TestStatusRoute*); the ledger branch has no per-route config of its own, // so these drive the handler directly — same fake-clock/tiny-TTL seam. package edge import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "sync" "testing" "time" "github.com/carlosframework/platform/internal/ledger" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // seedLedger writes one real chained entry, one blob and the app's opt-in // manifest THROUGH the ledger package, so the keys under test are byte-for- // byte the keys production writes (the whole point of the 1:1 URL↔key map). func seedLedger(t *testing.T, st release.Store, public bool) (entryKey, blobSHA string) { t.Helper() ctx := context.Background() a, err := ledger.Append(ctx, st, "bab", "platform", "carbon", "w", json.RawMessage(`{"n":1}`), func() time.Time { return time.Date(2026, 8, 3, 21, 0, 0, 0, time.UTC) }) if err != nil { t.Fatalf("append: %v", err) } sha, err := ledger.PutBlob(ctx, st, "bab", "platform", "carbon", []byte("blobby")) if err != nil { t.Fatalf("put blob: %v", err) } if err := ledger.WriteManifest(ctx, st, "bab", "platform", ledger.Manifest{Public: public, Ledgers: []string{"carbon"}, Title: "CARLOS"}); err != nil { t.Fatalf("write manifest: %v", err) } return a.Key, sha } func getStatus(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder { t.Helper() rr := httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "http://status.test"+path, nil)) return rr } // TestLedgerServingPublic: with public:true, every object the ledger package // writes is fetchable at the path that equals its bucket key, with the // content type a browser (and `carlos ledger verify -url`) expects. func TestLedgerServingPublic(t *testing.T) { st := release.NewDirStore(t.TempDir()) entryKey, blobSHA := seedLedger(t, st, true) h := newStatusHandler(st) // [contentType, cacheControl]: the mutable pair carries no Cache-Control // (freshness is the handler's own 60s TTL); the content-addressed pair // carries the immutable year, because it is cached by clients and CDNs // instead of in this process. for path, want := range map[string][2]string{ "/ledgers/bab/platform/ledgers.json": {"application/json", ""}, "/ledgers/bab/platform/carbon/HEAD.json": {"application/json", ""}, "/" + entryKey: {"application/json", ledgerImmutableCacheControl}, "/ledgers/bab/platform/carbon/blobs/" + blobSHA: {"application/octet-stream", ledgerImmutableCacheControl}, } { rr := getStatus(t, h, path) if rr.Code != http.StatusOK { t.Errorf("GET %s = %d, want 200", path, rr.Code) continue } if ct := rr.Header().Get("Content-Type"); ct != want[0] { t.Errorf("GET %s Content-Type = %q, want %q", path, ct, want[0]) } if cc := rr.Header().Get("Cache-Control"); cc != want[1] { t.Errorf("GET %s Cache-Control = %q, want %q", path, cc, want[1]) } // Ledger bytes are arbitrary tenant content served from the status // host's own origin: every shape must refuse content sniffing. if xcto := rr.Header().Get("X-Content-Type-Options"); xcto != "nosniff" { t.Errorf("GET %s X-Content-Type-Options = %q, want nosniff", path, xcto) } } if body := getStatus(t, h, "/ledgers/bab/platform/carbon/blobs/"+blobSHA).Body.String(); body != "blobby" { t.Errorf("blob body = %q, want %q", body, "blobby") } } // TestLedgerServingFailClosed: every way of NOT being a published public // object answers the same plain 404 — a non-public app must be // indistinguishable from an app that does not exist. func TestLedgerServingFailClosed(t *testing.T) { st := release.NewDirStore(t.TempDir()) entryKey, blobSHA := seedLedger(t, st, false) // chain exists, manifest says NOT public // An app whose manifest exists but is unparseable must also fail closed. if _, err := st.Put(context.Background(), ledger.ManifestKey("bab", "broken"), []byte("{not json"), ""); err != nil { t.Fatalf("seed broken manifest: %v", err) } h := newStatusHandler(st) for _, path := range []string{ "/ledgers/bab/platform/carbon/HEAD.json", "/ledgers/bab/platform/ledgers.json", "/" + entryKey, "/ledgers/bab/platform/carbon/blobs/" + blobSHA, "/ledgers/bab/broken/ledgers.json", // manifest unparseable "/ledgers/nope/nothing/carbon/HEAD.json", // no manifest at all "/ledgers/bab/platform/carbon/../../secrets", // traversal shape "/ledgers/bab", // too-short path "/ledgers/bab/platform", // too-short path "/ledgers/", // bare prefix } { rr := getStatus(t, h, path) if rr.Code != http.StatusNotFound { t.Errorf("GET %s = %d, want 404", path, rr.Code) } } // Byte-identical bodies: the private app and the app that was never // created must not be told apart by their 404s. private := getStatus(t, h, "/ledgers/bab/platform/ledgers.json") absent := getStatus(t, h, "/ledgers/nope/nothing/ledgers.json") if private.Body.String() != absent.Body.String() { t.Errorf("404 bodies differ: private %q vs absent %q", private.Body.String(), absent.Body.String()) } } // appendTo adds one entry to another ledger of the same app, so a test can // have a published and an unpublished chain side by side. func appendTo(t *testing.T, st release.Store, name string) string { t.Helper() a, err := ledger.Append(context.Background(), st, "bab", "platform", name, "w", json.RawMessage(`{"n":2}`), func() time.Time { return time.Date(2026, 8, 3, 22, 0, 0, 0, time.UTC) }) if err != nil { t.Fatalf("append %s: %v", name, err) } return a.Key } // TestLedgerAllowlistIsPerLedger: Manifest.Ledgers is an allowlist, not an // index. An account accrues chains privately, so public:true on the app must // not publish every chain it has ever written — only the ones it names. func TestLedgerAllowlistIsPerLedger(t *testing.T) { st := release.NewDirStore(t.TempDir()) seedLedger(t, st, true) // manifest publishes "carbon" only ironEntry := appendTo(t, st, "iron") ironSHA, err := ledger.PutBlob(context.Background(), st, "bab", "platform", "iron", []byte("ironblob")) if err != nil { t.Fatalf("put iron blob: %v", err) } h := newStatusHandler(st) if rr := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json"); rr.Code != http.StatusOK { t.Fatalf("published ledger = %d, want 200", rr.Code) } absent := getStatus(t, h, "/ledgers/nope/nothing/carbon/HEAD.json") for _, path := range []string{ "/ledgers/bab/platform/iron/HEAD.json", "/" + ironEntry, "/ledgers/bab/platform/iron/blobs/" + ironSHA, } { rr := getStatus(t, h, path) if rr.Code != http.StatusNotFound { t.Errorf("unpublished ledger GET %s = %d, want 404", path, rr.Code) continue } if rr.Body.String() != absent.Body.String() { t.Errorf("GET %s 404 body %q differs from an absent app's %q", path, rr.Body.String(), absent.Body.String()) } } // An empty list publishes nothing but the app's own index. if err := ledger.WriteManifest(context.Background(), st, "bab", "platform", ledger.Manifest{Public: true, Ledgers: nil, Title: "CARLOS"}); err != nil { t.Fatalf("rewrite manifest: %v", err) } h2 := newStatusHandler(st) // fresh handler: don't wait out the 60s TTL if rr := getStatus(t, h2, "/ledgers/bab/platform/ledgers.json"); rr.Code != http.StatusOK { t.Errorf("ledgers.json with an empty list = %d, want 200 (the app's own index)", rr.Code) } for _, path := range []string{ "/ledgers/bab/platform/carbon/HEAD.json", "/ledgers/bab/platform/iron/HEAD.json", } { if rr := getStatus(t, h2, path); rr.Code != http.StatusNotFound { t.Errorf("GET %s with an empty allowlist = %d, want 404", path, rr.Code) } } } // TestLedgerBlobContentTypeComesFromShape: a blob is named by its sha256 and // nothing stops a tenant uploading one whose name ends ".json" — the content // type must come from the path SHAPE, not from a suffix probe on the key. func TestLedgerBlobContentTypeComesFromShape(t *testing.T) { st := release.NewDirStore(t.TempDir()) seedLedger(t, st, true) if _, err := st.Put(context.Background(), "ledgers/bab/platform/carbon/blobs/deadbeef.json", []byte(`{"not":"json-typed"}`), ""); err != nil { t.Fatalf("seed blob: %v", err) } rr := getStatus(t, newStatusHandler(st), "/ledgers/bab/platform/carbon/blobs/deadbeef.json") if rr.Code != http.StatusOK { t.Fatalf("blob = %d, want 200", rr.Code) } if ct := rr.Header().Get("Content-Type"); ct != "application/octet-stream" { t.Errorf("blob Content-Type = %q, want application/octet-stream", ct) } } // TestLedgerServingNeverLeavesPrefix: a public manifest buys access to that // app's own ledger subtree and nothing else — no encoded traversal reaches a // key outside it. func TestLedgerServingNeverLeavesPrefix(t *testing.T) { st := release.NewDirStore(t.TempDir()) seedLedger(t, st, true) if _, err := st.Put(context.Background(), "status/fleet.json", []byte(`{"fleet":true}`), ""); err != nil { t.Fatalf("seed fleet: %v", err) } if _, err := st.Put(context.Background(), "apps/bab/platform/config.json", []byte(`{"secret":true}`), ""); err != nil { t.Fatalf("seed app config: %v", err) } h := newStatusHandler(st) for _, path := range []string{ "/ledgers/bab/platform/%2e%2e/%2e%2e/%2e%2e/status/fleet.json", "/ledgers/bab/platform/../../../apps/bab/platform/config.json", "/ledgers/bab/platform/carbon/%2e%2e/%2e%2e/%2e%2e/%2e%2e/apps/bab/platform/config.json", "/ledgers/bab/platform//../../apps/bab/platform/config.json", } { rr := getStatus(t, h, path) if rr.Code == http.StatusOK { t.Errorf("GET %s = 200 %q — escaped the app's ledger subtree", path, rr.Body.String()) } } } // TestLedgerServingOnlyLedgerShapes: even inside a public app's own subtree // the handler serves only the four object shapes the ledger primitive // defines; anything else that happens to be under the prefix stays private. func TestLedgerServingOnlyLedgerShapes(t *testing.T) { st := release.NewDirStore(t.TempDir()) seedLedger(t, st, true) ctx := context.Background() for _, key := range []string{ "ledgers/bab/platform/notes.txt", "ledgers/bab/platform/other.json", "ledgers/bab/platform/carbon/notes.txt", "ledgers/bab/platform/carbon/entries/raw.bin", "ledgers/bab/platform/carbon/blobs/deep/nested", } { if _, err := st.Put(ctx, key, []byte("private"), ""); err != nil { t.Fatalf("seed %s: %v", key, err) } } h := newStatusHandler(st) for _, key := range []string{ "ledgers/bab/platform/notes.txt", "ledgers/bab/platform/other.json", "ledgers/bab/platform/carbon/notes.txt", "ledgers/bab/platform/carbon/entries/raw.bin", "ledgers/bab/platform/carbon/blobs/deep/nested", } { if rr := getStatus(t, h, "/"+key); rr.Code != http.StatusNotFound { t.Errorf("GET /%s = %d, want 404", key, rr.Code) } } } // TestLedgerServingMissingObjectIs404: under a public app, an object that // isn't there is a 404 — the 503 "nothing honest left to serve" contract // belongs to the fleet page's own three objects, not to ledger reads. func TestLedgerServingMissingObjectIs404(t *testing.T) { st := release.NewDirStore(t.TempDir()) seedLedger(t, st, true) h := newStatusHandler(st) for _, path := range []string{ "/ledgers/bab/platform/nosuch/HEAD.json", "/ledgers/bab/platform/carbon/entries/20260101T000000Z-deadbeefcafe.json", "/ledgers/bab/platform/carbon/blobs/" + ledger.SHA256Hex([]byte("absent")), } { if rr := getStatus(t, h, path); rr.Code != http.StatusNotFound { t.Errorf("GET %s = %d, want 404", path, rr.Code) } } } // cacheKeys snapshots the handler's in-memory cache under its own lock. func cacheKeys(h *statusHandler) map[string]bool { h.mu.Lock() defer h.mu.Unlock() keys := make(map[string]bool, len(h.cache)) for k := range h.cache { keys[k] = true } return keys } // TestLedgerCacheClasses is the memory-safety pin: a public ledger has // unboundedly many entries and blobs (blobs up to 4 MiB), so serving them // must NOT put anything in the handler's never-evicting cache — otherwise // anyone on the internet could pin arbitrary edge memory just by walking a // public chain. Only the mutable, bounded set (one ledgers.json per public // app, one HEAD.json per ledger) is cached. func TestLedgerCacheClasses(t *testing.T) { dir := t.TempDir() st := release.NewDirStore(dir) entryKey, blobSHA := seedLedger(t, st, true) h := newStatusHandler(st) entryPath, blobPath := "/"+entryKey, "/ledgers/bab/platform/carbon/blobs/"+blobSHA if rr := getStatus(t, h, entryPath); rr.Code != http.StatusOK { t.Fatalf("GET %s = %d, want 200", entryPath, rr.Code) } if rr := getStatus(t, h, blobPath); rr.Code != http.StatusOK { t.Fatalf("GET %s = %d, want 200", blobPath, rr.Code) } keys := cacheKeys(h) if keys[entryKey] || keys["ledgers/bab/platform/carbon/blobs/"+blobSHA] { t.Errorf("immutable objects were cached in-process: %v", keys) } // The gate itself is cached — that read is what bounds the map. if want := ledger.ManifestKey("bab", "platform"); !keys[want] { t.Errorf("manifest %s not cached: %v", want, keys) } if len(keys) != 1 { t.Errorf("cache holds %d keys, want only the manifest: %v", len(keys), keys) } // Serving HEAD.json adds exactly one more key (the mutable class). if rr := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json"); rr.Code != http.StatusOK { t.Fatalf("GET HEAD.json = %d, want 200", rr.Code) } if keys = cacheKeys(h); len(keys) != 2 || !keys[ledger.HeadKey("bab", "platform", "carbon")] { t.Errorf("after HEAD.json the cache holds %v, want manifest + HEAD", keys) } // A miss never grows the map: probing sqids that do not exist must not // be a way to allocate memory in the edge (get only records an entry // after a SUCCESSFUL store read). for i, p := range []string{ "/ledgers/nope/nothing/ledgers.json", "/ledgers/nope/nothing/carbon/HEAD.json", "/ledgers/bab/platform/carbon/entries/20260101T000000Z-000000000000.json", } { if rr := getStatus(t, h, p); rr.Code != http.StatusNotFound { t.Fatalf("probe %d (%s) = %d, want 404", i, p, rr.Code) } } if after := cacheKeys(h); len(after) != 2 { t.Errorf("probing grew the cache to %v", after) } // And because entries are not cached at all, removing the backing // object is visible immediately — no stale copy is held anywhere in // this process, even though the manifest gate is still cached. if err := os.Remove(filepath.Join(dir, filepath.FromSlash(entryKey))); err != nil { t.Fatalf("remove entry: %v", err) } if rr := getStatus(t, h, entryPath); rr.Code != http.StatusNotFound { t.Errorf("removed entry = %d, want 404 (nothing cached in-process)", rr.Code) } } // countingStore records how many times each key is read, so a test can // assert what a request COST rather than only what it answered. type countingStore struct { release.Store mu sync.Mutex gets map[string]int } func newCountingStore(st release.Store) *countingStore { return &countingStore{Store: st, gets: make(map[string]int)} } func (c *countingStore) Get(ctx context.Context, key string) ([]byte, string, error) { c.mu.Lock() c.gets[key]++ c.mu.Unlock() return c.Store.Get(ctx, key) } func (c *countingStore) count(key string) int { c.mu.Lock() defer c.mu.Unlock() return c.gets[key] } // TestLedgerManifestMissesAreNegativeCached: status-kind routes are exempt // from the shared response cache, and get() only records an entry after a // SUCCESSFUL read — so without a negative cache every anonymous request to // /ledgers///x/HEAD.json costs exactly one bucket GET, and a // stranger with a loop is billing the deployment one S3 request per packet. // The decision "no readable public manifest here" is cached for the same 60s // the positive answer is. func TestLedgerManifestMissesAreNegativeCached(t *testing.T) { st := newCountingStore(release.NewDirStore(t.TempDir())) h := newStatusHandler(st) h.ttl = 50 * time.Millisecond fakeNow := time.Now() h.now = func() time.Time { return fakeNow } key := ledger.ManifestKey("nope", "nothing") for i := range 5 { if rr := getStatus(t, h, "/ledgers/nope/nothing/carbon/HEAD.json"); rr.Code != http.StatusNotFound { t.Fatalf("probe %d = %d, want 404", i, rr.Code) } } if got := st.count(key); got != 1 { t.Fatalf("%s was read %d times for 5 probes, want 1 (the miss must be cached)", key, got) } // Different objects under the SAME app share the one decision — the // gate is the app's manifest, so walking an app's key space is not a way // around it. for _, p := range []string{ "/ledgers/nope/nothing/ledgers.json", "/ledgers/nope/nothing/carbon/entries/20260101T000000Z-000000000000.json", "/ledgers/nope/nothing/carbon/blobs/" + ledger.SHA256Hex([]byte("x")), } { if rr := getStatus(t, h, p); rr.Code != http.StatusNotFound { t.Fatalf("GET %s = %d, want 404", p, rr.Code) } } if got := st.count(key); got != 1 { t.Fatalf("%s was read %d times, want still 1", key, got) } // Past the TTL the decision is re-taken — publishing an app takes effect // within one tick, exactly like the positive cache's staleness bound. fakeNow = fakeNow.Add(51 * time.Millisecond) if rr := getStatus(t, h, "/ledgers/nope/nothing/carbon/HEAD.json"); rr.Code != http.StatusNotFound { t.Fatalf("post-TTL probe = %d, want 404", rr.Code) } if got := st.count(key); got != 2 { t.Fatalf("%s read %d times after the TTL expired, want 2", key, got) } // A non-public app is the same cached decision: the manifest read that // answered it is itself cached, so neither costs a second GET. seedLedger(t, st, false) pkey := ledger.ManifestKey("bab", "platform") before := st.count(pkey) for range 3 { if rr := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json"); rr.Code != http.StatusNotFound { t.Fatalf("private app = %d, want 404", rr.Code) } } if got := st.count(pkey) - before; got > 1 { t.Fatalf("a private app cost %d manifest reads for 3 probes, want at most 1", got) } } // flakyStore fails every read while armed, with an error that is NOT // not-found — a bucket blip, a throttle, an expired credential — the class of // failure that resolves on its own. type flakyStore struct { release.Store mu sync.Mutex broken bool } func (f *flakyStore) breakIt(b bool) { f.mu.Lock() defer f.mu.Unlock() f.broken = b } func (f *flakyStore) Get(ctx context.Context, key string) ([]byte, string, error) { f.mu.Lock() broken := f.broken f.mu.Unlock() if broken { return nil, "", errors.New("Throttling: slow down") } return f.Store.Get(ctx, key) } // TestLedgerTransientStoreErrorIsNotNegativeCached: the negative cache exists // to make PROBING cheap to refuse, and a probe's manifest is not-found. A // transient store error is a different thing entirely: caching it would pin a // public app dark for the full TTL because the bucket hiccuped once while the // cache happened to be cold — the exact dishonesty the positive cache's // stale-on-error half exists to avoid, reintroduced from the other side. The // blipped request still 404s (fail-closed: nothing readable said public), but // the decision is not remembered, so the next request retries. func TestLedgerTransientStoreErrorIsNotNegativeCached(t *testing.T) { base := release.NewDirStore(t.TempDir()) st := &flakyStore{Store: base} seedLedger(t, base, true) h := newStatusHandler(st) // Cold cache + broken store: 404, because nothing readable says public. st.breakIt(true) if rr := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json"); rr.Code != http.StatusNotFound { t.Fatalf("during the blip = %d, want 404", rr.Code) } h.mu.Lock() n := len(h.misses) h.mu.Unlock() if n != 0 { t.Fatalf("a transient store error stamped %d negative-cache entries, want 0", n) } // The store recovers well inside the 60s TTL: the very next request must // serve, not sit behind a cached refusal. st.breakIt(false) if rr := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json"); rr.Code != http.StatusOK { t.Fatalf("after the store recovered = %d, want 200 (the blip must not be negatively cached)", rr.Code) } } // TestLedgerMissCacheIsBounded: the attacker picks the account and app path // segments, so the set of distinct manifest keys a prober can name is // unbounded — a negative cache that grew with it would be the memory // exhaustion the immutable classes are deliberately uncached to avoid, // reintroduced through the back door. func TestLedgerMissCacheIsBounded(t *testing.T) { st := release.NewDirStore(t.TempDir()) h := newStatusHandler(st) for i := range statusMissCacheMax * 3 { p := fmt.Sprintf("/ledgers/acct%d/app%d/carbon/HEAD.json", i, i) if rr := getStatus(t, h, p); rr.Code != http.StatusNotFound { t.Fatalf("probe %d = %d, want 404", i, rr.Code) } } h.mu.Lock() n := len(h.misses) h.mu.Unlock() if n > statusMissCacheMax { t.Fatalf("negative cache holds %d entries, over the %d bound", n, statusMissCacheMax) } // The positive cache is untouched by any of it: nothing was ever read. if keys := cacheKeys(h); len(keys) != 0 { t.Fatalf("probing populated the positive cache: %v", keys) } } // TestLedgerServingStaleManifestHonored pins the interaction with get()'s // stale-on-error half: the manifest is just another cached object, so once a // public manifest has been read, a store outage keeps the already-cached // objects serving (the alternative — 404ing a public ledger because the // bucket blipped — is the same dishonesty the status page's cache exists to // avoid). An object that was never cached still 404s: stale caching, not // fabrication. // // The outage is a flakyStore error, not a deleted directory: a deletion // answers os.ErrNotExist, which is the bucket saying the object is gone // (TestLedgerServingStopsAfterPurge), not a blip. func TestLedgerServingStaleManifestHonored(t *testing.T) { base := release.NewDirStore(t.TempDir()) st := &flakyStore{Store: base} entryKey, _ := seedLedger(t, base, true) h := newStatusHandler(st) h.ttl = 20 * time.Millisecond fakeNow := time.Now() h.now = func() time.Time { return fakeNow } first := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json") if first.Code != http.StatusOK { t.Fatalf("first fetch = %d, want 200", first.Code) } // Break the store entirely and age both cached objects past the TTL. st.breakIt(true) fakeNow = fakeNow.Add(21 * time.Millisecond) stale := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json") if stale.Code != http.StatusOK || stale.Body.String() != first.Body.String() { t.Fatalf("stale fetch = %d %q, want 200 %q", stale.Code, stale.Body.String(), first.Body.String()) } // The entry was never cached: a stale-but-public manifest does not // conjure a body for it. if rr := getStatus(t, h, "/"+entryKey); rr.Code != http.StatusNotFound { t.Errorf("uncached entry during outage = %d, want 404", rr.Code) } } // TestLedgerServingStopsAfterPurge (#219 item 2): purging an app deletes // its ledgers from the bucket, and an edge that had cached the public // manifest must stop serving them once its TTL runs out, not fall back to // the stale copy as it would for a bucket blip. Otherwise a later app of the // same name would find the purged app's history public on that edge. func TestLedgerServingStopsAfterPurge(t *testing.T) { st := release.NewDirStore(t.TempDir()) seedLedger(t, st, true) h := newStatusHandler(st) h.ttl = 20 * time.Millisecond fakeNow := time.Now() h.now = func() time.Time { return fakeNow } for _, p := range []string{"/ledgers/bab/platform/ledgers.json", "/ledgers/bab/platform/carbon/HEAD.json"} { if rr := getStatus(t, h, p); rr.Code != http.StatusOK { t.Fatalf("before the purge %s = %d, want 200", p, rr.Code) } } keys, err := st.List(context.Background(), release.LedgerAppPrefix("bab", "platform")) if err != nil { t.Fatal(err) } for _, k := range keys { if err := st.Delete(context.Background(), k); err != nil { t.Fatal(err) } } fakeNow = fakeNow.Add(21 * time.Millisecond) for _, p := range []string{"/ledgers/bab/platform/ledgers.json", "/ledgers/bab/platform/carbon/HEAD.json"} { if rr := getStatus(t, h, p); rr.Code != http.StatusNotFound { t.Errorf("after the purge %s = %d, want 404", p, rr.Code) } } if keys := cacheKeys(h); keys["ledgers/bab/platform/ledgers.json"] { t.Errorf("the purged manifest is still cached: %v", keys) } } // statusEdgeWithCache wires a full Edge (Host dispatch, shared response // cache) in front of one status route — testEdgeWithStore's shape, but // returning the *Edge so a test can look inside e.cache. func statusEdgeWithCache(t *testing.T) (release.Store, *Edge) { t.Helper() reg := openReg(t) if err := reg.Upsert(registry.Route{Host: "status.test", Kind: "status", Status: "ready"}); err != nil { t.Fatalf("upsert: %v", err) } e, err := New(reg, nil) if err != nil { t.Fatalf("New: %v", err) } store := release.NewDirStore(filepath.Join(t.TempDir(), "store")) _, pubB64, err := release.GenerateKey() if err != nil { t.Fatalf("generate key: %v", err) } pub, err := release.ParsePublicKey(pubB64, "test") if err != nil { t.Fatalf("parse public key: %v", err) } e.EnableStatic(store, pub, filepath.Join(t.TempDir(), "cache")) e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) if err := e.Reload(); err != nil { t.Fatalf("Reload: %v", err) } return store, e } // TestStatusResponsesNeverEnterSharedCache: the shared response cache is // process-global and shared across every host, and a public ledger chain is // unbounded in length — so a stranger walking one must not be able to admit // (and thereby evict) anything in it. An evicted entry for a hibernating // route is a wake that the cache existed to prevent. The client-facing // immutable Cache-Control still goes out: browsers and CDNs do the caching. func TestStatusResponsesNeverEnterSharedCache(t *testing.T) { store, e := statusEdgeWithCache(t) entryKey, blobSHA := seedLedger(t, store, true) if _, err := store.Put(context.Background(), "status/fleet.json", []byte(`{"ok":true}`), ""); err != nil { t.Fatalf("seed fleet: %v", err) } for _, path := range []string{ "/" + entryKey, "/ledgers/bab/platform/carbon/blobs/" + blobSHA, "/ledgers/bab/platform/carbon/HEAD.json", "/ledgers/bab/platform/ledgers.json", "/fleet.json", } { rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("GET", "http://status.test"+path, nil)) if rec.Code != http.StatusOK { t.Fatalf("GET %s = %d, want 200", path, rec.Code) } if _, ok := e.cache.get(CacheKey{Host: "status.test", Method: "GET", Path: path}); ok { t.Errorf("GET %s entered the shared edge response cache", path) } } // The header a client sees is unchanged — only the edge's own admission // is refused. rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("GET", "http://status.test/"+entryKey, nil)) if cc := rec.Header().Get("Cache-Control"); cc != ledgerImmutableCacheControl { t.Errorf("entry Cache-Control = %q, want %q", cc, ledgerImmutableCacheControl) } } // TestLedgerBranchLeavesFleetRoutesAlone: the /ledgers/ prefix branch must // not intercept the three fleet paths, which keep their own 503-when-nothing- // cached contract. func TestLedgerBranchLeavesFleetRoutesAlone(t *testing.T) { st := release.NewDirStore(t.TempDir()) seedLedger(t, st, true) h := newStatusHandler(st) for _, path := range []string{"/", "/fleet.json", "/status.css"} { if rr := getStatus(t, h, path); rr.Code != http.StatusServiceUnavailable { t.Errorf("GET %s = %d, want 503 (nothing published)", path, rr.Code) } } if _, err := st.Put(context.Background(), "status/fleet.json", []byte(`{"ok":true}`), ""); err != nil { t.Fatalf("seed fleet: %v", err) } rr := getStatus(t, h, "/fleet.json") if rr.Code != http.StatusOK || rr.Body.String() != `{"ok":true}` { t.Errorf("GET /fleet.json = %d %q, want 200 fleet body", rr.Code, rr.Body.String()) } if ct := rr.Header().Get("Content-Type"); ct != "application/json" { t.Errorf("GET /fleet.json Content-Type = %q", ct) } }