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_fixtures_test.go is the fixture table TestClaimConsoleMutationInvariant
4// compares, exactly, with s.claimEntries.snapshot() on an enabled console.
5//
6// Every key is "route <pattern>" or "worker <name>", spelled exactly as the
7// registrar records it. Every entry is a door (driven for real, foreign and
8// open), a refusal (an intentionally unsupported operation: the exact 409), a
9// read, or a nonApp entry. Reads and nonApp entries carry the reason they
10// cannot touch app-scoped state AND a probe (claimProbes, the
11// claim_invariant_probes_*.go tables): a valid authenticated request against a
12// staged resource, accepted only on the exact success status, with an effect
13// witness read from the world for every mutator. A rejected request is never
14// coverage. The probe census (claimProbeCensus) keeps the two tables in step.
15//
16// Doors drive account bab (release.OpsAccount, owned by the case's owner)
17// and its two enrolled apps: kass (shipped v1/v2, stable on v1, canary/x on
18// v2, two registry routes) and titogo (enrolled, nothing shipped). Paths are
19// literal so a door's request is exactly what a client sends.
20
21import (
22 "context"
23 "encoding/json"
24 "errors"
25 "net/http"
26 "net/url"
27 "strconv"
28 "strings"
29 "testing"
30 "time"
31
32 "github.com/carlosframework/platform/internal/appconfig"
33 "github.com/carlosframework/platform/internal/dbdoor"
34 "github.com/carlosframework/platform/internal/domains"
35 "github.com/carlosframework/platform/internal/hostclaim"
36 "github.com/carlosframework/platform/internal/hostops"
37 "github.com/carlosframework/platform/internal/objstore"
38 "github.com/carlosframework/platform/internal/provision"
39 "github.com/carlosframework/platform/internal/registry"
40 "github.com/carlosframework/platform/internal/relay"
41 "github.com/carlosframework/platform/internal/release"
42 "github.com/carlosframework/platform/internal/status"
43)
44
45// ---- table helpers ----------------------------------------------------------
46
47func claimRead(reason string) claimFixture { return claimFixture{read: reason} }
48func claimNonApp(reason string) claimFixture { return claimFixture{nonApp: reason} }
49
50// claimRefs parses "bab/kass" (an enrolled or setup-created incarnation) and
51// "+bab/name" (a name the request itself creates).
52func claimRefs(specs ...string) []claimAppRef {
53 out := make([]claimAppRef, 0, len(specs))
54 for _, s := range specs {
55 fresh := strings.HasPrefix(s, "+")
56 account, app, _ := strings.Cut(strings.TrimPrefix(s, "+"), "/")
57 out = append(out, claimAppRef{Account: account, App: app, Fresh: fresh})
58 }
59 return out
60}
61
62func claimAPI(method, path, body string) func(*testing.T, *claimCase) int {
63 return func(t *testing.T, cx *claimCase) int { return cx.api(t, method, path, body) }
64}
65
66// claimAPIVar is claimAPI with "<var>" in path or body replaced from cx.vars,
67// for names a setup only learns at runtime (a door key id, a proposal id).
68func claimAPIVar(method, path, body string) func(*testing.T, *claimCase) int {
69 return func(t *testing.T, cx *claimCase) int {
70 return cx.api(t, method, cx.expand(path), cx.expand(body))
71 }
72}
73
74func claimForm(path string, kv ...string) func(*testing.T, *claimCase) int {
75 return func(t *testing.T, cx *claimCase) int {
76 vals := make([]string, len(kv))
77 for i, v := range kv {
78 vals[i] = cx.expand(v)
79 }
80 return cx.form(t, cx.expand(path), vals...)
81 }
82}
83
84func claimWorker(name string) func(*testing.T, *claimCase) int {
85 return func(t *testing.T, cx *claimCase) int {
86 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
87 defer cancel()
88 cx.srv.RunClaimWorker(ctx, name)
89 return http.StatusOK
90 }
91}
92
93// claimRefusedAPI is claimAPI for a refused fixture: the owner's request,
94// answered with its status and body.
95func claimRefusedAPI(method, path, body string) func(*testing.T, *claimCase) (int, string) {
96 return func(t *testing.T, cx *claimCase) (int, string) {
97 status, _, out := rawReq(t, method, cx.ts.URL+cx.expand(path), cx.bearer, cx.expand(body))
98 return status, out
99 }
100}
101
102// claimRefusedForm is claimForm for a refused fixture: the signed-in
103// owner's form with the session's CSRF token (and any passkey step-up),
104// answered with its status and body.
105func claimRefusedForm(path string, kv ...string) func(*testing.T, *claimCase) (int, string) {
106 return func(t *testing.T, cx *claimCase) (int, string) {
107 v := url.Values{"csrf": {cx.csrf}}
108 for i := 0; i+1 < len(kv); i += 2 {
109 v.Add(kv[i], cx.expand(kv[i+1]))
110 }
111 status, _, out := postGated(t, cx.consoleFixture, cx.browser, cx.ts.URL+cx.expand(path), v)
112 return status, out
113 }
114}
115
116// claimLegacyAppTokenRevokeDoor is the valid authenticated request for a
117// token-revoke form at path: the signed-in owner (a member of bab, session
118// and CSRF as the browser has them) names the listing id of a live legacy app
119// token for bab/kass that the owner minted, and walks the step-up. A probe
120// without the session, the CSRF token or a real listing id is refused before
121// the mutation and so is not this fixture.
122//
123// check proves the mutation path was reached, not merely that a redirect
124// came back: after the open run the row must be gone from cli_tokens (the
125// hard DELETE ran), and after a foreign-exclusive run it must still be there.
126func claimLegacyAppTokenRevokeDoor(path string) *claimDoor {
127 return &claimDoor{
128 apps: claimRefs("bab/kass"),
129 setup: func(t *testing.T, cx *claimCase) {
130 raw, err := cx.st.NewAppToken(OpsAccountID, "kass", claimFxOwner, "census legacy app token", time.Now())
131 if err != nil {
132 t.Fatalf("stage legacy app token: %v", err)
133 }
134 cx.vars["apptok"] = hashToken(raw)
135 if !cx.legacyAppTokenLive(t) {
136 t.Fatal("the staged legacy app token is not in the owner's live listing")
137 }
138 },
139 do: claimForm(path, "id", "<apptok>"),
140 check: func(t *testing.T, cx *claimCase, open bool) string {
141 live := cx.legacyAppTokenLive(t)
142 switch {
143 case open && live:
144 return "the legacy app token is still live after the open run: the handler never reached the revoke"
145 case !open && !live:
146 return "the legacy app token was revoked while its app was closed under a foreign exclusive operation"
147 }
148 return ""
149 },
150 }
151}
152
153// claimEmailStepUpDoor is the passkey step-up callback's door for a session
154// that signed in by email: the held action is the control-plane clear, which
155// writes titogo's row, and finish completes the step-up through the entry
156// under test with the emailed code or link token, returning the final status.
157// Each run is a fresh email session with a fresh step-up send budget: a
158// completed step-up stays fresh for StepUpWindow, and the per-address send
159// limit would otherwise answer the second run, so this door must be the
160// emailed step-up every time.
161func claimEmailStepUpDoor(finish func(t *testing.T, cx *claimCase, c *http.Client, code, link string) int) *claimDoor {
162 return &claimDoor{
163 apps: claimRefs("bab/titogo"),
164 setup: func(t *testing.T, cx *claimCase) {
165 cx.mustForm(t, "/a/bab/apps/titogo/control-plane/set")
166 cx.srv.consoleMail = &fakeConsoleMail{ready: true}
167 cx.srv.challengeSent = make(chan struct{}, 4)
168 },
169 do: func(t *testing.T, cx *claimCase) int {
170 raw, err := cx.st.CreateSessionMethod(claimFxOwner, "", MethodEmail, time.Now())
171 if err != nil {
172 t.Fatal(err)
173 }
174 c := claimBrowserWithCookie(t, cx, sessionCookie, raw)
175 cx.srv.limits.mu.Lock()
176 cx.srv.limits.stepUps = keyedWindow{}
177 cx.srv.limits.mu.Unlock()
178 m := cx.rec.mark()
179 status, _, body := postForm(t, c, cx.ts.URL+"/a/bab/apps/titogo/control-plane/clear", url.Values{"csrf": {raw}})
180 if status != http.StatusOK || !strings.Contains(body, `action="/auth/email/stepup/code"`) {
181 t.Fatalf("control-plane clear did not ask for an emailed confirmation: %d:\n%s", status, body)
182 }
183 for _, e := range cx.rec.since(m) {
184 if !e.Control && (e.Unscoped || len(e.Scopes) != 0) {
185 t.Fatalf("the held form wrote %s before the emailed step-up", e)
186 }
187 }
188 select {
189 case <-cx.srv.challengeSent:
190 case <-time.After(10 * time.Second):
191 t.Fatal("the step-up email was never sent")
192 }
193 sent := cx.srv.consoleMail.(*fakeConsoleMail).sentCopy()
194 if len(sent) == 0 {
195 t.Fatal("no step-up email reached the console mailer")
196 }
197 _, msg, _ := strings.Cut(sent[len(sent)-1], "|")
198 cm, lm := stepUpCodeRe.FindStringSubmatch(msg), stepUpLinkRe.FindStringSubmatch(msg)
199 if cm == nil || lm == nil {
200 t.Fatalf("the step-up email has no code or link:\n%s", msg)
201 }
202 return finish(t, cx, c, cm[1], lm[1])
203 },
204 }
205}
206
207// legacyAppTokenLive reads the staged token's row from the owner's listing.
208func (cx *claimCase) legacyAppTokenLive(t *testing.T) bool {
209 t.Helper()
210 toks, err := cx.st.CLITokens(claimFxOwner)
211 if err != nil {
212 t.Fatal(err)
213 }
214 for _, tok := range toks {
215 if tok.ID == cx.vars["apptok"] {
216 return tok.IsApp()
217 }
218 }
219 return false
220}
221
222// claimRetireDoor deletes one record-backed host as the owner. A delete the
223// console accepted (202) must name the exclusive deleting operation it owns,
224// with the host and incarnation as its durable intent; a closed foreign
225// operation is never accepted. The boxes then acknowledge that operation
226// (world staging, as readyClaims does for a create) and the real recovery
227// pass, which is what carries an owned delete on, runs until the record
228// delete has happened or the operation finished.
229func claimRetireDoor(path, host string) func(*testing.T, *claimCase) int {
230 return func(t *testing.T, cx *claimCase) int {
231 status, _, out := rawReq(t, http.MethodDelete, cx.ts.URL+path, cx.bearer, "")
232 if status != http.StatusAccepted {
233 return status
234 }
235 op := claimReplyField(t, out, "operation_id")
236 claimReplyField(t, out, "phase")
237 for range 3 {
238 if !cx.ownsDelete(t, "bab", "titogo", op, host) {
239 break
240 }
241 cx.ackClaim(t, host, op)
242 claimWorker("StartClaimRecoveryPass")(t, cx)
243 }
244 return status
245 }
246}
247
248// ownsDelete reports whether app's admission is still closed under op, an
249// exclusive deleting operation whose durable intent names host. Anything
250// else holding the admission after a 202 for op fails the case.
251func (cx *claimCase) ownsDelete(t *testing.T, account, app, op, host string) bool {
252 t.Helper()
253 a, err := hostops.ReadCanonicalAdmission(context.Background(), cx.rel, account, app)
254 if err != nil {
255 t.Fatal(err)
256 }
257 cur := a.Value.Op
258 if cur == nil {
259 return false
260 }
261 if cur.ID != op || cur.Kind != hostops.OpDeleting || cur.Intent["host"] != host || cur.Intent["incarnation"] == "" {
262 t.Fatalf("delete answered 202 for operation %s, but %s/%s's admission holds %+v", op, account, app, cur)
263 }
264 return true
265}
266
267func (cx *claimCase) expand(s string) string {
268 for k, v := range cx.vars {
269 s = strings.ReplaceAll(s, "<"+k+">", v)
270 }
271 return s
272}
273
274// ---- staging (setup only; every one of these runs with apps open) -----------
275
276// resignPlan turns the account's pointer digest marker on and keeps the
277// resign plan for kass's channel in vars, as `carlos channels resign` shows
278// it before the person confirms.
279func (cx *claimCase) resignPlan(t *testing.T, channel string) {
280 t.Helper()
281 ctx := context.Background()
282 sg := release.RootSigner(cx.priv)
283 if err := release.WriteDigestMarker(ctx, cx.rel, sg, "bab", claimFxOwner); err != nil {
284 t.Fatalf("stage the digest marker: %v", err)
285 }
286 plan, err := sg.PlanResign(ctx, cx.rel, "bab", "kass", channel)
287 if err != nil {
288 t.Fatalf("stage a resign plan: %v", err)
289 }
290 hd, err := json.Marshal(plan.HistoryDigests)
291 if err != nil {
292 t.Fatal(err)
293 }
294 cx.vars["digest"], cx.vars["history_digests"] = plan.Digest, string(hd)
295}
296
297// authorizePlan deletes the promotion record kass's v1 promotion wrote and
298// keeps the authorize plan's digest in vars, as `carlos releases authorize`
299// shows it before the person confirms.
300func (cx *claimCase) authorizePlan(t *testing.T) {
301 t.Helper()
302 ctx := context.Background()
303 if err := cx.rel.Delete(ctx, release.PromotionRecordKey("bab", "kass", "v1")); err != nil {
304 t.Fatalf("clear kass v1's promotion record: %v", err)
305 }
306 plan, err := release.PlanAuthorize(ctx, cx.rel, "bab", "kass", "v1")
307 if err != nil {
308 t.Fatalf("stage an authorize plan: %v", err)
309 }
310 cx.vars["manifest_digest"] = plan.ManifestDigest
311}
312
313func (cx *claimCase) trash(t *testing.T, app string) {
314 t.Helper()
315 cx.mustAPI(t, http.MethodPost, "/api/cli/apps/bab/"+app+"/delete", "", http.StatusOK)
316}
317
318func (cx *claimCase) temporary(t *testing.T, name string) {
319 t.Helper()
320 cx.mustAPI(t, http.MethodPost, "/api/cli/apps/bab/new-temporary", `{"name":"`+name+`","ttl_seconds":86400}`, http.StatusOK)
321}
322
323func (cx *claimCase) fleet(t *testing.T) {
324 t.Helper()
325 cx.mustAPI(t, http.MethodPost, "/api/cli/fleets/bab/new", `{"name":"pi","bucket":"pi-fleet-data"}`, http.StatusOK)
326}
327
328// instanceWorld writes titogo's instances manifest (pool tito, region ie,
329// which the actor world has an active box for).
330func (cx *claimCase) instanceWorld(t *testing.T) {
331 t.Helper()
332 enableInstances(t, cx.consoleFixture, "titogo", titoManifest())
333}
334
335// heldInstance is the host-retry door's world: one instance whose box reports
336// it held back by a failed build.
337func (cx *claimCase) heldInstance(t *testing.T, host string) {
338 t.Helper()
339 cx.instanceWorld(t)
340 cx.instance(t, `{"host":"`+host+`","region":"ie","channel":"edge"}`)
341 box := status.CollectBox(nil, status.Probes{MachineID: "claim-box"}, time.Now())
342 box.Metrics = &status.BoxMetrics{Rollout: &status.RolloutReport{Hosts: map[string]status.HostRollout{
343 host: {Account: "bab", App: "titogo", Installed: "v2", Serving: "v1", Good: "v1", Awake: true,
344 HeldBy: "v2", Reason: status.ReasonHealth, HeldEpoch: 3, HeldBook: "claim-fixture-book", RetryV: status.HostRetryV},
345 }}}
346 if err := status.WriteBox(context.Background(), cx.rel, box); err != nil {
347 t.Fatal(err)
348 }
349}
350
351func (cx *claimCase) instance(t *testing.T, body string) {
352 t.Helper()
353 cx.mustAPI(t, http.MethodPost, "/api/cli/apps/bab/titogo/instances", body)
354 cx.readyClaims(t)
355}
356
357// movableInstance is the move door's world: an unpooled, unregioned
358// manifest, one instance in ie, and a uk edge with a staging bucket.
359func (cx *claimCase) movableInstance(t *testing.T) {
360 t.Helper()
361 m := titoManifest()
362 m.Regions = nil
363 m.Pool = ""
364 enableInstances(t, cx.consoleFixture, "titogo", m)
365 cx.instance(t, `{"host":"acme.go.tito.io","region":"ie"}`)
366 if _, err := cx.rel.Put(context.Background(), "control/edges/uk-box.json",
367 []byte(`{"v":1,"box":"uk-box","eip":"1.2.3.4","placement":"uk","data_bucket":"data-uk","data_bucket_region":"eu-west-2","hosts":{}}`), ""); err != nil {
368 t.Fatal(err)
369 }
370}
371
372func (cx *claimCase) sidecarWorld(t *testing.T) {
373 t.Helper()
374 enableSidecars(t, cx.consoleFixture, "titogo", titoSidecarManifest())
375}
376
377func (cx *claimCase) sidecar(t *testing.T) {
378 t.Helper()
379 cx.sidecarWorld(t)
380 cx.mustAPI(t, http.MethodPost, "/api/cli/apps/bab/titogo/sidecars", `{"name":"bot","region":"ie"}`)
381 cx.readyClaims(t)
382}
383
384const claimMailDomain = "titogo.bab.oncarlos.com"
385
386func (cx *claimCase) emailDeclared(t *testing.T) {
387 t.Helper()
388 declareEmail(t, cx.consoleFixture, cx.bearer, "titogo", "hello@"+claimMailDomain)
389}
390
391func (cx *claimCase) emailDomainEnsured(t *testing.T) {
392 t.Helper()
393 cx.emailDeclared(t)
394 cx.mailFake.StatusOnEnsure.DKIM, cx.mailFake.StatusOnEnsure.MailFrom, cx.mailFake.StatusOnEnsure.Sending = "SUCCESS", "SUCCESS", "SUCCESS"
395 ensureEmailDomain(t, cx.consoleFixture, cx.bearer, "titogo", claimMailDomain)
396}
397
398func (cx *claimCase) emailProvisioned(t *testing.T) {
399 t.Helper()
400 provisionEmail(t, cx.consoleFixture, cx.mailFake, cx.bearer, "titogo", claimMailDomain, "hello@"+claimMailDomain)
401}
402
403func (cx *claimCase) emailPush(t *testing.T) {
404 t.Helper()
405 cx.mustAPI(t, http.MethodPut, "/api/cli/apps/bab/titogo/email/events/push", `{"path":"/mail/events"}`, http.StatusOK)
406}
407
408func (cx *claimCase) storeGranted(t *testing.T) {
409 t.Helper()
410 grantStore(t, cx.consoleFixture, cx.bearer)
411}
412
413func (cx *claimCase) scanRequested(t *testing.T) {
414 t.Helper()
415 cx.storeGranted(t)
416 enableDeploymentScanning(t, cx.consoleFixture)
417 requestScanning(t, cx.consoleFixture, cx.bearer, false)
418}
419
420func (cx *claimCase) scanGranted(t *testing.T) {
421 t.Helper()
422 cx.scanRequested(t)
423 cx.mustAPI(t, http.MethodPost, "/api/cli/apps/bab/titogo/store/scan/grant", `{}`, http.StatusOK)
424}
425
426// scanObjectStaged is scanGranted with claim-fixture.txt present in the
427// granted bucket, the object claimOneObjectLister lists.
428func (cx *claimCase) scanObjectStaged(t *testing.T) {
429 t.Helper()
430 cx.scanGranted(t)
431 rec, _, err := objstore.Get(context.Background(), cx.rel, "bab", "titogo")
432 if err != nil || rec == nil || rec.Provisioned == nil {
433 t.Fatalf("granted store record %+v %v", rec, err)
434 }
435 cx.scanFake.PutObject(rec.Provisioned.Bucket, "claim-fixture.txt", `"claim-fixture"`, time.Now().Add(-time.Hour), 3)
436}
437
438// scanQueued stages one on-demand item for the drain to read.
439func (cx *claimCase) scanQueued(t *testing.T) {
440 t.Helper()
441 cx.scanObjectStaged(t)
442 cx.mustAPI(t, http.MethodPost, "/api/cli/apps/bab/titogo/store/scan/object", `{"key":"claim-fixture.txt"}`, http.StatusAccepted)
443}
444
445// scanMasterRotated replaces the deployment's callback master, so every
446// granted app is behind the current key id.
447func (cx *claimCase) scanMasterRotated(t *testing.T) {
448 t.Helper()
449 cx.scanGranted(t)
450 cx.srv.cfg.CallbackSigner = callbackTestSigner(t, "claim-fixture-webhook-master-2", "claim-fixture-webhook-master")
451}
452
453// relayArmed stages one armed region with a loaded master and takes the
454// writer lease, as armRelay does for the flag-off fixtures.
455func (cx *claimCase) relayArmed(t *testing.T, masters string) {
456 t.Helper()
457 t.Setenv(relayMastersEnv("eu-west-1"), masters)
458 if err := cx.srv.relayAcquire(context.Background()); err != nil {
459 t.Fatalf("relay writer lease: %v", err)
460 }
461}
462
463// relayStageMaster stages one more master for eu-west-1 (operator env)
464// and a live relay heartbeat that has loaded every staged kid (box side),
465// so a rotate has a new master to move onto.
466func (cx *claimCase) relayStageMaster(t *testing.T) {
467 t.Helper()
468 n, _ := strconv.Atoi(cx.vars["relay-masters"])
469 if n == 0 {
470 n = 1
471 }
472 n++
473 cx.vars["relay-masters"] = strconv.Itoa(n)
474 masters, kids := make([]string, 0, n), make([]string, 0, n)
475 for i := 1; i <= n; i++ {
476 kid := "k" + strconv.Itoa(i)
477 masters = append(masters, kid+":"+relayTestMaster(byte(i)))
478 kids = append(kids, kid)
479 }
480 t.Setenv(relayMastersEnv("eu-west-1"), strings.Join(masters, ","))
481 seedHeartbeat(t, cx.consoleFixture, "box1", "eu-west-1", time.Minute, kids...)
482}
483
484func (cx *claimCase) relayEnabled(t *testing.T) {
485 t.Helper()
486 cx.relayArmed(t, "k1:"+relayTestMaster(1))
487 cx.mustAPI(t, http.MethodPost, "/api/cli/apps/bab/titogo/relay", `{}`, http.StatusOK)
488}
489
490// proposal stages a guarded pipeline edit on titogo and records its id.
491func (cx *claimCase) proposal(t *testing.T) {
492 t.Helper()
493 setPipeline(t, cx.consoleFixture, "titogo",
494 release.PipelineChannel{Name: "edge"},
495 release.PipelineChannel{Name: "production", Bake: "24h", Rules: release.ChannelRules{ChangeApprovals: 1}})
496 out := cx.mustAPI(t, http.MethodPut, "/api/cli/apps/bab/titogo/pipeline",
497 `{"channels":[{"name":"edge"},{"name":"production","bake":"1h","rules":{"change_approvals":1}}]}`, http.StatusAccepted)
498 cx.vars["pending"] = claimReplyField(t, out, "pending_id")
499}
500
501func (cx *claimCase) service(t *testing.T) {
502 t.Helper()
503 cx.mustAPI(t, http.MethodPost, "/api/cli/services/bab", `{"name":"svc-a","role":"publish","app":"titogo"}`, http.StatusOK, http.StatusCreated)
504}
505
506// freshBrowser signs the owner in on a new session.
507func (cx *claimCase) freshBrowser(t *testing.T) *http.Client {
508 t.Helper()
509 cx.sessions++
510 c := newBrowser(t)
511 signUp(t, cx.consoleFixture, c, "code-claim-session-"+strconv.Itoa(cx.sessions)+"-"+claimSafeName(t.Name()))
512 return c
513}
514
515func (cx *claimCase) attached(t *testing.T, app, host, route string) {
516 t.Helper()
517 cx.mustAPI(t, http.MethodPost, "/api/cli/apps/bab/"+app+"/domains", attachBody(host, route), http.StatusOK)
518}
519
520// claimAliasRoute is titogo's provisioned instance: on an enabled console an
521// alias binds a host claim's incarnation, so its target must be a claimed,
522// serving record, never a legacy registry-only route like kass's.
523const claimAliasRoute = "acme.go.tito.io"
524
525// claimedRoute provisions claimAliasRoute (claimed and serving: readyClaims)
526// and publishes its ready registry row, which the browser attach door
527// aliases to.
528func (cx *claimCase) claimedRoute(t *testing.T) {
529 t.Helper()
530 cx.instanceWorld(t)
531 cx.instance(t, `{"host":"`+claimAliasRoute+`","region":"ie"}`)
532 cx.addRoute(t, registry.Route{Host: claimAliasRoute, Kind: "instance", Status: "ready",
533 App: "titogo", Account: "bab", Addr: "127.0.0.1:9201"})
534}
535
536// claimedAlias attaches host to claimAliasRoute through the attach door and
537// settles the alias registration it took.
538func (cx *claimCase) claimedAlias(t *testing.T, host string) {
539 t.Helper()
540 cx.claimedRoute(t)
541 cx.attached(t, "titogo", host, claimAliasRoute)
542 cx.settleRegistrations(t, "bab", "titogo")
543}
544
545// settleRegistrations removes every registration of account/app whose
546// effects settled and whose claim it completed, as the recovery sweep does.
547func (cx *claimCase) settleRegistrations(t *testing.T, account, app string) {
548 t.Helper()
549 ctx := context.Background()
550 admission, err := hostops.ReadCanonicalAdmission(ctx, cx.rel, account, app)
551 if err != nil {
552 t.Fatal(err)
553 }
554 for id := range admission.Value.Registrations {
555 if err := cx.srv.cfg.ClaimAuthority.Control.SettleRegistration(ctx, account, app, admission.Value.AppID, id, time.Now()); err != nil {
556 t.Fatalf("settle registration %s of %s/%s: %v", id, account, app, err)
557 }
558 }
559}
560
561// ackClaim stages every participant box's execution acknowledgement for
562// host's claim operation op (box side, below every boundary). A claim not
563// (yet) under op is left alone.
564func (cx *claimCase) ackClaim(t *testing.T, host, op string) {
565 t.Helper()
566 obs, err := hostclaim.Read(context.Background(), cx.rel, host)
567 if err != nil {
568 t.Fatal(err)
569 }
570 if obs.Absent || obs.Claim.Op.ID != op {
571 return
572 }
573 cx.ackExecution(t, obs)
574}
575
576// ackExecution acknowledges obs's operation for each participant that has
577// not acknowledged it yet, and returns the updated observation.
578func (cx *claimCase) ackExecution(t *testing.T, obs hostclaim.Observed) hostclaim.Observed {
579 t.Helper()
580 ctx := context.Background()
581 roster, err := cx.srv.cfg.ClaimAuthority.Directory.Read(ctx)
582 if err != nil {
583 t.Fatal(err)
584 }
585 for _, box := range obs.Claim.Op.Participants {
586 if _, done := obs.Claim.Op.Journal["exec_ack."+box]; done {
587 continue
588 }
589 op := obs.Claim.Op.ID
590 ack := hostclaim.ExecutionAcknowledgement{Op: op, Enrollment: roster.Roster.Boxes[box].Enrollment,
591 BootID: "fixture-boot", ControllerID: "fixture-controller", LeaseETag: "fixture-lease", Expires: time.Now().Add(time.Minute)}
592 if obs, err = hostclaim.Update(ctx, cx.rel, obs, hostclaim.AcknowledgeExecution(box, ack)); err != nil {
593 t.Fatalf("acknowledge %s for %s: %v", op, box, err)
594 }
595 }
596 return obs
597}
598
599// stagedMove writes a requested move to uk onto the instance record (world
600// staging below the boundary), so the cut and abort refusals answer a
601// request that would otherwise be valid.
602func (cx *claimCase) stagedMove(t *testing.T, more ...func(*provision.Record) error) {
603 t.Helper()
604 cx.movableInstance(t)
605 for _, mut := range append([]func(*provision.Record) error{provision.MoveRequest("uk", time.Now())}, more...) {
606 if _, err := provision.SetRecordMove(context.Background(), cx.rel, "bab", "titogo", "acme.go.tito.io", mut); err != nil {
607 t.Fatal(err)
608 }
609 }
610}
611
612// claimOfflineMoveID is the offline move offlineMove stages.
613const claimOfflineMoveID = "mv_zyxwvutsrqponmlkjihgfedcba"
614
615// offlineMove is movableInstance with an offline move from ie to uk walked
616// through the record's own transitions until it reaches state: the state a
617// door acts on, so its request is valid.
618func (cx *claimCase) offlineMove(t *testing.T, state string) {
619 t.Helper()
620 cx.movableInstance(t)
621 now := time.Now()
622 steps := []func(*provision.Record) error{provision.OfflineStart(claimOfflineMoveID, "ie", "uk", claimFxOwner, now)}
623 switch state {
624 case provision.OfflineStopping:
625 case provision.OfflineSealed, provision.OfflineLanded, provision.OfflineOpen:
626 steps = append(steps,
627 provision.OfflineSeal(claimOfflineMoveID, "ie-box", []string{"ie-box"}),
628 provision.OfflineSealedBy(claimOfflineMoveID, "ie-box", provision.MoveSource{}))
629 if state != provision.OfflineSealed {
630 steps = append(steps, provision.OfflineFlip(claimOfflineMoveID), provision.OfflineLandedBy(claimOfflineMoveID, "uk"))
631 }
632 if state == provision.OfflineOpen {
633 steps = append(steps, provision.OfflineOpenTransition(claimOfflineMoveID, now))
634 }
635 default:
636 t.Fatalf("offlineMove: no staging for %s", state)
637 }
638 for _, mut := range steps {
639 if _, err := provision.SetRecordMove(context.Background(), cx.rel, "bab", "titogo", "acme.go.tito.io", mut); err != nil {
640 t.Fatal(err)
641 }
642 }
643 rec, err := provision.GetRecord(context.Background(), cx.rel, "bab", "titogo", "acme.go.tito.io")
644 if err != nil || rec.Move == nil || rec.Move.State != state {
645 t.Fatalf("offlineMove: staged %+v (%v), want %s", rec, err, state)
646 }
647}
648
649// claimSecondSqid is the second account the account-move door moves into.
650var claimSecondSqid = domains.Sqid(2)
651
652func (cx *claimCase) secondAccount(t *testing.T) {
653 t.Helper()
654 if err := cx.st.CreateAccount(2, claimSecondSqid, "second", claimFxOwner, time.Now()); err != nil {
655 t.Fatal(err)
656 }
657 cx.addRoute(t, registry.Route{Host: "titogo.second.example", Kind: "instance", Status: "ready",
658 App: "titogo", Account: claimSecondSqid, Addr: "127.0.0.1:9101"})
659}
660
661// expireNow moves a temporary app's expiry into the past through the
662// test-only seed scope: the clock, not a door, is what the pass reacts to.
663func (cx *claimCase) expireNow(t *testing.T, app string) {
664 t.Helper()
665 appID, err := cx.st.ClaimAppID(OpsAccountID, app)
666 if err != nil {
667 t.Fatal(err)
668 }
669 scope := []claimSQLScope{{AccountID: OpsAccountID, App: app, AppID: appID, Mode: claimModeEffect}}
670 err = cx.st.withClaimSQL(gateCtx(t), scope, allowAllClaimSQL, func(v *Store) error {
671 _, err := v.db.Exec(`UPDATE apps SET expires_at = ? WHERE account_id = ? AND app = ?`, time.Now().Add(-time.Minute).Unix(), OpsAccountID, app)
672 return err
673 })
674 if err != nil {
675 t.Fatal(err)
676 }
677}
678
679// claimOKTransport records every outbound send before answering 200.
680type claimOKTransport struct{ rec *claimRecorder }
681
682func (tr claimOKTransport) RoundTrip(r *http.Request) (*http.Response, error) {
683 tr.rec.add(claimRawEvent{Surface: "http", Method: r.Method, Key: r.URL.String(), Unscoped: true})
684 return &http.Response{StatusCode: http.StatusOK, Status: "200 OK", Header: http.Header{},
685 Body: http.NoBody, Request: r}, nil
686}
687
688// crashedRestore stages an issuer that saved its restore receipt and died
689// before the marker delete: the recovery pass owns finishing it.
690func (cx *claimCase) crashedRestore(t *testing.T) {
691 t.Helper()
692 ctx := context.Background()
693 cx.trash(t, "titogo")
694 appID := cx.appIDs[claimAppRef{Account: "bab", App: "titogo"}]
695 ctl := cx.srv.cfg.ClaimAuthority.Control
696 owner := hostops.RegistrationOwner{Console: claimFxConsole, Enrollment: claimFxConsoleEnrollment, Controller: "claim-fixture-crashed"}
697 op := "claim-fixture-crashed-restore"
698 h, err := ctl.BeginExclusive(ctx, "bab", "titogo", appID, hostops.AppOperation{ID: op, Kind: hostops.OpAppMutation, AppID: appID,
699 Phase: "issued", Intent: map[string]string{"action": "restore", "started": strconv.FormatInt(time.Now().Add(-time.Hour).Unix(), 10)}}, owner)
700 if err != nil {
701 t.Fatalf("crashed restore: begin: %v", err)
702 }
703 if _, _, err := h.Runner(time.Now).Issue(ctx, hostops.Effect{ID: op, OperationID: op, AppID: appID,
704 Resource: hostops.OpAppMutation, Action: "restore", Owner: owner, State: "prepared"}); err != nil {
705 t.Fatalf("crashed restore: issue: %v", err)
706 }
707 tomb, err := release.ReadAppTombstone(ctx, cx.rel, "bab", "titogo")
708 if err != nil || tomb == nil {
709 t.Fatalf("crashed restore: marker %+v %v", tomb, err)
710 }
711 _, tag, err := cx.rel.Get(ctx, release.AppTombstoneKey("bab", "titogo"))
712 if err != nil {
713 t.Fatal(err)
714 }
715 if err := h.PrepareRestore(ctx, tag, tomb.DeletedAt.Unix()); err != nil {
716 t.Fatalf("crashed restore: prepare: %v", err)
717 }
718}
719
720// ---- the table ----------------------------------------------------------------
721
722var claimFixtures = map[string]claimFixture{
723 // ---- methodless routes (the handler checks r.Method itself) ----
724 "route /api/economics/backhaul": claimRead("operator backhaul report (GET only) computed from the economics view; writes nothing"),
725 "route /api/economics/bill": claimNonApp("operator bill import, stored under economics/; names no app"),
726 "route /fleet/architecture": claimRead("renders the deployment map from the cached snapshot; refreshing it is its own door"),
727 "route /fleet/architecture/refresh": claimNonApp("rebuilds the deployment map snapshot from box and edge files; no app object or row"),
728 "route /fleet/economics/bill": claimNonApp("operator bill form, stored under economics/"),
729 "route /fleet/economics/scenarios": claimNonApp("operator pricing scenarios, stored under economics/"),
730 "route /fleet/economics/tier": claimNonApp("an account's economics tier: an account record, not an app"),
731
732 // ---- DELETE ----
733 // Alias-only detach is intentionally unsupported (claimUnsupported); the
734 // alias it names is a real one, bound to a claimed instance.
735 "route DELETE /api/cli/apps/{account}/{app}/domains/{host}": {refused: &claimRefusalFixture{
736 apps: claimRefs("bab/titogo"),
737 setup: func(t *testing.T, cx *claimCase) { cx.claimedAlias(t, "tickets.example.com") },
738 do: claimRefusedAPI(http.MethodDelete, "/api/cli/apps/bab/titogo/domains/tickets.example.com", ""),
739 status: http.StatusConflict,
740 msg: claimRefusalText(errClaimAliasDetachUnsupported),
741 }},
742 "route DELETE /api/cli/apps/{account}/{app}/email/credentials/{name}": {door: &claimDoor{
743 apps: claimRefs("bab/titogo"),
744 setup: func(t *testing.T, cx *claimCase) {
745 cx.emailProvisioned(t)
746 cx.mustAPI(t, http.MethodPost, "/api/cli/apps/bab/titogo/email/credentials", `{"name":"laptop"}`, http.StatusOK)
747 },
748 do: claimAPI(http.MethodDelete, "/api/cli/apps/bab/titogo/email/credentials/laptop", ""),
749 }},
750 "route DELETE /api/cli/apps/{account}/{app}/email/domains/{domain}": {door: &claimDoor{
751 apps: claimRefs("bab/titogo"),
752 setup: func(t *testing.T, cx *claimCase) {
753 cx.mustAPI(t, http.MethodPost, "/api/cli/apps/bab/titogo/email/domains", `{"domain":"`+claimMailDomain+`"}`, http.StatusOK)
754 },
755 do: claimAPI(http.MethodDelete, "/api/cli/apps/bab/titogo/email/domains/"+claimMailDomain, ""),
756 }},
757 "route DELETE /api/cli/apps/{account}/{app}/email/events/push": {door: &claimDoor{
758 apps: claimRefs("bab/titogo"),
759 setup: func(t *testing.T, cx *claimCase) { cx.emailPush(t) },
760 do: claimAPI(http.MethodDelete, "/api/cli/apps/bab/titogo/email/events/push", ""),
761 }},
762 "route DELETE /api/cli/apps/{account}/{app}/instances/{host}": {door: &claimDoor{
763 apps: claimRefs("bab/titogo"),
764 setup: func(t *testing.T, cx *claimCase) {
765 cx.instanceWorld(t)
766 cx.instance(t, `{"host":"acme.go.tito.io","region":"ie"}`)
767 },
768 do: claimRetireDoor("/api/cli/apps/bab/titogo/instances/acme.go.tito.io", "acme.go.tito.io"),
769 }},
770 // Abort of a requested move: instance moves are intentionally
771 // unsupported (claimUnsupported).
772 "route DELETE /api/cli/apps/{account}/{app}/instances/{host}/move": {refused: &claimRefusalFixture{
773 apps: claimRefs("bab/titogo"),
774 setup: func(t *testing.T, cx *claimCase) { cx.stagedMove(t) },
775 do: claimRefusedAPI(http.MethodDelete, "/api/cli/apps/bab/titogo/instances/acme.go.tito.io/move", ""),
776 status: http.StatusConflict,
777 msg: claimMoveUnsupportedMsg,
778 }},
779 "route DELETE /api/cli/apps/{account}/{app}/sidecars/{name}": {door: &claimDoor{
780 apps: claimRefs("bab/titogo"),
781 setup: func(t *testing.T, cx *claimCase) { cx.sidecar(t) },
782 do: claimRetireDoor("/api/cli/apps/bab/titogo/sidecars/bot", provision.SidecarHost("bot", "titogo", "bab")),
783 }},
784
785 // ---- GET: reads ----
786 "route GET /a/{sqid}/apps/new": claimRead("renders the new-app form"),
787 "route GET /a/{sqid}/apps/{app}": claimRead("renders the app page from channels, releases and registry routes"),
788 "route GET /a/{sqid}/apps/{app}/account-review": claimRead("renders the route-account review card"),
789 "route GET /a/{sqid}/apps/{app}/activity": claimRead("renders the app's audit trail"),
790 "route GET /a/{sqid}/apps/{app}/analytics": claimRead("renders analytics read from the bucket"),
791 "route GET /a/{sqid}/apps/{app}/availability": claimRead("renders edge availability counts"),
792 "route GET /a/{sqid}/apps/{app}/deployments": claimRead("renders deployments (the releases page)"),
793 "route GET /a/{sqid}/apps/{app}/errors": claimRead("renders grouped app errors"),
794 "route GET /a/{sqid}/apps/{app}/instances": claimRead("renders the app's instance records"),
795 "route GET /a/{sqid}/apps/{app}/instances/{host}": claimRead("renders one instance and polls its report over HTTP"),
796 "route GET /a/{sqid}/apps/{app}/logs": claimRead("renders the logs page"),
797 "route GET /a/{sqid}/apps/{app}/logs/data": claimRead("returns log chunks as JSON"),
798 "route GET /a/{sqid}/apps/{app}/pipeline": claimRead("renders the pipeline form"),
799 "route GET /a/{sqid}/apps/{app}/releases": claimRead("renders releases"),
800 "route GET /a/{sqid}/apps/{app}/settings": claimRead("renders app settings"),
801 "route GET /a/{sqid}/apps/{app}/settings/{section}": claimRead("renders one app settings section"),
802 "route GET /a/{sqid}/fleet": claimRead("renders the account's fleet tab"),
803 "route GET /a/{sqid}/fleet/byo": claimRead("renders bring-your-own-box instructions"),
804 "route GET /a/{sqid}/fleets/{fleet}": claimRead("renders one fleet's boxes and placements"),
805 "route GET /a/{sqid}/settings": claimRead("renders account settings"),
806 "route GET /a/{sqid}/team": claimRead("renders members and invites"),
807 "route GET /a/{sqid}/trash": claimRead("lists trashed apps; purge is the trash/delete door"),
808 "route GET /a/{sqid}/{$}": claimRead("renders the account home"),
809 "route GET /accounts/new": claimRead("renders the new-account form"),
810 "route GET /api/cli/apps": claimRead("lists the caller's apps"),
811 "route GET /api/cli/apps/{account}/list": claimRead("lists an account's apps with lifecycle times"),
812 "route GET /api/cli/apps/{account}/{app}/analytics": claimRead("reads analytics"),
813 "route GET /api/cli/apps/{account}/{app}/availability": claimRead("reads availability counts"),
814 "route GET /api/cli/apps/{account}/{app}/channels": claimRead("reads channel pointers"),
815 "route GET /api/cli/apps/{account}/{app}/config": claimRead("reads app config metadata (never secret values)"),
816 "route GET /api/cli/apps/{account}/{app}/db/{host}/ltx/{level}": claimRead("serves exact LTX versions through lease.VersionedStore reads only"),
817 "route GET /api/cli/apps/{account}/{app}/domains": claimRead("lists domain claims"),
818 "route GET /api/cli/apps/{account}/{app}/domains/{host}": claimRead("reads one domain claim and its DNS status"),
819 "route GET /api/cli/apps/{account}/{app}/door-keys": claimRead("lists door keys"),
820 "route GET /api/cli/apps/{account}/{app}/edge-refs/{code}": claimRead("reads one edge record and the mesh key to verify the code"),
821 "route GET /api/cli/apps/{account}/{app}/email": claimRead("reads the email record"),
822 "route GET /api/cli/apps/{account}/{app}/email/events": claimRead("reads mail events"),
823 "route GET /api/cli/apps/{account}/{app}/email/events/push": claimRead("reads the push hook status"),
824 "route GET /api/cli/apps/{account}/{app}/errors": claimRead("reads grouped errors"),
825 "route GET /api/cli/apps/{account}/{app}/features": claimRead("reads feature flags"),
826 "route GET /api/cli/apps/{account}/{app}/geo": claimRead("looks an address up in the loaded GeoIP database; the instance gate only counts the caller's budget in memory"),
827 "route GET /api/cli/apps/{account}/{app}/instances": claimRead("lists instance records"),
828 "route GET /api/cli/apps/{account}/{app}/instances/{host}": claimRead("reads one instance record"),
829 "route GET /api/cli/apps/{account}/{app}/instances/{host}/move": claimRead("reads one instance's move state"),
830 "route GET /api/cli/apps/{account}/{app}/ledgers": claimRead("lists ledgers"),
831 "route GET /api/cli/apps/{account}/{app}/ledgers/{ledger}/head": claimRead("reads a ledger head"),
832 "route GET /api/cli/apps/{account}/{app}/logs": claimRead("reads log chunks"),
833 "route GET /api/cli/apps/{account}/{app}/pending": claimRead("lists pending proposals"),
834 "route GET /api/cli/apps/{account}/{app}/pipeline": claimRead("reads the pipeline"),
835 "route GET /api/cli/apps/{account}/{app}/regions": claimRead("reads armed regions"),
836 "route GET /api/cli/apps/{account}/{app}/relay": claimRead("reads relay status and heartbeats"),
837 "route GET /api/cli/apps/{account}/{app}/releases": claimRead("lists releases"),
838 "route GET /api/cli/apps/{account}/{app}/releases/{version}/authorize": claimRead(
839 "reads one build's manifest digests"),
840 "route GET /api/cli/apps/{account}/{app}/retention": claimRead("reads the retention policy"),
841 "route GET /api/cli/apps/{account}/{app}/routes": claimRead("lists registry routes"),
842 "route GET /api/cli/apps/{account}/{app}/schedules": claimRead("lists schedules and their box status"),
843 "route GET /api/cli/apps/{account}/{app}/sidecars": claimRead("lists sidecar records"),
844 "route GET /api/cli/apps/{account}/{app}/sidecars/{name}": claimRead("reads one sidecar record"),
845 "route GET /api/cli/apps/{account}/{app}/store": claimRead("reads the store record (never a secret)"),
846 "route GET /api/cli/apps/{account}/{app}/store/scan": claimRead("reads scanning status"),
847 "route GET /api/cli/apps/{account}/{app}/store/scan/object": claimRead(
848 "reads one object's verdict from its tags or a fallback result; parks on an in-process channel, writes nothing"),
849 "route GET /api/cli/apps/{account}/{app}/tags": claimRead("lists release tags"),
850 "route GET /api/cli/apps/{account}/{app}/tags/{version}": claimRead("reads one tag"),
851 "route GET /api/cli/apps/{account}/{app}/version-target": claimRead("reads the version target"),
852 "route GET /api/cli/fleets/{account}": claimRead("lists an account's fleets"),
853 "route GET /api/cli/services/{account}": claimRead("lists service credentials"),
854 "route GET /api/cli/status/instances": claimRead("operator live tenancy view"),
855 "route GET /api/cli/status/layout": claimRead("operator layout view"),
856 "route GET /api/cli/store/approvals": claimRead("operator entitlement queue"),
857 "route GET /api/cli/accounts/{account}/plan": claimRead("operator view of an account's plan and limits"),
858 "route GET /api/cli/system/antivirus": claimRead("reads the deployment scanning setting"),
859 "route GET /api/cli/system/geoip": claimRead("operator view of the GeoIP pointer, box heartbeats and feed status (control/geoip/, status/, edge files); writes nothing"),
860 "route GET /api/cli/system/jobs/{id}": claimRead("reads a system update job"),
861 "route GET /api/cli/system/layout": claimRead("reads the deployment layout intent"),
862 "route GET /api/cli/system/pointer-digests": claimRead("operator report of each account's pointer digest marker and the channels whose head has no digest; writes nothing"),
863 "route GET /api/cli/system/status": claimRead("reads platform release status"),
864 "route GET /apps": claimRead("legacy redirect into the account"),
865 "route GET /apps/{app}": claimRead("legacy redirect into the account"),
866 "route GET /auth/callback": claimNonApp("completes sign-in: session, user and possibly a person's first account"),
867 "route GET /auth/cli": claimRead("renders the device-code approval page"),
868 "route GET /auth/cli/tokens": claimRead("redirects to /settings"),
869 "route GET /auth/cli/whoami": claimRead("names the bearer's principal"),
870 "route GET /auth/confirm": claimRead("the retired confirmation link: renders the expired message for any token"),
871 "route GET /auth/email/link": claimNonApp("an emailed link's confirm page: sets the login-CSRF nonce cookie; spends nothing"),
872 "route GET /auth/signin": claimNonApp("starts an OAuth flow: the auth cookie and its PKCE state"),
873 "route GET /fleet": claimRead("operator fleet view"),
874 "route GET /fleet/economics": claimRead("operator economics view"),
875 "route GET /fleet/economics/accounts": claimRead("operator per-account economics table"),
876 "route GET /fleet/metrics": claimRead("operator box metrics"),
877 "route GET /fleet/store": claimRead("operator entitlement queue page"),
878 "route GET /healthz": claimRead("answers ok and the build"),
879 "route GET /invite/{token}": claimRead("shows an invitation; joining is the POST"),
880 "route GET /settings": claimRead("personal settings: the caller's own tokens"),
881 "route GET /{$}": claimRead("the front door: redirects or renders sign-in"),
882
883 // GET that writes: a replica file download streams versioned objects (reads
884 // only) but audits every successful GET as an activity row of the app
885 // (apidb.go: auditActivity), an app-scoped SQL write that needs admission.
886 // The old census called it a read and probed it with a made-up span, which
887 // is turned away before the audit, so it never saw the row.
888 "route GET /api/cli/apps/{account}/{app}/db/{host}/ltx/{level}/{span}": {door: &claimDoor{
889 apps: claimRefs("bab/titogo"),
890 setup: func(t *testing.T, cx *claimCase) {
891 cx.dbWorld(t)
892 cx.vars["dbhandle"] = cx.dbHandle(t)
893 },
894 do: func(t *testing.T, cx *claimCase) int {
895 return cx.apiReplyWith(t, http.MethodGet, dbdoor.GetPath("bab", "titogo", claimDBHost, 0, 1, 1),
896 map[string]string{dbdoor.HandleHeader: cx.vars["dbhandle"]}).Status
897 },
898 check: func(t *testing.T, cx *claimCase, open bool) string {
899 n := cx.sqlCount(t, `SELECT COUNT(*) FROM audit WHERE action = ? AND app = ?`, dbAuditAction, "titogo")
900 switch {
901 case open && n == 0:
902 return "the download wrote no db-read activity row: the handler never reached its audit"
903 case !open && n != 0:
904 return "a db-read activity row was written while the app was closed under a foreign exclusive operation"
905 }
906 return ""
907 },
908 }},
909
910 // GET that writes: the passkey step-up callback replays the held form,
911 // re-entering the ordinary handler. The held action here is the
912 // control-plane clear, which writes titogo's row.
913 "route GET /auth/stepup/callback": {door: &claimDoor{
914 apps: claimRefs("bab/titogo"),
915 setup: func(t *testing.T, cx *claimCase) { cx.mustForm(t, "/a/bab/apps/titogo/control-plane/set") },
916 do: func(t *testing.T, cx *claimCase) int {
917 // A fresh session per run: a completed step-up stays fresh for
918 // StepUpWindow, and this door must be the callback every time.
919 c := cx.freshBrowser(t)
920 m := cx.rec.mark()
921 status, loc, _ := postForm(t, c, cx.ts.URL+"/a/bab/apps/titogo/control-plane/clear", url.Values{"csrf": {sessionToken(t, cx.consoleFixture, c)}})
922 if status != http.StatusSeeOther || !strings.HasPrefix(loc, cx.fk.srv.URL) {
923 t.Fatalf("control-plane clear did not ask for a passkey: %d -> %q", status, loc)
924 }
925 for _, e := range cx.rec.since(m) {
926 if !e.Control && (e.Unscoped || len(e.Scopes) != 0) {
927 t.Fatalf("the held form wrote %s before the step-up callback", e)
928 }
929 }
930 status, _, _ = completeStepUp(t, cx.consoleFixture, c, loc)
931 return status
932 },
933 }},
934
935 // The update settings read (settings plan 3), apart so its long pattern
936 // leaves the block above aligned as it was.
937 "route GET /api/cli/apps/{account}/{app}/instances/{host}/updates": claimRead("reads one instance record, the app manifest and the box heartbeats"),
938
939 // ---- PATCH ----
940 "route PATCH /api/cli/apps/{account}/{app}/instances/{host}": {door: &claimDoor{
941 apps: claimRefs("bab/titogo"),
942 setup: func(t *testing.T, cx *claimCase) {
943 cx.instanceWorld(t)
944 cx.instance(t, `{"host":"ha.go.tito.io","region":"ie","kind":"service","addrs":["127.0.0.1:7001"]}`)
945 },
946 do: claimAPI(http.MethodPatch, "/api/cli/apps/bab/titogo/instances/ha.go.tito.io", `{"addrs":["127.0.0.1:7002","127.0.0.1:7001"]}`),
947 }},
948
949 // ---- POST: browser, account level ----
950 "route POST /a/{sqid}/analytics/setting": claimNonApp("the account-level analytics default (telemetry account record); each app's own setting is its own door"),
951 "route POST /a/{sqid}/apps/new": {door: &claimDoor{
952 apps: claimRefs("+bab/claim-web-new"),
953 do: claimForm("/a/bab/apps/new", "name", "claim-web-new"),
954 }},
955 "route POST /a/{sqid}/apps/new/region": claimRead("answers a region guess as JSON; writes nothing"),
956 "route POST /a/{sqid}/delete": claimNonApp("deletes an account only when it holds no app: AppCount is re-checked before the step-up and DeleteAccount refuses otherwise"),
957 "route POST /a/{sqid}/errors/level": claimNonApp("the account-level error log level (telemetry account record)"),
958 "route POST /a/{sqid}/fleets/{fleet}/detach": claimNonApp("detaches a fleet box in the registry; places no app"),
959 "route POST /a/{sqid}/fleets/{fleet}/place": {door: &claimDoor{
960 apps: claimRefs("bab/titogo"),
961 setup: func(t *testing.T, cx *claimCase) { cx.fleet(t) },
962 do: claimForm("/a/bab/fleets/pi/place", "app", "titogo"),
963 }},
964 "route POST /a/{sqid}/fleets/{fleet}/rotate": claimNonApp("rotates a fleet box's credential in the registry"),
965 "route POST /a/{sqid}/fleets/{fleet}/tags": claimNonApp("tags a fleet box in the registry"),
966 "route POST /a/{sqid}/invite": claimNonApp("account invitations (invites table)"),
967 "route POST /a/{sqid}/invite/revoke": claimNonApp("revokes an account invitation"),
968 "route POST /a/{sqid}/members/remove": claimNonApp("account membership rows; a person's own tokens carry no app"),
969 "route POST /a/{sqid}/members/transfer": claimNonApp("account ownership: membership roles"),
970 "route POST /a/{sqid}/services/new": {door: &claimDoor{
971 apps: claimRefs("bab/titogo"),
972 do: claimForm("/a/bab/services/new", "name", "svc-web", "role", "publish", "app", "titogo"),
973 }},
974 "route POST /a/{sqid}/services/revoke": {door: &claimDoor{
975 apps: claimRefs("bab/titogo"),
976 setup: func(t *testing.T, cx *claimCase) { cx.service(t) },
977 do: claimForm("/a/bab/services/revoke", "handle", "svc-a"),
978 }},
979 "route POST /a/{sqid}/services/rotate": {door: &claimDoor{
980 apps: claimRefs("bab/titogo"),
981 setup: func(t *testing.T, cx *claimCase) { cx.service(t) },
982 do: claimForm("/a/bab/services/rotate", "name", "svc-a"),
983 }},
984 "route POST /a/{sqid}/store/apply": claimNonApp("applies for the account's storage entitlement (store/entitlements/<account>)"),
985 "route POST /a/{sqid}/trash/delete": {door: &claimDoor{
986 apps: claimRefs("bab/titogo"),
987 setup: func(t *testing.T, cx *claimCase) { cx.purgeWorld(t) },
988 do: claimPurgeDoor,
989 }},
990 "route POST /a/{sqid}/trash/restore": {door: &claimDoor{
991 apps: claimRefs("bab/titogo"),
992 setup: func(t *testing.T, cx *claimCase) { cx.trash(t, "titogo") },
993 do: claimForm("/a/bab/trash/restore", "app", "titogo"),
994 }},
995
996 // ---- POST: browser, app level ----
997 "route POST /a/{sqid}/apps/{app}/analytics/setting": {door: &claimDoor{
998 apps: claimRefs("bab/kass"),
999 do: claimForm("/a/bab/apps/kass/analytics/setting", "count", "edge", "door", "on"),
1000 }},
1001 "route POST /a/{sqid}/apps/{app}/canary": {door: &claimDoor{
1002 apps: claimRefs("bab/kass"),
1003 do: claimForm("/a/bab/apps/kass/canary", "name", "rc", "version", "v1"),
1004 }},
1005 "route POST /a/{sqid}/apps/{app}/canary/retire": {door: &claimDoor{
1006 apps: claimRefs("bab/kass"),
1007 setup: func(t *testing.T, cx *claimCase) {
1008 cx.mustForm(t, "/a/bab/apps/kass/canary", "name", "rc", "version", "v1")
1009 },
1010 do: claimForm("/a/bab/apps/kass/canary/retire", "name", "rc", "confirmed", "1"),
1011 }},
1012 "route POST /a/{sqid}/apps/{app}/control-plane/clear": {door: &claimDoor{
1013 apps: claimRefs("bab/titogo"),
1014 setup: func(t *testing.T, cx *claimCase) { cx.mustForm(t, "/a/bab/apps/titogo/control-plane/set") },
1015 do: claimForm("/a/bab/apps/titogo/control-plane/clear"),
1016 }},
1017 "route POST /a/{sqid}/apps/{app}/control-plane/set": {door: &claimDoor{
1018 apps: claimRefs("bab/titogo"),
1019 do: claimForm("/a/bab/apps/titogo/control-plane/set"),
1020 }},
1021 "route POST /a/{sqid}/apps/{app}/delete": {door: &claimDoor{
1022 apps: claimRefs("bab/titogo"),
1023 do: claimForm("/a/bab/apps/titogo/delete", "confirm", "titogo"),
1024 }},
1025 // An alias of titogo's claimed, ready instance route.
1026 "route POST /a/{sqid}/apps/{app}/domains/attach": {door: &claimDoor{
1027 apps: claimRefs("bab/titogo"),
1028 setup: func(t *testing.T, cx *claimCase) { cx.claimedRoute(t) },
1029 do: claimForm("/a/bab/apps/titogo/domains/attach", "host", "www.titogo-claim.example"),
1030 }},
1031 // Alias-only detach is intentionally unsupported (claimUnsupported).
1032 "route POST /a/{sqid}/apps/{app}/domains/detach": {refused: &claimRefusalFixture{
1033 apps: claimRefs("bab/titogo"),
1034 setup: func(t *testing.T, cx *claimCase) { cx.claimedAlias(t, "www.titogo-claim.example") },
1035 do: claimRefusedForm("/a/bab/apps/titogo/domains/detach", "host", "www.titogo-claim.example", "confirm", "www.titogo-claim.example"),
1036 status: http.StatusConflict,
1037 msg: claimRefusalText(errClaimAliasDetachUnsupported),
1038 }},
1039 "route POST /a/{sqid}/apps/{app}/errors/level": {door: &claimDoor{
1040 apps: claimRefs("bab/kass"),
1041 do: claimForm("/a/bab/apps/kass/errors/level", "level", "warn"),
1042 }},
1043 "route POST /a/{sqid}/apps/{app}/fasttrack": {door: &claimDoor{
1044 apps: claimRefs("bab/kass"),
1045 do: claimForm("/a/bab/apps/kass/fasttrack", "channel", "stable", "confirmed", "1"),
1046 }},
1047 "route POST /a/{sqid}/apps/{app}/gate": {door: &claimDoor{
1048 apps: claimRefs("bab/titogo"),
1049 setup: func(t *testing.T, cx *claimCase) {
1050 // The gate lives on an instance record: without one the handler 404s.
1051 cx.instanceWorld(t)
1052 cx.instance(t, `{"host":"acme.go.tito.io","region":"ie"}`)
1053 },
1054 do: claimForm("/a/bab/apps/titogo/gate", "host", "acme.go.tito.io", "action", "set", "password", "claim-fixture-gate-password"),
1055 }},
1056 "route POST /a/{sqid}/apps/{app}/instances/{host}/restart": {door: &claimDoor{
1057 apps: claimRefs("bab/kass"),
1058 do: claimForm("/a/bab/apps/kass/instances/kass.carlosapp.dev/restart", "confirmed", "1"),
1059 }},
1060 "route POST /a/{sqid}/apps/{app}/instances/{host}/retry": {door: &claimDoor{
1061 apps: claimRefs("bab/titogo"),
1062 setup: func(t *testing.T, cx *claimCase) { cx.heldInstance(t, "web.go.tito.io") },
1063 do: claimForm("/a/bab/apps/titogo/instances/web.go.tito.io/retry", "confirmed", "1"),
1064 check: func(t *testing.T, cx *claimCase, open bool) string {
1065 g, found, err := release.LoadHostRetries(context.Background(), cx.rel, "bab", "titogo")
1066 if err != nil {
1067 return err.Error()
1068 }
1069 asked := false
1070 if found {
1071 for _, r := range g.Requests {
1072 asked = asked || (r.Scope.Kind == release.RestartScopeRetry && r.Scope.Value == "web.go.tito.io")
1073 }
1074 }
1075 switch {
1076 case open && !asked:
1077 return "the retry request was not written: the handler never reached the host-retry log"
1078 case !open && asked:
1079 return "a retry request was written while the app was closed under a foreign exclusive operation"
1080 }
1081 return ""
1082 },
1083 }},
1084 // The instance page's Updates section (settings plan 5 Task 5): Save and
1085 // Update now, the browser twins of plan 3's PUT …/updates and POST
1086 // …/updates/install. Save sends only what changed, so the world has a box
1087 // that applies update settings and the form switches auto off (manual).
1088 "route POST /a/{sqid}/apps/{app}/instances/{host}/updates": {door: &claimDoor{
1089 apps: claimRefs("bab/titogo"),
1090 setup: func(t *testing.T, cx *claimCase) {
1091 t.Helper()
1092 claimWorldInstance(t, cx)
1093 box := status.CollectBox(nil, status.Probes{MachineID: "claim-box"}, time.Now())
1094 box.Metrics = &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol}
1095 if err := status.WriteBox(context.Background(), cx.rel, box); err != nil {
1096 t.Fatal(err)
1097 }
1098 },
1099 do: claimForm("/a/bab/apps/titogo/instances/acme.go.tito.io/updates", "form", "updates", "was_auto", "1"),
1100 check: func(t *testing.T, cx *claimCase, open bool) string {
1101 rec, err := provision.GetRecord(context.Background(), cx.rel, "bab", "titogo", "acme.go.tito.io")
1102 if err != nil {
1103 return err.Error()
1104 }
1105 switch manual := rec.Updates == release.UpdatesManual; {
1106 case open && !manual:
1107 return "the settings were not written: the handler never reached the narrowed log"
1108 case !open && manual:
1109 return "settings were written while the app was closed under a foreign exclusive operation"
1110 }
1111 return ""
1112 },
1113 }},
1114 "route POST /a/{sqid}/apps/{app}/instances/{host}/updates/install": {door: &claimDoor{
1115 apps: claimRefs("bab/titogo"),
1116 setup: claimWorldInstance,
1117 do: claimForm("/a/bab/apps/titogo/instances/acme.go.tito.io/updates/install"),
1118 check: func(t *testing.T, cx *claimCase, open bool) string {
1119 rec, err := provision.GetRecord(context.Background(), cx.rel, "bab", "titogo", "acme.go.tito.io")
1120 if err != nil {
1121 return err.Error()
1122 }
1123 switch asked := rec.InstallRequest != nil; {
1124 case open && !asked:
1125 return "the install request was not written: the handler never reached the narrowed log"
1126 case !open && asked:
1127 return "an install request was written while the app was closed under a foreign exclusive operation"
1128 }
1129 return ""
1130 },
1131 }},
1132 "route POST /a/{sqid}/apps/{app}/keep": {door: &claimDoor{
1133 apps: claimRefs("bab/tmp-claim-keep"),
1134 setup: func(t *testing.T, cx *claimCase) { cx.temporary(t, "tmp-claim-keep") },
1135 do: claimForm("/a/bab/apps/tmp-claim-keep/keep"),
1136 }},
1137 // Account transfer is refused for every app on an enabled console
1138 // (claimUnsupported); the world is one where it would otherwise run.
1139 "route POST /a/{sqid}/apps/{app}/move": {refused: &claimRefusalFixture{
1140 apps: claimRefs("bab/titogo", "+"+claimSecondSqid+"/titogo"),
1141 setup: func(t *testing.T, cx *claimCase) { cx.secondAccount(t) },
1142 do: claimRefusedForm("/a/bab/apps/titogo/move", "confirmed", "1"),
1143 status: http.StatusConflict,
1144 msg: claimTransferUnsupportedMsg,
1145 }},
1146 "route POST /a/{sqid}/apps/{app}/pending/{id}/confirm": {door: &claimDoor{
1147 apps: claimRefs("bab/titogo"),
1148 setup: func(t *testing.T, cx *claimCase) { cx.proposal(t) },
1149 do: claimForm("/a/bab/apps/titogo/pending/<pending>/confirm"),
1150 }},
1151 "route POST /a/{sqid}/apps/{app}/pending/{id}/reject": {door: &claimDoor{
1152 apps: claimRefs("bab/titogo"),
1153 setup: func(t *testing.T, cx *claimCase) { cx.proposal(t) },
1154 do: claimForm("/a/bab/apps/titogo/pending/<pending>/reject"),
1155 }},
1156 "route POST /a/{sqid}/apps/{app}/pipeline": {door: &claimDoor{
1157 apps: claimRefs("bab/titogo"),
1158 do: claimForm("/a/bab/apps/titogo/pipeline",
1159 "ch.0.name", "edge", "ch.1.name", "production", "ch.1.bake", "24h"),
1160 }},
1161 "route POST /a/{sqid}/apps/{app}/place": {door: &claimDoor{
1162 apps: claimRefs("bab/titogo"),
1163 setup: func(t *testing.T, cx *claimCase) { cx.fleet(t) },
1164 do: claimForm("/a/bab/apps/titogo/place", "target", "fleet/pi"),
1165 }},
1166 "route POST /a/{sqid}/apps/{app}/production": {door: &claimDoor{
1167 apps: claimRefs("bab/kass"),
1168 do: claimForm("/a/bab/apps/kass/production", "production", "on"),
1169 }},
1170 "route POST /a/{sqid}/apps/{app}/promote": {door: &claimDoor{
1171 apps: claimRefs("bab/kass"),
1172 do: claimForm("/a/bab/apps/kass/promote", "channel", "canary/x", "version", "v1", "confirmed", "1"),
1173 }},
1174 // Rename is refused for every app on an enabled console
1175 // (claimUnsupported), recordless titogo included.
1176 "route POST /a/{sqid}/apps/{app}/rename": {refused: &claimRefusalFixture{
1177 apps: claimRefs("bab/titogo", "+bab/titogo-renamed"),
1178 do: claimRefusedForm("/a/bab/apps/titogo/rename", "to", "titogo-renamed", "confirmed", "1"),
1179 status: http.StatusConflict,
1180 msg: claimRenameUnsupportedMsg,
1181 }},
1182 "route POST /a/{sqid}/apps/{app}/restart": {door: &claimDoor{
1183 apps: claimRefs("bab/kass"),
1184 do: claimForm("/a/bab/apps/kass/restart", "confirmed", "1"),
1185 }},
1186 "route POST /a/{sqid}/apps/{app}/rollback": {door: &claimDoor{
1187 apps: claimRefs("bab/kass"),
1188 setup: func(t *testing.T, cx *claimCase) {
1189 cx.mustForm(t, "/a/bab/apps/kass/promote", "channel", "canary/x", "version", "v1", "confirmed", "1")
1190 },
1191 do: claimForm("/a/bab/apps/kass/rollback", "channel", "canary/x", "confirmed", "1"),
1192 }},
1193 "route POST /a/{sqid}/apps/{app}/store/destroy": {door: &claimDoor{
1194 apps: claimRefs("bab/titogo"),
1195 setup: func(t *testing.T, cx *claimCase) {
1196 cx.storeGranted(t)
1197 rec, _, err := objstore.Get(context.Background(), cx.rel, "bab", "titogo")
1198 if err != nil || rec == nil || rec.Provisioned == nil {
1199 t.Fatalf("store record %+v %v", rec, err)
1200 }
1201 cx.vars["bucket"] = rec.Provisioned.Bucket
1202 },
1203 do: claimForm("/a/bab/apps/titogo/store/destroy", "confirm", "<bucket>"),
1204 }},
1205 "route POST /a/{sqid}/apps/{app}/swap": {door: &claimDoor{
1206 apps: claimRefs("bab/kass"),
1207 do: claimForm("/a/bab/apps/kass/swap", "swap", "hold", "swap_concurrency", "2"),
1208 }},
1209
1210 // ---- POST: unauthenticated, signed ----
1211 // SES delivery events count against the sending app (mail_sender_counts).
1212 "route POST /api/ses/events": {door: &claimDoor{
1213 apps: claimRefs("bab/titogo"),
1214 setup: func(t *testing.T, cx *claimCase) {
1215 cx.srv.snsVerify = func(snsEnvelope) error { return nil } // the signature check's own seam
1216 cx.emailProvisioned(t)
1217 },
1218 do: func(t *testing.T, cx *claimCase) int {
1219 resp := sesPost(t, cx.consoleFixture, `{"Type":"Notification","TopicArn":"arn:aws:sns:eu-west-1:1:carlos-mail",`+
1220 `"Message":"{\"eventType\":\"Delivery\",\"mail\":{\"source\":\"hello@`+claimMailDomain+`\",`+
1221 `\"messageId\":\"claim-fixture-message\",\"destination\":[\"claim@example.com\"]},`+
1222 `\"delivery\":{\"recipients\":[\"claim@example.com\"]}}",`+
1223 `"SigningCertURL":"https://sns.eu-west-1.amazonaws.com/x.pem","Signature":"x"}`)
1224 resp.Body.Close()
1225 return resp.StatusCode
1226 },
1227 }},
1228
1229 // ---- POST: CLI, account and deployment level ----
1230 "route POST /accounts/new": claimNonApp("creates an account and its owner membership"),
1231 "route POST /api/cli/accounts/{account}/store/apply": claimNonApp("applies for the account's storage entitlement"),
1232 "route POST /api/cli/accounts/{account}/store/approve": claimNonApp("operator approves an account's storage entitlement"),
1233 "route POST /api/cli/accounts/{account}/plan": claimNonApp("operator sets an account's plan, packs and sponsored limits; names no app"),
1234 "route POST /api/cli/accounts/{account}/store/decline": claimNonApp("operator declines an account's storage entitlement"),
1235 "route POST /api/cli/fleets/{account}/new": claimNonApp("creates a customer fleet in the registry"),
1236 "route POST /api/cli/fleets/{account}/{fleet}/boxes": claimNonApp("enrolls a fleet box"),
1237 "route POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/detach": claimNonApp("detaches a fleet box"),
1238 "route POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/rotate": claimNonApp("rotates a fleet box's credential"),
1239 "route POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/tags": claimNonApp("tags a fleet box"),
1240 "route POST /api/cli/ops/hosts/{host}/resolve": {refused: &claimRefusalFixture{
1241 apps: []claimAppRef{{Account: "bab", App: "jam"}},
1242 setup: func(t *testing.T, cx *claimCase) {
1243 cx.enroll(t, "bab", "jam")
1244 cx.hostLossWorld(t)
1245 in := cx.lossInspection(t)
1246 body, err := json.Marshal(map[string]any{"action": "declare-lost", "submission": claimLossSubmission(in)})
1247 if err != nil {
1248 t.Fatal(err)
1249 }
1250 cx.vars["submission"] = string(body)
1251 },
1252 do: claimRefusedAPI(http.MethodPost, "/api/cli/ops/hosts/"+claimLossHost+"/resolve", "<submission>"), status: http.StatusConflict, msg: claimHostResolveUnsupportedMsg,
1253 }},
1254 "route POST /api/cli/ops/hosts/{host}/resolve/inspect": claimRead("operator host-loss inspection: reads the claim, the record and the roster and signs a challenge; writes nothing"),
1255 "route POST /api/cli/system/antivirus": claimNonApp("the deployment scanning switch (system/antivirus.json)"),
1256 "route POST /api/cli/system/layout": claimNonApp("the deployment layout intent (control/layout.json)"),
1257 "route POST /api/cli/system/activation/cancel": claimNonApp("cancels an unpublished system activation"),
1258 "route POST /api/cli/system/pointer-digests": claimNonApp("operator switch: writes or removes each account's root-signed pointer digest marker (releases/<acct>/pointer-digests), classified exactly by the mutation boundary"),
1259 "route POST /api/cli/system/rollback": claimNonApp("rolls the platform system pointer back"),
1260 "route POST /api/cli/system/seeds-floor": claimNonApp("operator seeds floor publish: control/seeds-protocol.json, classified exactly by the mutation boundary"),
1261 "route POST /api/cli/system/update": claimNonApp("moves the platform system pointer to a signed release"),
1262 "route PUT /api/cli/system/upload": claimNonApp("uploads and signs a platform system release"),
1263 "route PUT /api/cli/system/geoip": claimNonApp("operator GeoIP publish: the platform database blob and pointer under control/geoip/, each classified exactly by the mutation boundary"),
1264 "route DELETE /api/cli/system/geoip": claimNonApp("operator GeoIP off: deletes the pointer and sweeps the blobs under control/geoip/, each classified exactly by the mutation boundary"),
1265 "route POST /auth/cli/accounts": claimNonApp("creates an account for the bearer"),
1266 "route POST /auth/cli/approve": claimNonApp("approves a device-code login: cli_auth_requests"),
1267 "route POST /auth/cli/poll": claimNonApp("collects a device-code token: a person token, app ''"),
1268 "route POST /auth/cli/revoke": claimNonApp("revokes the bearer's own person token"),
1269 "route POST /auth/cli/start": claimNonApp("starts a device-code login"),
1270 // Not nonApp: main supports revoking a legacy kind='app' token here
1271 // (TestPersonalSettingsStillRevokesALegacyAppToken), and that DELETE is a
1272 // guarded cli_tokens write for the token's app. Person tokens (app '') are
1273 // the same handler's other branch; service credentials are refused.
1274 "route POST /auth/cli/tokens/revoke": {door: claimLegacyAppTokenRevokeDoor("/auth/cli/tokens/revoke")},
1275 "route POST /auth/continue": claimNonApp("starts sign-in: email challenges"),
1276 "route POST /auth/email/code": claimNonApp("signs in by an emailed code: email challenges, user, session and step-up"),
1277 // Not nonApp, for the reason the passkey step-up callback is a door: an
1278 // email session's step-up (emailed link or code) replays the held form
1279 // through the router, re-entering the ordinary handler, so an app
1280 // mutation lands through these entries. The link's sign-in branch writes
1281 // only challenges, user, session and step-up.
1282 "route POST /auth/email/link": {door: claimEmailStepUpDoor(func(t *testing.T, cx *claimCase, c *http.Client, _, link string) int {
1283 status, body, nonce := openLink(t, cx.consoleFixture, c, link)
1284 if status != http.StatusOK || nonce == "" {
1285 t.Fatalf("the step-up link's confirm page = %d:\n%s", status, body)
1286 }
1287 status, _, _ = postLink(t, cx.consoleFixture, c, link, nonce)
1288 return status
1289 })},
1290 "route POST /auth/email/stepup/code": {door: claimEmailStepUpDoor(func(t *testing.T, cx *claimCase, c *http.Client, code, _ string) int {
1291 status, _, _ := postForm(t, c, cx.ts.URL+"/auth/email/stepup/code", url.Values{"code": {code}})
1292 return status
1293 })},
1294 "route POST /auth/signout": claimNonApp("ends the session"),
1295 "route POST /fleet/store/{account}/{decision}": claimNonApp("operator entitlement decision for an account"),
1296 "route POST /invite/{token}": claimNonApp("accepts an invitation: membership"),
1297 "route POST /api/cli/apps/{account}/{app}/regions/guess": claimRead("answers a region guess from the caller's hints; writes nothing"),
1298
1299 // Re-issues every relay-enabled app's key: app config and relay records.
1300 // Each run rotates onto its own newly staged master: the foreign run
1301 // moves the region's issuer pointer (deployment state), so the open run
1302 // needs a master that is not yet the issuer to be a valid request.
1303 "route POST /api/cli/relay/rotate": {door: &claimDoor{
1304 apps: claimRefs("bab/titogo"),
1305 setup: func(t *testing.T, cx *claimCase) { cx.relayEnabled(t) },
1306 do: func(t *testing.T, cx *claimCase) int {
1307 cx.relayStageMaster(t)
1308 return cx.api(t, http.MethodPost, "/api/cli/relay/rotate", `{"region":"eu-west-1"}`)
1309 },
1310 }},
1311 "route POST /api/cli/services/{account}": {door: &claimDoor{
1312 apps: claimRefs("bab/titogo"),
1313 do: claimAPI(http.MethodPost, "/api/cli/services/bab", `{"name":"svc-cli","role":"publish","app":"titogo"}`),
1314 }},
1315 "route POST /api/cli/services/{account}/{name}/revoke": {door: &claimDoor{
1316 apps: claimRefs("bab/titogo"),
1317 setup: func(t *testing.T, cx *claimCase) { cx.service(t) },
1318 do: claimAPI(http.MethodPost, "/api/cli/services/bab/svc-a/revoke", `{}`),
1319 }},
1320 "route POST /api/cli/services/{account}/{name}/rotate": {door: &claimDoor{
1321 apps: claimRefs("bab/titogo"),
1322 setup: func(t *testing.T, cx *claimCase) { cx.service(t) },
1323 do: claimAPI(http.MethodPost, "/api/cli/services/bab/svc-a/rotate", `{}`),
1324 }},
1325
1326 // ---- POST: CLI, app level ----
1327 "route POST /api/cli/apps/{account}/new": {door: &claimDoor{
1328 apps: claimRefs("+bab/claim-cli-new"),
1329 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/new", `{"name":"claim-cli-new"}`),
1330 }},
1331 "route POST /api/cli/apps/{account}/new-temporary": {door: &claimDoor{
1332 apps: claimRefs("+bab/tmp-claim-cli"),
1333 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/new-temporary", `{"name":"tmp-claim-cli","ttl_seconds":86400}`),
1334 }},
1335 "route POST /api/cli/apps/{account}/{app}/build-probe": {door: &claimDoor{
1336 apps: claimRefs("bab/titogo"),
1337 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/build-probe", `{"build_probe":"/api/version"}`),
1338 }},
1339 "route POST /api/cli/apps/{account}/{app}/config/sync": {door: &claimDoor{
1340 apps: claimRefs("bab/titogo"),
1341 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/config/sync", `{}`),
1342 }},
1343 "route POST /api/cli/apps/{account}/{app}/delete": {door: &claimDoor{
1344 apps: claimRefs("bab/titogo"),
1345 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/delete", ""),
1346 }},
1347 // An alias of titogo's claimed instance (a legacy registry-only route
1348 // such as kass's has no incarnation to bind and is refused).
1349 "route POST /api/cli/apps/{account}/{app}/domains": {door: &claimDoor{
1350 apps: claimRefs("bab/titogo"),
1351 setup: func(t *testing.T, cx *claimCase) { cx.claimedRoute(t) },
1352 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/domains", attachBody("tickets.example.com", claimAliasRoute)),
1353 }},
1354 "route POST /api/cli/apps/{account}/{app}/door-keys": {door: &claimDoor{
1355 apps: claimRefs("bab/titogo"),
1356 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/door-keys", `{"label":"router"}`),
1357 }},
1358 "route POST /api/cli/apps/{account}/{app}/door-keys/{id}/revoke": {door: &claimDoor{
1359 apps: claimRefs("bab/titogo"),
1360 setup: func(t *testing.T, cx *claimCase) {
1361 out := cx.mustAPI(t, http.MethodPost, "/api/cli/apps/bab/titogo/door-keys", `{"label":"router"}`, http.StatusCreated)
1362 var minted struct{ ID string }
1363 if err := json.Unmarshal([]byte(out), &minted); err != nil || minted.ID == "" {
1364 t.Fatalf("door key mint reply %q: %v", out, err)
1365 }
1366 cx.vars["doorkey"] = minted.ID
1367 },
1368 do: claimAPIVar(http.MethodPost, "/api/cli/apps/bab/titogo/door-keys/<doorkey>/revoke", ""),
1369 }},
1370 "route POST /api/cli/apps/{account}/{app}/email": {door: &claimDoor{
1371 apps: claimRefs("bab/titogo"),
1372 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/email", `{"from":"hello@`+claimMailDomain+`"}`),
1373 }},
1374 "route POST /api/cli/apps/{account}/{app}/email/credentials": {door: &claimDoor{
1375 apps: claimRefs("bab/titogo"),
1376 setup: func(t *testing.T, cx *claimCase) { cx.emailProvisioned(t) },
1377 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/email/credentials", `{"name":"laptop"}`),
1378 }},
1379 "route POST /api/cli/apps/{account}/{app}/email/domains": {door: &claimDoor{
1380 apps: claimRefs("bab/titogo"),
1381 setup: func(t *testing.T, cx *claimCase) {
1382 cx.emailDeclared(t)
1383 cx.mailFake.StatusOnEnsure.DKIM = "SUCCESS"
1384 },
1385 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/email/domains", `{"domain":"`+claimMailDomain+`"}`),
1386 }},
1387 "route POST /api/cli/apps/{account}/{app}/email/domains/{domain}/verify": {door: &claimDoor{
1388 apps: claimRefs("bab/titogo"),
1389 setup: func(t *testing.T, cx *claimCase) { cx.emailDomainEnsured(t) },
1390 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/email/domains/"+claimMailDomain+"/verify", `{}`),
1391 }},
1392 "route POST /api/cli/apps/{account}/{app}/email/enable": {door: &claimDoor{
1393 apps: claimRefs("bab/titogo"),
1394 setup: func(t *testing.T, cx *claimCase) {
1395 cx.emailDomainEnsured(t)
1396 verifyEmailDomain(t, cx.consoleFixture, cx.bearer, "titogo", claimMailDomain)
1397 },
1398 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/email/enable", `{}`),
1399 }},
1400 "route POST /api/cli/apps/{account}/{app}/email/pause": {door: &claimDoor{
1401 apps: claimRefs("bab/titogo"),
1402 setup: func(t *testing.T, cx *claimCase) { cx.emailProvisioned(t) },
1403 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/email/pause", `{"paused":true}`),
1404 }},
1405 "route POST /api/cli/apps/{account}/{app}/email/rotate": {door: &claimDoor{
1406 apps: claimRefs("bab/titogo"),
1407 setup: func(t *testing.T, cx *claimCase) {
1408 cx.emailProvisioned(t)
1409 cx.mustAPI(t, http.MethodPost, "/api/cli/apps/bab/titogo/email/rotate", `{}`, http.StatusOK)
1410 },
1411 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/email/rotate", `{}`),
1412 }},
1413 "route POST /api/cli/apps/{account}/{app}/features": {door: &claimDoor{
1414 apps: claimRefs("bab/titogo"),
1415 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/features", `{"set":{"sandbox":"on"}}`),
1416 }},
1417 "route POST /api/cli/apps/{account}/{app}/instance-token": {door: &claimDoor{
1418 apps: claimRefs("bab/titogo"),
1419 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instance-token", `{}`),
1420 }},
1421 "route POST /api/cli/apps/{account}/{app}/instances": {door: &claimDoor{
1422 apps: claimRefs("bab/titogo"),
1423 setup: func(t *testing.T, cx *claimCase) { cx.instanceWorld(t) },
1424 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances", `{"host":"acme.go.tito.io","region":"ie"}`),
1425 }},
1426 "route POST /api/cli/apps/{account}/{app}/instances/{host}/channel": {door: &claimDoor{
1427 apps: claimRefs("bab/titogo"),
1428 setup: func(t *testing.T, cx *claimCase) {
1429 cx.instanceWorld(t)
1430 cx.instance(t, `{"host":"web.go.tito.io","region":"ie","channel":"edge"}`)
1431 },
1432 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/web.go.tito.io/channel", `{"channel":"beta"}`),
1433 }},
1434 // Seeded instances wait for the claim's protocol (#250, seeded-instances
1435 // spec §3.4): every import door refuses on a host-claim console before
1436 // it reads the record (claimUnsupported).
1437 "route PUT /api/cli/apps/{account}/{app}/instances/{host}/import": {refused: &claimRefusalFixture{
1438 apps: claimRefs("bab/titogo"),
1439 do: claimRefusedAPI(http.MethodPut, "/api/cli/apps/bab/titogo/instances/old.example.com/import", "SQLite format 3"),
1440 status: http.StatusConflict,
1441 msg: seedClaimConsoleRefusal().Msg,
1442 }},
1443 "route POST /api/cli/apps/{account}/{app}/instances/{host}/import/stopped": {refused: &claimRefusalFixture{
1444 apps: claimRefs("bab/titogo"),
1445 do: claimRefusedAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/old.example.com/import/stopped", ""),
1446 status: http.StatusConflict,
1447 msg: seedClaimConsoleRefusal().Msg,
1448 }},
1449 "route POST /api/cli/apps/{account}/{app}/instances/{host}/import/commit": {refused: &claimRefusalFixture{
1450 apps: claimRefs("bab/titogo"),
1451 do: claimRefusedAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/old.example.com/import/commit", ""),
1452 status: http.StatusConflict,
1453 msg: seedClaimConsoleRefusal().Msg,
1454 }},
1455 "route POST /api/cli/apps/{account}/{app}/instances/{host}/import/abort": {refused: &claimRefusalFixture{
1456 apps: claimRefs("bab/titogo"),
1457 do: claimRefusedAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/old.example.com/import/abort", ""),
1458 status: http.StatusConflict,
1459 msg: seedClaimConsoleRefusal().Msg,
1460 }},
1461 // The legacy route assignment (seeded-instances spec §6.4) says which
1462 // app may import routes filed under the operator account. It waits for
1463 // #250 with the import doors, and refuses before it reads anything; the
1464 // app it names is the one whose imports it would widen.
1465 "route POST /api/cli/system/legacy-routes": {refused: &claimRefusalFixture{
1466 apps: claimRefs("bab/kass"),
1467 do: claimRefusedAPI(http.MethodPost, "/api/cli/system/legacy-routes", `{"name":"kass","to":"bab/kass"}`),
1468 status: http.StatusConflict,
1469 msg: seedClaimConsoleRefusal().Msg,
1470 }},
1471 // The forwarded db read (db region forward spec §6, §9.1) never signs
1472 // on a host-claim console (dbForwardSigner): an instance homed outside
1473 // the console's region gets main's residency sentence, after the record
1474 // read and before anything is written. The console stays in the home
1475 // region and the instance is in ie, so the sentence is the residency one.
1476 "route POST /api/cli/apps/{account}/{app}/db/{host}/reads": {refused: &claimRefusalFixture{
1477 apps: claimRefs("bab/titogo"),
1478 setup: func(t *testing.T, cx *claimCase) {
1479 cx.movableInstance(t)
1480 cx.srv.cfg.HibernateBucket = claimDBBucket
1481 cx.srv.cfg.DBForward = true
1482 },
1483 do: claimRefusedAPI(http.MethodPost, "/api/cli/apps/bab/titogo/db/"+claimDBHost+"/reads", claimDBReadBody),
1484 status: http.StatusConflict,
1485 msg: claimDBReadResidencyMsg,
1486 }},
1487 // carlos run waits for the claim's reservation protocol (#250): every
1488 // run route refuses on a host-claim console (claimUnsupported).
1489 "route POST /api/cli/apps/{account}/{app}/instances/{host}/runs": {refused: &claimRefusalFixture{
1490 apps: claimRefs("bab/titogo"),
1491 do: claimRefusedAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/acme.example.com/runs", `{}`),
1492 status: http.StatusConflict,
1493 msg: claimRunUnsupportedMsg,
1494 }},
1495 "route GET /api/cli/apps/{account}/{app}/instances/{host}/runs": {refused: &claimRefusalFixture{
1496 apps: claimRefs("bab/titogo"),
1497 do: claimRefusedAPI(http.MethodGet, "/api/cli/apps/bab/titogo/instances/acme.example.com/runs?key=k", ""),
1498 status: http.StatusConflict,
1499 msg: claimRunUnsupportedMsg,
1500 }},
1501 "route GET /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}": {refused: &claimRefusalFixture{
1502 apps: claimRefs("bab/titogo"),
1503 do: claimRefusedAPI(http.MethodGet, "/api/cli/apps/bab/titogo/instances/acme.example.com/runs/run_aaaaaaaaaaaaaaaaaaaaaaaaaa", ""),
1504 status: http.StatusConflict,
1505 msg: claimRunUnsupportedMsg,
1506 }},
1507 "route POST /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}/cancel": {refused: &claimRefusalFixture{
1508 apps: claimRefs("bab/titogo"),
1509 do: claimRefusedAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/acme.example.com/runs/run_aaaaaaaaaaaaaaaaaaaaaaaaaa/cancel", `{}`),
1510 status: http.StatusConflict,
1511 msg: claimRunUnsupportedMsg,
1512 }},
1513 "route POST /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}/expire": {refused: &claimRefusalFixture{
1514 apps: claimRefs("bab/titogo"),
1515 do: claimRefusedAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/acme.example.com/runs/run_aaaaaaaaaaaaaaaaaaaaaaaaaa/expire", `{}`),
1516 status: http.StatusConflict,
1517 msg: claimRunUnsupportedMsg,
1518 }},
1519 "route POST /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}/ack": {refused: &claimRefusalFixture{
1520 apps: claimRefs("bab/titogo"),
1521 do: claimRefusedAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/acme.example.com/runs/run_aaaaaaaaaaaaaaaaaaaaaaaaaa/ack", `{}`),
1522 status: http.StatusConflict,
1523 msg: claimRunUnsupportedMsg,
1524 }},
1525 // Instance moves are intentionally unsupported (claimUnsupported): the
1526 // request is valid (a staging region exists), the cut follows a
1527 // promote-ready move.
1528 "route POST /api/cli/apps/{account}/{app}/instances/{host}/move": {refused: &claimRefusalFixture{
1529 apps: claimRefs("bab/titogo"),
1530 setup: func(t *testing.T, cx *claimCase) { cx.movableInstance(t) },
1531 do: claimRefusedAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/acme.go.tito.io/move", `{"region":"uk"}`),
1532 status: http.StatusConflict,
1533 msg: claimMoveUnsupportedMsg,
1534 }},
1535 "route POST /api/cli/apps/{account}/{app}/instances/{host}/move/cut": {refused: &claimRefusalFixture{
1536 apps: claimRefs("bab/titogo"),
1537 setup: func(t *testing.T, cx *claimCase) { cx.stagedMove(t, provision.MovePromoteReady(time.Now())) },
1538 do: claimRefusedAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/acme.go.tito.io/move/cut", ""),
1539 status: http.StatusConflict,
1540 msg: claimMoveUnsupportedMsg,
1541 }},
1542 // The offline operator move's doors are intentionally unsupported on a
1543 // claim console (claimUnsupported, MV-O20), as the legacy move doors
1544 // are. Each request is valid: the start names a movable instance and
1545 // another region, and each verb finds the offline move in the state it
1546 // acts on. The status view refuses the same way.
1547 "route GET /api/cli/apps/{account}/{app}/instances/{host}/move/offline": {refused: &claimRefusalFixture{
1548 apps: claimRefs("bab/titogo"),
1549 setup: func(t *testing.T, cx *claimCase) { cx.offlineMove(t, provision.OfflineStopping) },
1550 do: claimRefusedAPI(http.MethodGet, "/api/cli/apps/bab/titogo/instances/acme.go.tito.io/move/offline", ""),
1551 status: http.StatusConflict,
1552 msg: claimOfflineMoveUnsupportedMsg,
1553 }},
1554 "route POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline": {refused: &claimRefusalFixture{
1555 apps: claimRefs("bab/titogo"),
1556 setup: func(t *testing.T, cx *claimCase) { cx.movableInstance(t) },
1557 do: claimRefusedAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/acme.go.tito.io/move/offline", `{"region":"uk"}`),
1558 status: http.StatusConflict,
1559 msg: claimOfflineMoveUnsupportedMsg,
1560 }},
1561 "route POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/abort": {refused: &claimRefusalFixture{
1562 apps: claimRefs("bab/titogo"),
1563 setup: func(t *testing.T, cx *claimCase) { cx.offlineMove(t, provision.OfflineStopping) },
1564 do: claimRefusedAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/acme.go.tito.io/move/offline/abort", ""),
1565 status: http.StatusConflict,
1566 msg: claimOfflineMoveUnsupportedMsg,
1567 }},
1568 "route POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/finish": {refused: &claimRefusalFixture{
1569 apps: claimRefs("bab/titogo"),
1570 setup: func(t *testing.T, cx *claimCase) { cx.offlineMove(t, provision.OfflineOpen) },
1571 do: claimRefusedAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/acme.go.tito.io/move/offline/finish", ""),
1572 status: http.StatusConflict,
1573 msg: claimOfflineMoveUnsupportedMsg,
1574 }},
1575 "route POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/flip": {refused: &claimRefusalFixture{
1576 apps: claimRefs("bab/titogo"),
1577 setup: func(t *testing.T, cx *claimCase) { cx.offlineMove(t, provision.OfflineSealed) },
1578 do: claimRefusedAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/acme.go.tito.io/move/offline/flip", ""),
1579 status: http.StatusConflict,
1580 msg: claimOfflineMoveUnsupportedMsg,
1581 }},
1582 "route POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/open": {refused: &claimRefusalFixture{
1583 apps: claimRefs("bab/titogo"),
1584 setup: func(t *testing.T, cx *claimCase) { cx.offlineMove(t, provision.OfflineLanded) },
1585 do: claimRefusedAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/acme.go.tito.io/move/offline/open", ""),
1586 status: http.StatusConflict,
1587 msg: claimOfflineMoveUnsupportedMsg,
1588 }},
1589 "route POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/stage": {refused: &claimRefusalFixture{
1590 apps: claimRefs("bab/titogo"),
1591 setup: func(t *testing.T, cx *claimCase) { cx.movableInstance(t) },
1592 do: claimRefusedAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/acme.go.tito.io/move/offline/stage", `{"region":"uk"}`),
1593 status: http.StatusConflict,
1594 msg: claimOfflineMoveUnsupportedMsg,
1595 }},
1596 "route POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/seal": {refused: &claimRefusalFixture{
1597 apps: claimRefs("bab/titogo"),
1598 setup: func(t *testing.T, cx *claimCase) { cx.offlineMove(t, provision.OfflineStopping) },
1599 do: claimRefusedAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/acme.go.tito.io/move/offline/seal", ""),
1600 status: http.StatusConflict,
1601 msg: claimOfflineMoveUnsupportedMsg,
1602 }},
1603 "route POST /api/cli/apps/{account}/{app}/instances/{host}/retry": {door: &claimDoor{
1604 apps: claimRefs("bab/titogo"),
1605 setup: func(t *testing.T, cx *claimCase) { cx.heldInstance(t, "web.go.tito.io") },
1606 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/web.go.tito.io/retry", `{}`),
1607 check: func(t *testing.T, cx *claimCase, open bool) string {
1608 g, found, err := release.LoadHostRetries(context.Background(), cx.rel, "bab", "titogo")
1609 if err != nil {
1610 return err.Error()
1611 }
1612 asked := false
1613 if found {
1614 for _, r := range g.Requests {
1615 asked = asked || (r.Scope.Kind == release.RestartScopeRetry && r.Scope.Value == "web.go.tito.io")
1616 }
1617 }
1618 switch {
1619 case open && !asked:
1620 return "the retry request was not written: the handler never reached the host-retry log"
1621 case !open && asked:
1622 return "a retry request was written while the app was closed under a foreign exclusive operation"
1623 }
1624 return ""
1625 },
1626 }},
1627 "route POST /api/cli/apps/{account}/{app}/instances/{host}/updates/install": {door: &claimDoor{
1628 apps: claimRefs("bab/titogo"),
1629 setup: claimWorldInstance,
1630 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/acme.go.tito.io/updates/install", `{}`),
1631 }},
1632 "route POST /api/cli/apps/{account}/{app}/keep": {door: &claimDoor{
1633 apps: claimRefs("bab/tmp-claim-keep"),
1634 setup: func(t *testing.T, cx *claimCase) { cx.temporary(t, "tmp-claim-keep") },
1635 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/tmp-claim-keep/keep", `{}`),
1636 }},
1637 "route POST /api/cli/apps/{account}/{app}/ledgers/publish": {door: &claimDoor{
1638 apps: claimRefs("bab/titogo"),
1639 setup: func(t *testing.T, cx *claimCase) {
1640 cx.mustAPI(t, http.MethodPost, "/api/cli/apps/bab/titogo/ledgers/carbon/append", `{"payload":{"kg_central":1}}`, http.StatusOK)
1641 },
1642 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/ledgers/publish", `{"public":true,"ledgers":["carbon"],"title":"carbon"}`),
1643 }},
1644 "route POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/append": {door: &claimDoor{
1645 apps: claimRefs("bab/titogo"),
1646 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/ledgers/carbon/append", `{"payload":{"kg_central":1}}`),
1647 }},
1648 "route POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/blobs": {door: &claimDoor{
1649 apps: claimRefs("bab/titogo"),
1650 do: func(t *testing.T, cx *claimCase) int {
1651 req, err := http.NewRequest(http.MethodPost, cx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/prompts/blobs", strings.NewReader("corpus"))
1652 if err != nil {
1653 t.Fatal(err)
1654 }
1655 req.Header.Set("Authorization", "Bearer "+cx.bearer)
1656 res, err := http.DefaultClient.Do(req)
1657 if err != nil {
1658 t.Fatal(err)
1659 }
1660 res.Body.Close()
1661 return res.StatusCode
1662 },
1663 }},
1664 "route POST /api/cli/apps/{account}/{app}/place": {door: &claimDoor{
1665 apps: claimRefs("bab/titogo"),
1666 setup: func(t *testing.T, cx *claimCase) { cx.fleet(t) },
1667 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/place", `{"target":"fleet/pi"}`),
1668 }},
1669 "route POST /api/cli/apps/{account}/{app}/promote": {door: &claimDoor{
1670 apps: claimRefs("bab/kass"),
1671 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/kass/promote", `{"version":"v1","channel":"canary/claim"}`),
1672 }},
1673 "route POST /api/cli/apps/{account}/{app}/relay": {door: &claimDoor{
1674 apps: claimRefs("bab/titogo"),
1675 setup: func(t *testing.T, cx *claimCase) { cx.relayArmed(t, "k1:"+relayTestMaster(1)) },
1676 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/relay", `{}`),
1677 }},
1678 "route POST /api/cli/apps/{account}/{app}/relay/adopted": {door: &claimDoor{
1679 apps: claimRefs("bab/titogo"),
1680 setup: func(t *testing.T, cx *claimCase) {
1681 cx.addRoute(t, registry.Route{Host: "titogo.oncarlos.com", App: "titogo", Account: "bab",
1682 Kind: "service", Status: "ready", Addr: "10.0.0.9:8080"})
1683 if err := appconfig.SetExecDelivery(context.Background(), cx.rel, "bab", "titogo", appconfig.ExecDeliveryFile); err != nil {
1684 t.Fatal(err)
1685 }
1686 cx.relayEnabled(t)
1687 },
1688 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/relay/adopted", `{"host":"titogo.oncarlos.com"}`),
1689 }},
1690 "route POST /api/cli/apps/{account}/{app}/relay/disable": {door: &claimDoor{
1691 apps: claimRefs("bab/titogo"),
1692 setup: func(t *testing.T, cx *claimCase) { cx.relayEnabled(t) },
1693 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/relay/disable", `{}`),
1694 }},
1695 "route POST /api/cli/apps/{account}/{app}/releases": {door: &claimDoor{
1696 apps: claimRefs("bab/titogo"),
1697 do: func(t *testing.T, cx *claimCase) int {
1698 status, _ := postShip(t, cx.ts.URL+"/api/cli/apps/bab/titogo/releases", cx.bearer,
1699 []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: randomBytes(t, 1024)}})
1700 return status
1701 },
1702 }},
1703 "route POST /api/cli/apps/{account}/{app}/restart": {door: &claimDoor{
1704 apps: claimRefs("bab/titogo"),
1705 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/restart", ""),
1706 }},
1707 "route POST /api/cli/apps/{account}/{app}/restart/scope": {door: &claimDoor{
1708 apps: claimRefs("bab/kass"),
1709 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/kass/restart/scope", `{"host":"kass.carlosapp.dev"}`),
1710 }},
1711 "route POST /api/cli/apps/{account}/{app}/restore": {door: &claimDoor{
1712 apps: claimRefs("bab/titogo"),
1713 setup: func(t *testing.T, cx *claimCase) { cx.trash(t, "titogo") },
1714 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/restore", ""),
1715 }},
1716 "route POST /api/cli/apps/{account}/{app}/retention": {door: &claimDoor{
1717 apps: claimRefs("bab/titogo"),
1718 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/retention", `{"keep": 50}`),
1719 }},
1720 "route POST /api/cli/apps/{account}/{app}/channels/resign": {door: &claimDoor{
1721 apps: claimRefs("bab/kass"),
1722 setup: func(t *testing.T, cx *claimCase) { cx.resignPlan(t, "canary/x") },
1723 do: claimAPIVar(http.MethodPost, "/api/cli/apps/bab/kass/channels/resign",
1724 `{"channel":"canary/x","digest":"<digest>","history_digests":<history_digests>}`),
1725 check: func(t *testing.T, cx *claimCase, open bool) string {
1726 p, err := release.Channel(context.Background(), cx.rel, cx.pub, "bab", "kass", "canary/x")
1727 switch {
1728 case err != nil:
1729 return err.Error()
1730 case open && p.Digest != cx.vars["digest"]:
1731 return "canary/x was not re-signed with the confirmed digest"
1732 case !open && p.Digest != "":
1733 return "canary/x was re-signed while kass was foreign-owned"
1734 }
1735 return ""
1736 },
1737 }},
1738 "route POST /api/cli/apps/{account}/{app}/releases/{version}/authorize": {door: &claimDoor{
1739 apps: claimRefs("bab/kass"),
1740 setup: func(t *testing.T, cx *claimCase) { cx.authorizePlan(t) },
1741 do: claimAPIVar(http.MethodPost, "/api/cli/apps/bab/kass/releases/v1/authorize",
1742 `{"manifest_digest":"<manifest_digest>"}`),
1743 check: func(t *testing.T, cx *claimCase, open bool) string {
1744 rec, err := release.ReadPromotionRecord(context.Background(), cx.rel, cx.pub, "bab", "kass", "v1")
1745 switch {
1746 case !open && errors.Is(err, release.ErrNoPromotionRecord):
1747 return ""
1748 case !open && err == nil:
1749 return "kass v1's promotion record was written while kass was foreign-owned"
1750 case err != nil:
1751 return err.Error()
1752 case !rec.Authorized || rec.ManifestDigest != cx.vars["manifest_digest"]:
1753 return "kass v1's promotion record is not an authorization of the confirmed digest"
1754 }
1755 return ""
1756 },
1757 }},
1758 "route POST /api/cli/apps/{account}/{app}/rollback": {door: &claimDoor{
1759 apps: claimRefs("bab/kass"),
1760 setup: func(t *testing.T, cx *claimCase) {
1761 cx.mustAPI(t, http.MethodPost, "/api/cli/apps/bab/kass/promote", `{"version":"v1","channel":"canary/x"}`, http.StatusOK)
1762 },
1763 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/kass/rollback", `{"channel":"canary/x"}`),
1764 }},
1765 "route POST /api/cli/apps/{account}/{app}/schedules": {door: &claimDoor{
1766 apps: claimRefs("bab/titogo"),
1767 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/schedules", `{"name":"sync","every":"6h","path":"/jobs/sync"}`),
1768 }},
1769 "route POST /api/cli/apps/{account}/{app}/schedules/{name}/delete": {door: &claimDoor{
1770 apps: claimRefs("bab/titogo"),
1771 setup: func(t *testing.T, cx *claimCase) {
1772 cx.mustAPI(t, http.MethodPost, "/api/cli/apps/bab/titogo/schedules", `{"name":"sync","every":"6h","path":"/jobs/sync"}`, http.StatusOK)
1773 },
1774 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/schedules/sync/delete", `{}`),
1775 }},
1776 "route POST /api/cli/apps/{account}/{app}/schedules/{name}/run": {door: &claimDoor{
1777 apps: claimRefs("bab/titogo"),
1778 setup: func(t *testing.T, cx *claimCase) {
1779 cx.mustAPI(t, http.MethodPost, "/api/cli/apps/bab/titogo/schedules", `{"name":"sync","every":"6h","path":"/jobs/sync"}`, http.StatusOK)
1780 },
1781 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/schedules/sync/run", `{}`),
1782 }},
1783 "route POST /api/cli/apps/{account}/{app}/sidecars": {door: &claimDoor{
1784 apps: claimRefs("bab/titogo"),
1785 setup: func(t *testing.T, cx *claimCase) { cx.sidecarWorld(t) },
1786 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/sidecars", `{"name":"bot","region":"ie"}`),
1787 }},
1788 "route POST /api/cli/apps/{account}/{app}/sidecars/{name}/wake": {door: &claimDoor{
1789 apps: claimRefs("bab/titogo"),
1790 setup: func(t *testing.T, cx *claimCase) { cx.sidecar(t) },
1791 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/sidecars/bot/wake", ""),
1792 }},
1793 "route POST /api/cli/apps/{account}/{app}/store": {door: &claimDoor{
1794 apps: claimRefs("bab/titogo"),
1795 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/store", `{}`),
1796 }},
1797 "route POST /api/cli/apps/{account}/{app}/store/grant": {door: &claimDoor{
1798 apps: claimRefs("bab/titogo"),
1799 setup: func(t *testing.T, cx *claimCase) { declareStore(t, cx.consoleFixture, cx.bearer) },
1800 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/store/grant", `{}`),
1801 }},
1802 "route POST /api/cli/apps/{account}/{app}/store/provision": {door: &claimDoor{
1803 apps: claimRefs("bab/titogo"),
1804 setup: func(t *testing.T, cx *claimCase) {
1805 declareStore(t, cx.consoleFixture, cx.bearer)
1806 approveStoreAccount(t, cx.consoleFixture, "operator@keymail.dev")
1807 },
1808 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/store/provision", `{}`),
1809 }},
1810 "route POST /api/cli/apps/{account}/{app}/store/rotate": {door: &claimDoor{
1811 apps: claimRefs("bab/titogo"),
1812 setup: func(t *testing.T, cx *claimCase) { cx.storeGranted(t) },
1813 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/store/rotate", `{}`),
1814 }},
1815 "route POST /api/cli/apps/{account}/{app}/store/rotate/finish": {door: &claimDoor{
1816 apps: claimRefs("bab/titogo"),
1817 setup: func(t *testing.T, cx *claimCase) {
1818 cx.storeGranted(t)
1819 cx.mustAPI(t, http.MethodPost, "/api/cli/apps/bab/titogo/store/rotate", `{}`, http.StatusOK)
1820 },
1821 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/store/rotate/finish", `{}`),
1822 }},
1823 "route POST /api/cli/apps/{account}/{app}/store/scan": {door: &claimDoor{
1824 apps: claimRefs("bab/titogo"),
1825 setup: func(t *testing.T, cx *claimCase) {
1826 cx.storeGranted(t)
1827 enableDeploymentScanning(t, cx.consoleFixture)
1828 },
1829 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/store/scan", `{"encrypted":false}`),
1830 }},
1831 "route POST /api/cli/apps/{account}/{app}/store/scan/enforce": {door: &claimDoor{
1832 apps: claimRefs("bab/titogo"),
1833 setup: func(t *testing.T, cx *claimCase) { cx.scanGranted(t) },
1834 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/store/scan/enforce", `{"on":true}`),
1835 }},
1836 "route POST /api/cli/apps/{account}/{app}/store/scan/grant": {door: &claimDoor{
1837 apps: claimRefs("bab/titogo"),
1838 setup: func(t *testing.T, cx *claimCase) { cx.scanRequested(t) },
1839 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/store/scan/grant", `{}`),
1840 }},
1841 "route POST /api/cli/apps/{account}/{app}/store/scan/revoke": {door: &claimDoor{
1842 apps: claimRefs("bab/titogo"),
1843 setup: func(t *testing.T, cx *claimCase) { cx.scanGranted(t) },
1844 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/store/scan/revoke", `{}`),
1845 }},
1846 "route POST /api/cli/apps/{account}/{app}/store/scan/sweep": {door: &claimDoor{
1847 apps: claimRefs("bab/titogo"),
1848 setup: func(t *testing.T, cx *claimCase) { cx.scanGranted(t) },
1849 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/store/scan/sweep", `{}`),
1850 }},
1851 "route POST /api/cli/apps/{account}/{app}/store/scan/object": {door: &claimDoor{
1852 apps: claimRefs("bab/titogo"),
1853 setup: func(t *testing.T, cx *claimCase) { cx.scanGranted(t) },
1854 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/store/scan/object", `{"key":"claim-fixture.txt"}`),
1855 }},
1856 "route POST /api/cli/apps/{account}/{app}/store/scan/callback-key/rotate": {door: &claimDoor{
1857 apps: claimRefs("bab/titogo"),
1858 setup: func(t *testing.T, cx *claimCase) { cx.scanGranted(t) },
1859 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/store/scan/callback-key/rotate", `{}`),
1860 }},
1861 // Deployment-wide, but every effect is one scanning app's config and
1862 // store record, each written under that app's own mutation.
1863 "route POST /api/cli/system/antivirus/rotate-webhook-key": {door: &claimDoor{
1864 apps: claimRefs("bab/titogo"),
1865 setup: func(t *testing.T, cx *claimCase) { cx.scanMasterRotated(t) },
1866 do: claimAPI(http.MethodPost, "/api/cli/system/antivirus/rotate-webhook-key", `{}`),
1867 }},
1868 "route POST /api/cli/apps/{account}/{app}/ttl": {door: &claimDoor{
1869 apps: claimRefs("bab/tmp-claim-ttl"),
1870 setup: func(t *testing.T, cx *claimCase) { cx.temporary(t, "tmp-claim-ttl") },
1871 do: claimAPI(http.MethodPost, "/api/cli/apps/bab/tmp-claim-ttl/ttl", `{"ttl_seconds":432000}`),
1872 }},
1873
1874 // ---- PUT ----
1875 "route PUT /api/cli/apps/{account}/{app}/config": {door: &claimDoor{
1876 apps: claimRefs("bab/titogo"),
1877 do: claimAPI(http.MethodPut, "/api/cli/apps/bab/titogo/config", `{"set":{"PORT":{"kind":"env","value":"8080"}}}`),
1878 }},
1879 "route PUT /api/cli/apps/{account}/{app}/domains/{host}/paths": {door: &claimDoor{
1880 apps: claimRefs("bab/titogo"),
1881 setup: func(t *testing.T, cx *claimCase) { cx.claimedAlias(t, "ti.to") },
1882 do: claimAPI(http.MethodPut, "/api/cli/apps/bab/titogo/domains/ti.to/paths", `{"paths":{"paulca":"`+claimAliasRoute+`"}}`),
1883 }},
1884 "route PUT /api/cli/apps/{account}/{app}/email/events/push": {door: &claimDoor{
1885 apps: claimRefs("bab/titogo"),
1886 do: claimAPI(http.MethodPut, "/api/cli/apps/bab/titogo/email/events/push", `{"path":"/mail/events"}`),
1887 }},
1888 "route PUT /api/cli/apps/{account}/{app}/instances-manifest": {door: &claimDoor{
1889 apps: claimRefs("bab/titogo"),
1890 do: claimAPI(http.MethodPut, "/api/cli/apps/bab/titogo/instances-manifest", `{}`),
1891 }},
1892 "route PUT /api/cli/apps/{account}/{app}/instances/{host}/updates": {door: &claimDoor{
1893 apps: claimRefs("bab/titogo"),
1894 setup: claimWorldInstance,
1895 do: claimAPI(http.MethodPut, "/api/cli/apps/bab/titogo/instances/acme.go.tito.io/updates", `{"updates":"auto"}`),
1896 }},
1897 "route PUT /api/cli/apps/{account}/{app}/pipeline": {door: &claimDoor{
1898 apps: claimRefs("bab/titogo"),
1899 do: claimAPI(http.MethodPut, "/api/cli/apps/bab/titogo/pipeline", `{"channels":[{"name":"edge"}]}`),
1900 }},
1901 "route PUT /api/cli/apps/{account}/{app}/sidecars-manifest": {door: &claimDoor{
1902 apps: claimRefs("bab/titogo"),
1903 do: claimAPI(http.MethodPut, "/api/cli/apps/bab/titogo/sidecars-manifest", `{}`),
1904 }},
1905 "route PUT /api/cli/apps/{account}/{app}/swap": {door: &claimDoor{
1906 apps: claimRefs("bab/titogo"),
1907 do: claimAPI(http.MethodPut, "/api/cli/apps/bab/titogo/swap", `{"swap":"hold"}`),
1908 }},
1909 "route PUT /api/cli/apps/{account}/{app}/telemetry": {door: &claimDoor{
1910 apps: claimRefs("bab/titogo"),
1911 do: claimAPI(http.MethodPut, "/api/cli/apps/bab/titogo/telemetry", `{"errors":{"log_level":"warn"}}`),
1912 }},
1913 // The app's instance update policy (settings plan 3 Task 8) writes the
1914 // instances manifest, as the instances-manifest door does.
1915 "route PUT /api/cli/apps/{account}/{app}/updates": {door: &claimDoor{
1916 apps: claimRefs("bab/titogo"),
1917 setup: func(t *testing.T, cx *claimCase) {
1918 t.Helper()
1919 cx.instanceWorld(t)
1920 },
1921 do: claimAPI(http.MethodPut, "/api/cli/apps/bab/titogo/updates", `{"default_channel":"beta","instance_control":["updates"]}`),
1922 check: func(t *testing.T, cx *claimCase, open bool) string {
1923 m, err := provision.GetManifest(context.Background(), cx.rel, "bab", "titogo")
1924 switch {
1925 case err != nil:
1926 return err.Error()
1927 case open && (m.Channel != "beta" || m.Updates == nil):
1928 return "the titogo manifest carries no new policy after the open run: the handler never reached its write"
1929 case !open && (m.Channel != "stable" || m.Updates != nil):
1930 return "the titogo manifest's policy changed while titogo was closed under a foreign exclusive operation"
1931 }
1932 return ""
1933 },
1934 }},
1935 "route PUT /api/cli/apps/{account}/{app}/version-target": {door: &claimDoor{
1936 apps: claimRefs("bab/titogo"),
1937 do: claimAPI(http.MethodPut, "/api/cli/apps/bab/titogo/version-target", `{"target":"0.5.0"}`),
1938 }},
1939
1940 // ---- workers ----
1941 "worker LogPlacementDiscrepancies": claimRead("logs region drift between the registry and instance records; writes nothing"),
1942 "worker PublishMovesConsole": claimNonApp("the moves console record (control/moves-console.json): the platform's moves protocol and key fingerprint; no app, and nothing with the moves switch off"),
1943 "worker ReportApexSquats": claimRead("logs apex squats found in the registry; writes nothing"),
1944 "worker StartRunSweep": claimRead("off on a host-claim console, where every run route refuses: it logs that and writes nothing"),
1945 "worker StartSeedsPass": claimRead("off on a host-claim console, where every seed door and finishSeed refuse: it logs that and writes nothing"),
1946 "worker StartAlertWatcher": claimNonApp("box alert state under alerts/boxes and operator mail"),
1947 "worker StartEconomicsPass": claimNonApp("daily usage census, statements and bills: deployment and account economics"),
1948 "worker StartGeoipJobs": claimNonApp("GeoIP orphan sweep, D1 expiry and feed status under control/geoip/: the platform database, no app"),
1949 "worker StartHostReports": claimNonApp("this console's census report (status/consoles) and roster activation release"),
1950 "worker StartInfraPass": claimNonApp("the deployment's infrastructure inventory snapshot"),
1951 "worker StartPlanLimitsPass": claimNonApp("account plan rows and their limits records under accounts/<sqid>/limits.json; names no app"),
1952 "worker PublishPlansMarker": claimNonApp("the plans deployment marker (control/plans.json): whether boxes read limits; names no app"),
1953 "worker StartClaimRecoveryPass": {door: &claimDoor{
1954 apps: claimRefs("bab/titogo"),
1955 setup: func(t *testing.T, cx *claimCase) { cx.crashedRestore(t) },
1956 do: claimWorker("StartClaimRecoveryPass"),
1957 }},
1958 "worker StartDoorKeyResign": {door: &claimDoor{
1959 apps: claimRefs("bab/titogo"),
1960 setup: func(t *testing.T, cx *claimCase) {
1961 cx.mustAPI(t, http.MethodPost, "/api/cli/apps/bab/titogo/door-keys", `{"label":"router"}`, http.StatusCreated)
1962 },
1963 do: claimWorker("StartDoorKeyResign"),
1964 }},
1965 "worker StartExpiryPass": {door: &claimDoor{
1966 apps: claimRefs("bab/tmp-claim-expiry"),
1967 setup: func(t *testing.T, cx *claimCase) {
1968 cx.temporary(t, "tmp-claim-expiry")
1969 cx.expireNow(t, "tmp-claim-expiry")
1970 },
1971 do: claimWorker("StartExpiryPass"),
1972 }},
1973 "worker StartMailEventPush": {door: &claimDoor{
1974 apps: claimRefs("bab/titogo"),
1975 setup: func(t *testing.T, cx *claimCase) {
1976 claimMustRunAlone(t, "worker StartMailEventPush")
1977 old := mailPushClient.Transport
1978 mailPushClient.Transport = claimOKTransport{rec: cx.rec}
1979 t.Cleanup(func() { mailPushClient.Transport = old })
1980 cx.emailPush(t)
1981 appID := cx.appIDs[claimAppRef{Account: "bab", App: "titogo"}]
1982 scope := []claimSQLScope{{AccountID: OpsAccountID, App: "titogo", AppID: appID, Mode: claimModeEffect}}
1983 // Two queued events: the foreign run must leave both, the open run
1984 // delivers them.
1985 err := cx.st.withClaimSQL(gateCtx(t), scope, allowAllClaimSQL, func(v *Store) error {
1986 for _, notice := range []string{"claim-fixture-1", "claim-fixture-2"} {
1987 if err := v.EnqueueMailEventDelivery(context.Background(), "bab", "titogo", notice, []byte(`{}`), time.Now().Add(-time.Minute)); err != nil {
1988 return err
1989 }
1990 }
1991 return nil
1992 })
1993 if err != nil {
1994 t.Fatal(err)
1995 }
1996 },
1997 do: claimWorker("StartMailEventPush"),
1998 }},
1999 "worker StartRelay": {door: &claimDoor{
2000 apps: claimRefs("bab/titogo"),
2001 setup: func(t *testing.T, cx *claimCase) {
2002 cx.relayEnabled(t)
2003 ctx := context.Background()
2004 rec, tag, err := relay.GetRecord(ctx, cx.rel, "bab", "titogo")
2005 if err != nil || rec == nil {
2006 t.Fatalf("relay record %+v %v", rec, err)
2007 }
2008 // A converge the previous holder left half done.
2009 rec.Phase = relayPhaseConfig
2010 if _, err := relay.PutRecord(ctx, cx.rel, "bab", "titogo", rec, tag); err != nil {
2011 t.Fatal(err)
2012 }
2013 },
2014 do: claimWorker("StartRelay"),
2015 }},
2016 "worker StartScanPass": {door: &claimDoor{
2017 apps: claimRefs("bab/titogo"),
2018 setup: func(t *testing.T, cx *claimCase) { cx.scanGranted(t) },
2019 do: claimWorker("StartScanPass"),
2020 }},
2021 "worker StartScanDrain": {door: &claimDoor{
2022 apps: claimRefs("bab/titogo"),
2023 setup: func(t *testing.T, cx *claimCase) { cx.scanQueued(t) },
2024 do: claimWorker("StartScanDrain"),
2025 }},
2026}
2027
2028// readyClaims stages the box acknowledgements and completed create before a
2029// measured request. The actor simulator writes raw fixture state, while the
2030// real executor check validates the roster, release floor and acknowledgements.
2031func (cx *claimCase) readyClaims(t *testing.T) {
2032 t.Helper()
2033 ctx := context.Background()
2034 hosts, err := hostops.ListClaims(ctx, cx.rel)
2035 if err != nil {
2036 t.Fatal(err)
2037 }
2038 for _, host := range hosts {
2039 obs, err := hostclaim.Read(ctx, cx.rel, host)
2040 if err != nil {
2041 t.Fatal(err)
2042 }
2043 if obs.Absent || obs.Claim.Op.Kind != hostclaim.KindCreating {
2044 continue
2045 }
2046 obs = cx.ackExecution(t, obs)
2047 record, err := hostops.ReadRecord(ctx, cx.rel, obs.Claim.Holder.Key)
2048 if err != nil {
2049 t.Fatal(err)
2050 }
2051 executor, err := cx.srv.cfg.ClaimAuthority.Executor(ctx, obs, record)
2052 if err != nil {
2053 t.Fatalf("staged create executor: %v", err)
2054 }
2055 if _, err = hostclaim.Update(ctx, cx.rel, obs, hostclaim.CompleteCreate(obs.Claim.Op.ID, executor)); err != nil {
2056 t.Fatal(err)
2057 }
2058 cx.settleRegistrations(t, record.Holder.Account, record.Holder.App)
2059 }
2060}
2061