| index 45244cbb7..63afdcef0 100644 |
| --- a/internal/provision/reconcile_lifetime_test.go |
| +++ b/internal/provision/reconcile_lifetime_test.go |
| @@ -3,6 +3,7 @@ package provision |
| import ( |
| "context" |
| "errors" |
| + "fmt" |
| "os" |
| "strings" |
| "sync" |
| @@ -508,27 +509,146 @@ func TestOutOfScopeValidRecordFollowsTodaysRemoval(t *testing.T) { |
| {name: "another region, kept by the view", move: func(r *Record) { r.Region = "uk" }, keep: true, kept: true}, |
| {name: "another pool, kept by the view", move: func(r *Record) { r.Pool = "other" }, keep: true, kept: true}, |
| {name: "another region, the app trashed", move: func(r *Record) { r.Region = "uk" }, trash: true}, |
| + // Today an out-of-scope record is not in the pass's set, so its |
| + // row's reason is record gone and the trash marker is never asked: |
| + // the keep view keeps it, trashed or not. Judged, the same. |
| + {name: "another region, the app trashed, kept by the view", move: func(r *Record) { r.Region = "uk" }, keep: true, trash: true, kept: true}, |
| } { |
| - t.Run(c.name, func(t *testing.T) { |
| + for _, judged := range []bool{false, true} { |
| + t.Run(fmt.Sprintf("%s, judged %v", c.name, judged), func(t *testing.T) { |
| + x := newLifeScene(t) |
| + rec := x.record("shop", 1) |
| + x.putRecord(t, rec) |
| + x.putSlot(t, "shop", 1, SlotLive) |
| + x.minted(t, "shop") |
| + c.move(&rec) |
| + x.putRecord(t, rec) |
| + if !judged { |
| + x.cfg.Judge = nil |
| + } |
| + if c.keep { |
| + x.cfg.KeepParticipant = func(string) bool { return true } |
| + } |
| + if c.trash { |
| + if err := release.TrashApp(ctx, x.s, x.sqid, "shop", "op@example.com", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + x.pass(t) |
| + if got := x.row(t) != nil; got != c.kept { |
| + t.Fatalf("row kept = %v, want %v", got, c.kept) |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +// A record under a retired account is never in the pass's set (the listing |
| +// trusts only known accounts), so its row has always been removed as record |
| +// gone. A judge observing the row's own key must not bring it back. |
| +func TestRetiredAccountRowIsRemovedUnderAJudge(t *testing.T) { |
| + for _, judged := range []bool{false, true} { |
| + t.Run(fmt.Sprintf("judged %v", judged), func(t *testing.T) { |
| x := newLifeScene(t) |
| - rec := x.record("shop", 1) |
| - x.putRecord(t, rec) |
| - x.putSlot(t, "shop", 1, SlotLive) |
| + x.putRecord(t, x.record("shop", 0)) |
| x.minted(t, "shop") |
| - c.move(&rec) |
| - x.putRecord(t, rec) |
| - if c.keep { |
| - x.cfg.KeepParticipant = func(string) bool { return true } |
| - } |
| - if c.trash { |
| - if err := release.TrashApp(ctx, x.s, x.sqid, "shop", "op@example.com", time.Now()); err != nil { |
| - t.Fatal(err) |
| - } |
| + // The account is retired: its manifest is tombstoned. The record |
| + // beside the row is valid (no slot) and in scope. |
| + putReplace(t, x.s, "accounts/"+x.sqid+"/account.json", |
| + []byte(`{"id":`+fmt.Sprint(acctID)+`,"sqid":"`+x.sqid+`","name":"Shop","created_at":"2026-08-02T09:00:00Z","deleted_at":"2026-10-01T09:00:00Z"}`)) |
| + if !judged { |
| + x.cfg.Judge = nil |
| } |
| x.pass(t) |
| - if got := x.row(t) != nil; got != c.kept { |
| - t.Fatalf("row kept = %v, want %v", got, c.kept) |
| + if rt := x.row(t); rt != nil { |
| + t.Fatalf("a retired account's row survived: %+v", rt) |
| } |
| }) |
| } |
| } |
| + |
| +// The removal phase's own observation finds the row's record corrupt: the |
| +// row is protected, where a readable record would let the trash rule |
| +// remove it. |
| +func TestRemovalProtectsACorruptObservation(t *testing.T) { |
| + x := newLifeScene(t) |
| + x.putRecord(t, x.record("shop", 0)) |
| + x.minted(t, "shop") |
| + x.putRecord(t, x.record("cart", 0)) // cart sorts first: the pass selects it |
| + if err := release.TrashApp(ctx, x.s, x.sqid, "shop", "op@example.com", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + // The listing reads shop's body cleanly; the removal phase's read of it |
| + // (the second) finds it corrupt. |
| + s := &corruptOnSecondRead{Store: x.s, key: RecordKey(x.sqid, "shop", lifeHost)} |
| + if err := Reconcile(ctx, s, x.reg, x.cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt := x.row(t); rt == nil || rt.App != "shop" { |
| + t.Fatalf("a corrupt observation removed the row: %+v", rt) |
| + } |
| + x.pass(t) |
| + if rt := x.row(t); rt != nil && rt.App == "shop" { |
| + t.Fatalf("fixture: readable, the trashed owner's row survived: %+v", rt) |
| + } |
| +} |
| + |
| +// corruptOnSecondRead answers key's second and later reads with a body that |
| +// does not decode. |
| +type corruptOnSecondRead struct { |
| + release.Store |
| + key string |
| + mu sync.Mutex |
| + reads int |
| +} |
| + |
| +func (s *corruptOnSecondRead) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + raw, tag, err := s.Store.Get(ctx, key) |
| + if key == s.key { |
| + s.mu.Lock() |
| + s.reads++ |
| + n := s.reads |
| + s.mu.Unlock() |
| + if n > 1 && err == nil { |
| + return []byte("{not json"), tag, nil |
| + } |
| + } |
| + return raw, tag, err |
| +} |
| + |
| +// A held row whose record is gone is deleted only while its op still holds |
| +// it. When the hold changed since the listing the row stays this pass, and |
| +// the next pass, listing the new hold, removes it. |
| +func TestDeleteIfHoldRefusalLeavesTheRowForTheNextPass(t *testing.T) { |
| + x := newLifeScene(t) |
| + x.putRecord(t, x.record("shop", 0)) |
| + x.minted(t, "shop") |
| + if ok, err := x.reg.SetHoldIf(lifeHost, "moving", "mv_1", ""); err != nil || !ok { |
| + t.Fatalf("hold = %v, %v", ok, err) |
| + } |
| + x.putSlot(t, "shop", 1, SlotRetired) |
| + var calls []string |
| + first := true |
| + x.cfg.DeleteIfHold = func(host, op string) (bool, error) { |
| + calls = append(calls, op) |
| + if first { |
| + // Another op takes the row between the listing and the delete. |
| + first = false |
| + if err := x.reg.SetHold(host, "moving", "mv_2"); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + return x.reg.DeleteIfHoldWithTimers(host, op) |
| + } |
| + x.pass(t) |
| + if rt := x.row(t); rt == nil || rt.HoldOp != "mv_2" { |
| + t.Fatalf("after the refused delete: %+v, want the row held by mv_2", rt) |
| + } |
| + x.pass(t) |
| + if rt := x.row(t); rt != nil { |
| + t.Fatalf("the next pass left %+v", rt) |
| + } |
| + if len(calls) != 2 || calls[0] != "mv_1" || calls[1] != "mv_2" { |
| + t.Fatalf("DeleteIfHold calls %q, want mv_1 then mv_2", calls) |
| + } |
| +} |