package console import ( "context" "crypto/ed25519" "encoding/json" "errors" "testing" "time" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/hostops" "github.com/carlosframework/platform/internal/release" ) var claimTestOwner = hostops.RegistrationOwner{Console: "console", Enrollment: "enrollment", Controller: "controller"} func claimTestWriter(t *testing.T, raw release.Store) *hostops.ControlWriter { t.Helper() pub, _, err := ed25519.GenerateKey(nil) if err != nil { t.Fatal(err) } w, err := hostops.NewControlWriter(raw, pub, time.Now) if err != nil { t.Fatal(err) } return w } func seedClaimAdmission(t *testing.T, raw release.Store, account, app string, a hostops.AppAdmission) { t.Helper() key := "apps/" + account + "/" + app + "/admission.json" body, _ := json.Marshal(a) _, tag, _ := raw.Get(context.Background(), key) if _, err := raw.Put(context.Background(), key, body, tag); err != nil { t.Fatal(err) } } func TestClaimAppMutationGate(t *testing.T) { base := func() hostops.AppAdmission { return hostops.AppAdmission{V: 1, AppID: "app", Revision: 1, Epoch: 1, State: "open", Registrations: map[string]hostops.Registration{}, Effects: map[string]hostops.Effect{}} } for _, door := range []string{"restore", "protection", "store-grant", "store-provision", "store-rotate", "store-rotate-finish", "store-destroy", "scan-regrant", "store-declare"} { t.Run(door, func(t *testing.T) { ctx := context.Background() begin := func(a hostops.AppAdmission, id string) (*hostops.OperationHandle, error, *hostops.ControlWriter) { raw := release.NewDirStore(t.TempDir()) w := claimTestWriter(t, raw) seedClaimAdmission(t, raw, "bab", "demo", a) h, err := w.BeginExclusive(ctx, "bab", "demo", "app", hostops.AppOperation{ID: id, Kind: hostops.OpAppMutation, AppID: "app", Intent: map[string]string{"action": door}}, claimTestOwner) return h, err, w } a := base() a.State, a.Op = "closed", &hostops.AppOperation{ID: "purge", Kind: hostops.OpPurging, AppID: "app"} if _, err, _ := begin(a, "mutation"); !errors.Is(err, hostops.ErrPending) { t.Fatalf("purge gate: %v", err) } a = base() a.Effects["old"] = hostops.Effect{State: "uncertain"} if _, err, _ := begin(a, "mutation"); !errors.Is(err, hostops.ErrPending) { t.Fatalf("uncertain gate: %v", err) } a = base() a.Registrations["old"] = hostops.Registration{} if _, err, _ := begin(a, "mutation"); !errors.Is(err, hostops.ErrPending) { t.Fatalf("registration gate: %v", err) } h, err, w := begin(base(), "mutation") if err != nil { t.Fatal(err) } if h == nil { t.Fatal("no durable exclusion") } if _, err = w.BeginExclusive(ctx, "bab", "demo", "app", hostops.AppOperation{ID: "second", Kind: hostops.OpAppMutation, AppID: "app", Intent: map[string]string{"action": door}}, claimTestOwner); !errors.Is(err, hostops.ErrPending) { t.Fatal("concurrent mutation admitted") } }) } } func TestClaimAppMutationSerializesPurgeAndRecovers(t *testing.T) { ctx := t.Context() bucket := release.NewDirStore(t.TempDir()) st := openTestStore(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) } if _, err = st.CreateAppIn(id, "demo", "owner@keymail.dev", time.Now()); err != nil { t.Fatal(err) } if err = st.migrateClaimAppIdentity(); err != nil { t.Fatal(err) } if err = st.BindClaimAppIdentity(id, "demo", "app-id"); err != nil { t.Fatal(err) } acct, err := st.AccountBySqid(sqid) if err != nil { t.Fatal(err) } w := claimTestWriter(t, bucket) seedClaimAdmission(t, bucket, sqid, "demo", hostops.AppAdmission{V: 1, AppID: "app-id", Revision: 1, Epoch: 1, State: "open", Registrations: map[string]hostops.Registration{}, Effects: map[string]hostops.Effect{}}) s := &Server{store: st, claimOwner: claimTestOwner, cfg: Config{HostClaim: true, Releases: claimTestBoundary(t, bucket, w), ClaimAuthority: &hostops.ProductionAuthority{ConsoleID: "console", Enrollment: "enrollment", Control: w}}} calls := 0 err = s.claimAppMutation(ctx, acct, "demo", "store-destroy", func(ctx context.Context) error { calls++ // A purge cannot enter while the mutation's operation holds the app. if _, e := w.BeginExclusive(ctx, sqid, "demo", "app-id", hostops.AppOperation{ID: "purge", Kind: hostops.OpDeleting, AppID: "app-id"}, claimTestOwner); !errors.Is(e, hostops.ErrPending) { t.Fatalf("exclusive op entered running backend: %v", e) } return nil }) if err != nil { t.Fatal(err) } current, err := hostops.ReadAdmission(ctx, bucket, sqid, "demo", "app-id") if err != nil { t.Fatal(err) } var completed string for key, e := range current.Value.Effects { if e.State == "complete" { completed = key } } if completed == "" { t.Fatal("missing completed durable effect") } // Recreate exactly the persisted state at a crash before gate release. crashed := current.Value crashed.State, crashed.Op = "closed", &hostops.AppOperation{ID: completed, AppID: "app-id", Kind: hostops.OpAppMutation} seedClaimAdmission(t, bucket, sqid, "demo", crashed) if err = w.RecoverMutation(ctx, sqid, "demo", "app-id", completed); err != nil { t.Fatal(err) } if calls != 1 { t.Fatal("recovery repeated backend") } // A purge-owned app refuses a later mutation. h, err := w.BeginExclusive(ctx, sqid, "demo", "app-id", hostops.AppOperation{ID: "deleting", Kind: hostops.OpDeleting, AppID: "app-id"}, claimTestOwner) if err != nil { t.Fatal(err) } _ = h err = s.claimAppMutation(ctx, acct, "demo", "protection", func(context.Context) error { calls++; return nil }) if !errors.Is(err, hostops.ErrPending) || calls != 1 { t.Fatalf("mutation bypassed an exclusive operation: calls=%d err=%v", calls, err) } }