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 "context"
5 "crypto/ed25519"
6 "encoding/json"
7 "errors"
8 "net/url"
9 "strconv"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/carlosframework/platform/internal/hostactors"
15 "github.com/carlosframework/platform/internal/hostops"
16 "github.com/carlosframework/platform/internal/lease/leasetest"
17 "github.com/carlosframework/platform/internal/release"
18)
19
20// Every console in these tests is built by the full production constructor
21// with the flag on from the start, exactly as cmd/console builds it:
22// OpenGated (triggers armed before NewServer), NewServer with HostClaim and
23// the RAW bucket, raw versioned store, signing key and console id in Config,
24// then ConfigureClaimRuntime, which reads the signed roster, builds the
25// control writer, the production data and versioned boundaries and the
26// authority, and replaces every raw handle in Config. Nothing is set on the
27// authority by hand and nothing is switched on after construction. Nothing
28// below writes SQL except through the console's own code or the test-only
29// seed scope, and the raw bucket is only written to stage the world
30// (roster, markers, the initial admission).
31
32const (
33 crAcctID = int64(OpsAccountID) // NewServer's backfill creates it, sqid "bab"
34 crSqid = "bab"
35 crApp = "kass"
36 crAppID = "incarnation-a"
37 crOwner = "paul@keymail.dev"
38 crBucket = "deploy-bucket"
39 crNS = "platform"
40 crConsole = "console-a"
41 crOther = "console-b"
42)
43
44var crEnrollment = map[string]string{crConsole: strings.Repeat("a", 32), crOther: strings.Repeat("b", 32)}
45
46type claimRestoreWorld struct {
47 ctx context.Context
48 bucket *release.DirStore
49 a, b *Server
50}
51
52// publishRestoreRoster signs the host-actor roster naming both consoles
53// into the raw bucket, as the operator's roster publication does.
54func publishRestoreRoster(t *testing.T, raw release.Store, key ed25519.PrivateKey) {
55 t.Helper()
56 consoles := map[string]hostactors.Console{}
57 for id, enrollment := range crEnrollment {
58 consoles[id] = hostactors.Console{Enrollment: enrollment, InstanceID: id, Namespaces: []string{crNS}, DeploymentBucket: crBucket}
59 }
60 roster := hostactors.Roster{V: 1, DeploymentBucket: crBucket, Revision: 1, Boxes: map[string]hostactors.Box{}, Consoles: consoles,
61 Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}}
62 envelope, err := release.SignRootEnvelope(release.RootSigner(key), roster)
63 if err != nil {
64 t.Fatal(err)
65 }
66 if _, err := raw.Put(context.Background(), hostactors.Key, envelope, ""); err != nil {
67 t.Fatal(err)
68 }
69}
70
71func newEnabledRestoreConsole(t *testing.T, raw release.Store, key ed25519.PrivateKey, name string) *Server {
72 t.Helper()
73 st := openGatedStore(t)
74 if !st.claimGated() {
75 t.Fatal("OpenGated returned an ungated store")
76 }
77 versions := leasetest.NewVersionedBucket()
78 versions.AddBucket(crBucket)
79 srv, err := NewServer(Config{Origin: "https://console.example", Keymail: "https://keymail.example", HostClaim: true,
80 HostConsoleID: name, Bucket: crBucket, Releases: raw, DBVersions: versions,
81 Key: key, ReleasePub: key.Public().(ed25519.PublicKey)}, st)
82 if err != nil {
83 t.Fatal(err)
84 }
85 if err := srv.ConfigureClaimRuntime(t.Context()); err != nil {
86 t.Fatalf("ConfigureClaimRuntime: %v", err)
87 }
88 a := srv.cfg.ClaimAuthority
89 if a == nil || a.Control == nil || a.ConsoleID != name || a.Enrollment != crEnrollment[name] || srv.cfg.Releases == raw {
90 t.Fatalf("production constructor did not install the authority over the raw bucket: %+v", a)
91 }
92 if _, _, err := srv.claimRestoreRuntime(); err != nil {
93 t.Fatalf("restore runtime: %v", err)
94 }
95 return srv
96}
97
98// seedClaimRow writes this console's row for the incarnation, under the
99// test-only seed scope: the world before the test, not a door under test.
100func seedClaimRow(t *testing.T, st *Store, trashedAt time.Time) {
101 t.Helper()
102 scope := []claimSQLScope{{AccountID: crAcctID, App: crApp, AppID: crAppID, Mode: claimModeEffect}}
103 err := st.withClaimSQL(gateCtx(t), scope, allowAllClaimSQL, func(v *Store) error {
104 if _, err := v.CreateAppIn(crAcctID, crApp, crOwner, trashedAt.Add(-time.Hour)); err != nil {
105 return err
106 }
107 if err := v.BindClaimAppIdentity(crAcctID, crApp, crAppID); err != nil {
108 return err
109 }
110 return v.TrashAppRow(crAcctID, crApp, trashedAt)
111 })
112 if err != nil {
113 t.Fatal(err)
114 }
115}
116
117func newClaimRestoreWorld(t *testing.T) (*claimRestoreWorld, time.Time) {
118 t.Helper()
119 w := &claimRestoreWorld{ctx: t.Context(), bucket: release.NewDirStore(t.TempDir())}
120 a := hostops.AppAdmission{V: 1, AppID: crAppID, Revision: 1, Epoch: 1, State: "open", Registrations: map[string]hostops.Registration{}, Effects: map[string]hostops.Effect{}}
121 body, _ := json.Marshal(a)
122 if _, err := w.bucket.Put(w.ctx, "apps/"+crSqid+"/"+crApp+"/admission.json", body, ""); err != nil {
123 t.Fatal(err)
124 }
125 if _, err := w.bucket.Put(w.ctx, "apps/"+crSqid+"/"+crApp+"/releases/r1.json", []byte(`{}`), ""); err != nil {
126 t.Fatal(err)
127 }
128 trashed := time.Now().Add(-time.Hour).UTC()
129 if err := release.TrashApp(w.ctx, w.bucket, crSqid, crApp, crOwner, trashed); err != nil {
130 t.Fatal(err)
131 }
132 _, key, err := ed25519.GenerateKey(nil)
133 if err != nil {
134 t.Fatal(err)
135 }
136 publishRestoreRoster(t, w.bucket, key)
137 w.a = newEnabledRestoreConsole(t, w.bucket, key, crConsole)
138 w.b = newEnabledRestoreConsole(t, w.bucket, key, crOther)
139 seedClaimRow(t, w.a.store, trashed)
140 seedClaimRow(t, w.b.store, trashed)
141 return w, trashed
142}
143
144func (w *claimRestoreWorld) account(t *testing.T, s *Server) *Account {
145 t.Helper()
146 acct, err := s.store.AccountBySqid(crSqid)
147 if err != nil || acct == nil {
148 t.Fatalf("account: %+v %v", acct, err)
149 }
150 return acct
151}
152
153func (w *claimRestoreWorld) admission(t *testing.T) hostops.AdmissionObserved {
154 t.Helper()
155 a, err := hostops.ReadAdmission(w.ctx, w.bucket, crSqid, crApp, crAppID)
156 if err != nil {
157 t.Fatal(err)
158 }
159 return a
160}
161
162func (w *claimRestoreWorld) marker(t *testing.T) (*release.AppTombstone, string) {
163 t.Helper()
164 tomb, tag, err := readTombstoneVersion(w.ctx, w.bucket, crSqid, crApp)
165 if err != nil {
166 t.Fatal(err)
167 }
168 return tomb, tag
169}
170
171func requireLife(t *testing.T, s *Server, trashed, restoring bool) {
172 t.Helper()
173 life, err := s.store.AppLife(crAcctID, crApp)
174 if err != nil || life == nil {
175 t.Fatalf("life: %+v %v", life, err)
176 }
177 if life.Trashed() != trashed || life.Restoring != restoring {
178 t.Fatalf("row trashed=%v restoring=%v, want %v %v", life.Trashed(), life.Restoring, trashed, restoring)
179 }
180}
181
182func requireSettledReceipt(t *testing.T, a hostops.AdmissionObserved) hostops.RestoreMark {
183 t.Helper()
184 v := a.Value
185 if v.State != "open" || v.Op != nil || v.LastRestore == nil || v.LastRestore.State != "complete" || v.LastRestore.AppID != crAppID || v.LastRestore.Epoch == 0 || v.LastRestore.Epoch > v.Epoch {
186 t.Fatalf("restore not settled: %+v receipt %+v", v, v.LastRestore)
187 }
188 for id, e := range v.Effects {
189 if e.State != "complete" {
190 t.Fatalf("effect %s still %s", id, e.State)
191 }
192 }
193 return *v.LastRestore
194}
195
196func (w *claimRestoreWorld) materialize(t *testing.T, s *Server) {
197 t.Helper()
198 if err := MaterializeApps(w.ctx, s.cfg.Releases, s.store, true); err != nil {
199 t.Fatal(err)
200 }
201}
202
203// Issuer crash points, in restoreClaimApp's order.
204const (
205 crashAfterReceipt = iota // PrepareRestore saved, nothing issued
206 crashAfterIssue // effect issued, restoring slot set, marker still there
207 crashAfterDelete // the exact-ETag delete ran, receipt still prepared
208)
209
210// crashAt drives console A's own issuer steps up to a crash point, through
211// the same control writer, permit, grant and data boundary restoreClaimApp
212// uses, in its order (BeginExclusive, PrepareRestore, Issue, SQL, delete),
213// and stops without settling.
214func (w *claimRestoreWorld) crashAt(t *testing.T, point int) (op, etag string) {
215 t.Helper()
216 control, self, err := w.a.claimRestoreRuntime()
217 if err != nil {
218 t.Fatal(err)
219 }
220 tomb, etag := w.marker(t)
221 op = "restore-crashed"
222 owner := self
223 owner.Controller = op
224 now := time.Now()
225 h, err := control.BeginExclusive(w.ctx, crSqid, crApp, crAppID, hostops.AppOperation{ID: op, Kind: hostops.OpAppMutation, AppID: crAppID, Phase: "issued",
226 Intent: map[string]string{"action": "restore", hostops.RestoreActionStarted: strconv.FormatInt(now.Unix(), 10)}}, owner)
227 if err != nil {
228 t.Fatal(err)
229 }
230 if err := h.PrepareRestore(w.ctx, etag, tomb.DeletedAt.Unix()); err != nil {
231 t.Fatal(err)
232 }
233 if point == crashAfterReceipt {
234 return op, etag
235 }
236 pctx, _, err := h.Runner(time.Now).Issue(w.ctx, hostops.Effect{ID: op, Resource: hostops.OpAppMutation, Action: "restore"})
237 if err != nil {
238 t.Fatal(err)
239 }
240 scope := claimSQLScope{AccountID: crAcctID, App: crApp, AppID: crAppID}
241 err = w.a.store.WithAppMutation(w.ctx, []sqlGrant{permitGrant(pctx, w.a.cfg.Releases, crSqid, scope)}, func(v *Store) error {
242 _, err := v.SetClaimRestoring(crAcctID, crApp, crAppID, true)
243 return err
244 })
245 if err != nil {
246 t.Fatal(err)
247 }
248 if point == crashAfterDelete {
249 if err := release.DeleteIfMatch(pctx, w.a.cfg.Releases, release.AppTombstoneKey(crSqid, crApp), etag); err != nil {
250 t.Fatal(err)
251 }
252 }
253 return op, etag
254}
255
256// closeAdmission stages a foreign exclusive operation (as a purge would
257// hold) straight into the bucket.
258func (w *claimRestoreWorld) closeAdmission(t *testing.T) hostops.AdmissionObserved {
259 t.Helper()
260 a := w.admission(t)
261 v := a.Value
262 v.State = "closed"
263 v.Op = &hostops.AppOperation{ID: "purge", Kind: hostops.OpPurging, AppID: crAppID}
264 v.Revision++
265 body, _ := json.Marshal(v)
266 if _, err := w.bucket.Put(w.ctx, a.Key, body, a.ETag); err != nil {
267 t.Fatal(err)
268 }
269 return w.admission(t)
270}
271
272func TestClaimRestoreGateIsArmedOnEnabledConsoles(t *testing.T) {
273 w, _ := newClaimRestoreWorld(t)
274 for _, s := range []*Server{w.a, w.b} {
275 _, err := s.store.db.Exec(`UPDATE apps SET deleted_at = 0 WHERE app = ?`, crApp)
276 requireDenied(t, "ungated restore write", err)
277 requireLife(t, s, true, false)
278 }
279 if _, err := NewServer(Config{Origin: "https://console.example", Keymail: "https://keymail.example", Releases: w.bucket, HostClaim: true}, openTestStore(t)); err == nil {
280 t.Fatal("NewServer accepted HostClaim on an ungated database")
281 }
282 ungated, err := NewServer(Config{Origin: "https://console.example", Keymail: "https://keymail.example", Releases: w.bucket}, openTestStore(t))
283 if err != nil {
284 t.Fatal(err)
285 }
286 if ungated.store.claimGated() {
287 t.Fatal("flag-off console was gated")
288 }
289}
290
291func TestClaimRestoreAcrossTwoGatedConsoles(t *testing.T) {
292 w, _ := newClaimRestoreWorld(t)
293 _, oldTag := w.marker(t)
294 if err := w.a.restoreApp(w.ctx, w.account(t, w.a), crApp, time.Now()); err != nil {
295 t.Fatalf("restore: %v", err)
296 }
297 if tomb, _ := w.marker(t); tomb != nil {
298 t.Fatal("marker survived the restore")
299 }
300 receipt := requireSettledReceipt(t, w.admission(t))
301 if receipt.TombstoneETag != oldTag {
302 t.Fatalf("receipt names %q, want the deleted version %q", receipt.TombstoneETag, oldTag)
303 }
304 requireLife(t, w.a, false, false)
305 // Console B consumes the same incarnation-bound receipt and writes no
306 // canonical state.
307 before := w.admission(t)
308 requireLife(t, w.b, true, false)
309 w.materialize(t, w.b)
310 requireLife(t, w.b, false, false)
311 if after := w.admission(t); after.Value.AppID != before.Value.AppID || after.Value.Op != nil || *after.Value.LastRestore != *before.Value.LastRestore {
312 t.Fatal("projection changed the restore evidence or left admission closed")
313 }
314 // A later trash wins although the old receipt survives, on both consoles.
315 if err := release.TrashApp(w.ctx, w.bucket, crSqid, crApp, crOwner, time.Now().Add(time.Minute)); err != nil {
316 t.Fatal(err)
317 }
318 w.materialize(t, w.b)
319 w.materialize(t, w.a)
320 requireLife(t, w.b, true, false)
321 requireLife(t, w.a, true, false)
322 // The old receipt cannot be replayed against the new marker.
323 scope := claimSQLScope{AccountID: crAcctID, App: crApp, AppID: crAppID}
324 err := w.b.store.WithAppMutation(w.ctx, []sqlGrant{restoreReceiptGrant(w.ctx, w.bucket, crSqid, scope, receipt)}, func(v *Store) error {
325 _, err := v.FinishClaimRestoreRow(crAcctID, crApp, crAppID, receipt.DeletedAt, time.Now())
326 return err
327 })
328 if !errors.Is(err, ErrClaimSQLDenied) {
329 t.Fatalf("old receipt under a new marker: %v", err)
330 }
331 requireLife(t, w.b, true, false)
332}
333
334// The receipt precedes Issue, so both crash windows before the delete
335// (no effect yet, and an issued effect) are finished from the receipt and
336// never abandoned. The no-effect window needs ControlWriter.RecoverMutation
337// to accept a prepared receipt with no effect (control-work adaptation).
338func TestClaimRestoreCrashAfterReceiptBeforeDelete(t *testing.T) {
339 for _, c := range []struct {
340 name string
341 point int
342 finisher string
343 }{
344 {"receipt-only/sweep", crashAfterReceipt, "sweep"},
345 {"receipt-only/retry", crashAfterReceipt, "retry"},
346 {"issued/sweep", crashAfterIssue, "sweep"},
347 {"issued/retry", crashAfterIssue, "retry"},
348 } {
349 finisher := c.finisher
350 t.Run(c.name, func(t *testing.T) {
351 w, _ := newClaimRestoreWorld(t)
352 op, etag := w.crashAt(t, c.point)
353 if _, tag := w.marker(t); tag != etag {
354 t.Fatal("fixture is not the crash window")
355 }
356 requireLife(t, w.a, true, c.point == crashAfterIssue)
357 // Projection never restores from a prepared receipt.
358 w.materialize(t, w.b)
359 requireLife(t, w.b, true, false)
360 switch finisher {
361 case "sweep": // the other console's admission sweep
362 if err := w.b.reconcileClaimRestore(w.ctx, crSqid, crApp, crAppID); err != nil {
363 t.Fatal(err)
364 }
365 w.materialize(t, w.a)
366 case "retry": // the person presses Restore again on console A
367 if err := w.a.restoreApp(w.ctx, w.account(t, w.a), crApp, time.Now()); err != nil {
368 t.Fatalf("retry: %v", err)
369 }
370 }
371 if tomb, _ := w.marker(t); tomb != nil {
372 t.Fatal("receipt was not replayed")
373 }
374 if r := requireSettledReceipt(t, w.admission(t)); r.ID != op || r.TombstoneETag != etag {
375 t.Fatalf("settled the wrong receipt: %+v", r)
376 }
377 w.materialize(t, w.b)
378 requireLife(t, w.a, false, false)
379 requireLife(t, w.b, false, false)
380 })
381 }
382}
383
384func TestClaimRestoreCrashAfterDeleteBeforeCompletion(t *testing.T) {
385 w, _ := newClaimRestoreWorld(t)
386 op, etag := w.crashAt(t, crashAfterDelete)
387 if tomb, _ := w.marker(t); tomb != nil {
388 t.Fatal("fixture is not the crash window")
389 }
390 if a := w.admission(t).Value; a.Op == nil || a.Op.ID != op || a.LastRestore.State != "prepared" {
391 t.Fatalf("fixture admission: %+v", a)
392 }
393 // While the operation is open, no console projects anything.
394 w.materialize(t, w.b)
395 requireLife(t, w.b, true, false)
396 // B's own restore door finishes A's receipt first (step 0), then answers
397 // it as a restore already done.
398 if err := w.b.restoreApp(w.ctx, w.account(t, w.b), crApp, time.Now()); err != nil {
399 t.Fatalf("B's restore should finish A's receipt: %v", err)
400 }
401 if r := requireSettledReceipt(t, w.admission(t)); r.ID != op || r.TombstoneETag != etag {
402 t.Fatalf("settled the wrong receipt: %+v", r)
403 }
404 requireLife(t, w.b, false, false)
405 w.materialize(t, w.a)
406 requireLife(t, w.a, false, false)
407}
408
409func TestClaimRestoreSQLFailureIsLocalAndReplayable(t *testing.T) {
410 w, _ := newClaimRestoreWorld(t)
411 // A real SQL failure on console A only: a persistent trigger written on
412 // A's trusted boot handle after arming.
413 if _, err := w.a.store.bootDB().Exec(`CREATE TRIGGER fail_restore BEFORE UPDATE OF deleted_at ON apps WHEN NEW.deleted_at = 0
414BEGIN SELECT RAISE(ABORT, 'test restore SQL failure'); END`); err != nil {
415 t.Fatal(err)
416 }
417 err := w.a.restoreApp(w.ctx, w.account(t, w.a), crApp, time.Now())
418 var rf *restoreFailure
419 if !errors.As(err, &rf) || rf.Marker || !strings.Contains(err.Error(), "test restore SQL failure") {
420 t.Fatalf("restore = %v, want the row-only failure", err)
421 }
422 // The global effect settled; only A's projection is behind.
423 receipt := requireSettledReceipt(t, w.admission(t))
424 if tomb, _ := w.marker(t); tomb != nil {
425 t.Fatal("marker survived")
426 }
427 requireLife(t, w.a, true, true)
428 // Console B replays the same incarnation-bound terminal receipt without
429 // opening anything.
430 before := w.admission(t)
431 w.materialize(t, w.b)
432 requireLife(t, w.b, false, false)
433 after := w.admission(t)
434 if after.Value.AppID != before.Value.AppID || after.Value.Op != nil || *after.Value.LastRestore != *before.Value.LastRestore {
435 t.Fatal("replay changed the restore evidence or left admission closed")
436 }
437 // A replays it too once its SQL works again.
438 w.materialize(t, w.a)
439 requireLife(t, w.a, true, true) // still failing: nothing reopened
440 if _, err := w.a.store.bootDB().Exec(`DROP TRIGGER fail_restore`); err != nil {
441 t.Fatal(err)
442 }
443 w.materialize(t, w.a)
444 requireLife(t, w.a, false, false)
445 if r := requireSettledReceipt(t, w.admission(t)); r != receipt {
446 t.Fatal("receipt changed during replay")
447 }
448}
449
450func TestClaimRestoreReplayNeverReopensClosedAdmission(t *testing.T) {
451 w, _ := newClaimRestoreWorld(t)
452 if err := w.a.restoreApp(w.ctx, w.account(t, w.a), crApp, time.Now()); err != nil {
453 t.Fatal(err)
454 }
455 receipt := requireSettledReceipt(t, w.admission(t))
456 // Another operation now owns the app (as a purge would), with no marker
457 // present. B still has its stale trashed row.
458 closed := w.closeAdmission(t)
459 w.materialize(t, w.b)
460 requireLife(t, w.b, true, false)
461 scope := claimSQLScope{AccountID: crAcctID, App: crApp, AppID: crAppID}
462 err := w.b.store.WithAppMutation(w.ctx, []sqlGrant{restoreReceiptGrant(w.ctx, w.bucket, crSqid, scope, receipt)}, func(v *Store) error {
463 _, err := v.FinishClaimRestoreRow(crAcctID, crApp, crAppID, receipt.DeletedAt, time.Now())
464 return err
465 })
466 if !errors.Is(err, ErrClaimSQLDenied) {
467 t.Fatalf("receipt replayed under a foreign operation: %v", err)
468 }
469 // A restore door on B neither starts an operation nor reopens this one.
470 var rf *restoreFailure
471 if err := w.b.restoreApp(w.ctx, w.account(t, w.b), crApp, time.Now()); !errors.As(err, &rf) || !rf.Marker {
472 t.Fatalf("restore under a foreign operation = %v", err)
473 }
474 if now := w.admission(t); now.ETag != closed.ETag {
475 t.Fatal("canonical admission changed")
476 }
477 requireLife(t, w.b, true, false)
478}
479
480func TestClaimRestoreSuccessorIncarnationIsRefused(t *testing.T) {
481 w, _ := newClaimRestoreWorld(t)
482 if err := w.a.restoreApp(w.ctx, w.account(t, w.a), crApp, time.Now()); err != nil {
483 t.Fatal(err)
484 }
485 receipt := requireSettledReceipt(t, w.admission(t))
486 // A grant for a different incarnation cannot touch this row.
487 other := claimSQLScope{AccountID: crAcctID, App: crApp, AppID: "incarnation-b"}
488 r := receipt
489 r.AppID = "incarnation-b"
490 err := w.b.store.WithAppMutation(w.ctx, []sqlGrant{restoreReceiptGrant(w.ctx, w.bucket, crSqid, other, r)}, func(v *Store) error {
491 _, err := v.FinishClaimRestoreRow(crAcctID, crApp, crAppID, r.DeletedAt, time.Now())
492 return err
493 })
494 if err == nil {
495 t.Fatal("successor grant reached the old incarnation")
496 }
497 requireLife(t, w.b, true, false)
498}
499
500// A restore's receipt carries the closed epoch its operation ran in. Later
501// legitimate operations reopen admission and raise the epoch; a console that
502// missed the restore still catches it up, through materialization and
503// through its own restore door, because the receipt binds the same immutable
504// app_id with receipt.Epoch <= admission.Epoch, no marker and no purge.
505func TestClaimRestoreReplaysAfterLaterEpochBumps(t *testing.T) {
506 for _, via := range []string{"materialize", "door"} {
507 t.Run(via, func(t *testing.T) {
508 w, _ := newClaimRestoreWorld(t)
509 if err := w.a.restoreApp(w.ctx, w.account(t, w.a), crApp, time.Now()); err != nil {
510 t.Fatal(err)
511 }
512 receipt := requireSettledReceipt(t, w.admission(t))
513 control, self, err := w.a.claimRestoreRuntime()
514 if err != nil {
515 t.Fatal(err)
516 }
517 for i := 0; i < 2; i++ {
518 op := "later-mutation-" + strconv.Itoa(i)
519 owner := self
520 owner.Controller = op
521 h, err := control.BeginExclusive(w.ctx, crSqid, crApp, crAppID, hostops.AppOperation{ID: op, Kind: hostops.OpAppMutation, AppID: crAppID, Phase: "issued",
522 Intent: map[string]string{"action": "settings"}}, owner)
523 if err != nil {
524 t.Fatal(err)
525 }
526 if err := h.FinishMutation(w.ctx); err != nil {
527 t.Fatal(err)
528 }
529 }
530 now := w.admission(t).Value
531 if now.Epoch < receipt.Epoch+3 || now.LastRestore == nil || *now.LastRestore != receipt {
532 t.Fatalf("fixture: epoch %d, receipt %+v", now.Epoch, now.LastRestore)
533 }
534 requireLife(t, w.b, true, false)
535 switch via {
536 case "materialize":
537 w.materialize(t, w.b)
538 case "door":
539 if err := w.b.restoreApp(w.ctx, w.account(t, w.b), crApp, time.Now()); err != nil {
540 t.Fatalf("B's restore door on a lagging row: %v", err)
541 }
542 }
543 requireLife(t, w.b, false, false)
544 if after := w.admission(t); after.Value.Epoch <= now.Epoch || after.Value.Op != nil || *after.Value.LastRestore != receipt {
545 t.Fatal("catching up failed to release its projection operation or changed the receipt")
546 }
547 })
548 }
549}
550
551// The terminal replay grants bind terminal permits: a callback holding a
552// valid restore receipt or trash marker grant can still only write that
553// projection's columns of that incarnation's apps row. INSERT, a column the
554// projection does not own, DELETE and unbinding app_id are refused by SQLite.
555func TestClaimRestoreTerminalGrantsOnlyWriteTheirProjection(t *testing.T) {
556 w, _ := newClaimRestoreWorld(t)
557 if err := w.a.restoreApp(w.ctx, w.account(t, w.a), crApp, time.Now()); err != nil {
558 t.Fatal(err)
559 }
560 receipt := requireSettledReceipt(t, w.admission(t))
561 scope := claimSQLScope{AccountID: crAcctID, App: crApp, AppID: crAppID}
562 attempt := func(v *Store) {
563 t.Helper()
564 _, err := v.db.Exec(`INSERT INTO audit (app, address, action, created_at, account_id) VALUES (?, 'p', 'forged', 1, ?)`, crApp, crAcctID)
565 requireDenied(t, "insert", err)
566 _, err = v.db.Exec(`INSERT INTO apps (app, account_id, created_by, created_at) VALUES ('kass-twin', ?, 'x', 1)`, crAcctID)
567 requireDenied(t, "insert apps (unbound adoption)", err)
568 _, err = v.db.Exec(`UPDATE apps SET placement = 'evil' WHERE account_id = ? AND app = ?`, crAcctID, crApp)
569 requireDenied(t, "foreign column", err)
570 _, err = v.db.Exec(`UPDATE apps SET app_id = '' WHERE account_id = ? AND app = ?`, crAcctID, crApp)
571 requireDenied(t, "unbinding app_id", err)
572 _, err = v.db.Exec(`DELETE FROM apps WHERE account_id = ? AND app = ?`, crAcctID, crApp)
573 requireDenied(t, "delete", err)
574 }
575 err := w.b.claimProjection(w.ctx, w.account(t, w.b), crApp, func(pctx context.Context) error {
576 return w.b.store.WithAppMutation(pctx, []sqlGrant{restoreReceiptGrant(pctx, w.bucket, crSqid, scope, receipt)}, func(v *Store) error {
577 attempt(v)
578 changed, err := v.FinishClaimRestoreRow(crAcctID, crApp, crAppID, receipt.DeletedAt, time.Now())
579 if err == nil && !changed {
580 err = errors.New("the projection itself did not land")
581 }
582 return err
583 })
584 })
585 if err != nil {
586 t.Fatal(err)
587 }
588 requireLife(t, w.b, false, false)
589 // A later trash: the trash-marker grant hides the row and nothing else.
590 if err := release.TrashApp(w.ctx, w.bucket, crSqid, crApp, crOwner, time.Now().Add(time.Minute)); err != nil {
591 t.Fatal(err)
592 }
593 tomb, tag := w.marker(t)
594 err = w.b.claimProjection(w.ctx, w.account(t, w.b), crApp, func(pctx context.Context) error {
595 return w.b.store.WithAppMutation(pctx, []sqlGrant{tombstoneGrant(pctx, w.bucket, crSqid, scope, tag)}, func(v *Store) error {
596 attempt(v)
597 _, err := v.TrashClaimAppRow(crAcctID, crApp, crAppID, tomb.DeletedAt)
598 return err
599 })
600 })
601 if err != nil {
602 t.Fatal(err)
603 }
604 requireLife(t, w.b, true, false)
605 if n := countWhere(t, w.b.store, "apps", "app = ? AND placement = '' AND app_id = ?", crApp, crAppID); n != 1 {
606 t.Fatal("a terminal grant wrote outside its projection")
607 }
608 // While any operation owns the app, the marker grant refuses outright: a
609 // materializer cannot clear a live restore's restoring slot.
610 w.closeAdmission(t)
611 err = w.b.store.WithAppMutation(w.ctx, []sqlGrant{tombstoneGrant(w.ctx, w.bucket, crSqid, scope, tag)}, func(v *Store) error {
612 _, err := v.SetClaimRestoring(crAcctID, crApp, crAppID, false)
613 return err
614 })
615 if !errors.Is(err, ErrClaimSQLDenied) {
616 t.Fatalf("trash-marker grant under an operation: %v", err)
617 }
618}
619
620// The disabled branch is the base restoreApp, byte for byte: its separate
621// SQL failure response is unchanged.
622func TestClaimDisabledRestorePreservesSQLFailureResponse(t *testing.T) {
623 fx, c, csrf := trashPageFixture(t)
624 if _, err := fx.st.db.Exec(`CREATE TRIGGER fail_restore BEFORE UPDATE OF deleted_at ON apps WHEN NEW.deleted_at=0 BEGIN SELECT RAISE(ABORT,'test restore SQL failure'); END`); err != nil {
625 t.Fatal(err)
626 }
627 _, location, _ := postForm(t, c, fx.u("/trash/restore"), url.Values{"csrf": {csrf}, "app": {"kass"}})
628 _, _, body := get(t, c, fx.ts.URL+location)
629 if !strings.Contains(body, "The trash marker was removed") || strings.Contains(body, "Could not remove the trash marker") {
630 t.Fatalf("wrong disabled SQL failure response: %s", body)
631 }
632 if tomb, err := release.ReadAppTombstone(t.Context(), fx.rel, fx.sqid, "kass"); err != nil || tomb != nil {
633 t.Fatalf("marker: %+v %v", tomb, err)
634 }
635}
636