package console import ( "bytes" "context" "errors" "fmt" "net/http" "net/url" "os" "strings" "testing" "time" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/svcrole" ) func TestOwnerClaimPreservesExistingPipeline(t *testing.T) { for _, state := range []string{"legacy", "custom", "unreadable"} { t.Run(state, func(t *testing.T) { fx := newConsoleFixture(t) ctx := context.Background() const app = "existing-site" seedBucketApp(t, fx, "bab", app) key := release.PipelineKey("bab", app) switch state { case "custom": _, err := release.SetPipeline(ctx, fx.rel, fx.priv, "bab", app, []release.PipelineChannel{ {Name: "preview"}, {Name: "production", Bake: "24h", Rules: release.ChannelRules{Passkey: true, PromoteApprovals: 2, ChangeApprovals: 2}}, }, "paul@keymail.dev") if err != nil { t.Fatal(err) } case "unreadable": if _, err := fx.rel.Put(ctx, key, []byte("invalid signed pipeline"), ""); err != nil { t.Fatal(err) } } before, etag, beforeErr := fx.rel.Get(ctx, key) if state == "legacy" && !errors.Is(beforeErr, os.ErrNotExist) { t.Fatalf("legacy pipeline should be absent: %v", beforeErr) } if state != "legacy" && beforeErr != nil { t.Fatal(beforeErr) } token := cliToken(t, fx, "paul@keymail.dev", "claim-existing-site") status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": app}, token) if status != http.StatusOK { t.Fatalf("owner adoption = %d %v", status, out) } if has, err := fx.st.HasAppIn(OpsAccountID, app); err != nil || !has { t.Fatalf("app registration = %v, %v", has, err) } after, afterETag, err := fx.rel.Get(ctx, key) if state == "legacy" { if !errors.Is(err, os.ErrNotExist) { t.Fatalf("adoption created a pipeline for a legacy app: %v", err) } } else if err != nil || !bytes.Equal(before, after) || etag != afterETag { t.Fatalf("adoption changed existing pipeline: err=%v, etag before=%q after=%q", err, etag, afterETag) } }) } } func TestOwnerClaimNewAppGetsDefaultPipeline(t *testing.T) { fx := newConsoleFixture(t) token := cliToken(t, fx, "paul@keymail.dev", "claim-fresh-site") status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "fresh-site"}, token) if status != http.StatusOK { t.Fatalf("new app = %d %v", status, out) } pipeline, err := release.GetPipeline(context.Background(), fx.rel, fx.pub, "bab", "fresh-site") if err != nil || pipeline == nil { t.Fatalf("new app pipeline = %v, %v", pipeline, err) } if len(pipeline.Channels) != 1 || pipeline.Channels[0].Name != "edge" { t.Fatalf("new app channels = %#v", pipeline.Channels) } } // seedBucketApp puts an object under an account's own app prefix WITHOUT a // row, which is exactly what a direct-bucket ship does (direct mode skips the // claim) and exactly the state that makes #79 unrecoverable: the objects // block the claim, and no pass can discover a row that does not exist. func seedBucketApp(t *testing.T, fx *consoleFixture, sqid, app string) { t.Helper() key := "apps/" + sqid + "/" + app + "/channels/canary-console.json" if _, err := fx.rel.Put(context.Background(), key, []byte(`{}`), ""); err != nil { t.Fatalf("seed %s: %v", key, err) } } // opsAccount is the ops account row the claim tests hand to claimApp. Role is // not read by claimApp — the ownerClaim argument carries that decision — so // the plain by-id accessor (which does not join a membership) is enough. func opsAccount(t *testing.T, fx *consoleFixture) *Account { t.Helper() acct, err := fx.st.AccountByID(OpsAccountID) if err != nil { t.Fatal(err) } if acct == nil { t.Fatal("ops account missing from the fixture") } return acct } // TestOwnerClaimsAppWhoseObjectsExist is #79's fix. Before it, an app first // shipped direct-to-bucket could never be claimed and so was permanently // invisible to the console, with no recovery but deleting its release history. func TestOwnerClaimsAppWhoseObjectsExist(t *testing.T) { fx := newConsoleFixture(t) seedBucketApp(t, fx, "bab", "console") acct := opsAccount(t, fx) outcome, _, _ := fx.srv.claimApp(context.Background(), acct, "console", "paul@keymail.dev", "", true, regionReq{}) if outcome != claimOK { t.Fatalf("owner claim over existing objects = %v, want claimOK", outcome) } has, err := fx.st.HasAppIn(OpsAccountID, "console") if err != nil || !has { t.Fatalf("HasAppIn after claim = %v %v, want true", has, err) } } // TestNonOwnerStillRefusedOverExistingObjects: the exemption is OWNERSHIP, not // membership. Adopting existing release history is an ownership act. func TestNonOwnerStillRefusedOverExistingObjects(t *testing.T) { fx := newConsoleFixture(t) seedBucketApp(t, fx, "bab", "console") acct := opsAccount(t, fx) outcome, _, _ := fx.srv.claimApp(context.Background(), acct, "console", "member@keymail.dev", "", false, regionReq{}) if outcome != claimTaken { t.Fatalf("non-owner claim over existing objects = %v, want claimTaken", outcome) } } // TestLegacyPrefixStillBlocksAnOwner: the exemption covers the ACCOUNT- // QUALIFIED prefix only. Legacy objects at apps// predate account scoping // and are owed an `accounts migrate -from legacy`; handing the name out would // put an app in the way of that migration. func TestLegacyPrefixStillBlocksAnOwner(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.rel.Put(context.Background(), "apps/oldsite/channels/stable.json", []byte(`{}`), ""); err != nil { t.Fatal(err) } acct := opsAccount(t, fx) outcome, _, _ := fx.srv.claimApp(context.Background(), acct, "oldsite", "paul@keymail.dev", "", true, regionReq{}) if outcome != claimTaken { t.Fatalf("owner claim over a LEGACY prefix = %v, want claimTaken — those objects are owed a migrate", outcome) } } // TestGrantStillBlocksAnOwner: a CLI-granted app in ops stays claim-proof. // Verified live on flagship 2026-08-23: derp, meep and platform-site hold // grants; console does not, which is why the exemption is sufficient for it. func TestGrantStillBlocksAnOwner(t *testing.T) { fx := newConsoleFixture(t) if err := fx.st.Grant("someone@keymail.dev", "derp"); err != nil { t.Fatal(err) } acct := opsAccount(t, fx) outcome, _, _ := fx.srv.claimApp(context.Background(), acct, "derp", "paul@keymail.dev", "", true, regionReq{}) if outcome != claimTaken { t.Fatalf("owner claim over a GRANTED name = %v, want claimTaken", outcome) } } // TestServicePrincipalNeverClaimsOverObjects: a service has no role at all, so // ownerClaim must be pinned FALSE for it. An account-scoped PrincipalService // legitimately reaches claimApp (apicli.go blocks only app-scoped ones), so // without the pin the exemption would silently hand every CI credential the // power to adopt release history. func TestServicePrincipalNeverClaimsOverObjects(t *testing.T) { fx := newConsoleFixture(t) seedBucketApp(t, fx, "bab", "console") svc := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish) status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "console"}, svc) if status != http.StatusConflict { t.Fatalf("service claim over existing objects = %d %v, want 409 (taken)", status, out) } has, err := fx.st.HasAppIn(OpsAccountID, "console") if err != nil || has { t.Fatalf("HasAppIn after a service claim = %v %v, want false — a service must never adopt release history", has, err) } } // TestBrowserDoorMemberRefusedOverExistingObjects proves the browser door's // WIRING, not just claimApp's argument: handleNewApp now passes the session's // role through, and a plain member of the account must still be told the name // is taken. TestCreateApp covers the owner's side of the same door. func TestBrowserDoorMemberRefusedOverExistingObjects(t *testing.T) { fx := newConsoleFixture(t) seedBucketApp(t, fx, "bab", "console") if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } c, csrf := signInAs(t, fx, "sam@keymail.dev", "code-claim-member") status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"console"}}) if status != http.StatusSeeOther || loc != fx.path("/apps/new") { t.Fatalf("member claim over existing objects = %d -> %q, want 303 -> the create form", status, loc) } if _, _, body := get(t, c, fx.u("/apps/new")); !strings.Contains(body, "That name is taken.") { t.Fatal("member claim over existing objects: no taken flash") } if has, err := fx.st.HasAppIn(OpsAccountID, "console"); err != nil || has { t.Fatalf("HasAppIn after a member claim = %v %v, want false", has, err) } } // TestCreateAppStampsControlPlaneAndProbe: the flags are set in the SAME call // that claims the name. Claiming first and flagging second would leave a // window in which the production console is an ordinary app any ops member // could roll. func TestCreateAppStampsControlPlaneAndProbe(t *testing.T) { fx := newConsoleFixture(t) seedBucketApp(t, fx, "bab", "console") token := cliToken(t, fx, "paul@keymail.dev", "code-create-cp") status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]any{ "name": "console", "control_plane": true, "build_probe": "/healthz", }, token) if status != http.StatusOK { t.Fatalf("create = %d %v, want 200", status, out) } // The echo is what the CLI (runAppsCreate) hard-fails on if it disagrees // with what was asked for — assert the WIRE keys here too, not just the // store, so a renamed or dropped echo field (e.g. "controlPlane" instead // of "control_plane") fails a test instead of only failing every real // `carlos apps create -control-plane` at "did not stick". if out["control_plane"] != true || out["build_probe"] != "/healthz" { t.Fatalf("echo = %v, want the stamped flags the CLI hard-fails on", out) } cp, probe, err := fx.st.AppFlags(OpsAccountID, "console") if err != nil { t.Fatal(err) } if !cp || probe != "/healthz" { t.Fatalf("flags after create = %v %q, want true \"/healthz\"", cp, probe) } } // TestOnlyAnOwnerSetsControlPlane: the flag refuses destruction, so setting it // is an ownership act — a member who could set it could also make an app // undeletable by its owner. func TestOnlyAnOwnerSetsControlPlane(t *testing.T) { fx := newConsoleFixture(t) if err := fx.st.AddMember(OpsAccountID, "member@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } token := cliToken(t, fx, "member@keymail.dev", "code-create-cp-member") status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]any{ "name": "notyours", "control_plane": true, }, token) if status != http.StatusForbidden { t.Fatalf("member setting control_plane = %d %v, want 403", status, out) } has, err := fx.st.HasAppIn(OpsAccountID, "notyours") if err != nil || has { t.Fatalf("the app was claimed anyway (%v) — a refused flag must refuse the WHOLE create, like -place does", has) } } // TestBuildProbeMustBeAnAbsolutePath: the probe is a URL path the deploy watch // dials. Anything else is a typo that would otherwise surface as a deploy that // times out for reasons nobody can see. // // App names are indexed ("probeN"), NOT derived from the bad value's first // byte: "probe" + bad[:1] collapses "/healthz?x=1" and "/health z" to the // same app name "probe/", which release.ValidApp itself refuses — those two // rows were passing as claimInvalid (400 for the WRONG reason, before // build_probe is even read) rather than exercising validBuildProbe at all. // Indexing keeps every row's app name valid so every row actually reaches // the gate under test, and asserting the error body names "build_probe" // pins the refusal to the right gate. func TestBuildProbeMustBeAnAbsolutePath(t *testing.T) { fx := newConsoleFixture(t) token := cliToken(t, fx, "paul@keymail.dev", "code-create-probe-bad") bad := []string{ "healthz", // not absolute "http://x/healthz", // full URL, not a path "/healthz?x=1", // query — the watch appends its own "/health z", // space "//evil.example/healthz", // protocol-relative — silently points the watch at another host "/health\nz", // control character (CRLF-style header/request injection shape) "/" + strings.Repeat("a", 256), // 257 bytes, one over the server's bound } for i, b := range bad { name := fmt.Sprintf("probe%d", i) status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]any{ "name": name, "build_probe": b, }, token) if status != http.StatusBadRequest { t.Fatalf("build_probe %q = %d %v, want 400", b, status, out) } if !strings.Contains(str(t, out, "error"), "build_probe") { t.Fatalf("build_probe %q error = %v, want it to name build_probe (not some other gate)", b, out) } if has, err := fx.st.HasAppIn(OpsAccountID, name); err != nil || has { t.Fatalf("HasAppIn(%s) after a refused build_probe = %v %v, want false", name, has, err) } } } // TestOwnerExemptionRefusesAnUnknownPrefix pins the DEFAULT DIRECTION of the // #79 owner exemption rather than either of the two prefixes it covers today. // The decision is an allowlist — it names the account's own prefix — so a // hypothetical THIRD prefix appended to claimApp's list (a future migration // namespace, a shared-tenant prefix, anything) is refused for an owner until // someone deliberately adds it here. Written as the exclusion this replaced // (`prefix != LegacyAppPrefix(name)`), that same third prefix would have been // exempted silently, fail-open in a guard whose whole job is refusing. func TestOwnerExemptionRefusesAnUnknownPrefix(t *testing.T) { own := release.AppPrefix("bab", "console") if !ownerExempt(own, own) { t.Fatalf("ownerExempt(own, own) = false, want true: the account's own prefix IS the exemption") } for _, other := range []string{ release.LegacyAppPrefix("console"), // today's second prefix "apps/migrating/bab/console/", // a hypothetical third release.AppPrefix("bdk", "console"), // another account's strings.TrimSuffix(own, "/"), // a near-miss spelling } { if ownerExempt(other, own) { t.Fatalf("ownerExempt(%q, %q) = true, want false: only the account's own prefix is exempt", other, own) } } }