| new file mode 100644 |
| index 000000000..fefab06c5 |
| --- /dev/null |
| +++ b/internal/console/apiledger_test.go |
| @@ -0,0 +1,334 @@ |
| +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" |
| + "io" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/ledger" |
| +) |
| + |
| +// 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) |
| + } |
| + |
| + 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) |
| + } |
| + |
| + // 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) |
| + } |
| +} |
| + |
| +// 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) |
| + } |
| +} |