1use std::collections::BTreeSet;
7use std::sync::{Mutex, OnceLock};
8use std::time::Duration;
9
10use clap::ValueEnum;
11
12use crate::usage::VendorSnapshot;
13use crate::widget::cli::Cli;
14
15pub const HTTP_CLIENT_TIMEOUT: Duration = Duration::from_secs(30);
18
19pub const MAX_BODY_BYTES: usize = 2 * 1024 * 1024;
24
25pub(crate) const VENDOR_SECRET_ENV_VARS: &[&str] = &[
28 "ZAI_API_KEY",
29 "OPENROUTER_API_KEY",
30 "DEEPSEEK_API_KEY",
31 "KIMI_API_KEY",
32 "KILO_API_KEY",
33 "NOVITA_API_KEY",
34 "MINIMAX_API_KEY",
35 "MOONSHOT_API_KEY",
36 "XAI_MANAGEMENT_KEY",
37 "ANTHROPIC_ADMIN_KEY",
38 "XAI_API_KEY",
39 "GROK_API_KEY",
40 "OPENCODE_GO_API_KEY",
41 "COMMANDCODE_API_KEY",
42 "GITHUB_COPILOT_TOKEN",
43 "GH_TOKEN",
44 "GITHUB_TOKEN",
45 "OLLAMA_API_KEY",
46];
47
48fn registered_secret_env_vars() -> &'static Mutex<BTreeSet<&'static str>> {
53 static REGISTERED: OnceLock<Mutex<BTreeSet<&'static str>>> = OnceLock::new();
54 REGISTERED.get_or_init(|| Mutex::new(BTreeSet::new()))
55}
56
57pub fn register_secret_env_vars(names: &[String]) {
68 let mut registered = registered_secret_env_vars()
69 .lock()
70 .unwrap_or_else(|poisoned| poisoned.into_inner());
71 for name in names {
72 if !crate::config::is_valid_env_var_name(name)
73 || VENDOR_SECRET_ENV_VARS.contains(&name.as_str())
74 || registered.contains(name.as_str())
75 {
76 continue;
77 }
78 registered.insert(Box::leak(name.clone().into_boxed_str()));
79 }
80}
81
82pub(crate) fn vendor_secret_env_vars_to_remove(keep: &[&str]) -> Vec<&'static str> {
83 let registered = registered_secret_env_vars()
84 .lock()
85 .unwrap_or_else(|poisoned| poisoned.into_inner());
86 VENDOR_SECRET_ENV_VARS
87 .iter()
88 .copied()
89 .chain(registered.iter().copied())
90 .filter(|var| !keep.contains(var))
91 .collect()
92}
93
94pub fn same_origin_redirect_policy() -> reqwest::redirect::Policy {
99 reqwest::redirect::Policy::custom(|attempt| {
100 if attempt.previous().len() >= 10 {
101 return attempt.error("too many redirects");
102 }
103 let Some(origin) = attempt.previous().first() else {
104 return attempt.stop();
105 };
106 let target = attempt.url();
107 if target.scheme() == origin.scheme()
108 && target.host_str() == origin.host_str()
109 && target.port_or_known_default() == origin.port_or_known_default()
110 {
111 attempt.follow()
112 } else {
113 attempt.stop()
114 }
115 })
116}
117
118pub async fn read_body_capped(
126 mut resp: reqwest::Response,
127 max: usize,
128) -> crate::error::Result<Vec<u8>> {
129 let too_big = |n: u64| {
130 crate::error::AppError::Schema(format!(
131 "response body exceeds the {max}-byte limit ({n} bytes); refusing to buffer it"
132 ))
133 };
134 if let Some(len) = resp.content_length()
135 && len > max as u64
136 {
137 return Err(too_big(len));
138 }
139 let mut buf: Vec<u8> = Vec::new();
140 while let Some(chunk) = resp.chunk().await? {
141 if chunk.len() > max.saturating_sub(buf.len()) {
142 return Err(too_big(buf.len().saturating_add(chunk.len()) as u64));
143 }
144 buf.extend_from_slice(&chunk);
145 }
146 Ok(buf)
147}
148
149#[derive(
151 Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Hash, serde::Deserialize, serde::Serialize,
152)]
153#[serde(rename_all = "lowercase")]
154pub enum VendorId {
155 Anthropic,
156 #[serde(rename = "anthropic_api")]
157 AnthropicApi,
158 Openai,
159 Copilot,
160 Zai,
161 Openrouter,
162 Deepseek,
163 Kimi,
164 Kilo,
165 Novita,
166 Moonshot,
167 Grok,
168 Supergrok,
169 Grokbot,
170 Antigravity,
171 Cursor,
172 Minimax,
173 Kiro,
174 #[serde(rename = "nous")]
175 NousResearch,
176 #[serde(rename = "opencode-go")]
177 OpenCodeGo,
178 #[serde(rename = "commandcode")]
179 CommandCode,
180 Ollama,
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
187#[serde(rename_all = "lowercase")]
188pub enum AuthKind {
189 Oauth,
191 ApiKey,
193 Local,
196}
197
198impl AuthKind {
199 pub const fn as_str(self) -> &'static str {
200 match self {
201 AuthKind::Oauth => "oauth",
202 AuthKind::ApiKey => "apikey",
203 AuthKind::Local => "local",
204 }
205 }
206}
207
208impl VendorId {
209 pub fn slug(self) -> &'static str {
210 match self {
211 VendorId::Anthropic => "anthropic",
212 VendorId::AnthropicApi => "anthropic_api",
213 VendorId::Openai => "openai",
214 VendorId::Copilot => "copilot",
215 VendorId::Zai => "zai",
216 VendorId::Openrouter => "openrouter",
217 VendorId::Deepseek => "deepseek",
218 VendorId::Kimi => "kimi",
219 VendorId::Kilo => "kilo",
220 VendorId::Novita => "novita",
221 VendorId::Moonshot => "moonshot",
222 VendorId::Grok => "grok",
223 VendorId::Supergrok => "supergrok",
224 VendorId::Grokbot => "grokbot",
225 VendorId::Antigravity => "antigravity",
226 VendorId::Cursor => "cursor",
227 VendorId::Minimax => "minimax",
228 VendorId::Kiro => "kiro",
229 VendorId::NousResearch => "nous",
230 VendorId::OpenCodeGo => "opencode-go",
231 VendorId::CommandCode => "commandcode",
232 VendorId::Ollama => "ollama",
233 }
234 }
235
236 pub fn display_name(self) -> &'static str {
240 match self {
241 VendorId::Anthropic => "Claude",
242 VendorId::AnthropicApi => "Anthropic API",
243 VendorId::Openai => "Codex",
244 VendorId::Copilot => "GitHub Copilot",
245 VendorId::Zai => "Z.AI",
246 VendorId::Openrouter => "OpenRouter",
247 VendorId::Deepseek => "DeepSeek",
248 VendorId::Kimi => "Kimi",
249 VendorId::Kilo => "Kilo",
250 VendorId::Novita => "Novita",
251 VendorId::Moonshot => "Moonshot",
252 VendorId::Grok => "Grok",
253 VendorId::Supergrok => "SuperGrok",
254 VendorId::Grokbot => "Grok Bot",
255 VendorId::Antigravity => "Antigravity",
256 VendorId::Cursor => "Cursor",
257 VendorId::Minimax => "MiniMax",
258 VendorId::Kiro => "Kiro",
259 VendorId::NousResearch => "Nous Research",
260 VendorId::OpenCodeGo => "OpenCode Go",
261 VendorId::CommandCode => "Command Code",
262 VendorId::Ollama => "Ollama Cloud",
263 }
264 }
265
266 pub const fn bar_icon(self) -> &'static str {
270 match self {
271 VendorId::Anthropic => "",
272 VendorId::AnthropicApi => "",
273 VendorId::Openai => "",
274 VendorId::Copilot => "",
275 VendorId::Zai => VendorId::Zai.short_name(),
276 VendorId::Openrouter => "",
277 VendorId::Deepseek => "",
278 VendorId::Kimi => VendorId::Kimi.short_name(),
279 VendorId::Kilo => "",
280 VendorId::Novita => "",
281 VendorId::Moonshot => VendorId::Moonshot.short_name(),
282 VendorId::Grok | VendorId::Supergrok => "",
283 VendorId::Grokbot => VendorId::Grokbot.short_name(),
284 VendorId::Antigravity => VendorId::Antigravity.short_name(),
285 VendorId::Cursor => "❯",
286 VendorId::Minimax => VendorId::Minimax.short_name(),
287 VendorId::Kiro => "◆",
288 VendorId::NousResearch => VendorId::NousResearch.short_name(),
289 VendorId::OpenCodeGo => VendorId::OpenCodeGo.short_name(),
290 VendorId::CommandCode => VendorId::CommandCode.short_name(),
291 VendorId::Ollama => VendorId::Ollama.short_name(),
294 }
295 }
296
297 pub const fn short_name(self) -> &'static str {
302 match self {
303 VendorId::Anthropic => "cld",
304 VendorId::AnthropicApi => "aac",
305 VendorId::Openai => "gpt",
306 VendorId::Copilot => "ghc",
307 VendorId::Zai => "zai",
308 VendorId::Openrouter => "opr",
309 VendorId::Deepseek => "dsk",
310 VendorId::Kimi => "kmi",
311 VendorId::Kilo => "klo",
312 VendorId::Novita => "nvt",
313 VendorId::Moonshot => "msh",
314 VendorId::Grok => "grk",
315 VendorId::Supergrok => "sgk",
316 VendorId::Grokbot => "gbt",
317 VendorId::Antigravity => "agy",
318 VendorId::Cursor => "cur",
319 VendorId::Minimax => "mmx",
320 VendorId::Kiro => "kir",
321 VendorId::NousResearch => "nrs",
322 VendorId::OpenCodeGo => "ocg",
323 VendorId::CommandCode => "cmc",
324 VendorId::Ollama => "oll",
325 }
326 }
327
328 pub const fn config_section(self) -> &'static str {
336 match self {
337 VendorId::Anthropic => "anthropic",
338 VendorId::AnthropicApi => "anthropic_api",
339 VendorId::Openai => "openai",
340 VendorId::Copilot => "copilot",
341 VendorId::Zai => "zai",
342 VendorId::Openrouter => "openrouter",
343 VendorId::Deepseek => "deepseek",
344 VendorId::Kimi => "kimi",
345 VendorId::Kilo => "kilo",
346 VendorId::Novita => "novita",
347 VendorId::Moonshot => "moonshot",
348 VendorId::Grok => "grok",
349 VendorId::Supergrok => "supergrok",
350 VendorId::Grokbot => "grokbot",
351 VendorId::Antigravity => "antigravity",
352 VendorId::Cursor => "cursor",
353 VendorId::Minimax => "minimax",
354 VendorId::Kiro => "kiro",
355 VendorId::NousResearch => "nous",
356 VendorId::OpenCodeGo => "opencode-go",
357 VendorId::CommandCode => "commandcode",
358 VendorId::Ollama => "ollama",
359 }
360 }
361
362 pub const fn auth_kind(self) -> AuthKind {
368 match self {
369 VendorId::Anthropic
370 | VendorId::Openai
371 | VendorId::Copilot
372 | VendorId::NousResearch
373 | VendorId::CommandCode => AuthKind::Oauth,
374 VendorId::AnthropicApi
375 | VendorId::Zai
376 | VendorId::Openrouter
377 | VendorId::Deepseek
378 | VendorId::Kimi
379 | VendorId::Kilo
380 | VendorId::Novita
381 | VendorId::Moonshot
382 | VendorId::Grok
383 | VendorId::Minimax
384 | VendorId::OpenCodeGo
385 | VendorId::Ollama => AuthKind::ApiKey,
386 VendorId::Supergrok
393 | VendorId::Antigravity
394 | VendorId::Cursor
395 | VendorId::Kiro
396 | VendorId::Grokbot => AuthKind::Local,
397 }
398 }
399
400 pub const fn api_key_env(self) -> &'static str {
405 match self {
406 VendorId::AnthropicApi => "ANTHROPIC_ADMIN_KEY",
407 VendorId::Zai => "ZAI_API_KEY",
408 VendorId::Openrouter => "OPENROUTER_API_KEY",
409 VendorId::Deepseek => "DEEPSEEK_API_KEY",
410 VendorId::Kimi => "KIMI_API_KEY",
411 VendorId::Kilo => "KILO_API_KEY",
412 VendorId::Novita => "NOVITA_API_KEY",
413 VendorId::Moonshot => "MOONSHOT_API_KEY",
414 VendorId::Grok => "XAI_MANAGEMENT_KEY",
415 VendorId::Minimax => "MINIMAX_API_KEY",
416 VendorId::OpenCodeGo => "OPENCODE_GO_API_KEY",
417 VendorId::Ollama => "OLLAMA_API_KEY",
418 VendorId::Copilot => "GITHUB_COPILOT_TOKEN",
422 VendorId::CommandCode => "COMMANDCODE_API_KEY",
423 VendorId::Anthropic
424 | VendorId::Openai
425 | VendorId::Supergrok
426 | VendorId::Grokbot
427 | VendorId::Antigravity
428 | VendorId::Cursor
429 | VendorId::Kiro
430 | VendorId::NousResearch => "",
431 }
432 }
433
434 pub const fn sign_in_hint(self) -> &'static str {
447 match self {
448 VendorId::Anthropic => "Run `claude` in a terminal, then Refresh.",
449 VendorId::Openai => "Run `codex login` in a terminal, then Refresh.",
450 VendorId::Copilot => "Run `gh auth login` in a terminal, then Refresh.",
451 VendorId::Kiro => "Run `kiro-cli login` in a terminal, then Refresh.",
452 VendorId::Kimi => "Run `kimi` in a terminal, or set an API key.",
453 VendorId::CommandCode => "Run `commandcode` in a terminal, then Refresh.",
454 VendorId::NousResearch => {
455 "Run `ai-usagebar auth nous login` in a terminal, then Refresh."
456 }
457 VendorId::Cursor => "Sign in to the Cursor app, then Refresh.",
458 VendorId::Antigravity => "Open Antigravity or run `agy`, then Refresh.",
459 VendorId::Grok | VendorId::Supergrok => "Sign in with `grok`, then Refresh.",
460 VendorId::Grokbot => "Install and sign in to the Grok Bot desktop app, then Refresh.",
461 VendorId::AnthropicApi
466 | VendorId::Zai
467 | VendorId::Openrouter
468 | VendorId::Deepseek
469 | VendorId::Kilo
470 | VendorId::Novita
471 | VendorId::Moonshot
472 | VendorId::Minimax
473 | VendorId::OpenCodeGo
474 | VendorId::Ollama => "Add an API key in Settings, then Refresh.",
475 }
476 }
477
478 pub const fn login_command(self) -> &'static str {
479 match self {
480 VendorId::Anthropic => "claude",
481 VendorId::Openai => "codex login",
482 VendorId::Copilot => "gh auth login",
483 VendorId::CommandCode => "commandcode",
484 VendorId::NousResearch => "ai-usagebar auth nous login",
485 VendorId::Kiro => "kiro-cli login",
486 VendorId::Kimi => "kimi",
489 VendorId::AnthropicApi
490 | VendorId::Zai
491 | VendorId::Openrouter
492 | VendorId::Deepseek
493 | VendorId::Kilo
494 | VendorId::Novita
495 | VendorId::Moonshot
496 | VendorId::Grok
497 | VendorId::Supergrok
498 | VendorId::Grokbot
499 | VendorId::Antigravity
500 | VendorId::Cursor
501 | VendorId::Minimax
502 | VendorId::OpenCodeGo
503 | VendorId::Ollama => "",
504 }
505 }
506
507 pub fn all() -> &'static [VendorId] {
508 &[
509 VendorId::Anthropic,
510 VendorId::AnthropicApi,
511 VendorId::Openai,
512 VendorId::Copilot,
513 VendorId::Zai,
514 VendorId::Openrouter,
515 VendorId::Deepseek,
516 VendorId::Kimi,
517 VendorId::Kilo,
518 VendorId::Novita,
519 VendorId::Moonshot,
520 VendorId::Grok,
521 VendorId::Supergrok,
522 VendorId::Grokbot,
523 VendorId::Antigravity,
524 VendorId::Cursor,
525 VendorId::Minimax,
526 VendorId::Kiro,
527 VendorId::NousResearch,
528 VendorId::OpenCodeGo,
529 VendorId::CommandCode,
530 VendorId::Ollama,
531 ]
532 }
533}
534
535pub type VendorOutcome = crate::outcome::Outcome<VendorSnapshot>;
540
541#[derive(Debug, Clone)]
543pub struct RenderOpts {
544 pub format: Option<String>,
545 pub tooltip_format: Option<String>,
546 pub icon: Option<String>,
547 pub pace_tolerance: u32,
548 pub format_pace_color: bool,
549 pub tooltip_pace_pts: bool,
550}
551
552impl RenderOpts {
553 pub fn from_cli(cli: &Cli) -> Self {
554 Self {
555 format: cli.format.clone(),
556 tooltip_format: cli.tooltip_format.clone(),
557 icon: cli.icon.clone(),
558 pace_tolerance: cli.pace_tolerance,
559 format_pace_color: cli.format_pace_color,
560 tooltip_pace_pts: cli.tooltip_pace_pts,
561 }
562 }
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568
569 #[test]
570 fn every_vendor_has_stable_machine_and_display_names() {
571 for vendor in VendorId::all() {
572 assert!(!vendor.slug().is_empty());
573 assert!(!vendor.display_name().is_empty());
574 }
575 assert_eq!(VendorId::Anthropic.slug(), "anthropic");
576 assert_eq!(VendorId::Anthropic.display_name(), "Claude");
577 assert_eq!(VendorId::Openai.display_name(), "Codex");
578 assert_eq!(VendorId::Zai.display_name(), "Z.AI");
579 }
580
581 #[test]
585 fn every_vendor_short_name_is_a_unique_three_letter_code() {
586 let mut seen = std::collections::BTreeSet::new();
587 for vendor in VendorId::all() {
588 let short = vendor.short_name();
589 assert_eq!(short.len(), 3, "{} is not three letters", vendor.slug());
590 assert!(
591 short.chars().all(|c| c.is_ascii_lowercase()),
592 "{} is not lowercase ascii",
593 vendor.slug()
594 );
595 assert!(seen.insert(short), "{short} is used by two vendors");
596 }
597 assert_eq!(VendorId::Anthropic.short_name(), "cld");
598 assert_eq!(VendorId::Openai.short_name(), "gpt");
599 assert_eq!(VendorId::Zai.short_name(), "zai");
600 assert_eq!(VendorId::Antigravity.short_name(), "agy");
601 }
602
603 #[test]
609 fn every_vendor_has_a_bar_icon_and_no_two_share_one() {
610 use std::collections::BTreeMap;
611
612 let mut by_icon: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
613 for vendor in VendorId::all() {
614 assert!(!vendor.bar_icon().is_empty(), "{}", vendor.slug());
615 by_icon
616 .entry(vendor.bar_icon())
617 .or_default()
618 .push(vendor.slug());
619 }
620
621 let shared: Vec<_> = by_icon
622 .iter()
623 .filter(|(_, vendors)| vendors.len() > 1)
624 .filter(|(_, vendors)| vendors.as_slice() != ["grok", "supergrok"])
625 .collect();
626 assert!(
627 shared.is_empty(),
628 "these providers are indistinguishable in a bar that shows them \
629 side by side: {shared:#?}"
630 );
631 assert_eq!(VendorId::Anthropic.bar_icon(), "");
632 assert_eq!(VendorId::Openai.bar_icon(), "");
633 assert_eq!(VendorId::Supergrok.bar_icon(), VendorId::Grok.bar_icon());
634 assert_eq!(VendorId::CommandCode.bar_icon(), "cmc");
635 }
636
637 #[test]
638 fn new_vendor_contracts_keep_public_names_and_slugs() {
639 assert_eq!(VendorId::NousResearch.slug(), "nous");
640 assert_eq!(VendorId::NousResearch.display_name(), "Nous Research");
641 assert_eq!(VendorId::OpenCodeGo.slug(), "opencode-go");
642 assert_eq!(VendorId::OpenCodeGo.display_name(), "OpenCode Go");
643 assert_eq!(
644 serde_json::to_value(VendorId::OpenCodeGo).unwrap(),
645 serde_json::json!("opencode-go")
646 );
647 }
648
649 #[test]
650 fn vendor_secret_env_vars_cover_config_defaults() {
651 let configured_defaults = [
652 "ZAI_API_KEY",
653 "OPENROUTER_API_KEY",
654 "DEEPSEEK_API_KEY",
655 "KIMI_API_KEY",
656 "KILO_API_KEY",
657 "NOVITA_API_KEY",
658 "MINIMAX_API_KEY",
659 "MOONSHOT_API_KEY",
660 "XAI_MANAGEMENT_KEY",
661 "ANTHROPIC_ADMIN_KEY",
662 "GITHUB_COPILOT_TOKEN",
663 ];
664 for name in configured_defaults {
665 assert!(VENDOR_SECRET_ENV_VARS.contains(&name), "missing {name}");
666 }
667 }
668
669 #[test]
670 fn vars_to_remove_preserves_only_requested_grok_credentials() {
671 let removed = vendor_secret_env_vars_to_remove(&["XAI_API_KEY", "GROK_API_KEY"]);
672 assert!(!removed.contains(&"XAI_API_KEY"));
673 assert!(!removed.contains(&"GROK_API_KEY"));
674 assert!(removed.contains(&"ANTHROPIC_ADMIN_KEY"));
675 assert!(removed.contains(&"OPENROUTER_API_KEY"));
676 let builtins = removed
679 .iter()
680 .filter(|var| VENDOR_SECRET_ENV_VARS.contains(var))
681 .count();
682 assert_eq!(builtins, VENDOR_SECRET_ENV_VARS.len() - 2);
683 }
684
685 #[test]
686 fn a_registered_custom_env_var_is_scrubbed_like_a_builtin_one() {
687 let name = "AI_USAGEBAR_TEST_CUSTOM_TOKEN_7F3A";
688 assert!(!vendor_secret_env_vars_to_remove(&[]).contains(&name));
689
690 register_secret_env_vars(&[name.to_string(), "not a name!".to_string()]);
691 register_secret_env_vars(&[name.to_string()]);
692
693 let removed = vendor_secret_env_vars_to_remove(&[]);
694 assert_eq!(
695 removed.iter().filter(|var| **var == name).count(),
696 1,
697 "registering twice must not list it twice: {removed:?}"
698 );
699 assert!(!removed.contains(&"not a name!"), "{removed:?}");
700 assert!(
701 !vendor_secret_env_vars_to_remove(&[name]).contains(&name),
702 "`keep` applies to registered names too"
703 );
704 }
705
706 #[test]
707 fn copilot_token_is_removed_before_unrelated_subprocesses_launch() {
708 let removed = vendor_secret_env_vars_to_remove(&[]);
709 assert!(removed.contains(&"GITHUB_COPILOT_TOKEN"));
710 }
711
712 #[tokio::test]
713 async fn body_over_the_cap_is_refused_and_under_it_round_trips() {
714 let mut server = mockito::Server::new_async().await;
715 server
716 .mock("GET", "/big")
717 .with_status(200)
718 .with_body("x".repeat(4096))
719 .create_async()
720 .await;
721 server
722 .mock("GET", "/small")
723 .with_status(200)
724 .with_body("hello")
725 .create_async()
726 .await;
727
728 let client = reqwest::Client::new();
729
730 let resp = client
732 .get(format!("{}/big", server.url()))
733 .send()
734 .await
735 .unwrap();
736 let err = read_body_capped(resp, 1024).await.unwrap_err();
737 assert!(
738 err.to_string().contains("exceeds"),
739 "unexpected error: {err}"
740 );
741
742 let resp = client
744 .get(format!("{}/small", server.url()))
745 .send()
746 .await
747 .unwrap();
748 assert_eq!(read_body_capped(resp, 1024).await.unwrap(), b"hello");
749 }
750
751 #[tokio::test]
752 async fn chunked_body_without_content_length_still_hits_the_cap() {
753 let mut server = mockito::Server::new_async().await;
754 server
755 .mock("GET", "/chunked")
756 .with_status(200)
757 .with_chunked_body(|writer| writer.write_all(&[b'x'; 4096]))
758 .create_async()
759 .await;
760
761 let response = reqwest::Client::new()
762 .get(format!("{}/chunked", server.url()))
763 .send()
764 .await
765 .unwrap();
766 assert!(response.content_length().is_none());
767 let error = read_body_capped(response, 1024).await.unwrap_err();
768 assert!(error.to_string().contains("exceeds"), "{error}");
769 }
770
771 #[tokio::test]
772 async fn same_origin_redirects_still_work_with_vendor_headers() {
773 let mut server = mockito::Server::new_async().await;
774 let redirect = server
775 .mock("GET", "/start")
776 .match_header("x-api-key", "secret")
777 .with_status(302)
778 .with_header("location", "/finish")
779 .create_async()
780 .await;
781 let finish = server
782 .mock("GET", "/finish")
783 .match_header("x-api-key", "secret")
784 .with_status(200)
785 .create_async()
786 .await;
787 let client = reqwest::Client::builder()
788 .redirect(same_origin_redirect_policy())
789 .build()
790 .unwrap();
791
792 let response = client
793 .get(format!("{}/start", server.url()))
794 .header("x-api-key", "secret")
795 .send()
796 .await
797 .unwrap();
798
799 assert_eq!(response.status(), reqwest::StatusCode::OK);
800 redirect.assert_async().await;
801 finish.assert_async().await;
802 }
803
804 #[tokio::test]
805 async fn cross_origin_redirects_are_not_followed_with_vendor_headers() {
806 let mut origin = mockito::Server::new_async().await;
807 let mut target = mockito::Server::new_async().await;
808 let target_url = format!("{}/capture", target.url());
809 let redirect = origin
810 .mock("GET", "/start")
811 .match_header("x-api-key", "secret")
812 .with_status(302)
813 .with_header("location", &target_url)
814 .create_async()
815 .await;
816 let capture = target
817 .mock("GET", "/capture")
818 .expect(0)
819 .create_async()
820 .await;
821 let client = reqwest::Client::builder()
822 .redirect(same_origin_redirect_policy())
823 .build()
824 .unwrap();
825
826 let response = client
827 .get(format!("{}/start", origin.url()))
828 .header("x-api-key", "secret")
829 .send()
830 .await
831 .unwrap();
832
833 assert_eq!(response.status(), reqwest::StatusCode::FOUND);
834 redirect.assert_async().await;
835 capture.assert_async().await;
836 }
837
838 #[test]
839 fn vendor_id_slug_round_trip() {
840 for id in VendorId::all() {
841 assert_eq!(
842 id.slug(),
843 serde_json::to_value(id).unwrap().as_str().unwrap()
844 );
845 }
846 }
847}