package console // The ledger CLI API (apiledger.go): the ONLY write path to ledgers/ from // outside a box. The properties that matter, in the order they'd hurt if // lost: the writer stamped on an entry is the AUTHENTICATED identity and // nothing a client sent, every route is member-gated through apiAccountApp // (so an unknown account and someone else's are the identical 404), and the // caps — 64 KiB payload, 4 MiB blob, a validated ledger name — are enforced // before anything touches the store. import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "slices" "strings" "testing" "time" "github.com/carlosframework/platform/internal/ledger" "github.com/carlosframework/platform/internal/release" ) // postBody is postJSON's raw-body cousin: these tests send bodies that are // deliberately not round-trippable through json.Marshal (an oversize // payload, a body carrying a field the server must ignore), and read the // response as bytes so it can be decoded into a typed struct. func postBody(t *testing.T, u, bearer, body string) (int, []byte) { t.Helper() req, err := http.NewRequest(http.MethodPost, u, strings.NewReader(body)) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/json") if bearer != "" { req.Header.Set("Authorization", "Bearer "+bearer) } res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer res.Body.Close() out, err := io.ReadAll(res.Body) if err != nil { t.Fatal(err) } return res.StatusCode, out } // ledgerFixture is a console with titogo claimed in the ops account and a // MEMBER (not owner) token for it — member-level is exactly the gate this // API grants, the same one promote uses. func ledgerFixture(t *testing.T, code string) (*consoleFixture, string) { t.Helper() fx := newConsoleFixture(t) now := time.Now() if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { t.Fatal(err) } if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { t.Fatal(err) } return fx, cliToken(t, fx, "alice@keymail.dev", code) } // TestAPILedgerAppendAndHead: an append round-trips through HEAD, and the // writer on the stored entry is the token's identity — never anything the // client put in the body (the request below tries to set one). func TestAPILedgerAppendAndHead(t *testing.T) { fx, token := ledgerFixture(t, "code-ledger-append") status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, `{"payload":{"kg_central":1},"writer":"attacker@example.com"}`) if status != http.StatusOK { t.Fatalf("append = %d %s", status, out) } var a ledger.Appended if err := json.Unmarshal(out, &a); err != nil { t.Fatalf("append body %s: %v", out, err) } if a.Count != 1 || a.Key == "" || a.SHA256 == "" { t.Fatalf("append = %+v, want count 1 with a key and sha", a) } status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/head", token) if status != http.StatusOK { t.Fatalf("head = %d %s", status, body) } var h ledger.Head if err := json.Unmarshal([]byte(body), &h); err != nil { t.Fatalf("head body %s: %v", body, err) } if h.HeadKey != a.Key || h.HeadSHA256 != a.SHA256 || h.Count != 1 { t.Fatalf("head = %+v, want it to name the appended entry %+v", h, a) } // The writer is the token's identity, stamped server-side. The body's // own "writer" field must have gone nowhere. eb, _, err := fx.rel.Get(context.Background(), a.Key) if err != nil { t.Fatalf("stored entry %s: %v", a.Key, err) } var e ledger.Entry if err := json.Unmarshal(eb, &e); err != nil { t.Fatal(err) } if e.Writer != "alice@keymail.dev" { t.Fatalf("entry writer = %q, want the authenticated identity alice@keymail.dev (%+v)", e.Writer, e) } if bytes.Contains(eb, []byte("attacker@example.com")) { t.Fatalf("stored entry carries a client-supplied writer: %s", eb) } } // TestAPILedgerAuthz: no token is the same 401 every CLI door gives, and a // non-member — whether the account is one they don't belong to or an app it // never claimed — gets the byte-identical 404 apiAccountApp already // produces, on the WRITE routes too. func TestAPILedgerAuthz(t *testing.T) { fx, _ := ledgerFixture(t, "code-ledger-authz") status, _ := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", "", `{"payload":1}`) if status != http.StatusUnauthorized { t.Fatalf("append with no token = %d, want 401", status) } // (a) member of nothing, probing a real account+app. eve := cliToken(t, fx, "eve@keymail.dev", "code-ledger-eve") statusA, bodyA := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", eve, `{"payload":1}`) if statusA != http.StatusNotFound { t.Fatalf("append as non-member = %d, want 404", statusA) } // (b) member of bab, but an app it never claimed. if err := fx.st.AddMember(OpsAccountID, "frank@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } frank := cliToken(t, fx, "frank@keymail.dev", "code-ledger-frank") statusB, bodyB := postBody(t, fx.ts.URL+"/api/cli/apps/bab/ghost-app/ledgers/carbon/append", frank, `{"payload":1}`) if statusB != http.StatusNotFound { t.Fatalf("append against an unclaimed app = %d, want 404", statusB) } if !bytes.Equal(bodyA, bodyB) { t.Fatalf("the two refusals are not byte-identical:\n(a) %q\n(b) %q", bodyA, bodyB) } if string(bodyA) != "404 page not found\n" { t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", bodyA) } // Nothing was written by any of the refused calls. if _, _, err := fx.rel.Get(context.Background(), ledger.HeadKey("bab", "titogo", "carbon")); err == nil { t.Fatalf("a ledger HEAD exists after only refused appends, want none") } // The read routes refuse identically. if st, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers", eve); st != http.StatusNotFound || body != "404 page not found\n" { t.Fatalf("list as non-member = %d %q, want the identical 404", st, body) } if st, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/head", eve); st != http.StatusNotFound || body != "404 page not found\n" { t.Fatalf("head as non-member = %d %q, want the identical 404", st, body) } } // TestAPILedgerRejects: a name that isn't a ledger name, and a payload over // the 64 KiB cap, are both refused before anything lands in the store. func TestAPILedgerRejects(t *testing.T) { fx, token := ledgerFixture(t, "code-ledger-rejects") // Bad ledger name in the path — uppercase and a space. status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/Not%20Valid/append", token, `{"payload":1}`) if status != http.StatusBadRequest { t.Fatalf("bad ledger name = %d %s, want 400", status, out) } // A traversal attempt in the name segment, which would otherwise become // bucket key segments. status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/..%2f..%2fetc/append", token, `{"payload":1}`) if status != http.StatusBadRequest && status != http.StatusNotFound { t.Fatalf("traversal ledger name = %d %s, want a refusal", status, out) } // Oversized payload: over the cap, but under MaxBytesReader's limit, so // this is the handler's own arithmetic refusing it, not the reader's. big := `{"payload":"` + strings.Repeat("x", ledger.MaxPayload) + `"}` status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, big) if status != http.StatusBadRequest && status != http.StatusRequestEntityTooLarge { t.Fatalf("oversize payload = %d %s, want 400 or 413", status, out) } // Far oversized: MaxBytesReader cuts this one off. huge := `{"payload":"` + strings.Repeat("x", ledger.MaxPayload+64<<10) + `"}` status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, huge) if status != http.StatusBadRequest && status != http.StatusRequestEntityTooLarge { t.Fatalf("far-oversize payload = %d %s, want 400 or 413", status, out) } // A body with no payload at all. status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, `{}`) if status != http.StatusBadRequest { t.Fatalf("missing payload = %d %s, want 400", status, out) } // None of the refusals created a chain. if _, _, err := fx.rel.Get(context.Background(), ledger.HeadKey("bab", "titogo", "carbon")); err == nil { t.Fatalf("carbon HEAD exists after only refused appends, want none") } } // TestAPILedgerPublishAndList: the opt-in manifest round-trips, and the // listing names both the manifest and the ledgers that actually exist — // derived from the HEAD keys in the bucket, not from the manifest. func TestAPILedgerPublishAndList(t *testing.T) { fx, token := ledgerFixture(t, "code-ledger-publish") // A write happens with NO manifest present: opt-in gates serving, not // writing (spec §2.3). if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, `{"payload":{"n":1}}`); status != http.StatusOK { t.Fatalf("append before any publish = %d %s, want 200 (opt-in gates serving, not writing)", status, out) } // Before publishing, the manifest reads back null and the ledger is // still listed. status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers", token) if status != http.StatusOK { t.Fatalf("list before publish = %d %s", status, body) } var pre struct { Manifest *ledger.Manifest `json:"manifest"` Ledgers []string `json:"ledgers"` } if err := json.Unmarshal([]byte(body), &pre); err != nil { t.Fatalf("list body %s: %v", body, err) } if pre.Manifest != nil { t.Fatalf("manifest before publish = %+v, want null", pre.Manifest) } if len(pre.Ledgers) != 1 || pre.Ledgers[0] != "carbon" { t.Fatalf("ledgers before publish = %v, want [carbon]", pre.Ledgers) } status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token, `{"public":true,"ledgers":["carbon"],"title":"Titogo"}`) if status != http.StatusOK { t.Fatalf("publish = %d %s", status, out) } // A second entry and a blob under the same chain: the listing is // delimiter-aware, so neither the entries/ nor the blobs/ directory may // surface as a ledger name, however much history accrues. if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, `{"payload":{"n":2}}`); status != http.StatusOK { t.Fatalf("second append = %d %s", status, out) } if _, err := fx.rel.Put(context.Background(), ledger.BlobKey("bab", "titogo", "carbon", strings.Repeat("a", 64)), []byte("x"), ""); err != nil { t.Fatal(err) } status, body = getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers", token) if status != http.StatusOK || !strings.Contains(body, `"carbon"`) { t.Fatalf("list = %d %s, want carbon among the ledgers", status, body) } var post struct { Manifest *ledger.Manifest `json:"manifest"` Ledgers []string `json:"ledgers"` } if err := json.Unmarshal([]byte(body), &post); err != nil { t.Fatalf("list body %s: %v", body, err) } if post.Manifest == nil || !post.Manifest.Public || post.Manifest.Title != "Titogo" || len(post.Manifest.Ledgers) != 1 || post.Manifest.Ledgers[0] != "carbon" { t.Fatalf("manifest after publish = %+v, want the published one", post.Manifest) } if post.Manifest.Schema != ledger.Schema { t.Fatalf("manifest schema = %d, want %d (stamped server-side)", post.Manifest.Schema, ledger.Schema) } if len(post.Ledgers) != 1 || post.Ledgers[0] != "carbon" { t.Fatalf("ledgers = %v, want exactly [carbon] — entries/ and blobs/ are not ledgers", post.Ledgers) } // Publishing is a visibility change, so it leaves an audit line naming // who did it, the flag, and the ledgers — the same trail promote, // rollback and ship write to. events, err := fx.st.Events(OpsAccountID, "titogo", 10) if err != nil { t.Fatal(err) } var pub *Event for i := range events { if events[i].Action == "ledger-publish" { pub = &events[i] } } if pub == nil { t.Fatalf("events = %v, want a ledger-publish row", events) } if pub.Address != "alice@keymail.dev" { t.Fatalf("ledger-publish address = %q, want the authenticated identity", pub.Address) } if !strings.Contains(pub.Detail, "public=true") || !strings.Contains(pub.Detail, "carbon") || !strings.Contains(pub.Detail, "(cli)") { t.Fatalf("ledger-publish detail = %q, want the flag, the ledgers and the CLI marker", pub.Detail) } // A manifest naming a ledger that isn't a ledger name is refused. if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token, `{"public":true,"ledgers":["Not Valid"]}`); status != http.StatusBadRequest { t.Fatalf("publish with a bad ledger name = %d %s, want 400", status, out) } } // publishLedgers posts one publish call and decodes the RESULTING manifest // the handler answers with — the shape a CLI needs in order to tell the user // what is public now rather than what they just asked for. func publishLedgers(t *testing.T, fx *consoleFixture, token, body string) (int, ledger.Manifest) { t.Helper() status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token, body) var m ledger.Manifest if status == http.StatusOK { if err := json.Unmarshal(out, &m); err != nil { t.Fatalf("publish body %s: %v", out, err) } } return status, m } // TestAPILedgerPublishIsAdditive is the defect this handler was written // around: an app's manifest is a SET of published ledgers accrued over // separate publish calls, and the edge reads that set as a per-ledger // ALLOWLIST. A publish that replaced the whole manifest would silently // unpublish every ledger the caller didn't name in this one call — while the // CLI printed a success line — and would make publishing two ledgers // impossible one call at a time. func TestAPILedgerPublishIsAdditive(t *testing.T) { fx, token := ledgerFixture(t, "code-ledger-additive") status, m := publishLedgers(t, fx, token, `{"public":true,"ledgers":["carbon"],"title":"Titogo"}`) if status != http.StatusOK { t.Fatalf("first publish = %d", status) } if !m.Public || len(m.Ledgers) != 1 || m.Ledgers[0] != "carbon" || m.Title != "Titogo" { t.Fatalf("first publish = %+v", m) } // Publishing a SECOND ledger keeps the first, and an omitted title keeps // the stored one (a caller publishing one more chain is not asking to // erase the app's title). status, m = publishLedgers(t, fx, token, `{"public":true,"ledgers":["prompts"]}`) if status != http.StatusOK { t.Fatalf("second publish = %d", status) } if len(m.Ledgers) != 2 || m.Ledgers[0] != "carbon" || m.Ledgers[1] != "prompts" { t.Fatalf("second publish = %+v, want [carbon prompts] in order", m.Ledgers) } if m.Title != "Titogo" { t.Fatalf("title = %q, want the stored title preserved when the request omits one", m.Title) } // Re-publishing one that is already public is idempotent — no duplicate. if _, m = publishLedgers(t, fx, token, `{"public":true,"ledgers":["carbon"]}`); len(m.Ledgers) != 2 { t.Fatalf("re-publish = %+v, want no duplicate", m.Ledgers) } // A non-empty title replaces the stored one. if _, m = publishLedgers(t, fx, token, `{"public":true,"ledgers":["carbon"],"title":"Tito"}`); m.Title != "Tito" { t.Fatalf("title = %q, want the new one", m.Title) } // The store agrees with what the handler reported. stored, err := ledger.ReadManifest(context.Background(), fx.rel, "bab", "titogo") if err != nil { t.Fatal(err) } if len(stored.Ledgers) != 2 || stored.Title != "Tito" || !stored.Public { t.Fatalf("stored manifest = %+v, want it to match the reported state", stored) } } // TestAPILedgerUnpublishRemovesOne: unpublish is the mirror — it removes the // named ledgers and flips public off ONLY when nothing is left. Unpublishing // one of two chains must not take the other one dark. func TestAPILedgerUnpublishRemovesOne(t *testing.T) { fx, token := ledgerFixture(t, "code-ledger-unpublish") if status, _ := publishLedgers(t, fx, token, `{"public":true,"ledgers":["carbon","prompts"],"title":"T"}`); status != http.StatusOK { t.Fatalf("publish = %d", status) } status, m := publishLedgers(t, fx, token, `{"public":false,"ledgers":["carbon"]}`) if status != http.StatusOK { t.Fatalf("unpublish = %d", status) } if !m.Public { t.Fatalf("unpublish of one of two = %+v, want public still true", m) } if len(m.Ledgers) != 1 || m.Ledgers[0] != "prompts" { t.Fatalf("unpublish left %v, want [prompts]", m.Ledgers) } // Unpublishing the last one empties the list AND flips public off, so // the app's own ledgers.json index stops serving too. status, m = publishLedgers(t, fx, token, `{"public":false,"ledgers":["prompts"]}`) if status != http.StatusOK { t.Fatalf("final unpublish = %d", status) } if m.Public || len(m.Ledgers) != 0 { t.Fatalf("final unpublish = %+v, want public:false and an empty list", m) } if m.Title != "T" { t.Fatalf("title = %q, want it kept — unpublishing is not forgetting", m.Title) } } // TestAPILedgerUnpublishWithoutManifest: unpublishing an app that never // published is a no-op success, not a 404 and not a manifest conjured out of // nothing. func TestAPILedgerUnpublishWithoutManifest(t *testing.T) { fx, token := ledgerFixture(t, "code-ledger-unpublish-none") status, m := publishLedgers(t, fx, token, `{"public":false,"ledgers":["carbon"]}`) if status != http.StatusOK { t.Fatalf("unpublish with no manifest = %d, want 200", status) } if m.Public || len(m.Ledgers) != 0 { t.Fatalf("= %+v, want the zero manifest", m) } if _, _, err := fx.rel.Get(context.Background(), ledger.ManifestKey("bab", "titogo")); err == nil { t.Fatal("a no-op unpublish wrote a manifest, want none") } } // TestAPILedgerPublishTitleCap: the title is member-supplied text served // publicly at ledgers.json, so it is bounded at the write door — the same // place every other public-facing input on this API is bounded. func TestAPILedgerPublishTitleCap(t *testing.T) { fx, token := ledgerFixture(t, "code-ledger-title") long := `{"public":true,"ledgers":["carbon"],"title":"` + strings.Repeat("t", 121) + `"}` status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token, long) if status != http.StatusBadRequest { t.Fatalf("oversize title = %d %s, want 400", status, out) } if !strings.Contains(string(out), "title too long") { t.Fatalf("refusal = %s, want it to name the cap", out) } if _, _, err := fx.rel.Get(context.Background(), ledger.ManifestKey("bab", "titogo")); err == nil { t.Fatal("a refused publish wrote a manifest") } // Exactly at the cap is fine, and surrounding whitespace is trimmed // rather than counted. at := strings.Repeat("t", 120) status, m := publishLedgers(t, fx, token, `{"public":true,"ledgers":["carbon"],"title":" `+at+` "}`) if status != http.StatusOK { t.Fatalf("title at the cap = %d, want 200", status) } if m.Title != at { t.Fatalf("title = %q, want it trimmed to the %d-byte value", m.Title, len(at)) } } // TestAPILedgerPublishCountCap: the manifest became read-modify-write, which // removed the implicit bound the 8 KiB request cap used to give a whole-value // replace — names now ACCUMULATE. ledgers.json is member-supplied content // served publicly at the edge, held in the edge's in-memory cache and // linearly scanned on every ledger request, so the set is bounded at the // write door, on the RESULTING manifest, like every other public-facing input // here. func TestAPILedgerPublishCountCap(t *testing.T) { fx, token := ledgerFixture(t, "code-ledger-count") names := make([]string, 0, maxLedgerCount) for i := range maxLedgerCount { names = append(names, fmt.Sprintf("l%d", i)) } full, err := json.Marshal(map[string]any{"public": true, "ledgers": names, "title": "T"}) if err != nil { t.Fatal(err) } status, m := publishLedgers(t, fx, token, string(full)) if status != http.StatusOK { t.Fatalf("publish of %d ledgers = %d, want 200 (the cap is a ceiling, not a fence below it)", maxLedgerCount, status) } if len(m.Ledgers) != maxLedgerCount { t.Fatalf("published %d ledgers, want %d", len(m.Ledgers), maxLedgerCount) } // The one that would step over is refused as the caller's fault, and // nothing new is written: not the name, not the title that rode with it. status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token, `{"public":true,"ledgers":["overflow"],"title":"nope"}`) if status != http.StatusBadRequest { t.Fatalf("publish over the cap = %d %s, want 400", status, out) } if !strings.Contains(string(out), "too many public ledgers") { t.Fatalf("refusal = %s, want it to name the cap", out) } stored, err := ledger.ReadManifest(context.Background(), fx.rel, "bab", "titogo") if err != nil { t.Fatal(err) } if len(stored.Ledgers) != maxLedgerCount || slices.Contains(stored.Ledgers, "overflow") { t.Fatalf("stored manifest = %+v, want the refused publish to have written nothing", stored) } if stored.Title != "T" { t.Fatalf("title = %q, want the refused publish to have left it alone", stored.Title) } // Re-publishing one that is ALREADY on the list at the cap is fine — the // bound is on the resulting set, and that set does not grow. if status, m := publishLedgers(t, fx, token, `{"public":true,"ledgers":["l0"]}`); status != http.StatusOK || len(m.Ledgers) != maxLedgerCount { t.Fatalf("idempotent re-publish at the cap = %d %+v, want 200 and no growth", status, m.Ledgers) } // And the way back down is never blocked: an app that hit the cap must // still be able to unpublish. status, m = publishLedgers(t, fx, token, `{"public":false,"ledgers":["l0"]}`) if status != http.StatusOK { t.Fatalf("unpublish at the cap = %d, want 200", status) } if len(m.Ledgers) != maxLedgerCount-1 || slices.Contains(m.Ledgers, "l0") { t.Fatalf("unpublish at the cap left %v", m.Ledgers) } } // TestAPILedgerHeadMissing: a ledger that was never written is the same // stdlib 404 as an app you can't see — there is nothing to distinguish. func TestAPILedgerHeadMissing(t *testing.T) { fx, token := ledgerFixture(t, "code-ledger-head-missing") status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/nothing-here/head", token) if status != http.StatusNotFound { t.Fatalf("head of an unwritten ledger = %d %q, want 404", status, body) } } // TestAPILedgerBlob: raw bytes upload content-addressed, the returned sha // is what the bytes hash to, and a re-upload of the same bytes is the same // answer. func TestAPILedgerBlob(t *testing.T) { fx, token := ledgerFixture(t, "code-ledger-blob") req, err := http.NewRequest(http.MethodPost, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/prompts/blobs", bytes.NewReader([]byte("corpus"))) if err != nil { t.Fatal(err) } req.Header.Set("Authorization", "Bearer "+token) res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer res.Body.Close() raw, err := io.ReadAll(res.Body) if err != nil { t.Fatal(err) } if res.StatusCode != http.StatusOK { t.Fatalf("blob = %d %s", res.StatusCode, raw) } var out struct { SHA256 string `json:"sha256"` } if err := json.Unmarshal(raw, &out); err != nil { t.Fatalf("blob body %s: %v", raw, err) } want := ledger.SHA256Hex([]byte("corpus")) if out.SHA256 != want { t.Fatalf("blob sha256 = %q, want %q (computed from the bytes sent)", out.SHA256, want) } stored, _, err := fx.rel.Get(context.Background(), ledger.BlobKey("bab", "titogo", "prompts", want)) if err != nil { t.Fatalf("stored blob: %v", err) } if !bytes.Equal(stored, []byte("corpus")) { t.Fatalf("stored blob = %q, want the bytes sent", stored) } // A bad ledger name in the path refuses before the body is read. if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/Not%20Valid/blobs", token, "corpus"); status != http.StatusBadRequest { t.Fatalf("blob with a bad ledger name = %d %s, want 400", status, out) } } // TestAPILedgerWritesAreRateLimited: every WRITE door here passes the same // write budget its promote/rollback/new-account siblings do, keyed on the // address. Without it, the one API that appends an object per call is the // one API with no bound on how fast a token can fill a bucket. func TestAPILedgerWritesAreRateLimited(t *testing.T) { fx, token := ledgerFixture(t, "code-ledger-ratelimit") limited := false for i := 0; i < cliWriteBurst+3; i++ { status, _ := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, `{"payload":{"n":1}}`) if status == http.StatusTooManyRequests { limited = true break } } if !limited { t.Fatalf("append was never rate-limited in %d attempts", cliWriteBurst+3) } // The budget is shared across the write doors — an exhausted address // cannot simply switch verb. (Same limiter, same key: this is what // proves publish and blobs pass it too, not just append.) if status, _ := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token, `{"public":true,"ledgers":["carbon"]}`); status != http.StatusTooManyRequests { t.Fatalf("publish on an exhausted budget = %d, want 429", status) } req, err := http.NewRequest(http.MethodPost, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/prompts/blobs", bytes.NewReader([]byte("corpus"))) if err != nil { t.Fatal(err) } req.Header.Set("Authorization", "Bearer "+token) res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusTooManyRequests { t.Fatalf("blob on an exhausted budget = %d, want 429", res.StatusCode) } // The READ doors are unlimited, like their read-only siblings — an // exhausted write budget must not blind a CLI to what it already wrote. if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers", token); status != http.StatusOK { t.Fatalf("list on an exhausted write budget = %d %s, want 200", status, body) } if status, _ := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/head", token); status != http.StatusOK { t.Fatalf("head on an exhausted write budget = %d, want 200", status) } } // casStore loses every HEAD race, the way a chain under more concurrent // writers than the retry loop can outlast behaves. type casStore struct{ release.Store } func (c casStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { if strings.HasSuffix(key, "/HEAD.json") || strings.HasSuffix(key, "/ledgers.json") { return "", release.ErrCASConflict } return c.Store.Put(ctx, key, body, ifMatch) } // brokenStore is a bucket the console cannot reach, whose error text is // exactly the kind that must never reach a client. type brokenStore struct{ release.Store } const brokenStoreErr = "AccessDenied: s3://carlos-prod-secret-bucket/ledgers request id ABC123" func (b brokenStore) Put(context.Context, string, []byte, string) (string, error) { return "", errors.New(brokenStoreErr) } func (b brokenStore) ListDirs(context.Context, string) ([]string, error) { return nil, errors.New(brokenStoreErr) } // TestAPILedgerStoreFailureStatuses: a failure that is not the caller's // fault must not be reported as if it were. Contention is retry-worthy // (503), a broken bucket is not (500), and neither answer may echo the // store's own error text — bucket names and request ids are for the log. func TestAPILedgerStoreFailureStatuses(t *testing.T) { t.Run("contention is 503", func(t *testing.T) { fx, token := ledgerFixture(t, "code-ledger-busy") fx.srv.cfg.Releases = casStore{fx.rel} status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, `{"payload":{"n":1}}`) if status != http.StatusServiceUnavailable { t.Fatalf("append under total contention = %d %s, want 503", status, out) } if !strings.Contains(string(out), "try again") { t.Fatalf("contention error = %s, want it to invite a retry", out) } if strings.Contains(string(out), "CAS") || strings.Contains(string(out), "HEAD.json") { t.Fatalf("contention error leaks store internals: %s", out) } // Publish's own lost race is the same retry-worthy answer. if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token, `{"public":true,"ledgers":["carbon"]}`); status != http.StatusServiceUnavailable { t.Fatalf("publish under total contention = %d %s, want 503", status, out) } }) t.Run("a broken bucket is 500 and says nothing", func(t *testing.T) { fx, token := ledgerFixture(t, "code-ledger-broken") fx.srv.cfg.Releases = brokenStore{fx.rel} type answer struct { what string status int body string } appendStatus, appendBody := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, `{"payload":{"n":1}}`) publishStatus, publishBody := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token, `{"public":true,"ledgers":["carbon"]}`) listStatus, listBody := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers", token) cases := []answer{ {"append", appendStatus, string(appendBody)}, {"publish", publishStatus, string(publishBody)}, {"list", listStatus, listBody}, } for _, c := range cases { if c.status != http.StatusInternalServerError { t.Errorf("%s against a broken bucket = %d %s, want 500", c.what, c.status, c.body) } if strings.Contains(c.body, "AccessDenied") || strings.Contains(c.body, "carlos-prod-secret-bucket") || strings.Contains(c.body, "ABC123") { t.Errorf("%s leaked the store's error text to the client: %s", c.what, c.body) } } }) }