| index 163d49993..181587124 100644 |
| --- a/internal/console/controlplane_test.go |
| +++ b/internal/console/controlplane_test.go |
| @@ -14,6 +14,7 @@ import ( |
| "time" |
| |
| "github.com/carlosframework/platform/internal/objstore" |
| + "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/svcrole" |
| ) |
| @@ -864,3 +865,416 @@ func TestControlPlaneShipRefusalNamesTheMintTrap(t *testing.T) { |
| t.Fatalf("minted ship of an ordinary app = %d %v, want 201", status, out) |
| } |
| } |
| + |
| +// --- The instance doors ------------------------------------------------------ |
| +// |
| +// The eighth door, found mid-rollout. `carlos deploy` resolves hosts through |
| +// instance records, so the console needs one — but a record is an AUTHORITY |
| +// TRANSFER (AGENTS.md): once it exists and the route is Provisioned, the |
| +// provisioning reconciler owns the row, and its removal phase retires the |
| +// route the moment the record disappears. So a single DELETE on this family |
| +// takes the control plane off the air, and PATCH repoints it. |
| +// |
| +// The split is the one the flag already draws everywhere else: destruction is |
| +// refused outright; the WRITES a person legitimately performs stay open to a |
| +// person and closed to a CI credential. |
| + |
| +// cpInstanceFixture is controlPlaneFixture with the instance feature enabled |
| +// on the flagged `console` app and on an ordinary `widget` beside it, so |
| +// every test below can assert the refusal AND that a tenant's identical call |
| +// is untouched by it. |
| +// |
| +// Custom domains deliberately, not the platform apex: the sibling-apex rail |
| +// in handleAPIInstanceCreate only fires inside <sqid>.<AppsDomain>, and these |
| +// tests are about the flag, not about that rail. |
| +func cpInstanceFixture(t *testing.T, label string) (*consoleFixture, string) { |
| + t.Helper() |
| + fx, token := controlPlaneFixture(t, label) |
| + enableInstances(t, fx, "console", provision.Manifest{Schema: 1, App: "console", |
| + Domain: "cp.tito.io", Pool: "tito", Channel: "stable", Regions: []string{"ie"}}) |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + enableInstances(t, fx, "widget", provision.Manifest{Schema: 1, App: "widget", |
| + Domain: "widget.tito.io", Pool: "tito", Channel: "stable", Regions: []string{"ie"}}) |
| + return fx, token |
| +} |
| + |
| +// TestControlPlaneAppRefusesInstanceDelete is the whole reason this slice |
| +// exists. Deleting the console's instance record is not an edit to a bucket |
| +// object — it is the instruction the reconciler's removal phase acts on, and |
| +// the act it performs is retiring the control plane's route. Refused |
| +// OUTRIGHT, for a person as much as for a service: the same class as trash, |
| +// purge and store-destroy, and the same way out (clear the flag first). |
| +func TestControlPlaneAppRefusesInstanceDelete(t *testing.T) { |
| + fx, token := cpInstanceFixture(t, "code-cp-inst-del") |
| + u := instancesURL(fx, "bab", "console") |
| + |
| + // The record exists first — created by the person the next test proves |
| + // may create it — so the refusal below is refusing a delete that would |
| + // otherwise have succeeded. |
| + if status, _, out := rawReq(t, http.MethodPost, u, token, |
| + `{"host":"box1.cp.tito.io","region":"ie"}`); status != http.StatusCreated { |
| + t.Fatalf("create = %d %s", status, out) |
| + } |
| + |
| + status, _, out := rawReq(t, http.MethodDelete, u+"/box1.cp.tito.io", token, "") |
| + if status != http.StatusForbidden { |
| + t.Fatalf("a PERSON deleting the control plane's instance record = %d %s, want 403", status, out) |
| + } |
| + if !strings.Contains(out, "control plane") { |
| + t.Fatalf("refusal %q does not name the flag the operator has to clear", out) |
| + } |
| + if !strings.Contains(out, "/a/bab/apps/console/settings") { |
| + t.Fatalf("refusal %q does not point at the settings card that can clear the flag", out) |
| + } |
| + // The record survived. This is the assertion the door exists for: the |
| + // reconciler never sees the record go, so the route is never retired. |
| + if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "console", "box1.cp.tito.io"); err != nil { |
| + t.Fatalf("the control plane's instance record did not survive a refused delete: %v", err) |
| + } |
| + |
| + // A service credential holding the role this door grants meets the same |
| + // wall — it is refused for what the app IS, not for what the caller is. |
| + svc := serviceToken(t, fx.st, OpsAccountID, "ci", "console", svcrole.Instance) |
| + if status, _, out := rawReq(t, http.MethodDelete, u+"/box1.cp.tito.io", svc, ""); status != http.StatusForbidden { |
| + t.Fatalf("a SERVICE deleting the control plane's instance record = %d %s, want 403", status, out) |
| + } |
| + |
| + // A traversal host on the flagged app is refused by the flag, not by the |
| + // shape check: nothing below the guard runs for a control-plane app. |
| + if status, _, out := rawReq(t, http.MethodDelete, u+"/..%2f..%2finstances.json", token, ""); status != http.StatusForbidden { |
| + t.Fatalf("traversal delete on a flagged app = %d %s, want the flag's 403", status, out) |
| + } |
| + |
| + // And an ORDINARY app deletes exactly as it always did. |
| + wu := instancesURL(fx, "bab", "widget") |
| + if status, _, out := rawReq(t, http.MethodPost, wu, token, |
| + `{"host":"box1.widget.tito.io","region":"ie"}`); status != http.StatusCreated { |
| + t.Fatalf("ordinary create = %d %s", status, out) |
| + } |
| + if status, _, out := rawReq(t, http.MethodDelete, wu+"/box1.widget.tito.io", token, ""); status != http.StatusOK { |
| + t.Fatalf("ordinary delete = %d %s, want 200 — the flag is not a fleet-wide freeze", status, out) |
| + } |
| +} |
| + |
| +// TestControlPlaneAppAllowsAPersonToCreateAndPatchItsInstance is the half |
| +// that must keep working, and the runbook step this whole slice unblocks: |
| +// the console has no instance record because it predates provisioning, and |
| +// `carlos deploy -app console` cannot resolve a host until a PERSON writes |
| +// one. A create gate here would close the door the flag exists to keep open. |
| +func TestControlPlaneAppAllowsAPersonToCreateAndPatchItsInstance(t *testing.T) { |
| + fx, token := cpInstanceFixture(t, "code-cp-inst-person") |
| + u := instancesURL(fx, "bab", "console") |
| + if status, _, out := rawReq(t, http.MethodPost, u, token, |
| + `{"host":"box1.cp.tito.io","region":"ie","kind":"service","addrs":["127.0.0.1:9001"]}`); status != http.StatusCreated { |
| + t.Fatalf("a PERSON creating the control plane's instance record = %d %s, want 201 — this is the runbook step", status, out) |
| + } |
| + if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "console", "box1.cp.tito.io"); err != nil { |
| + t.Fatalf("the record a person created is not there: %v", err) |
| + } |
| + if status, _, out := rawReq(t, http.MethodPatch, updateURL(fx, "bab", "console", "box1.cp.tito.io"), |
| + token, `{"addrs":["127.0.0.1:9002"]}`); status != http.StatusOK { |
| + t.Fatalf("a PERSON patching the control plane's instance record = %d %s, want 200", status, out) |
| + } |
| + rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "console", "box1.cp.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(rec.Addrs) != 1 || rec.Addrs[0] != "127.0.0.1:9002" { |
| + t.Fatalf("addrs after a person's patch = %v, want the requested one", rec.Addrs) |
| + } |
| +} |
| + |
| +// TestControlPlaneAppRefusesAServiceInstanceCreate: the record a create |
| +// writes is the thing the reconciler then owns, so minting one for the |
| +// control plane is acquiring authority over the console's route. A person |
| +// may; a CI credential may not. |
| +func TestControlPlaneAppRefusesAServiceInstanceCreate(t *testing.T) { |
| + fx, _ := cpInstanceFixture(t, "code-cp-inst-svc-create") |
| + svc := serviceToken(t, fx.st, OpsAccountID, "ci", "console", svcrole.Instance) |
| + status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "console"), svc, |
| + `{"host":"box2.cp.tito.io","region":"ie"}`) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("a SERVICE creating the control plane's instance record = %d %s, want 403", status, out) |
| + } |
| + if !strings.Contains(out, "a person must") { |
| + t.Fatalf("refusal %q does not say a person is the principal this needs", out) |
| + } |
| + if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "console", "box2.cp.tito.io"); err == nil { |
| + t.Fatal("a record exists after only a refused service create") |
| + } |
| + |
| + // The same credential shape, bound to an ordinary app, creates freely: |
| + // nothing about service provisioning became worse for a tenant. |
| + ws := serviceToken(t, fx.st, OpsAccountID, "ci-widget", "widget", svcrole.Instance) |
| + if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "widget"), ws, |
| + `{"host":"box2.widget.tito.io","region":"ie"}`); status != http.StatusCreated { |
| + t.Fatalf("a SERVICE creating an ORDINARY app's instance = %d %s, want 201", status, out) |
| + } |
| +} |
| + |
| +// TestControlPlaneAppRefusesAServiceInstancePatch: the provisioning |
| +// reconciler converges a route's Channel and Environment FROM this record on |
| +// every pass, so a PATCH repoints the control plane. Same split as create. |
| +func TestControlPlaneAppRefusesAServiceInstancePatch(t *testing.T) { |
| + fx, token := cpInstanceFixture(t, "code-cp-inst-svc-patch") |
| + u := instancesURL(fx, "bab", "console") |
| + if status, _, out := rawReq(t, http.MethodPost, u, token, |
| + `{"host":"box3.cp.tito.io","region":"ie","kind":"service","addrs":["127.0.0.1:9001"]}`); status != http.StatusCreated { |
| + t.Fatalf("create = %d %s", status, out) |
| + } |
| + svc := serviceToken(t, fx.st, OpsAccountID, "ci", "console", svcrole.Instance) |
| + status, _, out := rawReq(t, http.MethodPatch, updateURL(fx, "bab", "console", "box3.cp.tito.io"), svc, |
| + `{"addrs":["10.0.0.1:1"]}`) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("a SERVICE patching the control plane's instance record = %d %s, want 403", status, out) |
| + } |
| + if !strings.Contains(out, "a person must") { |
| + t.Fatalf("refusal %q does not say a person is the principal this needs", out) |
| + } |
| + rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "console", "box3.cp.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(rec.Addrs) != 1 || rec.Addrs[0] != "127.0.0.1:9001" { |
| + t.Fatalf("addrs after a refused service patch = %v, want the created pair untouched", rec.Addrs) |
| + } |
| + |
| + // An ordinary app's record is still a service's to patch. |
| + ws := serviceToken(t, fx.st, OpsAccountID, "ci-widget", "widget", svcrole.Instance) |
| + if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "widget"), ws, |
| + `{"host":"box3.widget.tito.io","region":"ie","kind":"service","addrs":["127.0.0.1:9001"]}`); status != http.StatusCreated { |
| + t.Fatalf("ordinary create = %d %s", status, out) |
| + } |
| + if status, _, out := rawReq(t, http.MethodPatch, updateURL(fx, "bab", "widget", "box3.widget.tito.io"), ws, |
| + `{"addrs":["127.0.0.1:9002"]}`); status != http.StatusOK { |
| + t.Fatalf("a SERVICE patching an ORDINARY app's instance = %d %s, want 200", status, out) |
| + } |
| +} |
| + |
| +// TestControlPlaneInstanceRefusalIsNotAnOracle: the three guards sit AFTER |
| +// the family's membership gate, so a stranger meets the byte-identical 404 |
| +// and never the refusal. A control-plane app must not be discoverable by |
| +// asking a door which sentence it answers with. |
| +func TestControlPlaneInstanceRefusalIsNotAnOracle(t *testing.T) { |
| + fx, _ := cpInstanceFixture(t, "code-cp-inst-oracle") |
| + eve := cliToken(t, fx, "eve@keymail.dev", "code-cp-inst-eve") |
| + want := canonicalNotFound(t) |
| + u := instancesURL(fx, "bab", "console") |
| + for _, c := range []struct{ what, method, url, body string }{ |
| + {"non-member create on a flagged app", http.MethodPost, u, `{"host":"box9.cp.tito.io","region":"ie"}`}, |
| + {"non-member patch on a flagged app", http.MethodPatch, u + "/box9.cp.tito.io", `{"addrs":[]}`}, |
| + {"non-member delete on a flagged app", http.MethodDelete, u + "/box9.cp.tito.io", ""}, |
| + } { |
| + status, h, out := rawReq(t, c.method, c.url, eve, c.body) |
| + assertNotFound(t, c.what, want, status, h, out) |
| + } |
| +} |
| + |
| +// --- The detach doors -------------------------------------------------------- |
| +// |
| +// The NINTH door, and the same fuse as the eighth: handleAPIDomainDetach's |
| +// first-route branch calls the very provision.DeleteRecord the instance |
| +// delete does, on the same app, and the route is registered |
| +// svcrole.Allow(svcrole.Publish, svcrole.Instance) — so a service credential |
| +// reaches it. Its browser twin is detachDomain, shared by |
| +// handleAppDomainDetach. |
| +// |
| +// Refused OUTRIGHT rather than only on the record-deleting branch. A |
| +// conditional guard would have to re-derive "does this claim mint the app's |
| +// route" ahead of the code that derives it, and the two would drift; and |
| +// releasing a hostname the fleet reaches the control plane by is not |
| +// something to do in passing. |
| + |
| +// cpDetachFixture attaches a hostname to `kass` through the shared core and |
| +// THEN sets the flag — the order a real control plane arrives in (the console |
| +// had its hostname long before anyone thought to mark it), and the only order |
| +// in which the attach half is reachable at all. |
| +// |
| +// The control in these tests is the SAME app with the flag cleared rather |
| +// than a sibling app, deliberately: it isolates the flag as the only variable |
| +// between a refusal and a 200, which a second app with its own routes and |
| +// claims could not do as cleanly. |
| +func cpDetachFixture(t *testing.T, label, host string) (*consoleFixture, string) { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + acct, err := fx.st.AccountBySqid(fx.sqid) |
| + if err != nil || acct == nil { |
| + t.Fatalf("fixture account %s: %v", fx.sqid, err) |
| + } |
| + if _, ref := fx.srv.attachDomain(context.Background(), acct, "kass", host, "", "", "paul@keymail.dev"); ref != nil { |
| + t.Fatalf("attach setup: refused %+v", ref) |
| + } |
| + if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + return fx, cliToken(t, fx, "paul@keymail.dev", label) |
| +} |
| + |
| +// TestControlPlaneAppRefusesDomainDetach: the API door. Refused for a person |
| +// and for BOTH service roles the route admits, the claim survives, and |
| +// clearing the flag is the only way through — which is what proves the |
| +// refusal is the flag's and nothing else's. |
| +func TestControlPlaneAppRefusesDomainDetach(t *testing.T) { |
| + const host = "docs.example.com" |
| + fx, token := cpDetachFixture(t, "code-cp-detach", host) |
| + u := fx.ts.URL + "/api/cli/apps/bab/kass/domains/" + host |
| + |
| + status, _, out := rawReq(t, http.MethodDelete, u, token, "") |
| + if status != http.StatusForbidden { |
| + t.Fatalf("a PERSON detaching a control-plane app's domain = %d %s, want 403", status, out) |
| + } |
| + if !strings.Contains(out, "control plane") { |
| + t.Fatalf("refusal %q does not name the flag the operator has to clear", out) |
| + } |
| + if !strings.Contains(out, "/a/bab/apps/kass/settings") { |
| + t.Fatalf("refusal %q does not point at the settings card that can clear the flag", out) |
| + } |
| + claimOf(t, fx, host) |
| + |
| + // Both roles the route admits, since either would otherwise walk round |
| + // the instance family's guard by taking this door instead. |
| + for _, role := range []svcrole.Role{svcrole.Publish, svcrole.Instance} { |
| + svc := serviceToken(t, fx.st, OpsAccountID, "ci-"+string(role), "kass", role) |
| + if status, _, out := rawReq(t, http.MethodDelete, u, svc, ""); status != http.StatusForbidden { |
| + t.Fatalf("a SERVICE (%s) detaching a control-plane app's domain = %d %s, want 403", role, status, out) |
| + } |
| + } |
| + claimOf(t, fx, host) |
| + |
| + // A malformed host answers the SAME 403, not the hostname check's 400: |
| + // on a flagged app this door's answer is constant, so it leaks nothing |
| + // about which hosts are even well-shaped here. |
| + if status, _, out := rawReq(t, http.MethodDelete, |
| + fx.ts.URL+"/api/cli/apps/bab/kass/domains/..%2f..%2fclaims", token, ""); status != http.StatusForbidden { |
| + t.Fatalf("malformed host on a flagged app = %d %s, want the flag's 403", status, out) |
| + } |
| + |
| + // Clear the flag and the identical call goes through. The flag is the |
| + // only variable between these two lines. |
| + if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", false); err != nil { |
| + t.Fatal(err) |
| + } |
| + status, _, out = rawReq(t, http.MethodDelete, u, token, "") |
| + if status != http.StatusOK || !strings.Contains(out, `"removed":true`) { |
| + t.Fatalf("detach once the flag is cleared = %d %s, want 200 removed true", status, out) |
| + } |
| + claimAbsent(t, fx, host) |
| +} |
| + |
| +// TestControlPlaneAppRefusesBrowserDomainDetach: the browser twin deletes the |
| +// same record through the same core, so a guard only on the API door would |
| +// leave the route retirable by a form post. Rendered as a page, like every |
| +// other browser refusal, and ahead of the claim read. |
| +func TestControlPlaneAppRefusesBrowserDomainDetach(t *testing.T) { |
| + const host = "docs.example.com" |
| + fx, _ := cpDetachFixture(t, "code-cp-detach-browser", host) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + status, _, body := postForm(t, c, fx.u("/apps/kass/domains/detach"), |
| + url.Values{"csrf": {csrf}, "host": {host}, "confirm": {host}}) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("owner detaching a control-plane app's domain in a browser = %d, want 403:\n%s", status, body) |
| + } |
| + if !strings.Contains(body, "control plane") { |
| + t.Fatalf("browser refusal does not name the flag:\n%s", body) |
| + } |
| + if !strings.Contains(body, "/a/bab/apps/kass/settings") { |
| + t.Fatalf("browser refusal does not point at the settings card that can clear the flag:\n%s", body) |
| + } |
| + claimOf(t, fx, host) |
| + |
| + // Cleared, the same form post lands. |
| + if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if status, loc, body := postForm(t, c, fx.u("/apps/kass/domains/detach"), |
| + url.Values{"csrf": {csrf}, "host": {host}, "confirm": {host}}); status != http.StatusSeeOther || |
| + loc != fx.path("/apps/kass/settings") { |
| + t.Fatalf("browser detach once the flag is cleared = %d -> %q, want 303 -> settings:\n%s", status, loc, body) |
| + } |
| + claimAbsent(t, fx, host) |
| +} |
| + |
| +// TestControlPlaneDomainDetachRefusalIsNotAnOracle: the guard sits after |
| +// domainsGate, so a stranger meets the family's byte-identical 404 and never |
| +// the sentence that would tell them this account holds a control-plane app. |
| +func TestControlPlaneDomainDetachRefusalIsNotAnOracle(t *testing.T) { |
| + const host = "docs.example.com" |
| + fx, _ := cpDetachFixture(t, "code-cp-detach-oracle", host) |
| + eve := cliToken(t, fx, "eve@keymail.dev", "code-cp-detach-eve") |
| + status, h, out := rawReq(t, http.MethodDelete, |
| + fx.ts.URL+"/api/cli/apps/bab/kass/domains/"+host, eve, "") |
| + assertNotFound(t, "non-member detach on a flagged app", canonicalNotFound(t), status, h, out) |
| + claimOf(t, fx, host) |
| +} |
| + |
| +// TestControlPlaneAppRefusesAServiceDomainAttach closes the CREATE half of |
| +// the provision-record lifecycle at the door that is not the instance one. |
| +// The first-route branch of `POST .../domains` writes a declared |
| +// provision.Record, which is the same authority transfer `POST .../instances` |
| +// refuses a service for — so a CI credential must not be able to acquire it |
| +// by taking the other door. |
| +// |
| +// Service-only, not outright: a PERSON attaching a domain to the control |
| +// plane is an ordinary operator act. Removal is the asymmetric one. |
| +// |
| +// Both service roles the route admits, and both request SHAPES — the alias |
| +// form and the `kind:"static"` first-route form — because the guard is |
| +// deliberately not conditional on which branch would have run. |
| +func TestControlPlaneAppRefusesAServiceDomainAttach(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + u := fx.ts.URL + "/api/cli/apps/bab/kass/domains" |
| + const host = "docs.example.com" |
| + |
| + for _, role := range []svcrole.Role{svcrole.Publish, svcrole.Instance} { |
| + svc := serviceToken(t, fx.st, OpsAccountID, "ci-"+string(role), "kass", role) |
| + for what, body := range map[string]string{ |
| + "alias form": attachBody(host, "kass.carlosapp.dev"), |
| + "first-route form": `{"host":"` + host + `","kind":"static"}`, |
| + } { |
| + status, _, out := rawReq(t, http.MethodPost, u, svc, body) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("a SERVICE (%s) attaching a domain to the control plane, %s = %d %s, want 403", |
| + role, what, status, out) |
| + } |
| + if !strings.Contains(out, "a person must") { |
| + t.Fatalf("refusal %q does not say a person is the principal this needs", out) |
| + } |
| + } |
| + claimAbsent(t, fx, host) |
| + } |
| + |
| + // The half that must keep working: a person attaches. |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-cp-attach") |
| + if status, _, out := rawReq(t, http.MethodPost, u, token, |
| + attachBody(host, "kass.carlosapp.dev")); status != http.StatusOK { |
| + t.Fatalf("a PERSON attaching a domain to the control plane = %d %s, want 200", status, out) |
| + } |
| + claimOf(t, fx, host) |
| + |
| + // A non-member still meets the family's byte-identical 404, never the |
| + // refusal: the guard sits after domainsGate, so it is not an oracle for |
| + // which accounts hold a control-plane app. |
| + eve := cliToken(t, fx, "eve@keymail.dev", "code-cp-attach-eve") |
| + status, h, out := rawReq(t, http.MethodPost, u, eve, attachBody("eve.example.com", "kass.carlosapp.dev")) |
| + assertNotFound(t, "non-member attach on a flagged app", canonicalNotFound(t), status, h, out) |
| + claimAbsent(t, fx, "eve.example.com") |
| + |
| + // The control: with the flag cleared, the identical service call lands. |
| + // The flag is the only variable between these two outcomes. |
| + if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", false); err != nil { |
| + t.Fatal(err) |
| + } |
| + svc := serviceToken(t, fx.st, OpsAccountID, "ci-cleared", "kass", svcrole.Publish) |
| + if status, _, out := rawReq(t, http.MethodPost, u, svc, |
| + attachBody("other.example.com", "kass.carlosapp.dev")); status != http.StatusOK { |
| + t.Fatalf("a SERVICE attaching once the flag is cleared = %d %s, want 200", status, out) |
| + } |
| + claimOf(t, fx, "other.example.com") |
| +} |