package console import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/url" "path/filepath" "strconv" "strings" "testing" "time" "github.com/carlosframework/platform/internal/svcrole" ) // postJSON is how the CLI talks to the console: a JSON body, an optional // bearer token, and no cookies. func postJSON(t *testing.T, u string, body any, bearer string) (int, map[string]any) { t.Helper() raw, err := json.Marshal(body) if err != nil { t.Fatal(err) } req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(raw)) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/json") if bearer != "" { req.Header.Set("Authorization", "Bearer "+bearer) } return doJSON(t, req) } func getJSON(t *testing.T, u, bearer string) (int, map[string]any) { t.Helper() req, err := http.NewRequest(http.MethodGet, u, nil) if err != nil { t.Fatal(err) } if bearer != "" { req.Header.Set("Authorization", "Bearer "+bearer) } return doJSON(t, req) } func doJSON(t *testing.T, req *http.Request) (int, map[string]any) { t.Helper() res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer res.Body.Close() raw, err := io.ReadAll(res.Body) if err != nil { t.Fatal(err) } out := map[string]any{} if len(bytes.TrimSpace(raw)) > 0 { if err := json.Unmarshal(raw, &out); err != nil { t.Fatalf("%s %s: body is not JSON: %v\n%s", req.Method, req.URL, err, raw) } } return res.StatusCode, out } func str(t *testing.T, m map[string]any, k string) string { t.Helper() v, _ := m[k].(string) return v } // TestCLILoginEndToEnd is the whole ceremony against the real handlers: a // terminal starts a login, a signed-in human approves it in a browser, the // terminal collects a token, uses it, sees it listed, and revokes it. func TestCLILoginEndToEnd(t *testing.T) { fx := newConsoleFixture(t) browser := newBrowser(t) signUp(t, fx, browser, "code-cli") status, start := postJSON(t, fx.ts.URL+"/auth/cli/start", map[string]string{"label": "paul@laptop"}, "") if status != http.StatusOK { t.Fatalf("start = %d %v", status, start) } userCode, deviceCode := str(t, start, "user_code"), str(t, start, "device_code") if userCode == "" || deviceCode == "" { t.Fatalf("start returned %v, want both codes", start) } if got, want := str(t, start, "verification_uri"), fx.ts.URL+"/auth/cli"; got != want { t.Fatalf("verification_uri = %q, want %q", got, want) } if got := str(t, start, "verification_uri_complete"); !strings.Contains(got, url.QueryEscape(userCode)) { t.Fatalf("verification_uri_complete = %q, want the code in it", got) } // Nothing yet. if _, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": deviceCode}, ""); str(t, poll, "status") != CLIAuthPending { t.Fatalf("poll before approval = %v, want pending", poll) } // Typing the code is what produces a described terminal — following a // link never does (see TestCLIApprovalPageNeverApprovesFromALink). csrf := sessionToken(t, fx, browser) code, _, body := postForm(t, browser, fx.ts.URL+"/auth/cli/approve", url.Values{"code": {userCode}, "action": {"continue"}, "csrf": {csrf}}) if code != http.StatusOK { t.Fatalf("continue = %d", code) } for _, want := range []string{"paul@laptop", userCode, "Approve"} { if !strings.Contains(body, want) { t.Fatalf("approval page missing %q in:\n%s", want, body) } } // CSRF: the session token double-submitted, exactly like every other // mutation on this console. if code, _, _ := postForm(t, browser, fx.ts.URL+"/auth/cli/approve", url.Values{"code": {userCode}, "action": {"approve"}, "csrf": {"wrong"}}); code != http.StatusForbidden { t.Fatalf("approve with a bad CSRF token = %d, want 403", code) } // And a signed-out browser cannot approve at all. if code, loc, _ := postForm(t, newBrowser(t), fx.ts.URL+"/auth/cli/approve", url.Values{"code": {userCode}, "action": {"approve"}, "csrf": {csrf}}); code != http.StatusFound || loc != "/" { t.Fatalf("signed-out approve = %d -> %q, want 302 -> /", code, loc) } // Approving mints a 30-day credential, so it is behind passkey step-up // (stepup.go): a direct POST is answered with a trip through Keymail, and // only the confirmed round trip approves. if code, loc, _ := postForm(t, browser, fx.ts.URL+"/auth/cli/approve", url.Values{"code": {userCode}, "action": {"approve"}, "csrf": {csrf}}); code != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) { t.Fatalf("approve without a fresh passkey = %d -> %q, want a redirect into Keymail", code, loc) } if _, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": deviceCode}, ""); str(t, poll, "status") == CLIAuthApproved { t.Fatal("the terminal was approved before the passkey confirmation") } code, _, body = postGated(t, fx, browser, fx.ts.URL+"/auth/cli/approve", url.Values{"code": {userCode}, "action": {"approve"}, "csrf": {csrf}}) if code != http.StatusOK || !strings.Contains(body, "Approved") { t.Fatalf("approve = %d, body:\n%s", code, body) } _, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": deviceCode}, "") if str(t, poll, "status") != CLIAuthApproved { t.Fatalf("poll after approval = %v", poll) } token := str(t, poll, "token") if token == "" || str(t, poll, "address") != "paul@keymail.dev" { t.Fatalf("poll after approval = %v, want a token bound to paul", poll) } // The token identifies its holder — and only with the right bearer. status, who := getJSON(t, fx.ts.URL+"/auth/cli/whoami", token) if status != http.StatusOK || str(t, who, "address") != "paul@keymail.dev" { t.Fatalf("whoami = %d %v", status, who) } if status, _ := getJSON(t, fx.ts.URL+"/auth/cli/whoami", token+"x"); status != http.StatusUnauthorized { t.Fatalf("whoami with a wrong token = %d, want 401", status) } if status, _ := getJSON(t, fx.ts.URL+"/auth/cli/whoami", ""); status != http.StatusUnauthorized { t.Fatalf("whoami with no token = %d, want 401", status) } // The owner can see it, and kill it, from the console. code, _, body = get(t, browser, fx.ts.URL+"/settings") if code != http.StatusOK || !strings.Contains(body, "paul@laptop") { t.Fatalf("tokens page = %d, body:\n%s", code, body) } if strings.Contains(body, token) { t.Fatal("the tokens page prints the bearer token itself") } id := "" toks, err := fx.st.CLITokens("paul@keymail.dev") if err != nil { t.Fatal(err) } if len(toks) != 1 { t.Fatalf("CLITokens = %d, want 1", len(toks)) } id = toks[0].ID if code, _, _ := postForm(t, browser, fx.ts.URL+"/auth/cli/tokens/revoke", url.Values{"id": {id}, "csrf": {"wrong"}}); code != http.StatusForbidden { t.Fatalf("revoke with a bad CSRF token = %d, want 403", code) } // Revoking is step-up gated too (the same session that has just confirmed // a passkey is still inside the window here, so no second trip is made). if code, _, _ := postGated(t, fx, browser, fx.ts.URL+"/auth/cli/tokens/revoke", url.Values{"id": {id}, "csrf": {csrf}}); code != http.StatusSeeOther { t.Fatalf("revoke = %d, want 303", code) } if status, _ := getJSON(t, fx.ts.URL+"/auth/cli/whoami", token); status != http.StatusUnauthorized { t.Fatalf("whoami after revocation = %d, want 401", status) } } // The device-code phishing attack, refused. An attacker starts a login, sends // the victim the ready-made verification_uri_complete with a pretext, and the // victim clicks it while signed in. If that page rendered an Approve button // for the attacker's code, one click would bind a 90-day credential — held by // the attacker — to the victim's identity. This is the campaign that has been // run against Microsoft tenants at scale, so the page must not do it: no // lookup, no description, no Approve button, and an EMPTY input the human has // to type into off their own screen. func TestCLIApprovalPageNeverApprovesFromALink(t *testing.T) { fx := newConsoleFixture(t) victim := newBrowser(t) signUp(t, fx, victim, "code-phish") // The attacker's terminal, started from somewhere else entirely. _, start := postJSON(t, fx.ts.URL+"/auth/cli/start", map[string]string{"label": "attacker@vps"}, "") userCode := str(t, start, "user_code") status, _, body := get(t, victim, fx.ts.URL+"/auth/cli?code="+url.QueryEscape(userCode)) if status != http.StatusOK { t.Fatalf("approval page = %d", status) } // The code is echoed to be CHECKED against the terminal's screen… if !strings.Contains(body, userCode) { t.Fatalf("the page hides the code the link carried, so it cannot be checked:\n%s", body) } // …but never described (that would also make this an enumeration oracle)… if strings.Contains(body, "attacker@vps") { t.Fatalf("the page resolved a code from a URL:\n%s", body) } // …and never one click from approval. if strings.Contains(body, `value="approve"`) { t.Fatalf("the page rendered an Approve button for a code from a URL:\n%s", body) } if strings.Contains(body, `name="code" value="`+userCode) { t.Fatalf("the page prefilled the typed-code field from the URL:\n%s", body) } // The request really is still pending — the page did not consume it, it // simply refused to act on it. _, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": str(t, start, "device_code")}, "") if str(t, poll, "status") != CLIAuthPending { t.Fatalf("poll = %v, want the request untouched and pending", poll) } } // Issuing a credential tells the human, in the inbox their identity is // anchored to. Every other defence here is preventive; this is the one that // makes a phished approval detectable afterwards. func TestCLIIssuanceNotifiesTheUser(t *testing.T) { fx := newConsoleFixture(t) browser := newBrowser(t) signUp(t, fx, browser, "code-notify") fx.srv.cliNotified = make(chan struct{}, 1) _, start := postJSON(t, fx.ts.URL+"/auth/cli/start", map[string]string{"label": "paul@laptop"}, "") userCode, deviceCode := str(t, start, "user_code"), str(t, start, "device_code") postGated(t, fx, browser, fx.ts.URL+"/auth/cli/approve", url.Values{"code": {userCode}, "action": {"approve"}, "csrf": {sessionToken(t, fx, browser)}}) if _, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": deviceCode}, ""); str(t, poll, "token") == "" { t.Fatalf("no token issued: %v", poll) } select { case <-fx.srv.cliNotified: case <-time.After(5 * time.Second): t.Fatal("no notification attempt after a token was issued") } var found string for _, body := range fx.fk.sentBodies() { if strings.Contains(body, "command line") || strings.Contains(body, "terminal") { found = body } } if found == "" { t.Fatalf("no issuance notice in the sealed sends: %v", fx.fk.sentBodies()) } for _, want := range []string{"paul@laptop", "/settings", "revoke"} { if !strings.Contains(found, want) { t.Fatalf("issuance notice missing %q:\n%s", want, found) } } } // Someone who signs in by email has no Keymail grant. The issuance notice // still reaches them, by the console mailer; someone with a grant still gets // the sealed Keymail send and no email. func TestCLIIssuedNoticeByEmail(t *testing.T) { fx, m := newEmailFixture(t, true) fx.srv.cliNotified = make(chan struct{}, 2) wait := func() { t.Helper() select { case <-fx.srv.cliNotified: case <-time.After(5 * time.Second): t.Fatal("notifyCLIIssued never finished") } } fx.srv.notifyCLIIssued("new@example.com", "raw-token") wait() sent := m.sentCopy() if len(sent) != 1 { t.Fatalf("console mailer got %d messages, want 1: %q", len(sent), sent) } to, body, _ := strings.Cut(sent[0], "|") if to != "new@example.com" { t.Fatalf("notice went to %q", to) } for _, want := range []string{"A terminal was just approved to act as new@example.com", "/settings", "revoke"} { if !strings.Contains(body, want) { t.Fatalf("notice missing %q:\n%s", want, body) } } if err := fx.st.SetGrantToken("granted@example.com", "grant-tok", time.Now()); err != nil { t.Fatal(err) } fx.srv.notifyCLIIssued("granted@example.com", "raw-token-2") wait() if got := m.sentCopy(); len(got) != 1 { t.Fatalf("a grant holder was also emailed: %q", got) } } // X-Forwarded-For is only believed from a peer we put there. Otherwise one // caller presents a fresh "IP" per request and walks past every per-IP limit. func TestClientIPIgnoresUntrustedForwardedFor(t *testing.T) { st, err := Open(filepath.Join(t.TempDir(), "console.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) // No bucket, so no mesh key and no proof has ever been seen: loopback // keeps the trust it had before proofs existed (edgeproof.go). s := &Server{cfg: Config{EdgeIPs: []string{"198.51.100.9"}}, store: st} r := &http.Request{Header: http.Header{}, RemoteAddr: "203.0.113.5:41234"} r.Header.Set("X-Forwarded-For", "1.2.3.4") if got := s.clientIP(r); got != "203.0.113.5" { t.Fatalf("clientIP from an untrusted peer = %q, want the peer address", got) } // The configured edge, and the loopback edge on this box, are believed — // and it is the LAST element that is theirs to state. for _, peer := range []string{"198.51.100.9:9", "127.0.0.1:9"} { r.RemoteAddr = peer r.Header.Set("X-Forwarded-For", "9.9.9.9, 1.2.3.4") if got := s.clientIP(r); got != "1.2.3.4" { t.Fatalf("clientIP behind %s = %q, want 1.2.3.4", peer, got) } } } // The label is the one field a human is asked to recognise, and the machine // asking chooses it. Bidi overrides and zero-width joiners exist to make a // string render as something it is not. func TestCleanLabelIsPrintableASCII(t *testing.T) { for in, want := range map[string]string{ "paul@laptop": "paul@laptop", "paul\u202e@evil": "paul@evil", "paul\u200b@laptop": "paul@laptop", "paul\x07@laptop\n": "paul@laptop", " paul@laptop ": "paul@laptop", "héllo@laptop": "hllo@laptop", strings.Repeat("a", 200): strings.Repeat("a", 64), } { if got := cleanLabel(in); got != want { t.Errorf("cleanLabel(%q) = %q, want %q", in, got, want) } } } // A denied login is final and tells the terminal so. func TestCLILoginDeny(t *testing.T) { fx := newConsoleFixture(t) browser := newBrowser(t) signUp(t, fx, browser, "code-deny") _, start := postJSON(t, fx.ts.URL+"/auth/cli/start", map[string]string{"label": "someone@elsewhere"}, "") userCode, deviceCode := str(t, start, "user_code"), str(t, start, "device_code") code, _, body := postForm(t, browser, fx.ts.URL+"/auth/cli/approve", url.Values{"code": {userCode}, "action": {"deny"}, "csrf": {sessionToken(t, fx, browser)}}) if code != http.StatusOK || !strings.Contains(body, "Denied") { t.Fatalf("deny = %d, body:\n%s", code, body) } _, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": deviceCode}, "") if str(t, poll, "status") != CLIAuthDenied { t.Fatalf("poll after deny = %v, want denied", poll) } } // The JSON endpoints refuse a form-encoded body. That is the CSRF story for // the unauthenticated half of the flow: a cross-site
can only send // form encodings, and anything that can set application/json has already been // through a CORS preflight this console never answers. func TestCLIStartRefusesFormEncoding(t *testing.T) { fx := newConsoleFixture(t) res, err := http.PostForm(fx.ts.URL+"/auth/cli/start", url.Values{"label": {"x"}}) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusUnsupportedMediaType { t.Fatalf("form-encoded start = %d, want 415", res.StatusCode) } } // An unauthenticated endpoint that mints state is rate-limited, or it is a // way to fill a database from the internet. func TestCLIStartRateLimited(t *testing.T) { fx := newConsoleFixture(t) limited := false for i := 0; i < cliStartBurst+3; i++ { status, _ := postJSON(t, fx.ts.URL+"/auth/cli/start", map[string]string{"label": "x"}, "") if status == http.StatusTooManyRequests { limited = true break } } if !limited { t.Fatalf("start was never rate-limited in %d attempts", cliStartBurst+3) } } // Guessing the short typed code is the one brute-forceable step, so wrong // codes are counted — at BOTH places a code is resolved, and against the // signed-in address and the client IP rather than the session token (which is // re-minted by signing in again, and so resets a counter keyed on it). // // The payoff being bounded here is the inverted one: guessing somebody else's // pending code and approving it binds THEIR terminal to YOUR address. func TestCLIApproveRateLimitsWrongCodes(t *testing.T) { for _, action := range []string{"continue", "approve"} { t.Run(action, func(t *testing.T) { fx := newConsoleFixture(t) browser := newBrowser(t) signUp(t, fx, browser, "code-brute-"+action) csrf := sessionToken(t, fx, browser) limited := false for i := 0; i < cliGuessBurst+3; i++ { // postGated, because approve is step-up gated: the first // attempt walks the passkey round trip, the rest are inside // the window. The limiter still has to bite either way — a // confirmed human is not licensed to guess codes. code, _, _ := postGated(t, fx, browser, fx.ts.URL+"/auth/cli/approve", url.Values{"code": {"AAAA-AAAA"}, "action": {action}, "csrf": {csrf}}) if code == http.StatusTooManyRequests { limited = true break } } if !limited { t.Fatalf("wrong codes were never rate-limited in %d attempts", cliGuessBurst+3) } }) } } func TestCLIPollUnknownDeviceCode(t *testing.T) { fx := newConsoleFixture(t) status, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": "nope"}, "") if status != http.StatusOK || str(t, poll, "status") != CLIAuthUnknown { t.Fatalf("poll with a made-up device code = %d %v, want 200 unknown", status, poll) } } // The account a logged-in CLI creates has the logged-in human as its owner — // the whole reason this feature exists. An ownerless account was invisible to // its own creator in the console. func TestCLICreatesAccountOwnedByTheToken(t *testing.T) { fx := newConsoleFixture(t) token := cliToken(t, fx, "paul@keymail.dev", "code-acct") status, out := postJSON(t, fx.ts.URL+"/auth/cli/accounts", map[string]string{"name": "kass"}, token) if status != http.StatusOK { t.Fatalf("accounts create = %d %v", status, out) } sqid := str(t, out, "sqid") if sqid == "" || str(t, out, "name") != "kass" || str(t, out, "owner") != "paul@keymail.dev" { t.Fatalf("accounts create returned %v", out) } acct, err := fx.st.AccountBySqid(sqid) if err != nil || acct == nil { t.Fatalf("AccountBySqid(%s) = %v %v", sqid, acct, err) } role, err := fx.st.Role(acct.ID, "paul@keymail.dev") if err != nil || role != RoleOwner { t.Fatalf("role = %q %v, want owner", role, err) } // The manifest is in the bucket too — the account exists for every box, // not just for this console's database. if _, _, err := fx.rel.Get(t.Context(), AccountManifestKey(sqid)); err != nil { t.Fatalf("account manifest: %v", err) } // And an unauthenticated caller mints nothing. if status, _ := postJSON(t, fx.ts.URL+"/auth/cli/accounts", map[string]string{"name": "Sneaky"}, ""); status != http.StatusUnauthorized { t.Fatalf("unauthenticated accounts create = %d, want 401", status) } if status, _ := postJSON(t, fx.ts.URL+"/auth/cli/accounts", map[string]string{"name": ""}, token); status != http.StatusBadRequest { t.Fatalf("nameless accounts create = %d, want 400", status) } // A non-slug name is refused the same way: it would appear in URLs and // -account exactly as typed. if status, _ := postJSON(t, fx.ts.URL+"/auth/cli/accounts", map[string]string{"name": "Paul's Stuff!"}, token); status != http.StatusBadRequest { t.Fatalf("non-slug accounts create = %d, want 400", status) } } // `carlos auth logout` retires the credential rather than just forgetting it. func TestCLILogoutRevokes(t *testing.T) { fx := newConsoleFixture(t) token := cliToken(t, fx, "paul@keymail.dev", "code-logout") if status, _ := postJSON(t, fx.ts.URL+"/auth/cli/revoke", map[string]string{}, token); status != http.StatusOK { t.Fatalf("revoke = %d, want 200", status) } if status, _ := getJSON(t, fx.ts.URL+"/auth/cli/whoami", token); status != http.StatusUnauthorized { t.Fatalf("whoami after logout = %d, want 401", status) } // Revoking an already-dead token is not an error worth surfacing, but it // must not be an authenticated action either. if status, _ := postJSON(t, fx.ts.URL+"/auth/cli/revoke", map[string]string{}, token); status != http.StatusUnauthorized { t.Fatalf("second revoke = %d, want 401", status) } } // cliToken walks the whole approval ceremony and hands back a live token, for // tests whose subject is what happens AFTER logging in. func cliToken(t *testing.T, fx *consoleFixture, address, oauthCode string) string { t.Helper() browser := newBrowser(t) fx.setUser(address) signUp(t, fx, browser, oauthCode) _, start := postJSON(t, fx.ts.URL+"/auth/cli/start", map[string]string{"label": "test@harness"}, "") userCode, deviceCode := str(t, start, "user_code"), str(t, start, "device_code") if code, _, body := postGated(t, fx, browser, fx.ts.URL+"/auth/cli/approve", url.Values{"code": {userCode}, "action": {"approve"}, "csrf": {sessionToken(t, fx, browser)}}); code != http.StatusOK { t.Fatalf("approve = %d:\n%s", code, body) } _, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": deviceCode}, "") tok := str(t, poll, "token") if tok == "" { t.Fatalf("no token from poll: %v", poll) } return tok } // TestPersonalSettingsPage: the CLI-tokens page becomes the personal settings // page at /settings. The old URL keeps working as a redirect — Keymail // notification bodies already mailed link to it — and a signed-out visitor // gets exactly the protection handleCLITokens has always given this page. func TestPersonalSettingsPage(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) status, _, body := get(t, c, fx.ts.URL+"/settings") if status != 200 { t.Fatalf("GET /settings = %d", status) } for _, want := range []string{"Your settings", "Command-line access"} { if !strings.Contains(body, want) { t.Fatalf("/settings missing %q", want) } } // The old URL keeps working — Keymail notification bodies link to it. status, loc, _ := get(t, c, fx.ts.URL+"/auth/cli/tokens") if status != http.StatusSeeOther || loc != "/settings" { t.Fatalf("/auth/cli/tokens = %d -> %q, want 303 -> /settings", status, loc) } // Signed out, the personal page must not render. a := newBrowser(t) status, loc, _ = get(t, a, fx.ts.URL+"/settings") if status != http.StatusFound || loc != "/" { t.Fatalf("signed-out /settings = %d -> %q, want 302 -> /", status, loc) } } // TestCLITokensPageRendersAppTokenHonestly: an app token never expires // (expires_at 0), and the page must SAY so rather than rendering the Unix // epoch — "expired in 1970" is a row a reader dismisses instead of // rotating. The row is also labelled with what it is and what it is bound // to, because "Revoke" on an unlabelled row is a decision made blind. func TestCLITokensPageRendersAppTokenHonestly(t *testing.T) { now := time.Now() p := cliPage{page: page{Title: "Command-line access", Address: "paul@keymail.dev"}, Console: "https://console.example", Days: 90, Tokens: []CLIToken{ {ID: "aaa", Address: "paul@keymail.dev", Label: "paul@laptop", RemoteAddr: "192.0.2.7", CreatedAt: now, ExpiresAt: now.Add(CLITokenTTL)}, {ID: "bbb", Address: "paul@keymail.dev", Label: "tito signup", Kind: cliKindApp, AccountID: 7, App: "tito", CreatedAt: now}, }} var buf bytes.Buffer if err := templates.ExecuteTemplate(&buf, "settings", p); err != nil { t.Fatalf("render settings: %v", err) } html := buf.String() if strings.Contains(html, "1970") { t.Errorf("the app token's expiry rendered as the Unix epoch:\n%s", html) } if !strings.Contains(html, "Never") { t.Errorf("the app token's expiry does not say Never:\n%s", html) } if !strings.Contains(html, "tito") { t.Errorf("the app token row does not name the app it is bound to:\n%s", html) } if !strings.Contains(strings.ToLower(html), "app token") { t.Errorf("the app token row is not labelled as one:\n%s", html) } // The user token's own expiry is untouched by the branch. if !strings.Contains(html, now.Add(CLITokenTTL).UTC().Format("2006-01-02")) { t.Errorf("the user token's expiry no longer renders:\n%s", html) } } // ---- rate limits keyed on the principal (Task 5, issue #220 consequence 1) - // TestServiceBudgetIsNotItsOperatorsBudget is the fix for consequence 1: a // server holding a credential must not draw down the budget of the human who // minted it. The service exhausts its OWN create budget (svcCreateBurst); // its minter's own budget is untouched. func TestServiceBudgetIsNotItsOperatorsBudget(t *testing.T) { fx := newConsoleFixture(t) svc := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish) for i := 0; i < svcCreateBurst; i++ { name := fmt.Sprintf("svc-a%d", i) if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": name}, svc); status != http.StatusOK { t.Fatalf("service create %d = %d %v", i, status, out) } } status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "svc-over"}, svc) if status != http.StatusTooManyRequests { t.Fatalf("service create over budget = %d %v, want 429", status, out) } // The human who minted it has spent nothing. 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")) if status != http.StatusOK { t.Fatalf("human create after service exhausted its own budget = %d %v, want 200", status, out) } } // TestRotationDoesNotResetTheBudget: the create budget is keyed on the // service's NAME (principalKey), not its token hash — rotating mid-flood // must buy the caller nothing. func TestRotationDoesNotResetTheBudget(t *testing.T) { fx := newConsoleFixture(t) tok := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish) for i := 0; i < svcCreateBurst; i++ { name := fmt.Sprintf("rot-a%d", i) if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": name}, tok); status != http.StatusOK { t.Fatalf("create %d = %d %v", i, status, out) } } fresh, err := fx.st.RotateServicePrincipal(OpsAccountID, "hub", time.Now()) if err != nil { t.Fatalf("rotate: %v", err) } status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "after-rotate"}, fresh) if status != http.StatusTooManyRequests { t.Errorf("create after rotate = %d %v, want 429 — rotation must not reset the budget", status, out) } } // TestPersonBudgetsAreUnchanged pins that no human's create limit moved: the // same cliCreateBurst as before this change, keyed on the same address. func TestPersonBudgetsAreUnchanged(t *testing.T) { fx := newConsoleFixture(t) if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } tok := cliToken(t, fx, "alice@keymail.dev", "code-person-budget") for i := 0; i < cliCreateBurst; i++ { name := fmt.Sprintf("person-a%d", i) if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": name}, tok); status != http.StatusOK { t.Fatalf("create %d = %d %v", i, status, out) } } status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "person-over"}, tok) if status != http.StatusTooManyRequests { t.Errorf("create %d+1 = %d %v, want 429 at the unchanged human limit", cliCreateBurst, status, out) } } // TestTwoNamedServicesHaveSeparateBudgets is Minor 5 (Task 5 review): two // DIFFERENT named services in one account must not share a budget just // because they share an account — closing this alongside C32's empty-name // collapse, since a naive fix for one (keying on account id alone) could // still fail the other. Exhausting "hub-a"'s create budget must leave // "hub-b"'s untouched. func TestTwoNamedServicesHaveSeparateBudgets(t *testing.T) { fx := newConsoleFixture(t) svcA := serviceToken(t, fx.st, OpsAccountID, "hub-a", "", svcrole.Publish) svcB := serviceToken(t, fx.st, OpsAccountID, "hub-b", "", svcrole.Publish) for i := 0; i < svcCreateBurst; i++ { name := fmt.Sprintf("hub-a-%d", i) if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": name}, svcA); status != http.StatusOK { t.Fatalf("hub-a create %d = %d %v", i, status, out) } } status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "hub-a-over"}, svcA) if status != http.StatusTooManyRequests { t.Fatalf("hub-a over budget = %d %v, want 429", status, out) } // hub-b, a DIFFERENT named service in the SAME account, is untouched. status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "hub-b-fresh"}, svcB) if status != http.StatusOK { t.Fatalf("hub-b create after hub-a exhausted its own budget = %d %v, want 200", status, out) } } // TestServiceWriteBudgetIsOwnAndSeparateFromHuman: the write budget's own // service picker (svcWriteBurst), exercised through a real write door // (restart) rather than the picker in isolation — the call site is the part // that can get the wrong limiter, not the picker itself. func TestServiceWriteBudgetIsOwnAndSeparateFromHuman(t *testing.T) { fx := newConsoleFixture(t) now := time.Now() if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { t.Fatal(err) } svc := serviceToken(t, fx.st, OpsAccountID, "operator", "titogo", svcrole.Operate) for i := 0; i < svcWriteBurst; i++ { if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", map[string]string{}, svc); status != http.StatusOK { t.Fatalf("service restart %d = %d %v", i, status, out) } } if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", map[string]string{}, svc); status != http.StatusTooManyRequests { t.Fatalf("service restart over budget = %d %v, want 429", status, out) } human := cliToken(t, fx, "paul@keymail.dev", "code-write-budget-human") if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", map[string]string{}, human); status != http.StatusOK { t.Fatalf("human restart after service exhausted its own budget = %d %v, want 200", status, out) } } // TestServiceShipBudgetIsOwnAndKeyedByName: ship's service branch gets its // own budget (svcShipBurst) keyed the same way every other service limiter // is (principalKey — the NAME), unlike the person branch below. func TestServiceShipBudgetIsOwnAndKeyedByName(t *testing.T) { fx := newConsoleFixture(t) now := time.Now() if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { t.Fatal(err) } svc := serviceToken(t, fx.st, OpsAccountID, "shipper", "titogo", svcrole.Publish) for i := 0; i < svcShipBurst; i++ { v := fmt.Sprintf("v%03d", i) status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", svc, []shipPart{metaPart(t, v, "binary"), {name: "linux-arm64", body: []byte("x")}}) if status != http.StatusCreated { t.Fatalf("service ship %d = %d %v", i, status, out) } } status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", svc, []shipPart{metaPart(t, "over-budget", "binary"), {name: "linux-arm64", body: []byte("x")}}) if status != http.StatusTooManyRequests { t.Fatalf("service ship over budget = %d %v, want 429", status, out) } fresh, err := fx.st.RotateServicePrincipal(OpsAccountID, "shipper", time.Now()) if err != nil { t.Fatalf("rotate: %v", err) } status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", fresh, []shipPart{metaPart(t, "after-rotate", "binary"), {name: "linux-arm64", body: []byte("x")}}) if status != http.StatusTooManyRequests { t.Fatalf("service ship after rotate = %d %v, want 429 — rotation must not reset the budget", status, out) } } // TestShipRefusalNamesTheWait: a refused ship has to say WHEN — in a header // a script can read and in prose a human can. // // Until 2026-08-23 it said "wait a while" over an HOUR-long fixed window, // which is the worst pair of answers on this surface: the caller that meets // this budget most is one shipping in a loop, and it could not tell a short // pause from a fifty-five-minute lockout, so what it concluded was that // shipping had stopped working. Naming the wait is only worth doing because // the window is now a minute; the two changes are one change. // // The Retry-After ceiling is asserted as cliShipWindow, not as the literal // 60: that is the one line here that fails if the window goes back to an // hour, which is the regression this test is really for. func TestShipRefusalNamesTheWait(t *testing.T) { fx := newConsoleFixture(t) now := time.Now() if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-ship-retryafter") for i := 0; i < cliShipBurst; i++ { v := fmt.Sprintf("w%03d", i) status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, []shipPart{metaPart(t, v, "binary"), {name: "linux-arm64", body: []byte("x")}}) if status != http.StatusCreated { t.Fatalf("ship %d = %d %v", i, status, out) } } status, hdr, out := postShipRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, []shipPart{metaPart(t, "over", "binary"), {name: "linux-arm64", body: []byte("x")}}) if status != http.StatusTooManyRequests { t.Fatalf("ship over budget = %d %v, want 429", status, out) } secs, err := strconv.Atoi(hdr.Get("Retry-After")) if err != nil { t.Fatalf("Retry-After = %q, want a whole number of seconds", hdr.Get("Retry-After")) } // Never 0 (an invitation to spin straight back into the refusal) and // never longer than the window it is waiting out. if secs < 1 || time.Duration(secs)*time.Second > cliShipWindow { t.Fatalf("Retry-After = %ds, want 1..%d — a wait a caller can sleep on rather than give up on", secs, cliShipWindow/time.Second) } if msg := str(t, out, "error"); !strings.Contains(msg, fmt.Sprintf("%ds", secs)) { 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) } } // TestPersonShipBudgetStaysKeyedByToken pins the one branch the brief says // must NOT move: a person's ship budget stays keyed on the TOKEN HASH, not // the address (handleAPIShip's own comment) — a second token for the same // address gets a FRESH ship budget, unlike every other write door here. func TestPersonShipBudgetStaysKeyedByToken(t *testing.T) { fx := newConsoleFixture(t) now := time.Now() if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { t.Fatal(err) } tok1 := cliToken(t, fx, "paul@keymail.dev", "code-ship-key-1") for i := 0; i < cliShipBurst; i++ { v := fmt.Sprintf("p%03d", i) status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", tok1, []shipPart{metaPart(t, v, "binary"), {name: "linux-arm64", body: []byte("x")}}) if status != http.StatusCreated { t.Fatalf("ship %d = %d %v", i, status, out) } } status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", tok1, []shipPart{metaPart(t, "over", "binary"), {name: "linux-arm64", body: []byte("x")}}) if status != http.StatusTooManyRequests { t.Fatalf("ship over budget = %d %v, want 429", status, out) } tok2 := cliToken(t, fx, "paul@keymail.dev", "code-ship-key-2") status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", tok2, []shipPart{metaPart(t, "fresh-token", "binary"), {name: "linux-arm64", body: []byte("x")}}) if status != http.StatusCreated { t.Fatalf("second token same address = %d %v, want 201 (ship stays token-keyed)", status, out) } }