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_probes_nonapp_test.go is the probe table for every entry
4// that mutates deployment, account or person state (never app state): each has
5// a valid request, the world it needs, and an effect witness read from the
6// world. See claim_invariant_probes_test.go for what a probe has to prove.
7
8import (
9 "context"
10 "encoding/json"
11 "errors"
12 "github.com/carlosframework/platform/internal/hostactors"
13 "net/http"
14 "net/url"
15 "strings"
16 "testing"
17 "time"
18
19 "github.com/carlosframework/platform/internal/economics"
20 "github.com/carlosframework/platform/internal/geoip"
21 "github.com/carlosframework/platform/internal/infraview"
22 "github.com/carlosframework/platform/internal/plans"
23 "github.com/carlosframework/platform/internal/release"
24 "github.com/carlosframework/platform/internal/seeds"
25 "github.com/carlosframework/platform/internal/telemetry"
26)
27
28// claimProbes is every read, nonApp and worker probe, merged. A key in two
29// tables is a mistake the first run reports.
30var claimProbes = func() map[string]claimProbe {
31 out := map[string]claimProbe{}
32 for _, table := range []map[string]claimProbe{claimNonAppProbes, claimReadProbes, claimWorkerProbes} {
33 for key, p := range table {
34 if _, dup := out[key]; dup {
35 panic("duplicate claim probe: " + key)
36 }
37 out[key] = p
38 }
39 }
40 return out
41}()
42
43const claimBillMonth = "2026-08"
44
45// claimInviteWorld is a pending invitation to kim@keymail.dev into bab, its
46// token kept in vars.
47func claimInviteWorld(t *testing.T, cx *claimCase) {
48 t.Helper()
49 tok, err := cx.st.CreateInvite(OpsAccountID, "kim@keymail.dev", claimFxOwner, time.Now())
50 if err != nil {
51 t.Fatal(err)
52 }
53 cx.vars["invite"] = tok
54}
55
56// claimMemberWorld is kim@keymail.dev as a member of bab.
57func claimMemberWorld(t *testing.T, cx *claimCase) {
58 t.Helper()
59 if err := cx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil {
60 t.Fatal(err)
61 }
62}
63
64// claimInventorySeam gives the infra pass an inventory to take, the seam
65// production fills with the AWS provider (resolveInventory).
66func claimInventorySeam(t *testing.T, cx *claimCase) {
67 t.Helper()
68 cx.srv.inventory = func(context.Context) (infraview.Inventory, error) {
69 return infraview.Inventory{Provider: "aws", Account: "123456789012", TakenAt: time.Now().UTC(), Resources: []infraview.Resource{}}, nil
70 }
71}
72
73// claimDeviceLogin is one device-code login waiting for a human, its codes in
74// vars.
75func claimDeviceLogin(t *testing.T, cx *claimCase) {
76 t.Helper()
77 userCode, deviceCode, err := cx.st.NewCLIAuth("census terminal", "192.0.2.1", time.Now())
78 if err != nil {
79 t.Fatal(err)
80 }
81 cx.vars["usercode"], cx.vars["devicecode"] = userCode, deviceCode
82}
83
84func claimSigninProbe() claimProbe {
85 var c *http.Client
86 return claimProbe{
87 do: func(t *testing.T, cx *claimCase) claimReply {
88 c = newBrowser(t)
89 return cx.pageReplyAs(t, c, "/auth/signin")
90 },
91 status: http.StatusSeeOther, loc: claimKeymailLoc + "/oauth/authorize",
92 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
93 if cx.cookieOf(c, authCookie) == "" {
94 return "the sign-in flow's state cookie was not set"
95 }
96 return ""
97 },
98 }
99}
100
101func claimCallbackProbe() claimProbe {
102 var c *http.Client
103 return claimProbe{
104 do: func(t *testing.T, cx *claimCase) claimReply {
105 c = newBrowser(t)
106 return cx.signInReply(t, c, "code-claim-callback-probe")
107 },
108 status: http.StatusSeeOther, loc: "/a/",
109 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
110 if !cx.signedInAs(c, claimFxOwner) {
111 return "no live session for " + claimFxOwner + " came out of the callback"
112 }
113 return ""
114 },
115 }
116}
117
118func claimConfirmProbe() claimProbe {
119 // Retired (spec 11): the old Apps-inbox link only explains itself now,
120 // even for a token the old table still holds.
121 var c *http.Client
122 return claimProbe{
123 setup: func(t *testing.T, cx *claimCase) {
124 raw, err := cx.st.CreateConfirmation(claimFxOwner, time.Now())
125 if err != nil {
126 t.Fatal(err)
127 }
128 cx.vars["confirm"] = raw
129 },
130 do: func(t *testing.T, cx *claimCase) claimReply {
131 c = newBrowser(t)
132 return cx.pageReplyAs(t, c, "/auth/confirm?token=<confirm>")
133 },
134 body: []string{esc(copyText("link.expired"))},
135 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
136 if cx.signedInAs(c, claimFxOwner) {
137 return "the retired confirmation link opened a session"
138 }
139 return ""
140 },
141 }
142}
143
144// claimEmailChallengeWorld is a sign-in challenge for the case's owner in a
145// browser's sign-in flow, the flow token, link token and code kept in vars.
146func claimEmailChallengeWorld(t *testing.T, cx *claimCase) {
147 t.Helper()
148 flow, err := randomToken()
149 if err != nil {
150 t.Fatal(err)
151 }
152 link, code, err := cx.st.CreateEmailChallenge(EmailChallenge{Purpose: ChallengeSignIn, Address: claimFxOwner, FlowHash: hashToken(flow)}, time.Now())
153 if err != nil {
154 t.Fatal(err)
155 }
156 cx.vars["flow"], cx.vars["link"], cx.vars["code"] = flow, link, code
157}
158
159// claimBrowserWithCookie is a fresh browser already holding one console
160// cookie.
161func claimBrowserWithCookie(t *testing.T, cx *claimCase, name, value string) *http.Client {
162 t.Helper()
163 c := newBrowser(t)
164 u, err := url.Parse(cx.ts.URL)
165 if err != nil {
166 t.Fatal(err)
167 }
168 c.Jar.SetCookies(u, []*http.Cookie{{Name: name, Value: value, Path: "/"}})
169 return c
170}
171
172func claimEmailLinkPageProbe() claimProbe {
173 var c *http.Client
174 return claimProbe{
175 setup: claimEmailChallengeWorld,
176 do: func(t *testing.T, cx *claimCase) claimReply {
177 c = newBrowser(t)
178 return cx.pageReplyAs(t, c, "/auth/email/link?t=<link>")
179 },
180 body: []string{esc(copyText("link.title", "address", claimFxOwner)), `name="t" value="<link>"`},
181 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
182 nonce := cx.cookieOf(c, linkConfirmCookie)
183 if nonce == "" || !strings.Contains(r.Body, `name="nonce" value="`+nonce+`"`) {
184 return "the confirm page set no nonce cookie matching its form"
185 }
186 if _, ok := cx.st.PeekEmailLink(cx.vars["link"], time.Now()); !ok {
187 return "the GET spent the link"
188 }
189 return ""
190 },
191 }
192}
193
194func claimEmailCodeProbe() claimProbe {
195 var c *http.Client
196 return claimProbe{
197 setup: claimEmailChallengeWorld,
198 do: func(t *testing.T, cx *claimCase) claimReply {
199 c = claimBrowserWithCookie(t, cx, flowCookie, cx.vars["flow"])
200 status, loc, body := postForm(t, c, cx.ts.URL+"/auth/email/code", url.Values{"code": {cx.vars["code"]}})
201 return cx.browserReply(c, status, loc, body)
202 },
203 status: http.StatusSeeOther, loc: "/a/",
204 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
205 if !cx.signedInAs(c, claimFxOwner) {
206 return "the emailed code opened no session"
207 }
208 if _, ok := cx.st.LiveEmailChallenge(ChallengeSignIn, hashToken(cx.vars["flow"]), time.Now()); ok {
209 return "the challenge is still standing after its code was used"
210 }
211 return ""
212 },
213 }
214}
215
216func claimContinueProbe() claimProbe {
217 // An address Keymail's lookup does not know: the email door, which
218 // records a challenge and answers with the check page.
219 const addr = "probe@example.com"
220 return claimProbe{
221 setup: func(t *testing.T, cx *claimCase) {
222 cx.srv.consoleMail = &fakeConsoleMail{ready: true}
223 },
224 do: func(t *testing.T, cx *claimCase) claimReply {
225 status, loc, body := postForm(t, newBrowser(t), cx.ts.URL+"/auth/continue", url.Values{"address": {addr}})
226 return claimReply{Status: status, Loc: loc, Body: body}
227 },
228 body: []string{esc(copyText("check.sent", "address", addr))},
229 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
230 if cx.sqlCount(t, `SELECT COUNT(*) FROM email_challenges WHERE address = ?`, addr) == 0 {
231 return "no sign-in challenge was recorded for " + addr
232 }
233 return ""
234 },
235 }
236}
237
238func claimAcceptInviteProbe() claimProbe {
239 return claimProbe{
240 setup: claimInviteWorld,
241 do: func(t *testing.T, cx *claimCase) claimReply {
242 // The invitation is kim's: the request is kim's own signed-in
243 // browser accepting it.
244 cx.setUser("kim@keymail.dev")
245 c := newBrowser(t)
246 signUp(t, cx.consoleFixture, c, "code-claim-kim-invite")
247 return cx.formReplyAs(t, c, sessionToken(t, cx.consoleFixture, c), "/invite/<invite>")
248 },
249 status: http.StatusSeeOther, loc: "/a/bab/",
250 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
251 role, err := cx.st.Role(OpsAccountID, "kim@keymail.dev")
252 if err != nil {
253 return err.Error()
254 }
255 if role != RoleMember {
256 return "kim@keymail.dev holds role " + `"` + role + `"` + " in bab, want a member"
257 }
258 return ""
259 },
260 }
261}
262
263var claimNonAppProbes = map[string]claimProbe{
264 // ---- operator economics ----
265 "route /api/economics/bill": {
266 do: claimAPIReply(http.MethodPost, "/api/economics/bill", `{"month":"`+claimBillMonth+`","total_usd":123.45,"services":{"AmazonEC2":55}}`),
267 body: []string{`"month":"` + claimBillMonth + `"`},
268 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
269 var b economics.Bill
270 if !cx.bucketJSON(economics.BillKey(claimBillMonth), &b) {
271 return "no bill object for " + claimBillMonth
272 }
273 if b.TotalUSD != 123.45 || b.Source != economics.SourceManual || b.Services["AmazonEC2"] != 55 {
274 return "the stored bill is not the one sent"
275 }
276 return ""
277 },
278 },
279 "route /fleet/economics/bill": {
280 do: claimFormReply("/fleet/economics/bill", "month", claimBillMonth, "total", "123.45", "lines", "AmazonEC2=55"),
281 status: http.StatusSeeOther, loc: "/fleet/economics",
282 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
283 var b economics.Bill
284 if !cx.bucketJSON(economics.BillKey(claimBillMonth), &b) || b.TotalUSD != 123.45 || b.Services["AmazonEC2"] != 55 {
285 return "no manual bill for " + claimBillMonth + " with the submitted lines"
286 }
287 return ""
288 },
289 },
290 "route /fleet/economics/scenarios": {
291 do: func(t *testing.T, cx *claimCase) claimReply {
292 raw, err := json.Marshal(economics.DefaultScenario())
293 if err != nil {
294 t.Fatal(err)
295 }
296 return cx.formReply(t, "/fleet/economics/scenarios", "name", "census", "scenario", string(raw), "activate", "1")
297 },
298 status: http.StatusSeeOther, loc: "/fleet/economics",
299 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
300 var sc economics.Scenario
301 if !cx.bucketJSON(economics.ScenarioKey("census"), &sc) || sc.Name != "census" {
302 return "no scenario named census was saved"
303 }
304 var active economics.Active
305 if !cx.bucketJSON(economics.ActiveKey, &active) || active.Name != "census" {
306 return "the saved scenario was not made active"
307 }
308 return ""
309 },
310 },
311 "route /fleet/economics/tier": {
312 do: claimFormReply("/fleet/economics/tier", "account", "bab", "tier", "pool"),
313 status: http.StatusSeeOther, loc: "/fleet/economics/accounts",
314 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
315 tiers, err := cx.st.AccountTiers()
316 if err != nil {
317 return err.Error()
318 }
319 if tiers["bab"] != "pool" {
320 return `bab's tier is "` + tiers["bab"] + `", want pool`
321 }
322 return ""
323 },
324 },
325 "route /fleet/architecture/refresh": {
326 setup: claimInventorySeam,
327 do: claimFormReply("/fleet/architecture/refresh"),
328 status: http.StatusSeeOther, loc: "/fleet/architecture",
329 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
330 if r.Flash != "ok" {
331 return "the refresh did not say it refreshed: " + r.Note
332 }
333 var inv infraview.Inventory
334 if !cx.bucketJSON(infraview.InventoryKey, &inv) || inv.Provider != "aws" {
335 return "no inventory snapshot was stored"
336 }
337 return ""
338 },
339 },
340
341 // ---- sign-in and session ----
342 "route GET /auth/signin": claimSigninProbe(),
343 "route GET /auth/callback": claimCallbackProbe(),
344 "route GET /auth/confirm": claimConfirmProbe(),
345 "route POST /auth/continue": claimContinueProbe(),
346 "route GET /auth/email/link": claimEmailLinkPageProbe(),
347 "route POST /auth/email/code": claimEmailCodeProbe(),
348 "route POST /auth/signout": {
349 do: claimFormReply("/auth/signout"),
350 status: http.StatusSeeOther, loc: "/",
351 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
352 // "/" is also where a signed-out request is bounced, so the proof is
353 // the session itself: it was live and is gone.
354 if _, ok := cx.st.SessionAddress(cx.csrf, time.Now()); ok {
355 return "the session is still live after sign-out"
356 }
357 return ""
358 },
359 },
360 "route POST /accounts/new": {
361 do: claimFormReply("/accounts/new", "name", "census-acct"),
362 status: http.StatusSeeOther, loc: "/a/",
363 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
364 if !cx.accountNamed(t, claimFxOwner, "census-acct") {
365 return "the owner has no account named census-acct"
366 }
367 return ""
368 },
369 },
370 "route POST /invite/{token}": claimAcceptInviteProbe(),
371
372 // ---- the CLI's own login ----
373 "route POST /auth/cli/start": {
374 do: func(t *testing.T, cx *claimCase) claimReply {
375 return cx.apiReplyAs(t, "", http.MethodPost, "/auth/cli/start", `{"label":"census terminal"}`)
376 },
377 body: []string{`"user_code"`, `"device_code"`},
378 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
379 var out struct {
380 UserCode string `json:"user_code"`
381 }
382 if json.Unmarshal([]byte(r.Body), &out) != nil || out.UserCode == "" {
383 return "the answer carries no user code"
384 }
385 if req, err := cx.st.CLIAuthByUserCode(out.UserCode, time.Now()); err != nil || req == nil {
386 return "no login is waiting on the code it answered"
387 }
388 return ""
389 },
390 },
391 "route POST /auth/cli/approve": {
392 setup: claimDeviceLogin,
393 do: claimFormReply("/auth/cli/approve", "code", "<usercode>", "action", "approve"),
394 body: []string{"<h1>Approved</h1>"},
395 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
396 status, token, addr, err := cx.st.PollCLIAuth(cx.vars["devicecode"], time.Now())
397 if err != nil {
398 return err.Error()
399 }
400 if status != CLIAuthApproved || token == "" || addr != claimFxOwner {
401 return "the login was not approved for " + claimFxOwner + " (" + status + ")"
402 }
403 return ""
404 },
405 },
406 "route POST /auth/cli/poll": {
407 setup: func(t *testing.T, cx *claimCase) {
408 claimDeviceLogin(t, cx)
409 if err := cx.st.ApproveCLIAuth(cx.vars["usercode"], claimFxOwner, time.Now()); err != nil {
410 t.Fatal(err)
411 }
412 },
413 do: func(t *testing.T, cx *claimCase) claimReply {
414 return cx.apiReplyAs(t, "", http.MethodPost, "/auth/cli/poll", `{"device_code":"<devicecode>"}`)
415 },
416 body: []string{`"status":"approved"`, `"token"`},
417 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
418 var out struct {
419 Token string `json:"token"`
420 }
421 if json.Unmarshal([]byte(r.Body), &out) != nil || out.Token == "" {
422 return "the answer carries no token"
423 }
424 if addr, ok := cx.st.CLITokenAddress(out.Token, time.Now()); !ok || addr != claimFxOwner {
425 return "the token the poll handed out does not act as " + claimFxOwner
426 }
427 return ""
428 },
429 },
430 "route POST /auth/cli/revoke": {
431 setup: func(t *testing.T, cx *claimCase) {
432 // A second terminal of the owner's: the request revokes ITSELF.
433 cx.vars["terminal"] = mintUserToken(t, cx.st, claimFxOwner, time.Now())
434 },
435 do: func(t *testing.T, cx *claimCase) claimReply {
436 return cx.apiReplyAs(t, cx.vars["terminal"], http.MethodPost, "/auth/cli/revoke", `{}`)
437 },
438 body: []string{`"revoked":true`},
439 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
440 if _, ok := cx.st.CLITokenAddress(cx.vars["terminal"], time.Now()); ok {
441 return "the terminal's token still acts after its own revoke"
442 }
443 if _, ok := cx.st.CLITokenAddress(cx.bearer, time.Now()); !ok {
444 return "the revoke took the owner's other terminal with it"
445 }
446 return ""
447 },
448 },
449 "route POST /auth/cli/accounts": {
450 do: claimAPIReply(http.MethodPost, "/auth/cli/accounts", `{"name":"census-cli-acct"}`),
451 body: []string{`"sqid"`, `"census-cli-acct"`},
452 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
453 if !cx.accountNamed(t, claimFxOwner, "census-cli-acct") {
454 return "the owner has no account named census-cli-acct"
455 }
456 return ""
457 },
458 },
459
460 // ---- account settings and membership ----
461 "route POST /a/{sqid}/analytics/setting": {
462 do: claimFormReply("/a/bab/analytics/setting", "count", "edge", "door", "on"),
463 status: http.StatusSeeOther, loc: "/a/bab/settings",
464 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
465 st, _, err := telemetry.Read(context.Background(), cx.rel, telemetry.AccountKey("bab"))
466 if err != nil {
467 return err.Error()
468 }
469 if st.Analytics == nil || st.Analytics.Count != "edge" || st.Analytics.Door != "on" {
470 return "the account's analytics default was not set to edge and on"
471 }
472 return ""
473 },
474 },
475 "route POST /a/{sqid}/errors/level": {
476 do: claimFormReply("/a/bab/errors/level", "level", "warn"),
477 status: http.StatusSeeOther, loc: "/a/bab/settings",
478 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
479 st, _, err := telemetry.Read(context.Background(), cx.rel, telemetry.AccountKey("bab"))
480 if err != nil {
481 return err.Error()
482 }
483 if st.Errors == nil || st.Errors.LogLevel != "warn" {
484 return "the account's error log level was not set to warn"
485 }
486 return ""
487 },
488 },
489 "route POST /a/{sqid}/invite": {
490 do: claimFormReply("/a/bab/invite", "address", "kim@keymail.dev"),
491 status: http.StatusSeeOther, loc: "/a/bab/team",
492 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
493 pending, err := cx.st.PendingInvites(OpsAccountID)
494 if err != nil {
495 return err.Error()
496 }
497 for _, inv := range pending {
498 if inv.Address == "kim@keymail.dev" {
499 return ""
500 }
501 }
502 return "no invitation to kim@keymail.dev is pending"
503 },
504 },
505 "route POST /a/{sqid}/invite/revoke": {
506 setup: claimInviteWorld,
507 do: claimFormReply("/a/bab/invite/revoke", "token", "<invite>"),
508 status: http.StatusSeeOther, loc: "/a/bab/team",
509 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
510 pending, err := cx.st.PendingInvites(OpsAccountID)
511 if err != nil {
512 return err.Error()
513 }
514 for _, inv := range pending {
515 if inv.Token == cx.vars["invite"] {
516 return "the invitation is still pending"
517 }
518 }
519 if r.Flash != "ok" {
520 return "the revoke did not say it revoked: " + r.Note
521 }
522 return ""
523 },
524 },
525 "route POST /a/{sqid}/members/remove": {
526 setup: claimMemberWorld,
527 do: claimFormReply("/a/bab/members/remove", "address", "kim@keymail.dev"),
528 status: http.StatusSeeOther, loc: "/a/bab/team",
529 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
530 role, err := cx.st.Role(OpsAccountID, "kim@keymail.dev")
531 if err != nil {
532 return err.Error()
533 }
534 if role != "" {
535 return `kim@keymail.dev still holds role "` + role + `"`
536 }
537 return ""
538 },
539 },
540 "route POST /a/{sqid}/members/transfer": {
541 setup: claimMemberWorld,
542 do: claimFormReply("/a/bab/members/transfer", "address", "kim@keymail.dev"),
543 status: http.StatusSeeOther, loc: "/a/bab/team",
544 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
545 kim, err := cx.st.Role(OpsAccountID, "kim@keymail.dev")
546 if err != nil {
547 return err.Error()
548 }
549 me, err := cx.st.Role(OpsAccountID, claimFxOwner)
550 if err != nil {
551 return err.Error()
552 }
553 if kim != RoleOwner || me != RoleMember {
554 return `ownership did not move: kim is "` + kim + `", the previous owner "` + me + `"`
555 }
556 return ""
557 },
558 },
559 "route POST /a/{sqid}/delete": {
560 setup: func(t *testing.T, cx *claimCase) { cx.vars["doomed"] = cx.mintedAccount(t, "census-doomed") },
561 do: claimFormReply("/a/<doomed>/delete", "confirm", "census-doomed"),
562 status: http.StatusSeeOther, loc: "/a/",
563 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
564 acct, err := cx.st.AccountBySqid(cx.vars["doomed"])
565 if err != nil {
566 return err.Error()
567 }
568 if acct != nil {
569 return "the account is still live"
570 }
571 return ""
572 },
573 },
574 "route POST /a/{sqid}/store/apply": {
575 do: claimFormReply("/a/bab/store/apply", "note", "census"),
576 status: http.StatusSeeOther, loc: "/a/bab/settings",
577 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
578 if e := cx.entitlement(t); e == nil || e.Applied == nil || e.Applied.By != claimFxOwner {
579 return "no storage application by the owner was recorded"
580 }
581 return ""
582 },
583 },
584
585 // ---- fleets (registry) ----
586 "route POST /a/{sqid}/fleets/{fleet}/detach": {
587 setup: func(t *testing.T, cx *claimCase) { cx.fleetBoxWorld(t) },
588 do: claimFormReply("/a/bab/fleets/pi/detach", "label", "pi-1"),
589 status: http.StatusSeeOther, loc: "/a/bab/fleets/pi",
590 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
591 if b, ok := cx.fleetBox(t, "pi", "pi-1"); !ok || b.Status != "detached" {
592 return "the box is not detached in the registry"
593 }
594 return ""
595 },
596 },
597 "route POST /a/{sqid}/fleets/{fleet}/rotate": {
598 setup: func(t *testing.T, cx *claimCase) { cx.fleetBoxWorld(t) },
599 do: claimFormReply("/a/bab/fleets/pi/rotate", "label", "pi-1"),
600 body: []string{"pi-1"},
601 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
602 if b, ok := cx.fleetBox(t, "pi", "pi-1"); !ok || b.TokenHash == "" || b.TokenHash == cx.vars["boxhash"] {
603 return "the box's token hash did not change"
604 }
605 return ""
606 },
607 },
608 "route POST /a/{sqid}/fleets/{fleet}/tags": {
609 setup: func(t *testing.T, cx *claimCase) { cx.fleetBoxWorld(t) },
610 do: claimFormReply("/a/bab/fleets/pi/tags", "label", "pi-1", "tags", "office,free"),
611 status: http.StatusSeeOther, loc: "/a/bab/fleets/pi",
612 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
613 if b, ok := cx.fleetBox(t, "pi", "pi-1"); !ok || b.Tags != "office,free" {
614 return "the box is not tagged office,free"
615 }
616 return ""
617 },
618 },
619 "route POST /api/cli/fleets/{account}/new": {
620 do: claimAPIReply(http.MethodPost, "/api/cli/fleets/bab/new", `{"name":"pi","bucket":"pi-fleet-data"}`),
621 body: []string{`"fleet"`, `"pi-fleet-data"`},
622 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
623 if !cx.fleetNamed(t, "pi") {
624 return "the registry has no fleet pi"
625 }
626 return ""
627 },
628 },
629 "route POST /api/cli/fleets/{account}/{fleet}/boxes": {
630 setup: func(t *testing.T, cx *claimCase) { cx.fleet(t) },
631 do: claimAPIReply(http.MethodPost, "/api/cli/fleets/bab/pi/boxes", `{"label":"pi-1"}`),
632 body: []string{`"token"`, `"pi-1"`},
633 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
634 if b, ok := cx.fleetBox(t, "pi", "pi-1"); !ok || b.Status != "active" {
635 return "the registry has no active box pi-1 in fleet pi"
636 }
637 return ""
638 },
639 },
640 "route POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/detach": {
641 setup: func(t *testing.T, cx *claimCase) { cx.fleetBoxWorld(t) },
642 do: claimAPIReply(http.MethodPost, "/api/cli/fleets/bab/pi/boxes/pi-1/detach", `{}`),
643 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
644 if b, ok := cx.fleetBox(t, "pi", "pi-1"); !ok || b.Status != "detached" {
645 return "the box is not detached in the registry"
646 }
647 return ""
648 },
649 },
650 "route POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/rotate": {
651 setup: func(t *testing.T, cx *claimCase) { cx.fleetBoxWorld(t) },
652 do: claimAPIReply(http.MethodPost, "/api/cli/fleets/bab/pi/boxes/pi-1/rotate", `{}`),
653 body: []string{`"token"`},
654 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
655 if b, ok := cx.fleetBox(t, "pi", "pi-1"); !ok || b.TokenHash == "" || b.TokenHash == cx.vars["boxhash"] {
656 return "the box's token hash did not change"
657 }
658 return ""
659 },
660 },
661 "route POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/tags": {
662 setup: func(t *testing.T, cx *claimCase) { cx.fleetBoxWorld(t) },
663 do: claimAPIReply(http.MethodPost, "/api/cli/fleets/bab/pi/boxes/pi-1/tags", `{"tags":"office,free"}`),
664 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
665 if b, ok := cx.fleetBox(t, "pi", "pi-1"); !ok || b.Tags != "office,free" {
666 return "the box is not tagged office,free"
667 }
668 return ""
669 },
670 },
671
672 // ---- object-storage entitlements ----
673 "route POST /api/cli/accounts/{account}/store/apply": {
674 do: claimAPIReply(http.MethodPost, "/api/cli/accounts/bab/store/apply", `{"note":"census"}`),
675 body: []string{`"entitlement"`},
676 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
677 if e := cx.entitlement(t); e == nil || e.Applied == nil || e.Applied.Note != "census" {
678 return "no storage application with the note was recorded"
679 }
680 return ""
681 },
682 },
683 "route POST /api/cli/accounts/{account}/plan": {
684 setup: func(t *testing.T, cx *claimCase) { cx.srv.cfg.PlanCatalog = examplePlanCatalog(t) },
685 do: claimAPIReply(http.MethodPost, "/api/cli/accounts/bab/plan", `{"plan":"personal","reason":"census"}`),
686 body: []string{`"plan":"personal"`},
687 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
688 var rec plans.Record
689 if !cx.bucketJSON(plans.LimitsKey("bab"), &rec) || rec.Plan != "personal" {
690 return "the personal plan was not published for bab"
691 }
692 return ""
693 },
694 },
695 "route POST /api/cli/accounts/{account}/store/approve": {
696 setup: func(t *testing.T, cx *claimCase) {
697 cx.mustAPI(t, http.MethodPost, "/api/cli/accounts/bab/store/apply", `{"note":"census"}`, http.StatusOK)
698 },
699 do: claimAPIReply(http.MethodPost, "/api/cli/accounts/bab/store/approve", `{}`),
700 body: []string{`"entitlement"`},
701 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
702 if e := cx.entitlement(t); e == nil || !e.Approved() {
703 return "the storage entitlement is not approved"
704 }
705 return ""
706 },
707 },
708 "route POST /api/cli/accounts/{account}/store/decline": {
709 setup: func(t *testing.T, cx *claimCase) {
710 cx.mustAPI(t, http.MethodPost, "/api/cli/accounts/bab/store/apply", `{"note":"census"}`, http.StatusOK)
711 },
712 do: claimAPIReply(http.MethodPost, "/api/cli/accounts/bab/store/decline", `{"reason":"census decline"}`),
713 body: []string{`"entitlement"`},
714 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
715 if e := cx.entitlement(t); e == nil || e.Decision == nil || e.Decision.Approved || e.Decision.Reason != "census decline" {
716 return "the storage entitlement is not declined with the reason"
717 }
718 return ""
719 },
720 },
721 "route POST /fleet/store/{account}/{decision}": {
722 setup: func(t *testing.T, cx *claimCase) {
723 cx.mustAPI(t, http.MethodPost, "/api/cli/accounts/bab/store/apply", `{"note":"census"}`, http.StatusOK)
724 },
725 do: claimFormReply("/fleet/store/bab/decline", "reason", "census decline"),
726 status: http.StatusSeeOther, loc: "/fleet/store",
727 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
728 if e := cx.entitlement(t); e == nil || e.Decision == nil || e.Decision.Approved {
729 return "the storage entitlement is not declined"
730 }
731 return ""
732 },
733 },
734
735 // ---- the platform system doors ----
736 "route POST /api/cli/system/antivirus": {
737 do: claimAPIReply(http.MethodPost, "/api/cli/system/antivirus", `{"enabled":true}`),
738 body: []string{`"enabled":true`},
739 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
740 av, _, err := release.GetAntivirus(context.Background(), cx.rel)
741 if err != nil || av == nil || !av.Enabled || av.By != claimFxOwner {
742 return "the deployment's scanning switch is not on, set by the owner"
743 }
744 return ""
745 },
746 },
747 "route POST /api/cli/system/layout": {
748 do: claimAPIReply(http.MethodPost, "/api/cli/system/layout", `{"mode":"flat"}`),
749 body: []string{`"mode":"flat"`},
750 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
751 log, found, err := release.LoadLayoutIntents(context.Background(), cx.rel)
752 if err != nil || !found || len(log.Requests) == 0 || log.Requests[len(log.Requests)-1].Mode != release.LayoutModeFlat {
753 return "no flat layout intent was recorded"
754 }
755 return ""
756 },
757 },
758 "route POST /api/cli/system/pointer-digests": {
759 do: claimAPIReply(http.MethodPost, "/api/cli/system/pointer-digests", `{"on":true,"accounts":["bab"]}`),
760 body: []string{`"account":"bab","on":true`},
761 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
762 m, err := release.ReadDigestMarker(context.Background(), cx.rel, cx.pub, "bab")
763 if err != nil || m == nil || m.By != claimFxOwner {
764 return "bab's pointer digest marker is not on, written by the owner"
765 }
766 return ""
767 },
768 },
769 "route POST /api/cli/system/seeds-floor": {
770 do: claimAPIReply(http.MethodPost, "/api/cli/system/seeds-floor", `{"min_release":"v0.27.0"}`),
771 body: []string{`"min_release":"v0.27.0"`},
772 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
773 f, err := seeds.ReadFloor(context.Background(), cx.rel)
774 if err != nil || f == nil || f.MinRelease != "v0.27.0" {
775 return "no seeds floor at v0.27.0 was published"
776 }
777 return ""
778 },
779 },
780 "route PUT /api/cli/system/upload": {
781 do: func(t *testing.T, cx *claimCase) claimReply { return cx.systemUploadReply(t, "66587fa") },
782 body: []string{`"upload"`, `"size"`},
783 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
784 handle := claimReplyField(t, r.Body, "upload")
785 cx.srv.systemMu.Lock()
786 up, ok := cx.srv.systemUploads[handle]
787 cx.srv.systemMu.Unlock()
788 if !ok || up.version != "66587fa" || up.platform != "linux-arm64" {
789 return "the console holds no parked build under the handle it answered"
790 }
791 return ""
792 },
793 },
794 "route POST /api/cli/system/update": {
795 setup: func(t *testing.T, cx *claimCase) {
796 cx.stageSystemCandidate(t, "66587fa")
797 up := cx.systemUploadReply(t, "66587fa")
798 if up.Status != http.StatusOK {
799 t.Fatalf("stage upload: %s", up)
800 }
801 cx.vars["upload"] = claimReplyField(t, up.Body, "upload")
802 },
803 do: claimAPIReply(http.MethodPost, "/api/cli/system/update", `{"channel":"canary","upload":"<upload>"}`),
804 body: []string{`"job"`},
805 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
806 job := cx.systemJob(t, claimReplyField(t, r.Body, "job"))
807 if job["error"] != "" {
808 return "the update job failed"
809 }
810 if got := cx.systemVersion("canary"); got != "66587fa" {
811 return `the canary pointer names "` + got + `", want 66587fa`
812 }
813 return ""
814 },
815 },
816 "route POST /api/cli/system/activation/cancel": {
817 setup: func(t *testing.T, cx *claimCase) {
818 ctx := context.Background()
819 cx.systemRelease(t, "canary", "66587fa")
820 current, _, err := release.GetSystemPointer(ctx, cx.rel, cx.pub, "router", "canary")
821 if err != nil {
822 t.Fatal(err)
823 }
824 prepared, err := release.PrepareSystemPromotion(ctx, cx.rel, release.RootSigner(cx.priv), "router", "canary", current.Current, release.SystemPromoteOptions{}, "claim-cancel-probe")
825 if err != nil {
826 t.Fatal(err)
827 }
828 d := cx.claimFixtureDirectory()
829 o, err := d.Read(ctx)
830 if err != nil {
831 t.Fatal(err)
832 }
833 h := hostactors.Reservation{ID: prepared.ID, Publication: &prepared, RequestSHA256: strings.Repeat("a", 64), UpdateMode: "promote", FloorSHA256: o.Roster.FloorSHA256, Actors: map[string]string{}, Releases: map[string]hostactors.Report{}}
834 for ref, box := range o.Roster.Boxes {
835 h.Actors["box:"+ref] = box.Enrollment
836 h.Releases["box:"+ref] = hostactors.Report{Enrollment: box.Enrollment}
837 break
838 }
839 next := hostactors.Clone(o.Roster)
840 next.Activations[h.ID] = h
841 raw, err := release.SignRootEnvelope(release.RootSigner(cx.priv), next)
842 if err != nil {
843 t.Fatal(err)
844 }
845 if _, err = cx.rel.Put(ctx, hostactors.Key, raw, o.ETag); err != nil {
846 t.Fatal(err)
847 }
848 raw, err = json.Marshal(prepared)
849 if err != nil {
850 t.Fatal(err)
851 }
852 cx.vars["cancel-publication"] = string(raw)
853 },
854 do: claimAPIReply(http.MethodPost, "/api/cli/system/activation/cancel", `{"channel":"canary","activation":"claim-cancel-probe"}`),
855 body: []string{`"activation":"claim-cancel-probe"`},
856 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
857 ctx := context.Background()
858 o, err := cx.claimFixtureDirectory().Read(ctx)
859 if err != nil {
860 return "cannot read roster after cancellation"
861 }
862 if _, ok := o.Roster.Activations["claim-cancel-probe"]; ok {
863 return "activation remains held"
864 }
865 var p release.SystemPublication
866 if err = json.Unmarshal([]byte(cx.vars["cancel-publication"]), &p); err != nil {
867 t.Fatal(err)
868 }
869 if _, err = release.PublishSystemPublication(ctx, cx.rel, cx.pub, p); !errors.Is(err, release.ErrCASConflict) {
870 return "cancelled publication was not fenced"
871 }
872 if cx.systemVersion("canary") != "66587fa" {
873 return "cancellation changed the running version"
874 }
875 return ""
876 },
877 },
878 "route POST /api/cli/system/rollback": {
879 setup: func(t *testing.T, cx *claimCase) {
880 cx.systemRelease(t, "canary", "66587fa")
881 cx.systemRelease(t, "canary", "77698ab")
882 if got := cx.systemVersion("canary"); got != "77698ab" {
883 t.Fatalf("stage rollback: canary names %q, want 77698ab", got)
884 }
885 },
886 do: claimAPIReply(http.MethodPost, "/api/cli/system/rollback", `{"channel":"canary"}`),
887 body: []string{`"version":"66587fa"`},
888 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
889 if got := cx.systemVersion("canary"); got != "66587fa" {
890 return `the canary pointer names "` + got + `", want the first build 66587fa`
891 }
892 return ""
893 },
894 },
895 "route PUT /api/cli/system/geoip": {
896 serial: "withFixtureProbes swaps the package variable geoipProbeAddrs",
897 setup: func(t *testing.T, cx *claimCase) { claimMustRunAlone(t, "geoip probe"); withFixtureProbes(t) },
898 do: func(t *testing.T, cx *claimCase) claimReply {
899 return cx.apiReply(t, http.MethodPut, "/api/cli/system/geoip", string(fixture(t, "GeoIP2-City-Test.mmdb")))
900 },
901 status: http.StatusAccepted, body: []string{`"job"`},
902 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
903 // The publish is a job: the 202 only says it was accepted.
904 for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
905 raw, _, err := cx.rel.Get(context.Background(), geoip.PointerKey)
906 if err != nil {
907 continue
908 }
909 if ptr, err := geoip.ParsePointer(raw); err == nil && ptr.DatabaseType == "GeoIP2-City" && cx.bucketHas(ptr.Key) {
910 return ""
911 }
912 }
913 return "the geoip pointer never named the uploaded database"
914 },
915 },
916 "route DELETE /api/cli/system/geoip": {
917 serial: "withFixtureProbes swaps the package variable geoipProbeAddrs",
918 setup: func(t *testing.T, cx *claimCase) {
919 claimMustRunAlone(t, "geoip probe")
920 withFixtureProbes(t)
921 if _, err := cx.srv.geoip.Publish(context.Background(), strings.NewReader(string(fixture(t, "GeoIP2-City-Test.mmdb"))), geoipPublishOpts{Origin: "manual"}); err != nil {
922 t.Fatalf("stage a published geoip database: %v", err)
923 }
924 if !cx.bucketHas(geoip.PointerKey) {
925 t.Fatal("stage a published geoip database: no pointer")
926 }
927 },
928 do: claimAPIReply(http.MethodDelete, "/api/cli/system/geoip", ""),
929 body: []string{`"off":true`},
930 effect: func(t *testing.T, cx *claimCase, r claimReply) string {
931 if cx.bucketHas(geoip.PointerKey) {
932 return "the geoip pointer is still published"
933 }
934 keys, err := cx.rel.List(context.Background(), geoip.Prefix)
935 if err != nil {
936 return err.Error()
937 }
938 for _, k := range keys {
939 if strings.HasSuffix(k, ".mmdb") {
940 return "the blob " + k + " survived turning geoip off"
941 }
942 }
943 return ""
944 },
945 },
946}
947