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 "bytes"
5 "context"
6 "errors"
7 "fmt"
8 "net/http"
9 "net/url"
10 "os"
11 "strings"
12 "testing"
13 "time"
14
15 "github.com/carlosframework/platform/internal/release"
16 "github.com/carlosframework/platform/internal/svcrole"
17)
18
19func TestOwnerClaimPreservesExistingPipeline(t *testing.T) {
20 for _, state := range []string{"legacy", "custom", "unreadable"} {
21 t.Run(state, func(t *testing.T) {
22 fx := newConsoleFixture(t)
23 ctx := context.Background()
24 const app = "existing-site"
25 seedBucketApp(t, fx, "bab", app)
26 key := release.PipelineKey("bab", app)
27 switch state {
28 case "custom":
29 _, err := release.SetPipeline(ctx, fx.rel, fx.priv, "bab", app, []release.PipelineChannel{
30 {Name: "preview"},
31 {Name: "production", Bake: "24h", Rules: release.ChannelRules{Passkey: true, PromoteApprovals: 2, ChangeApprovals: 2}},
32 }, "paul@keymail.dev")
33 if err != nil {
34 t.Fatal(err)
35 }
36 case "unreadable":
37 if _, err := fx.rel.Put(ctx, key, []byte("invalid signed pipeline"), ""); err != nil {
38 t.Fatal(err)
39 }
40 }
41 before, etag, beforeErr := fx.rel.Get(ctx, key)
42 if state == "legacy" && !errors.Is(beforeErr, os.ErrNotExist) {
43 t.Fatalf("legacy pipeline should be absent: %v", beforeErr)
44 }
45 if state != "legacy" && beforeErr != nil {
46 t.Fatal(beforeErr)
47 }
48 token := cliToken(t, fx, "paul@keymail.dev", "claim-existing-site")
49 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": app}, token)
50 if status != http.StatusOK {
51 t.Fatalf("owner adoption = %d %v", status, out)
52 }
53 if has, err := fx.st.HasAppIn(OpsAccountID, app); err != nil || !has {
54 t.Fatalf("app registration = %v, %v", has, err)
55 }
56 after, afterETag, err := fx.rel.Get(ctx, key)
57 if state == "legacy" {
58 if !errors.Is(err, os.ErrNotExist) {
59 t.Fatalf("adoption created a pipeline for a legacy app: %v", err)
60 }
61 } else if err != nil || !bytes.Equal(before, after) || etag != afterETag {
62 t.Fatalf("adoption changed existing pipeline: err=%v, etag before=%q after=%q", err, etag, afterETag)
63 }
64 })
65 }
66}
67
68func TestOwnerClaimNewAppGetsDefaultPipeline(t *testing.T) {
69 fx := newConsoleFixture(t)
70 token := cliToken(t, fx, "paul@keymail.dev", "claim-fresh-site")
71 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "fresh-site"}, token)
72 if status != http.StatusOK {
73 t.Fatalf("new app = %d %v", status, out)
74 }
75 pipeline, err := release.GetPipeline(context.Background(), fx.rel, fx.pub, "bab", "fresh-site")
76 if err != nil || pipeline == nil {
77 t.Fatalf("new app pipeline = %v, %v", pipeline, err)
78 }
79 if len(pipeline.Channels) != 1 || pipeline.Channels[0].Name != "edge" {
80 t.Fatalf("new app channels = %#v", pipeline.Channels)
81 }
82}
83
84// seedBucketApp puts an object under an account's own app prefix WITHOUT a
85// row, which is exactly what a direct-bucket ship does (direct mode skips the
86// claim) and exactly the state that makes #79 unrecoverable: the objects
87// block the claim, and no pass can discover a row that does not exist.
88func seedBucketApp(t *testing.T, fx *consoleFixture, sqid, app string) {
89 t.Helper()
90 key := "apps/" + sqid + "/" + app + "/channels/canary-console.json"
91 if _, err := fx.rel.Put(context.Background(), key, []byte(`{}`), ""); err != nil {
92 t.Fatalf("seed %s: %v", key, err)
93 }
94}
95
96// opsAccount is the ops account row the claim tests hand to claimApp. Role is
97// not read by claimApp — the ownerClaim argument carries that decision — so
98// the plain by-id accessor (which does not join a membership) is enough.
99func opsAccount(t *testing.T, fx *consoleFixture) *Account {
100 t.Helper()
101 acct, err := fx.st.AccountByID(OpsAccountID)
102 if err != nil {
103 t.Fatal(err)
104 }
105 if acct == nil {
106 t.Fatal("ops account missing from the fixture")
107 }
108 return acct
109}
110
111// TestOwnerClaimsAppWhoseObjectsExist is #79's fix. Before it, an app first
112// shipped direct-to-bucket could never be claimed and so was permanently
113// invisible to the console, with no recovery but deleting its release history.
114func TestOwnerClaimsAppWhoseObjectsExist(t *testing.T) {
115 fx := newConsoleFixture(t)
116 seedBucketApp(t, fx, "bab", "console")
117 acct := opsAccount(t, fx)
118 outcome, _, _ := fx.srv.claimApp(context.Background(), acct, "console", "paul@keymail.dev", "", true, regionReq{})
119 if outcome != claimOK {
120 t.Fatalf("owner claim over existing objects = %v, want claimOK", outcome)
121 }
122 has, err := fx.st.HasAppIn(OpsAccountID, "console")
123 if err != nil || !has {
124 t.Fatalf("HasAppIn after claim = %v %v, want true", has, err)
125 }
126}
127
128// TestNonOwnerStillRefusedOverExistingObjects: the exemption is OWNERSHIP, not
129// membership. Adopting existing release history is an ownership act.
130func TestNonOwnerStillRefusedOverExistingObjects(t *testing.T) {
131 fx := newConsoleFixture(t)
132 seedBucketApp(t, fx, "bab", "console")
133 acct := opsAccount(t, fx)
134 outcome, _, _ := fx.srv.claimApp(context.Background(), acct, "console", "member@keymail.dev", "", false, regionReq{})
135 if outcome != claimTaken {
136 t.Fatalf("non-owner claim over existing objects = %v, want claimTaken", outcome)
137 }
138}
139
140// TestLegacyPrefixStillBlocksAnOwner: the exemption covers the ACCOUNT-
141// QUALIFIED prefix only. Legacy objects at apps/<app>/ predate account scoping
142// and are owed an `accounts migrate -from legacy`; handing the name out would
143// put an app in the way of that migration.
144func TestLegacyPrefixStillBlocksAnOwner(t *testing.T) {
145 fx := newConsoleFixture(t)
146 if _, err := fx.rel.Put(context.Background(), "apps/oldsite/channels/stable.json", []byte(`{}`), ""); err != nil {
147 t.Fatal(err)
148 }
149 acct := opsAccount(t, fx)
150 outcome, _, _ := fx.srv.claimApp(context.Background(), acct, "oldsite", "paul@keymail.dev", "", true, regionReq{})
151 if outcome != claimTaken {
152 t.Fatalf("owner claim over a LEGACY prefix = %v, want claimTaken — those objects are owed a migrate", outcome)
153 }
154}
155
156// TestGrantStillBlocksAnOwner: a CLI-granted app in ops stays claim-proof.
157// Verified live on flagship 2026-08-23: derp, meep and platform-site hold
158// grants; console does not, which is why the exemption is sufficient for it.
159func TestGrantStillBlocksAnOwner(t *testing.T) {
160 fx := newConsoleFixture(t)
161 if err := fx.st.Grant("someone@keymail.dev", "derp"); err != nil {
162 t.Fatal(err)
163 }
164 acct := opsAccount(t, fx)
165 outcome, _, _ := fx.srv.claimApp(context.Background(), acct, "derp", "paul@keymail.dev", "", true, regionReq{})
166 if outcome != claimTaken {
167 t.Fatalf("owner claim over a GRANTED name = %v, want claimTaken", outcome)
168 }
169}
170
171// TestServicePrincipalNeverClaimsOverObjects: a service has no role at all, so
172// ownerClaim must be pinned FALSE for it. An account-scoped PrincipalService
173// legitimately reaches claimApp (apicli.go blocks only app-scoped ones), so
174// without the pin the exemption would silently hand every CI credential the
175// power to adopt release history.
176func TestServicePrincipalNeverClaimsOverObjects(t *testing.T) {
177 fx := newConsoleFixture(t)
178 seedBucketApp(t, fx, "bab", "console")
179 svc := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish)
180 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "console"}, svc)
181 if status != http.StatusConflict {
182 t.Fatalf("service claim over existing objects = %d %v, want 409 (taken)", status, out)
183 }
184 has, err := fx.st.HasAppIn(OpsAccountID, "console")
185 if err != nil || has {
186 t.Fatalf("HasAppIn after a service claim = %v %v, want false — a service must never adopt release history", has, err)
187 }
188}
189
190// TestBrowserDoorMemberRefusedOverExistingObjects proves the browser door's
191// WIRING, not just claimApp's argument: handleNewApp now passes the session's
192// role through, and a plain member of the account must still be told the name
193// is taken. TestCreateApp covers the owner's side of the same door.
194func TestBrowserDoorMemberRefusedOverExistingObjects(t *testing.T) {
195 fx := newConsoleFixture(t)
196 seedBucketApp(t, fx, "bab", "console")
197 if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
198 t.Fatal(err)
199 }
200 c, csrf := signInAs(t, fx, "sam@keymail.dev", "code-claim-member")
201
202 status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"console"}})
203 if status != http.StatusSeeOther || loc != fx.path("/apps/new") {
204 t.Fatalf("member claim over existing objects = %d -> %q, want 303 -> the create form", status, loc)
205 }
206 if _, _, body := get(t, c, fx.u("/apps/new")); !strings.Contains(body, "That name is taken.") {
207 t.Fatal("member claim over existing objects: no taken flash")
208 }
209 if has, err := fx.st.HasAppIn(OpsAccountID, "console"); err != nil || has {
210 t.Fatalf("HasAppIn after a member claim = %v %v, want false", has, err)
211 }
212}
213
214// TestCreateAppStampsControlPlaneAndProbe: the flags are set in the SAME call
215// that claims the name. Claiming first and flagging second would leave a
216// window in which the production console is an ordinary app any ops member
217// could roll.
218func TestCreateAppStampsControlPlaneAndProbe(t *testing.T) {
219 fx := newConsoleFixture(t)
220 seedBucketApp(t, fx, "bab", "console")
221 token := cliToken(t, fx, "paul@keymail.dev", "code-create-cp")
222 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]any{
223 "name": "console", "control_plane": true, "build_probe": "/healthz",
224 }, token)
225 if status != http.StatusOK {
226 t.Fatalf("create = %d %v, want 200", status, out)
227 }
228 // The echo is what the CLI (runAppsCreate) hard-fails on if it disagrees
229 // with what was asked for — assert the WIRE keys here too, not just the
230 // store, so a renamed or dropped echo field (e.g. "controlPlane" instead
231 // of "control_plane") fails a test instead of only failing every real
232 // `carlos apps create -control-plane` at "did not stick".
233 if out["control_plane"] != true || out["build_probe"] != "/healthz" {
234 t.Fatalf("echo = %v, want the stamped flags the CLI hard-fails on", out)
235 }
236 cp, probe, err := fx.st.AppFlags(OpsAccountID, "console")
237 if err != nil {
238 t.Fatal(err)
239 }
240 if !cp || probe != "/healthz" {
241 t.Fatalf("flags after create = %v %q, want true \"/healthz\"", cp, probe)
242 }
243}
244
245// TestOnlyAnOwnerSetsControlPlane: the flag refuses destruction, so setting it
246// is an ownership act — a member who could set it could also make an app
247// undeletable by its owner.
248func TestOnlyAnOwnerSetsControlPlane(t *testing.T) {
249 fx := newConsoleFixture(t)
250 if err := fx.st.AddMember(OpsAccountID, "member@keymail.dev", RoleMember, time.Now()); err != nil {
251 t.Fatal(err)
252 }
253 token := cliToken(t, fx, "member@keymail.dev", "code-create-cp-member")
254 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]any{
255 "name": "notyours", "control_plane": true,
256 }, token)
257 if status != http.StatusForbidden {
258 t.Fatalf("member setting control_plane = %d %v, want 403", status, out)
259 }
260 has, err := fx.st.HasAppIn(OpsAccountID, "notyours")
261 if err != nil || has {
262 t.Fatalf("the app was claimed anyway (%v) — a refused flag must refuse the WHOLE create, like -place does", has)
263 }
264}
265
266// TestBuildProbeMustBeAnAbsolutePath: the probe is a URL path the deploy watch
267// dials. Anything else is a typo that would otherwise surface as a deploy that
268// times out for reasons nobody can see.
269//
270// App names are indexed ("probeN"), NOT derived from the bad value's first
271// byte: "probe" + bad[:1] collapses "/healthz?x=1" and "/health z" to the
272// same app name "probe/", which release.ValidApp itself refuses — those two
273// rows were passing as claimInvalid (400 for the WRONG reason, before
274// build_probe is even read) rather than exercising validBuildProbe at all.
275// Indexing keeps every row's app name valid so every row actually reaches
276// the gate under test, and asserting the error body names "build_probe"
277// pins the refusal to the right gate.
278func TestBuildProbeMustBeAnAbsolutePath(t *testing.T) {
279 fx := newConsoleFixture(t)
280 token := cliToken(t, fx, "paul@keymail.dev", "code-create-probe-bad")
281 bad := []string{
282 "healthz", // not absolute
283 "http://x/healthz", // full URL, not a path
284 "/healthz?x=1", // query — the watch appends its own
285 "/health z", // space
286 "//evil.example/healthz", // protocol-relative — silently points the watch at another host
287 "/health\nz", // control character (CRLF-style header/request injection shape)
288 "/" + strings.Repeat("a", 256), // 257 bytes, one over the server's bound
289 }
290 for i, b := range bad {
291 name := fmt.Sprintf("probe%d", i)
292 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]any{
293 "name": name, "build_probe": b,
294 }, token)
295 if status != http.StatusBadRequest {
296 t.Fatalf("build_probe %q = %d %v, want 400", b, status, out)
297 }
298 if !strings.Contains(str(t, out, "error"), "build_probe") {
299 t.Fatalf("build_probe %q error = %v, want it to name build_probe (not some other gate)", b, out)
300 }
301 if has, err := fx.st.HasAppIn(OpsAccountID, name); err != nil || has {
302 t.Fatalf("HasAppIn(%s) after a refused build_probe = %v %v, want false", name, has, err)
303 }
304 }
305}
306
307// TestOwnerExemptionRefusesAnUnknownPrefix pins the DEFAULT DIRECTION of the
308// #79 owner exemption rather than either of the two prefixes it covers today.
309// The decision is an allowlist — it names the account's own prefix — so a
310// hypothetical THIRD prefix appended to claimApp's list (a future migration
311// namespace, a shared-tenant prefix, anything) is refused for an owner until
312// someone deliberately adds it here. Written as the exclusion this replaced
313// (`prefix != LegacyAppPrefix(name)`), that same third prefix would have been
314// exempted silently, fail-open in a guard whose whole job is refusing.
315func TestOwnerExemptionRefusesAnUnknownPrefix(t *testing.T) {
316 own := release.AppPrefix("bab", "console")
317 if !ownerExempt(own, own) {
318 t.Fatalf("ownerExempt(own, own) = false, want true: the account's own prefix IS the exemption")
319 }
320 for _, other := range []string{
321 release.LegacyAppPrefix("console"), // today's second prefix
322 "apps/migrating/bab/console/", // a hypothetical third
323 release.AppPrefix("bdk", "console"), // another account's
324 strings.TrimSuffix(own, "/"), // a near-miss spelling
325 } {
326 if ownerExempt(other, own) {
327 t.Fatalf("ownerExempt(%q, %q) = true, want false: only the account's own prefix is exempt", other, own)
328 }
329 }
330}
331