返回 CodeWhale
tests.rs
根目录 / crates / cli / src / cloud / machine / tests.rs
1 //! Machine-token contract tests.
2 //!
3 //! Every branch the control plane can take is exercised here against the
4 //! documented `details.code`, because the CLI's whole job in a CI failure is
5 //! to turn one of those codes into the one sentence that names the fix.
6
7 use std::sync::Mutex;
8
9 use serde_json::json;
10
11 use super::*;
12 use crate::cloud::{CloudResponse, CloudTransport};
13
14 const VALID: &str = "cwc_key_3f2a9c1e4b7d8a0f5c6e2b91_AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-xQRST";
15
16 fn err_body(code: &str, message: &str) -> serde_json::Value {
17 json!({
18 "error": "request_failed",
19 "message": message,
20 "details": { "code": code },
21 })
22 }
23
24 fn err_response(status: u16, code: &str, message: &str) -> CloudResponse {
25 CloudResponse {
26 status,
27 body: serde_json::to_vec(&err_body(code, message)).unwrap(),
28 retry_after: None,
29 }
30 }
31
32 /// A transport that replays a scripted queue and records every request.
33 struct ScriptedTransport {
34 responses: Mutex<Vec<Result<CloudResponse>>>,
35 seen: Mutex<Vec<(String, Option<String>)>>,
36 }
37
38 impl ScriptedTransport {
39 fn new(responses: Vec<Result<CloudResponse>>) -> Self {
40 let mut responses = responses;
41 responses.reverse();
42 Self {
43 responses: Mutex::new(responses),
44 seen: Mutex::new(Vec::new()),
45 }
46 }
47
48 fn ok(responses: Vec<CloudResponse>) -> Self {
49 Self::new(responses.into_iter().map(Ok).collect())
50 }
51
52 fn calls(&self) -> usize {
53 self.seen.lock().unwrap().len()
54 }
55
56 fn bearers(&self) -> Vec<Option<String>> {
57 self.seen
58 .lock()
59 .unwrap()
60 .iter()
61 .map(|(_, bearer)| bearer.clone())
62 .collect()
63 }
64 }
65
66 impl CloudTransport for ScriptedTransport {
67 fn execute(&self, request: crate::cloud::CloudRequest) -> Result<CloudResponse> {
68 self.seen
69 .lock()
70 .unwrap()
71 .push((request.path.clone(), request.bearer.clone()));
72 self.responses
73 .lock()
74 .unwrap()
75 .pop()
76 .unwrap_or_else(|| Err(anyhow!("scripted transport exhausted")))
77 }
78 }
79
80 // -- token format ---------------------------------------------------------
81
82 #[test]
83 fn a_well_formed_token_parses_and_exposes_only_its_non_secret_head() {
84 let key = MachineKey::parse(VALID).expect("the canonical shape must parse");
85 assert_eq!(key.head(), "cwc_key_3f2a9c1e4b7d8a0f5c6e2b91");
86 assert_eq!(key.head().len(), TOKEN_HEAD_LEN);
87 assert!(key.head().ends_with("3f2a9c1e4b7d8a0f5c6e2b91"));
88 // The head is the whole key id, not a truncated fingerprint: it is what
89 // maps a token in a build log to exactly one revocable listing row.
90 assert!(VALID.starts_with(key.head()));
91
92 // Debug is the accident-prone surface: a `{:?}` in a panic must not leak.
93 let debug = format!("{key:?}");
94 assert!(
95 debug.contains("cwc_key_3f2a9c1e4b7d8a0f5c6e2b91"),
96 "{debug}"
97 );
98 assert!(!debug.contains(&VALID[TOKEN_HEAD_LEN..]), "{debug}");
99 }
100
101 #[test]
102 fn ci_whitespace_and_wrapping_quotes_are_stripped_before_matching() {
103 for raw in [
104 format!(" {VALID}\n"),
105 format!("\"{VALID}\""),
106 format!("'{VALID}'"),
107 format!(" \" {VALID} \" "),
108 ] {
109 let key = MachineKey::parse(&raw).unwrap_or_else(|err| {
110 panic!("secret-paste artifact must be tolerated: {raw:?}: {err}")
111 });
112 assert_eq!(key.head(), "cwc_key_3f2a9c1e4b7d8a0f5c6e2b91");
113 }
114 }
115
116 #[test]
117 fn a_malformed_token_fails_locally_and_the_message_never_echoes_it() {
118 let truncated = &VALID[..40];
119 let cases: Vec<String> = vec![
120 truncated.to_string(),
121 format!("{VALID}x"),
122 VALID.replacen("cwc_key_", "cwc_kex_", 1),
123 // Uppercase in the id half: the contract pins [0-9a-f].
124 VALID.replacen("3f2a", "3F2A", 1),
125 // '+' is base64, not base64url.
126 format!("{}+{}", &VALID[..74], &VALID[75..]),
127 "not-a-key".to_string(),
128 ];
129 for raw in cases {
130 let err = MachineKey::parse(&raw).expect_err("malformed values must not be sent");
131 let text = err.to_string();
132 assert!(text.contains("CODEWHALE_API_KEY"), "{text}");
133 // Naming the likely cause is the point: a 401 cannot distinguish a
134 // half-pasted key from a deleted one, but a local check can.
135 assert!(text.contains("truncated or shell-mangled"), "{text}");
136 assert!(!text.contains(&raw), "the error echoed the value: {text}");
137 }
138 }
139
140 #[test]
141 fn an_exported_but_empty_secret_reads_as_unset_not_as_invalid() {
142 for raw in [Some(""), Some(" "), None] {
143 let env = MachineKeyEnv::from_raw(raw);
144 assert!(!env.is_present(), "{raw:?}");
145 assert!(env.resolve().unwrap().is_none(), "{raw:?}");
146 let err = env.require().expect_err("require must fail when unset");
147 assert!(err.to_string().contains("CODEWHALE_API_KEY"), "{err}");
148 }
149 }
150
151 #[test]
152 fn presence_is_independent_of_validity_so_management_can_refuse_a_bad_key() {
153 let env = MachineKeyEnv::from_raw(Some("cwc_key_truncated"));
154 assert!(env.is_present());
155 assert!(env.resolve().is_err());
156 // A key that is present but unparseable must still trigger the "keys
157 // cannot manage keys" refusal rather than a format complaint.
158 let err = reject_machine_key_for_management(&env, false).expect_err("must refuse");
159 assert!(err.to_string().contains("codewhale login"), "{err}");
160 }
161
162 // -- base URL -------------------------------------------------------------
163
164 #[test]
165 fn base_url_precedence_is_flag_then_machine_env_then_device_env_then_default() {
166 assert_eq!(
167 resolve_api_base(
168 Some("https://flag.example/"),
169 Some("https://machine.example"),
170 Some("https://device.example"),
171 DEFAULT_BASE_FOR_TEST,
172 ),
173 "https://flag.example"
174 );
175 assert_eq!(
176 resolve_api_base(
177 None,
178 Some("https://machine.example/"),
179 Some("https://device.example"),
180 DEFAULT_BASE_FOR_TEST,
181 ),
182 "https://machine.example"
183 );
184 assert_eq!(
185 resolve_api_base(
186 None,
187 None,
188 Some("https://device.example"),
189 DEFAULT_BASE_FOR_TEST
190 ),
191 "https://device.example"
192 );
193 assert_eq!(
194 resolve_api_base(None, Some(" "), None, DEFAULT_BASE_FOR_TEST),
195 DEFAULT_BASE_FOR_TEST
196 );
197 }
198
199 const DEFAULT_BASE_FOR_TEST: &str = "https://api.codewhale.net";
200
201 #[test]
202 fn plaintext_to_a_remote_host_is_a_hard_error_and_loopback_is_allowed() {
203 // A warning in CI is a line nobody reads, so this must fail closed.
204 let err = require_secure_base("http://api.codewhale.net")
205 .expect_err("cleartext to a remote host must be refused");
206 let text = format!("{err:#}");
207 assert!(text.contains("CODEWHALE_API_KEY"), "{text}");
208
209 require_secure_base("https://api.codewhale.net").expect("https is fine");
210 for loopback in [
211 "http://localhost:8787",
212 "http://127.0.0.1:8787",
213 "http://[::1]:8787",
214 ] {
215 require_secure_base(loopback).unwrap_or_else(|err| panic!("{loopback}: {err:#}"));
216 }
217 }
218
219 // -- error classification -------------------------------------------------
220
221 #[test]
222 fn every_documented_error_code_maps_to_its_own_actionable_message() {
223 struct Case {
224 status: u16,
225 code: &'static str,
226 expect: &'static str,
227 exit: i32,
228 retryable: bool,
229 }
230 let cases = [
231 Case {
232 status: 401,
233 code: "api_key_invalid",
234 expect: "is not valid",
235 exit: EXIT_AUTH,
236 retryable: false,
237 },
238 Case {
239 status: 401,
240 code: "api_key_revoked",
241 expect: "was revoked",
242 exit: EXIT_AUTH,
243 retryable: false,
244 },
245 Case {
246 status: 401,
247 code: "api_key_expired",
248 expect: "expired",
249 exit: EXIT_AUTH,
250 retryable: false,
251 },
252 Case {
253 status: 401,
254 code: "api_key_required",
255 expect: "Codewhale CLI bug",
256 exit: EXIT_AUTH,
257 retryable: false,
258 },
259 Case {
260 status: 401,
261 code: "auth_required",
262 expect: "codewhale login",
263 exit: EXIT_AUTH,
264 retryable: false,
265 },
266 Case {
267 status: 403,
268 code: "api_key_route_denied",
269 expect: "cannot be used for this command",
270 exit: EXIT_AUTH,
271 retryable: false,
272 },
273 Case {
274 status: 403,
275 code: "api_key_scope_denied",
276 expect: "scope",
277 exit: EXIT_AUTH,
278 retryable: false,
279 },
280 Case {
281 status: 409,
282 code: "account_agent_model_unconfigured",
283 expect: "no agent model configured",
284 exit: EXIT_AGENT_UNCONFIGURED,
285 retryable: false,
286 },
287 Case {
288 status: 409,
289 code: "api_key_limit_reached",
290 expect: "25 active API keys",
291 exit: EXIT_LIMIT,
292 retryable: false,
293 },
294 Case {
295 status: 400,
296 code: "api_key_name_invalid",
297 expect: "server prose",
298 exit: EXIT_INPUT,
299 retryable: false,
300 },
301 Case {
302 status: 400,
303 code: "api_key_expiry_invalid",
304 expect: "server prose",
305 exit: EXIT_INPUT,
306 retryable: false,
307 },
308 Case {
309 status: 400,
310 code: "api_key_scopes_invalid",
311 expect: "server prose",
312 exit: EXIT_INPUT,
313 retryable: false,
314 },
315 Case {
316 status: 400,
317 code: "api_key_body_invalid",
318 expect: "server prose",
319 exit: EXIT_INPUT,
320 retryable: false,
321 },
322 Case {
323 status: 404,
324 code: "api_key_not_found",
325 expect: "No such Codewhale API key",
326 exit: EXIT_INPUT,
327 retryable: false,
328 },
329 Case {
330 status: 503,
331 code: "api_key_unavailable",
332 expect: "does not support API keys yet",
333 exit: EXIT_UNAVAILABLE,
334 retryable: false,
335 },
336 Case {
337 status: 503,
338 code: "control_plane_not_attached",
339 expect: "attached to the account control plane",
340 exit: EXIT_UNAVAILABLE,
341 retryable: false,
342 },
343 ];
344 for case in cases {
345 let error = classify(&err_response(case.status, case.code, "server prose"));
346 assert_eq!(error.code, case.code);
347 assert_eq!(error.exit_code, case.exit, "exit code for {}", case.code);
348 assert_eq!(
349 error.retryable, case.retryable,
350 "retryability for {}",
351 case.code
352 );
353 assert!(
354 error.message.contains(case.expect),
355 "{} message was {:?}",
356 case.code,
357 error.message
358 );
359 }
360 }
361
362 #[test]
363 fn a_missing_model_and_a_bad_credential_carry_different_exit_codes() {
364 // CI logs must tell a configuration problem from a credential problem
365 // without parsing English.
366 let config = classify(&err_response(409, "account_agent_model_unconfigured", ""));
367 let credential = classify(&err_response(401, "api_key_invalid", ""));
368 assert_ne!(config.exit_code, credential.exit_code);
369 assert_eq!(config.exit_code, EXIT_AGENT_UNCONFIGURED);
370 assert_eq!(credential.exit_code, EXIT_AUTH);
371 }
372
373 #[test]
374 fn the_two_503_codes_do_not_read_as_the_same_problem() {
375 // They need different fixes from different people.
376 let unavailable = classify(&err_response(503, "api_key_unavailable", ""));
377 let detached = classify(&err_response(503, "control_plane_not_attached", ""));
378 assert_ne!(unavailable.message, detached.message);
379 assert!(
380 detached.message.contains("routing/deployment"),
381 "{}",
382 detached.message
383 );
384 assert!(!unavailable.message.contains("routing/deployment"));
385 }
386
387 #[test]
388 fn api_key_not_found_does_not_pretend_to_distinguish_the_three_causes() {
389 let error = classify(&err_response(404, "api_key_not_found", ""));
390 let text = error.message.to_lowercase();
391 // Unknown, malformed, and another account's id answer identically, so
392 // revoke cannot be used to probe for foreign key ids.
393 for leak in [
394 "another account",
395 "belongs to",
396 "malformed",
397 "does not exist",
398 ] {
399 assert!(!text.contains(leak), "{text}");
400 }
401 }
402
403 #[test]
404 fn api_key_field_unknown_names_the_fields_the_server_rejected() {
405 let body = json!({
406 "error": "bad_request",
407 "message": "unknown fields in request body",
408 "details": { "code": "api_key_field_unknown", "fields": ["ttl", "owner"] },
409 });
410 let error = classify(&CloudResponse {
411 status: 400,
412 body: serde_json::to_vec(&body).unwrap(),
413 retry_after: None,
414 });
415 assert_eq!(error.exit_code, EXIT_INPUT);
416 assert!(error.message.contains("ttl"), "{}", error.message);
417 assert!(error.message.contains("owner"), "{}", error.message);
418 }
419
420 #[test]
421 fn classification_reads_the_code_not_the_status() {
422 // Three 401s, three different fixes. Only `details.code` separates them.
423 let invalid = classify(&err_response(401, "api_key_invalid", ""));
424 let revoked = classify(&err_response(401, "api_key_revoked", ""));
425 let expired = classify(&err_response(401, "api_key_expired", ""));
426 assert_ne!(invalid.message, revoked.message);
427 assert_ne!(revoked.message, expired.message);
428 assert_ne!(invalid.message, expired.message);
429 }
430
431 #[test]
432 fn server_prose_cannot_rewrite_the_terminal() {
433 let error = classify(&err_response(
434 400,
435 "api_key_name_invalid",
436 "bad\u{1b}[2Jname\n\rmore",
437 ));
438 assert!(!error.message.contains('\u{1b}'), "{}", error.message);
439 assert!(!error.message.contains('\n'), "{}", error.message);
440 }
441
442 #[test]
443 fn an_unrecognized_body_still_classifies_by_status() {
444 let response = CloudResponse {
445 status: 500,
446 body: b"<html>oops</html>".to_vec(),
447 retry_after: None,
448 };
449 let error = classify(&response);
450 assert!(error.retryable);
451 assert_eq!(error.exit_code, EXIT_TRANSPORT);
452 assert!(error.message.contains("HTTP 500"), "{}", error.message);
453 }
454
455 // -- retry ----------------------------------------------------------------
456
457 #[test]
458 fn retry_after_wins_over_exponential_backoff_and_is_capped() {
459 assert_eq!(backoff_delay(1, Some(7)), Duration::from_secs(7));
460 assert_eq!(backoff_delay(3, Some(2)), Duration::from_secs(2));
461 // A hostile or broken Retry-After cannot park CI for a day.
462 assert_eq!(
463 backoff_delay(1, Some(86_400)),
464 Duration::from_millis(MAX_BACKOFF_MS)
465 );
466 // Without a header the delay still grows.
467 assert!(backoff_delay(2, None) > backoff_delay(1, None));
468 }
469
470 #[test]
471 fn a_rate_limited_get_is_retried_and_honors_retry_after() {
472 let transport = ScriptedTransport::ok(vec![
473 CloudResponse {
474 status: 429,
475 body: serde_json::to_vec(&err_body("rate_limited", "slow down")).unwrap(),
476 retry_after: Some(3),
477 },
478 CloudResponse {
479 status: 200,
480 body: serde_json::to_vec(&json!({
481 "agent": { "configured": true, "modelProvider": "deepseek", "accountId": "user_1" }
482 }))
483 .unwrap(),
484 retry_after: None,
485 },
486 ]);
487 let mut slept = Vec::new();
488 let mut sleeper = |duration: Duration| slept.push(duration);
489 let client = MachineClient::new(&transport, MachineKey::parse(VALID).unwrap());
490 let agent = client.agent(&mut sleeper).expect("the retry must succeed");
491 assert!(agent.agent.configured);
492 assert_eq!(transport.calls(), 2);
493 assert_eq!(slept, vec![Duration::from_secs(3)]);
494 }
495
496 #[test]
497 fn a_transport_failure_on_a_get_is_retried_up_to_three_attempts() {
498 let transport = ScriptedTransport::new(vec![
499 Err(anyhow!("connection reset")),
500 Err(anyhow!("connection reset")),
501 Err(anyhow!("connection reset")),
502 ]);
503 let mut sleeper = |_: Duration| {};
504 let client = MachineClient::new(&transport, MachineKey::parse(VALID).unwrap());
505 let err = client
506 .whoami(&mut sleeper)
507 .expect_err("exhausted retries must fail");
508 assert_eq!(transport.calls(), MAX_ATTEMPTS as usize);
509 let machine_error = err
510 .downcast_ref::<MachineError>()
511 .expect("transport failures must carry an exit code");
512 assert_eq!(machine_error.exit_code, EXIT_TRANSPORT);
513 }
514
515 #[test]
516 fn a_non_retryable_auth_failure_is_not_retried() {
517 let transport = ScriptedTransport::ok(vec![err_response(401, "api_key_revoked", "revoked")]);
518 let mut sleeper = |_: Duration| panic!("a revoked key must never be retried");
519 let client = MachineClient::new(&transport, MachineKey::parse(VALID).unwrap());
520 let err = client.whoami(&mut sleeper).expect_err("revoked must fail");
521 assert_eq!(transport.calls(), 1);
522 assert_eq!(
523 err.downcast_ref::<MachineError>().unwrap().exit_code,
524 EXIT_AUTH
525 );
526 }
527
528 #[test]
529 fn create_is_marked_never_retryable_because_a_replay_mints_an_invisible_key() {
530 // The variant is the guard: a POST that actually succeeded server-side
531 // would mint a second key whose one-time secret the caller never saw.
532 assert_ne!(Retry::Never, Retry::Idempotent);
533 }
534
535 // -- request shape --------------------------------------------------------
536
537 #[test]
538 fn exactly_one_credential_reaches_the_wire_and_it_is_the_machine_key() {
539 let transport = ScriptedTransport::ok(vec![CloudResponse {
540 status: 200,
541 body: serde_json::to_vec(&json!({
542 "account": { "id": "user_1", "displayName": "H", "email": "h@example.test", "region": "us-west", "plan": "free" },
543 "apiKey": { "id": "3f2a9c1e4b7d8a0f5c6e2b91", "name": "ci", "displayPrefix": "cwc_key_3f2a9c1e4b7d8a0f5c6e2b91", "scopes": ["account:read"], "createdAt": "2026-01-01T00:00:00Z" },
544 "agent": { "configured": true, "modelProvider": "deepseek" }
545 }))
546 .unwrap(),
547 retry_after: None,
548 }]);
549 let mut sleeper = |_: Duration| {};
550 let key = MachineKey::parse(VALID).unwrap();
551 let client = MachineClient::new(&transport, key);
552 client.whoami(&mut sleeper).unwrap();
553 let bearers = transport.bearers();
554 assert_eq!(bearers.len(), 1);
555 assert_eq!(bearers[0].as_deref(), Some(VALID));
556 assert_eq!(
557 transport.seen.lock().unwrap()[0].0,
558 "/api/account/api-key/whoami"
559 );
560 }
561
562 // -- whoami / agent rendering ---------------------------------------------
563
564 fn whoami_fixture(configured: bool) -> WhoamiResponse {
565 let provider = if configured { "deepseek" } else { "" };
566 serde_json::from_value(json!({
567 "account": { "id": "user_1", "displayName": "Hunter", "email": "h@example.test", "region": "us-west", "plan": "free" },
568 "apiKey": {
569 "id": "3f2a9c1e4b7d8a0f5c6e2b91",
570 "name": "github-actions",
571 "displayPrefix": "cwc_key_3f2a9c1e4b7d8a0f5c6e2b91",
572 "scopes": ["account:read", "agent:run"],
573 "createdAt": "2026-01-01T00:00:00Z",
574 "expiresAt": null,
575 "lastUsedAt": null,
576 "revokedAt": null
577 },
578 "agent": { "configured": configured, "modelProvider": provider }
579 }))
580 .unwrap()
581 }
582
583 #[test]
584 fn whoami_prints_the_account_and_only_the_key_head() {
585 let mut out = Vec::new();
586 let key = MachineKey::parse(VALID).unwrap();
587 write_whoami(
588 &mut out,
589 &whoami_fixture(true),
590 "https://api.codewhale.net",
591 key.head(),
592 )
593 .unwrap();
594 let text = String::from_utf8(out).unwrap();
595 assert!(text.contains("user_1"), "{text}");
596 assert!(text.contains("cwc_key_3f2a9c1e4b7d8a0f5c6e2b91"), "{text}");
597 assert!(
598 !text.contains(&VALID[TOKEN_HEAD_LEN..]),
599 "the secret half leaked: {text}"
600 );
601 }
602
603 #[test]
604 fn an_unconfigured_agent_is_a_distinct_line_on_a_successful_whoami() {
605 // The server answers 200 here: authentication succeeded, and a diagnosis
606 // surface that failed on unrelated configuration would tell the operator
607 // nothing about the credential they came to check.
608 let mut out = Vec::new();
609 write_whoami(
610 &mut out,
611 &whoami_fixture(false),
612 "https://api.codewhale.net",
613 "cwc_key_x",
614 )
615 .unwrap();
616 let text = String::from_utf8(out).unwrap();
617 assert!(
618 text.contains("user_1"),
619 "the account must still print: {text}"
620 );
621 assert!(text.contains("not configured"), "{text}");
622 assert!(text.contains("codewhale account keys set"), "{text}");
623 }
624
625 // -- local input validation -----------------------------------------------
626
627 #[test]
628 fn key_names_are_checked_locally_against_the_server_pattern() {
629 for good in [
630 "github-actions",
631 "a",
632 "CI runner 2",
633 "team/ci",
634 "a.b_c:d@e-f",
635 &"n".repeat(64),
636 ] {
637 validate_key_name(good).unwrap_or_else(|err| panic!("{good:?}: {err}"));
638 }
639 for bad in [
640 "",
641 " leading-space",
642 "-leading-dash",
643 "bad\nname",
644 "bad*name",
645 &"n".repeat(65),
646 ] {
647 assert!(validate_key_name(bad).is_err(), "{bad:?} must be rejected");
648 }
649 }
650
651 #[test]
652 fn scopes_are_normalized_against_the_closed_set() {
653 // Omitting --scope means every scope, sent explicitly rather than left
654 // to whatever the control plane defaults to.
655 assert_eq!(
656 validate_scopes(&[]).unwrap(),
657 Some(vec![
658 "account:read".to_string(),
659 "agent:run".to_string(),
660 "models:infer".to_string(),
661 ])
662 );
663 assert_eq!(
664 validate_scopes(&["models:infer".into()]).unwrap(),
665 Some(vec!["models:infer".to_string()])
666 );
667 assert_eq!(
668 validate_scopes(&[
669 "agent:run".into(),
670 "account:read".into(),
671 "agent:run".into()
672 ])
673 .unwrap(),
674 Some(vec!["agent:run".to_string(), "account:read".to_string()])
675 );
676 let err = validate_scopes(&["billing:write".into()]).expect_err("closed set");
677 assert!(err.to_string().contains("account:read"), "{err}");
678 }
679
680 #[test]
681 fn key_ids_are_checked_as_a_paste_check_not_an_existence_check() {
682 validate_key_id("3f2a9c1e4b7d8a0f5c6e2b91").unwrap();
683 validate_key_id(" 3f2a9c1e4b7d8a0f5c6e2b91 ").unwrap();
684 for bad in [
685 "3f2a",
686 "3F2A9C1E4B7D8A0F5C6E2B91",
687 "3f2a9c1e4b7d8a0f5c6e2b9z",
688 ] {
689 assert!(validate_key_id(bad).is_err(), "{bad}");
690 }
691 }
692
693 // -- management refusal ---------------------------------------------------
694
695 #[test]
696 fn a_machine_key_alone_cannot_manage_keys_and_is_refused_before_the_wire() {
697 let env = MachineKeyEnv::from_raw(Some(VALID));
698 let err = reject_machine_key_for_management(&env, false)
699 .expect_err("a key must not be able to mint a successor");
700 let text = err.to_string();
701 assert!(
702 text.contains("Managing API keys needs an interactive login."),
703 "{text}"
704 );
705 assert!(text.contains("codewhale login"), "{text}");
706 // The refusal must explain *why*, not just refuse.
707 assert!(text.contains("minting a replacement"), "{text}");
708 assert!(!text.contains(VALID), "the refusal echoed the key: {text}");
709 }
710
711 #[test]
712 fn a_session_alongside_a_machine_key_still_manages_keys() {
713 // The session is the credential the management routes accept; having a
714 // key exported at the same time is not a reason to refuse a human.
715 let env = MachineKeyEnv::from_raw(Some(VALID));
716 reject_machine_key_for_management(&env, true).expect("a real login may manage keys");
717 reject_machine_key_for_management(&MachineKeyEnv::default(), false)
718 .expect("no key present is not this check's problem");
719 }
720
721 // -- created-key rendering ------------------------------------------------
722
723 #[test]
724 fn a_created_secret_is_printed_once_with_an_unmissable_notice() {
725 let created: ApiKeyCreateResponse = serde_json::from_value(json!({
726 "apiKey": {
727 "id": "3f2a9c1e4b7d8a0f5c6e2b91",
728 "name": "github-actions",
729 "displayPrefix": "cwc_key_3f2a9c1e4b7d8a0f5c6e2b91",
730 "scopes": ["account:read", "agent:run"],
731 "createdAt": "2026-01-01T00:00:00Z",
732 "expiresAt": null
733 },
734 "secret": VALID,
735 }))
736 .unwrap();
737 let mut out = Vec::new();
738 write_created_key(&mut out, &created).unwrap();
739 let text = String::from_utf8(out).unwrap();
740 assert_eq!(
741 text.matches(VALID).count(),
742 1,
743 "printed more than once: {text}"
744 );
745 assert!(
746 text.contains("ONLY TIME YOU WILL SEE THIS SECRET"),
747 "{text}"
748 );
749 assert!(text.contains("CODEWHALE_API_KEY"), "{text}");
750 assert!(text.contains("cannot show it again"), "{text}");
751 }
752
753 #[test]
754 fn a_listing_can_never_carry_a_secret_because_the_type_has_no_field_for_one() {
755 // The server never returns one; the type makes a regression that started
756 // returning one impossible to render.
757 let listing: ApiKeyListResponse = serde_json::from_value(json!({
758 "apiKeys": [
759 { "id": "3f2a9c1e4b7d8a0f5c6e2b91", "name": "ci", "displayPrefix": "cwc_key_3f2a9c1e4b7d8a0f5c6e2b91",
760 "scopes": ["account:read"], "createdAt": "2026-01-01T00:00:00Z", "secret": VALID },
761 { "id": "aaaabbbbccccddddeeeeffff", "name": "old", "displayPrefix": "cwc_key_aaaabbbbccccddddeeeeffff",
762 "scopes": ["agent:run"], "createdAt": "2025-01-01T00:00:00Z", "revokedAt": "2026-02-02T00:00:00Z" }
763 ]
764 }))
765 .unwrap();
766 let mut out = Vec::new();
767 write_key_listing(&mut out, &listing.api_keys).unwrap();
768 let text = String::from_utf8(out).unwrap();
769 assert!(!text.contains(VALID), "a listing leaked a secret: {text}");
770 // Revoked keys stay listed so an owner can audit history.
771 assert!(text.contains("[revoked]"), "{text}");
772 assert!(text.contains("[active]"), "{text}");
773 }
774
775 #[test]
776 fn an_empty_listing_names_the_command_that_fixes_it() {
777 let mut out = Vec::new();
778 write_key_listing(&mut out, &[]).unwrap();
779 let text = String::from_utf8(out).unwrap();
780 assert!(text.contains("codewhale account api-keys create"), "{text}");
781 }
782
783 // -- review wiring --------------------------------------------------------
784
785 #[test]
786 fn review_resolves_the_route_from_the_accounts_configured_provider() {
787 let agent: AgentState = serde_json::from_value(
788 json!({ "configured": true, "modelProvider": "deepseek", "accountId": "user_1" }),
789 )
790 .unwrap();
791 let provider = review_provider_from_agent(&agent).expect("deepseek is a known route");
792 assert_eq!(provider, ProviderKind::Deepseek);
793 }
794
795 #[test]
796 fn review_refuses_with_the_configuration_exit_code_when_no_model_is_set() {
797 let agent = AgentState {
798 configured: false,
799 model_provider: String::new(),
800 account_id: None,
801 };
802 let err = review_provider_from_agent(&agent).expect_err("machine work needs a model");
803 let machine_error = err.downcast_ref::<MachineError>().expect("typed error");
804 assert_eq!(machine_error.exit_code, EXIT_AGENT_UNCONFIGURED);
805 assert_ne!(machine_error.exit_code, EXIT_AUTH);
806 assert!(
807 machine_error.message.contains("codewhale account keys set"),
808 "{machine_error}"
809 );
810 }
811
812 #[test]
813 fn an_unknown_provider_identifier_asks_for_an_upgrade_rather_than_guessing() {
814 let agent = AgentState {
815 configured: true,
816 model_provider: "a-provider-from-the-future".to_string(),
817 account_id: None,
818 };
819 let err = review_provider_from_agent(&agent).expect_err("unknown routes must not be guessed");
820 assert!(err.to_string().contains("Upgrade `codewhale`"), "{err}");
821 }
822
822 lines RUST