carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3// claim_invariant_purge_test.go drives the trash/delete door through the
4// real purge: the owner's request reserves an owned purging operation (202,
5// phase reserved), every roster box publishes its signed census report for
6// that reservation (box side, below every boundary, as ackClaim stages
7// execution acknowledgements), and the production recovery pass carries the
8// owned purge through adopted, claimed, torn-down, retired and the sweep,
9// whose exact DeleteVersion is the versioned app effect.
10
11import (
12 "context"
13 "crypto/ed25519"
14 "crypto/sha256"
15 "encoding/json"
16 "net/http"
17 "net/url"
18 "slices"
19 "sort"
20 "strings"
21 "testing"
22 "time"
23
24 "github.com/carlosframework/platform/internal/hostactors"
25 "github.com/carlosframework/platform/internal/hostops"
26 "github.com/carlosframework/platform/internal/release"
27)
28
29// claimPurgeOld is the historical object version the sweep must delete.
30const claimPurgeOld = "apps/bab/titogo/claim-fixture-old.json"
31
32// claimCensusKey is a roster box's census signing key. Each box owns its
33// private key; the fixture derives it from the box id so setup and the
34// door's box-side publication agree without extra case state.
35func claimCensusKey(box string) ed25519.PrivateKey {
36 seed := sha256.Sum256([]byte("claim-fixture-census:" + box))
37 return ed25519.NewKeyFromSeed(seed[:])
38}
39
40// claimFixtureDirectory is the roster directory over the RAW bucket: box-side
41// enrollment and census publication are world staging, never console effects.
42func (cx *claimCase) claimFixtureDirectory() hostactors.Directory {
43 return hostactors.Directory{Store: cx.rel, PublicKey: cx.pub, Bucket: claimFxBucket}
44}
45
46// purgeWorld trashes titogo, leaves a historical version under its prefix
47// for the sweep, and binds each roster box's census key through the root
48// enrollment path (BindCensusKey with the box's proof of possession).
49func (cx *claimCase) purgeWorld(t *testing.T) {
50 t.Helper()
51 ctx := context.Background()
52 cx.trash(t, "titogo")
53 // A historical object version under the app's prefix, written below the
54 // boundary (world staging): the purge sweep must list it and delete that
55 // exact version through the versioned boundary.
56 cx.versions.Put(claimFxBucket, claimPurgeOld, []byte(`{}`))
57 d := cx.claimFixtureDirectory()
58 signer := release.RootSigner(cx.priv)
59 obs, err := d.Read(ctx)
60 if err != nil {
61 t.Fatal(err)
62 }
63 boxes := make([]string, 0, len(obs.Roster.Boxes))
64 for box := range obs.Roster.Boxes {
65 boxes = append(boxes, box)
66 }
67 sort.Strings(boxes)
68 for _, box := range boxes {
69 key := claimCensusKey(box)
70 binding := hostactors.CensusKeyBinding{Bucket: claimFxBucket, Box: box, Enrollment: obs.Roster.Boxes[box].Enrollment,
71 PublicKey: key.Public().(ed25519.PublicKey)}
72 raw, err := json.Marshal(binding)
73 if err != nil {
74 t.Fatal(err)
75 }
76 if obs, err = d.BindCensusKey(ctx, signer, obs, binding, ed25519.Sign(key, raw)); err != nil {
77 t.Fatalf("bind census key of %s: %v", box, err)
78 }
79 }
80}
81
82// publishPurgeCensus publishes every roster box's complete, signed census
83// report for reservation (box side, raw bucket). Each box installs nothing
84// of the purged app: the fixture's boxes carry no installations at all.
85func (cx *claimCase) publishPurgeCensus(t *testing.T, reservation string) {
86 t.Helper()
87 ctx := context.Background()
88 d := cx.claimFixtureDirectory()
89 obs, err := d.Read(ctx)
90 if err != nil {
91 t.Fatal(err)
92 }
93 for box, b := range obs.Roster.Boxes {
94 report := hostops.CensusReport{Box: box, Enrollment: b.Enrollment, BootID: "claim-fixture-boot", Reservation: reservation,
95 DirectoryRevision: obs.Roster.Revision, CapturedAt: time.Now().UTC(), Complete: true, Installations: []hostops.CensusInstallation{}}
96 if err := hostops.PublishCensusReport(ctx, cx.rel, d, report, claimCensusKey(box)); err != nil {
97 t.Fatalf("publish census of %s for %s: %v", box, reservation, err)
98 }
99 }
100}
101
102// ownsPurge reports whether app's admission is still closed under op, the
103// exclusive purging operation the door accepted. Anything else holding the
104// admission after a 202 for op fails the case, so a closed foreign operation
105// can never be accepted.
106func (cx *claimCase) ownsPurge(t *testing.T, account, app, op string) bool {
107 t.Helper()
108 a, err := hostops.ReadCanonicalAdmission(context.Background(), cx.rel, account, app)
109 if err != nil {
110 t.Fatal(err)
111 }
112 cur := a.Value.Op
113 if cur == nil {
114 return false
115 }
116 if cur.ID != op || cur.Kind != hostops.OpPurging {
117 t.Fatalf("purge answered 202 for operation %s, but %s/%s's admission holds %+v", op, account, app, cur)
118 }
119 return true
120}
121
122// claimPurgeDoor is the owner's permanent delete of trashed titogo. A purge
123// the console accepted (202) must own the exclusive purging operation it
124// names; the boxes then publish their census for that reservation and the
125// real recovery pass carries the owned purge on until it has finished.
126func claimPurgeDoor(t *testing.T, cx *claimCase) int {
127 v := url.Values{"csrf": {cx.csrf}, "app": {"titogo"}, "confirm": {"titogo"}}
128 status, _, out := postGated(t, cx.consoleFixture, cx.browser, cx.ts.URL+"/a/bab/trash/delete", v)
129 if status != http.StatusAccepted {
130 return status
131 }
132 admission, err := hostops.ReadCanonicalAdmission(context.Background(), cx.rel, "bab", "titogo")
133 if err != nil || admission.Value.Op == nil {
134 t.Fatalf("missing accepted purge: %v", err)
135 }
136 op := admission.Value.Op.ID
137 if !strings.Contains(out, op) || !strings.Contains(out, "reserved") || !strings.Contains(out, "<html") {
138 t.Fatalf("purge acceptance is not an HTML status page: %s", out)
139 }
140
141 if !cx.ownsPurge(t, "bab", "titogo", op) {
142 t.Fatalf("purge answered 202 for %s, but titogo's admission holds no operation", op)
143 }
144 cx.publishPurgeCensus(t, op)
145 for i := 0; i < 12 && cx.ownsPurge(t, "bab", "titogo", op); i++ {
146 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
147 if err := cx.srv.RunClaimRecoveryPass(ctx); err != nil {
148 t.Logf("purge %s recovery pass %d: %v", op, i, err)
149 }
150 cancel()
151 }
152 if cx.ownsPurge(t, "bab", "titogo", op) {
153 t.Fatalf("purge %s still holds titogo's admission after the recovery passes", op)
154 }
155 if p, _, err := hostops.ReadPurge(context.Background(), cx.rel, "bab", "titogo"); err == nil && p.Phase != "done" {
156 t.Fatalf("purge %s released titogo's admission in phase %q, want done", op, p.Phase)
157 }
158 return status
159}
160
161// TestClaimPurgeDoorCompletes runs the trash/delete fixture alone and pins
162// that its open run reached the sweep's exact version delete.
163func TestClaimPurgeDoorCompletes(t *testing.T) {
164 f, ok := claimFixtures["route POST /a/{sqid}/trash/delete"]
165 if !ok || f.door == nil {
166 t.Fatal("trash/delete is not a door")
167 }
168 problems, writers := runClaimDoor(t, f.door)
169 for _, p := range problems {
170 t.Error(p)
171 }
172 if !slices.Contains(writers, "versioned/DeleteVersion") {
173 t.Errorf("the purge's open run reached %v, want versioned/DeleteVersion", writers)
174 }
175}
176