package console import ( "encoding/json" "testing" "time" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/hostops" "github.com/carlosframework/platform/internal/release" ) // The store is gated from the start; the row is seeded under the test-only // seed scope, and every projection write must pass the console's own grants. func TestMaterializeClaimPurgeAcrossConsoles(t *testing.T) { for _, phase := range []string{"journal", "complete", "successor", "successor-unproven", "open"} { t.Run(phase, func(t *testing.T) { ctx := t.Context() bucket := release.NewDirStore(t.TempDir()) st := openGatedStore(t) id, sqid, err := domains.MintAccount(ctx, bucket, "Account", "owner@keymail.dev", time.Now()) if err != nil { t.Fatal(err) } if _, err = MaterializeAccounts(ctx, bucket, st); err != nil { t.Fatal(err) } err = st.withClaimSQL(gateCtx(t), []claimSQLScope{{AccountID: id, App: "demo", AppID: "old", Mode: claimModeEffect}}, allowAllClaimSQL, func(v *Store) error { if _, err := v.CreateAppIn(id, "demo", "owner@keymail.dev", time.Now()); err != nil { return err } if err := v.BindClaimAppIdentity(id, "demo", "old"); err != nil { return err } return v.TrashAppRow(id, "demo", time.Now()) }) if err != nil { t.Fatal(err) } a := hostops.AppAdmission{V: 1, AppID: "old", Revision: 5, Epoch: 1, State: "closed", Registrations: map[string]hostops.Registration{}, Effects: map[string]hostops.Effect{}, LastPurge: &hostops.PurgeMark{ID: "purge", AppID: "old"}} if phase == "journal" { a.Op = &hostops.AppOperation{ID: "purge", AppID: "old", Kind: "purging"} if _, err = bucket.Put(ctx, "apps/"+sqid+"/demo/purge.json", []byte(`{}`), ""); err != nil { t.Fatal(err) } } if phase == "successor" || phase == "successor-unproven" { a.AppID, a.State = "new", "open" a.Epoch++ } if phase == "successor-unproven" { // An unequal app_id alone is not terminal evidence. a.LastPurge = &hostops.PurgeMark{ID: "purge", AppID: "some-other"} } if phase == "open" { a.State = "open" } body, _ := json.Marshal(a) if _, err = bucket.Put(ctx, "apps/"+sqid+"/demo/admission.json", body, ""); err != nil { t.Fatal(err) } before, _, err := bucket.Get(ctx, "apps/"+sqid+"/demo/admission.json") if err != nil { t.Fatal(err) } if err = MaterializeApps(ctx, bucket, st, true); err != nil { t.Fatal(err) } rows, err := st.AppRows() if err != nil { t.Fatal(err) } if phase == "complete" || phase == "successor" { if len(rows) != 0 { t.Fatal("retired row retained or revived") } } else if len(rows) != 1 || !rows[0].Deleted { t.Fatal("purge or successor revived old row") } after, _, err := bucket.Get(ctx, "apps/"+sqid+"/demo/admission.json") if err != nil || string(after) != string(before) { t.Fatalf("materialization wrote canonical admission: %v", err) } }) } } // Absence never restores: a trashed row with no marker, no receipt and open // admission stays trashed, and a prepared (not completed) receipt is not a // restore either. func TestMaterializeClaimAbsenceIsNotRestore(t *testing.T) { for _, state := range []string{"none", "prepared"} { t.Run(state, func(t *testing.T) { ctx := t.Context() bucket := release.NewDirStore(t.TempDir()) st := openGatedStore(t) id, sqid, err := domains.MintAccount(ctx, bucket, "Account", "owner@keymail.dev", time.Now()) if err != nil { t.Fatal(err) } if _, err = MaterializeAccounts(ctx, bucket, st); err != nil { t.Fatal(err) } trashed := time.Now().Add(-time.Hour) err = st.withClaimSQL(gateCtx(t), []claimSQLScope{{AccountID: id, App: "demo", AppID: "old", Mode: claimModeEffect}}, allowAllClaimSQL, func(v *Store) error { if _, err := v.CreateAppIn(id, "demo", "owner@keymail.dev", trashed); err != nil { return err } if err := v.BindClaimAppIdentity(id, "demo", "old"); err != nil { return err } return v.TrashAppRow(id, "demo", trashed) }) if err != nil { t.Fatal(err) } a := hostops.AppAdmission{V: 1, AppID: "old", Revision: 2, Epoch: 1, State: "open", Registrations: map[string]hostops.Registration{}, Effects: map[string]hostops.Effect{}} if state == "prepared" { a.LastRestore = &hostops.RestoreMark{ID: "r", AppID: "old", Epoch: 1, TombstoneETag: "e", DeletedAt: trashed.Unix(), State: "prepared"} } body, _ := json.Marshal(a) if _, err = bucket.Put(ctx, "apps/"+sqid+"/demo/admission.json", body, ""); err != nil { t.Fatal(err) } if _, err = bucket.Put(ctx, "apps/"+sqid+"/demo/releases/r1.json", []byte(`{}`), ""); err != nil { t.Fatal(err) } if err = MaterializeApps(ctx, bucket, st, true); err != nil { t.Fatal(err) } if has, err := st.HasTrashedAppIn(id, "demo"); err != nil || !has { t.Fatalf("absence restored the row: %v %v", has, err) } }) } } // Two consoles share one bucket. A purge completes and console A replays it; // the name is then recreated (new app_id, last_purge retained) and console A // binds its row to the new incarnation. Console B lagged through all of it // and still has the retired row: its next materialization replays the // retired incarnation's purge, keyed on the retired app_id, and nothing of // the successor (on either console) or of canonical state changes. func TestMaterializeClaimPurgeReplaysAfterRecreationOnLaggingConsole(t *testing.T) { ctx := t.Context() bucket := release.NewDirStore(t.TempDir()) id, sqid, err := domains.MintAccount(ctx, bucket, "Account", "owner@keymail.dev", time.Now()) if err != nil { t.Fatal(err) } seed := func(st *Store, appID string, trashed bool) { t.Helper() err := st.withClaimSQL(gateCtx(t), []claimSQLScope{{AccountID: id, App: "demo", AppID: appID, Mode: claimModeEffect}}, allowAllClaimSQL, func(v *Store) error { if _, err := v.CreateAppIn(id, "demo", "owner@keymail.dev", time.Now()); err != nil { return err } if err := v.BindClaimAppIdentity(id, "demo", appID); err != nil { return err } if !trashed { return nil } return v.TrashAppRow(id, "demo", time.Now()) }) if err != nil { t.Fatal(err) } } consoleA, consoleB := openGatedStore(t), openGatedStore(t) for _, st := range []*Store{consoleA, consoleB} { if _, err = MaterializeAccounts(ctx, bucket, st); err != nil { t.Fatal(err) } seed(st, "old", true) } key := "apps/" + sqid + "/demo/admission.json" put := func(a hostops.AppAdmission) { t.Helper() _, tag, err := bucket.Get(ctx, key) if err != nil { tag = "" } body, _ := json.Marshal(a) if _, err := bucket.Put(ctx, key, body, tag); err != nil { t.Fatal(err) } } retired := hostops.AppAdmission{V: 1, AppID: "old", Revision: 5, Epoch: 3, State: "closed", Registrations: map[string]hostops.Registration{}, Effects: map[string]hostops.Effect{}, LastPurge: &hostops.PurgeMark{ID: "purge-1", AppID: "old"}} put(retired) if err := MaterializeApps(ctx, bucket, consoleA, true); err != nil { t.Fatal(err) } if rows, err := consoleA.AppRows(); err != nil || len(rows) != 0 { t.Fatalf("console A kept the retired row: %d %v", len(rows), err) } // Recreation: what EnrollNewApp writes (new app_id, revision and epoch // advanced, last_purge carried over). Console A binds its new row. put(hostops.AppAdmission{V: 1, AppID: "new", Revision: 6, Epoch: 4, State: "open", Registrations: map[string]hostops.Registration{}, Effects: map[string]hostops.Effect{}, LastPurge: retired.LastPurge}) seed(consoleA, "new", false) before, _, err := bucket.Get(ctx, key) if err != nil { t.Fatal(err) } // Console B catches up. if err := MaterializeApps(ctx, bucket, consoleB, true); err != nil { t.Fatal(err) } if rows, err := consoleB.AppRows(); err != nil || len(rows) != 0 { t.Fatalf("lagging console kept the retired row after recreation: %+v %v", rows, err) } // Idempotent, and the successor on console A is untouched. for _, st := range []*Store{consoleA, consoleB} { if err := MaterializeApps(ctx, bucket, st, true); err != nil { t.Fatal(err) } } if got, err := consoleA.ClaimAppID(id, "demo"); err != nil || got != "new" { t.Fatalf("successor row on console A: %q %v", got, err) } if has, err := consoleA.HasTrashedAppIn(id, "demo"); err != nil || has { t.Fatalf("successor row trashed: %v %v", has, err) } after, _, err := bucket.Get(ctx, key) if err != nil || string(after) != string(before) { t.Fatalf("materialization wrote canonical admission (last_purge must be retained): %v", err) } var kept hostops.AppAdmission if err := json.Unmarshal(after, &kept); err != nil || kept.LastPurge == nil || *kept.LastPurge != *retired.LastPurge { t.Fatalf("last_purge not retained: %+v %v", kept.LastPurge, err) } }