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
3import (
4 "context"
5 "net/http"
6 "net/url"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "runtime/debug"
11 "strings"
12 "sync"
13 "testing"
14 "time"
15
16 "github.com/carlosframework/platform/internal/objstore"
17 "github.com/carlosframework/platform/internal/provision"
18 "github.com/carlosframework/platform/internal/release"
19 "github.com/carlosframework/platform/internal/svcrole"
20)
21
22// controlPlaneFixture is a console holding ONE control-plane app: `console`
23// in the ops account (bab), owned by paul, with the flag set. It returns the
24// fixture and a live CLI token for paul, so a test can go straight to the
25// door it is about.
26//
27// Deliberately minimal, because later tasks build on it: nothing is shipped,
28// no channel is pointed anywhere, and the ship rate limiter is left alone —
29// a test that ships more than twice calls liftShipBudget itself (apicli_test.go).
30func controlPlaneFixture(t *testing.T, label string) (*consoleFixture, string) {
31 t.Helper()
32 fx := newConsoleFixture(t)
33 if _, err := fx.st.CreateAppIn(OpsAccountID, "console", "paul@keymail.dev", time.Now()); err != nil {
34 t.Fatal(err)
35 }
36 if err := fx.st.SetAppControlPlane(OpsAccountID, "console", true); err != nil {
37 t.Fatal(err)
38 }
39 return fx, cliToken(t, fx, "paul@keymail.dev", label)
40}
41
42// TestControlPlaneAppRefusesTrash: the console's release history is not
43// something to delete behind a confirmation dialog. Refused outright, not
44// "with fresh auth" — a gesture is not the guard here, the flag is.
45func TestControlPlaneAppRefusesTrash(t *testing.T) {
46 fx, token := controlPlaneFixture(t, "code-cp-trash")
47 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/delete", map[string]string{}, token)
48 if status != http.StatusForbidden {
49 t.Fatalf("trash a control-plane app = %d %v, want 403", status, out)
50 }
51 msg := str(t, out, "error")
52 if !strings.Contains(msg, "control plane") {
53 t.Fatalf("refusal %q does not name the flag the operator has to clear", msg)
54 }
55 // And WHERE to clear it. A CLI caller has no page in front of them, so a
56 // refusal that names the flag without naming the card strands exactly the
57 // operator it just stopped.
58 if !strings.Contains(msg, "/a/bab/apps/console/settings/advanced") {
59 t.Fatalf("refusal %q does not point at the settings card that can clear the flag", msg)
60 }
61 present, err := fx.st.HasAppIn(OpsAccountID, "console")
62 if err != nil || !present {
63 t.Fatalf("app row after a refused trash: HasAppIn = %v %v, want true (untouched)", present, err)
64 }
65}
66
67// TestControlPlaneAppAllowsAPersonToPromote is the other half, and the one
68// that matters most: gating promotion is what an earlier draft got wrong.
69// `stable`'s fresh-auth 403 has no API-side step-up and carlos deploy
70// log.Fatalfs on a failed promote AFTER the ship has landed, so a promote gate
71// would break the very command this work exists to create.
72func TestControlPlaneAppAllowsAPersonToPromote(t *testing.T) {
73 fx, token := controlPlaneFixture(t, "code-cp-promote")
74 // A real linux-arm64 binary stamped cp00001: the ship door checks both of
75 // those on a control-plane app (checkControlPlaneArtifacts), so a
76 // placeholder byte would never reach the promote this test is about.
77 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token,
78 []shipPart{metaPart(t, "cp00001", "binary"), {name: "linux-arm64", body: buildTestELF(t, "linux", "arm64", "cp00001")}})
79 if status != http.StatusCreated {
80 t.Fatalf("ship = %d %v", status, out)
81 }
82 status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/promote",
83 map[string]string{"version": "cp00001", "channel": "canary/console"}, token)
84 if status != http.StatusOK {
85 t.Fatalf("a PERSON promoting a control-plane app = %d %v, want 200 — gating this breaks carlos deploy", status, out)
86 }
87}
88
89// TestControlPlaneAppRefusesAServicePromote: a person with ops membership may
90// roll the console; a CI credential may not. This is what the flag buys on the
91// promote path instead of a fresh-auth gate.
92func TestControlPlaneAppRefusesAServicePromote(t *testing.T) {
93 fx, token := controlPlaneFixture(t, "code-cp-svc")
94 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token,
95 []shipPart{metaPart(t, "cp00002", "binary"), {name: "linux-arm64", body: buildTestELF(t, "linux", "arm64", "cp00002")}})
96 if status != http.StatusCreated {
97 t.Fatalf("ship = %d %v", status, out)
98 }
99 svc := serviceToken(t, fx.st, OpsAccountID, "ci", "console", svcrole.Publish)
100 status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/promote",
101 map[string]string{"version": "cp00002", "channel": "canary/console"}, svc)
102 if status != http.StatusForbidden {
103 t.Fatalf("a SERVICE promoting a control-plane app = %d %v, want 403", status, out)
104 }
105}
106
107// TestControlPlaneAppRefusesAServiceRollback: rollback moves the same pointer
108// promote does, so it wears the same service refusal — otherwise a CI
109// credential rolls the control plane by taking the other verb.
110func TestControlPlaneAppRefusesAServiceRollback(t *testing.T) {
111 fx, token := controlPlaneFixture(t, "code-cp-svc-rollback")
112 liftShipBudget(fx)
113 for _, v := range []string{"cp00003", "cp00004"} {
114 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token,
115 []shipPart{metaPart(t, v, "binary"), {name: "linux-arm64", body: buildTestELF(t, "linux", "arm64", v)}})
116 if status != http.StatusCreated {
117 t.Fatalf("ship %s = %d %v", v, status, out)
118 }
119 status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/promote",
120 map[string]string{"version": v, "channel": "canary/console"}, token)
121 if status != http.StatusOK {
122 t.Fatalf("promote %s = %d %v", v, status, out)
123 }
124 }
125 svc := serviceToken(t, fx.st, OpsAccountID, "ci", "console", svcrole.Publish)
126 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/rollback",
127 map[string]string{"channel": "canary/console"}, svc)
128 if status != http.StatusForbidden {
129 t.Fatalf("a SERVICE rolling back a control-plane app = %d %v, want 403", status, out)
130 }
131 // And the pointer did not move.
132 if got := fx.channelVersion(t, "console", "canary/console").Version; got != "cp00004" {
133 t.Fatalf("channel after a refused rollback = %q, want cp00004", got)
134 }
135}
136
137// TestControlPlaneAppRefusesBrowserRename: the browser doors render pages, so
138// their refusal is a flash and a redirect rather than JSON — but it is the
139// same refusal, ahead of the confirm card and ahead of any passkey.
140func TestControlPlaneAppRefusesBrowserRename(t *testing.T) {
141 fx, _ := controlPlaneFixture(t, "code-cp-rename")
142 c := signInPaul(t, fx)
143 csrf := sessionToken(t, fx, c)
144 status, loc, _ := postForm(t, c, fx.u("/apps/console/rename"),
145 url.Values{"csrf": {csrf}, "to": {"console2"}})
146 if status != http.StatusSeeOther {
147 t.Fatalf("rename a control-plane app = %d, want a redirect carrying the refusal", status)
148 }
149 if !strings.Contains(loc, "/apps/console/settings") {
150 t.Fatalf("rename refusal redirected to %q, want the settings tab", loc)
151 }
152 if renamed, err := fx.st.HasAppIn(OpsAccountID, "console"); err != nil || !renamed {
153 t.Fatalf("app row after a refused rename: HasAppIn(console) = %v %v, want true", renamed, err)
154 }
155 if moved, err := fx.st.HasAppIn(OpsAccountID, "console2"); err != nil || moved {
156 t.Fatalf("HasAppIn(console2) = %v %v, want false — the rename must not have landed", moved, err)
157 }
158}
159
160// TestControlPlaneAppRefusesPurge is the refusal this whole flag exists for:
161// "Delete now" is the console's one irreversible button, and its target is
162// apps/<sqid>/console/** — the release history the fleet is running. The
163// refusal must land BEFORE requireFreshAuth, so the answer is the trash page
164// with a flash, never a trip into Keymail for a passkey that cannot help.
165func TestControlPlaneAppRefusesPurge(t *testing.T) {
166 fx, _ := controlPlaneFixture(t, "code-cp-purge")
167 c := signInPaul(t, fx)
168 csrf := sessionToken(t, fx, c)
169 status, loc, _ := postForm(t, c, fx.u("/trash/delete"),
170 url.Values{"csrf": {csrf}, "app": {"console"}, "confirm": {"console"}})
171 if status != http.StatusSeeOther {
172 t.Fatalf("purge a control-plane app = %d, want a redirect carrying the refusal", status)
173 }
174 if loc != fx.path("/trash") {
175 t.Fatalf("purge refusal redirected to %q, want %q — a step-up redirect here means the flag was checked too late",
176 loc, fx.path("/trash"))
177 }
178 if present, err := fx.st.HasAppIn(OpsAccountID, "console"); err != nil || !present {
179 t.Fatalf("app row after a refused purge: HasAppIn = %v %v, want true (untouched)", present, err)
180 }
181}
182
183// TestOrdinaryAppUnaffected: every refusal above is scoped to the flag.
184func TestOrdinaryAppUnaffected(t *testing.T) {
185 fx := newConsoleFixture(t)
186 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
187 t.Fatal(err)
188 }
189 token := cliToken(t, fx, "paul@keymail.dev", "code-cp-ordinary")
190 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", map[string]string{}, token)
191 if status == http.StatusForbidden {
192 t.Fatalf("trash an ORDINARY app = %d %v, want it to work as before", status, out)
193 }
194}
195
196// TestControlPlaneRefusalIsNotAMembershipOracle: a non-member must meet the
197// byte-identical 404 in apiAccountApp before the flag is ever consulted, or
198// the refusal becomes a way to learn which accounts hold a control-plane app.
199func TestControlPlaneRefusalIsNotAMembershipOracle(t *testing.T) {
200 fx, _ := controlPlaneFixture(t, "code-cp-oracle")
201 stranger := cliToken(t, fx, "eve@keymail.dev", "code-cp-stranger")
202 // postRaw, not postJSON: apiAccountApp's 404 is http.NotFound's plain
203 // text, and the point of this test is that those bytes do not vary.
204 status, body := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/console/delete", map[string]string{}, stranger)
205 if status != http.StatusNotFound {
206 t.Fatalf("a non-member trashing the control plane = %d %q, want the byte-identical 404", status, body)
207 }
208 otherStatus, otherBody := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/nosuchapp/delete", map[string]string{}, stranger)
209 if otherStatus != status || otherBody != body {
210 t.Fatalf("control-plane 404 (%d %q) differs from unknown-app 404 (%d %q) — the refusal is an oracle",
211 status, body, otherStatus, otherBody)
212 }
213}
214
215// TestControlPlaneAppRefusesAccountDeletion closes the door one level up.
216// Refusing trash, purge, rename and transfer on the app while the ACCOUNT that
217// holds it can be retired would be a guard with a documented bypass — worse
218// than no guard, because it reads as protection.
219//
220// Deliberately asserted with postForm, not postGated: the refusal must arrive
221// BEFORE requireFreshAuth, so the answer is the settings tab and never a
222// redirect into Keymail for a passkey that cannot help. (The pre-existing
223// "account still holds apps" refusal sits BELOW step-up, so a passkey demand
224// here would mean the control-plane guard never fired.)
225func TestControlPlaneAppRefusesAccountDeletion(t *testing.T) {
226 fx, c, csrf, sqid := deletableFixture(t)
227 acct, err := fx.st.AccountBySqid(sqid)
228 if err != nil || acct == nil {
229 t.Fatalf("AccountBySqid(%q) = %+v %v", sqid, acct, err)
230 }
231 // TWO flagged apps, because the refusal LOOPS: a guard that stopped at the
232 // first row would name one app and leave the operator to discover the
233 // other by trying again (coordinator ruling 7 — an account may hold more
234 // than one).
235 for _, app := range []string{"console", "keymail"} {
236 if _, err := fx.st.CreateAppIn(acct.ID, app, "paul@keymail.dev", time.Now()); err != nil {
237 t.Fatal(err)
238 }
239 if err := fx.st.SetAppControlPlane(acct.ID, app, true); err != nil {
240 t.Fatal(err)
241 }
242 }
243 status, loc, body := postForm(t, c, fx.ts.URL+"/a/"+sqid+"/delete",
244 url.Values{"csrf": {csrf}, "confirm": {"Testco"}})
245 if status != http.StatusSeeOther {
246 t.Fatalf("delete an account holding the control plane = %d, want the flash redirect:\n%s", status, body)
247 }
248 if loc != "/a/"+sqid+"/settings" {
249 t.Fatalf("refusal redirected to %q, want the settings tab — a Keymail redirect means the guard ran too late", loc)
250 }
251 if a, _ := fx.st.AccountBySqid(sqid); a == nil {
252 t.Fatal("the account was deleted despite holding a control-plane app")
253 }
254 // The flash must name the app and the flag, or the operator cannot act.
255 _, _, page := get(t, c, fx.ts.URL+"/a/"+sqid+"/settings")
256 if !strings.Contains(page, "control plane") {
257 t.Fatalf("refusal flash does not name the flag:\n%s", page)
258 }
259 for _, app := range []string{"console", "keymail"} {
260 if !strings.Contains(page, app) {
261 t.Fatalf("refusal flash does not name %q — the loop stopped early:\n%s", app, page)
262 }
263 }
264}
265
266// TestAccountWithNoControlPlaneAppStillDeletes is the negative half, and it
267// is the one that keeps the guard honest: a lookup that fell back to "refuse"
268// for every account would turn one refusal into "no account may ever be
269// deleted", and nothing else in the suite would notice.
270func TestAccountWithNoControlPlaneAppStillDeletes(t *testing.T) {
271 fx, c, csrf, sqid := deletableFixture(t)
272 status, _, body := postGated(t, fx, c, fx.ts.URL+"/a/"+sqid+"/delete",
273 url.Values{"csrf": {csrf}, "confirm": {"Testco"}})
274 if status != http.StatusSeeOther {
275 t.Fatalf("delete an ordinary empty account = %d, want it to work as before:\n%s", status, body)
276 }
277 if a, _ := fx.st.AccountBySqid(sqid); a != nil {
278 t.Fatal("an account with no control-plane app survived its own deletion")
279 }
280}
281
282// TestControlPlaneRetentionRefusesAServiceButNotAPerson: retention is how the
283// console's rollback DEPTH gets shortened, so a CI credential must not reach
284// it on a control-plane app. A person still may — how much history to keep is
285// an operator's decision, and PruneEligible already refuses to touch anything
286// promoted, tagged or recent.
287func TestControlPlaneRetentionRefusesAServiceButNotAPerson(t *testing.T) {
288 fx, token := controlPlaneFixture(t, "code-cp-retention")
289 svc := serviceToken(t, fx.st, OpsAccountID, "ci", "console", svcrole.Publish)
290 body := map[string]any{"keep": release.KeepFloor}
291
292 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/retention", body, svc)
293 if status != http.StatusForbidden {
294 t.Fatalf("a SERVICE setting retention on the control plane = %d %v, want 403", status, out)
295 }
296 status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/retention", body, token)
297 if status != http.StatusOK {
298 t.Fatalf("a PERSON setting retention on the control plane = %d %v, want 200 — only the service is barred", status, out)
299 }
300}
301
302// TestServiceMayPreviewRetentionButNotApplyIt: a preview writes nothing, and
303// refusing it would leave CI no way to see what a policy WOULD do before a
304// person applies it. The refusal is scoped to the branch that mutates.
305func TestServiceMayPreviewRetentionButNotApplyIt(t *testing.T) {
306 fx, _ := controlPlaneFixture(t, "code-cp-dryrun")
307 svc := serviceToken(t, fx.st, OpsAccountID, "ci", "console", svcrole.Publish)
308
309 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/retention",
310 map[string]any{"keep": release.KeepFloor, "dry_run": true}, svc)
311 if status != http.StatusOK {
312 t.Fatalf("a SERVICE previewing retention on the control plane = %d %v, want 200", status, out)
313 }
314 if dry, _ := out["dry_run"].(bool); !dry {
315 t.Fatalf("preview answer is not a dry run: %v", out)
316 }
317 // The same credential, the same door, the branch that writes: refused.
318 status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/retention",
319 map[string]any{"keep": release.KeepFloor}, svc)
320 if status != http.StatusForbidden {
321 t.Fatalf("a SERVICE applying retention = %d %v, want 403", status, out)
322 }
323 // And `off`, which is a write of its own shape.
324 status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/retention",
325 map[string]any{"off": true}, svc)
326 if status != http.StatusForbidden {
327 t.Fatalf("a SERVICE clearing retention = %d %v, want 403", status, out)
328 }
329}
330
331// TestOwnerClearsTheControlPlaneFlag is the door the refusals point at. Every
332// refusal in this file says "clear the control-plane flag first"; without a
333// way to do that, the sentence instructs an operator to do something
334// impossible on the exact path where they are already blocked.
335//
336// Browser-only and passkey-gated by design (spec §6): setting the flag is
337// easy (the CLI, at claim time), clearing it is hard. There is deliberately
338// no API clear door — the console's fresh-auth 403 is a dead end for an API
339// caller, so a CLI clear would be either ungated or unusable.
340func TestOwnerClearsTheControlPlaneFlag(t *testing.T) {
341 fx, token := controlPlaneFixture(t, "code-cp-clear")
342 c := signInPaul(t, fx)
343 csrf := sessionToken(t, fx, c)
344
345 // The page has to OFFER it, or the door might as well not exist.
346 if _, _, page := get(t, c, fx.u("/apps/console/settings/advanced")); !strings.Contains(page, "/apps/console/control-plane/clear") {
347 t.Fatalf("the settings page offers no way to clear the flag:\n%s", page)
348 }
349 status, loc, body := postGated(t, fx, c, fx.u("/apps/console/control-plane/clear"),
350 url.Values{"csrf": {csrf}})
351 if status != http.StatusSeeOther {
352 t.Fatalf("clear the flag = %d -> %q:\n%s", status, loc, body)
353 }
354 cp, _, err := fx.st.AppFlags(OpsAccountID, "console")
355 if err != nil || cp {
356 t.Fatalf("AppFlags after a confirmed clear = %v %v, want false", cp, err)
357 }
358 // The card is state-driven: with the flag gone, so is the button.
359 if _, _, page := get(t, c, fx.u("/apps/console/settings/advanced")); strings.Contains(page, "/apps/console/control-plane/clear") {
360 t.Fatalf("the clear form is still offered on an app that is no longer flagged:\n%s", page)
361 }
362 // And the verb that was refused now works — the whole point of the door.
363 status2, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/delete", map[string]string{}, token)
364 if status2 != http.StatusOK {
365 t.Fatalf("trash after clearing the flag = %d %v, want 200", status2, out)
366 }
367}
368
369// TestClearingTheControlPlaneFlagDemandsAPasskey: without the step-up the
370// clear does not happen. postForm, not postGated, so the passkey round trip
371// is visible rather than walked.
372func TestClearingTheControlPlaneFlagDemandsAPasskey(t *testing.T) {
373 fx, _ := controlPlaneFixture(t, "code-cp-clear-nopasskey")
374 c := signInPaul(t, fx)
375 csrf := sessionToken(t, fx, c)
376
377 status, loc, body := postForm(t, c, fx.u("/apps/console/control-plane/clear"),
378 url.Values{"csrf": {csrf}})
379 if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) {
380 t.Fatalf("clear without a passkey = %d -> %q, want the step-up redirect:\n%s", status, loc, body)
381 }
382 if cp, _, err := fx.st.AppFlags(OpsAccountID, "console"); err != nil || !cp {
383 t.Fatalf("AppFlags after an unconfirmed clear = %v %v, want true (still flagged)", cp, err)
384 }
385}
386
387// TestMemberCannotClearTheControlPlaneFlag proves the gate ORDER as much as
388// the permission: a member meets the byte-identical 404, and never a redirect
389// into Keymail — requireFreshAuth is LAST, so no step-up is ever offered to
390// someone who should have got a 404.
391func TestMemberCannotClearTheControlPlaneFlag(t *testing.T) {
392 fx, _ := controlPlaneFixture(t, "code-cp-clear-member")
393 if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil {
394 t.Fatal(err)
395 }
396 fx.setUser("kim@keymail.dev")
397 c := newBrowser(t)
398 signUp(t, fx, c, "code-cp-clear-kim")
399 csrf := sessionToken(t, fx, c)
400
401 status, loc, _ := postForm(t, c, fx.u("/apps/console/control-plane/clear"),
402 url.Values{"csrf": {csrf}})
403 if status != http.StatusNotFound {
404 t.Fatalf("a member clearing the flag = %d -> %q, want the byte-identical 404", status, loc)
405 }
406 if loc != "" {
407 t.Fatalf("a member was redirected to %q — step-up must never be offered ahead of the 404", loc)
408 }
409 if cp, _, err := fx.st.AppFlags(OpsAccountID, "console"); err != nil || !cp {
410 t.Fatalf("AppFlags after a member's attempt = %v %v, want true (still flagged)", cp, err)
411 }
412 // The 404 above has to be the OWNER GATE's, not a missing route: an
413 // unregistered path answers a member exactly the same way, so without this
414 // the test would still pass with the door deleted.
415 fx.setUser("paul@keymail.dev")
416 owner := newBrowser(t)
417 signUp(t, fx, owner, "code-cp-clear-owner-probe")
418 status, loc, _ = postForm(t, owner, fx.u("/apps/console/control-plane/clear"),
419 url.Values{"csrf": {sessionToken(t, fx, owner)}})
420 if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) {
421 t.Fatalf("the OWNER on the same URL = %d -> %q, want the handler's step-up — the member's 404 may just be a missing route", status, loc)
422 }
423}
424
425// TestOwnerSetsTheControlPlaneFlagWithoutAPasskey is the other half of the
426// card, and it is what stops the flag being a one-way trap: an owner who
427// clears it on a live console must be able to put it back without a database
428// write on the box.
429//
430// postForm, not postGated: setting the flag is the SAFE direction (spec §6),
431// so it deliberately demands no step-up — a redirect into Keymail here would
432// be the asymmetry inverted.
433func TestOwnerSetsTheControlPlaneFlagWithoutAPasskey(t *testing.T) {
434 fx := newConsoleFixture(t)
435 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
436 t.Fatal(err)
437 }
438 token := cliToken(t, fx, "paul@keymail.dev", "code-cp-set")
439 c := signInPaul(t, fx)
440 csrf := sessionToken(t, fx, c)
441
442 if _, _, page := get(t, c, fx.u("/apps/titogo/settings/advanced")); !strings.Contains(page, "/apps/titogo/control-plane/set") {
443 t.Fatalf("the settings page offers no way to set the flag:\n%s", page)
444 }
445 status, loc, body := postForm(t, c, fx.u("/apps/titogo/control-plane/set"),
446 url.Values{"csrf": {csrf}})
447 if status != http.StatusSeeOther {
448 t.Fatalf("set the flag = %d -> %q:\n%s", status, loc, body)
449 }
450 if loc != fx.path("/apps/titogo/settings/advanced") {
451 t.Fatalf("set redirected to %q, want the settings tab — a Keymail redirect means setting grew a passkey it must not have", loc)
452 }
453 if cp, _, err := fx.st.AppFlags(OpsAccountID, "titogo"); err != nil || !cp {
454 t.Fatalf("AppFlags after a set = %v %v, want true", cp, err)
455 }
456 // The refusals came back with it — the set is not decoration.
457 status2, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", map[string]string{}, token)
458 if status2 != http.StatusForbidden {
459 t.Fatalf("trash after setting the flag = %d %v, want 403", status2, out)
460 }
461}
462
463// TestMemberCannotSetTheControlPlaneFlag: declaring an app to be the platform
464// is an owner's act, and a member meets the same byte-identical 404 the clear
465// door gives them — with no step-up ever offered ahead of it.
466func TestMemberCannotSetTheControlPlaneFlag(t *testing.T) {
467 fx := newConsoleFixture(t)
468 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
469 t.Fatal(err)
470 }
471 if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil {
472 t.Fatal(err)
473 }
474 fx.setUser("kim@keymail.dev")
475 c := newBrowser(t)
476 signUp(t, fx, c, "code-cp-set-kim")
477 csrf := sessionToken(t, fx, c)
478
479 status, loc, _ := postForm(t, c, fx.u("/apps/titogo/control-plane/set"),
480 url.Values{"csrf": {csrf}})
481 if status != http.StatusNotFound || loc != "" {
482 t.Fatalf("a member setting the flag = %d -> %q, want the byte-identical 404", status, loc)
483 }
484 if cp, _, err := fx.st.AppFlags(OpsAccountID, "titogo"); err != nil || cp {
485 t.Fatalf("AppFlags after a member's attempt = %v %v, want false", cp, err)
486 }
487 // Same proof as the clear door's: an owner must REACH the handler on this
488 // URL, or the member's 404 is indistinguishable from a route that is not
489 // registered at all.
490 fx.setUser("paul@keymail.dev")
491 owner := newBrowser(t)
492 signUp(t, fx, owner, "code-cp-set-owner-probe")
493 status, loc, _ = postForm(t, owner, fx.u("/apps/titogo/control-plane/set"),
494 url.Values{"csrf": {sessionToken(t, fx, owner)}})
495 if status != http.StatusSeeOther || loc != fx.path("/apps/titogo/settings/advanced") {
496 t.Fatalf("the OWNER on the same URL = %d -> %q, want the handler's redirect — the member's 404 may just be a missing route", status, loc)
497 }
498 if cp, _, err := fx.st.AppFlags(OpsAccountID, "titogo"); err != nil || !cp {
499 t.Fatalf("AppFlags after the owner's probe = %v %v, want true", cp, err)
500 }
501}
502
503// TestControlPlaneAppRefusesStoreDestroy: the sixth destructive door. Its
504// ceremony — owner, CSRF, the typed BUCKET name, a passkey — is exactly the
505// ceremony spec §6 rules insufficient for the control plane, and what it
506// destroys (bucket, objects, IAM principal, credentials, record) has, by its
507// own doc, no trash and no undo. Refused before the typed word and before the
508// passkey, so the answer is the settings tab rather than a Keymail round trip.
509func TestControlPlaneAppRefusesStoreDestroy(t *testing.T) {
510 fx, _ := controlPlaneFixture(t, "code-cp-store")
511 putRecord(t, fx, "console", &objstore.Record{
512 Declared: &objstore.Declared{EnvPrefix: objstore.DefaultEnvPrefix, At: time.Now(), By: "paul@keymail.dev"},
513 })
514 c := signInPaul(t, fx)
515 csrf := sessionToken(t, fx, c)
516
517 status, loc, body := postForm(t, c, fx.u("/apps/console/store/destroy"),
518 url.Values{"csrf": {csrf}, "confirm": {"console"}})
519 if status != http.StatusSeeOther {
520 t.Fatalf("destroy a control-plane app's store = %d -> %q:\n%s", status, loc, body)
521 }
522 if loc != fx.path("/apps/console/settings/storage") {
523 t.Fatalf("refusal redirected to %q, want the settings tab — a Keymail redirect means the guard ran too late", loc)
524 }
525 if rec := storedRecord(t, fx, "console"); rec == nil {
526 t.Fatal("the store record was destroyed despite the control-plane flag")
527 }
528 // Cleared, the same door works — the refusal is the flag's, not the store's.
529 if err := fx.st.SetAppControlPlane(OpsAccountID, "console", false); err != nil {
530 t.Fatal(err)
531 }
532 status, loc, body = postGated(t, fx, c, fx.u("/apps/console/store/destroy"),
533 url.Values{"csrf": {csrf}, "confirm": {"console"}})
534 if status != http.StatusSeeOther || loc != fx.path("/apps/console/settings/storage") {
535 t.Fatalf("destroy after clearing the flag = %d -> %q:\n%s", status, loc, body)
536 }
537 if rec := storedRecord(t, fx, "console"); rec != nil {
538 t.Fatalf("the declaration survived a destroy that should have worked: %+v", rec)
539 }
540}
541
542// TestControlPlaneLookupFailureRefuses pins the named project constraint: "a
543// lookup failure must REFUSE, not fall through". Treating an unreadable row as
544// "not the control plane" would make a database blip the way past every guard
545// in this file — and `cp, _, _ := s.store.AppFlags(...)` reads like harmless
546// tidying, so this is the branch a later simplification inverts.
547//
548// The column is dropped rather than the database closed, deliberately: closing
549// it would break the token lookup and the app 404 first, and the request would
550// never reach the flag at all. Dropping ONE column leaves every other reader
551// working and fails exactly AppFlags.
552func TestControlPlaneLookupFailureRefuses(t *testing.T) {
553 fx, token := controlPlaneFixture(t, "code-cp-blip")
554 c := signInPaul(t, fx)
555 csrf := sessionToken(t, fx, c)
556 if _, err := fx.st.db.Exec(`ALTER TABLE apps DROP COLUMN control_plane`); err != nil {
557 t.Fatalf("drop the flag column: %v", err)
558 }
559
560 // The JSON door: 503, not the 200 a fall-through would give.
561 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/delete", map[string]string{}, token)
562 if status != http.StatusServiceUnavailable {
563 t.Fatalf("trash with an unreadable flag = %d %v, want 503", status, out)
564 }
565 if msg := str(t, out, "error"); !strings.Contains(msg, "Could not check") {
566 t.Fatalf("refusal %q does not say the check itself failed", msg)
567 }
568 if present, err := fx.st.HasAppIn(OpsAccountID, "console"); err != nil || !present {
569 t.Fatalf("app row after a refused trash: HasAppIn = %v %v, want true (untouched)", present, err)
570 }
571
572 // The browser door: the flash, the trash page, and no passkey demanded —
573 // postForm, so a step-up redirect would show rather than be walked.
574 status, loc, body := postForm(t, c, fx.u("/trash/delete"),
575 url.Values{"csrf": {csrf}, "app": {"console"}, "confirm": {"console"}})
576 if status != http.StatusSeeOther || loc != fx.path("/trash") {
577 t.Fatalf("purge with an unreadable flag = %d -> %q, want the trash page's flash:\n%s", status, loc, body)
578 }
579 if _, _, page := get(t, c, fx.ts.URL+fx.path("/trash")); !strings.Contains(page, "Could not check") {
580 t.Fatalf("purge refusal flash does not say the check failed:\n%s", page)
581 }
582}
583
584// --- Ship-time refusals -----------------------------------------------------
585
586// testELFCache holds one compiled binary per (goos, goarch, build): several
587// tests here ship the same artifact, and a link is cheap but not free.
588var testELFCache sync.Map
589
590// testELFSource is the smallest program that is still a real Go binary — it
591// has to be one, because what is under test is that debug/elf and
592// debug/buildinfo read back what a real build writes.
593const testELFSource = `package main
594
595var Build = "dev"
596
597func main() { println(Build) }
598`
599
600// buildTestELF compiles a real binary for goos/goarch, stamped with the
601// release recipe's own ldflags, and returns its bytes.
602//
603// A REAL build rather than a handcrafted ELF header, deliberately: the claim
604// these checks rest on is that `-s -w` strips neither the machine field nor
605// the recorded -ldflags, and a fixture assembled by hand would only prove
606// that the checks can read the fixture. This is measured against the
607// toolchain that actually links the console.
608//
609// The -X names the PLATFORM's symbol path even though this throwaway module
610// has no such symbol. The linker ignores an unknown -X target silently and
611// records the flag either way, and the flag record is exactly what
612// checkControlPlaneArtifacts parses — spelling it any other way would test a
613// spelling no console build uses.
614func buildTestELF(t *testing.T, goos, goarch, build string) []byte {
615 t.Helper()
616 key := goos + "/" + goarch + "/" + build
617 if b, ok := testELFCache.Load(key); ok {
618 return b.([]byte)
619 }
620 if _, err := exec.LookPath("go"); err != nil {
621 t.Skipf("no go toolchain available to build a %s test binary: %v", key, err)
622 }
623 dir := t.TempDir()
624 for name, body := range map[string]string{
625 "go.mod": "module carlostestelf\n\ngo 1.24\n",
626 "main.go": testELFSource,
627 } {
628 if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
629 t.Fatal(err)
630 }
631 }
632 out := filepath.Join(dir, "artifact")
633 // An EMPTY build is the forgotten-ldflag case, so the flag is left off
634 // altogether rather than passed with an empty value: `-X path=` would
635 // still record a -X, and the binary under test has to be the one an
636 // operator actually produces by forgetting.
637 ldflags := "-s -w"
638 if build != "" {
639 ldflags += " -X github.com/carlosframework/platform/internal/version.Build=" + build
640 }
641 cmd := exec.Command("go", "build", "-ldflags", ldflags, "-o", out, ".")
642 cmd.Dir = dir
643 // GOWORK=off and CGO_ENABLED=0 so the build does not depend on this
644 // checkout's module graph or on a C toolchain for the host arch.
645 cmd.Env = append(os.Environ(), "GOOS="+goos, "GOARCH="+goarch, "CGO_ENABLED=0", "GOWORK=off")
646 if msg, err := cmd.CombinedOutput(); err != nil {
647 t.Fatalf("build a %s test binary: %v\n%s", key, err, msg)
648 }
649 b, err := os.ReadFile(out)
650 if err != nil {
651 t.Fatal(err)
652 }
653 testELFCache.Store(key, b)
654 return b
655}
656
657// TestControlPlaneShipRefusesTheWrongArchitecture: failure mode #1, an amd64
658// binary shipped to Graviton boxes. -target linux-arm64 is carlos deploy's
659// default, which protects the careful caller; this closes the path a hand-run
660// ship, a CI job or a future script takes into the same channel.
661func TestControlPlaneShipRefusesTheWrongArchitecture(t *testing.T) {
662 fx, token := controlPlaneFixture(t, "code-cp-arch")
663 liftShipBudget(fx)
664 amd64ELF := buildTestELF(t, "linux", "amd64", "cp00003")
665 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token,
666 []shipPart{metaPart(t, "cp00003", "binary"), {name: "linux-arm64", body: amd64ELF}})
667 if status != http.StatusBadRequest {
668 t.Fatalf("amd64 ELF as linux-arm64 = %d %v, want 400", status, out)
669 }
670 msg := str(t, out, "error")
671 if !strings.Contains(msg, "linux-arm64") || !strings.Contains(msg, "amd64") {
672 t.Fatalf("refusal %q must name both the declared target and what was found", msg)
673 }
674 // Refused BEFORE anything was written: a refusal that leaves the release
675 // stored is not a refusal.
676 if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "console", "cp00003"); err == nil {
677 t.Fatal("the release landed despite the refusal")
678 }
679}
680
681// TestControlPlaneShipRefusesAMismatchedEmbeddedBuild: closes the ldflags trap
682// at the door. Build without -X …version.Build and the roll succeeds while the
683// probe reports "dev" forever — deploy reports failure on a healthy deploy,
684// the exact inverse of the lie the probe exists to kill.
685func TestControlPlaneShipRefusesAMismatchedEmbeddedBuild(t *testing.T) {
686 fx, token := controlPlaneFixture(t, "code-cp-embed")
687 liftShipBudget(fx)
688 wrong := buildTestELF(t, "linux", "arm64", "notthesha")
689 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token,
690 []shipPart{metaPart(t, "cp00004", "binary"), {name: "linux-arm64", body: wrong}})
691 if status != http.StatusBadRequest {
692 t.Fatalf("embedded build != -version = %d %v, want 400", status, out)
693 }
694 if msg := str(t, out, "error"); !strings.Contains(msg, "notthesha") || !strings.Contains(msg, "cp00004") {
695 t.Fatalf("refusal %q must name both builds", msg)
696 }
697 // "Refused before any byte is written" is the load-bearing claim of where
698 // this check sits, so every refusal pins it, not just the first.
699 if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "console", "cp00004"); err == nil {
700 t.Fatal("the release landed despite the refusal")
701 }
702}
703
704// TestControlPlaneShipRefusesAMissingBuildStamp is the COMMON half of the
705// same trap — the ldflag simply forgotten — and it earns its own sentence:
706// "built with version.Build=\"\"" is true and tells the operator nothing,
707// where naming the flag is the whole fix.
708func TestControlPlaneShipRefusesAMissingBuildStamp(t *testing.T) {
709 fx, token := controlPlaneFixture(t, "code-cp-nostamp")
710 liftShipBudget(fx)
711 unstamped := buildTestELF(t, "linux", "arm64", "")
712 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token,
713 []shipPart{metaPart(t, "cp00006", "binary"), {name: "linux-arm64", body: unstamped}})
714 if status != http.StatusBadRequest {
715 t.Fatalf("unstamped binary = %d %v, want 400", status, out)
716 }
717 msg := str(t, out, "error")
718 if !strings.Contains(msg, "internal/version.Build=") {
719 t.Fatalf("refusal %q does not name the link flag that is missing", msg)
720 }
721 if !strings.Contains(msg, "cp00006") {
722 t.Fatalf("refusal %q does not say what to stamp the build as", msg)
723 }
724 if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "console", "cp00006"); err == nil {
725 t.Fatal("the release landed despite the refusal")
726 }
727}
728
729// TestControlPlaneShipAcceptsAMatchingArtifact.
730func TestControlPlaneShipAcceptsAMatchingArtifact(t *testing.T) {
731 fx, token := controlPlaneFixture(t, "code-cp-ok")
732 liftShipBudget(fx)
733 good := buildTestELF(t, "linux", "arm64", "cp00005")
734 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token,
735 []shipPart{metaPart(t, "cp00005", "binary"), {name: "linux-arm64", body: good}})
736 if status != http.StatusCreated {
737 t.Fatalf("matching artifact = %d %v, want 201", status, out)
738 }
739}
740
741// TestControlPlaneStaticShipIsNotELFChecked: a static release ships a
742// tarball, so both checks are exempt by kind — reading it as an ELF would
743// refuse every static ship the control plane could ever make.
744func TestControlPlaneStaticShipIsNotELFChecked(t *testing.T) {
745 fx, token := controlPlaneFixture(t, "code-cp-static")
746 liftShipBudget(fx)
747 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token,
748 []shipPart{metaPart(t, "cp00007", "static"), {name: "site.tar.gz", body: []byte("not an ELF, and never was")}})
749 if status != http.StatusCreated {
750 t.Fatalf("static ship of a control-plane app = %d %v, want 201", status, out)
751 }
752}
753
754// TestControlPlaneShipIgnoresAnUnknownTarget: the guard catches a wrong build
755// of a target we KNOW, and does not bound the target vocabulary — targetRe
756// already does that. A part named for a machine this map has never heard of
757// passes, rather than the platform refusing a target it has not been taught.
758func TestControlPlaneShipIgnoresAnUnknownTarget(t *testing.T) {
759 fx, token := controlPlaneFixture(t, "code-cp-unknown-target")
760 liftShipBudget(fx)
761 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token,
762 []shipPart{metaPart(t, "cp00008", "binary"), {name: "plan9-riscv64", body: []byte("whatever that is")}})
763 if status != http.StatusCreated {
764 t.Fatalf("unknown target = %d %v, want 201", status, out)
765 }
766}
767
768// TestOrdinaryAppShipIsNotArchChecked: a tenant may legitimately ship an
769// artifact we would guess wrong about; refusing on our inference is not our
770// call. Only the app that IS the control plane, on boxes we own and know are
771// arm64, gets the check.
772func TestOrdinaryAppShipIsNotArchChecked(t *testing.T) {
773 fx := newConsoleFixture(t)
774 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
775 t.Fatal(err)
776 }
777 token := cliToken(t, fx, "paul@keymail.dev", "code-cp-tenant")
778 liftShipBudget(fx)
779 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
780 []shipPart{metaPart(t, "t0000001", "binary"), {name: "linux-arm64", body: []byte("not an ELF at all")}})
781 if status != http.StatusCreated {
782 t.Fatalf("ordinary app shipping a non-ELF = %d %v, want 201 (unaffected)", status, out)
783 }
784}
785
786// TestEmbeddedBuildReadsOnlyTheBuildSymbol is the parser's own table. It
787// exists because the field it reads is a STRING the linker recorded, not a
788// structured value: everything about "which build is this" rests on cutting
789// that string in exactly the right place.
790//
791// The decoy row is the regression guard. An earlier version searched each
792// raw field for "internal/version.Build=" and returned whatever followed, so
793// a binary with NO build stamp could report another flag's value as its
794// build — a false MATCH, which is the one failure direction these checks
795// must never have.
796func TestEmbeddedBuildReadsOnlyTheBuildSymbol(t *testing.T) {
797 const sym = "github.com/carlosframework/platform/internal/version.Build"
798 cases := []struct {
799 name string
800 ldflags string
801 want string
802 }{
803 {"the release recipe", "-s -w -X " + sym + "=abc1234", "abc1234"},
804 {"one-argument spelling", "-X=" + sym + "=abc1234", "abc1234"},
805 {"no -X at all (the forgotten ldflag)", "-s -w", ""},
806 {"a vendored or renamed prefix still counts", "-X example.com/fork/internal/version.Build=abc1234", "abc1234"},
807 {"a decoy inside another flag's value", "-s -w -X main.Notes=internal/version.Build=decoy", ""},
808 {"a neighbouring symbol", "-X " + sym + "Date=2026-08-25", ""},
809 {"a foreign symbol that merely ends in Build", "-X example.com/app/internal/versionx.Build=nope", ""},
810 {"stamp among other stamps", "-X main.who=paul -X " + sym + "=abc1234 -X main.when=now", "abc1234"},
811 }
812 for _, c := range cases {
813 t.Run(c.name, func(t *testing.T) {
814 bi := &debug.BuildInfo{Settings: []debug.BuildSetting{
815 {Key: "-compiler", Value: "gc"},
816 {Key: "-ldflags", Value: c.ldflags},
817 }}
818 if got := embeddedBuild(bi); got != c.want {
819 t.Fatalf("embeddedBuild(%q) = %q, want %q", c.ldflags, got, c.want)
820 }
821 })
822 }
823 // No -ldflags setting at all — a `go build` with no flags, which is the
824 // shape of every unstamped local build.
825 if got := embeddedBuild(&debug.BuildInfo{Settings: []debug.BuildSetting{{Key: "-compiler", Value: "gc"}}}); got != "" {
826 t.Fatalf("embeddedBuild with no -ldflags setting = %q, want \"\"", got)
827 }
828}
829
830// TestControlPlaneShipRefusalNamesTheMintTrap: a version MINTED at the door
831// cannot be in a binary the operator linked before the request, so every
832// minted ship of a flagged app is refused — and the refusal has to say why,
833// or it sends them in a circle: rebuild with the stamp it names, and the next
834// mint moves the target again.
835func TestControlPlaneShipRefusalNamesTheMintTrap(t *testing.T) {
836 fx, token := controlPlaneFixture(t, "code-cp-mint")
837 liftShipBudget(fx)
838 ctx := context.Background()
839 if err := release.SetTarget(ctx, fx.rel, release.OpsAccount, "console", "0.5.0", "t", time.Now().UTC()); err != nil {
840 t.Fatalf("set target: %v", err)
841 }
842 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token,
843 []shipPart{metaPart(t, "", "binary"), {name: "linux-arm64", body: buildTestELF(t, "linux", "arm64", "cp00009")}})
844 if status != http.StatusBadRequest {
845 t.Fatalf("minted ship of a control-plane app = %d %v, want 400", status, out)
846 }
847 msg := str(t, out, "error")
848 if !strings.Contains(msg, "--version") || !strings.Contains(msg, "minted") {
849 t.Fatalf("refusal %q must say the version was minted here and that --version is the way out", msg)
850 }
851 if _, err := release.GetManifest(ctx, fx.rel, release.OpsAccount, "console", "0.5.0-1"); err == nil {
852 t.Fatal("the minted release landed despite the refusal")
853 }
854 // An ORDINARY app minting is untouched — the trap is the flag's, not the
855 // mint's, and nothing about minting became worse for a tenant.
856 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
857 t.Fatal(err)
858 }
859 if err := release.SetTarget(ctx, fx.rel, release.OpsAccount, "titogo", "0.5.0", "t", time.Now().UTC()); err != nil {
860 t.Fatalf("set target: %v", err)
861 }
862 status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
863 []shipPart{metaPart(t, "", "binary"), {name: "linux-arm64", body: []byte("not an ELF at all")}})
864 if status != http.StatusCreated {
865 t.Fatalf("minted ship of an ordinary app = %d %v, want 201", status, out)
866 }
867}
868
869// --- The instance doors ------------------------------------------------------
870//
871// The eighth door, found mid-rollout. `carlos deploy` resolves hosts through
872// instance records, so the console needs one — but a record is an AUTHORITY
873// TRANSFER (AGENTS.md): once it exists and the route is Provisioned, the
874// provisioning reconciler owns the row, and its removal phase retires the
875// route the moment the record disappears. So a single DELETE on this family
876// takes the control plane off the air, and PATCH repoints it.
877//
878// The split is the one the flag already draws everywhere else: destruction is
879// refused outright; the WRITES a person legitimately performs stay open to a
880// person and closed to a CI credential.
881
882// cpInstanceFixture is controlPlaneFixture with the instance feature enabled
883// on the flagged `console` app and on an ordinary `widget` beside it, so
884// every test below can assert the refusal AND that a tenant's identical call
885// is untouched by it.
886//
887// Custom domains deliberately, not the platform apex: the sibling-apex rail
888// in handleAPIInstanceCreate only fires inside <sqid>.<AppsDomain>, and these
889// tests are about the flag, not about that rail.
890func cpInstanceFixture(t *testing.T, label string) (*consoleFixture, string) {
891 t.Helper()
892 fx, token := controlPlaneFixture(t, label)
893 enableInstances(t, fx, "console", provision.Manifest{Schema: 1, App: "console",
894 Domain: "cp.tito.io", Pool: "tito", Channel: "stable", Regions: []string{"ie"}})
895 if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil {
896 t.Fatal(err)
897 }
898 enableInstances(t, fx, "widget", provision.Manifest{Schema: 1, App: "widget",
899 Domain: "widget.tito.io", Pool: "tito", Channel: "stable", Regions: []string{"ie"}})
900 return fx, token
901}
902
903// TestControlPlaneAppRefusesInstanceDelete is the whole reason this slice
904// exists. Deleting the console's instance record is not an edit to a bucket
905// object — it is the instruction the reconciler's removal phase acts on, and
906// the act it performs is retiring the control plane's route. Refused
907// OUTRIGHT, for a person as much as for a service: the same class as trash,
908// purge and store-destroy, and the same way out (clear the flag first).
909func TestControlPlaneAppRefusesInstanceDelete(t *testing.T) {
910 fx, token := cpInstanceFixture(t, "code-cp-inst-del")
911 u := instancesURL(fx, "bab", "console")
912
913 // The record exists first — created by the person the next test proves
914 // may create it — so the refusal below is refusing a delete that would
915 // otherwise have succeeded.
916 if status, _, out := rawReq(t, http.MethodPost, u, token,
917 `{"host":"box1.cp.tito.io","region":"ie"}`); status != http.StatusCreated {
918 t.Fatalf("create = %d %s", status, out)
919 }
920
921 status, _, out := rawReq(t, http.MethodDelete, u+"/box1.cp.tito.io", token, "")
922 if status != http.StatusForbidden {
923 t.Fatalf("a PERSON deleting the control plane's instance record = %d %s, want 403", status, out)
924 }
925 if !strings.Contains(out, "control plane") {
926 t.Fatalf("refusal %q does not name the flag the operator has to clear", out)
927 }
928 if !strings.Contains(out, "/a/bab/apps/console/settings/advanced") {
929 t.Fatalf("refusal %q does not point at the settings card that can clear the flag", out)
930 }
931 // The record survived. This is the assertion the door exists for: the
932 // reconciler never sees the record go, so the route is never retired.
933 if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "console", "box1.cp.tito.io"); err != nil {
934 t.Fatalf("the control plane's instance record did not survive a refused delete: %v", err)
935 }
936
937 // A service credential holding the role this door grants meets the same
938 // wall — it is refused for what the app IS, not for what the caller is.
939 svc := serviceToken(t, fx.st, OpsAccountID, "ci", "console", svcrole.Instance)
940 if status, _, out := rawReq(t, http.MethodDelete, u+"/box1.cp.tito.io", svc, ""); status != http.StatusForbidden {
941 t.Fatalf("a SERVICE deleting the control plane's instance record = %d %s, want 403", status, out)
942 }
943
944 // A traversal host on the flagged app is refused by the flag, not by the
945 // shape check: nothing below the guard runs for a control-plane app.
946 if status, _, out := rawReq(t, http.MethodDelete, u+"/..%2f..%2finstances.json", token, ""); status != http.StatusForbidden {
947 t.Fatalf("traversal delete on a flagged app = %d %s, want the flag's 403", status, out)
948 }
949
950 // And an ORDINARY app deletes exactly as it always did.
951 wu := instancesURL(fx, "bab", "widget")
952 if status, _, out := rawReq(t, http.MethodPost, wu, token,
953 `{"host":"box1.widget.tito.io","region":"ie"}`); status != http.StatusCreated {
954 t.Fatalf("ordinary create = %d %s", status, out)
955 }
956 if status, _, out := rawReq(t, http.MethodDelete, wu+"/box1.widget.tito.io", token, ""); status != http.StatusOK {
957 t.Fatalf("ordinary delete = %d %s, want 200 — the flag is not a fleet-wide freeze", status, out)
958 }
959}
960
961// TestControlPlaneAppAllowsAPersonToCreateAndPatchItsInstance is the half
962// that must keep working, and the runbook step this whole slice unblocks:
963// the console has no instance record because it predates provisioning, and
964// `carlos deploy -app console` cannot resolve a host until a PERSON writes
965// one. A create gate here would close the door the flag exists to keep open.
966func TestControlPlaneAppAllowsAPersonToCreateAndPatchItsInstance(t *testing.T) {
967 fx, token := cpInstanceFixture(t, "code-cp-inst-person")
968 letStateUpstreams(t, fx, "paul@keymail.dev")
969 u := instancesURL(fx, "bab", "console")
970 if status, _, out := rawReq(t, http.MethodPost, u, token,
971 `{"host":"box1.cp.tito.io","region":"ie","kind":"service","addrs":["127.0.0.1:9001"]}`); status != http.StatusCreated {
972 t.Fatalf("a PERSON creating the control plane's instance record = %d %s, want 201 — this is the runbook step", status, out)
973 }
974 if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "console", "box1.cp.tito.io"); err != nil {
975 t.Fatalf("the record a person created is not there: %v", err)
976 }
977 if status, _, out := rawReq(t, http.MethodPatch, updateURL(fx, "bab", "console", "box1.cp.tito.io"),
978 token, `{"addrs":["127.0.0.1:9002"]}`); status != http.StatusOK {
979 t.Fatalf("a PERSON patching the control plane's instance record = %d %s, want 200", status, out)
980 }
981 rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "console", "box1.cp.tito.io")
982 if err != nil {
983 t.Fatal(err)
984 }
985 if len(rec.Addrs) != 1 || rec.Addrs[0] != "127.0.0.1:9002" {
986 t.Fatalf("addrs after a person's patch = %v, want the requested one", rec.Addrs)
987 }
988}
989
990// TestControlPlaneAppRefusesAServiceInstanceCreate: the record a create
991// writes is the thing the reconciler then owns, so minting one for the
992// control plane is acquiring authority over the console's route. A person
993// may; a CI credential may not.
994func TestControlPlaneAppRefusesAServiceInstanceCreate(t *testing.T) {
995 fx, _ := cpInstanceFixture(t, "code-cp-inst-svc-create")
996 svc := serviceToken(t, fx.st, OpsAccountID, "ci", "console", svcrole.Instance)
997 status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "console"), svc,
998 `{"host":"box2.cp.tito.io","region":"ie"}`)
999 if status != http.StatusForbidden {
1000 t.Fatalf("a SERVICE creating the control plane's instance record = %d %s, want 403", status, out)
1001 }
1002 if !strings.Contains(out, "a person must") {
1003 t.Fatalf("refusal %q does not say a person is the principal this needs", out)
1004 }
1005 if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "console", "box2.cp.tito.io"); err == nil {
1006 t.Fatal("a record exists after only a refused service create")
1007 }
1008
1009 // The same credential shape, bound to an ordinary app, creates freely:
1010 // nothing about service provisioning became worse for a tenant.
1011 ws := serviceToken(t, fx.st, OpsAccountID, "ci-widget", "widget", svcrole.Instance)
1012 if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "widget"), ws,
1013 `{"host":"box2.widget.tito.io","region":"ie"}`); status != http.StatusCreated {
1014 t.Fatalf("a SERVICE creating an ORDINARY app's instance = %d %s, want 201", status, out)
1015 }
1016}
1017
1018// TestControlPlaneAppRefusesAServiceInstancePatch: the provisioning
1019// reconciler converges a route's Channel and Environment FROM this record on
1020// every pass, so a PATCH repoints the control plane. Same split as create.
1021func TestControlPlaneAppRefusesAServiceInstancePatch(t *testing.T) {
1022 fx, token := cpInstanceFixture(t, "code-cp-inst-svc-patch")
1023 letStateUpstreams(t, fx, "paul@keymail.dev")
1024 u := instancesURL(fx, "bab", "console")
1025 if status, _, out := rawReq(t, http.MethodPost, u, token,
1026 `{"host":"box3.cp.tito.io","region":"ie","kind":"service","addrs":["127.0.0.1:9001"]}`); status != http.StatusCreated {
1027 t.Fatalf("create = %d %s", status, out)
1028 }
1029 svc := serviceToken(t, fx.st, OpsAccountID, "ci", "console", svcrole.Instance)
1030 status, _, out := rawReq(t, http.MethodPatch, updateURL(fx, "bab", "console", "box3.cp.tito.io"), svc,
1031 `{"addrs":["10.0.0.1:1"]}`)
1032 if status != http.StatusForbidden {
1033 t.Fatalf("a SERVICE patching the control plane's instance record = %d %s, want 403", status, out)
1034 }
1035 if !strings.Contains(out, "a person must") {
1036 t.Fatalf("refusal %q does not say a person is the principal this needs", out)
1037 }
1038 rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "console", "box3.cp.tito.io")
1039 if err != nil {
1040 t.Fatal(err)
1041 }
1042 if len(rec.Addrs) != 1 || rec.Addrs[0] != "127.0.0.1:9001" {
1043 t.Fatalf("addrs after a refused service patch = %v, want the created pair untouched", rec.Addrs)
1044 }
1045
1046 // An ordinary app's record is still a service's to patch. An operator
1047 // states its upstream (only one may: refuseMemberUpstreams); the service
1048 // then restates it, which is not a new one.
1049 ws := serviceToken(t, fx.st, OpsAccountID, "ci-widget", "widget", svcrole.Instance)
1050 if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "widget"), token,
1051 `{"host":"box3.widget.tito.io","region":"ie","kind":"service","addrs":["127.0.0.1:9001"]}`); status != http.StatusCreated {
1052 t.Fatalf("ordinary create = %d %s", status, out)
1053 }
1054 if status, _, out := rawReq(t, http.MethodPatch, updateURL(fx, "bab", "widget", "box3.widget.tito.io"), ws,
1055 `{"addrs":["127.0.0.1:9001"]}`); status != http.StatusOK {
1056 t.Fatalf("a SERVICE patching an ORDINARY app's instance = %d %s, want 200", status, out)
1057 }
1058}
1059
1060// TestControlPlaneInstanceRefusalIsNotAnOracle: the three guards sit AFTER
1061// the family's membership gate, so a stranger meets the byte-identical 404
1062// and never the refusal. A control-plane app must not be discoverable by
1063// asking a door which sentence it answers with.
1064func TestControlPlaneInstanceRefusalIsNotAnOracle(t *testing.T) {
1065 fx, _ := cpInstanceFixture(t, "code-cp-inst-oracle")
1066 eve := cliToken(t, fx, "eve@keymail.dev", "code-cp-inst-eve")
1067 want := canonicalNotFound(t)
1068 u := instancesURL(fx, "bab", "console")
1069 for _, c := range []struct{ what, method, url, body string }{
1070 {"non-member create on a flagged app", http.MethodPost, u, `{"host":"box9.cp.tito.io","region":"ie"}`},
1071 {"non-member patch on a flagged app", http.MethodPatch, u + "/box9.cp.tito.io", `{"addrs":[]}`},
1072 {"non-member delete on a flagged app", http.MethodDelete, u + "/box9.cp.tito.io", ""},
1073 } {
1074 status, h, out := rawReq(t, c.method, c.url, eve, c.body)
1075 assertNotFound(t, c.what, want, status, h, out)
1076 }
1077}
1078
1079// --- The detach doors --------------------------------------------------------
1080//
1081// The NINTH door, and the same fuse as the eighth: handleAPIDomainDetach's
1082// first-route branch calls the very provision.DeleteRecord the instance
1083// delete does, on the same app, and the route is registered
1084// svcrole.Allow(svcrole.Publish, svcrole.Instance) — so a service credential
1085// reaches it. Its browser twin is detachDomain, shared by
1086// handleAppDomainDetach.
1087//
1088// Refused OUTRIGHT rather than only on the record-deleting branch. A
1089// conditional guard would have to re-derive "does this claim mint the app's
1090// route" ahead of the code that derives it, and the two would drift; and
1091// releasing a hostname the fleet reaches the control plane by is not
1092// something to do in passing.
1093
1094// cpDetachFixture attaches a hostname to `kass` through the shared core and
1095// THEN sets the flag — the order a real control plane arrives in (the console
1096// had its hostname long before anyone thought to mark it), and the only order
1097// in which the attach half is reachable at all.
1098//
1099// The control in these tests is the SAME app with the flag cleared rather
1100// than a sibling app, deliberately: it isolates the flag as the only variable
1101// between a refusal and a 200, which a second app with its own routes and
1102// claims could not do as cleanly.
1103func cpDetachFixture(t *testing.T, label, host string) (*consoleFixture, string) {
1104 t.Helper()
1105 fx := newConsoleFixture(t)
1106 acct, err := fx.st.AccountBySqid(fx.sqid)
1107 if err != nil || acct == nil {
1108 t.Fatalf("fixture account %s: %v", fx.sqid, err)
1109 }
1110 if _, ref := fx.srv.attachDomain(context.Background(), acct, "kass", host, "", "", "paul@keymail.dev"); ref != nil {
1111 t.Fatalf("attach setup: refused %+v", ref)
1112 }
1113 if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", true); err != nil {
1114 t.Fatal(err)
1115 }
1116 return fx, cliToken(t, fx, "paul@keymail.dev", label)
1117}
1118
1119// TestControlPlaneAppRefusesDomainDetach: the API door. Refused for a person
1120// and for BOTH service roles the route admits, the claim survives, and
1121// clearing the flag is the only way through — which is what proves the
1122// refusal is the flag's and nothing else's.
1123func TestControlPlaneAppRefusesDomainDetach(t *testing.T) {
1124 const host = "docs.example.com"
1125 fx, token := cpDetachFixture(t, "code-cp-detach", host)
1126 u := fx.ts.URL + "/api/cli/apps/bab/kass/domains/" + host
1127
1128 status, _, out := rawReq(t, http.MethodDelete, u, token, "")
1129 if status != http.StatusForbidden {
1130 t.Fatalf("a PERSON detaching a control-plane app's domain = %d %s, want 403", status, out)
1131 }
1132 if !strings.Contains(out, "control plane") {
1133 t.Fatalf("refusal %q does not name the flag the operator has to clear", out)
1134 }
1135 if !strings.Contains(out, "/a/bab/apps/kass/settings/advanced") {
1136 t.Fatalf("refusal %q does not point at the settings card that can clear the flag", out)
1137 }
1138 claimOf(t, fx, host)
1139
1140 // Both roles the route admits, since either would otherwise walk round
1141 // the instance family's guard by taking this door instead.
1142 for _, role := range []svcrole.Role{svcrole.Publish, svcrole.Instance} {
1143 svc := serviceToken(t, fx.st, OpsAccountID, "ci-"+string(role), "kass", role)
1144 if status, _, out := rawReq(t, http.MethodDelete, u, svc, ""); status != http.StatusForbidden {
1145 t.Fatalf("a SERVICE (%s) detaching a control-plane app's domain = %d %s, want 403", role, status, out)
1146 }
1147 }
1148 claimOf(t, fx, host)
1149
1150 // A malformed host answers the SAME 403, not the hostname check's 400:
1151 // on a flagged app this door's answer is constant, so it leaks nothing
1152 // about which hosts are even well-shaped here.
1153 if status, _, out := rawReq(t, http.MethodDelete,
1154 fx.ts.URL+"/api/cli/apps/bab/kass/domains/..%2f..%2fclaims", token, ""); status != http.StatusForbidden {
1155 t.Fatalf("malformed host on a flagged app = %d %s, want the flag's 403", status, out)
1156 }
1157
1158 // Clear the flag and the identical call goes through. The flag is the
1159 // only variable between these two lines.
1160 if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", false); err != nil {
1161 t.Fatal(err)
1162 }
1163 status, _, out = rawReq(t, http.MethodDelete, u, token, "")
1164 if status != http.StatusOK || !strings.Contains(out, `"removed":true`) {
1165 t.Fatalf("detach once the flag is cleared = %d %s, want 200 removed true", status, out)
1166 }
1167 claimAbsent(t, fx, host)
1168}
1169
1170// TestControlPlaneAppRefusesBrowserDomainDetach: the browser twin deletes the
1171// same record through the same core, so a guard only on the API door would
1172// leave the route retirable by a form post. Rendered as a page, like every
1173// other browser refusal, and ahead of the claim read.
1174func TestControlPlaneAppRefusesBrowserDomainDetach(t *testing.T) {
1175 const host = "docs.example.com"
1176 fx, _ := cpDetachFixture(t, "code-cp-detach-browser", host)
1177 c := signInPaul(t, fx)
1178 csrf := sessionToken(t, fx, c)
1179
1180 status, _, body := postForm(t, c, fx.u("/apps/kass/domains/detach"),
1181 url.Values{"csrf": {csrf}, "host": {host}, "confirm": {host}})
1182 if status != http.StatusForbidden {
1183 t.Fatalf("owner detaching a control-plane app's domain in a browser = %d, want 403:\n%s", status, body)
1184 }
1185 if !strings.Contains(body, "control plane") {
1186 t.Fatalf("browser refusal does not name the flag:\n%s", body)
1187 }
1188 if !strings.Contains(body, "/a/bab/apps/kass/settings/advanced") {
1189 t.Fatalf("browser refusal does not point at the settings card that can clear the flag:\n%s", body)
1190 }
1191 claimOf(t, fx, host)
1192
1193 // Cleared, the same form post lands.
1194 if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", false); err != nil {
1195 t.Fatal(err)
1196 }
1197 if status, loc, body := postForm(t, c, fx.u("/apps/kass/domains/detach"),
1198 url.Values{"csrf": {csrf}, "host": {host}, "confirm": {host}}); status != http.StatusSeeOther ||
1199 loc != fx.path("/apps/kass/settings/domains") {
1200 t.Fatalf("browser detach once the flag is cleared = %d -> %q, want 303 -> settings:\n%s", status, loc, body)
1201 }
1202 claimAbsent(t, fx, host)
1203}
1204
1205// TestControlPlaneDomainDetachRefusalIsNotAnOracle: the guard sits after
1206// domainsGate, so a stranger meets the family's byte-identical 404 and never
1207// the sentence that would tell them this account holds a control-plane app.
1208func TestControlPlaneDomainDetachRefusalIsNotAnOracle(t *testing.T) {
1209 const host = "docs.example.com"
1210 fx, _ := cpDetachFixture(t, "code-cp-detach-oracle", host)
1211 eve := cliToken(t, fx, "eve@keymail.dev", "code-cp-detach-eve")
1212 status, h, out := rawReq(t, http.MethodDelete,
1213 fx.ts.URL+"/api/cli/apps/bab/kass/domains/"+host, eve, "")
1214 assertNotFound(t, "non-member detach on a flagged app", canonicalNotFound(t), status, h, out)
1215 claimOf(t, fx, host)
1216}
1217
1218// TestControlPlaneAppRefusesAServiceDomainAttach closes the CREATE half of
1219// the provision-record lifecycle at the door that is not the instance one.
1220// The first-route branch of `POST .../domains` writes a declared
1221// provision.Record, which is the same authority transfer `POST .../instances`
1222// refuses a service for — so a CI credential must not be able to acquire it
1223// by taking the other door.
1224//
1225// Service-only, not outright: a PERSON attaching a domain to the control
1226// plane is an ordinary operator act. Removal is the asymmetric one.
1227//
1228// Both service roles the route admits, and both request SHAPES — the alias
1229// form and the `kind:"static"` first-route form — because the guard is
1230// deliberately not conditional on which branch would have run.
1231func TestControlPlaneAppRefusesAServiceDomainAttach(t *testing.T) {
1232 fx := newConsoleFixture(t)
1233 if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", true); err != nil {
1234 t.Fatal(err)
1235 }
1236 u := fx.ts.URL + "/api/cli/apps/bab/kass/domains"
1237 const host = "docs.example.com"
1238
1239 for _, role := range []svcrole.Role{svcrole.Publish, svcrole.Instance} {
1240 svc := serviceToken(t, fx.st, OpsAccountID, "ci-"+string(role), "kass", role)
1241 for what, body := range map[string]string{
1242 "alias form": attachBody(host, "kass.carlosapp.dev"),
1243 "first-route form": `{"host":"` + host + `","kind":"static"}`,
1244 } {
1245 status, _, out := rawReq(t, http.MethodPost, u, svc, body)
1246 if status != http.StatusForbidden {
1247 t.Fatalf("a SERVICE (%s) attaching a domain to the control plane, %s = %d %s, want 403",
1248 role, what, status, out)
1249 }
1250 if !strings.Contains(out, "a person must") {
1251 t.Fatalf("refusal %q does not say a person is the principal this needs", out)
1252 }
1253 }
1254 claimAbsent(t, fx, host)
1255 }
1256
1257 // The half that must keep working: a person attaches.
1258 token := cliToken(t, fx, "paul@keymail.dev", "code-cp-attach")
1259 if status, _, out := rawReq(t, http.MethodPost, u, token,
1260 attachBody(host, "kass.carlosapp.dev")); status != http.StatusOK {
1261 t.Fatalf("a PERSON attaching a domain to the control plane = %d %s, want 200", status, out)
1262 }
1263 claimOf(t, fx, host)
1264
1265 // A non-member still meets the family's byte-identical 404, never the
1266 // refusal: the guard sits after domainsGate, so it is not an oracle for
1267 // which accounts hold a control-plane app.
1268 eve := cliToken(t, fx, "eve@keymail.dev", "code-cp-attach-eve")
1269 status, h, out := rawReq(t, http.MethodPost, u, eve, attachBody("eve.example.com", "kass.carlosapp.dev"))
1270 assertNotFound(t, "non-member attach on a flagged app", canonicalNotFound(t), status, h, out)
1271 claimAbsent(t, fx, "eve.example.com")
1272
1273 // The control: with the flag cleared, the identical service call lands.
1274 // The flag is the only variable between these two outcomes.
1275 if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", false); err != nil {
1276 t.Fatal(err)
1277 }
1278 svc := serviceToken(t, fx.st, OpsAccountID, "ci-cleared", "kass", svcrole.Publish)
1279 if status, _, out := rawReq(t, http.MethodPost, u, svc,
1280 attachBody("other.example.com", "kass.carlosapp.dev")); status != http.StatusOK {
1281 t.Fatalf("a SERVICE attaching once the flag is cleared = %d %s, want 200", status, out)
1282 }
1283 claimOf(t, fx, "other.example.com")
1284}
1285
1286// TestControlPlaneAppRefusesAServicePathPublish is the prefix-map door's place
1287// in this family. A published prefix map splits a hostname's traffic by leading
1288// URL segment, and the reconcile pass admits any target route in the same
1289// account — so a map on the CONTROL PLANE's own hostname hands a slice of the
1290// console's URL space (`/admin`, `/api`) to whatever else that account runs.
1291// That is not a decision a CI credential should be able to take.
1292//
1293// Service-only, like the attach above and unlike detach: a PERSON publishing a
1294// prefix map for the control plane is an ordinary operator act. BOTH service
1295// roles the route admits are exercised (Publish and Instance — see
1296// TestAPIDomainPathsReachesPublishAndInstance), because this guard, not the
1297// role table, is what keeps either of them off the console's own hostname.
1298func TestControlPlaneAppRefusesAServicePathPublish(t *testing.T) {
1299 const host = "docs.example.com"
1300 fx, person := cpDetachFixture(t, "code-cp-paths", host)
1301 u := fx.ts.URL + "/api/cli/apps/bab/kass/domains/" + host + "/paths"
1302 body := pathsBody(t, map[string]string{"paulca": "kass.carlosapp.dev"})
1303
1304 svc := serviceToken(t, fx.st, OpsAccountID, "ci", "kass", svcrole.Publish)
1305 status, _, out := rawReq(t, http.MethodPut, u, svc, body)
1306 if status != http.StatusForbidden {
1307 t.Fatalf("a SERVICE publishing paths for the control plane = %d %s, want 403", status, out)
1308 }
1309 if !strings.Contains(out, "a person must") {
1310 t.Fatalf("refusal %q does not say a person is the principal this needs", out)
1311 }
1312 if c := claimOf(t, fx, host); len(c.Paths) != 0 {
1313 t.Fatalf("claim paths = %v, want none — a refused publish must not land", c.Paths)
1314 }
1315
1316 // The other role the door admits, refused identically: an app's router
1317 // holding a CARLOS_INSTANCE_TOKEN reaches this handler (that is the
1318 // widening in spec §7) and meets the same guard.
1319 inst := serviceToken(t, fx.st, OpsAccountID, "router", "kass", svcrole.Instance)
1320 instStatus, _, instOut := rawReq(t, http.MethodPut, u, inst, body)
1321 if instStatus != status || instOut != out {
1322 t.Fatalf("an INSTANCE publishing paths for the control plane = %d %s, want the same %d %s a PUBLISH gets",
1323 instStatus, instOut, status, out)
1324 }
1325 if c := claimOf(t, fx, host); len(c.Paths) != 0 {
1326 t.Fatalf("claim paths = %v, want none — a refused publish must not land", c.Paths)
1327 }
1328
1329 // The guard sits BEFORE the hostname is normalized, so its answer cannot
1330 // vary with host SHAPE: a flagged app refuses a malformed host and a valid
1331 // one with the identical status and the identical body. Were the order
1332 // reversed, the 400 "invalid hostname" would reach a prober first and this
1333 // door would report whether a name is well-formed before reporting that
1334 // the app is off limits — a small oracle, but the same class as the ones
1335 // the domains family is built to close.
1336 //
1337 // Only shapes that actually REACH the handler are comparable here. A
1338 // segment of "." or ".." is collapsed by net/http's own path cleaning
1339 // before routing, so it lands on a different pattern entirely (405) and
1340 // says nothing about this guard's ordering. The percent-encoded traversal
1341 // does reach it, which is the case the normalization below the guard
1342 // exists for.
1343 for _, bad := range []string{"not a host", "no-dot", "%2e%2e%2fclaims", "UPPER.example.com."} {
1344 badStatus, _, badOut := rawReq(t, http.MethodPut,
1345 fx.ts.URL+"/api/cli/apps/bab/kass/domains/"+bad+"/paths", svc, body)
1346 if badStatus != status || badOut != out {
1347 t.Fatalf("host %q answered %d %q; the flagged-app refusal must be constant across host shapes (%d %q)",
1348 bad, badStatus, badOut, status, out)
1349 }
1350 }
1351
1352 // A non-member still meets the family's byte-identical 404, never the
1353 // refusal: the guard sits after domainsGate, so it is not an oracle for
1354 // which accounts hold a control-plane app.
1355 eve := cliToken(t, fx, "eve@keymail.dev", "code-cp-paths-eve")
1356 eveStatus, eveH, eveOut := rawReq(t, http.MethodPut, u, eve, body)
1357 assertNotFound(t, "non-member path publish on a flagged app", canonicalNotFound(t), eveStatus, eveH, eveOut)
1358
1359 // The half that must keep working: a person publishes.
1360 if status, _, out := rawReq(t, http.MethodPut, u, person, body); status != http.StatusOK {
1361 t.Fatalf("a PERSON publishing paths for the control plane = %d %s, want 200", status, out)
1362 }
1363 if c := claimOf(t, fx, host); c.Paths["paulca"] != "kass.carlosapp.dev" {
1364 t.Fatalf("claim paths = %v, want the person's publish to have landed", c.Paths)
1365 }
1366
1367 // The control: with the flag cleared, the identical service call lands.
1368 // The flag is the only variable between these two outcomes.
1369 if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", false); err != nil {
1370 t.Fatal(err)
1371 }
1372 cleared := pathsBody(t, map[string]string{"bigcorp": "kass.carlosapp.dev"})
1373 if status, _, out := rawReq(t, http.MethodPut, u, svc, cleared); status != http.StatusOK {
1374 t.Fatalf("a SERVICE publishing once the flag is cleared = %d %s, want 200", status, out)
1375 }
1376 if c := claimOf(t, fx, host); c.Paths["bigcorp"] != "kass.carlosapp.dev" {
1377 t.Fatalf("claim paths = %v, want the service's publish to have landed once the flag was cleared", c.Paths)
1378 }
1379}
1380