| 1 | package console |
| 2 | |
| 3 | import ( |
| 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. |
| 15 | func 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. |
| 92 | func 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. |
| 145 | func 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 | |