| index 88d5c7cb0..7f28e7757 100644 |
| --- a/apimode_test.go |
| +++ b/apimode_test.go |
| @@ -20,6 +20,7 @@ import ( |
| "time" |
| |
| "github.com/carlosframework/platform/internal/edge" |
| + "github.com/carlosframework/platform/internal/ledger" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| |
| @@ -1241,3 +1242,547 @@ func TestShipAPIMissingArtifactRefusedBeforeDialing(t *testing.T) { |
| } |
| checkNoRegistry() |
| } |
| + |
| +// ---- Task 6: carlos ledger — apiSession methods and the CLI verbs -------- |
| + |
| +// TestAPISessionLedgerAppend pins the wire shape handleAPILedgerAppend |
| +// (internal/console/apiledger.go) requires: bearer auth, POST to |
| +// .../ledgers/<name>/append, body {"payload": <verbatim>}. |
| +func TestAPISessionLedgerAppend(t *testing.T) { |
| + var gotAuth, gotPath, gotBody string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotAuth = r.Header.Get("Authorization") |
| + gotPath = r.URL.Path |
| + b, _ := io.ReadAll(r.Body) |
| + gotBody = string(b) |
| + json.NewEncoder(w).Encode(ledger.Appended{Key: "ledgers/acct/app/carbon/entries/x.json", SHA256: strings.Repeat("a", 64), Count: 3}) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + a, err := s.ledgerAppend(context.Background(), "acct", "app", "carbon", json.RawMessage(`{"n":1}`)) |
| + if err != nil { |
| + t.Fatalf("ledgerAppend: %v", err) |
| + } |
| + if gotAuth != "Bearer cli-token" { |
| + t.Fatalf("Authorization = %q, want Bearer cli-token", gotAuth) |
| + } |
| + if gotPath != "/api/cli/apps/acct/app/ledgers/carbon/append" { |
| + t.Fatalf("path = %q", gotPath) |
| + } |
| + if !strings.Contains(gotBody, `"payload":{"n":1}`) { |
| + t.Fatalf("request body = %q, want the payload carried verbatim", gotBody) |
| + } |
| + if a.Count != 3 { |
| + t.Fatalf("Count = %d, want 3", a.Count) |
| + } |
| +} |
| + |
| +// TestAPISessionLedgerAppendBusy pins ledgerStoreError's contention status: |
| +// a 503 with a JSON "error" body must surface that text, not a generic |
| +// status-only message — it is what tells a caller "retry", not "stop". |
| +func TestAPISessionLedgerAppendBusy(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusServiceUnavailable) |
| + json.NewEncoder(w).Encode(map[string]string{"error": "the ledger is busy right now — try again in a moment"}) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + _, err := s.ledgerAppend(context.Background(), "acct", "app", "carbon", json.RawMessage(`{"n":1}`)) |
| + if err == nil || !strings.Contains(err.Error(), "busy") { |
| + t.Fatalf("ledgerAppend 503 error = %v, want it to surface the busy message", err) |
| + } |
| +} |
| + |
| +// TestAPISessionLedgerHeadNotFound pins ledgerHead's DEDICATED 404 message — |
| +// deliberately not apiErrorFromBody's generic "-account/-app" text, since an |
| +// unknown LEDGER under an app the caller does belong to is a different, far |
| +// more common case ("nothing appended yet"). |
| +func TestAPISessionLedgerHeadNotFound(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + http.NotFound(w, r) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + _, err := s.ledgerHead(context.Background(), "acct", "app", "carbon") |
| + if err == nil { |
| + t.Fatal("ledgerHead against a 404 must error") |
| + } |
| + if !strings.Contains(err.Error(), `"carbon"`) { |
| + t.Fatalf("error = %q, want it to name the ledger", err) |
| + } |
| + if strings.Contains(err.Error(), "-account") { |
| + t.Fatalf("error = %q, must NOT fall back to the generic membership message", err) |
| + } |
| +} |
| + |
| +func TestAPISessionLedgerHead(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(ledger.Head{Schema: 1, UpdatedAt: "2026-08-03T00:00:00Z", HeadKey: "k", HeadSHA256: strings.Repeat("b", 64), Count: 5}) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + h, err := s.ledgerHead(context.Background(), "acct", "app", "carbon") |
| + if err != nil { |
| + t.Fatalf("ledgerHead: %v", err) |
| + } |
| + if h.Count != 5 || h.HeadKey != "k" { |
| + t.Fatalf("head = %+v", h) |
| + } |
| +} |
| + |
| +// TestAPISessionLedgerList pins ledgerList's raw-body contract: the caller |
| +// (runLedgerList) decodes {"manifest","ledgers"} itself, so a null manifest |
| +// survives round-trip as nil rather than a caller-side zero value that would |
| +// erase the "never published" distinction. |
| +func TestAPISessionLedgerList(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(map[string]any{"manifest": nil, "ledgers": []string{"carbon"}}) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + body, err := s.ledgerList(context.Background(), "acct", "app") |
| + if err != nil { |
| + t.Fatalf("ledgerList: %v", err) |
| + } |
| + var res struct { |
| + Manifest *ledger.Manifest `json:"manifest"` |
| + Ledgers []string `json:"ledgers"` |
| + } |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + t.Fatalf("unmarshal: %v", err) |
| + } |
| + if res.Manifest != nil { |
| + t.Fatalf("manifest = %+v, want nil", res.Manifest) |
| + } |
| + if len(res.Ledgers) != 1 || res.Ledgers[0] != "carbon" { |
| + t.Fatalf("ledgers = %v, want [carbon]", res.Ledgers) |
| + } |
| +} |
| + |
| +func TestAPISessionLedgerPublish(t *testing.T) { |
| + var gotPath, gotBody string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath = r.URL.Path |
| + b, _ := io.ReadAll(r.Body) |
| + gotBody = string(b) |
| + w.Write([]byte("{}")) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + if err := s.ledgerPublish(context.Background(), "acct", "app", |
| + ledger.Manifest{Public: true, Ledgers: []string{"carbon"}, Title: "T"}); err != nil { |
| + t.Fatalf("ledgerPublish: %v", err) |
| + } |
| + if gotPath != "/api/cli/apps/acct/app/ledgers/publish" { |
| + t.Fatalf("path = %q", gotPath) |
| + } |
| + if !strings.Contains(gotBody, `"public":true`) || !strings.Contains(gotBody, `"carbon"`) { |
| + t.Fatalf("body = %q", gotBody) |
| + } |
| +} |
| + |
| +// TestAPISessionLedgerBlob pins ledgerBlob's request plumbing — the one |
| +// method that hand-builds its request rather than going through |
| +// getJSON/postJSON, because the wire body is raw bytes, not JSON. Same base |
| +// URL and bearer header as getJSON, POST instead of GET, a bytes.Reader body |
| +// instead of nil. |
| +func TestAPISessionLedgerBlob(t *testing.T) { |
| + var gotAuth, gotPath string |
| + var gotBody []byte |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotAuth = r.Header.Get("Authorization") |
| + gotPath = r.URL.Path |
| + gotBody, _ = io.ReadAll(r.Body) |
| + json.NewEncoder(w).Encode(map[string]string{"sha256": ledger.SHA256Hex(gotBody)}) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + sha, err := s.ledgerBlob(context.Background(), "acct", "app", "carbon", []byte("corpus")) |
| + if err != nil { |
| + t.Fatalf("ledgerBlob: %v", err) |
| + } |
| + if gotAuth != "Bearer cli-token" { |
| + t.Fatalf("Authorization = %q, want Bearer cli-token", gotAuth) |
| + } |
| + if gotPath != "/api/cli/apps/acct/app/ledgers/carbon/blobs" { |
| + t.Fatalf("path = %q", gotPath) |
| + } |
| + if string(gotBody) != "corpus" { |
| + t.Fatalf("request body = %q, want the raw bytes verbatim", gotBody) |
| + } |
| + if want := ledger.SHA256Hex([]byte("corpus")); sha != want { |
| + t.Fatalf("sha256 = %q, want %q", sha, want) |
| + } |
| +} |
| + |
| +// TestAPISessionLedgerBlobError pins ledgerBlob's error path — a non-200 |
| +// status decodes through apiErrorFromBody exactly like every other method |
| +// here, even though the request itself was raw bytes, not JSON. |
| +func TestAPISessionLedgerBlobError(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusRequestEntityTooLarge) |
| + json.NewEncoder(w).Encode(map[string]string{"error": "blob exceeds the 4 MiB cap"}) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + _, err := s.ledgerBlob(context.Background(), "acct", "app", "carbon", []byte("x")) |
| + if err == nil || !strings.Contains(err.Error(), "4 MiB cap") { |
| + t.Fatalf("ledgerBlob error = %v, want the server's 413 message surfaced", err) |
| + } |
| +} |
| + |
| +// ---- Task 6: the ledger verbs wired to resolveMode / public HTTPS -------- |
| + |
| +// TestLedgerAppendVerbAPIMode pins runLedgerAppend's console wiring: it |
| +// reads the entry from a file argument, validates it client-side (already |
| +// smoke-tested via `go run . ledger append` — invalid JSON never reaches |
| +// this server, see the task report), and posts through the console. |
| +func TestLedgerAppendVerbAPIMode(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + var reqCount int32 |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + atomic.AddInt32(&reqCount, 1) |
| + if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/ledgers/carbon/append" { |
| + t.Errorf("path = %s", r.URL.Path) |
| + } |
| + var in struct { |
| + Payload json.RawMessage `json:"payload"` |
| + } |
| + json.NewDecoder(r.Body).Decode(&in) |
| + if string(in.Payload) != `{"n":1}` { |
| + t.Errorf("payload = %s, want {\"n\":1}", in.Payload) |
| + } |
| + json.NewEncoder(w).Encode(ledger.Appended{Key: "k", SHA256: strings.Repeat("c", 64), Count: 7}) |
| + })) |
| + defer ts.Close() |
| + |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + dir := t.TempDir() |
| + entryPath := filepath.Join(dir, "entry.json") |
| + if err := os.WriteFile(entryPath, []byte(`{"n":1}`), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + buf := captureStdout(t) |
| + runLedgerAppend([]string{"-app", "titogo", "-account", apiTestAccount, "carbon", entryPath}) |
| + |
| + out := buf.String() |
| + if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) { |
| + t.Fatalf("stdout = %q, want it to contain %q", out, want) |
| + } |
| + if !strings.Contains(out, "appended to carbon") || !strings.Contains(out, "#7") { |
| + t.Fatalf("stdout = %q, want the appended line naming the ledger and count 7", out) |
| + } |
| + if !strings.Contains(out, strings.Repeat("c", 12)) { |
| + t.Fatalf("stdout = %q, want the sha's first 12 hex chars", out) |
| + } |
| + if got := atomic.LoadInt32(&reqCount); got != 1 { |
| + t.Fatalf("server got %d requests, want 1", got) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestLedgerAppendVerbReadsStdin pins the "-" convention: the entry argument |
| +// "-" reads the payload from stdin rather than treating "-" as a filename. |
| +func TestLedgerAppendVerbReadsStdin(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + |
| + var gotPayload string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + var in struct { |
| + Payload json.RawMessage `json:"payload"` |
| + } |
| + json.NewDecoder(r.Body).Decode(&in) |
| + gotPayload = string(in.Payload) |
| + json.NewEncoder(w).Encode(ledger.Appended{Key: "k", SHA256: strings.Repeat("d", 64), Count: 1}) |
| + })) |
| + defer ts.Close() |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + r, w, err := os.Pipe() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := w.WriteString(`{"n":2}`); err != nil { |
| + t.Fatal(err) |
| + } |
| + w.Close() |
| + origStdin := os.Stdin |
| + os.Stdin = r |
| + t.Cleanup(func() { os.Stdin = origStdin }) |
| + |
| + buf := captureStdout(t) |
| + runLedgerAppend([]string{"-app", "titogo", "-account", apiTestAccount, "carbon", "-"}) |
| + |
| + if gotPayload != `{"n":2}` { |
| + t.Fatalf("payload the server received = %q, want the stdin content", gotPayload) |
| + } |
| + if !strings.Contains(buf.String(), "appended to carbon") { |
| + t.Fatalf("stdout = %q", buf.String()) |
| + } |
| +} |
| + |
| +// TestLedgerHeadVerbAPIMode pins the one deliberately-different output shape |
| +// in this file: head prints ONLY the HEAD JSON (no "using ... as ..." |
| +// banner line) so a script can pipe it straight into `jq`. |
| +func TestLedgerHeadVerbAPIMode(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/ledgers/carbon/head" { |
| + t.Errorf("path = %s", r.URL.Path) |
| + } |
| + json.NewEncoder(w).Encode(ledger.Head{ |
| + Schema: 1, UpdatedAt: "2026-08-03T00:00:00Z", |
| + HeadKey: "ledgers/x/HEAD.json", HeadSHA256: strings.Repeat("e", 64), Count: 9, |
| + }) |
| + })) |
| + defer ts.Close() |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runLedgerHead([]string{"-app", "titogo", "-account", apiTestAccount, "carbon"}) |
| + |
| + out := buf.String() |
| + if strings.Contains(out, "using ") { |
| + t.Fatalf("stdout = %q, head must print ONLY the HEAD JSON, no banner line", out) |
| + } |
| + var h ledger.Head |
| + if err := json.Unmarshal([]byte(out), &h); err != nil { |
| + t.Fatalf("head output is not valid JSON: %v (%q)", err, out) |
| + } |
| + if h.Count != 9 { |
| + t.Fatalf("head.Count = %d, want 9", h.Count) |
| + } |
| +} |
| + |
| +// Note: runLedgerHead's 404 path ends in log.Fatal(err), which exits the |
| +// process — untestable in-process, matching this file's existing convention |
| +// (captureStdout's own doc comment above: "log.Fatal paths are untouched by |
| +// this seam on purpose"). The error text itself, which is what a human |
| +// actually reads, is pinned at the apiSession layer instead, by |
| +// TestAPISessionLedgerHeadNotFound above. |
| + |
| +func TestLedgerListVerbAPIMode(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/ledgers" { |
| + t.Errorf("path = %s", r.URL.Path) |
| + } |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "manifest": map[string]any{"schema": 1, "public": true, "ledgers": []string{"carbon"}, "title": "Titogo"}, |
| + "ledgers": []string{"carbon", "prompts"}, |
| + }) |
| + })) |
| + defer ts.Close() |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runLedgerList([]string{"-app", "titogo", "-account", apiTestAccount}) |
| + |
| + out := buf.String() |
| + if !strings.Contains(out, `published "Titogo"`) || !strings.Contains(out, "carbon") { |
| + t.Fatalf("stdout = %q, want the published-manifest line naming the title and ledgers", out) |
| + } |
| + if !strings.Contains(out, "prompts") { |
| + t.Fatalf("stdout = %q, want the prompts ledger listed even though it isn't in the manifest", out) |
| + } |
| +} |
| + |
| +// TestLedgerListVerbUnpublished pins the null-manifest case: "never |
| +// published" must read as its own message, not an empty/zero-value table. |
| +func TestLedgerListVerbUnpublished(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(map[string]any{"manifest": nil, "ledgers": []string{"carbon"}}) |
| + })) |
| + defer ts.Close() |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runLedgerList([]string{"-app", "titogo", "-account", apiTestAccount}) |
| + |
| + out := buf.String() |
| + if !strings.Contains(out, "not published") { |
| + t.Fatalf("stdout = %q, want a not-published message for a null manifest", out) |
| + } |
| + if !strings.Contains(out, "carbon") { |
| + t.Fatalf("stdout = %q, want the carbon ledger still listed", out) |
| + } |
| +} |
| + |
| +func TestLedgerPublishVerbAPIMode(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + |
| + var gotPath, gotBody string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath = r.URL.Path |
| + b, _ := io.ReadAll(r.Body) |
| + gotBody = string(b) |
| + w.Write([]byte("{}")) |
| + })) |
| + defer ts.Close() |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runLedgerPublish([]string{"-app", "titogo", "-account", apiTestAccount, "-title", "Titogo", "carbon"}) |
| + |
| + if gotPath != "/api/cli/apps/"+apiTestAccount+"/titogo/ledgers/publish" { |
| + t.Fatalf("path = %q", gotPath) |
| + } |
| + if !strings.Contains(gotBody, `"public":true`) || !strings.Contains(gotBody, `"carbon"`) || !strings.Contains(gotBody, `"Titogo"`) { |
| + t.Fatalf("publish body = %q", gotBody) |
| + } |
| + if !strings.Contains(buf.String(), "published titogo: carbon now public") { |
| + t.Fatalf("stdout = %q", buf.String()) |
| + } |
| +} |
| + |
| +// TestLedgerPublishVerbUnpublish pins -public=false's own message (an |
| +// unpublish is not the same act as a publish, and must not read like one). |
| +func TestLedgerPublishVerbUnpublish(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + |
| + var gotBody string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + b, _ := io.ReadAll(r.Body) |
| + gotBody = string(b) |
| + w.Write([]byte("{}")) |
| + })) |
| + defer ts.Close() |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runLedgerPublish([]string{"-app", "titogo", "-account", apiTestAccount, "-public=false", "carbon"}) |
| + |
| + if !strings.Contains(gotBody, `"public":false`) { |
| + t.Fatalf("publish body = %q, want public:false", gotBody) |
| + } |
| + if !strings.Contains(buf.String(), "unpublished titogo: carbon no longer public") { |
| + t.Fatalf("stdout = %q", buf.String()) |
| + } |
| +} |
| + |
| +func TestLedgerBlobVerbAPIMode(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/ledgers/prompts/blobs" { |
| + t.Errorf("path = %s", r.URL.Path) |
| + } |
| + b, _ := io.ReadAll(r.Body) |
| + json.NewEncoder(w).Encode(map[string]string{"sha256": ledger.SHA256Hex(b)}) |
| + })) |
| + defer ts.Close() |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + dir := t.TempDir() |
| + path := filepath.Join(dir, "transcript.txt") |
| + if err := os.WriteFile(path, []byte("corpus bytes"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + buf := captureStdout(t) |
| + runLedgerBlob([]string{"-app", "titogo", "-account", apiTestAccount, "prompts", path}) |
| + |
| + want := ledger.SHA256Hex([]byte("corpus bytes")) |
| + if !strings.Contains(buf.String(), want) { |
| + t.Fatalf("stdout = %q, want it to contain the uploaded blob's sha256 %q", buf.String(), want) |
| + } |
| +} |
| + |
| +// TestLedgerBlobVerbMultipleFiles pins that "blob <ledger> <file>..." takes |
| +// every trailing file argument, one upload per file. |
| +func TestLedgerBlobVerbMultipleFiles(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + |
| + var reqCount int32 |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + atomic.AddInt32(&reqCount, 1) |
| + b, _ := io.ReadAll(r.Body) |
| + json.NewEncoder(w).Encode(map[string]string{"sha256": ledger.SHA256Hex(b)}) |
| + })) |
| + defer ts.Close() |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + dir := t.TempDir() |
| + pathA := filepath.Join(dir, "a.txt") |
| + pathB := filepath.Join(dir, "b.txt") |
| + if err := os.WriteFile(pathA, []byte("aaa"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(pathB, []byte("bbb"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + buf := captureStdout(t) |
| + runLedgerBlob([]string{"-app", "titogo", "-account", apiTestAccount, "prompts", pathA, pathB}) |
| + |
| + out := buf.String() |
| + if !strings.Contains(out, ledger.SHA256Hex([]byte("aaa"))) || !strings.Contains(out, ledger.SHA256Hex([]byte("bbb"))) { |
| + t.Fatalf("stdout = %q, want both files' sha256", out) |
| + } |
| + if got := atomic.LoadInt32(&reqCount); got != 2 { |
| + t.Fatalf("server got %d requests, want 2 (one per file)", got) |
| + } |
| +} |
| + |
| +// TestLedgerVerifyVerb pins runLedgerVerify's own wiring: -url trimmed and |
| +// handed to ledger.HTTPGetter, -account treated as a bare sqid (never |
| +// resolved), and the printed message. Deliberately does NOT call |
| +// useTempCredentials/clearDeploymentEnv/loginTo — the whole point of |
| +// "verify -url" is that it needs no credential and no console session at |
| +// all, and this test proves it by never configuring either. |
| +func TestLedgerVerifyVerb(t *testing.T) { |
| + dir := t.TempDir() |
| + st := release.NewDirStore(dir) |
| + fixedNow := func(i int) func() time.Time { |
| + return func() time.Time { return time.Date(2026, 8, 3, 0, i, 0, 0, time.UTC) } |
| + } |
| + for i := range 2 { |
| + if _, err := ledger.Append(context.Background(), st, "bab", "platform", "carbon", "w", |
| + json.RawMessage(`{"i":1}`), fixedNow(i)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + b, _, err := st.Get(r.Context(), strings.TrimPrefix(r.URL.Path, "/")) |
| + if err != nil { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + w.Write(b) |
| + })) |
| + defer ts.Close() |
| + |
| + buf := captureStdout(t) |
| + // Trailing slash on -url, deliberately, to pin strings.TrimRight. |
| + runLedgerVerify([]string{"-app", "platform", "-account", "bab", "-url", ts.URL + "/", "carbon"}) |
| + |
| + if !strings.Contains(buf.String(), "verified: 2 entries") { |
| + t.Fatalf("stdout = %q, want the verified line", buf.String()) |
| + } |
| +} |