package console // The service-credential pane on the account settings page (server.go's // handleAccountSettings plus the three POSTs beside it) — the browser half // of what the API family (apiservices.go) and the CLI (services.go) already // do. // // The property the whole file turns on: REVOCATION MUST WORK WITHOUT A // TERMINAL. Someone racing a compromise at 3am has a browser and a phone, // and the pane is the only door they can reach. So revoke is tested end to // end — a credential that worked against a real API door stops working // after the button — and it is tested to be reachable in one step, with no // passkey round trip in front of it. // // The other property is the one this repo pins everywhere: the ordered gate. // session → CSRF → owner (byte-identical 404) → requireFreshAuth LAST. A // non-member must never get a redirect that confirms an account exists. import ( "net/http" "net/url" "strings" "testing" "time" "github.com/carlosframework/platform/internal/svcrole" ) // paneDoor is one of the pane's three POSTs, with a form that would // otherwise be accepted — every gate assertion below has to be about the // gate, not about a request that would have been refused anyway. type paneDoor struct { name string path string form url.Values } func paneDoors() []paneDoor { return []paneDoor{ {"create", "/services/new", url.Values{"name": {"hub"}, "role": {"publish"}, "app": {""}}}, {"rotate", "/services/rotate", url.Values{"name": {"hub"}}}, {"revoke", "/services/revoke", url.Values{"handle": {"hub"}}}, } } // withCSRF copies a door's form and stamps this browser's own token on it. func withCSRF(form url.Values, csrf string) url.Values { out := url.Values{} for k, v := range form { out[k] = v } out.Set("csrf", csrf) return out } // signInAs signs a fresh browser in as address, and puts the fake identity // provider back to the fixture's owner afterwards so a later sign-in is not // silently somebody else. func signInAs(t *testing.T, fx *consoleFixture, address, code string) (*http.Client, string) { t.Helper() fx.setUser(address) defer fx.setUser("paul@keymail.dev") c := newBrowser(t) signUp(t, fx, c, code) return c, sessionToken(t, fx, c) } // TestServicePaneIsOwnerGatedInOrder — the ordered gate this repo pins: // session → CSRF → owner (byte-identical 404) → requireFreshAuth LAST. A // non-member must never get a redirect that confirms an account exists. // // The step-up half is deliberately asymmetric and that asymmetry is the // point: create and rotate hand out a working production credential and are // stepped up, revoke only REMOVES authority and is not. See the handlers' // own doc comments. func TestServicePaneIsOwnerGatedInOrder(t *testing.T) { fx := newConsoleFixture(t) if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish) owner := signInPaul(t, fx) ownerCSRF := sessionToken(t, fx, owner) member, memberCSRF := signInAs(t, fx, "kim@keymail.dev", "code-svcpane-member") stranger, strangerCSRF := signInAs(t, fx, "eve@keymail.dev", "code-svcpane-stranger") // The bytes an unrouted URL answers with. Every refusal below that is // meant to conceal the account has to be exactly these. _, _, unrouted := get(t, owner, fx.ts.URL+"/no/such/path") for _, d := range paneDoors() { u := fx.u(d.path) // SIGNED OUT: the front page, and nothing about this account. if status, loc, _ := postForm(t, newBrowser(t), u, d.form); status != http.StatusFound || loc != "/" { t.Errorf("%s signed out = %d -> %q, want 302 -> /", d.name, status, loc) } // NON-MEMBER and MEMBER: the byte-identical 404, and never a // redirect — a hop into Keymail would confirm the account exists to // one and confirm the action exists to the other. for _, who := range []struct { label string c *http.Client csrf string }{{"non-member", stranger, strangerCSRF}, {"member", member, memberCSRF}} { status, loc, body := postForm(t, who.c, u, withCSRF(d.form, who.csrf)) if status != http.StatusNotFound || body != unrouted { t.Errorf("%s as %s = %d %q, want the byte-identical 404 %q", d.name, who.label, status, body, unrouted) } if loc != "" { t.Errorf("%s as %s redirected to %q — a refusal that conceals must not hop anywhere", d.name, who.label, loc) } } // OWNER, BAD CSRF: refused before any pending action can be parked, // so no passkey trip and nothing stashed. status, loc, body := postForm(t, owner, u, withCSRF(d.form, "wrong")) if status != http.StatusForbidden || !strings.Contains(body, "Cross-site request blocked") { t.Errorf("%s with a bad CSRF token = %d, want 403 refused:\n%s", d.name, status, body) } if strings.HasPrefix(loc, fx.fk.srv.URL) { t.Errorf("%s with a bad CSRF token hopped into Keymail (%q) — CSRF must come first", d.name, loc) } } var stashed int fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions`).Scan(&stashed) if stashed != 0 { t.Errorf("%d pending actions parked by refused requests, want 0", stashed) } // OWNER, GOOD CSRF. create and rotate demand the passkey; revoke does // not, because the emergency control must not sit behind a round trip. for _, d := range paneDoors()[:2] { status, loc, body := postForm(t, owner, fx.u(d.path), withCSRF(d.form, ownerCSRF)) if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) { t.Errorf("%s as the owner = %d -> %q, want a redirect into Keymail:\n%s", d.name, status, loc, body) } } status, loc, body := postForm(t, owner, fx.u("/services/revoke"), withCSRF(url.Values{"handle": {"hub"}}, ownerCSRF)) if strings.HasPrefix(loc, fx.fk.srv.URL) { t.Fatalf("revoke demanded a passkey (-> %q) — the emergency control must be one step", loc) } if status != http.StatusSeeOther || loc != fx.path("/settings") { t.Fatalf("revoke as the owner = %d -> %q, want 303 back to the settings page:\n%s", status, loc, body) } } // TestRevokeFromThePageKillsTheCredential — end to end, which is the only // way this claim is worth anything: a credential that WORKED against a real // API door stops working the moment the button is pressed, and leaves the // listing with it. func TestRevokeFromThePageKillsTheCredential(t *testing.T) { fx := newConsoleFixture(t) tok := serviceToken(t, fx.st, OpsAccountID, "shipbot", "kass", svcrole.Operate) door := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/config" if status, body := getRaw(t, door, tok); status != http.StatusOK { t.Fatalf("the credential does not work before the button: %d %s", status, body) } c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) if _, _, body := get(t, c, fx.u("/settings")); !strings.Contains(body, "shipbot") { t.Fatalf("the pane does not list the credential it is about to revoke:\n%s", body) } status, loc, body := postForm(t, c, fx.u("/services/revoke"), url.Values{"csrf": {csrf}, "handle": {"shipbot"}}) if status != http.StatusSeeOther || loc != fx.path("/settings") { t.Fatalf("revoke = %d -> %q:\n%s", status, loc, body) } if status, body := getRaw(t, door, tok); status != http.StatusUnauthorized { t.Fatalf("the credential still works after the button: %d %s", status, body) } // Two views, because the first one carries the flash — which NAMES the // credential it killed, exactly as it should. The listing assertion has // to be made against a view with no flash on it, or it would be reading // the confirmation and calling it a row. if _, _, flashed := get(t, c, fx.u("/settings")); !strings.Contains(flashed, "Revoked") { t.Fatalf("the page does not say the revoke landed:\n%s", flashed) } _, _, page := get(t, c, fx.u("/settings")) if strings.Contains(page, "shipbot") { t.Fatalf("a revoked credential is still listed:\n%s", page) } } // TestRevokeSaysWhenNothingMatched — the same distinction the CLI draws // between "revoked" and "nothing to revoke". A page that says "revoked" // when a stale handle matched nothing is a false all-clear at the one door // used in anger. func TestRevokeSaysWhenNothingMatched(t *testing.T) { fx := newConsoleFixture(t) serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) // A typo. Idempotent — it is not an error — but it must not read as a kill. postForm(t, c, fx.u("/services/revoke"), url.Values{"csrf": {csrf}, "handle": {"hubb"}}) _, _, body := get(t, c, fx.u("/settings")) if strings.Contains(body, "Revoked") { t.Fatalf("a handle that matched nothing reported a revoke:\n%s", body) } if !strings.Contains(body, "Nothing live") { t.Fatalf("the page does not say the handle matched nothing:\n%s", body) } // And the real credential is untouched by the miss. if rows, err := fx.st.ServicePrincipals(OpsAccountID); err != nil || len(rows) != 1 { t.Fatalf("ServicePrincipals = %d rows %v after a missed revoke, want the credential still live", len(rows), err) } // The second revoke of a name the first one killed says the same thing. postForm(t, c, fx.u("/services/revoke"), url.Values{"csrf": {csrf}, "handle": {"hub"}}) if _, _, first := get(t, c, fx.u("/settings")); !strings.Contains(first, "Revoked") { t.Fatalf("the first revoke did not report a kill:\n%s", first) } postForm(t, c, fx.u("/services/revoke"), url.Values{"csrf": {csrf}, "handle": {"hub"}}) _, _, second := get(t, c, fx.u("/settings")) if strings.Contains(second, "Revoked") { t.Fatalf("the second revoke of the same handle reported a kill:\n%s", second) } } // TestPaneRevokesALegacyAppTokenByItsListingID — a legacy kind='app' row has // no name, so the listing id is the only handle it will ever have. Without // this an owner could SEE such a credential on the pane and have no way to // kill it from there. func TestPaneRevokesALegacyAppTokenByItsListingID(t *testing.T) { fx := newConsoleFixture(t) tok, err := fx.st.NewAppToken(OpsAccountID, "kass", "paul@keymail.dev", "router", time.Now()) if err != nil { t.Fatal(err) } rows, err := fx.st.ServicePrincipals(OpsAccountID) if err != nil || len(rows) != 1 { t.Fatalf("ServicePrincipals = %d rows %v, want the legacy row", len(rows), err) } id := rows[0].ID c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) _, _, page := get(t, c, fx.u("/settings")) // The label is the only distinguisher a nameless row has beyond its app. if !strings.Contains(page, "router") { t.Fatalf("the pane does not show the legacy row's label:\n%s", page) } if !strings.Contains(page, id) { t.Fatalf("the pane offers no handle for the nameless row:\n%s", page) } if status, _, body := postForm(t, c, fx.u("/services/revoke"), url.Values{"csrf": {csrf}, "handle": {id}}); status != http.StatusSeeOther { t.Fatalf("revoke by listing id = %d:\n%s", status, body) } if _, ok := fx.st.CLIPrincipal(tok, time.Now()); ok { t.Fatal("the legacy app token still resolves after being revoked from the pane") } } // TestThePageNeverRendersASecret — the listing is built from stored rows, // and only a credential's sha256 is stored, so there is nothing in it to // leak even in principle. This asserts that where it can be seen: the // plaintext of a live credential is not on the page that lists it. func TestThePageNeverRendersASecret(t *testing.T) { fx := newConsoleFixture(t) a := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish) b := serviceToken(t, fx.st, OpsAccountID, "shipbot", "kass", svcrole.Operate) legacy, err := fx.st.NewAppToken(OpsAccountID, "kass", "paul@keymail.dev", "router", time.Now()) if err != nil { t.Fatal(err) } c := signInPaul(t, fx) status, _, body := get(t, c, fx.u("/settings")) if status != http.StatusOK { t.Fatalf("GET the account settings page = %d", status) } if !strings.Contains(body, "hub") || !strings.Contains(body, "shipbot") { t.Fatalf("the pane does not list the credentials it is meant to:\n%s", body) } for _, secret := range []string{a, b, legacy} { if strings.Contains(body, secret) { t.Fatalf("the account settings page rendered a live credential's plaintext") } } } // TestTheFreshSecretAppearsOnceAndOnlyInItsOwnResponse — the once-only // discipline handleFleetRotateBoxPage established: the plaintext is rendered // DIRECTLY in the response to the POST that minted it, never redirected to // and never put in a flash cookie, which would write a live credential into // the browser's cookie jar and hand it out again on the next page view. func TestTheFreshSecretAppearsOnceAndOnlyInItsOwnResponse(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) status, _, body := postGated(t, fx, c, fx.u("/services/new"), url.Values{"csrf": {csrf}, "name": {"hub"}, "role": {"publish"}, "app": {""}}) if status != http.StatusOK { t.Fatalf("create = %d:\n%s", status, body) } secret := paneSecret(t, body) if _, ok := fx.st.CLIPrincipal(secret, time.Now()); !ok { t.Fatal("the secret the page showed does not resolve — it is not the credential's own") } // C47: the page answers 200 rather than redirecting, because a redirect // would have to carry the plaintext somewhere the next request could read // it. That trade is accepted — and the honest half of accepting it is // saying, next to the secret, what a reload does. Create's reload is // refused by the name's uniqueness, so the secret above survives it. if !strings.Contains(body, "Reloading this page") { t.Fatalf("the mint response does not say what a reload does:\n%s", body) } if strings.Count(body, secret) != 1 { t.Fatalf("the fresh secret appears %d times in its own response, want exactly 1", strings.Count(body, secret)) } if hdr := paneCacheControl(t, c, fx, csrf); !strings.Contains(hdr, "no-store") { t.Fatalf("Cache-Control on a response carrying a credential = %q, want no-store", hdr) } // Never in the cookie jar: a flash cookie is storage the browser keeps. for _, ck := range c.Jar.Cookies(mustURL(t, fx.ts.URL)) { if strings.Contains(ck.Value, secret) { t.Fatalf("cookie %q carries the fresh secret", ck.Name) } } // And gone from the very next view of the same page. if _, _, again := get(t, c, fx.u("/settings")); strings.Contains(again, secret) { t.Fatalf("the secret survived into a later view of the page:\n%s", again) } // Rotate shows the NEW secret once, and the old one stops resolving. status, _, body = postGated(t, fx, c, fx.u("/services/rotate"), url.Values{"csrf": {csrf}, "name": {"hub"}}) if status != http.StatusOK { t.Fatalf("rotate = %d:\n%s", status, body) } fresh := paneSecret(t, body) if fresh == secret { t.Fatal("rotate showed the same secret again") } // C47, the sharp end: a reload here rotates AGAIN and retires the secret // the operator has just pasted into a config. An operator must not // discover that by losing it. if !strings.Contains(body, "Reloading this page rotates again") { t.Fatalf("the rotate response does not warn that a reload retires this secret:\n%s", body) } // Minor 4: an operator re-arming a credential is shown WHAT they are // re-arming. The facts come from findServiceCredential's read-back, so // rendering them is also what makes a degraded read visible rather than // silently absent. // // Asserted against the SECRET BLOCK, not the whole page: the listing and // the mint form both mention every role and the words "the whole // account", so a page-wide Contains would pass without the block saying // anything at all. block := paneSecretBlock(t, body) for _, want := range []string{"publish", "the whole account"} { if !strings.Contains(block, want) { t.Fatalf("the rotate block does not name the credential's %q:\n%s", want, block) } } if _, ok := fx.st.CLIPrincipal(secret, time.Now()); ok { t.Fatal("the pre-rotation secret still resolves") } if _, ok := fx.st.CLIPrincipal(fresh, time.Now()); !ok { t.Fatal("the rotated secret does not resolve") } } // paneSecret pulls the plaintext out of the once-only block. The page prints // it on its own CARLOS_SERVICE_TOKEN= line, the same shape the CLI prints. func paneSecret(t *testing.T, body string) string { t.Helper() const marker = "CARLOS_SERVICE_TOKEN=" i := strings.Index(body, marker) if i < 0 { t.Fatalf("no secret in the response:\n%s", body) } rest := body[i+len(marker):] j := strings.IndexAny(rest, "<\n\r ") if j < 0 { t.Fatalf("the secret is not delimited:\n%s", body) } secret := strings.TrimSpace(rest[:j]) if secret == "" { t.Fatalf("empty secret in:\n%s", body) } return secret } // paneSecretBlock slices out the banner the fresh secret is rendered in, so a // claim about what the OPERATOR IS TOLD at the moment of minting is not // satisfied by the listing or the mint form further down the same page. func paneSecretBlock(t *testing.T, body string) string { t.Helper() i := strings.Index(body, "CARLOS_SERVICE_TOKEN=") if i < 0 { t.Fatalf("no secret in the response:\n%s", body) } start := strings.LastIndex(body[:i], `
") if start < 0 || end < 0 { t.Fatalf("the secret is not inside a banner block:\n%s", body) } return body[start : i+end] } // paneCacheControl mints one more credential purely to read the response // header off it — the header is the property under test, and the body is // already covered above. func paneCacheControl(t *testing.T, c *http.Client, fx *consoleFixture, csrf string) string { t.Helper() res, err := c.PostForm(fx.u("/services/new"), url.Values{"csrf": {csrf}, "name": {"second"}, "role": {"operate"}, "app": {""}}) if err != nil { t.Fatal(err) } defer res.Body.Close() return res.Header.Get("Cache-Control") } func mustURL(t *testing.T, raw string) *url.URL { t.Helper() u, err := url.Parse(raw) if err != nil { t.Fatal(err) } return u } // TestTheServicePaneIsOwnerOnlyOnThePage — a MEMBER sees the account // settings page without this section rather than with an empty one. An // empty section reads as "this account has no service credentials", which // is a different and possibly false statement. func TestTheServicePaneIsOwnerOnlyOnThePage(t *testing.T) { fx := newConsoleFixture(t) if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish) owner := signInPaul(t, fx) if _, _, body := get(t, owner, fx.u("/settings")); !strings.Contains(body, "Service credentials") || !strings.Contains(body, "hub") { t.Fatalf("the owner's settings page has no service pane:\n%s", body) } member, _ := signInAs(t, fx, "kim@keymail.dev", "code-svcpane-owneronly") status, _, body := get(t, member, fx.u("/settings")) if status != http.StatusOK { t.Fatalf("member GET settings = %d", status) } if strings.Contains(body, "Service credentials") || strings.Contains(body, "hub") { t.Fatalf("a member's settings page rendered the service pane:\n%s", body) } } // TestPersonalSettingsMirrorsServiceCredentialsReadOnly — absorbing a // credential into the account listing must not make it VANISH from a page // people already know. It stays on /settings, read-only, pointing at the // account pane that manages it. func TestPersonalSettingsMirrorsServiceCredentialsReadOnly(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.st.NewServicePrincipal(OpsAccountID, "hub", "", svcrole.Publish, "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } c := signInPaul(t, fx) status, _, body := get(t, c, fx.ts.URL+"/settings") if status != http.StatusOK { t.Fatalf("GET /settings = %d", status) } if !strings.Contains(body, "hub") { t.Fatalf("the credential vanished from the page its minter knows:\n%s", body) } if !strings.Contains(body, "/a/"+fx.sqid+"/settings") || !strings.Contains(body, "manage there") { t.Fatalf("/settings does not point at the account pane that manages it:\n%s", body) } if strings.Contains(body, `action="/auth/cli/tokens/revoke"`) { t.Fatalf("/settings offers a revoke button for a service credential — the mirror is read-only:\n%s", body) } } // TestPersonalSettingsStillRevokesALegacyAppToken is the other half of the // mirror rule, and the reason the read-only treatment keys on IsService and // not on "anything that is not a person": an app token has always been // revocable from this page, and its minter may not be an account owner. func TestPersonalSettingsStillRevokesALegacyAppToken(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.st.NewAppToken(OpsAccountID, "kass", "paul@keymail.dev", "router", time.Now()); err != nil { t.Fatal(err) } c := signInPaul(t, fx) _, _, body := get(t, c, fx.ts.URL+"/settings") if !strings.Contains(body, `action="/auth/cli/tokens/revoke"`) { t.Fatalf("/settings no longer offers to revoke an app token:\n%s", body) } } // TestPersonalSettingsCannotHandPostAServiceRevoke (C49) — the mirror is // read-only in FACT, not only in presentation. // // Withholding the button from the template is not a control: POST // /auth/cli/tokens/revoke accepts any listing id whose row carries the // viewer's address, and a service credential's address column IS its minter. // Worse than the pane's own revoke in three ways at once — RevokeCLIToken is // a hard DELETE rather than the revoked_at tombstone, it writes no account // audit row, and it survives the minter losing ownership. An owner who minted // a credential and was later transferred out could still hand-post its id, // stop a production service, and leave the account's activity feed silent. // // It is refused BEFORE the passkey: no gesture could make this the right // door, so asking for one would waste it. func TestPersonalSettingsCannotHandPostAServiceRevoke(t *testing.T) { fx := newConsoleFixture(t) secret, err := fx.st.NewServicePrincipal(OpsAccountID, "hub", "", svcrole.Publish, "paul@keymail.dev", time.Now()) if err != nil { t.Fatal(err) } toks, err := fx.st.CLITokens("paul@keymail.dev") if err != nil || len(toks) != 1 { t.Fatalf("CLITokens = %d rows %v, want the service row", len(toks), err) } id := toks[0].ID c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) // postForm, not postGated: a hop into Keymail here would itself be the // bug — the refusal must cost nothing. status, loc, body := postForm(t, c, fx.ts.URL+"/auth/cli/tokens/revoke", url.Values{"csrf": {csrf}, "id": {id}}) if strings.HasPrefix(loc, fx.fk.srv.URL) { t.Fatalf("the refusal cost a passkey trip (-> %q)", loc) } if status != http.StatusSeeOther || loc != "/settings" { t.Fatalf("hand-posted service revoke = %d -> %q, want 303 back to /settings:\n%s", status, loc, body) } if _, ok := fx.st.CLIPrincipal(secret, time.Now()); !ok { t.Fatal("a hand-posted /settings revoke killed a service credential") } // And the row is still THERE — RevokeCLIToken is a hard DELETE, so a // credential that went through it would be gone rather than tombstoned. if rows, err := fx.st.ServicePrincipals(OpsAccountID); err != nil || len(rows) != 1 { t.Fatalf("ServicePrincipals = %d rows %v, want the credential untouched", len(rows), err) } _, _, page := get(t, c, fx.ts.URL+"/settings") if !strings.Contains(page, "is a service credential") { t.Fatalf("/settings does not say why the revoke was refused:\n%s", page) } } // TestPersonalSettingsRefusesAnAppTokenRevokeAfterRemoval (#219 item 3): the // legacy app-token revoke on /settings is scoped to the minter's address, so // it outlived their membership. Somebody removed from the account must not be // able to stop that account's app by revoking a credential they once minted. // Refused before the passkey: no gesture would change the answer. func TestPersonalSettingsRefusesAnAppTokenRevokeAfterRemoval(t *testing.T) { fx := newConsoleFixture(t) otherID := testAccountID() if err := fx.st.CreateAccount(otherID, accountSqid(otherID), "Sam's", "sam@keymail.dev", time.Now()); err != nil { t.Fatal(err) } if err := fx.st.AddMember(otherID, "paul@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } secret, err := fx.st.NewAppToken(otherID, "kass", "paul@keymail.dev", "router", time.Now()) if err != nil { t.Fatal(err) } toks, err := fx.st.CLITokens("paul@keymail.dev") if err != nil || len(toks) != 1 { t.Fatalf("CLITokens = %d rows %v, want the app token", len(toks), err) } id := toks[0].ID if err := fx.st.RemoveMember(otherID, "paul@keymail.dev"); err != nil { t.Fatal(err) } c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) status, loc, body := postForm(t, c, fx.ts.URL+"/auth/cli/tokens/revoke", url.Values{"csrf": {csrf}, "id": {id}}) if strings.HasPrefix(loc, fx.fk.srv.URL) { t.Fatalf("the refusal cost a passkey trip (-> %q)", loc) } if status != http.StatusSeeOther || loc != "/settings" { t.Fatalf("revoke after removal = %d -> %q, want 303 back to /settings:\n%s", status, loc, body) } if _, ok := fx.st.CLIPrincipal(secret, time.Now()); !ok { t.Fatal("a removed member revoked the account's app token") } _, _, page := get(t, c, fx.ts.URL+"/settings") if !strings.Contains(page, "no longer a member") { t.Fatalf("/settings does not say why the revoke was refused:\n%s", page) } } // TestPaneMintRefusalsCostNoPasskey — a bad name or an unknown role is the // caller's slip, and no gesture could change the answer, so it is refused // BEFORE the passkey. Same reasoning handleStoreDestroy's "no backend" // refusal carries. func TestPaneMintRefusalsCostNoPasskey(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) for _, bad := range []struct { why string form url.Values }{ {"an unusable name", url.Values{"csrf": {csrf}, "name": {"Hub Two"}, "role": {"publish"}}}, {"an unknown role", url.Values{"csrf": {csrf}, "name": {"hub"}, "role": {"root"}}}, {"an app that is not here", url.Values{"csrf": {csrf}, "name": {"hub"}, "role": {"publish"}, "app": {"ghost"}}}, } { status, loc, body := postForm(t, c, fx.u("/services/new"), bad.form) if strings.HasPrefix(loc, fx.fk.srv.URL) { t.Errorf("create with %s cost a passkey trip (-> %q)", bad.why, loc) } if status != http.StatusSeeOther || loc != fx.path("/settings") { t.Errorf("create with %s = %d -> %q, want 303 back to the page:\n%s", bad.why, status, loc, body) } } if rows, err := fx.st.ServicePrincipals(OpsAccountID); err != nil || len(rows) != 0 { t.Fatalf("ServicePrincipals = %d rows %v after three refused mints, want none", len(rows), err) } } // TestPaneMintIsAudited — the pane's writes belong in the same trail the API // and the CLI write to, filed the same way (serviceAuditKey: an app-scoped // credential's grant belongs in THAT APP's feed). func TestPaneMintIsAudited(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) if status, _, body := postGated(t, fx, c, fx.u("/services/new"), url.Values{"csrf": {csrf}, "name": {"shipbot"}, "role": {"operate"}, "app": {"kass"}}); status != http.StatusOK { t.Fatalf("create = %d:\n%s", status, body) } events, err := fx.st.Events(OpsAccountID, "kass", 20) if err != nil { t.Fatal(err) } var found bool for _, e := range events { if e.Action == "service-create" && strings.Contains(e.Detail, "shipbot") { found = true if e.Address != "paul@keymail.dev" { t.Errorf("audit actor = %q, want the member who pressed the button", e.Address) } if !strings.Contains(e.Detail, "console") { t.Errorf("audit detail %q does not say which door it came through", e.Detail) } } } if !found { t.Fatalf("no service-create event on the app the credential was scoped to: %+v", events) } } // The FORM is the door that offers an account-wide credential, so it is the // one most likely to meet the Service-role refusal — and the one where // "could not mint, try again" is worst, because the owner would retry the same // impossible request forever. Enforcement lives in NewServicePrincipal and // held either way; this is about the sentence the owner reads. func TestPaneSaysWhyAServiceCredentialNeedsAnApp(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) status, loc, body := postForm(t, c, fx.u("/services/new"), url.Values{"csrf": {csrf}, "name": {"scanbot"}, "role": {"service"}, "app": {""}}) if status != http.StatusSeeOther || loc != fx.path("/settings") { t.Fatalf("account-wide service mint = %d -> %q, want 303 back to the page:\n%s", status, loc, body) } if rows, err := fx.st.ServicePrincipals(OpsAccountID); err != nil || len(rows) != 0 { t.Fatalf("ServicePrincipals = %d rows %v, want the mint refused", len(rows), err) } // The page has to SAY it, not log it: the generic failure would send the // owner round the same loop. _, _, page := get(t, c, fx.u("/settings")) if !strings.Contains(page, "must name one app") { t.Fatalf("the settings page does not explain the refusal:\n%s", page) } }