package console // claim_invariant_purge_test.go drives the trash/delete door through the // real purge: the owner's request reserves an owned purging operation (202, // phase reserved), every roster box publishes its signed census report for // that reservation (box side, below every boundary, as ackClaim stages // execution acknowledgements), and the production recovery pass carries the // owned purge through adopted, claimed, torn-down, retired and the sweep, // whose exact DeleteVersion is the versioned app effect. import ( "context" "crypto/ed25519" "crypto/sha256" "encoding/json" "net/http" "net/url" "slices" "sort" "strings" "testing" "time" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/hostops" "github.com/carlosframework/platform/internal/release" ) // claimPurgeOld is the historical object version the sweep must delete. const claimPurgeOld = "apps/bab/titogo/claim-fixture-old.json" // claimCensusKey is a roster box's census signing key. Each box owns its // private key; the fixture derives it from the box id so setup and the // door's box-side publication agree without extra case state. func claimCensusKey(box string) ed25519.PrivateKey { seed := sha256.Sum256([]byte("claim-fixture-census:" + box)) return ed25519.NewKeyFromSeed(seed[:]) } // claimFixtureDirectory is the roster directory over the RAW bucket: box-side // enrollment and census publication are world staging, never console effects. func (cx *claimCase) claimFixtureDirectory() hostactors.Directory { return hostactors.Directory{Store: cx.rel, PublicKey: cx.pub, Bucket: claimFxBucket} } // purgeWorld trashes titogo, leaves a historical version under its prefix // for the sweep, and binds each roster box's census key through the root // enrollment path (BindCensusKey with the box's proof of possession). func (cx *claimCase) purgeWorld(t *testing.T) { t.Helper() ctx := context.Background() cx.trash(t, "titogo") // A historical object version under the app's prefix, written below the // boundary (world staging): the purge sweep must list it and delete that // exact version through the versioned boundary. cx.versions.Put(claimFxBucket, claimPurgeOld, []byte(`{}`)) d := cx.claimFixtureDirectory() signer := release.RootSigner(cx.priv) obs, err := d.Read(ctx) if err != nil { t.Fatal(err) } boxes := make([]string, 0, len(obs.Roster.Boxes)) for box := range obs.Roster.Boxes { boxes = append(boxes, box) } sort.Strings(boxes) for _, box := range boxes { key := claimCensusKey(box) binding := hostactors.CensusKeyBinding{Bucket: claimFxBucket, Box: box, Enrollment: obs.Roster.Boxes[box].Enrollment, PublicKey: key.Public().(ed25519.PublicKey)} raw, err := json.Marshal(binding) if err != nil { t.Fatal(err) } if obs, err = d.BindCensusKey(ctx, signer, obs, binding, ed25519.Sign(key, raw)); err != nil { t.Fatalf("bind census key of %s: %v", box, err) } } } // publishPurgeCensus publishes every roster box's complete, signed census // report for reservation (box side, raw bucket). Each box installs nothing // of the purged app: the fixture's boxes carry no installations at all. func (cx *claimCase) publishPurgeCensus(t *testing.T, reservation string) { t.Helper() ctx := context.Background() d := cx.claimFixtureDirectory() obs, err := d.Read(ctx) if err != nil { t.Fatal(err) } for box, b := range obs.Roster.Boxes { report := hostops.CensusReport{Box: box, Enrollment: b.Enrollment, BootID: "claim-fixture-boot", Reservation: reservation, DirectoryRevision: obs.Roster.Revision, CapturedAt: time.Now().UTC(), Complete: true, Installations: []hostops.CensusInstallation{}} if err := hostops.PublishCensusReport(ctx, cx.rel, d, report, claimCensusKey(box)); err != nil { t.Fatalf("publish census of %s for %s: %v", box, reservation, err) } } } // ownsPurge reports whether app's admission is still closed under op, the // exclusive purging operation the door accepted. Anything else holding the // admission after a 202 for op fails the case, so a closed foreign operation // can never be accepted. func (cx *claimCase) ownsPurge(t *testing.T, account, app, op string) bool { t.Helper() a, err := hostops.ReadCanonicalAdmission(context.Background(), cx.rel, account, app) if err != nil { t.Fatal(err) } cur := a.Value.Op if cur == nil { return false } if cur.ID != op || cur.Kind != hostops.OpPurging { t.Fatalf("purge answered 202 for operation %s, but %s/%s's admission holds %+v", op, account, app, cur) } return true } // claimPurgeDoor is the owner's permanent delete of trashed titogo. A purge // the console accepted (202) must own the exclusive purging operation it // names; the boxes then publish their census for that reservation and the // real recovery pass carries the owned purge on until it has finished. func claimPurgeDoor(t *testing.T, cx *claimCase) int { v := url.Values{"csrf": {cx.csrf}, "app": {"titogo"}, "confirm": {"titogo"}} status, _, out := postGated(t, cx.consoleFixture, cx.browser, cx.ts.URL+"/a/bab/trash/delete", v) if status != http.StatusAccepted { return status } admission, err := hostops.ReadCanonicalAdmission(context.Background(), cx.rel, "bab", "titogo") if err != nil || admission.Value.Op == nil { t.Fatalf("missing accepted purge: %v", err) } op := admission.Value.Op.ID if !strings.Contains(out, op) || !strings.Contains(out, "reserved") || !strings.Contains(out, "