carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package 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
19import (
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.
32type paneDoor struct {
33 name string
34 path string
35 form url.Values
36}
37
38func 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.
47func 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.
59func 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.
76func 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.
157func 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.
195func 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.
231func 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.
267func 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.
296func 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.
376func 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.
398func 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.
415func 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
426func 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.
439func 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.
465func 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.
491func 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.
517func 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.
561func 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.
606func 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).
634func 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.
668func 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