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 Antigravity,
170 Cursor,
171 Minimax,
172 Kiro,
173 #[serde(rename = "nous")]
174 NousResearch,
175 #[serde(rename = "opencode-go")]
176 OpenCodeGo,
177 #[serde(rename = "commandcode")]
178 CommandCode,
179 Ollama,
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
186#[serde(rename_all = "lowercase")]
187pub enum AuthKind {
188 Oauth,
190 ApiKey,
192 Local,
195}
196
197impl AuthKind {
198 pub const fn as_str(self) -> &'static str {
199 match self {
200 AuthKind::Oauth => "oauth",
201 AuthKind::ApiKey => "apikey",
202 AuthKind::Local => "local",
203 }
204 }
205}
206
207impl VendorId {
208 pub fn slug(self) -> &'static str {
209 match self {
210 VendorId::Anthropic => "anthropic",
211 VendorId::AnthropicApi => "anthropic_api",
212 VendorId::Openai => "openai",
213 VendorId::Copilot => "copilot",
214 VendorId::Zai => "zai",
215 VendorId::Openrouter => "openrouter",
216 VendorId::Deepseek => "deepseek",
217 VendorId::Kimi => "kimi",
218 VendorId::Kilo => "kilo",
219 VendorId::Novita => "novita",
220 VendorId::Moonshot => "moonshot",
221 VendorId::Grok => "grok",
222 VendorId::Supergrok => "supergrok",
223 VendorId::Antigravity => "antigravity",
224 VendorId::Cursor => "cursor",
225 VendorId::Minimax => "minimax",
226 VendorId::Kiro => "kiro",
227 VendorId::NousResearch => "nous",
228 VendorId::OpenCodeGo => "opencode-go",
229 VendorId::CommandCode => "commandcode",
230 VendorId::Ollama => "ollama",
231 }
232 }
233
234 pub fn display_name(self) -> &'static str {
238 match self {
239 VendorId::Anthropic => "Claude",
240 VendorId::AnthropicApi => "Anthropic API",
241 VendorId::Openai => "Codex",
242 VendorId::Copilot => "GitHub Copilot",
243 VendorId::Zai => "Z.AI",
244 VendorId::Openrouter => "OpenRouter",
245 VendorId::Deepseek => "DeepSeek",
246 VendorId::Kimi => "Kimi",
247 VendorId::Kilo => "Kilo",
248 VendorId::Novita => "Novita",
249 VendorId::Moonshot => "Moonshot",
250 VendorId::Grok => "Grok",
251 VendorId::Supergrok => "SuperGrok",
252 VendorId::Antigravity => "Antigravity",
253 VendorId::Cursor => "Cursor",
254 VendorId::Minimax => "MiniMax",
255 VendorId::Kiro => "Kiro",
256 VendorId::NousResearch => "Nous Research",
257 VendorId::OpenCodeGo => "OpenCode Go",
258 VendorId::CommandCode => "Command Code",
259 VendorId::Ollama => "Ollama Cloud",
260 }
261 }
262
263 pub const fn bar_icon(self) -> &'static str {
267 match self {
268 VendorId::Anthropic => "",
269 VendorId::AnthropicApi => "",
270 VendorId::Openai => "",
271 VendorId::Copilot => "",
272 VendorId::Zai => VendorId::Zai.short_name(),
273 VendorId::Openrouter => "",
274 VendorId::Deepseek => "",
275 VendorId::Kimi => VendorId::Kimi.short_name(),
276 VendorId::Kilo => "",
277 VendorId::Novita => "",
278 VendorId::Moonshot => VendorId::Moonshot.short_name(),
279 VendorId::Grok | VendorId::Supergrok => "",
280 VendorId::Antigravity => VendorId::Antigravity.short_name(),
281 VendorId::Cursor => "❯",
282 VendorId::Minimax => VendorId::Minimax.short_name(),
283 VendorId::Kiro => "◆",
284 VendorId::NousResearch => VendorId::NousResearch.short_name(),
285 VendorId::OpenCodeGo => VendorId::OpenCodeGo.short_name(),
286 VendorId::CommandCode => VendorId::CommandCode.short_name(),
287 VendorId::Ollama => VendorId::Ollama.short_name(),
290 }
291 }
292
293 pub const fn short_name(self) -> &'static str {
298 match self {
299 VendorId::Anthropic => "cld",
300 VendorId::AnthropicApi => "aac",
301 VendorId::Openai => "gpt",
302 VendorId::Copilot => "ghc",
303 VendorId::Zai => "zai",
304 VendorId::Openrouter => "opr",
305 VendorId::Deepseek => "dsk",
306 VendorId::Kimi => "kmi",
307 VendorId::Kilo => "klo",
308 VendorId::Novita => "nvt",
309 VendorId::Moonshot => "msh",
310 VendorId::Grok => "grk",
311 VendorId::Supergrok => "sgk",
312 VendorId::Antigravity => "agy",
313 VendorId::Cursor => "cur",
314 VendorId::Minimax => "mmx",
315 VendorId::Kiro => "kir",
316 VendorId::NousResearch => "nrs",
317 VendorId::OpenCodeGo => "ocg",
318 VendorId::CommandCode => "cmc",
319 VendorId::Ollama => "oll",
320 }
321 }
322
323 pub const fn config_section(self) -> &'static str {
331 match self {
332 VendorId::Anthropic => "anthropic",
333 VendorId::AnthropicApi => "anthropic_api",
334 VendorId::Openai => "openai",
335 VendorId::Copilot => "copilot",
336 VendorId::Zai => "zai",
337 VendorId::Openrouter => "openrouter",
338 VendorId::Deepseek => "deepseek",
339 VendorId::Kimi => "kimi",
340 VendorId::Kilo => "kilo",
341 VendorId::Novita => "novita",
342 VendorId::Moonshot => "moonshot",
343 VendorId::Grok => "grok",
344 VendorId::Supergrok => "supergrok",
345 VendorId::Antigravity => "antigravity",
346 VendorId::Cursor => "cursor",
347 VendorId::Minimax => "minimax",
348 VendorId::Kiro => "kiro",
349 VendorId::NousResearch => "nous",
350 VendorId::OpenCodeGo => "opencode-go",
351 VendorId::CommandCode => "commandcode",
352 VendorId::Ollama => "ollama",
353 }
354 }
355
356 pub const fn auth_kind(self) -> AuthKind {
362 match self {
363 VendorId::Anthropic
364 | VendorId::Openai
365 | VendorId::Copilot
366 | VendorId::NousResearch
367 | VendorId::CommandCode => AuthKind::Oauth,
368 VendorId::AnthropicApi
369 | VendorId::Zai
370 | VendorId::Openrouter
371 | VendorId::Deepseek
372 | VendorId::Kimi
373 | VendorId::Kilo
374 | VendorId::Novita
375 | VendorId::Moonshot
376 | VendorId::Grok
377 | VendorId::Minimax
378 | VendorId::OpenCodeGo
379 | VendorId::Ollama => AuthKind::ApiKey,
380 VendorId::Supergrok | VendorId::Antigravity | VendorId::Cursor | VendorId::Kiro => {
386 AuthKind::Local
387 }
388 }
389 }
390
391 pub const fn api_key_env(self) -> &'static str {
396 match self {
397 VendorId::AnthropicApi => "ANTHROPIC_ADMIN_KEY",
398 VendorId::Zai => "ZAI_API_KEY",
399 VendorId::Openrouter => "OPENROUTER_API_KEY",
400 VendorId::Deepseek => "DEEPSEEK_API_KEY",
401 VendorId::Kimi => "KIMI_API_KEY",
402 VendorId::Kilo => "KILO_API_KEY",
403 VendorId::Novita => "NOVITA_API_KEY",
404 VendorId::Moonshot => "MOONSHOT_API_KEY",
405 VendorId::Grok => "XAI_MANAGEMENT_KEY",
406 VendorId::Minimax => "MINIMAX_API_KEY",
407 VendorId::OpenCodeGo => "OPENCODE_GO_API_KEY",
408 VendorId::Ollama => "OLLAMA_API_KEY",
409 VendorId::Copilot => "GITHUB_COPILOT_TOKEN",
413 VendorId::CommandCode => "COMMANDCODE_API_KEY",
414 VendorId::Anthropic
415 | VendorId::Openai
416 | VendorId::Supergrok
417 | VendorId::Antigravity
418 | VendorId::Cursor
419 | VendorId::Kiro
420 | VendorId::NousResearch => "",
421 }
422 }
423
424 pub const fn sign_in_hint(self) -> &'static str {
437 match self {
438 VendorId::Anthropic => "Run `claude` in a terminal, then Refresh.",
439 VendorId::Openai => "Run `codex login` in a terminal, then Refresh.",
440 VendorId::Copilot => "Run `gh auth login` in a terminal, then Refresh.",
441 VendorId::Kiro => "Run `kiro-cli login` in a terminal, then Refresh.",
442 VendorId::Kimi => "Run `kimi` in a terminal, or set an API key.",
443 VendorId::CommandCode => "Run `commandcode` in a terminal, then Refresh.",
444 VendorId::NousResearch => {
445 "Run `ai-usagebar auth nous login` in a terminal, then Refresh."
446 }
447 VendorId::Cursor => "Sign in to the Cursor app, then Refresh.",
448 VendorId::Antigravity => "Open Antigravity or run `agy`, then Refresh.",
449 VendorId::Grok | VendorId::Supergrok => "Sign in with `grok`, then Refresh.",
450 VendorId::AnthropicApi
455 | VendorId::Zai
456 | VendorId::Openrouter
457 | VendorId::Deepseek
458 | VendorId::Kilo
459 | VendorId::Novita
460 | VendorId::Moonshot
461 | VendorId::Minimax
462 | VendorId::OpenCodeGo
463 | VendorId::Ollama => "Add an API key in Settings, then Refresh.",
464 }
465 }
466
467 pub const fn login_command(self) -> &'static str {
468 match self {
469 VendorId::Anthropic => "claude",
470 VendorId::Openai => "codex login",
471 VendorId::Copilot => "gh auth login",
472 VendorId::CommandCode => "commandcode",
473 VendorId::NousResearch => "ai-usagebar auth nous login",
474 VendorId::Kiro => "kiro-cli login",
475 VendorId::Kimi => "kimi",
478 VendorId::AnthropicApi
479 | VendorId::Zai
480 | VendorId::Openrouter
481 | VendorId::Deepseek
482 | VendorId::Kilo
483 | VendorId::Novita
484 | VendorId::Moonshot
485 | VendorId::Grok
486 | VendorId::Supergrok
487 | VendorId::Antigravity
488 | VendorId::Cursor
489 | VendorId::Minimax
490 | VendorId::OpenCodeGo
491 | VendorId::Ollama => "",
492 }
493 }
494
495 pub fn all() -> &'static [VendorId] {
496 &[
497 VendorId::Anthropic,
498 VendorId::AnthropicApi,
499 VendorId::Openai,
500 VendorId::Copilot,
501 VendorId::Zai,
502 VendorId::Openrouter,
503 VendorId::Deepseek,
504 VendorId::Kimi,
505 VendorId::Kilo,
506 VendorId::Novita,
507 VendorId::Moonshot,
508 VendorId::Grok,
509 VendorId::Supergrok,
510 VendorId::Antigravity,
511 VendorId::Cursor,
512 VendorId::Minimax,
513 VendorId::Kiro,
514 VendorId::NousResearch,
515 VendorId::OpenCodeGo,
516 VendorId::CommandCode,
517 VendorId::Ollama,
518 ]
519 }
520}
521
522pub type VendorOutcome = crate::outcome::Outcome<VendorSnapshot>;
527
528#[derive(Debug, Clone)]
530pub struct RenderOpts {
531 pub format: Option<String>,
532 pub tooltip_format: Option<String>,
533 pub icon: Option<String>,
534 pub pace_tolerance: u32,
535 pub format_pace_color: bool,
536 pub tooltip_pace_pts: bool,
537}
538
539impl RenderOpts {
540 pub fn from_cli(cli: &Cli) -> Self {
541 Self {
542 format: cli.format.clone(),
543 tooltip_format: cli.tooltip_format.clone(),
544 icon: cli.icon.clone(),
545 pace_tolerance: cli.pace_tolerance,
546 format_pace_color: cli.format_pace_color,
547 tooltip_pace_pts: cli.tooltip_pace_pts,
548 }
549 }
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555
556 #[test]
557 fn every_vendor_has_stable_machine_and_display_names() {
558 for vendor in VendorId::all() {
559 assert!(!vendor.slug().is_empty());
560 assert!(!vendor.display_name().is_empty());
561 }
562 assert_eq!(VendorId::Anthropic.slug(), "anthropic");
563 assert_eq!(VendorId::Anthropic.display_name(), "Claude");
564 assert_eq!(VendorId::Openai.display_name(), "Codex");
565 assert_eq!(VendorId::Zai.display_name(), "Z.AI");
566 }
567
568 #[test]
572 fn every_vendor_short_name_is_a_unique_three_letter_code() {
573 let mut seen = std::collections::BTreeSet::new();
574 for vendor in VendorId::all() {
575 let short = vendor.short_name();
576 assert_eq!(short.len(), 3, "{} is not three letters", vendor.slug());
577 assert!(
578 short.chars().all(|c| c.is_ascii_lowercase()),
579 "{} is not lowercase ascii",
580 vendor.slug()
581 );
582 assert!(seen.insert(short), "{short} is used by two vendors");
583 }
584 assert_eq!(VendorId::Anthropic.short_name(), "cld");
585 assert_eq!(VendorId::Openai.short_name(), "gpt");
586 assert_eq!(VendorId::Zai.short_name(), "zai");
587 assert_eq!(VendorId::Antigravity.short_name(), "agy");
588 }
589
590 #[test]
596 fn every_vendor_has_a_bar_icon_and_no_two_share_one() {
597 use std::collections::BTreeMap;
598
599 let mut by_icon: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
600 for vendor in VendorId::all() {
601 assert!(!vendor.bar_icon().is_empty(), "{}", vendor.slug());
602 by_icon
603 .entry(vendor.bar_icon())
604 .or_default()
605 .push(vendor.slug());
606 }
607
608 let shared: Vec<_> = by_icon
609 .iter()
610 .filter(|(_, vendors)| vendors.len() > 1)
611 .filter(|(_, vendors)| vendors.as_slice() != ["grok", "supergrok"])
612 .collect();
613 assert!(
614 shared.is_empty(),
615 "these providers are indistinguishable in a bar that shows them \
616 side by side: {shared:#?}"
617 );
618 assert_eq!(VendorId::Anthropic.bar_icon(), "");
619 assert_eq!(VendorId::Openai.bar_icon(), "");
620 assert_eq!(VendorId::Supergrok.bar_icon(), VendorId::Grok.bar_icon());
621 assert_eq!(VendorId::CommandCode.bar_icon(), "cmc");
622 }
623
624 #[test]
625 fn new_vendor_contracts_keep_public_names_and_slugs() {
626 assert_eq!(VendorId::NousResearch.slug(), "nous");
627 assert_eq!(VendorId::NousResearch.display_name(), "Nous Research");
628 assert_eq!(VendorId::OpenCodeGo.slug(), "opencode-go");
629 assert_eq!(VendorId::OpenCodeGo.display_name(), "OpenCode Go");
630 assert_eq!(
631 serde_json::to_value(VendorId::OpenCodeGo).unwrap(),
632 serde_json::json!("opencode-go")
633 );
634 }
635
636 #[test]
637 fn vendor_secret_env_vars_cover_config_defaults() {
638 let configured_defaults = [
639 "ZAI_API_KEY",
640 "OPENROUTER_API_KEY",
641 "DEEPSEEK_API_KEY",
642 "KIMI_API_KEY",
643 "KILO_API_KEY",
644 "NOVITA_API_KEY",
645 "MINIMAX_API_KEY",
646 "MOONSHOT_API_KEY",
647 "XAI_MANAGEMENT_KEY",
648 "ANTHROPIC_ADMIN_KEY",
649 "GITHUB_COPILOT_TOKEN",
650 ];
651 for name in configured_defaults {
652 assert!(VENDOR_SECRET_ENV_VARS.contains(&name), "missing {name}");
653 }
654 }
655
656 #[test]
657 fn vars_to_remove_preserves_only_requested_grok_credentials() {
658 let removed = vendor_secret_env_vars_to_remove(&["XAI_API_KEY", "GROK_API_KEY"]);
659 assert!(!removed.contains(&"XAI_API_KEY"));
660 assert!(!removed.contains(&"GROK_API_KEY"));
661 assert!(removed.contains(&"ANTHROPIC_ADMIN_KEY"));
662 assert!(removed.contains(&"OPENROUTER_API_KEY"));
663 let builtins = removed
666 .iter()
667 .filter(|var| VENDOR_SECRET_ENV_VARS.contains(var))
668 .count();
669 assert_eq!(builtins, VENDOR_SECRET_ENV_VARS.len() - 2);
670 }
671
672 #[test]
673 fn a_registered_custom_env_var_is_scrubbed_like_a_builtin_one() {
674 let name = "AI_USAGEBAR_TEST_CUSTOM_TOKEN_7F3A";
675 assert!(!vendor_secret_env_vars_to_remove(&[]).contains(&name));
676
677 register_secret_env_vars(&[name.to_string(), "not a name!".to_string()]);
678 register_secret_env_vars(&[name.to_string()]);
679
680 let removed = vendor_secret_env_vars_to_remove(&[]);
681 assert_eq!(
682 removed.iter().filter(|var| **var == name).count(),
683 1,
684 "registering twice must not list it twice: {removed:?}"
685 );
686 assert!(!removed.contains(&"not a name!"), "{removed:?}");
687 assert!(
688 !vendor_secret_env_vars_to_remove(&[name]).contains(&name),
689 "`keep` applies to registered names too"
690 );
691 }
692
693 #[test]
694 fn copilot_token_is_removed_before_unrelated_subprocesses_launch() {
695 let removed = vendor_secret_env_vars_to_remove(&[]);
696 assert!(removed.contains(&"GITHUB_COPILOT_TOKEN"));
697 }
698
699 #[tokio::test]
700 async fn body_over_the_cap_is_refused_and_under_it_round_trips() {
701 let mut server = mockito::Server::new_async().await;
702 server
703 .mock("GET", "/big")
704 .with_status(200)
705 .with_body("x".repeat(4096))
706 .create_async()
707 .await;
708 server
709 .mock("GET", "/small")
710 .with_status(200)
711 .with_body("hello")
712 .create_async()
713 .await;
714
715 let client = reqwest::Client::new();
716
717 let resp = client
719 .get(format!("{}/big", server.url()))
720 .send()
721 .await
722 .unwrap();
723 let err = read_body_capped(resp, 1024).await.unwrap_err();
724 assert!(
725 err.to_string().contains("exceeds"),
726 "unexpected error: {err}"
727 );
728
729 let resp = client
731 .get(format!("{}/small", server.url()))
732 .send()
733 .await
734 .unwrap();
735 assert_eq!(read_body_capped(resp, 1024).await.unwrap(), b"hello");
736 }
737
738 #[tokio::test]
739 async fn chunked_body_without_content_length_still_hits_the_cap() {
740 let mut server = mockito::Server::new_async().await;
741 server
742 .mock("GET", "/chunked")
743 .with_status(200)
744 .with_chunked_body(|writer| writer.write_all(&[b'x'; 4096]))
745 .create_async()
746 .await;
747
748 let response = reqwest::Client::new()
749 .get(format!("{}/chunked", server.url()))
750 .send()
751 .await
752 .unwrap();
753 assert!(response.content_length().is_none());
754 let error = read_body_capped(response, 1024).await.unwrap_err();
755 assert!(error.to_string().contains("exceeds"), "{error}");
756 }
757
758 #[tokio::test]
759 async fn same_origin_redirects_still_work_with_vendor_headers() {
760 let mut server = mockito::Server::new_async().await;
761 let redirect = server
762 .mock("GET", "/start")
763 .match_header("x-api-key", "secret")
764 .with_status(302)
765 .with_header("location", "/finish")
766 .create_async()
767 .await;
768 let finish = server
769 .mock("GET", "/finish")
770 .match_header("x-api-key", "secret")
771 .with_status(200)
772 .create_async()
773 .await;
774 let client = reqwest::Client::builder()
775 .redirect(same_origin_redirect_policy())
776 .build()
777 .unwrap();
778
779 let response = client
780 .get(format!("{}/start", server.url()))
781 .header("x-api-key", "secret")
782 .send()
783 .await
784 .unwrap();
785
786 assert_eq!(response.status(), reqwest::StatusCode::OK);
787 redirect.assert_async().await;
788 finish.assert_async().await;
789 }
790
791 #[tokio::test]
792 async fn cross_origin_redirects_are_not_followed_with_vendor_headers() {
793 let mut origin = mockito::Server::new_async().await;
794 let mut target = mockito::Server::new_async().await;
795 let target_url = format!("{}/capture", target.url());
796 let redirect = origin
797 .mock("GET", "/start")
798 .match_header("x-api-key", "secret")
799 .with_status(302)
800 .with_header("location", &target_url)
801 .create_async()
802 .await;
803 let capture = target
804 .mock("GET", "/capture")
805 .expect(0)
806 .create_async()
807 .await;
808 let client = reqwest::Client::builder()
809 .redirect(same_origin_redirect_policy())
810 .build()
811 .unwrap();
812
813 let response = client
814 .get(format!("{}/start", origin.url()))
815 .header("x-api-key", "secret")
816 .send()
817 .await
818 .unwrap();
819
820 assert_eq!(response.status(), reqwest::StatusCode::FOUND);
821 redirect.assert_async().await;
822 capture.assert_async().await;
823 }
824
825 #[test]
826 fn vendor_id_slug_round_trip() {
827 for id in VendorId::all() {
828 assert_eq!(
829 id.slug(),
830 serde_json::to_value(id).unwrap().as_str().unwrap()
831 );
832 }
833 }
834}