| new file mode 100644 |
| index 000000000..34d8b197a |
| --- /dev/null |
| +++ b/internal/edge/status_test.go |
| @@ -0,0 +1,256 @@ |
| +// 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" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/ledger" |
| + "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) |
| + |
| + for path, wantCT := range map[string]string{ |
| + "/ledgers/bab/platform/ledgers.json": "application/json", |
| + "/ledgers/bab/platform/carbon/HEAD.json": "application/json", |
| + "/" + entryKey: "application/json", |
| + "/ledgers/bab/platform/carbon/blobs/" + blobSHA: "application/octet-stream", |
| + } { |
| + 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 != wantCT { |
| + t.Errorf("GET %s Content-Type = %q, want %q", path, ct, wantCT) |
| + } |
| + } |
| + 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()) |
| + } |
| +} |
| + |
| +// 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) |
| + } |
| + } |
| +} |
| + |
| +// 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. |
| +func TestLedgerServingStaleManifestHonored(t *testing.T) { |
| + dir := t.TempDir() |
| + st := release.NewDirStore(dir) |
| + entryKey, _ := seedLedger(t, st, 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. |
| + if err := os.RemoveAll(dir); err != nil { |
| + t.Fatalf("break store: %v", err) |
| + } |
| + 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) |
| + } |
| +} |
| + |
| +// 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) |
| + } |
| +} |