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
3import (
4 "encoding/json"
5 "testing"
6 "time"
7
8 "github.com/carlosframework/platform/internal/domains"
9 "github.com/carlosframework/platform/internal/hostops"
10 "github.com/carlosframework/platform/internal/release"
11)
12
13// The store is gated from the start; the row is seeded under the test-only
14// seed scope, and every projection write must pass the console's own grants.
15func TestMaterializeClaimPurgeAcrossConsoles(t *testing.T) {
16 for _, phase := range []string{"journal", "complete", "successor", "successor-unproven", "open"} {
17 t.Run(phase, func(t *testing.T) {
18 ctx := t.Context()
19 bucket := release.NewDirStore(t.TempDir())
20 st := openGatedStore(t)
21 id, sqid, err := domains.MintAccount(ctx, bucket, "Account", "owner@keymail.dev", time.Now())
22 if err != nil {
23 t.Fatal(err)
24 }
25 if _, err = MaterializeAccounts(ctx, bucket, st); err != nil {
26 t.Fatal(err)
27 }
28 err = st.withClaimSQL(gateCtx(t), []claimSQLScope{{AccountID: id, App: "demo", AppID: "old", Mode: claimModeEffect}}, allowAllClaimSQL, func(v *Store) error {
29 if _, err := v.CreateAppIn(id, "demo", "owner@keymail.dev", time.Now()); err != nil {
30 return err
31 }
32 if err := v.BindClaimAppIdentity(id, "demo", "old"); err != nil {
33 return err
34 }
35 return v.TrashAppRow(id, "demo", time.Now())
36 })
37 if err != nil {
38 t.Fatal(err)
39 }
40 a := hostops.AppAdmission{V: 1, AppID: "old", Revision: 5, Epoch: 1, State: "closed", Registrations: map[string]hostops.Registration{}, Effects: map[string]hostops.Effect{},
41 LastPurge: &hostops.PurgeMark{ID: "purge", AppID: "old"}}
42 if phase == "journal" {
43 a.Op = &hostops.AppOperation{ID: "purge", AppID: "old", Kind: "purging"}
44 if _, err = bucket.Put(ctx, "apps/"+sqid+"/demo/purge.json", []byte(`{}`), ""); err != nil {
45 t.Fatal(err)
46 }
47 }
48 if phase == "successor" || phase == "successor-unproven" {
49 a.AppID, a.State = "new", "open"
50 a.Epoch++
51 }
52 if phase == "successor-unproven" {
53 // An unequal app_id alone is not terminal evidence.
54 a.LastPurge = &hostops.PurgeMark{ID: "purge", AppID: "some-other"}
55 }
56 if phase == "open" {
57 a.State = "open"
58 }
59 body, _ := json.Marshal(a)
60 if _, err = bucket.Put(ctx, "apps/"+sqid+"/demo/admission.json", body, ""); err != nil {
61 t.Fatal(err)
62 }
63 before, _, err := bucket.Get(ctx, "apps/"+sqid+"/demo/admission.json")
64 if err != nil {
65 t.Fatal(err)
66 }
67 if err = MaterializeApps(ctx, bucket, st, true); err != nil {
68 t.Fatal(err)
69 }
70 rows, err := st.AppRows()
71 if err != nil {
72 t.Fatal(err)
73 }
74 if phase == "complete" || phase == "successor" {
75 if len(rows) != 0 {
76 t.Fatal("retired row retained or revived")
77 }
78 } else if len(rows) != 1 || !rows[0].Deleted {
79 t.Fatal("purge or successor revived old row")
80 }
81 after, _, err := bucket.Get(ctx, "apps/"+sqid+"/demo/admission.json")
82 if err != nil || string(after) != string(before) {
83 t.Fatalf("materialization wrote canonical admission: %v", err)
84 }
85 })
86 }
87}
88
89// Absence never restores: a trashed row with no marker, no receipt and open
90// admission stays trashed, and a prepared (not completed) receipt is not a
91// restore either.
92func TestMaterializeClaimAbsenceIsNotRestore(t *testing.T) {
93 for _, state := range []string{"none", "prepared"} {
94 t.Run(state, func(t *testing.T) {
95 ctx := t.Context()
96 bucket := release.NewDirStore(t.TempDir())
97 st := openGatedStore(t)
98 id, sqid, err := domains.MintAccount(ctx, bucket, "Account", "owner@keymail.dev", time.Now())
99 if err != nil {
100 t.Fatal(err)
101 }
102 if _, err = MaterializeAccounts(ctx, bucket, st); err != nil {
103 t.Fatal(err)
104 }
105 trashed := time.Now().Add(-time.Hour)
106 err = st.withClaimSQL(gateCtx(t), []claimSQLScope{{AccountID: id, App: "demo", AppID: "old", Mode: claimModeEffect}}, allowAllClaimSQL, func(v *Store) error {
107 if _, err := v.CreateAppIn(id, "demo", "owner@keymail.dev", trashed); err != nil {
108 return err
109 }
110 if err := v.BindClaimAppIdentity(id, "demo", "old"); err != nil {
111 return err
112 }
113 return v.TrashAppRow(id, "demo", trashed)
114 })
115 if err != nil {
116 t.Fatal(err)
117 }
118 a := hostops.AppAdmission{V: 1, AppID: "old", Revision: 2, Epoch: 1, State: "open", Registrations: map[string]hostops.Registration{}, Effects: map[string]hostops.Effect{}}
119 if state == "prepared" {
120 a.LastRestore = &hostops.RestoreMark{ID: "r", AppID: "old", Epoch: 1, TombstoneETag: "e", DeletedAt: trashed.Unix(), State: "prepared"}
121 }
122 body, _ := json.Marshal(a)
123 if _, err = bucket.Put(ctx, "apps/"+sqid+"/demo/admission.json", body, ""); err != nil {
124 t.Fatal(err)
125 }
126 if _, err = bucket.Put(ctx, "apps/"+sqid+"/demo/releases/r1.json", []byte(`{}`), ""); err != nil {
127 t.Fatal(err)
128 }
129 if err = MaterializeApps(ctx, bucket, st, true); err != nil {
130 t.Fatal(err)
131 }
132 if has, err := st.HasTrashedAppIn(id, "demo"); err != nil || !has {
133 t.Fatalf("absence restored the row: %v %v", has, err)
134 }
135 })
136 }
137}
138
139// Two consoles share one bucket. A purge completes and console A replays it;
140// the name is then recreated (new app_id, last_purge retained) and console A
141// binds its row to the new incarnation. Console B lagged through all of it
142// and still has the retired row: its next materialization replays the
143// retired incarnation's purge, keyed on the retired app_id, and nothing of
144// the successor (on either console) or of canonical state changes.
145func TestMaterializeClaimPurgeReplaysAfterRecreationOnLaggingConsole(t *testing.T) {
146 ctx := t.Context()
147 bucket := release.NewDirStore(t.TempDir())
148 id, sqid, err := domains.MintAccount(ctx, bucket, "Account", "owner@keymail.dev", time.Now())
149 if err != nil {
150 t.Fatal(err)
151 }
152 seed := func(st *Store, appID string, trashed bool) {
153 t.Helper()
154 err := st.withClaimSQL(gateCtx(t), []claimSQLScope{{AccountID: id, App: "demo", AppID: appID, Mode: claimModeEffect}}, allowAllClaimSQL, func(v *Store) error {
155 if _, err := v.CreateAppIn(id, "demo", "owner@keymail.dev", time.Now()); err != nil {
156 return err
157 }
158 if err := v.BindClaimAppIdentity(id, "demo", appID); err != nil {
159 return err
160 }
161 if !trashed {
162 return nil
163 }
164 return v.TrashAppRow(id, "demo", time.Now())
165 })
166 if err != nil {
167 t.Fatal(err)
168 }
169 }
170 consoleA, consoleB := openGatedStore(t), openGatedStore(t)
171 for _, st := range []*Store{consoleA, consoleB} {
172 if _, err = MaterializeAccounts(ctx, bucket, st); err != nil {
173 t.Fatal(err)
174 }
175 seed(st, "old", true)
176 }
177 key := "apps/" + sqid + "/demo/admission.json"
178 put := func(a hostops.AppAdmission) {
179 t.Helper()
180 _, tag, err := bucket.Get(ctx, key)
181 if err != nil {
182 tag = ""
183 }
184 body, _ := json.Marshal(a)
185 if _, err := bucket.Put(ctx, key, body, tag); err != nil {
186 t.Fatal(err)
187 }
188 }
189 retired := hostops.AppAdmission{V: 1, AppID: "old", Revision: 5, Epoch: 3, State: "closed", Registrations: map[string]hostops.Registration{}, Effects: map[string]hostops.Effect{},
190 LastPurge: &hostops.PurgeMark{ID: "purge-1", AppID: "old"}}
191 put(retired)
192 if err := MaterializeApps(ctx, bucket, consoleA, true); err != nil {
193 t.Fatal(err)
194 }
195 if rows, err := consoleA.AppRows(); err != nil || len(rows) != 0 {
196 t.Fatalf("console A kept the retired row: %d %v", len(rows), err)
197 }
198 // Recreation: what EnrollNewApp writes (new app_id, revision and epoch
199 // advanced, last_purge carried over). Console A binds its new row.
200 put(hostops.AppAdmission{V: 1, AppID: "new", Revision: 6, Epoch: 4, State: "open", Registrations: map[string]hostops.Registration{}, Effects: map[string]hostops.Effect{},
201 LastPurge: retired.LastPurge})
202 seed(consoleA, "new", false)
203 before, _, err := bucket.Get(ctx, key)
204 if err != nil {
205 t.Fatal(err)
206 }
207 // Console B catches up.
208 if err := MaterializeApps(ctx, bucket, consoleB, true); err != nil {
209 t.Fatal(err)
210 }
211 if rows, err := consoleB.AppRows(); err != nil || len(rows) != 0 {
212 t.Fatalf("lagging console kept the retired row after recreation: %+v %v", rows, err)
213 }
214 // Idempotent, and the successor on console A is untouched.
215 for _, st := range []*Store{consoleA, consoleB} {
216 if err := MaterializeApps(ctx, bucket, st, true); err != nil {
217 t.Fatal(err)
218 }
219 }
220 if got, err := consoleA.ClaimAppID(id, "demo"); err != nil || got != "new" {
221 t.Fatalf("successor row on console A: %q %v", got, err)
222 }
223 if has, err := consoleA.HasTrashedAppIn(id, "demo"); err != nil || has {
224 t.Fatalf("successor row trashed: %v %v", has, err)
225 }
226 after, _, err := bucket.Get(ctx, key)
227 if err != nil || string(after) != string(before) {
228 t.Fatalf("materialization wrote canonical admission (last_purge must be retained): %v", err)
229 }
230 var kept hostops.AppAdmission
231 if err := json.Unmarshal(after, &kept); err != nil || kept.LastPurge == nil || *kept.LastPurge != *retired.LastPurge {
232 t.Fatalf("last_purge not retained: %+v %v", kept.LastPurge, err)
233 }
234}
235