carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package ledger
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/carlosframework/platform/internal/release"
13)
14
15func fixedNow() time.Time { return time.Date(2026, 8, 3, 21, 4, 0, 0, time.UTC) }
16
17func TestAppendFirstAndSecond(t *testing.T) {
18 st := release.NewDirStore(t.TempDir())
19 ctx := context.Background()
20
21 a1, err := Append(ctx, st, "bab", "platform", "carbon", "paul@keymail.dev",
22 json.RawMessage(`{"n":1}`), fixedNow)
23 if err != nil {
24 t.Fatal(err)
25 }
26 // HEAD points at the entry, count 1, and the stored bytes hash to
27 // the recorded sha.
28 hb, _, err := st.Get(ctx, HeadKey("bab", "platform", "carbon"))
29 if err != nil {
30 t.Fatal(err)
31 }
32 var h Head
33 if err := json.Unmarshal(hb, &h); err != nil {
34 t.Fatal(err)
35 }
36 if h.HeadKey != a1.Key || h.Count != 1 || h.Schema != Schema {
37 t.Fatalf("head = %+v, appended = %+v", h, a1)
38 }
39 eb, _, err := st.Get(ctx, a1.Key)
40 if err != nil {
41 t.Fatal(err)
42 }
43 if SHA256Hex(eb) != a1.SHA256 || h.HeadSHA256 != a1.SHA256 {
44 t.Fatal("stored bytes do not hash to the recorded sha")
45 }
46 var e1 Entry
47 if err := json.Unmarshal(eb, &e1); err != nil {
48 t.Fatal(err)
49 }
50 if e1.PrevKey != "" || e1.PrevSHA256 != "" || e1.Writer != "paul@keymail.dev" {
51 t.Fatalf("first entry = %+v", e1)
52 }
53
54 // Second append chains to the first.
55 later := func() time.Time { return fixedNow().Add(time.Minute) }
56 a2, err := Append(ctx, st, "bab", "platform", "carbon", "paul@keymail.dev",
57 json.RawMessage(`{"n":2}`), later)
58 if err != nil {
59 t.Fatal(err)
60 }
61 eb2, _, _ := st.Get(ctx, a2.Key)
62 var e2 Entry
63 if err := json.Unmarshal(eb2, &e2); err != nil {
64 t.Fatal(err)
65 }
66 if e2.PrevKey != a1.Key || e2.PrevSHA256 != a1.SHA256 || a2.Count != 2 {
67 t.Fatalf("second entry does not chain: %+v (count %d)", e2, a2.Count)
68 }
69}
70
71func TestAppendRejects(t *testing.T) {
72 st := release.NewDirStore(t.TempDir())
73 ctx := context.Background()
74 if _, err := Append(ctx, st, "bab", "platform", "Not A Name", "w",
75 json.RawMessage(`{}`), fixedNow); err == nil {
76 t.Fatal("bad ledger name accepted")
77 }
78 big := json.RawMessage(`"` + string(make([]byte, MaxPayload)) + `"`)
79 if _, err := Append(ctx, st, "bab", "platform", "carbon", "w", big, fixedNow); err == nil {
80 t.Fatal("oversized payload accepted")
81 }
82 if _, err := Append(ctx, st, "bab", "platform", "carbon", "w",
83 json.RawMessage(`{not json`), fixedNow); err == nil {
84 t.Fatal("invalid JSON payload accepted")
85 }
86}
87
88// casOnceStore makes the FIRST head Put lose its race, as if another
89// writer advanced HEAD between our read and our write.
90type casOnceStore struct {
91 release.Store
92 tripped bool
93}
94
95func (c *casOnceStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
96 if !c.tripped && key == HeadKey("bab", "platform", "carbon") {
97 c.tripped = true
98 // Simulate the race: a competing append lands first.
99 if _, err := Append(ctx, c.Store, "bab", "platform", "carbon", "rival",
100 json.RawMessage(`{"rival":true}`), fixedNow); err != nil {
101 return "", err
102 }
103 return "", release.ErrCASConflict
104 }
105 return c.Store.Put(ctx, key, body, ifMatch)
106}
107
108func TestAppendRetriesLostRace(t *testing.T) {
109 st := &casOnceStore{Store: release.NewDirStore(t.TempDir())}
110 ctx := context.Background()
111 later := func() time.Time { return fixedNow().Add(time.Minute) }
112 a, err := Append(ctx, st, "bab", "platform", "carbon", "me",
113 json.RawMessage(`{"mine":true}`), later)
114 if err != nil {
115 t.Fatal(err)
116 }
117 if a.Count != 2 {
118 t.Fatalf("count = %d, want 2 (rival's entry then ours)", a.Count)
119 }
120 eb, _, err := st.Get(ctx, a.Key)
121 if errors.Is(err, os.ErrNotExist) || err != nil {
122 t.Fatal(err)
123 }
124 var e Entry
125 if err := json.Unmarshal(eb, &e); err != nil {
126 t.Fatal(err)
127 }
128 if e.PrevKey == "" {
129 t.Fatal("retried entry did not re-chain onto the rival's head")
130 }
131}
132
133// alwaysCASStore loses every HEAD race — the shape of a chain under more
134// concurrent writers than the retry loop can outlast.
135type alwaysCASStore struct{ release.Store }
136
137func (a alwaysCASStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
138 if strings.HasSuffix(key, "/HEAD.json") {
139 return "", release.ErrCASConflict
140 }
141 return a.Store.Put(ctx, key, body, ifMatch)
142}
143
144// TestAppendExhaustionIsErrBusy: losing every race is neither the caller's
145// fault nor a broken store — it is retry-worthy, and a caller must be able
146// to tell WITHOUT matching on error text, because callers that match on text
147// break the first time the sentence changes.
148func TestAppendExhaustionIsErrBusy(t *testing.T) {
149 st := alwaysCASStore{Store: release.NewDirStore(t.TempDir())}
150 _, err := Append(context.Background(), st, "bab", "platform", "carbon", "me",
151 json.RawMessage(`{"n":1}`), fixedNow)
152 if err == nil {
153 t.Fatal("Append succeeded against a store that loses every HEAD race")
154 }
155 if !errors.Is(err, ErrBusy) {
156 t.Fatalf("exhaustion err = %v, want it to wrap ErrBusy", err)
157 }
158 // The underlying cause is still reachable — the sentinel adds to the
159 // chain, it does not replace it.
160 if !errors.Is(err, release.ErrCASConflict) {
161 t.Fatalf("exhaustion err = %v, want it to still wrap ErrCASConflict", err)
162 }
163 // A caller-caused failure must NOT look busy.
164 _, err = Append(context.Background(), st, "bab", "platform", "Not Valid", "me",
165 json.RawMessage(`{"n":1}`), fixedNow)
166 if err == nil || errors.Is(err, ErrBusy) {
167 t.Fatalf("invalid-name err = %v, want a non-ErrBusy failure", err)
168 }
169}
170