| 1 | package console |
| 2 | |
| 3 | // The service-credential pane on the account settings page (server.go's |
| 4 | // handleAccountSettings plus the three POSTs beside it) — the browser half |
| 5 | // of what the API family (apiservices.go) and the CLI (services.go) already |
| 6 | // do. |
| 7 | // |
| 8 | // The property the whole file turns on: REVOCATION MUST WORK WITHOUT A |
| 9 | // TERMINAL. Someone racing a compromise at 3am has a browser and a phone, |
| 10 | // and the pane is the only door they can reach. So revoke is tested end to |
| 11 | // end — a credential that worked against a real API door stops working |
| 12 | // after the button — and it is tested to be reachable in one step, with no |
| 13 | // passkey round trip in front of it. |
| 14 | // |
| 15 | // The other property is the one this repo pins everywhere: the ordered gate. |
| 16 | // session → CSRF → owner (byte-identical 404) → requireFreshAuth LAST. A |
| 17 | // non-member must never get a redirect that confirms an account exists. |
| 18 | |
| 19 | import ( |
| 20 | "net/http" |
| 21 | "net/url" |
| 22 | "strings" |
| 23 | "testing" |
| 24 | "time" |
| 25 | |
| 26 | "github.com/carlosframework/platform/internal/svcrole" |
| 27 | ) |
| 28 | |
| 29 | // paneDoor is one of the pane's three POSTs, with a form that would |
| 30 | // otherwise be accepted — every gate assertion below has to be about the |
| 31 | // gate, not about a request that would have been refused anyway. |
| 32 | type paneDoor struct { |
| 33 | name string |
| 34 | path string |
| 35 | form url.Values |
| 36 | } |
| 37 | |
| 38 | func paneDoors() []paneDoor { |
| 39 | return []paneDoor{ |
| 40 | {"create", "/services/new", url.Values{"name": {"hub"}, "role": {"publish"}, "app": {""}}}, |
| 41 | {"rotate", "/services/rotate", url.Values{"name": {"hub"}}}, |
| 42 | {"revoke", "/services/revoke", url.Values{"handle": {"hub"}}}, |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | // withCSRF copies a door's form and stamps this browser's own token on it. |
| 47 | func withCSRF(form url.Values, csrf string) url.Values { |
| 48 | out := url.Values{} |
| 49 | for k, v := range form { |
| 50 | out[k] = v |
| 51 | } |
| 52 | out.Set("csrf", csrf) |
| 53 | return out |
| 54 | } |
| 55 | |
| 56 | // signInAs signs a fresh browser in as address, and puts the fake identity |
| 57 | // provider back to the fixture's owner afterwards so a later sign-in is not |
| 58 | // silently somebody else. |
| 59 | func signInAs(t *testing.T, fx *consoleFixture, address, code string) (*http.Client, string) { |
| 60 | t.Helper() |
| 61 | fx.setUser(address) |
| 62 | defer fx.setUser("paul@keymail.dev") |
| 63 | c := newBrowser(t) |
| 64 | signUp(t, fx, c, code) |
| 65 | return c, sessionToken(t, fx, c) |
| 66 | } |
| 67 | |
| 68 | // TestServicePaneIsOwnerGatedInOrder — the ordered gate this repo pins: |
| 69 | // session → CSRF → owner (byte-identical 404) → requireFreshAuth LAST. A |
| 70 | // non-member must never get a redirect that confirms an account exists. |
| 71 | // |
| 72 | // The step-up half is deliberately asymmetric and that asymmetry is the |
| 73 | // point: create and rotate hand out a working production credential and are |
| 74 | // stepped up, revoke only REMOVES authority and is not. See the handlers' |
| 75 | // own doc comments. |
| 76 | func TestServicePaneIsOwnerGatedInOrder(t *testing.T) { |
| 77 | fx := newConsoleFixture(t) |
| 78 | if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish) |
| 82 | |
| 83 | owner := signInPaul(t, fx) |
| 84 | ownerCSRF := sessionToken(t, fx, owner) |
| 85 | member, memberCSRF := signInAs(t, fx, "kim@keymail.dev", "code-svcpane-member") |
| 86 | stranger, strangerCSRF := signInAs(t, fx, "eve@keymail.dev", "code-svcpane-stranger") |
| 87 | |
| 88 | // The bytes an unrouted URL answers with. Every refusal below that is |
| 89 | // meant to conceal the account has to be exactly these. |
| 90 | _, _, unrouted := get(t, owner, fx.ts.URL+"/no/such/path") |
| 91 | |
| 92 | for _, d := range paneDoors() { |
| 93 | u := fx.u(d.path) |
| 94 | |
| 95 | // SIGNED OUT: the front page, and nothing about this account. |
| 96 | if status, loc, _ := postForm(t, newBrowser(t), u, d.form); status != http.StatusFound || loc != "/" { |
| 97 | t.Errorf("%s signed out = %d -> %q, want 302 -> /", d.name, status, loc) |
| 98 | } |
| 99 | |
| 100 | // NON-MEMBER and MEMBER: the byte-identical 404, and never a |
| 101 | // redirect — a hop into Keymail would confirm the account exists to |
| 102 | // one and confirm the action exists to the other. |
| 103 | for _, who := range []struct { |
| 104 | label string |
| 105 | c *http.Client |
| 106 | csrf string |
| 107 | }{{"non-member", stranger, strangerCSRF}, {"member", member, memberCSRF}} { |
| 108 | status, loc, body := postForm(t, who.c, u, withCSRF(d.form, who.csrf)) |
| 109 | if status != http.StatusNotFound || body != unrouted { |
| 110 | t.Errorf("%s as %s = %d %q, want the byte-identical 404 %q", |
| 111 | d.name, who.label, status, body, unrouted) |
| 112 | } |
| 113 | if loc != "" { |
| 114 | t.Errorf("%s as %s redirected to %q — a refusal that conceals must not hop anywhere", |
| 115 | d.name, who.label, loc) |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | // OWNER, BAD CSRF: refused before any pending action can be parked, |
| 120 | // so no passkey trip and nothing stashed. |
| 121 | status, loc, body := postForm(t, owner, u, withCSRF(d.form, "wrong")) |
| 122 | if status != http.StatusForbidden || !strings.Contains(body, "Cross-site request blocked") { |
| 123 | t.Errorf("%s with a bad CSRF token = %d, want 403 refused:\n%s", d.name, status, body) |
| 124 | } |
| 125 | if strings.HasPrefix(loc, fx.fk.srv.URL) { |
| 126 | t.Errorf("%s with a bad CSRF token hopped into Keymail (%q) — CSRF must come first", d.name, loc) |
| 127 | } |
| 128 | } |
| 129 | var stashed int |
| 130 | fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions`).Scan(&stashed) |
| 131 | if stashed != 0 { |
| 132 | t.Errorf("%d pending actions parked by refused requests, want 0", stashed) |
| 133 | } |
| 134 | |
| 135 | // OWNER, GOOD CSRF. create and rotate demand the passkey; revoke does |
| 136 | // not, because the emergency control must not sit behind a round trip. |
| 137 | for _, d := range paneDoors()[:2] { |
| 138 | status, loc, body := postForm(t, owner, fx.u(d.path), withCSRF(d.form, ownerCSRF)) |
| 139 | if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) { |
| 140 | t.Errorf("%s as the owner = %d -> %q, want a redirect into Keymail:\n%s", d.name, status, loc, body) |
| 141 | } |
| 142 | } |
| 143 | status, loc, body := postForm(t, owner, fx.u("/services/revoke"), |
| 144 | withCSRF(url.Values{"handle": {"hub"}}, ownerCSRF)) |
| 145 | if strings.HasPrefix(loc, fx.fk.srv.URL) { |
| 146 | t.Fatalf("revoke demanded a passkey (-> %q) — the emergency control must be one step", loc) |
| 147 | } |
| 148 | if status != http.StatusSeeOther || loc != fx.path("/settings") { |
| 149 | t.Fatalf("revoke as the owner = %d -> %q, want 303 back to the settings page:\n%s", status, loc, body) |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | // TestRevokeFromThePageKillsTheCredential — end to end, which is the only |
| 154 | // way this claim is worth anything: a credential that WORKED against a real |
| 155 | // API door stops working the moment the button is pressed, and leaves the |
| 156 | // listing with it. |
| 157 | func TestRevokeFromThePageKillsTheCredential(t *testing.T) { |
| 158 | fx := newConsoleFixture(t) |
| 159 | tok := serviceToken(t, fx.st, OpsAccountID, "shipbot", "kass", svcrole.Operate) |
| 160 | door := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/config" |
| 161 | if status, body := getRaw(t, door, tok); status != http.StatusOK { |
| 162 | t.Fatalf("the credential does not work before the button: %d %s", status, body) |
| 163 | } |
| 164 | |
| 165 | c := signInPaul(t, fx) |
| 166 | csrf := sessionToken(t, fx, c) |
| 167 | if _, _, body := get(t, c, fx.u("/settings")); !strings.Contains(body, "shipbot") { |
| 168 | t.Fatalf("the pane does not list the credential it is about to revoke:\n%s", body) |
| 169 | } |
| 170 | status, loc, body := postForm(t, c, fx.u("/services/revoke"), |
| 171 | url.Values{"csrf": {csrf}, "handle": {"shipbot"}}) |
| 172 | if status != http.StatusSeeOther || loc != fx.path("/settings") { |
| 173 | t.Fatalf("revoke = %d -> %q:\n%s", status, loc, body) |
| 174 | } |
| 175 | if status, body := getRaw(t, door, tok); status != http.StatusUnauthorized { |
| 176 | t.Fatalf("the credential still works after the button: %d %s", status, body) |
| 177 | } |
| 178 | // Two views, because the first one carries the flash — which NAMES the |
| 179 | // credential it killed, exactly as it should. The listing assertion has |
| 180 | // to be made against a view with no flash on it, or it would be reading |
| 181 | // the confirmation and calling it a row. |
| 182 | if _, _, flashed := get(t, c, fx.u("/settings")); !strings.Contains(flashed, "Revoked") { |
| 183 | t.Fatalf("the page does not say the revoke landed:\n%s", flashed) |
| 184 | } |
| 185 | _, _, page := get(t, c, fx.u("/settings")) |
| 186 | if strings.Contains(page, "shipbot") { |
| 187 | t.Fatalf("a revoked credential is still listed:\n%s", page) |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | // TestRevokeSaysWhenNothingMatched — the same distinction the CLI draws |
| 192 | // between "revoked" and "nothing to revoke". A page that says "revoked" |
| 193 | // when a stale handle matched nothing is a false all-clear at the one door |
| 194 | // used in anger. |
| 195 | func TestRevokeSaysWhenNothingMatched(t *testing.T) { |
| 196 | fx := newConsoleFixture(t) |
| 197 | serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish) |
| 198 | c := signInPaul(t, fx) |
| 199 | csrf := sessionToken(t, fx, c) |
| 200 | |
| 201 | // A typo. Idempotent — it is not an error — but it must not read as a kill. |
| 202 | postForm(t, c, fx.u("/services/revoke"), url.Values{"csrf": {csrf}, "handle": {"hubb"}}) |
| 203 | _, _, body := get(t, c, fx.u("/settings")) |
| 204 | if strings.Contains(body, "Revoked") { |
| 205 | t.Fatalf("a handle that matched nothing reported a revoke:\n%s", body) |
| 206 | } |
| 207 | if !strings.Contains(body, "Nothing live") { |
| 208 | t.Fatalf("the page does not say the handle matched nothing:\n%s", body) |
| 209 | } |
| 210 | // And the real credential is untouched by the miss. |
| 211 | if rows, err := fx.st.ServicePrincipals(OpsAccountID); err != nil || len(rows) != 1 { |
| 212 | t.Fatalf("ServicePrincipals = %d rows %v after a missed revoke, want the credential still live", len(rows), err) |
| 213 | } |
| 214 | |
| 215 | // The second revoke of a name the first one killed says the same thing. |
| 216 | postForm(t, c, fx.u("/services/revoke"), url.Values{"csrf": {csrf}, "handle": {"hub"}}) |
| 217 | if _, _, first := get(t, c, fx.u("/settings")); !strings.Contains(first, "Revoked") { |
| 218 | t.Fatalf("the first revoke did not report a kill:\n%s", first) |
| 219 | } |
| 220 | postForm(t, c, fx.u("/services/revoke"), url.Values{"csrf": {csrf}, "handle": {"hub"}}) |
| 221 | _, _, second := get(t, c, fx.u("/settings")) |
| 222 | if strings.Contains(second, "Revoked") { |
| 223 | t.Fatalf("the second revoke of the same handle reported a kill:\n%s", second) |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | // TestPaneRevokesALegacyAppTokenByItsListingID — a legacy kind='app' row has |
| 228 | // no name, so the listing id is the only handle it will ever have. Without |
| 229 | // this an owner could SEE such a credential on the pane and have no way to |
| 230 | // kill it from there. |
| 231 | func TestPaneRevokesALegacyAppTokenByItsListingID(t *testing.T) { |
| 232 | fx := newConsoleFixture(t) |
| 233 | tok, err := fx.st.NewAppToken(OpsAccountID, "kass", "paul@keymail.dev", "router", time.Now()) |
| 234 | if err != nil { |
| 235 | t.Fatal(err) |
| 236 | } |
| 237 | rows, err := fx.st.ServicePrincipals(OpsAccountID) |
| 238 | if err != nil || len(rows) != 1 { |
| 239 | t.Fatalf("ServicePrincipals = %d rows %v, want the legacy row", len(rows), err) |
| 240 | } |
| 241 | id := rows[0].ID |
| 242 | |
| 243 | c := signInPaul(t, fx) |
| 244 | csrf := sessionToken(t, fx, c) |
| 245 | _, _, page := get(t, c, fx.u("/settings")) |
| 246 | // The label is the only distinguisher a nameless row has beyond its app. |
| 247 | if !strings.Contains(page, "router") { |
| 248 | t.Fatalf("the pane does not show the legacy row's label:\n%s", page) |
| 249 | } |
| 250 | if !strings.Contains(page, id) { |
| 251 | t.Fatalf("the pane offers no handle for the nameless row:\n%s", page) |
| 252 | } |
| 253 | |
| 254 | if status, _, body := postForm(t, c, fx.u("/services/revoke"), |
| 255 | url.Values{"csrf": {csrf}, "handle": {id}}); status != http.StatusSeeOther { |
| 256 | t.Fatalf("revoke by listing id = %d:\n%s", status, body) |
| 257 | } |
| 258 | if _, ok := fx.st.CLIPrincipal(tok, time.Now()); ok { |
| 259 | t.Fatal("the legacy app token still resolves after being revoked from the pane") |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | // TestThePageNeverRendersASecret — the listing is built from stored rows, |
| 264 | // and only a credential's sha256 is stored, so there is nothing in it to |
| 265 | // leak even in principle. This asserts that where it can be seen: the |
| 266 | // plaintext of a live credential is not on the page that lists it. |
| 267 | func TestThePageNeverRendersASecret(t *testing.T) { |
| 268 | fx := newConsoleFixture(t) |
| 269 | a := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish) |
| 270 | b := serviceToken(t, fx.st, OpsAccountID, "shipbot", "kass", svcrole.Operate) |
| 271 | legacy, err := fx.st.NewAppToken(OpsAccountID, "kass", "paul@keymail.dev", "router", time.Now()) |
| 272 | if err != nil { |
| 273 | t.Fatal(err) |
| 274 | } |
| 275 | |
| 276 | c := signInPaul(t, fx) |
| 277 | status, _, body := get(t, c, fx.u("/settings")) |
| 278 | if status != http.StatusOK { |
| 279 | t.Fatalf("GET the account settings page = %d", status) |
| 280 | } |
| 281 | if !strings.Contains(body, "hub") || !strings.Contains(body, "shipbot") { |
| 282 | t.Fatalf("the pane does not list the credentials it is meant to:\n%s", body) |
| 283 | } |
| 284 | for _, secret := range []string{a, b, legacy} { |
| 285 | if strings.Contains(body, secret) { |
| 286 | t.Fatalf("the account settings page rendered a live credential's plaintext") |
| 287 | } |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | // TestTheFreshSecretAppearsOnceAndOnlyInItsOwnResponse — the once-only |
| 292 | // discipline handleFleetRotateBoxPage established: the plaintext is rendered |
| 293 | // DIRECTLY in the response to the POST that minted it, never redirected to |
| 294 | // and never put in a flash cookie, which would write a live credential into |
| 295 | // the browser's cookie jar and hand it out again on the next page view. |
| 296 | func TestTheFreshSecretAppearsOnceAndOnlyInItsOwnResponse(t *testing.T) { |
| 297 | fx := newConsoleFixture(t) |
| 298 | c := signInPaul(t, fx) |
| 299 | csrf := sessionToken(t, fx, c) |
| 300 | |
| 301 | status, _, body := postGated(t, fx, c, fx.u("/services/new"), |
| 302 | url.Values{"csrf": {csrf}, "name": {"hub"}, "role": {"publish"}, "app": {""}}) |
| 303 | if status != http.StatusOK { |
| 304 | t.Fatalf("create = %d:\n%s", status, body) |
| 305 | } |
| 306 | secret := paneSecret(t, body) |
| 307 | if _, ok := fx.st.CLIPrincipal(secret, time.Now()); !ok { |
| 308 | t.Fatal("the secret the page showed does not resolve — it is not the credential's own") |
| 309 | } |
| 310 | // C47: the page answers 200 rather than redirecting, because a redirect |
| 311 | // would have to carry the plaintext somewhere the next request could read |
| 312 | // it. That trade is accepted — and the honest half of accepting it is |
| 313 | // saying, next to the secret, what a reload does. Create's reload is |
| 314 | // refused by the name's uniqueness, so the secret above survives it. |
| 315 | if !strings.Contains(body, "Reloading this page") { |
| 316 | t.Fatalf("the mint response does not say what a reload does:\n%s", body) |
| 317 | } |
| 318 | if strings.Count(body, secret) != 1 { |
| 319 | t.Fatalf("the fresh secret appears %d times in its own response, want exactly 1", strings.Count(body, secret)) |
| 320 | } |
| 321 | if hdr := paneCacheControl(t, c, fx, csrf); !strings.Contains(hdr, "no-store") { |
| 322 | t.Fatalf("Cache-Control on a response carrying a credential = %q, want no-store", hdr) |
| 323 | } |
| 324 | // Never in the cookie jar: a flash cookie is storage the browser keeps. |
| 325 | for _, ck := range c.Jar.Cookies(mustURL(t, fx.ts.URL)) { |
| 326 | if strings.Contains(ck.Value, secret) { |
| 327 | t.Fatalf("cookie %q carries the fresh secret", ck.Name) |
| 328 | } |
| 329 | } |
| 330 | // And gone from the very next view of the same page. |
| 331 | if _, _, again := get(t, c, fx.u("/settings")); strings.Contains(again, secret) { |
| 332 | t.Fatalf("the secret survived into a later view of the page:\n%s", again) |
| 333 | } |
| 334 | |
| 335 | // Rotate shows the NEW secret once, and the old one stops resolving. |
| 336 | status, _, body = postGated(t, fx, c, fx.u("/services/rotate"), |
| 337 | url.Values{"csrf": {csrf}, "name": {"hub"}}) |
| 338 | if status != http.StatusOK { |
| 339 | t.Fatalf("rotate = %d:\n%s", status, body) |
| 340 | } |
| 341 | fresh := paneSecret(t, body) |
| 342 | if fresh == secret { |
| 343 | t.Fatal("rotate showed the same secret again") |
| 344 | } |
| 345 | // C47, the sharp end: a reload here rotates AGAIN and retires the secret |
| 346 | // the operator has just pasted into a config. An operator must not |
| 347 | // discover that by losing it. |
| 348 | if !strings.Contains(body, "Reloading this page rotates again") { |
| 349 | t.Fatalf("the rotate response does not warn that a reload retires this secret:\n%s", body) |
| 350 | } |
| 351 | // Minor 4: an operator re-arming a credential is shown WHAT they are |
| 352 | // re-arming. The facts come from findServiceCredential's read-back, so |
| 353 | // rendering them is also what makes a degraded read visible rather than |
| 354 | // silently absent. |
| 355 | // |
| 356 | // Asserted against the SECRET BLOCK, not the whole page: the listing and |
| 357 | // the mint form both mention every role and the words "the whole |
| 358 | // account", so a page-wide Contains would pass without the block saying |
| 359 | // anything at all. |
| 360 | block := paneSecretBlock(t, body) |
| 361 | for _, want := range []string{"publish", "the whole account"} { |
| 362 | if !strings.Contains(block, want) { |
| 363 | t.Fatalf("the rotate block does not name the credential's %q:\n%s", want, block) |
| 364 | } |
| 365 | } |
| 366 | if _, ok := fx.st.CLIPrincipal(secret, time.Now()); ok { |
| 367 | t.Fatal("the pre-rotation secret still resolves") |
| 368 | } |
| 369 | if _, ok := fx.st.CLIPrincipal(fresh, time.Now()); !ok { |
| 370 | t.Fatal("the rotated secret does not resolve") |
| 371 | } |
| 372 | } |
| 373 | |
| 374 | // paneSecret pulls the plaintext out of the once-only block. The page prints |
| 375 | // it on its own CARLOS_SERVICE_TOKEN= line, the same shape the CLI prints. |
| 376 | func paneSecret(t *testing.T, body string) string { |
| 377 | t.Helper() |
| 378 | const marker = "CARLOS_SERVICE_TOKEN=" |
| 379 | i := strings.Index(body, marker) |
| 380 | if i < 0 { |
| 381 | t.Fatalf("no secret in the response:\n%s", body) |
| 382 | } |
| 383 | rest := body[i+len(marker):] |
| 384 | j := strings.IndexAny(rest, "<\n\r ") |
| 385 | if j < 0 { |
| 386 | t.Fatalf("the secret is not delimited:\n%s", body) |
| 387 | } |
| 388 | secret := strings.TrimSpace(rest[:j]) |
| 389 | if secret == "" { |
| 390 | t.Fatalf("empty secret in:\n%s", body) |
| 391 | } |
| 392 | return secret |
| 393 | } |
| 394 | |
| 395 | // paneSecretBlock slices out the banner the fresh secret is rendered in, so a |
| 396 | // claim about what the OPERATOR IS TOLD at the moment of minting is not |
| 397 | // satisfied by the listing or the mint form further down the same page. |
| 398 | func paneSecretBlock(t *testing.T, body string) string { |
| 399 | t.Helper() |
| 400 | i := strings.Index(body, "CARLOS_SERVICE_TOKEN=") |
| 401 | if i < 0 { |
| 402 | t.Fatalf("no secret in the response:\n%s", body) |
| 403 | } |
| 404 | start := strings.LastIndex(body[:i], `<div class="banner">`) |
| 405 | end := strings.Index(body[i:], "</div>") |
| 406 | if start < 0 || end < 0 { |
| 407 | t.Fatalf("the secret is not inside a banner block:\n%s", body) |
| 408 | } |
| 409 | return body[start : i+end] |
| 410 | } |
| 411 | |
| 412 | // paneCacheControl mints one more credential purely to read the response |
| 413 | // header off it — the header is the property under test, and the body is |
| 414 | // already covered above. |
| 415 | func paneCacheControl(t *testing.T, c *http.Client, fx *consoleFixture, csrf string) string { |
| 416 | t.Helper() |
| 417 | res, err := c.PostForm(fx.u("/services/new"), |
| 418 | url.Values{"csrf": {csrf}, "name": {"second"}, "role": {"operate"}, "app": {""}}) |
| 419 | if err != nil { |
| 420 | t.Fatal(err) |
| 421 | } |
| 422 | defer res.Body.Close() |
| 423 | return res.Header.Get("Cache-Control") |
| 424 | } |
| 425 | |
| 426 | func mustURL(t *testing.T, raw string) *url.URL { |
| 427 | t.Helper() |
| 428 | u, err := url.Parse(raw) |
| 429 | if err != nil { |
| 430 | t.Fatal(err) |
| 431 | } |
| 432 | return u |
| 433 | } |
| 434 | |
| 435 | // TestTheServicePaneIsOwnerOnlyOnThePage — a MEMBER sees the account |
| 436 | // settings page without this section rather than with an empty one. An |
| 437 | // empty section reads as "this account has no service credentials", which |
| 438 | // is a different and possibly false statement. |
| 439 | func TestTheServicePaneIsOwnerOnlyOnThePage(t *testing.T) { |
| 440 | fx := newConsoleFixture(t) |
| 441 | if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil { |
| 442 | t.Fatal(err) |
| 443 | } |
| 444 | serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish) |
| 445 | |
| 446 | owner := signInPaul(t, fx) |
| 447 | if _, _, body := get(t, owner, fx.u("/settings")); !strings.Contains(body, "Service credentials") || |
| 448 | !strings.Contains(body, "hub") { |
| 449 | t.Fatalf("the owner's settings page has no service pane:\n%s", body) |
| 450 | } |
| 451 | member, _ := signInAs(t, fx, "kim@keymail.dev", "code-svcpane-owneronly") |
| 452 | status, _, body := get(t, member, fx.u("/settings")) |
| 453 | if status != http.StatusOK { |
| 454 | t.Fatalf("member GET settings = %d", status) |
| 455 | } |
| 456 | if strings.Contains(body, "Service credentials") || strings.Contains(body, "hub") { |
| 457 | t.Fatalf("a member's settings page rendered the service pane:\n%s", body) |
| 458 | } |
| 459 | } |
| 460 | |
| 461 | // TestPersonalSettingsMirrorsServiceCredentialsReadOnly — absorbing a |
| 462 | // credential into the account listing must not make it VANISH from a page |
| 463 | // people already know. It stays on /settings, read-only, pointing at the |
| 464 | // account pane that manages it. |
| 465 | func TestPersonalSettingsMirrorsServiceCredentialsReadOnly(t *testing.T) { |
| 466 | fx := newConsoleFixture(t) |
| 467 | if _, err := fx.st.NewServicePrincipal(OpsAccountID, "hub", "", svcrole.Publish, |
| 468 | "paul@keymail.dev", time.Now()); err != nil { |
| 469 | t.Fatal(err) |
| 470 | } |
| 471 | c := signInPaul(t, fx) |
| 472 | status, _, body := get(t, c, fx.ts.URL+"/settings") |
| 473 | if status != http.StatusOK { |
| 474 | t.Fatalf("GET /settings = %d", status) |
| 475 | } |
| 476 | if !strings.Contains(body, "hub") { |
| 477 | t.Fatalf("the credential vanished from the page its minter knows:\n%s", body) |
| 478 | } |
| 479 | if !strings.Contains(body, "/a/"+fx.sqid+"/settings") || !strings.Contains(body, "manage there") { |
| 480 | t.Fatalf("/settings does not point at the account pane that manages it:\n%s", body) |
| 481 | } |
| 482 | if strings.Contains(body, `action="/auth/cli/tokens/revoke"`) { |
| 483 | t.Fatalf("/settings offers a revoke button for a service credential — the mirror is read-only:\n%s", body) |
| 484 | } |
| 485 | } |
| 486 | |
| 487 | // TestPersonalSettingsStillRevokesALegacyAppToken is the other half of the |
| 488 | // mirror rule, and the reason the read-only treatment keys on IsService and |
| 489 | // not on "anything that is not a person": an app token has always been |
| 490 | // revocable from this page, and its minter may not be an account owner. |
| 491 | func TestPersonalSettingsStillRevokesALegacyAppToken(t *testing.T) { |
| 492 | fx := newConsoleFixture(t) |
| 493 | if _, err := fx.st.NewAppToken(OpsAccountID, "kass", "paul@keymail.dev", "router", time.Now()); err != nil { |
| 494 | t.Fatal(err) |
| 495 | } |
| 496 | c := signInPaul(t, fx) |
| 497 | _, _, body := get(t, c, fx.ts.URL+"/settings") |
| 498 | if !strings.Contains(body, `action="/auth/cli/tokens/revoke"`) { |
| 499 | t.Fatalf("/settings no longer offers to revoke an app token:\n%s", body) |
| 500 | } |
| 501 | } |
| 502 | |
| 503 | // TestPersonalSettingsCannotHandPostAServiceRevoke (C49) — the mirror is |
| 504 | // read-only in FACT, not only in presentation. |
| 505 | // |
| 506 | // Withholding the button from the template is not a control: POST |
| 507 | // /auth/cli/tokens/revoke accepts any listing id whose row carries the |
| 508 | // viewer's address, and a service credential's address column IS its minter. |
| 509 | // Worse than the pane's own revoke in three ways at once — RevokeCLIToken is |
| 510 | // a hard DELETE rather than the revoked_at tombstone, it writes no account |
| 511 | // audit row, and it survives the minter losing ownership. An owner who minted |
| 512 | // a credential and was later transferred out could still hand-post its id, |
| 513 | // stop a production service, and leave the account's activity feed silent. |
| 514 | // |
| 515 | // It is refused BEFORE the passkey: no gesture could make this the right |
| 516 | // door, so asking for one would waste it. |
| 517 | func TestPersonalSettingsCannotHandPostAServiceRevoke(t *testing.T) { |
| 518 | fx := newConsoleFixture(t) |
| 519 | secret, err := fx.st.NewServicePrincipal(OpsAccountID, "hub", "", svcrole.Publish, |
| 520 | "paul@keymail.dev", time.Now()) |
| 521 | if err != nil { |
| 522 | t.Fatal(err) |
| 523 | } |
| 524 | toks, err := fx.st.CLITokens("paul@keymail.dev") |
| 525 | if err != nil || len(toks) != 1 { |
| 526 | t.Fatalf("CLITokens = %d rows %v, want the service row", len(toks), err) |
| 527 | } |
| 528 | id := toks[0].ID |
| 529 | |
| 530 | c := signInPaul(t, fx) |
| 531 | csrf := sessionToken(t, fx, c) |
| 532 | // postForm, not postGated: a hop into Keymail here would itself be the |
| 533 | // bug — the refusal must cost nothing. |
| 534 | status, loc, body := postForm(t, c, fx.ts.URL+"/auth/cli/tokens/revoke", |
| 535 | url.Values{"csrf": {csrf}, "id": {id}}) |
| 536 | if strings.HasPrefix(loc, fx.fk.srv.URL) { |
| 537 | t.Fatalf("the refusal cost a passkey trip (-> %q)", loc) |
| 538 | } |
| 539 | if status != http.StatusSeeOther || loc != "/settings" { |
| 540 | t.Fatalf("hand-posted service revoke = %d -> %q, want 303 back to /settings:\n%s", status, loc, body) |
| 541 | } |
| 542 | if _, ok := fx.st.CLIPrincipal(secret, time.Now()); !ok { |
| 543 | t.Fatal("a hand-posted /settings revoke killed a service credential") |
| 544 | } |
| 545 | // And the row is still THERE — RevokeCLIToken is a hard DELETE, so a |
| 546 | // credential that went through it would be gone rather than tombstoned. |
| 547 | if rows, err := fx.st.ServicePrincipals(OpsAccountID); err != nil || len(rows) != 1 { |
| 548 | t.Fatalf("ServicePrincipals = %d rows %v, want the credential untouched", len(rows), err) |
| 549 | } |
| 550 | _, _, page := get(t, c, fx.ts.URL+"/settings") |
| 551 | if !strings.Contains(page, "is a service credential") { |
| 552 | t.Fatalf("/settings does not say why the revoke was refused:\n%s", page) |
| 553 | } |
| 554 | } |
| 555 | |
| 556 | // TestPersonalSettingsRefusesAnAppTokenRevokeAfterRemoval (#219 item 3): the |
| 557 | // legacy app-token revoke on /settings is scoped to the minter's address, so |
| 558 | // it outlived their membership. Somebody removed from the account must not be |
| 559 | // able to stop that account's app by revoking a credential they once minted. |
| 560 | // Refused before the passkey: no gesture would change the answer. |
| 561 | func TestPersonalSettingsRefusesAnAppTokenRevokeAfterRemoval(t *testing.T) { |
| 562 | fx := newConsoleFixture(t) |
| 563 | otherID := testAccountID() |
| 564 | if err := fx.st.CreateAccount(otherID, accountSqid(otherID), "Sam's", "sam@keymail.dev", time.Now()); err != nil { |
| 565 | t.Fatal(err) |
| 566 | } |
| 567 | if err := fx.st.AddMember(otherID, "paul@keymail.dev", RoleMember, time.Now()); err != nil { |
| 568 | t.Fatal(err) |
| 569 | } |
| 570 | secret, err := fx.st.NewAppToken(otherID, "kass", "paul@keymail.dev", "router", time.Now()) |
| 571 | if err != nil { |
| 572 | t.Fatal(err) |
| 573 | } |
| 574 | toks, err := fx.st.CLITokens("paul@keymail.dev") |
| 575 | if err != nil || len(toks) != 1 { |
| 576 | t.Fatalf("CLITokens = %d rows %v, want the app token", len(toks), err) |
| 577 | } |
| 578 | id := toks[0].ID |
| 579 | if err := fx.st.RemoveMember(otherID, "paul@keymail.dev"); err != nil { |
| 580 | t.Fatal(err) |
| 581 | } |
| 582 | |
| 583 | c := signInPaul(t, fx) |
| 584 | csrf := sessionToken(t, fx, c) |
| 585 | status, loc, body := postForm(t, c, fx.ts.URL+"/auth/cli/tokens/revoke", |
| 586 | url.Values{"csrf": {csrf}, "id": {id}}) |
| 587 | if strings.HasPrefix(loc, fx.fk.srv.URL) { |
| 588 | t.Fatalf("the refusal cost a passkey trip (-> %q)", loc) |
| 589 | } |
| 590 | if status != http.StatusSeeOther || loc != "/settings" { |
| 591 | t.Fatalf("revoke after removal = %d -> %q, want 303 back to /settings:\n%s", status, loc, body) |
| 592 | } |
| 593 | if _, ok := fx.st.CLIPrincipal(secret, time.Now()); !ok { |
| 594 | t.Fatal("a removed member revoked the account's app token") |
| 595 | } |
| 596 | _, _, page := get(t, c, fx.ts.URL+"/settings") |
| 597 | if !strings.Contains(page, "no longer a member") { |
| 598 | t.Fatalf("/settings does not say why the revoke was refused:\n%s", page) |
| 599 | } |
| 600 | } |
| 601 | |
| 602 | // TestPaneMintRefusalsCostNoPasskey — a bad name or an unknown role is the |
| 603 | // caller's slip, and no gesture could change the answer, so it is refused |
| 604 | // BEFORE the passkey. Same reasoning handleStoreDestroy's "no backend" |
| 605 | // refusal carries. |
| 606 | func TestPaneMintRefusalsCostNoPasskey(t *testing.T) { |
| 607 | fx := newConsoleFixture(t) |
| 608 | c := signInPaul(t, fx) |
| 609 | csrf := sessionToken(t, fx, c) |
| 610 | for _, bad := range []struct { |
| 611 | why string |
| 612 | form url.Values |
| 613 | }{ |
| 614 | {"an unusable name", url.Values{"csrf": {csrf}, "name": {"Hub Two"}, "role": {"publish"}}}, |
| 615 | {"an unknown role", url.Values{"csrf": {csrf}, "name": {"hub"}, "role": {"root"}}}, |
| 616 | {"an app that is not here", url.Values{"csrf": {csrf}, "name": {"hub"}, "role": {"publish"}, "app": {"ghost"}}}, |
| 617 | } { |
| 618 | status, loc, body := postForm(t, c, fx.u("/services/new"), bad.form) |
| 619 | if strings.HasPrefix(loc, fx.fk.srv.URL) { |
| 620 | t.Errorf("create with %s cost a passkey trip (-> %q)", bad.why, loc) |
| 621 | } |
| 622 | if status != http.StatusSeeOther || loc != fx.path("/settings") { |
| 623 | t.Errorf("create with %s = %d -> %q, want 303 back to the page:\n%s", bad.why, status, loc, body) |
| 624 | } |
| 625 | } |
| 626 | if rows, err := fx.st.ServicePrincipals(OpsAccountID); err != nil || len(rows) != 0 { |
| 627 | t.Fatalf("ServicePrincipals = %d rows %v after three refused mints, want none", len(rows), err) |
| 628 | } |
| 629 | } |
| 630 | |
| 631 | // TestPaneMintIsAudited — the pane's writes belong in the same trail the API |
| 632 | // and the CLI write to, filed the same way (serviceAuditKey: an app-scoped |
| 633 | // credential's grant belongs in THAT APP's feed). |
| 634 | func TestPaneMintIsAudited(t *testing.T) { |
| 635 | fx := newConsoleFixture(t) |
| 636 | c := signInPaul(t, fx) |
| 637 | csrf := sessionToken(t, fx, c) |
| 638 | if status, _, body := postGated(t, fx, c, fx.u("/services/new"), |
| 639 | url.Values{"csrf": {csrf}, "name": {"shipbot"}, "role": {"operate"}, "app": {"kass"}}); status != http.StatusOK { |
| 640 | t.Fatalf("create = %d:\n%s", status, body) |
| 641 | } |
| 642 | events, err := fx.st.Events(OpsAccountID, "kass", 20) |
| 643 | if err != nil { |
| 644 | t.Fatal(err) |
| 645 | } |
| 646 | var found bool |
| 647 | for _, e := range events { |
| 648 | if e.Action == "service-create" && strings.Contains(e.Detail, "shipbot") { |
| 649 | found = true |
| 650 | if e.Address != "paul@keymail.dev" { |
| 651 | t.Errorf("audit actor = %q, want the member who pressed the button", e.Address) |
| 652 | } |
| 653 | if !strings.Contains(e.Detail, "console") { |
| 654 | t.Errorf("audit detail %q does not say which door it came through", e.Detail) |
| 655 | } |
| 656 | } |
| 657 | } |
| 658 | if !found { |
| 659 | t.Fatalf("no service-create event on the app the credential was scoped to: %+v", events) |
| 660 | } |
| 661 | } |
| 662 | |
| 663 | // The FORM is the door that offers an account-wide credential, so it is the |
| 664 | // one most likely to meet the Service-role refusal — and the one where |
| 665 | // "could not mint, try again" is worst, because the owner would retry the same |
| 666 | // impossible request forever. Enforcement lives in NewServicePrincipal and |
| 667 | // held either way; this is about the sentence the owner reads. |
| 668 | func TestPaneSaysWhyAServiceCredentialNeedsAnApp(t *testing.T) { |
| 669 | fx := newConsoleFixture(t) |
| 670 | c := signInPaul(t, fx) |
| 671 | csrf := sessionToken(t, fx, c) |
| 672 | |
| 673 | status, loc, body := postForm(t, c, fx.u("/services/new"), |
| 674 | url.Values{"csrf": {csrf}, "name": {"scanbot"}, "role": {"service"}, "app": {""}}) |
| 675 | if status != http.StatusSeeOther || loc != fx.path("/settings") { |
| 676 | t.Fatalf("account-wide service mint = %d -> %q, want 303 back to the page:\n%s", status, loc, body) |
| 677 | } |
| 678 | if rows, err := fx.st.ServicePrincipals(OpsAccountID); err != nil || len(rows) != 0 { |
| 679 | t.Fatalf("ServicePrincipals = %d rows %v, want the mint refused", len(rows), err) |
| 680 | } |
| 681 | // The page has to SAY it, not log it: the generic failure would send the |
| 682 | // owner round the same loop. |
| 683 | _, _, page := get(t, c, fx.u("/settings")) |
| 684 | if !strings.Contains(page, "must name one app") { |
| 685 | t.Fatalf("the settings page does not explain the refusal:\n%s", page) |
| 686 | } |
| 687 | } |
| 688 | |