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
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "net/url"
10 "path/filepath"
11 "strconv"
12 "strings"
13 "testing"
14 "time"
15
16 "github.com/carlosframework/platform/internal/svcrole"
17)
18
19// postJSON is how the CLI talks to the console: a JSON body, an optional
20// bearer token, and no cookies.
21func postJSON(t *testing.T, u string, body any, bearer string) (int, map[string]any) {
22 t.Helper()
23 raw, err := json.Marshal(body)
24 if err != nil {
25 t.Fatal(err)
26 }
27 req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(raw))
28 if err != nil {
29 t.Fatal(err)
30 }
31 req.Header.Set("Content-Type", "application/json")
32 if bearer != "" {
33 req.Header.Set("Authorization", "Bearer "+bearer)
34 }
35 return doJSON(t, req)
36}
37
38func getJSON(t *testing.T, u, bearer string) (int, map[string]any) {
39 t.Helper()
40 req, err := http.NewRequest(http.MethodGet, u, nil)
41 if err != nil {
42 t.Fatal(err)
43 }
44 if bearer != "" {
45 req.Header.Set("Authorization", "Bearer "+bearer)
46 }
47 return doJSON(t, req)
48}
49
50func doJSON(t *testing.T, req *http.Request) (int, map[string]any) {
51 t.Helper()
52 res, err := http.DefaultClient.Do(req)
53 if err != nil {
54 t.Fatal(err)
55 }
56 defer res.Body.Close()
57 raw, err := io.ReadAll(res.Body)
58 if err != nil {
59 t.Fatal(err)
60 }
61 out := map[string]any{}
62 if len(bytes.TrimSpace(raw)) > 0 {
63 if err := json.Unmarshal(raw, &out); err != nil {
64 t.Fatalf("%s %s: body is not JSON: %v\n%s", req.Method, req.URL, err, raw)
65 }
66 }
67 return res.StatusCode, out
68}
69
70func str(t *testing.T, m map[string]any, k string) string {
71 t.Helper()
72 v, _ := m[k].(string)
73 return v
74}
75
76// TestCLILoginEndToEnd is the whole ceremony against the real handlers: a
77// terminal starts a login, a signed-in human approves it in a browser, the
78// terminal collects a token, uses it, sees it listed, and revokes it.
79func TestCLILoginEndToEnd(t *testing.T) {
80 fx := newConsoleFixture(t)
81 browser := newBrowser(t)
82 signUp(t, fx, browser, "code-cli")
83
84 status, start := postJSON(t, fx.ts.URL+"/auth/cli/start", map[string]string{"label": "paul@laptop"}, "")
85 if status != http.StatusOK {
86 t.Fatalf("start = %d %v", status, start)
87 }
88 userCode, deviceCode := str(t, start, "user_code"), str(t, start, "device_code")
89 if userCode == "" || deviceCode == "" {
90 t.Fatalf("start returned %v, want both codes", start)
91 }
92 if got, want := str(t, start, "verification_uri"), fx.ts.URL+"/auth/cli"; got != want {
93 t.Fatalf("verification_uri = %q, want %q", got, want)
94 }
95 if got := str(t, start, "verification_uri_complete"); !strings.Contains(got, url.QueryEscape(userCode)) {
96 t.Fatalf("verification_uri_complete = %q, want the code in it", got)
97 }
98
99 // Nothing yet.
100 if _, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": deviceCode}, ""); str(t, poll, "status") != CLIAuthPending {
101 t.Fatalf("poll before approval = %v, want pending", poll)
102 }
103
104 // Typing the code is what produces a described terminal — following a
105 // link never does (see TestCLIApprovalPageNeverApprovesFromALink).
106 csrf := sessionToken(t, fx, browser)
107 code, _, body := postForm(t, browser, fx.ts.URL+"/auth/cli/approve",
108 url.Values{"code": {userCode}, "action": {"continue"}, "csrf": {csrf}})
109 if code != http.StatusOK {
110 t.Fatalf("continue = %d", code)
111 }
112 for _, want := range []string{"paul@laptop", userCode, "Approve"} {
113 if !strings.Contains(body, want) {
114 t.Fatalf("approval page missing %q in:\n%s", want, body)
115 }
116 }
117
118 // CSRF: the session token double-submitted, exactly like every other
119 // mutation on this console.
120 if code, _, _ := postForm(t, browser, fx.ts.URL+"/auth/cli/approve",
121 url.Values{"code": {userCode}, "action": {"approve"}, "csrf": {"wrong"}}); code != http.StatusForbidden {
122 t.Fatalf("approve with a bad CSRF token = %d, want 403", code)
123 }
124 // And a signed-out browser cannot approve at all.
125 if code, loc, _ := postForm(t, newBrowser(t), fx.ts.URL+"/auth/cli/approve",
126 url.Values{"code": {userCode}, "action": {"approve"}, "csrf": {csrf}}); code != http.StatusFound || loc != "/" {
127 t.Fatalf("signed-out approve = %d -> %q, want 302 -> /", code, loc)
128 }
129
130 // Approving mints a 30-day credential, so it is behind passkey step-up
131 // (stepup.go): a direct POST is answered with a trip through Keymail, and
132 // only the confirmed round trip approves.
133 if code, loc, _ := postForm(t, browser, fx.ts.URL+"/auth/cli/approve",
134 url.Values{"code": {userCode}, "action": {"approve"}, "csrf": {csrf}}); code != http.StatusSeeOther ||
135 !strings.HasPrefix(loc, fx.fk.srv.URL) {
136 t.Fatalf("approve without a fresh passkey = %d -> %q, want a redirect into Keymail", code, loc)
137 }
138 if _, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": deviceCode}, ""); str(t, poll, "status") == CLIAuthApproved {
139 t.Fatal("the terminal was approved before the passkey confirmation")
140 }
141 code, _, body = postGated(t, fx, browser, fx.ts.URL+"/auth/cli/approve",
142 url.Values{"code": {userCode}, "action": {"approve"}, "csrf": {csrf}})
143 if code != http.StatusOK || !strings.Contains(body, "Approved") {
144 t.Fatalf("approve = %d, body:\n%s", code, body)
145 }
146
147 _, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": deviceCode}, "")
148 if str(t, poll, "status") != CLIAuthApproved {
149 t.Fatalf("poll after approval = %v", poll)
150 }
151 token := str(t, poll, "token")
152 if token == "" || str(t, poll, "address") != "paul@keymail.dev" {
153 t.Fatalf("poll after approval = %v, want a token bound to paul", poll)
154 }
155
156 // The token identifies its holder — and only with the right bearer.
157 status, who := getJSON(t, fx.ts.URL+"/auth/cli/whoami", token)
158 if status != http.StatusOK || str(t, who, "address") != "paul@keymail.dev" {
159 t.Fatalf("whoami = %d %v", status, who)
160 }
161 if status, _ := getJSON(t, fx.ts.URL+"/auth/cli/whoami", token+"x"); status != http.StatusUnauthorized {
162 t.Fatalf("whoami with a wrong token = %d, want 401", status)
163 }
164 if status, _ := getJSON(t, fx.ts.URL+"/auth/cli/whoami", ""); status != http.StatusUnauthorized {
165 t.Fatalf("whoami with no token = %d, want 401", status)
166 }
167
168 // The owner can see it, and kill it, from the console.
169 code, _, body = get(t, browser, fx.ts.URL+"/settings")
170 if code != http.StatusOK || !strings.Contains(body, "paul@laptop") {
171 t.Fatalf("tokens page = %d, body:\n%s", code, body)
172 }
173 if strings.Contains(body, token) {
174 t.Fatal("the tokens page prints the bearer token itself")
175 }
176 id := ""
177 toks, err := fx.st.CLITokens("paul@keymail.dev")
178 if err != nil {
179 t.Fatal(err)
180 }
181 if len(toks) != 1 {
182 t.Fatalf("CLITokens = %d, want 1", len(toks))
183 }
184 id = toks[0].ID
185 if code, _, _ := postForm(t, browser, fx.ts.URL+"/auth/cli/tokens/revoke",
186 url.Values{"id": {id}, "csrf": {"wrong"}}); code != http.StatusForbidden {
187 t.Fatalf("revoke with a bad CSRF token = %d, want 403", code)
188 }
189 // Revoking is step-up gated too (the same session that has just confirmed
190 // a passkey is still inside the window here, so no second trip is made).
191 if code, _, _ := postGated(t, fx, browser, fx.ts.URL+"/auth/cli/tokens/revoke",
192 url.Values{"id": {id}, "csrf": {csrf}}); code != http.StatusSeeOther {
193 t.Fatalf("revoke = %d, want 303", code)
194 }
195 if status, _ := getJSON(t, fx.ts.URL+"/auth/cli/whoami", token); status != http.StatusUnauthorized {
196 t.Fatalf("whoami after revocation = %d, want 401", status)
197 }
198}
199
200// The device-code phishing attack, refused. An attacker starts a login, sends
201// the victim the ready-made verification_uri_complete with a pretext, and the
202// victim clicks it while signed in. If that page rendered an Approve button
203// for the attacker's code, one click would bind a 90-day credential — held by
204// the attacker — to the victim's identity. This is the campaign that has been
205// run against Microsoft tenants at scale, so the page must not do it: no
206// lookup, no description, no Approve button, and an EMPTY input the human has
207// to type into off their own screen.
208func TestCLIApprovalPageNeverApprovesFromALink(t *testing.T) {
209 fx := newConsoleFixture(t)
210 victim := newBrowser(t)
211 signUp(t, fx, victim, "code-phish")
212
213 // The attacker's terminal, started from somewhere else entirely.
214 _, start := postJSON(t, fx.ts.URL+"/auth/cli/start", map[string]string{"label": "attacker@vps"}, "")
215 userCode := str(t, start, "user_code")
216
217 status, _, body := get(t, victim, fx.ts.URL+"/auth/cli?code="+url.QueryEscape(userCode))
218 if status != http.StatusOK {
219 t.Fatalf("approval page = %d", status)
220 }
221 // The code is echoed to be CHECKED against the terminal's screen…
222 if !strings.Contains(body, userCode) {
223 t.Fatalf("the page hides the code the link carried, so it cannot be checked:\n%s", body)
224 }
225 // …but never described (that would also make this an enumeration oracle)…
226 if strings.Contains(body, "attacker@vps") {
227 t.Fatalf("the page resolved a code from a URL:\n%s", body)
228 }
229 // …and never one click from approval.
230 if strings.Contains(body, `value="approve"`) {
231 t.Fatalf("the page rendered an Approve button for a code from a URL:\n%s", body)
232 }
233 if strings.Contains(body, `name="code" value="`+userCode) {
234 t.Fatalf("the page prefilled the typed-code field from the URL:\n%s", body)
235 }
236 // The request really is still pending — the page did not consume it, it
237 // simply refused to act on it.
238 _, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": str(t, start, "device_code")}, "")
239 if str(t, poll, "status") != CLIAuthPending {
240 t.Fatalf("poll = %v, want the request untouched and pending", poll)
241 }
242}
243
244// Issuing a credential tells the human, in the inbox their identity is
245// anchored to. Every other defence here is preventive; this is the one that
246// makes a phished approval detectable afterwards.
247func TestCLIIssuanceNotifiesTheUser(t *testing.T) {
248 fx := newConsoleFixture(t)
249 browser := newBrowser(t)
250 signUp(t, fx, browser, "code-notify")
251 fx.srv.cliNotified = make(chan struct{}, 1)
252
253 _, start := postJSON(t, fx.ts.URL+"/auth/cli/start", map[string]string{"label": "paul@laptop"}, "")
254 userCode, deviceCode := str(t, start, "user_code"), str(t, start, "device_code")
255 postGated(t, fx, browser, fx.ts.URL+"/auth/cli/approve",
256 url.Values{"code": {userCode}, "action": {"approve"}, "csrf": {sessionToken(t, fx, browser)}})
257 if _, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": deviceCode}, ""); str(t, poll, "token") == "" {
258 t.Fatalf("no token issued: %v", poll)
259 }
260 select {
261 case <-fx.srv.cliNotified:
262 case <-time.After(5 * time.Second):
263 t.Fatal("no notification attempt after a token was issued")
264 }
265 var found string
266 for _, body := range fx.fk.sentBodies() {
267 if strings.Contains(body, "command line") || strings.Contains(body, "terminal") {
268 found = body
269 }
270 }
271 if found == "" {
272 t.Fatalf("no issuance notice in the sealed sends: %v", fx.fk.sentBodies())
273 }
274 for _, want := range []string{"paul@laptop", "/settings", "revoke"} {
275 if !strings.Contains(found, want) {
276 t.Fatalf("issuance notice missing %q:\n%s", want, found)
277 }
278 }
279}
280
281// Someone who signs in by email has no Keymail grant. The issuance notice
282// still reaches them, by the console mailer; someone with a grant still gets
283// the sealed Keymail send and no email.
284func TestCLIIssuedNoticeByEmail(t *testing.T) {
285 fx, m := newEmailFixture(t, true)
286 fx.srv.cliNotified = make(chan struct{}, 2)
287 wait := func() {
288 t.Helper()
289 select {
290 case <-fx.srv.cliNotified:
291 case <-time.After(5 * time.Second):
292 t.Fatal("notifyCLIIssued never finished")
293 }
294 }
295
296 fx.srv.notifyCLIIssued("new@example.com", "raw-token")
297 wait()
298 sent := m.sentCopy()
299 if len(sent) != 1 {
300 t.Fatalf("console mailer got %d messages, want 1: %q", len(sent), sent)
301 }
302 to, body, _ := strings.Cut(sent[0], "|")
303 if to != "new@example.com" {
304 t.Fatalf("notice went to %q", to)
305 }
306 for _, want := range []string{"A terminal was just approved to act as new@example.com", "/settings", "revoke"} {
307 if !strings.Contains(body, want) {
308 t.Fatalf("notice missing %q:\n%s", want, body)
309 }
310 }
311
312 if err := fx.st.SetGrantToken("granted@example.com", "grant-tok", time.Now()); err != nil {
313 t.Fatal(err)
314 }
315 fx.srv.notifyCLIIssued("granted@example.com", "raw-token-2")
316 wait()
317 if got := m.sentCopy(); len(got) != 1 {
318 t.Fatalf("a grant holder was also emailed: %q", got)
319 }
320}
321
322// X-Forwarded-For is only believed from a peer we put there. Otherwise one
323// caller presents a fresh "IP" per request and walks past every per-IP limit.
324func TestClientIPIgnoresUntrustedForwardedFor(t *testing.T) {
325 st, err := Open(filepath.Join(t.TempDir(), "console.db"))
326 if err != nil {
327 t.Fatal(err)
328 }
329 t.Cleanup(func() { st.Close() })
330 // No bucket, so no mesh key and no proof has ever been seen: loopback
331 // keeps the trust it had before proofs existed (edgeproof.go).
332 s := &Server{cfg: Config{EdgeIPs: []string{"198.51.100.9"}}, store: st}
333 r := &http.Request{Header: http.Header{}, RemoteAddr: "203.0.113.5:41234"}
334 r.Header.Set("X-Forwarded-For", "1.2.3.4")
335 if got := s.clientIP(r); got != "203.0.113.5" {
336 t.Fatalf("clientIP from an untrusted peer = %q, want the peer address", got)
337 }
338 // The configured edge, and the loopback edge on this box, are believed —
339 // and it is the LAST element that is theirs to state.
340 for _, peer := range []string{"198.51.100.9:9", "127.0.0.1:9"} {
341 r.RemoteAddr = peer
342 r.Header.Set("X-Forwarded-For", "9.9.9.9, 1.2.3.4")
343 if got := s.clientIP(r); got != "1.2.3.4" {
344 t.Fatalf("clientIP behind %s = %q, want 1.2.3.4", peer, got)
345 }
346 }
347}
348
349// The label is the one field a human is asked to recognise, and the machine
350// asking chooses it. Bidi overrides and zero-width joiners exist to make a
351// string render as something it is not.
352func TestCleanLabelIsPrintableASCII(t *testing.T) {
353 for in, want := range map[string]string{
354 "paul@laptop": "paul@laptop",
355 "paul\u202e@evil": "paul@evil",
356 "paul\u200b@laptop": "paul@laptop",
357 "paul\x07@laptop\n": "paul@laptop",
358 " paul@laptop ": "paul@laptop",
359 "héllo@laptop": "hllo@laptop",
360 strings.Repeat("a", 200): strings.Repeat("a", 64),
361 } {
362 if got := cleanLabel(in); got != want {
363 t.Errorf("cleanLabel(%q) = %q, want %q", in, got, want)
364 }
365 }
366}
367
368// A denied login is final and tells the terminal so.
369func TestCLILoginDeny(t *testing.T) {
370 fx := newConsoleFixture(t)
371 browser := newBrowser(t)
372 signUp(t, fx, browser, "code-deny")
373 _, start := postJSON(t, fx.ts.URL+"/auth/cli/start", map[string]string{"label": "someone@elsewhere"}, "")
374 userCode, deviceCode := str(t, start, "user_code"), str(t, start, "device_code")
375 code, _, body := postForm(t, browser, fx.ts.URL+"/auth/cli/approve",
376 url.Values{"code": {userCode}, "action": {"deny"}, "csrf": {sessionToken(t, fx, browser)}})
377 if code != http.StatusOK || !strings.Contains(body, "Denied") {
378 t.Fatalf("deny = %d, body:\n%s", code, body)
379 }
380 _, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": deviceCode}, "")
381 if str(t, poll, "status") != CLIAuthDenied {
382 t.Fatalf("poll after deny = %v, want denied", poll)
383 }
384}
385
386// The JSON endpoints refuse a form-encoded body. That is the CSRF story for
387// the unauthenticated half of the flow: a cross-site <form> can only send
388// form encodings, and anything that can set application/json has already been
389// through a CORS preflight this console never answers.
390func TestCLIStartRefusesFormEncoding(t *testing.T) {
391 fx := newConsoleFixture(t)
392 res, err := http.PostForm(fx.ts.URL+"/auth/cli/start", url.Values{"label": {"x"}})
393 if err != nil {
394 t.Fatal(err)
395 }
396 res.Body.Close()
397 if res.StatusCode != http.StatusUnsupportedMediaType {
398 t.Fatalf("form-encoded start = %d, want 415", res.StatusCode)
399 }
400}
401
402// An unauthenticated endpoint that mints state is rate-limited, or it is a
403// way to fill a database from the internet.
404func TestCLIStartRateLimited(t *testing.T) {
405 fx := newConsoleFixture(t)
406 limited := false
407 for i := 0; i < cliStartBurst+3; i++ {
408 status, _ := postJSON(t, fx.ts.URL+"/auth/cli/start", map[string]string{"label": "x"}, "")
409 if status == http.StatusTooManyRequests {
410 limited = true
411 break
412 }
413 }
414 if !limited {
415 t.Fatalf("start was never rate-limited in %d attempts", cliStartBurst+3)
416 }
417}
418
419// Guessing the short typed code is the one brute-forceable step, so wrong
420// codes are counted — at BOTH places a code is resolved, and against the
421// signed-in address and the client IP rather than the session token (which is
422// re-minted by signing in again, and so resets a counter keyed on it).
423//
424// The payoff being bounded here is the inverted one: guessing somebody else's
425// pending code and approving it binds THEIR terminal to YOUR address.
426func TestCLIApproveRateLimitsWrongCodes(t *testing.T) {
427 for _, action := range []string{"continue", "approve"} {
428 t.Run(action, func(t *testing.T) {
429 fx := newConsoleFixture(t)
430 browser := newBrowser(t)
431 signUp(t, fx, browser, "code-brute-"+action)
432 csrf := sessionToken(t, fx, browser)
433 limited := false
434 for i := 0; i < cliGuessBurst+3; i++ {
435 // postGated, because approve is step-up gated: the first
436 // attempt walks the passkey round trip, the rest are inside
437 // the window. The limiter still has to bite either way — a
438 // confirmed human is not licensed to guess codes.
439 code, _, _ := postGated(t, fx, browser, fx.ts.URL+"/auth/cli/approve",
440 url.Values{"code": {"AAAA-AAAA"}, "action": {action}, "csrf": {csrf}})
441 if code == http.StatusTooManyRequests {
442 limited = true
443 break
444 }
445 }
446 if !limited {
447 t.Fatalf("wrong codes were never rate-limited in %d attempts", cliGuessBurst+3)
448 }
449 })
450 }
451}
452
453func TestCLIPollUnknownDeviceCode(t *testing.T) {
454 fx := newConsoleFixture(t)
455 status, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": "nope"}, "")
456 if status != http.StatusOK || str(t, poll, "status") != CLIAuthUnknown {
457 t.Fatalf("poll with a made-up device code = %d %v, want 200 unknown", status, poll)
458 }
459}
460
461// The account a logged-in CLI creates has the logged-in human as its owner —
462// the whole reason this feature exists. An ownerless account was invisible to
463// its own creator in the console.
464func TestCLICreatesAccountOwnedByTheToken(t *testing.T) {
465 fx := newConsoleFixture(t)
466 token := cliToken(t, fx, "paul@keymail.dev", "code-acct")
467
468 status, out := postJSON(t, fx.ts.URL+"/auth/cli/accounts", map[string]string{"name": "kass"}, token)
469 if status != http.StatusOK {
470 t.Fatalf("accounts create = %d %v", status, out)
471 }
472 sqid := str(t, out, "sqid")
473 if sqid == "" || str(t, out, "name") != "kass" || str(t, out, "owner") != "paul@keymail.dev" {
474 t.Fatalf("accounts create returned %v", out)
475 }
476 acct, err := fx.st.AccountBySqid(sqid)
477 if err != nil || acct == nil {
478 t.Fatalf("AccountBySqid(%s) = %v %v", sqid, acct, err)
479 }
480 role, err := fx.st.Role(acct.ID, "paul@keymail.dev")
481 if err != nil || role != RoleOwner {
482 t.Fatalf("role = %q %v, want owner", role, err)
483 }
484 // The manifest is in the bucket too — the account exists for every box,
485 // not just for this console's database.
486 if _, _, err := fx.rel.Get(t.Context(), AccountManifestKey(sqid)); err != nil {
487 t.Fatalf("account manifest: %v", err)
488 }
489 // And an unauthenticated caller mints nothing.
490 if status, _ := postJSON(t, fx.ts.URL+"/auth/cli/accounts", map[string]string{"name": "Sneaky"}, ""); status != http.StatusUnauthorized {
491 t.Fatalf("unauthenticated accounts create = %d, want 401", status)
492 }
493 if status, _ := postJSON(t, fx.ts.URL+"/auth/cli/accounts", map[string]string{"name": ""}, token); status != http.StatusBadRequest {
494 t.Fatalf("nameless accounts create = %d, want 400", status)
495 }
496 // A non-slug name is refused the same way: it would appear in URLs and
497 // -account exactly as typed.
498 if status, _ := postJSON(t, fx.ts.URL+"/auth/cli/accounts",
499 map[string]string{"name": "Paul's Stuff!"}, token); status != http.StatusBadRequest {
500 t.Fatalf("non-slug accounts create = %d, want 400", status)
501 }
502}
503
504// `carlos auth logout` retires the credential rather than just forgetting it.
505func TestCLILogoutRevokes(t *testing.T) {
506 fx := newConsoleFixture(t)
507 token := cliToken(t, fx, "paul@keymail.dev", "code-logout")
508 if status, _ := postJSON(t, fx.ts.URL+"/auth/cli/revoke", map[string]string{}, token); status != http.StatusOK {
509 t.Fatalf("revoke = %d, want 200", status)
510 }
511 if status, _ := getJSON(t, fx.ts.URL+"/auth/cli/whoami", token); status != http.StatusUnauthorized {
512 t.Fatalf("whoami after logout = %d, want 401", status)
513 }
514 // Revoking an already-dead token is not an error worth surfacing, but it
515 // must not be an authenticated action either.
516 if status, _ := postJSON(t, fx.ts.URL+"/auth/cli/revoke", map[string]string{}, token); status != http.StatusUnauthorized {
517 t.Fatalf("second revoke = %d, want 401", status)
518 }
519}
520
521// cliToken walks the whole approval ceremony and hands back a live token, for
522// tests whose subject is what happens AFTER logging in.
523func cliToken(t *testing.T, fx *consoleFixture, address, oauthCode string) string {
524 t.Helper()
525 browser := newBrowser(t)
526 fx.setUser(address)
527 signUp(t, fx, browser, oauthCode)
528 _, start := postJSON(t, fx.ts.URL+"/auth/cli/start", map[string]string{"label": "test@harness"}, "")
529 userCode, deviceCode := str(t, start, "user_code"), str(t, start, "device_code")
530 if code, _, body := postGated(t, fx, browser, fx.ts.URL+"/auth/cli/approve",
531 url.Values{"code": {userCode}, "action": {"approve"}, "csrf": {sessionToken(t, fx, browser)}}); code != http.StatusOK {
532 t.Fatalf("approve = %d:\n%s", code, body)
533 }
534 _, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": deviceCode}, "")
535 tok := str(t, poll, "token")
536 if tok == "" {
537 t.Fatalf("no token from poll: %v", poll)
538 }
539 return tok
540}
541
542// TestPersonalSettingsPage: the CLI-tokens page becomes the personal settings
543// page at /settings. The old URL keeps working as a redirect — Keymail
544// notification bodies already mailed link to it — and a signed-out visitor
545// gets exactly the protection handleCLITokens has always given this page.
546func TestPersonalSettingsPage(t *testing.T) {
547 fx := newConsoleFixture(t)
548 c := signInPaul(t, fx)
549 status, _, body := get(t, c, fx.ts.URL+"/settings")
550 if status != 200 {
551 t.Fatalf("GET /settings = %d", status)
552 }
553 for _, want := range []string{"Your settings", "Command-line access"} {
554 if !strings.Contains(body, want) {
555 t.Fatalf("/settings missing %q", want)
556 }
557 }
558 // The old URL keeps working — Keymail notification bodies link to it.
559 status, loc, _ := get(t, c, fx.ts.URL+"/auth/cli/tokens")
560 if status != http.StatusSeeOther || loc != "/settings" {
561 t.Fatalf("/auth/cli/tokens = %d -> %q, want 303 -> /settings", status, loc)
562 }
563 // Signed out, the personal page must not render.
564 a := newBrowser(t)
565 status, loc, _ = get(t, a, fx.ts.URL+"/settings")
566 if status != http.StatusFound || loc != "/" {
567 t.Fatalf("signed-out /settings = %d -> %q, want 302 -> /", status, loc)
568 }
569}
570
571// TestCLITokensPageRendersAppTokenHonestly: an app token never expires
572// (expires_at 0), and the page must SAY so rather than rendering the Unix
573// epoch — "expired in 1970" is a row a reader dismisses instead of
574// rotating. The row is also labelled with what it is and what it is bound
575// to, because "Revoke" on an unlabelled row is a decision made blind.
576func TestCLITokensPageRendersAppTokenHonestly(t *testing.T) {
577 now := time.Now()
578 p := cliPage{page: page{Title: "Command-line access", Address: "paul@keymail.dev"},
579 Console: "https://console.example", Days: 90,
580 Tokens: []CLIToken{
581 {ID: "aaa", Address: "paul@keymail.dev", Label: "paul@laptop",
582 RemoteAddr: "192.0.2.7", CreatedAt: now, ExpiresAt: now.Add(CLITokenTTL)},
583 {ID: "bbb", Address: "paul@keymail.dev", Label: "tito signup",
584 Kind: cliKindApp, AccountID: 7, App: "tito", CreatedAt: now},
585 }}
586 var buf bytes.Buffer
587 if err := templates.ExecuteTemplate(&buf, "settings", p); err != nil {
588 t.Fatalf("render settings: %v", err)
589 }
590 html := buf.String()
591 if strings.Contains(html, "1970") {
592 t.Errorf("the app token's expiry rendered as the Unix epoch:\n%s", html)
593 }
594 if !strings.Contains(html, "Never") {
595 t.Errorf("the app token's expiry does not say Never:\n%s", html)
596 }
597 if !strings.Contains(html, "tito") {
598 t.Errorf("the app token row does not name the app it is bound to:\n%s", html)
599 }
600 if !strings.Contains(strings.ToLower(html), "app token") {
601 t.Errorf("the app token row is not labelled as one:\n%s", html)
602 }
603 // The user token's own expiry is untouched by the branch.
604 if !strings.Contains(html, now.Add(CLITokenTTL).UTC().Format("2006-01-02")) {
605 t.Errorf("the user token's expiry no longer renders:\n%s", html)
606 }
607}
608
609// ---- rate limits keyed on the principal (Task 5, issue #220 consequence 1) -
610
611// TestServiceBudgetIsNotItsOperatorsBudget is the fix for consequence 1: a
612// server holding a credential must not draw down the budget of the human who
613// minted it. The service exhausts its OWN create budget (svcCreateBurst);
614// its minter's own budget is untouched.
615func TestServiceBudgetIsNotItsOperatorsBudget(t *testing.T) {
616 fx := newConsoleFixture(t)
617 svc := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish)
618
619 for i := 0; i < svcCreateBurst; i++ {
620 name := fmt.Sprintf("svc-a%d", i)
621 if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": name}, svc); status != http.StatusOK {
622 t.Fatalf("service create %d = %d %v", i, status, out)
623 }
624 }
625 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "svc-over"}, svc)
626 if status != http.StatusTooManyRequests {
627 t.Fatalf("service create over budget = %d %v, want 429", status, out)
628 }
629 // The human who minted it has spent nothing.
630 status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "mine"}, cliToken(t, fx, "paul@keymail.dev", "code-svc-budget-human"))
631 if status != http.StatusOK {
632 t.Fatalf("human create after service exhausted its own budget = %d %v, want 200", status, out)
633 }
634}
635
636// TestRotationDoesNotResetTheBudget: the create budget is keyed on the
637// service's NAME (principalKey), not its token hash — rotating mid-flood
638// must buy the caller nothing.
639func TestRotationDoesNotResetTheBudget(t *testing.T) {
640 fx := newConsoleFixture(t)
641 tok := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish)
642 for i := 0; i < svcCreateBurst; i++ {
643 name := fmt.Sprintf("rot-a%d", i)
644 if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": name}, tok); status != http.StatusOK {
645 t.Fatalf("create %d = %d %v", i, status, out)
646 }
647 }
648 fresh, err := fx.st.RotateServicePrincipal(OpsAccountID, "hub", time.Now())
649 if err != nil {
650 t.Fatalf("rotate: %v", err)
651 }
652 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "after-rotate"}, fresh)
653 if status != http.StatusTooManyRequests {
654 t.Errorf("create after rotate = %d %v, want 429 — rotation must not reset the budget", status, out)
655 }
656}
657
658// TestPersonBudgetsAreUnchanged pins that no human's create limit moved: the
659// same cliCreateBurst as before this change, keyed on the same address.
660func TestPersonBudgetsAreUnchanged(t *testing.T) {
661 fx := newConsoleFixture(t)
662 if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil {
663 t.Fatal(err)
664 }
665 tok := cliToken(t, fx, "alice@keymail.dev", "code-person-budget")
666 for i := 0; i < cliCreateBurst; i++ {
667 name := fmt.Sprintf("person-a%d", i)
668 if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": name}, tok); status != http.StatusOK {
669 t.Fatalf("create %d = %d %v", i, status, out)
670 }
671 }
672 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "person-over"}, tok)
673 if status != http.StatusTooManyRequests {
674 t.Errorf("create %d+1 = %d %v, want 429 at the unchanged human limit", cliCreateBurst, status, out)
675 }
676}
677
678// TestTwoNamedServicesHaveSeparateBudgets is Minor 5 (Task 5 review): two
679// DIFFERENT named services in one account must not share a budget just
680// because they share an account — closing this alongside C32's empty-name
681// collapse, since a naive fix for one (keying on account id alone) could
682// still fail the other. Exhausting "hub-a"'s create budget must leave
683// "hub-b"'s untouched.
684func TestTwoNamedServicesHaveSeparateBudgets(t *testing.T) {
685 fx := newConsoleFixture(t)
686 svcA := serviceToken(t, fx.st, OpsAccountID, "hub-a", "", svcrole.Publish)
687 svcB := serviceToken(t, fx.st, OpsAccountID, "hub-b", "", svcrole.Publish)
688 for i := 0; i < svcCreateBurst; i++ {
689 name := fmt.Sprintf("hub-a-%d", i)
690 if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": name}, svcA); status != http.StatusOK {
691 t.Fatalf("hub-a create %d = %d %v", i, status, out)
692 }
693 }
694 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "hub-a-over"}, svcA)
695 if status != http.StatusTooManyRequests {
696 t.Fatalf("hub-a over budget = %d %v, want 429", status, out)
697 }
698 // hub-b, a DIFFERENT named service in the SAME account, is untouched.
699 status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "hub-b-fresh"}, svcB)
700 if status != http.StatusOK {
701 t.Fatalf("hub-b create after hub-a exhausted its own budget = %d %v, want 200", status, out)
702 }
703}
704
705// TestServiceWriteBudgetIsOwnAndSeparateFromHuman: the write budget's own
706// service picker (svcWriteBurst), exercised through a real write door
707// (restart) rather than the picker in isolation — the call site is the part
708// that can get the wrong limiter, not the picker itself.
709func TestServiceWriteBudgetIsOwnAndSeparateFromHuman(t *testing.T) {
710 fx := newConsoleFixture(t)
711 now := time.Now()
712 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
713 t.Fatal(err)
714 }
715 svc := serviceToken(t, fx.st, OpsAccountID, "operator", "titogo", svcrole.Operate)
716 for i := 0; i < svcWriteBurst; i++ {
717 if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", map[string]string{}, svc); status != http.StatusOK {
718 t.Fatalf("service restart %d = %d %v", i, status, out)
719 }
720 }
721 if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", map[string]string{}, svc); status != http.StatusTooManyRequests {
722 t.Fatalf("service restart over budget = %d %v, want 429", status, out)
723 }
724 human := cliToken(t, fx, "paul@keymail.dev", "code-write-budget-human")
725 if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", map[string]string{}, human); status != http.StatusOK {
726 t.Fatalf("human restart after service exhausted its own budget = %d %v, want 200", status, out)
727 }
728}
729
730// TestServiceShipBudgetIsOwnAndKeyedByName: ship's service branch gets its
731// own budget (svcShipBurst) keyed the same way every other service limiter
732// is (principalKey — the NAME), unlike the person branch below.
733func TestServiceShipBudgetIsOwnAndKeyedByName(t *testing.T) {
734 fx := newConsoleFixture(t)
735 now := time.Now()
736 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
737 t.Fatal(err)
738 }
739 svc := serviceToken(t, fx.st, OpsAccountID, "shipper", "titogo", svcrole.Publish)
740 for i := 0; i < svcShipBurst; i++ {
741 v := fmt.Sprintf("v%03d", i)
742 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", svc,
743 []shipPart{metaPart(t, v, "binary"), {name: "linux-arm64", body: []byte("x")}})
744 if status != http.StatusCreated {
745 t.Fatalf("service ship %d = %d %v", i, status, out)
746 }
747 }
748 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", svc,
749 []shipPart{metaPart(t, "over-budget", "binary"), {name: "linux-arm64", body: []byte("x")}})
750 if status != http.StatusTooManyRequests {
751 t.Fatalf("service ship over budget = %d %v, want 429", status, out)
752 }
753 fresh, err := fx.st.RotateServicePrincipal(OpsAccountID, "shipper", time.Now())
754 if err != nil {
755 t.Fatalf("rotate: %v", err)
756 }
757 status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", fresh,
758 []shipPart{metaPart(t, "after-rotate", "binary"), {name: "linux-arm64", body: []byte("x")}})
759 if status != http.StatusTooManyRequests {
760 t.Fatalf("service ship after rotate = %d %v, want 429 — rotation must not reset the budget", status, out)
761 }
762}
763
764// TestShipRefusalNamesTheWait: a refused ship has to say WHEN — in a header
765// a script can read and in prose a human can.
766//
767// Until 2026-08-23 it said "wait a while" over an HOUR-long fixed window,
768// which is the worst pair of answers on this surface: the caller that meets
769// this budget most is one shipping in a loop, and it could not tell a short
770// pause from a fifty-five-minute lockout, so what it concluded was that
771// shipping had stopped working. Naming the wait is only worth doing because
772// the window is now a minute; the two changes are one change.
773//
774// The Retry-After ceiling is asserted as cliShipWindow, not as the literal
775// 60: that is the one line here that fails if the window goes back to an
776// hour, which is the regression this test is really for.
777func TestShipRefusalNamesTheWait(t *testing.T) {
778 fx := newConsoleFixture(t)
779 now := time.Now()
780 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
781 t.Fatal(err)
782 }
783 token := cliToken(t, fx, "paul@keymail.dev", "code-ship-retryafter")
784 for i := 0; i < cliShipBurst; i++ {
785 v := fmt.Sprintf("w%03d", i)
786 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
787 []shipPart{metaPart(t, v, "binary"), {name: "linux-arm64", body: []byte("x")}})
788 if status != http.StatusCreated {
789 t.Fatalf("ship %d = %d %v", i, status, out)
790 }
791 }
792 status, hdr, out := postShipRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
793 []shipPart{metaPart(t, "over", "binary"), {name: "linux-arm64", body: []byte("x")}})
794 if status != http.StatusTooManyRequests {
795 t.Fatalf("ship over budget = %d %v, want 429", status, out)
796 }
797 secs, err := strconv.Atoi(hdr.Get("Retry-After"))
798 if err != nil {
799 t.Fatalf("Retry-After = %q, want a whole number of seconds", hdr.Get("Retry-After"))
800 }
801 // Never 0 (an invitation to spin straight back into the refusal) and
802 // never longer than the window it is waiting out.
803 if secs < 1 || time.Duration(secs)*time.Second > cliShipWindow {
804 t.Fatalf("Retry-After = %ds, want 1..%d — a wait a caller can sleep on rather than give up on",
805 secs, cliShipWindow/time.Second)
806 }
807 if msg := str(t, out, "error"); !strings.Contains(msg, fmt.Sprintf("%ds", secs)) {
808 t.Fatalf("refusal %q does not name the %ds wait its own Retry-After promises — the human and the script must be told the same thing", msg, secs)
809 }
810}
811
812// TestPersonShipBudgetStaysKeyedByToken pins the one branch the brief says
813// must NOT move: a person's ship budget stays keyed on the TOKEN HASH, not
814// the address (handleAPIShip's own comment) — a second token for the same
815// address gets a FRESH ship budget, unlike every other write door here.
816func TestPersonShipBudgetStaysKeyedByToken(t *testing.T) {
817 fx := newConsoleFixture(t)
818 now := time.Now()
819 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
820 t.Fatal(err)
821 }
822 tok1 := cliToken(t, fx, "paul@keymail.dev", "code-ship-key-1")
823 for i := 0; i < cliShipBurst; i++ {
824 v := fmt.Sprintf("p%03d", i)
825 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", tok1,
826 []shipPart{metaPart(t, v, "binary"), {name: "linux-arm64", body: []byte("x")}})
827 if status != http.StatusCreated {
828 t.Fatalf("ship %d = %d %v", i, status, out)
829 }
830 }
831 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", tok1,
832 []shipPart{metaPart(t, "over", "binary"), {name: "linux-arm64", body: []byte("x")}})
833 if status != http.StatusTooManyRequests {
834 t.Fatalf("ship over budget = %d %v, want 429", status, out)
835 }
836 tok2 := cliToken(t, fx, "paul@keymail.dev", "code-ship-key-2")
837 status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", tok2,
838 []shipPart{metaPart(t, "fresh-token", "binary"), {name: "linux-arm64", body: []byte("x")}})
839 if status != http.StatusCreated {
840 t.Fatalf("second token same address = %d %v, want 201 (ship stays token-keyed)", status, out)
841 }
842}
843