package ledger import ( "context" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "github.com/carlosframework/platform/internal/release" ) func chainOf(t *testing.T, st release.Store, n int) { t.Helper() for i := range n { at := func() time.Time { return fixedNow().Add(time.Duration(i) * time.Minute) } if _, err := Append(context.Background(), st, "bab", "platform", "carbon", "w", json.RawMessage(`{"i":`+string(rune('0'+i))+`}`), at); err != nil { t.Fatal(err) } } } func TestVerifyGoodChain(t *testing.T) { st := release.NewDirStore(t.TempDir()) chainOf(t, st, 3) res, err := Verify(context.Background(), StoreGetter(st), "bab", "platform", "carbon") if err != nil { t.Fatal(err) } if res.Count != 3 { t.Fatalf("count = %d, want 3", res.Count) } } func TestVerifyDetectsTamper(t *testing.T) { dir := t.TempDir() st := release.NewDirStore(dir) chainOf(t, st, 3) // Flip one byte in the OLDEST entry's stored file, behind the store's // back — exactly the mutation a hash chain exists to catch. keys, err := st.List(context.Background(), Prefix("bab", "platform", "carbon")+"entries/") if err != nil || len(keys) != 3 { t.Fatalf("entries: %v %v", keys, err) } p := filepath.Join(dir, filepath.FromSlash(keys[0])) b, err := os.ReadFile(p) if err != nil { t.Fatal(err) } b[len(b)-2] ^= 1 if err := os.WriteFile(p, b, 0o644); err != nil { t.Fatal(err) } if _, err := Verify(context.Background(), StoreGetter(st), "bab", "platform", "carbon"); err == nil { t.Fatal("tampered chain verified clean") } } // TestVerifyPinsTheWalkToItsOwnLedger: a verifier follows prev_key, and // prev_key is bytes the chain itself supplies — so a chain that points its // walk at ANOTHER ledger's entries (or at an object outside the ledger // prefix entirely) must fail, not quietly borrow that object's history and // report a clean verification. The count and the hashes can all be made to // agree; what cannot be forged is the entry's own ledger name and the prefix // its keys live under. func TestVerifyPinsTheWalkToItsOwnLedger(t *testing.T) { ctx := context.Background() st := release.NewDirStore(t.TempDir()) // A real two-entry chain in "iron", whose genesis we will graft onto a // hand-built "carbon" chain. if _, err := Append(ctx, st, "bab", "platform", "iron", "w", json.RawMessage(`{"i":0}`), fixedNow); err != nil { t.Fatal(err) } ib, _, err := st.Get(ctx, HeadKey("bab", "platform", "iron")) if err != nil { t.Fatal(err) } var ih Head if err := json.Unmarshal(ib, &ih); err != nil { t.Fatal(err) } // carbon's single entry chains to iron's genesis: every hash checks out, // the walk terminates, and the count matches. e := Entry{Schema: Schema, Ledger: "carbon", Writer: "w", TS: "2026-08-03T21:00:00Z", Payload: json.RawMessage(`{"i":1}`), PrevKey: ih.HeadKey, PrevSHA256: ih.HeadSHA256} eb, err := json.Marshal(e) if err != nil { t.Fatal(err) } sha := SHA256Hex(eb) key := EntryKey("bab", "platform", "carbon", e.TS, sha) if _, err := st.Put(ctx, key, eb, ""); err != nil { t.Fatal(err) } hb, err := json.Marshal(Head{Schema: Schema, UpdatedAt: e.TS, HeadKey: key, HeadSHA256: sha, Count: 2}) if err != nil { t.Fatal(err) } if _, err := st.Put(ctx, HeadKey("bab", "platform", "carbon"), hb, ""); err != nil { t.Fatal(err) } _, err = Verify(ctx, StoreGetter(st), "bab", "platform", "carbon") if err == nil { t.Fatal("a chain walking into another ledger's entries verified clean") } if !strings.Contains(err.Error(), "carbon") { t.Fatalf("error = %v, want it to name the ledger the walk left", err) } } func TestVerifyOverHTTP(t *testing.T) { dir := t.TempDir() st := release.NewDirStore(dir) chainOf(t, st, 2) 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() res, err := Verify(context.Background(), HTTPGetter(ts.Client(), ts.URL), "bab", "platform", "carbon") if err != nil || res.Count != 2 { t.Fatalf("res=%+v err=%v", res, err) } }