| 1 | package console |
| 2 | |
| 3 | import ( |
| 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. |
| 21 | func 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 | |
| 38 | func 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 | |
| 50 | func 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 | |
| 70 | func 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. |
| 79 | func 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. |
| 208 | func 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. |
| 247 | func 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. |
| 284 | func 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. |
| 324 | func 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. |
| 352 | func 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. |
| 369 | func 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. |
| 390 | func 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. |
| 404 | func 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. |
| 426 | func 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 | |
| 453 | func 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. |
| 464 | func 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. |
| 505 | func 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. |
| 523 | func 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. |
| 546 | func 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. |
| 576 | func 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. |
| 615 | func 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. |
| 639 | func 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. |
| 660 | func 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. |
| 684 | func 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. |
| 709 | func 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. |
| 733 | func 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. |
| 777 | func 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. |
| 816 | func 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 | |