| 1 | package console |
| 2 | |
| 3 | import ( |
| 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 | |
| 19 | func 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 | |
| 68 | func 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. |
| 88 | func 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. |
| 99 | func 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. |
| 114 | func 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. |
| 130 | func 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. |
| 144 | func 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. |
| 159 | func 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. |
| 176 | func 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. |
| 194 | func 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. |
| 218 | func 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. |
| 248 | func 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. |
| 278 | func 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. |
| 315 | func 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 | |