carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3// The ledger CLI API (apiledger.go): the ONLY write path to ledgers/ from
4// outside a box. The properties that matter, in the order they'd hurt if
5// lost: the writer stamped on an entry is the AUTHENTICATED identity and
6// nothing a client sent, every route is member-gated through apiAccountApp
7// (so an unknown account and someone else's are the identical 404), and the
8// caps — 64 KiB payload, 4 MiB blob, a validated ledger name — are enforced
9// before anything touches the store.
10
11import (
12 "bytes"
13 "context"
14 "encoding/json"
15 "errors"
16 "fmt"
17 "io"
18 "net/http"
19 "slices"
20 "strings"
21 "testing"
22 "time"
23
24 "github.com/carlosframework/platform/internal/ledger"
25 "github.com/carlosframework/platform/internal/release"
26)
27
28// postBody is postJSON's raw-body cousin: these tests send bodies that are
29// deliberately not round-trippable through json.Marshal (an oversize
30// payload, a body carrying a field the server must ignore), and read the
31// response as bytes so it can be decoded into a typed struct.
32func postBody(t *testing.T, u, bearer, body string) (int, []byte) {
33 t.Helper()
34 req, err := http.NewRequest(http.MethodPost, u, strings.NewReader(body))
35 if err != nil {
36 t.Fatal(err)
37 }
38 req.Header.Set("Content-Type", "application/json")
39 if bearer != "" {
40 req.Header.Set("Authorization", "Bearer "+bearer)
41 }
42 res, err := http.DefaultClient.Do(req)
43 if err != nil {
44 t.Fatal(err)
45 }
46 defer res.Body.Close()
47 out, err := io.ReadAll(res.Body)
48 if err != nil {
49 t.Fatal(err)
50 }
51 return res.StatusCode, out
52}
53
54// ledgerFixture is a console with titogo claimed in the ops account and a
55// MEMBER (not owner) token for it — member-level is exactly the gate this
56// API grants, the same one promote uses.
57func ledgerFixture(t *testing.T, code string) (*consoleFixture, string) {
58 t.Helper()
59 fx := newConsoleFixture(t)
60 now := time.Now()
61 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
62 t.Fatal(err)
63 }
64 if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil {
65 t.Fatal(err)
66 }
67 return fx, cliToken(t, fx, "alice@keymail.dev", code)
68}
69
70// TestAPILedgerAppendAndHead: an append round-trips through HEAD, and the
71// writer on the stored entry is the token's identity — never anything the
72// client put in the body (the request below tries to set one).
73func TestAPILedgerAppendAndHead(t *testing.T) {
74 fx, token := ledgerFixture(t, "code-ledger-append")
75
76 status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token,
77 `{"payload":{"kg_central":1},"writer":"attacker@example.com"}`)
78 if status != http.StatusOK {
79 t.Fatalf("append = %d %s", status, out)
80 }
81 var a ledger.Appended
82 if err := json.Unmarshal(out, &a); err != nil {
83 t.Fatalf("append body %s: %v", out, err)
84 }
85 if a.Count != 1 || a.Key == "" || a.SHA256 == "" {
86 t.Fatalf("append = %+v, want count 1 with a key and sha", a)
87 }
88
89 status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/head", token)
90 if status != http.StatusOK {
91 t.Fatalf("head = %d %s", status, body)
92 }
93 var h ledger.Head
94 if err := json.Unmarshal([]byte(body), &h); err != nil {
95 t.Fatalf("head body %s: %v", body, err)
96 }
97 if h.HeadKey != a.Key || h.HeadSHA256 != a.SHA256 || h.Count != 1 {
98 t.Fatalf("head = %+v, want it to name the appended entry %+v", h, a)
99 }
100
101 // The writer is the token's identity, stamped server-side. The body's
102 // own "writer" field must have gone nowhere.
103 eb, _, err := fx.rel.Get(context.Background(), a.Key)
104 if err != nil {
105 t.Fatalf("stored entry %s: %v", a.Key, err)
106 }
107 var e ledger.Entry
108 if err := json.Unmarshal(eb, &e); err != nil {
109 t.Fatal(err)
110 }
111 if e.Writer != "alice@keymail.dev" {
112 t.Fatalf("entry writer = %q, want the authenticated identity alice@keymail.dev (%+v)", e.Writer, e)
113 }
114 if bytes.Contains(eb, []byte("attacker@example.com")) {
115 t.Fatalf("stored entry carries a client-supplied writer: %s", eb)
116 }
117}
118
119// TestAPILedgerAuthz: no token is the same 401 every CLI door gives, and a
120// non-member — whether the account is one they don't belong to or an app it
121// never claimed — gets the byte-identical 404 apiAccountApp already
122// produces, on the WRITE routes too.
123func TestAPILedgerAuthz(t *testing.T) {
124 fx, _ := ledgerFixture(t, "code-ledger-authz")
125
126 status, _ := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", "",
127 `{"payload":1}`)
128 if status != http.StatusUnauthorized {
129 t.Fatalf("append with no token = %d, want 401", status)
130 }
131
132 // (a) member of nothing, probing a real account+app.
133 eve := cliToken(t, fx, "eve@keymail.dev", "code-ledger-eve")
134 statusA, bodyA := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", eve,
135 `{"payload":1}`)
136 if statusA != http.StatusNotFound {
137 t.Fatalf("append as non-member = %d, want 404", statusA)
138 }
139 // (b) member of bab, but an app it never claimed.
140 if err := fx.st.AddMember(OpsAccountID, "frank@keymail.dev", RoleMember, time.Now()); err != nil {
141 t.Fatal(err)
142 }
143 frank := cliToken(t, fx, "frank@keymail.dev", "code-ledger-frank")
144 statusB, bodyB := postBody(t, fx.ts.URL+"/api/cli/apps/bab/ghost-app/ledgers/carbon/append", frank,
145 `{"payload":1}`)
146 if statusB != http.StatusNotFound {
147 t.Fatalf("append against an unclaimed app = %d, want 404", statusB)
148 }
149 if !bytes.Equal(bodyA, bodyB) {
150 t.Fatalf("the two refusals are not byte-identical:\n(a) %q\n(b) %q", bodyA, bodyB)
151 }
152 if string(bodyA) != "404 page not found\n" {
153 t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", bodyA)
154 }
155
156 // Nothing was written by any of the refused calls.
157 if _, _, err := fx.rel.Get(context.Background(), ledger.HeadKey("bab", "titogo", "carbon")); err == nil {
158 t.Fatalf("a ledger HEAD exists after only refused appends, want none")
159 }
160
161 // The read routes refuse identically.
162 if st, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers", eve); st != http.StatusNotFound || body != "404 page not found\n" {
163 t.Fatalf("list as non-member = %d %q, want the identical 404", st, body)
164 }
165 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" {
166 t.Fatalf("head as non-member = %d %q, want the identical 404", st, body)
167 }
168}
169
170// TestAPILedgerRejects: a name that isn't a ledger name, and a payload over
171// the 64 KiB cap, are both refused before anything lands in the store.
172func TestAPILedgerRejects(t *testing.T) {
173 fx, token := ledgerFixture(t, "code-ledger-rejects")
174
175 // Bad ledger name in the path — uppercase and a space.
176 status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/Not%20Valid/append", token,
177 `{"payload":1}`)
178 if status != http.StatusBadRequest {
179 t.Fatalf("bad ledger name = %d %s, want 400", status, out)
180 }
181 // A traversal attempt in the name segment, which would otherwise become
182 // bucket key segments.
183 status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/..%2f..%2fetc/append", token,
184 `{"payload":1}`)
185 if status != http.StatusBadRequest && status != http.StatusNotFound {
186 t.Fatalf("traversal ledger name = %d %s, want a refusal", status, out)
187 }
188
189 // Oversized payload: over the cap, but under MaxBytesReader's limit, so
190 // this is the handler's own arithmetic refusing it, not the reader's.
191 big := `{"payload":"` + strings.Repeat("x", ledger.MaxPayload) + `"}`
192 status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, big)
193 if status != http.StatusBadRequest && status != http.StatusRequestEntityTooLarge {
194 t.Fatalf("oversize payload = %d %s, want 400 or 413", status, out)
195 }
196 // Far oversized: MaxBytesReader cuts this one off.
197 huge := `{"payload":"` + strings.Repeat("x", ledger.MaxPayload+64<<10) + `"}`
198 status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, huge)
199 if status != http.StatusBadRequest && status != http.StatusRequestEntityTooLarge {
200 t.Fatalf("far-oversize payload = %d %s, want 400 or 413", status, out)
201 }
202 // A body with no payload at all.
203 status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, `{}`)
204 if status != http.StatusBadRequest {
205 t.Fatalf("missing payload = %d %s, want 400", status, out)
206 }
207
208 // None of the refusals created a chain.
209 if _, _, err := fx.rel.Get(context.Background(), ledger.HeadKey("bab", "titogo", "carbon")); err == nil {
210 t.Fatalf("carbon HEAD exists after only refused appends, want none")
211 }
212}
213
214// TestAPILedgerPublishAndList: the opt-in manifest round-trips, and the
215// listing names both the manifest and the ledgers that actually exist —
216// derived from the HEAD keys in the bucket, not from the manifest.
217func TestAPILedgerPublishAndList(t *testing.T) {
218 fx, token := ledgerFixture(t, "code-ledger-publish")
219
220 // A write happens with NO manifest present: opt-in gates serving, not
221 // writing (spec §2.3).
222 if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token,
223 `{"payload":{"n":1}}`); status != http.StatusOK {
224 t.Fatalf("append before any publish = %d %s, want 200 (opt-in gates serving, not writing)", status, out)
225 }
226
227 // Before publishing, the manifest reads back null and the ledger is
228 // still listed.
229 status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers", token)
230 if status != http.StatusOK {
231 t.Fatalf("list before publish = %d %s", status, body)
232 }
233 var pre struct {
234 Manifest *ledger.Manifest `json:"manifest"`
235 Ledgers []string `json:"ledgers"`
236 }
237 if err := json.Unmarshal([]byte(body), &pre); err != nil {
238 t.Fatalf("list body %s: %v", body, err)
239 }
240 if pre.Manifest != nil {
241 t.Fatalf("manifest before publish = %+v, want null", pre.Manifest)
242 }
243 if len(pre.Ledgers) != 1 || pre.Ledgers[0] != "carbon" {
244 t.Fatalf("ledgers before publish = %v, want [carbon]", pre.Ledgers)
245 }
246
247 status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token,
248 `{"public":true,"ledgers":["carbon"],"title":"Titogo"}`)
249 if status != http.StatusOK {
250 t.Fatalf("publish = %d %s", status, out)
251 }
252
253 // A second entry and a blob under the same chain: the listing is
254 // delimiter-aware, so neither the entries/ nor the blobs/ directory may
255 // surface as a ledger name, however much history accrues.
256 if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token,
257 `{"payload":{"n":2}}`); status != http.StatusOK {
258 t.Fatalf("second append = %d %s", status, out)
259 }
260 if _, err := fx.rel.Put(context.Background(),
261 ledger.BlobKey("bab", "titogo", "carbon", strings.Repeat("a", 64)), []byte("x"), ""); err != nil {
262 t.Fatal(err)
263 }
264
265 status, body = getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers", token)
266 if status != http.StatusOK || !strings.Contains(body, `"carbon"`) {
267 t.Fatalf("list = %d %s, want carbon among the ledgers", status, body)
268 }
269 var post struct {
270 Manifest *ledger.Manifest `json:"manifest"`
271 Ledgers []string `json:"ledgers"`
272 }
273 if err := json.Unmarshal([]byte(body), &post); err != nil {
274 t.Fatalf("list body %s: %v", body, err)
275 }
276 if post.Manifest == nil || !post.Manifest.Public || post.Manifest.Title != "Titogo" ||
277 len(post.Manifest.Ledgers) != 1 || post.Manifest.Ledgers[0] != "carbon" {
278 t.Fatalf("manifest after publish = %+v, want the published one", post.Manifest)
279 }
280 if post.Manifest.Schema != ledger.Schema {
281 t.Fatalf("manifest schema = %d, want %d (stamped server-side)", post.Manifest.Schema, ledger.Schema)
282 }
283 if len(post.Ledgers) != 1 || post.Ledgers[0] != "carbon" {
284 t.Fatalf("ledgers = %v, want exactly [carbon] — entries/ and blobs/ are not ledgers", post.Ledgers)
285 }
286
287 // Publishing is a visibility change, so it leaves an audit line naming
288 // who did it, the flag, and the ledgers — the same trail promote,
289 // rollback and ship write to.
290 events, err := fx.st.Events(OpsAccountID, "titogo", 10)
291 if err != nil {
292 t.Fatal(err)
293 }
294 var pub *Event
295 for i := range events {
296 if events[i].Action == "ledger-publish" {
297 pub = &events[i]
298 }
299 }
300 if pub == nil {
301 t.Fatalf("events = %v, want a ledger-publish row", events)
302 }
303 if pub.Address != "alice@keymail.dev" {
304 t.Fatalf("ledger-publish address = %q, want the authenticated identity", pub.Address)
305 }
306 if !strings.Contains(pub.Detail, "public=true") || !strings.Contains(pub.Detail, "carbon") ||
307 !strings.Contains(pub.Detail, "(cli)") {
308 t.Fatalf("ledger-publish detail = %q, want the flag, the ledgers and the CLI marker", pub.Detail)
309 }
310
311 // A manifest naming a ledger that isn't a ledger name is refused.
312 if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token,
313 `{"public":true,"ledgers":["Not Valid"]}`); status != http.StatusBadRequest {
314 t.Fatalf("publish with a bad ledger name = %d %s, want 400", status, out)
315 }
316}
317
318// publishLedgers posts one publish call and decodes the RESULTING manifest
319// the handler answers with — the shape a CLI needs in order to tell the user
320// what is public now rather than what they just asked for.
321func publishLedgers(t *testing.T, fx *consoleFixture, token, body string) (int, ledger.Manifest) {
322 t.Helper()
323 status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token, body)
324 var m ledger.Manifest
325 if status == http.StatusOK {
326 if err := json.Unmarshal(out, &m); err != nil {
327 t.Fatalf("publish body %s: %v", out, err)
328 }
329 }
330 return status, m
331}
332
333// TestAPILedgerPublishIsAdditive is the defect this handler was written
334// around: an app's manifest is a SET of published ledgers accrued over
335// separate publish calls, and the edge reads that set as a per-ledger
336// ALLOWLIST. A publish that replaced the whole manifest would silently
337// unpublish every ledger the caller didn't name in this one call — while the
338// CLI printed a success line — and would make publishing two ledgers
339// impossible one call at a time.
340func TestAPILedgerPublishIsAdditive(t *testing.T) {
341 fx, token := ledgerFixture(t, "code-ledger-additive")
342
343 status, m := publishLedgers(t, fx, token, `{"public":true,"ledgers":["carbon"],"title":"Titogo"}`)
344 if status != http.StatusOK {
345 t.Fatalf("first publish = %d", status)
346 }
347 if !m.Public || len(m.Ledgers) != 1 || m.Ledgers[0] != "carbon" || m.Title != "Titogo" {
348 t.Fatalf("first publish = %+v", m)
349 }
350
351 // Publishing a SECOND ledger keeps the first, and an omitted title keeps
352 // the stored one (a caller publishing one more chain is not asking to
353 // erase the app's title).
354 status, m = publishLedgers(t, fx, token, `{"public":true,"ledgers":["prompts"]}`)
355 if status != http.StatusOK {
356 t.Fatalf("second publish = %d", status)
357 }
358 if len(m.Ledgers) != 2 || m.Ledgers[0] != "carbon" || m.Ledgers[1] != "prompts" {
359 t.Fatalf("second publish = %+v, want [carbon prompts] in order", m.Ledgers)
360 }
361 if m.Title != "Titogo" {
362 t.Fatalf("title = %q, want the stored title preserved when the request omits one", m.Title)
363 }
364
365 // Re-publishing one that is already public is idempotent — no duplicate.
366 if _, m = publishLedgers(t, fx, token, `{"public":true,"ledgers":["carbon"]}`); len(m.Ledgers) != 2 {
367 t.Fatalf("re-publish = %+v, want no duplicate", m.Ledgers)
368 }
369 // A non-empty title replaces the stored one.
370 if _, m = publishLedgers(t, fx, token, `{"public":true,"ledgers":["carbon"],"title":"Tito"}`); m.Title != "Tito" {
371 t.Fatalf("title = %q, want the new one", m.Title)
372 }
373
374 // The store agrees with what the handler reported.
375 stored, err := ledger.ReadManifest(context.Background(), fx.rel, "bab", "titogo")
376 if err != nil {
377 t.Fatal(err)
378 }
379 if len(stored.Ledgers) != 2 || stored.Title != "Tito" || !stored.Public {
380 t.Fatalf("stored manifest = %+v, want it to match the reported state", stored)
381 }
382}
383
384// TestAPILedgerUnpublishRemovesOne: unpublish is the mirror — it removes the
385// named ledgers and flips public off ONLY when nothing is left. Unpublishing
386// one of two chains must not take the other one dark.
387func TestAPILedgerUnpublishRemovesOne(t *testing.T) {
388 fx, token := ledgerFixture(t, "code-ledger-unpublish")
389
390 if status, _ := publishLedgers(t, fx, token, `{"public":true,"ledgers":["carbon","prompts"],"title":"T"}`); status != http.StatusOK {
391 t.Fatalf("publish = %d", status)
392 }
393
394 status, m := publishLedgers(t, fx, token, `{"public":false,"ledgers":["carbon"]}`)
395 if status != http.StatusOK {
396 t.Fatalf("unpublish = %d", status)
397 }
398 if !m.Public {
399 t.Fatalf("unpublish of one of two = %+v, want public still true", m)
400 }
401 if len(m.Ledgers) != 1 || m.Ledgers[0] != "prompts" {
402 t.Fatalf("unpublish left %v, want [prompts]", m.Ledgers)
403 }
404
405 // Unpublishing the last one empties the list AND flips public off, so
406 // the app's own ledgers.json index stops serving too.
407 status, m = publishLedgers(t, fx, token, `{"public":false,"ledgers":["prompts"]}`)
408 if status != http.StatusOK {
409 t.Fatalf("final unpublish = %d", status)
410 }
411 if m.Public || len(m.Ledgers) != 0 {
412 t.Fatalf("final unpublish = %+v, want public:false and an empty list", m)
413 }
414 if m.Title != "T" {
415 t.Fatalf("title = %q, want it kept — unpublishing is not forgetting", m.Title)
416 }
417}
418
419// TestAPILedgerUnpublishWithoutManifest: unpublishing an app that never
420// published is a no-op success, not a 404 and not a manifest conjured out of
421// nothing.
422func TestAPILedgerUnpublishWithoutManifest(t *testing.T) {
423 fx, token := ledgerFixture(t, "code-ledger-unpublish-none")
424
425 status, m := publishLedgers(t, fx, token, `{"public":false,"ledgers":["carbon"]}`)
426 if status != http.StatusOK {
427 t.Fatalf("unpublish with no manifest = %d, want 200", status)
428 }
429 if m.Public || len(m.Ledgers) != 0 {
430 t.Fatalf("= %+v, want the zero manifest", m)
431 }
432 if _, _, err := fx.rel.Get(context.Background(), ledger.ManifestKey("bab", "titogo")); err == nil {
433 t.Fatal("a no-op unpublish wrote a manifest, want none")
434 }
435}
436
437// TestAPILedgerPublishTitleCap: the title is member-supplied text served
438// publicly at ledgers.json, so it is bounded at the write door — the same
439// place every other public-facing input on this API is bounded.
440func TestAPILedgerPublishTitleCap(t *testing.T) {
441 fx, token := ledgerFixture(t, "code-ledger-title")
442
443 long := `{"public":true,"ledgers":["carbon"],"title":"` + strings.Repeat("t", 121) + `"}`
444 status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token, long)
445 if status != http.StatusBadRequest {
446 t.Fatalf("oversize title = %d %s, want 400", status, out)
447 }
448 if !strings.Contains(string(out), "title too long") {
449 t.Fatalf("refusal = %s, want it to name the cap", out)
450 }
451 if _, _, err := fx.rel.Get(context.Background(), ledger.ManifestKey("bab", "titogo")); err == nil {
452 t.Fatal("a refused publish wrote a manifest")
453 }
454
455 // Exactly at the cap is fine, and surrounding whitespace is trimmed
456 // rather than counted.
457 at := strings.Repeat("t", 120)
458 status, m := publishLedgers(t, fx, token, `{"public":true,"ledgers":["carbon"],"title":" `+at+` "}`)
459 if status != http.StatusOK {
460 t.Fatalf("title at the cap = %d, want 200", status)
461 }
462 if m.Title != at {
463 t.Fatalf("title = %q, want it trimmed to the %d-byte value", m.Title, len(at))
464 }
465}
466
467// TestAPILedgerPublishCountCap: the manifest became read-modify-write, which
468// removed the implicit bound the 8 KiB request cap used to give a whole-value
469// replace — names now ACCUMULATE. ledgers.json is member-supplied content
470// served publicly at the edge, held in the edge's in-memory cache and
471// linearly scanned on every ledger request, so the set is bounded at the
472// write door, on the RESULTING manifest, like every other public-facing input
473// here.
474func TestAPILedgerPublishCountCap(t *testing.T) {
475 fx, token := ledgerFixture(t, "code-ledger-count")
476
477 names := make([]string, 0, maxLedgerCount)
478 for i := range maxLedgerCount {
479 names = append(names, fmt.Sprintf("l%d", i))
480 }
481 full, err := json.Marshal(map[string]any{"public": true, "ledgers": names, "title": "T"})
482 if err != nil {
483 t.Fatal(err)
484 }
485 status, m := publishLedgers(t, fx, token, string(full))
486 if status != http.StatusOK {
487 t.Fatalf("publish of %d ledgers = %d, want 200 (the cap is a ceiling, not a fence below it)", maxLedgerCount, status)
488 }
489 if len(m.Ledgers) != maxLedgerCount {
490 t.Fatalf("published %d ledgers, want %d", len(m.Ledgers), maxLedgerCount)
491 }
492
493 // The one that would step over is refused as the caller's fault, and
494 // nothing new is written: not the name, not the title that rode with it.
495 status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token,
496 `{"public":true,"ledgers":["overflow"],"title":"nope"}`)
497 if status != http.StatusBadRequest {
498 t.Fatalf("publish over the cap = %d %s, want 400", status, out)
499 }
500 if !strings.Contains(string(out), "too many public ledgers") {
501 t.Fatalf("refusal = %s, want it to name the cap", out)
502 }
503 stored, err := ledger.ReadManifest(context.Background(), fx.rel, "bab", "titogo")
504 if err != nil {
505 t.Fatal(err)
506 }
507 if len(stored.Ledgers) != maxLedgerCount || slices.Contains(stored.Ledgers, "overflow") {
508 t.Fatalf("stored manifest = %+v, want the refused publish to have written nothing", stored)
509 }
510 if stored.Title != "T" {
511 t.Fatalf("title = %q, want the refused publish to have left it alone", stored.Title)
512 }
513
514 // Re-publishing one that is ALREADY on the list at the cap is fine — the
515 // bound is on the resulting set, and that set does not grow.
516 if status, m := publishLedgers(t, fx, token, `{"public":true,"ledgers":["l0"]}`); status != http.StatusOK ||
517 len(m.Ledgers) != maxLedgerCount {
518 t.Fatalf("idempotent re-publish at the cap = %d %+v, want 200 and no growth", status, m.Ledgers)
519 }
520
521 // And the way back down is never blocked: an app that hit the cap must
522 // still be able to unpublish.
523 status, m = publishLedgers(t, fx, token, `{"public":false,"ledgers":["l0"]}`)
524 if status != http.StatusOK {
525 t.Fatalf("unpublish at the cap = %d, want 200", status)
526 }
527 if len(m.Ledgers) != maxLedgerCount-1 || slices.Contains(m.Ledgers, "l0") {
528 t.Fatalf("unpublish at the cap left %v", m.Ledgers)
529 }
530}
531
532// TestAPILedgerHeadMissing: a ledger that was never written is the same
533// stdlib 404 as an app you can't see — there is nothing to distinguish.
534func TestAPILedgerHeadMissing(t *testing.T) {
535 fx, token := ledgerFixture(t, "code-ledger-head-missing")
536
537 status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/nothing-here/head", token)
538 if status != http.StatusNotFound {
539 t.Fatalf("head of an unwritten ledger = %d %q, want 404", status, body)
540 }
541}
542
543// TestAPILedgerBlob: raw bytes upload content-addressed, the returned sha
544// is what the bytes hash to, and a re-upload of the same bytes is the same
545// answer.
546func TestAPILedgerBlob(t *testing.T) {
547 fx, token := ledgerFixture(t, "code-ledger-blob")
548
549 req, err := http.NewRequest(http.MethodPost,
550 fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/prompts/blobs",
551 bytes.NewReader([]byte("corpus")))
552 if err != nil {
553 t.Fatal(err)
554 }
555 req.Header.Set("Authorization", "Bearer "+token)
556 res, err := http.DefaultClient.Do(req)
557 if err != nil {
558 t.Fatal(err)
559 }
560 defer res.Body.Close()
561 raw, err := io.ReadAll(res.Body)
562 if err != nil {
563 t.Fatal(err)
564 }
565 if res.StatusCode != http.StatusOK {
566 t.Fatalf("blob = %d %s", res.StatusCode, raw)
567 }
568 var out struct {
569 SHA256 string `json:"sha256"`
570 }
571 if err := json.Unmarshal(raw, &out); err != nil {
572 t.Fatalf("blob body %s: %v", raw, err)
573 }
574 want := ledger.SHA256Hex([]byte("corpus"))
575 if out.SHA256 != want {
576 t.Fatalf("blob sha256 = %q, want %q (computed from the bytes sent)", out.SHA256, want)
577 }
578 stored, _, err := fx.rel.Get(context.Background(), ledger.BlobKey("bab", "titogo", "prompts", want))
579 if err != nil {
580 t.Fatalf("stored blob: %v", err)
581 }
582 if !bytes.Equal(stored, []byte("corpus")) {
583 t.Fatalf("stored blob = %q, want the bytes sent", stored)
584 }
585
586 // A bad ledger name in the path refuses before the body is read.
587 if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/Not%20Valid/blobs", token,
588 "corpus"); status != http.StatusBadRequest {
589 t.Fatalf("blob with a bad ledger name = %d %s, want 400", status, out)
590 }
591}
592
593// TestAPILedgerWritesAreRateLimited: every WRITE door here passes the same
594// write budget its promote/rollback/new-account siblings do, keyed on the
595// address. Without it, the one API that appends an object per call is the
596// one API with no bound on how fast a token can fill a bucket.
597func TestAPILedgerWritesAreRateLimited(t *testing.T) {
598 fx, token := ledgerFixture(t, "code-ledger-ratelimit")
599
600 limited := false
601 for i := 0; i < cliWriteBurst+3; i++ {
602 status, _ := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token,
603 `{"payload":{"n":1}}`)
604 if status == http.StatusTooManyRequests {
605 limited = true
606 break
607 }
608 }
609 if !limited {
610 t.Fatalf("append was never rate-limited in %d attempts", cliWriteBurst+3)
611 }
612
613 // The budget is shared across the write doors — an exhausted address
614 // cannot simply switch verb. (Same limiter, same key: this is what
615 // proves publish and blobs pass it too, not just append.)
616 if status, _ := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token,
617 `{"public":true,"ledgers":["carbon"]}`); status != http.StatusTooManyRequests {
618 t.Fatalf("publish on an exhausted budget = %d, want 429", status)
619 }
620 req, err := http.NewRequest(http.MethodPost,
621 fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/prompts/blobs", bytes.NewReader([]byte("corpus")))
622 if err != nil {
623 t.Fatal(err)
624 }
625 req.Header.Set("Authorization", "Bearer "+token)
626 res, err := http.DefaultClient.Do(req)
627 if err != nil {
628 t.Fatal(err)
629 }
630 res.Body.Close()
631 if res.StatusCode != http.StatusTooManyRequests {
632 t.Fatalf("blob on an exhausted budget = %d, want 429", res.StatusCode)
633 }
634
635 // The READ doors are unlimited, like their read-only siblings — an
636 // exhausted write budget must not blind a CLI to what it already wrote.
637 if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers", token); status != http.StatusOK {
638 t.Fatalf("list on an exhausted write budget = %d %s, want 200", status, body)
639 }
640 if status, _ := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/head", token); status != http.StatusOK {
641 t.Fatalf("head on an exhausted write budget = %d, want 200", status)
642 }
643}
644
645// casStore loses every HEAD race, the way a chain under more concurrent
646// writers than the retry loop can outlast behaves.
647type casStore struct{ release.Store }
648
649func (c casStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
650 if strings.HasSuffix(key, "/HEAD.json") || strings.HasSuffix(key, "/ledgers.json") {
651 return "", release.ErrCASConflict
652 }
653 return c.Store.Put(ctx, key, body, ifMatch)
654}
655
656// brokenStore is a bucket the console cannot reach, whose error text is
657// exactly the kind that must never reach a client.
658type brokenStore struct{ release.Store }
659
660const brokenStoreErr = "AccessDenied: s3://carlos-prod-secret-bucket/ledgers request id ABC123"
661
662func (b brokenStore) Put(context.Context, string, []byte, string) (string, error) {
663 return "", errors.New(brokenStoreErr)
664}
665
666func (b brokenStore) ListDirs(context.Context, string) ([]string, error) {
667 return nil, errors.New(brokenStoreErr)
668}
669
670// TestAPILedgerStoreFailureStatuses: a failure that is not the caller's
671// fault must not be reported as if it were. Contention is retry-worthy
672// (503), a broken bucket is not (500), and neither answer may echo the
673// store's own error text — bucket names and request ids are for the log.
674func TestAPILedgerStoreFailureStatuses(t *testing.T) {
675 t.Run("contention is 503", func(t *testing.T) {
676 fx, token := ledgerFixture(t, "code-ledger-busy")
677 fx.srv.cfg.Releases = casStore{fx.rel}
678
679 status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token,
680 `{"payload":{"n":1}}`)
681 if status != http.StatusServiceUnavailable {
682 t.Fatalf("append under total contention = %d %s, want 503", status, out)
683 }
684 if !strings.Contains(string(out), "try again") {
685 t.Fatalf("contention error = %s, want it to invite a retry", out)
686 }
687 if strings.Contains(string(out), "CAS") || strings.Contains(string(out), "HEAD.json") {
688 t.Fatalf("contention error leaks store internals: %s", out)
689 }
690 // Publish's own lost race is the same retry-worthy answer.
691 if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token,
692 `{"public":true,"ledgers":["carbon"]}`); status != http.StatusServiceUnavailable {
693 t.Fatalf("publish under total contention = %d %s, want 503", status, out)
694 }
695 })
696
697 t.Run("a broken bucket is 500 and says nothing", func(t *testing.T) {
698 fx, token := ledgerFixture(t, "code-ledger-broken")
699 fx.srv.cfg.Releases = brokenStore{fx.rel}
700
701 type answer struct {
702 what string
703 status int
704 body string
705 }
706 appendStatus, appendBody := postBody(t,
707 fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, `{"payload":{"n":1}}`)
708 publishStatus, publishBody := postBody(t,
709 fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token, `{"public":true,"ledgers":["carbon"]}`)
710 listStatus, listBody := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers", token)
711 cases := []answer{
712 {"append", appendStatus, string(appendBody)},
713 {"publish", publishStatus, string(publishBody)},
714 {"list", listStatus, listBody},
715 }
716
717 for _, c := range cases {
718 if c.status != http.StatusInternalServerError {
719 t.Errorf("%s against a broken bucket = %d %s, want 500", c.what, c.status, c.body)
720 }
721 if strings.Contains(c.body, "AccessDenied") ||
722 strings.Contains(c.body, "carlos-prod-secret-bucket") ||
723 strings.Contains(c.body, "ABC123") {
724 t.Errorf("%s leaked the store's error text to the client: %s", c.what, c.body)
725 }
726 }
727 })
728}
729