carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// Tests for statusHandler's /ledgers/ branch (status.go). The three fleet
2// routes are exercised end-to-end through the Edge in edge_test.go
3// (TestStatusRoute*); the ledger branch has no per-route config of its own,
4// so these drive the handler directly — same fake-clock/tiny-TTL seam.
5package edge
6
7import (
8 "context"
9 "encoding/json"
10 "errors"
11 "fmt"
12 "net/http"
13 "net/http/httptest"
14 "os"
15 "path/filepath"
16 "sync"
17 "testing"
18 "time"
19
20 "github.com/carlosframework/platform/internal/ledger"
21 "github.com/carlosframework/platform/internal/registry"
22 "github.com/carlosframework/platform/internal/release"
23)
24
25// seedLedger writes one real chained entry, one blob and the app's opt-in
26// manifest THROUGH the ledger package, so the keys under test are byte-for-
27// byte the keys production writes (the whole point of the 1:1 URL↔key map).
28func seedLedger(t *testing.T, st release.Store, public bool) (entryKey, blobSHA string) {
29 t.Helper()
30 ctx := context.Background()
31 a, err := ledger.Append(ctx, st, "bab", "platform", "carbon", "w",
32 json.RawMessage(`{"n":1}`),
33 func() time.Time { return time.Date(2026, 8, 3, 21, 0, 0, 0, time.UTC) })
34 if err != nil {
35 t.Fatalf("append: %v", err)
36 }
37 sha, err := ledger.PutBlob(ctx, st, "bab", "platform", "carbon", []byte("blobby"))
38 if err != nil {
39 t.Fatalf("put blob: %v", err)
40 }
41 if err := ledger.WriteManifest(ctx, st, "bab", "platform",
42 ledger.Manifest{Public: public, Ledgers: []string{"carbon"}, Title: "CARLOS"}); err != nil {
43 t.Fatalf("write manifest: %v", err)
44 }
45 return a.Key, sha
46}
47
48func getStatus(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
49 t.Helper()
50 rr := httptest.NewRecorder()
51 h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "http://status.test"+path, nil))
52 return rr
53}
54
55// TestLedgerServingPublic: with public:true, every object the ledger package
56// writes is fetchable at the path that equals its bucket key, with the
57// content type a browser (and `carlos ledger verify -url`) expects.
58func TestLedgerServingPublic(t *testing.T) {
59 st := release.NewDirStore(t.TempDir())
60 entryKey, blobSHA := seedLedger(t, st, true)
61 h := newStatusHandler(st)
62
63 // [contentType, cacheControl]: the mutable pair carries no Cache-Control
64 // (freshness is the handler's own 60s TTL); the content-addressed pair
65 // carries the immutable year, because it is cached by clients and CDNs
66 // instead of in this process.
67 for path, want := range map[string][2]string{
68 "/ledgers/bab/platform/ledgers.json": {"application/json", ""},
69 "/ledgers/bab/platform/carbon/HEAD.json": {"application/json", ""},
70 "/" + entryKey: {"application/json", ledgerImmutableCacheControl},
71 "/ledgers/bab/platform/carbon/blobs/" + blobSHA: {"application/octet-stream", ledgerImmutableCacheControl},
72 } {
73 rr := getStatus(t, h, path)
74 if rr.Code != http.StatusOK {
75 t.Errorf("GET %s = %d, want 200", path, rr.Code)
76 continue
77 }
78 if ct := rr.Header().Get("Content-Type"); ct != want[0] {
79 t.Errorf("GET %s Content-Type = %q, want %q", path, ct, want[0])
80 }
81 if cc := rr.Header().Get("Cache-Control"); cc != want[1] {
82 t.Errorf("GET %s Cache-Control = %q, want %q", path, cc, want[1])
83 }
84 // Ledger bytes are arbitrary tenant content served from the status
85 // host's own origin: every shape must refuse content sniffing.
86 if xcto := rr.Header().Get("X-Content-Type-Options"); xcto != "nosniff" {
87 t.Errorf("GET %s X-Content-Type-Options = %q, want nosniff", path, xcto)
88 }
89 }
90 if body := getStatus(t, h, "/ledgers/bab/platform/carbon/blobs/"+blobSHA).Body.String(); body != "blobby" {
91 t.Errorf("blob body = %q, want %q", body, "blobby")
92 }
93}
94
95// TestLedgerServingFailClosed: every way of NOT being a published public
96// object answers the same plain 404 — a non-public app must be
97// indistinguishable from an app that does not exist.
98func TestLedgerServingFailClosed(t *testing.T) {
99 st := release.NewDirStore(t.TempDir())
100 entryKey, blobSHA := seedLedger(t, st, false) // chain exists, manifest says NOT public
101 // An app whose manifest exists but is unparseable must also fail closed.
102 if _, err := st.Put(context.Background(), ledger.ManifestKey("bab", "broken"),
103 []byte("{not json"), ""); err != nil {
104 t.Fatalf("seed broken manifest: %v", err)
105 }
106 h := newStatusHandler(st)
107
108 for _, path := range []string{
109 "/ledgers/bab/platform/carbon/HEAD.json",
110 "/ledgers/bab/platform/ledgers.json",
111 "/" + entryKey,
112 "/ledgers/bab/platform/carbon/blobs/" + blobSHA,
113 "/ledgers/bab/broken/ledgers.json", // manifest unparseable
114 "/ledgers/nope/nothing/carbon/HEAD.json", // no manifest at all
115 "/ledgers/bab/platform/carbon/../../secrets", // traversal shape
116 "/ledgers/bab", // too-short path
117 "/ledgers/bab/platform", // too-short path
118 "/ledgers/", // bare prefix
119 } {
120 rr := getStatus(t, h, path)
121 if rr.Code != http.StatusNotFound {
122 t.Errorf("GET %s = %d, want 404", path, rr.Code)
123 }
124 }
125
126 // Byte-identical bodies: the private app and the app that was never
127 // created must not be told apart by their 404s.
128 private := getStatus(t, h, "/ledgers/bab/platform/ledgers.json")
129 absent := getStatus(t, h, "/ledgers/nope/nothing/ledgers.json")
130 if private.Body.String() != absent.Body.String() {
131 t.Errorf("404 bodies differ: private %q vs absent %q", private.Body.String(), absent.Body.String())
132 }
133}
134
135// appendTo adds one entry to another ledger of the same app, so a test can
136// have a published and an unpublished chain side by side.
137func appendTo(t *testing.T, st release.Store, name string) string {
138 t.Helper()
139 a, err := ledger.Append(context.Background(), st, "bab", "platform", name, "w",
140 json.RawMessage(`{"n":2}`),
141 func() time.Time { return time.Date(2026, 8, 3, 22, 0, 0, 0, time.UTC) })
142 if err != nil {
143 t.Fatalf("append %s: %v", name, err)
144 }
145 return a.Key
146}
147
148// TestLedgerAllowlistIsPerLedger: Manifest.Ledgers is an allowlist, not an
149// index. An account accrues chains privately, so public:true on the app must
150// not publish every chain it has ever written — only the ones it names.
151func TestLedgerAllowlistIsPerLedger(t *testing.T) {
152 st := release.NewDirStore(t.TempDir())
153 seedLedger(t, st, true) // manifest publishes "carbon" only
154 ironEntry := appendTo(t, st, "iron")
155 ironSHA, err := ledger.PutBlob(context.Background(), st, "bab", "platform", "iron", []byte("ironblob"))
156 if err != nil {
157 t.Fatalf("put iron blob: %v", err)
158 }
159 h := newStatusHandler(st)
160
161 if rr := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json"); rr.Code != http.StatusOK {
162 t.Fatalf("published ledger = %d, want 200", rr.Code)
163 }
164 absent := getStatus(t, h, "/ledgers/nope/nothing/carbon/HEAD.json")
165 for _, path := range []string{
166 "/ledgers/bab/platform/iron/HEAD.json",
167 "/" + ironEntry,
168 "/ledgers/bab/platform/iron/blobs/" + ironSHA,
169 } {
170 rr := getStatus(t, h, path)
171 if rr.Code != http.StatusNotFound {
172 t.Errorf("unpublished ledger GET %s = %d, want 404", path, rr.Code)
173 continue
174 }
175 if rr.Body.String() != absent.Body.String() {
176 t.Errorf("GET %s 404 body %q differs from an absent app's %q",
177 path, rr.Body.String(), absent.Body.String())
178 }
179 }
180
181 // An empty list publishes nothing but the app's own index.
182 if err := ledger.WriteManifest(context.Background(), st, "bab", "platform",
183 ledger.Manifest{Public: true, Ledgers: nil, Title: "CARLOS"}); err != nil {
184 t.Fatalf("rewrite manifest: %v", err)
185 }
186 h2 := newStatusHandler(st) // fresh handler: don't wait out the 60s TTL
187 if rr := getStatus(t, h2, "/ledgers/bab/platform/ledgers.json"); rr.Code != http.StatusOK {
188 t.Errorf("ledgers.json with an empty list = %d, want 200 (the app's own index)", rr.Code)
189 }
190 for _, path := range []string{
191 "/ledgers/bab/platform/carbon/HEAD.json",
192 "/ledgers/bab/platform/iron/HEAD.json",
193 } {
194 if rr := getStatus(t, h2, path); rr.Code != http.StatusNotFound {
195 t.Errorf("GET %s with an empty allowlist = %d, want 404", path, rr.Code)
196 }
197 }
198}
199
200// TestLedgerBlobContentTypeComesFromShape: a blob is named by its sha256 and
201// nothing stops a tenant uploading one whose name ends ".json" — the content
202// type must come from the path SHAPE, not from a suffix probe on the key.
203func TestLedgerBlobContentTypeComesFromShape(t *testing.T) {
204 st := release.NewDirStore(t.TempDir())
205 seedLedger(t, st, true)
206 if _, err := st.Put(context.Background(),
207 "ledgers/bab/platform/carbon/blobs/deadbeef.json", []byte(`{"not":"json-typed"}`), ""); err != nil {
208 t.Fatalf("seed blob: %v", err)
209 }
210 rr := getStatus(t, newStatusHandler(st), "/ledgers/bab/platform/carbon/blobs/deadbeef.json")
211 if rr.Code != http.StatusOK {
212 t.Fatalf("blob = %d, want 200", rr.Code)
213 }
214 if ct := rr.Header().Get("Content-Type"); ct != "application/octet-stream" {
215 t.Errorf("blob Content-Type = %q, want application/octet-stream", ct)
216 }
217}
218
219// TestLedgerServingNeverLeavesPrefix: a public manifest buys access to that
220// app's own ledger subtree and nothing else — no encoded traversal reaches a
221// key outside it.
222func TestLedgerServingNeverLeavesPrefix(t *testing.T) {
223 st := release.NewDirStore(t.TempDir())
224 seedLedger(t, st, true)
225 if _, err := st.Put(context.Background(), "status/fleet.json", []byte(`{"fleet":true}`), ""); err != nil {
226 t.Fatalf("seed fleet: %v", err)
227 }
228 if _, err := st.Put(context.Background(), "apps/bab/platform/config.json", []byte(`{"secret":true}`), ""); err != nil {
229 t.Fatalf("seed app config: %v", err)
230 }
231 h := newStatusHandler(st)
232
233 for _, path := range []string{
234 "/ledgers/bab/platform/%2e%2e/%2e%2e/%2e%2e/status/fleet.json",
235 "/ledgers/bab/platform/../../../apps/bab/platform/config.json",
236 "/ledgers/bab/platform/carbon/%2e%2e/%2e%2e/%2e%2e/%2e%2e/apps/bab/platform/config.json",
237 "/ledgers/bab/platform//../../apps/bab/platform/config.json",
238 } {
239 rr := getStatus(t, h, path)
240 if rr.Code == http.StatusOK {
241 t.Errorf("GET %s = 200 %q — escaped the app's ledger subtree", path, rr.Body.String())
242 }
243 }
244}
245
246// TestLedgerServingOnlyLedgerShapes: even inside a public app's own subtree
247// the handler serves only the four object shapes the ledger primitive
248// defines; anything else that happens to be under the prefix stays private.
249func TestLedgerServingOnlyLedgerShapes(t *testing.T) {
250 st := release.NewDirStore(t.TempDir())
251 seedLedger(t, st, true)
252 ctx := context.Background()
253 for _, key := range []string{
254 "ledgers/bab/platform/notes.txt",
255 "ledgers/bab/platform/other.json",
256 "ledgers/bab/platform/carbon/notes.txt",
257 "ledgers/bab/platform/carbon/entries/raw.bin",
258 "ledgers/bab/platform/carbon/blobs/deep/nested",
259 } {
260 if _, err := st.Put(ctx, key, []byte("private"), ""); err != nil {
261 t.Fatalf("seed %s: %v", key, err)
262 }
263 }
264 h := newStatusHandler(st)
265 for _, key := range []string{
266 "ledgers/bab/platform/notes.txt",
267 "ledgers/bab/platform/other.json",
268 "ledgers/bab/platform/carbon/notes.txt",
269 "ledgers/bab/platform/carbon/entries/raw.bin",
270 "ledgers/bab/platform/carbon/blobs/deep/nested",
271 } {
272 if rr := getStatus(t, h, "/"+key); rr.Code != http.StatusNotFound {
273 t.Errorf("GET /%s = %d, want 404", key, rr.Code)
274 }
275 }
276}
277
278// TestLedgerServingMissingObjectIs404: under a public app, an object that
279// isn't there is a 404 — the 503 "nothing honest left to serve" contract
280// belongs to the fleet page's own three objects, not to ledger reads.
281func TestLedgerServingMissingObjectIs404(t *testing.T) {
282 st := release.NewDirStore(t.TempDir())
283 seedLedger(t, st, true)
284 h := newStatusHandler(st)
285 for _, path := range []string{
286 "/ledgers/bab/platform/nosuch/HEAD.json",
287 "/ledgers/bab/platform/carbon/entries/20260101T000000Z-deadbeefcafe.json",
288 "/ledgers/bab/platform/carbon/blobs/" + ledger.SHA256Hex([]byte("absent")),
289 } {
290 if rr := getStatus(t, h, path); rr.Code != http.StatusNotFound {
291 t.Errorf("GET %s = %d, want 404", path, rr.Code)
292 }
293 }
294}
295
296// cacheKeys snapshots the handler's in-memory cache under its own lock.
297func cacheKeys(h *statusHandler) map[string]bool {
298 h.mu.Lock()
299 defer h.mu.Unlock()
300 keys := make(map[string]bool, len(h.cache))
301 for k := range h.cache {
302 keys[k] = true
303 }
304 return keys
305}
306
307// TestLedgerCacheClasses is the memory-safety pin: a public ledger has
308// unboundedly many entries and blobs (blobs up to 4 MiB), so serving them
309// must NOT put anything in the handler's never-evicting cache — otherwise
310// anyone on the internet could pin arbitrary edge memory just by walking a
311// public chain. Only the mutable, bounded set (one ledgers.json per public
312// app, one HEAD.json per ledger) is cached.
313func TestLedgerCacheClasses(t *testing.T) {
314 dir := t.TempDir()
315 st := release.NewDirStore(dir)
316 entryKey, blobSHA := seedLedger(t, st, true)
317 h := newStatusHandler(st)
318
319 entryPath, blobPath := "/"+entryKey, "/ledgers/bab/platform/carbon/blobs/"+blobSHA
320 if rr := getStatus(t, h, entryPath); rr.Code != http.StatusOK {
321 t.Fatalf("GET %s = %d, want 200", entryPath, rr.Code)
322 }
323 if rr := getStatus(t, h, blobPath); rr.Code != http.StatusOK {
324 t.Fatalf("GET %s = %d, want 200", blobPath, rr.Code)
325 }
326 keys := cacheKeys(h)
327 if keys[entryKey] || keys["ledgers/bab/platform/carbon/blobs/"+blobSHA] {
328 t.Errorf("immutable objects were cached in-process: %v", keys)
329 }
330 // The gate itself is cached — that read is what bounds the map.
331 if want := ledger.ManifestKey("bab", "platform"); !keys[want] {
332 t.Errorf("manifest %s not cached: %v", want, keys)
333 }
334 if len(keys) != 1 {
335 t.Errorf("cache holds %d keys, want only the manifest: %v", len(keys), keys)
336 }
337
338 // Serving HEAD.json adds exactly one more key (the mutable class).
339 if rr := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json"); rr.Code != http.StatusOK {
340 t.Fatalf("GET HEAD.json = %d, want 200", rr.Code)
341 }
342 if keys = cacheKeys(h); len(keys) != 2 || !keys[ledger.HeadKey("bab", "platform", "carbon")] {
343 t.Errorf("after HEAD.json the cache holds %v, want manifest + HEAD", keys)
344 }
345
346 // A miss never grows the map: probing sqids that do not exist must not
347 // be a way to allocate memory in the edge (get only records an entry
348 // after a SUCCESSFUL store read).
349 for i, p := range []string{
350 "/ledgers/nope/nothing/ledgers.json",
351 "/ledgers/nope/nothing/carbon/HEAD.json",
352 "/ledgers/bab/platform/carbon/entries/20260101T000000Z-000000000000.json",
353 } {
354 if rr := getStatus(t, h, p); rr.Code != http.StatusNotFound {
355 t.Fatalf("probe %d (%s) = %d, want 404", i, p, rr.Code)
356 }
357 }
358 if after := cacheKeys(h); len(after) != 2 {
359 t.Errorf("probing grew the cache to %v", after)
360 }
361
362 // And because entries are not cached at all, removing the backing
363 // object is visible immediately — no stale copy is held anywhere in
364 // this process, even though the manifest gate is still cached.
365 if err := os.Remove(filepath.Join(dir, filepath.FromSlash(entryKey))); err != nil {
366 t.Fatalf("remove entry: %v", err)
367 }
368 if rr := getStatus(t, h, entryPath); rr.Code != http.StatusNotFound {
369 t.Errorf("removed entry = %d, want 404 (nothing cached in-process)", rr.Code)
370 }
371}
372
373// countingStore records how many times each key is read, so a test can
374// assert what a request COST rather than only what it answered.
375type countingStore struct {
376 release.Store
377 mu sync.Mutex
378 gets map[string]int
379}
380
381func newCountingStore(st release.Store) *countingStore {
382 return &countingStore{Store: st, gets: make(map[string]int)}
383}
384
385func (c *countingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
386 c.mu.Lock()
387 c.gets[key]++
388 c.mu.Unlock()
389 return c.Store.Get(ctx, key)
390}
391
392func (c *countingStore) count(key string) int {
393 c.mu.Lock()
394 defer c.mu.Unlock()
395 return c.gets[key]
396}
397
398// TestLedgerManifestMissesAreNegativeCached: status-kind routes are exempt
399// from the shared response cache, and get() only records an entry after a
400// SUCCESSFUL read — so without a negative cache every anonymous request to
401// /ledgers/<random>/<random>/x/HEAD.json costs exactly one bucket GET, and a
402// stranger with a loop is billing the deployment one S3 request per packet.
403// The decision "no readable public manifest here" is cached for the same 60s
404// the positive answer is.
405func TestLedgerManifestMissesAreNegativeCached(t *testing.T) {
406 st := newCountingStore(release.NewDirStore(t.TempDir()))
407 h := newStatusHandler(st)
408 h.ttl = 50 * time.Millisecond
409 fakeNow := time.Now()
410 h.now = func() time.Time { return fakeNow }
411
412 key := ledger.ManifestKey("nope", "nothing")
413 for i := range 5 {
414 if rr := getStatus(t, h, "/ledgers/nope/nothing/carbon/HEAD.json"); rr.Code != http.StatusNotFound {
415 t.Fatalf("probe %d = %d, want 404", i, rr.Code)
416 }
417 }
418 if got := st.count(key); got != 1 {
419 t.Fatalf("%s was read %d times for 5 probes, want 1 (the miss must be cached)", key, got)
420 }
421
422 // Different objects under the SAME app share the one decision — the
423 // gate is the app's manifest, so walking an app's key space is not a way
424 // around it.
425 for _, p := range []string{
426 "/ledgers/nope/nothing/ledgers.json",
427 "/ledgers/nope/nothing/carbon/entries/20260101T000000Z-000000000000.json",
428 "/ledgers/nope/nothing/carbon/blobs/" + ledger.SHA256Hex([]byte("x")),
429 } {
430 if rr := getStatus(t, h, p); rr.Code != http.StatusNotFound {
431 t.Fatalf("GET %s = %d, want 404", p, rr.Code)
432 }
433 }
434 if got := st.count(key); got != 1 {
435 t.Fatalf("%s was read %d times, want still 1", key, got)
436 }
437
438 // Past the TTL the decision is re-taken — publishing an app takes effect
439 // within one tick, exactly like the positive cache's staleness bound.
440 fakeNow = fakeNow.Add(51 * time.Millisecond)
441 if rr := getStatus(t, h, "/ledgers/nope/nothing/carbon/HEAD.json"); rr.Code != http.StatusNotFound {
442 t.Fatalf("post-TTL probe = %d, want 404", rr.Code)
443 }
444 if got := st.count(key); got != 2 {
445 t.Fatalf("%s read %d times after the TTL expired, want 2", key, got)
446 }
447
448 // A non-public app is the same cached decision: the manifest read that
449 // answered it is itself cached, so neither costs a second GET.
450 seedLedger(t, st, false)
451 pkey := ledger.ManifestKey("bab", "platform")
452 before := st.count(pkey)
453 for range 3 {
454 if rr := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json"); rr.Code != http.StatusNotFound {
455 t.Fatalf("private app = %d, want 404", rr.Code)
456 }
457 }
458 if got := st.count(pkey) - before; got > 1 {
459 t.Fatalf("a private app cost %d manifest reads for 3 probes, want at most 1", got)
460 }
461}
462
463// flakyStore fails every read while armed, with an error that is NOT
464// not-found — a bucket blip, a throttle, an expired credential — the class of
465// failure that resolves on its own.
466type flakyStore struct {
467 release.Store
468 mu sync.Mutex
469 broken bool
470}
471
472func (f *flakyStore) breakIt(b bool) {
473 f.mu.Lock()
474 defer f.mu.Unlock()
475 f.broken = b
476}
477
478func (f *flakyStore) Get(ctx context.Context, key string) ([]byte, string, error) {
479 f.mu.Lock()
480 broken := f.broken
481 f.mu.Unlock()
482 if broken {
483 return nil, "", errors.New("Throttling: slow down")
484 }
485 return f.Store.Get(ctx, key)
486}
487
488// TestLedgerTransientStoreErrorIsNotNegativeCached: the negative cache exists
489// to make PROBING cheap to refuse, and a probe's manifest is not-found. A
490// transient store error is a different thing entirely: caching it would pin a
491// public app dark for the full TTL because the bucket hiccuped once while the
492// cache happened to be cold — the exact dishonesty the positive cache's
493// stale-on-error half exists to avoid, reintroduced from the other side. The
494// blipped request still 404s (fail-closed: nothing readable said public), but
495// the decision is not remembered, so the next request retries.
496func TestLedgerTransientStoreErrorIsNotNegativeCached(t *testing.T) {
497 base := release.NewDirStore(t.TempDir())
498 st := &flakyStore{Store: base}
499 seedLedger(t, base, true)
500 h := newStatusHandler(st)
501
502 // Cold cache + broken store: 404, because nothing readable says public.
503 st.breakIt(true)
504 if rr := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json"); rr.Code != http.StatusNotFound {
505 t.Fatalf("during the blip = %d, want 404", rr.Code)
506 }
507 h.mu.Lock()
508 n := len(h.misses)
509 h.mu.Unlock()
510 if n != 0 {
511 t.Fatalf("a transient store error stamped %d negative-cache entries, want 0", n)
512 }
513
514 // The store recovers well inside the 60s TTL: the very next request must
515 // serve, not sit behind a cached refusal.
516 st.breakIt(false)
517 if rr := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json"); rr.Code != http.StatusOK {
518 t.Fatalf("after the store recovered = %d, want 200 (the blip must not be negatively cached)", rr.Code)
519 }
520}
521
522// TestLedgerMissCacheIsBounded: the attacker picks the account and app path
523// segments, so the set of distinct manifest keys a prober can name is
524// unbounded — a negative cache that grew with it would be the memory
525// exhaustion the immutable classes are deliberately uncached to avoid,
526// reintroduced through the back door.
527func TestLedgerMissCacheIsBounded(t *testing.T) {
528 st := release.NewDirStore(t.TempDir())
529 h := newStatusHandler(st)
530 for i := range statusMissCacheMax * 3 {
531 p := fmt.Sprintf("/ledgers/acct%d/app%d/carbon/HEAD.json", i, i)
532 if rr := getStatus(t, h, p); rr.Code != http.StatusNotFound {
533 t.Fatalf("probe %d = %d, want 404", i, rr.Code)
534 }
535 }
536 h.mu.Lock()
537 n := len(h.misses)
538 h.mu.Unlock()
539 if n > statusMissCacheMax {
540 t.Fatalf("negative cache holds %d entries, over the %d bound", n, statusMissCacheMax)
541 }
542 // The positive cache is untouched by any of it: nothing was ever read.
543 if keys := cacheKeys(h); len(keys) != 0 {
544 t.Fatalf("probing populated the positive cache: %v", keys)
545 }
546}
547
548// TestLedgerServingStaleManifestHonored pins the interaction with get()'s
549// stale-on-error half: the manifest is just another cached object, so once a
550// public manifest has been read, a store outage keeps the already-cached
551// objects serving (the alternative — 404ing a public ledger because the
552// bucket blipped — is the same dishonesty the status page's cache exists to
553// avoid). An object that was never cached still 404s: stale caching, not
554// fabrication.
555//
556// The outage is a flakyStore error, not a deleted directory: a deletion
557// answers os.ErrNotExist, which is the bucket saying the object is gone
558// (TestLedgerServingStopsAfterPurge), not a blip.
559func TestLedgerServingStaleManifestHonored(t *testing.T) {
560 base := release.NewDirStore(t.TempDir())
561 st := &flakyStore{Store: base}
562 entryKey, _ := seedLedger(t, base, true)
563 h := newStatusHandler(st)
564 h.ttl = 20 * time.Millisecond
565 fakeNow := time.Now()
566 h.now = func() time.Time { return fakeNow }
567
568 first := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json")
569 if first.Code != http.StatusOK {
570 t.Fatalf("first fetch = %d, want 200", first.Code)
571 }
572
573 // Break the store entirely and age both cached objects past the TTL.
574 st.breakIt(true)
575 fakeNow = fakeNow.Add(21 * time.Millisecond)
576
577 stale := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json")
578 if stale.Code != http.StatusOK || stale.Body.String() != first.Body.String() {
579 t.Fatalf("stale fetch = %d %q, want 200 %q", stale.Code, stale.Body.String(), first.Body.String())
580 }
581
582 // The entry was never cached: a stale-but-public manifest does not
583 // conjure a body for it.
584 if rr := getStatus(t, h, "/"+entryKey); rr.Code != http.StatusNotFound {
585 t.Errorf("uncached entry during outage = %d, want 404", rr.Code)
586 }
587}
588
589// TestLedgerServingStopsAfterPurge (#219 item 2): purging an app deletes
590// its ledgers from the bucket, and an edge that had cached the public
591// manifest must stop serving them once its TTL runs out, not fall back to
592// the stale copy as it would for a bucket blip. Otherwise a later app of the
593// same name would find the purged app's history public on that edge.
594func TestLedgerServingStopsAfterPurge(t *testing.T) {
595 st := release.NewDirStore(t.TempDir())
596 seedLedger(t, st, true)
597 h := newStatusHandler(st)
598 h.ttl = 20 * time.Millisecond
599 fakeNow := time.Now()
600 h.now = func() time.Time { return fakeNow }
601
602 for _, p := range []string{"/ledgers/bab/platform/ledgers.json", "/ledgers/bab/platform/carbon/HEAD.json"} {
603 if rr := getStatus(t, h, p); rr.Code != http.StatusOK {
604 t.Fatalf("before the purge %s = %d, want 200", p, rr.Code)
605 }
606 }
607 keys, err := st.List(context.Background(), release.LedgerAppPrefix("bab", "platform"))
608 if err != nil {
609 t.Fatal(err)
610 }
611 for _, k := range keys {
612 if err := st.Delete(context.Background(), k); err != nil {
613 t.Fatal(err)
614 }
615 }
616 fakeNow = fakeNow.Add(21 * time.Millisecond)
617 for _, p := range []string{"/ledgers/bab/platform/ledgers.json", "/ledgers/bab/platform/carbon/HEAD.json"} {
618 if rr := getStatus(t, h, p); rr.Code != http.StatusNotFound {
619 t.Errorf("after the purge %s = %d, want 404", p, rr.Code)
620 }
621 }
622 if keys := cacheKeys(h); keys["ledgers/bab/platform/ledgers.json"] {
623 t.Errorf("the purged manifest is still cached: %v", keys)
624 }
625}
626
627// statusEdgeWithCache wires a full Edge (Host dispatch, shared response
628// cache) in front of one status route — testEdgeWithStore's shape, but
629// returning the *Edge so a test can look inside e.cache.
630func statusEdgeWithCache(t *testing.T) (release.Store, *Edge) {
631 t.Helper()
632 reg := openReg(t)
633 if err := reg.Upsert(registry.Route{Host: "status.test", Kind: "status", Status: "ready"}); err != nil {
634 t.Fatalf("upsert: %v", err)
635 }
636 e, err := New(reg, nil)
637 if err != nil {
638 t.Fatalf("New: %v", err)
639 }
640 store := release.NewDirStore(filepath.Join(t.TempDir(), "store"))
641 _, pubB64, err := release.GenerateKey()
642 if err != nil {
643 t.Fatalf("generate key: %v", err)
644 }
645 pub, err := release.ParsePublicKey(pubB64, "test")
646 if err != nil {
647 t.Fatalf("parse public key: %v", err)
648 }
649 e.EnableStatic(store, pub, filepath.Join(t.TempDir(), "cache"))
650 e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
651 if err := e.Reload(); err != nil {
652 t.Fatalf("Reload: %v", err)
653 }
654 return store, e
655}
656
657// TestStatusResponsesNeverEnterSharedCache: the shared response cache is
658// process-global and shared across every host, and a public ledger chain is
659// unbounded in length — so a stranger walking one must not be able to admit
660// (and thereby evict) anything in it. An evicted entry for a hibernating
661// route is a wake that the cache existed to prevent. The client-facing
662// immutable Cache-Control still goes out: browsers and CDNs do the caching.
663func TestStatusResponsesNeverEnterSharedCache(t *testing.T) {
664 store, e := statusEdgeWithCache(t)
665 entryKey, blobSHA := seedLedger(t, store, true)
666 if _, err := store.Put(context.Background(), "status/fleet.json", []byte(`{"ok":true}`), ""); err != nil {
667 t.Fatalf("seed fleet: %v", err)
668 }
669
670 for _, path := range []string{
671 "/" + entryKey,
672 "/ledgers/bab/platform/carbon/blobs/" + blobSHA,
673 "/ledgers/bab/platform/carbon/HEAD.json",
674 "/ledgers/bab/platform/ledgers.json",
675 "/fleet.json",
676 } {
677 rec := httptest.NewRecorder()
678 e.ServeHTTP(rec, httptest.NewRequest("GET", "http://status.test"+path, nil))
679 if rec.Code != http.StatusOK {
680 t.Fatalf("GET %s = %d, want 200", path, rec.Code)
681 }
682 if _, ok := e.cache.get(CacheKey{Host: "status.test", Method: "GET", Path: path}); ok {
683 t.Errorf("GET %s entered the shared edge response cache", path)
684 }
685 }
686
687 // The header a client sees is unchanged — only the edge's own admission
688 // is refused.
689 rec := httptest.NewRecorder()
690 e.ServeHTTP(rec, httptest.NewRequest("GET", "http://status.test/"+entryKey, nil))
691 if cc := rec.Header().Get("Cache-Control"); cc != ledgerImmutableCacheControl {
692 t.Errorf("entry Cache-Control = %q, want %q", cc, ledgerImmutableCacheControl)
693 }
694}
695
696// TestLedgerBranchLeavesFleetRoutesAlone: the /ledgers/ prefix branch must
697// not intercept the three fleet paths, which keep their own 503-when-nothing-
698// cached contract.
699func TestLedgerBranchLeavesFleetRoutesAlone(t *testing.T) {
700 st := release.NewDirStore(t.TempDir())
701 seedLedger(t, st, true)
702 h := newStatusHandler(st)
703 for _, path := range []string{"/", "/fleet.json", "/status.css"} {
704 if rr := getStatus(t, h, path); rr.Code != http.StatusServiceUnavailable {
705 t.Errorf("GET %s = %d, want 503 (nothing published)", path, rr.Code)
706 }
707 }
708 if _, err := st.Put(context.Background(), "status/fleet.json", []byte(`{"ok":true}`), ""); err != nil {
709 t.Fatalf("seed fleet: %v", err)
710 }
711 rr := getStatus(t, h, "/fleet.json")
712 if rr.Code != http.StatusOK || rr.Body.String() != `{"ok":true}` {
713 t.Errorf("GET /fleet.json = %d %q, want 200 fleet body", rr.Code, rr.Body.String())
714 }
715 if ct := rr.Header().Get("Content-Type"); ct != "application/json" {
716 t.Errorf("GET /fleet.json Content-Type = %q", ct)
717 }
718}
719