1use std::collections::BTreeMap;
14use std::fmt;
15
16use serde::{Deserialize, Serialize};
17
18use super::broker::BrokerError;
19use super::provider::OAuthProviderId;
20
21pub const DEFAULT_ACCOUNT_NAME: &str = "default";
23
24pub const AUTO_ACCOUNT_NAME: &str = "auto";
26
27const MAX_LABEL_LEN: usize = 32;
28
29#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
34pub struct AccountLabel(String);
35
36impl AccountLabel {
37 pub fn parse(raw: &str) -> Result<Self, String> {
38 let value = raw.trim();
39 if value.is_empty() {
40 return Err("account label is empty".into());
41 }
42 if value == DEFAULT_ACCOUNT_NAME || value == AUTO_ACCOUNT_NAME {
43 return Err(format!("'{value}' is reserved and cannot be used as an account label"));
44 }
45 if value.len() > MAX_LABEL_LEN {
46 return Err(format!(
47 "account label is too long ({} chars; max {MAX_LABEL_LEN})",
48 value.len()
49 ));
50 }
51 let mut chars = value.chars();
52 let first = chars.next().expect("non-empty");
53 if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
54 return Err(
55 "account label must start with a lowercase letter or digit (a-z, 0-9)".into(),
56 );
57 }
58 if !chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-'))
59 {
60 return Err(
61 "account label may only contain a-z, 0-9, '.', '_' and '-' (max 32 chars)".into(),
62 );
63 }
64 Ok(Self(value.to_string()))
65 }
66
67 pub fn as_str(&self) -> &str {
68 &self.0
69 }
70}
71
72impl fmt::Debug for AccountLabel {
73 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74 write!(f, "AccountLabel({})", self.0)
75 }
76}
77
78impl fmt::Display for AccountLabel {
79 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80 f.write_str(&self.0)
81 }
82}
83
84#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
88pub enum Account {
89 #[default]
91 Default,
92 Named(AccountLabel),
93}
94
95impl Account {
96 pub fn parse(raw: &str) -> Result<Self, String> {
100 let value = raw.trim();
101 if value == DEFAULT_ACCOUNT_NAME {
102 return Ok(Self::Default);
103 }
104 AccountLabel::parse(value).map(Self::Named)
105 }
106
107 pub fn named(label: &str) -> Result<Self, String> {
108 AccountLabel::parse(label).map(Self::Named)
109 }
110
111 pub fn is_default(&self) -> bool {
112 matches!(self, Self::Default)
113 }
114
115 pub fn label_str(&self) -> &str {
117 match self {
118 Self::Default => DEFAULT_ACCOUNT_NAME,
119 Self::Named(label) => label.as_str(),
120 }
121 }
122}
123
124impl fmt::Debug for Account {
125 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126 write!(f, "Account({})", self.label_str())
127 }
128}
129
130impl fmt::Display for Account {
131 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132 f.write_str(self.label_str())
133 }
134}
135
136impl Serialize for Account {
137 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
138 serializer.serialize_str(self.label_str())
139 }
140}
141
142impl<'de> Deserialize<'de> for Account {
143 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
144 let raw = String::deserialize(deserializer)?;
145 Account::parse(&raw).map_err(serde::de::Error::custom)
146 }
147}
148
149#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
153pub struct CredentialRef {
154 pub provider: OAuthProviderId,
155 pub account: Account,
156}
157
158impl CredentialRef {
159 pub fn new(provider: OAuthProviderId, account: Account) -> Self {
160 Self { provider, account }
161 }
162
163 pub fn default_for(provider: OAuthProviderId) -> Self {
164 Self {
165 provider,
166 account: Account::Default,
167 }
168 }
169
170 pub fn storage_key(&self) -> String {
172 match &self.account {
173 Account::Default => self.provider.as_str().to_string(),
174 Account::Named(label) => format!("{}@{}", self.provider.as_str(), label.as_str()),
175 }
176 }
177
178 pub fn parse_storage_key(key: &str) -> Option<Self> {
182 match key.split_once('@') {
183 None => {
184 let provider: OAuthProviderId = key.parse().ok()?;
185 Some(Self::default_for(provider))
186 }
187 Some((provider, label)) => {
188 let provider: OAuthProviderId = provider.parse().ok()?;
189 let label = AccountLabel::parse(label).ok()?;
190 Some(Self::new(provider, Account::Named(label)))
191 }
192 }
193 }
194
195 pub(crate) fn key_belongs_to(key: &str, provider: OAuthProviderId) -> bool {
198 key == provider.as_str()
199 || key
200 .strip_prefix(provider.as_str())
201 .is_some_and(|rest| rest.starts_with('@'))
202 }
203}
204
205#[derive(Serialize, Deserialize)]
206struct CredentialRefWire {
207 provider: String,
208 account: String,
209}
210
211impl Serialize for CredentialRef {
212 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
213 CredentialRefWire {
214 provider: self.provider.as_str().to_string(),
215 account: self.account.label_str().to_string(),
216 }
217 .serialize(serializer)
218 }
219}
220
221impl<'de> Deserialize<'de> for CredentialRef {
222 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
223 let wire = CredentialRefWire::deserialize(deserializer)?;
224 let provider: OAuthProviderId = wire.provider.parse().map_err(serde::de::Error::custom)?;
225 let account = Account::parse(&wire.account).map_err(serde::de::Error::custom)?;
226 Ok(Self { provider, account })
227 }
228}
229
230impl fmt::Debug for CredentialRef {
231 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232 write!(f, "CredentialRef({})", self.storage_key())
233 }
234}
235
236impl fmt::Display for CredentialRef {
237 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238 f.write_str(&self.storage_key())
239 }
240}
241
242#[derive(Clone, PartialEq, Eq, Debug)]
246pub enum AccountSelector {
247 Account(Account),
248 Auto,
251 Invalid { source: String, reason: String },
255}
256
257impl AccountSelector {
258 pub fn parse(raw: &str) -> Result<Self, String> {
260 let value = raw.trim();
261 if value == AUTO_ACCOUNT_NAME {
262 return Ok(Self::Auto);
263 }
264 Account::parse(value).map(Self::Account)
265 }
266
267 pub fn parse_or_invalid(raw: &str, source: &str) -> Self {
269 match Self::parse(raw) {
270 Ok(selector) => selector,
271 Err(reason) => Self::Invalid {
272 source: source.to_string(),
273 reason,
274 },
275 }
276 }
277
278 pub fn as_config_value(&self) -> String {
279 match self {
280 Self::Account(account) => account.label_str().to_string(),
281 Self::Auto => AUTO_ACCOUNT_NAME.to_string(),
282 Self::Invalid { .. } => String::new(),
283 }
284 }
285}
286
287pub fn account_env_var(provider: OAuthProviderId) -> String {
290 format!(
291 "SYNAPS_ACCOUNT_{}",
292 provider.as_str().to_ascii_uppercase().replace('-', "_")
293 )
294}
295
296pub fn account_config_key(provider: OAuthProviderId) -> String {
298 format!("auth.account.{}", provider.as_str())
299}
300
301#[derive(Clone, Debug, Default, PartialEq, Eq)]
304pub struct AccountPolicy {
305 per_provider: BTreeMap<OAuthProviderId, AccountSelector>,
306}
307
308impl AccountPolicy {
309 pub fn new() -> Self {
310 Self::default()
311 }
312
313 pub fn set(&mut self, provider: OAuthProviderId, selector: AccountSelector) {
314 self.per_provider.insert(provider, selector);
315 }
316
317 pub fn with(mut self, provider: OAuthProviderId, selector: AccountSelector) -> Self {
318 self.set(provider, selector);
319 self
320 }
321
322 pub fn selector(&self, provider: OAuthProviderId) -> AccountSelector {
323 self.per_provider
324 .get(&provider)
325 .cloned()
326 .unwrap_or(AccountSelector::Account(Account::Default))
327 }
328
329 pub fn is_empty(&self) -> bool {
330 self.per_provider.is_empty()
331 }
332
333 pub fn from_config_map(map: &BTreeMap<String, String>) -> (Self, Vec<String>) {
341 let mut policy = Self::new();
342 let mut warnings = Vec::new();
343 for (provider, value) in map {
344 if value.trim().is_empty() {
345 continue;
346 }
347 let source = format!("auth.account.{provider}");
348 let Ok(provider_id) = provider.parse::<OAuthProviderId>() else {
349 warnings.push(format!("{source}: unknown OAuth provider (ignored)"));
350 continue;
351 };
352 let selector = AccountSelector::parse_or_invalid(value, &source);
353 if let AccountSelector::Invalid { reason, .. } = &selector {
354 warnings.push(format!(
355 "{source}: {reason} — account selection for {provider} is disabled until fixed"
356 ));
357 }
358 policy.set(provider_id, selector);
359 }
360 (policy, warnings)
361 }
362
363 pub fn with_env_overlay(mut self) -> Self {
367 for descriptor in super::provider::DESCRIPTORS.iter() {
368 let var = account_env_var(descriptor.id);
369 let Ok(raw) = std::env::var(&var) else {
370 continue;
371 };
372 if raw.trim().is_empty() {
373 continue;
374 }
375 let selector = AccountSelector::parse_or_invalid(&raw, &var);
376 if let AccountSelector::Invalid { reason, .. } = &selector {
377 tracing::warn!(var = %var, "invalid account selector; failing closed: {reason}");
378 }
379 self.set(descriptor.id, selector);
380 }
381 self
382 }
383
384 pub fn from_environment() -> Self {
388 let (policy, _warnings) = Self::from_config_map(&crate::config::load_config().auth.accounts);
389 policy.with_env_overlay()
390 }
391
392 pub fn resolve(&self, provider: OAuthProviderId) -> Result<CredentialRef, BrokerError> {
396 match self.selector(provider) {
397 AccountSelector::Account(account) => Ok(CredentialRef::new(provider, account)),
398 AccountSelector::Auto => Err(BrokerError::NoAccountAvailable {
399 provider: provider.as_str().to_string(),
400 reason: "automatic account selection requires a capacity selector".into(),
401 failure: None,
402 }),
403 AccountSelector::Invalid { source, reason } => Err(BrokerError::InvalidAccount(
404 format!("{source}: {reason}"),
405 )),
406 }
407 }
408}
409
410#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
419pub struct AccountSummary {
420 pub provider: String,
421 pub label: String,
422 #[serde(default, skip_serializing_if = "Option::is_none")]
423 pub identity: Option<String>,
424 #[serde(default, skip_serializing_if = "Option::is_none")]
425 pub account_id_prefix: Option<String>,
426 #[serde(default, skip_serializing_if = "Option::is_none")]
432 pub seat_fingerprint: Option<String>,
433 #[serde(default)]
435 pub expires: u64,
436 #[serde(default, skip_serializing_if = "Option::is_none")]
437 pub added_at: Option<u64>,
438 #[serde(default)]
440 pub selected: bool,
441 #[serde(default, skip_serializing_if = "Option::is_none")]
443 pub cooldown_until: Option<u64>,
444}
445
446impl AccountSummary {
447 pub fn new(provider: OAuthProviderId, account: &Account) -> Self {
449 Self {
450 provider: provider.as_str().to_string(),
451 label: account.label_str().to_string(),
452 ..Self::default()
453 }
454 }
455
456 pub fn with_account_id(mut self, account_id: Option<&str>) -> Self {
459 self.account_id_prefix = account_id.and_then(account_id_prefix);
460 self.seat_fingerprint = self
461 .provider
462 .parse::<OAuthProviderId>()
463 .ok()
464 .zip(account_id)
465 .and_then(|(provider, id)| seat_fingerprint(provider, id));
466 self
467 }
468
469 pub fn credential_ref(&self) -> Option<CredentialRef> {
470 let provider: OAuthProviderId = self.provider.parse().ok()?;
471 let account = Account::parse(&self.label).ok()?;
472 Some(CredentialRef::new(provider, account))
473 }
474}
475
476pub fn seat_fingerprint(provider: OAuthProviderId, account_id: &str) -> Option<String> {
483 let id = account_id.trim();
484 if id.is_empty() {
485 return None;
486 }
487 Some(hex_digest(&[b"synaps-seat-v1", provider.as_str().as_bytes(), id.as_bytes()]))
488}
489
490fn hex_digest(parts: &[&[u8]]) -> String {
491 use sha2::{Digest, Sha256};
492 let mut hasher = Sha256::new();
493 for part in parts {
494 hasher.update(part);
495 hasher.update([0u8]);
496 }
497 format!("{:x}", hasher.finalize())
498}
499
500#[derive(Clone, PartialEq, Eq)]
507pub struct SeatIdentity(String);
508
509impl SeatIdentity {
510 pub fn of(provider: OAuthProviderId, creds: &super::OAuthCredentials) -> Self {
511 if let Some(fp) = creds
512 .account_id
513 .as_deref()
514 .and_then(|id| seat_fingerprint(provider, id))
515 {
516 return Self(format!("id:{fp}"));
517 }
518 let material = if creds.refresh.is_empty() {
519 creds.access.as_bytes()
520 } else {
521 creds.refresh.as_bytes()
522 };
523 Self(format!(
524 "cred:{}",
525 hex_digest(&[b"synaps-cred-v1", provider.as_str().as_bytes(), material])
526 ))
527 }
528
529 pub fn seat_fingerprint(&self) -> Option<&str> {
531 self.0.strip_prefix("id:")
532 }
533
534 pub fn as_str(&self) -> &str {
535 &self.0
536 }
537}
538
539impl fmt::Debug for SeatIdentity {
540 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
541 let (kind, digest) = self.0.split_once(':').unwrap_or(("?", &self.0));
543 write!(f, "SeatIdentity({kind}:{}…)", &digest[..digest.len().min(8)])
544 }
545}
546
547#[derive(Clone, PartialEq, Eq)]
554pub struct MaterialDigest(String);
555
556impl MaterialDigest {
557 pub fn of(provider: OAuthProviderId, creds: &super::OAuthCredentials) -> Self {
558 Self(hex_digest(&[
559 b"synaps-refused-refresh-v1",
560 provider.as_str().as_bytes(),
561 creds.access.as_bytes(),
562 creds.refresh.as_bytes(),
563 ]))
564 }
565
566 #[doc(hidden)]
568 pub fn expose_for_tests(&self) -> &str {
569 &self.0
570 }
571}
572
573pub(crate) fn account_id_prefix(account_id: &str) -> Option<String> {
575 let trimmed = account_id.trim();
576 if trimmed.is_empty() {
577 return None;
578 }
579 Some(trimmed.chars().take(8).collect())
580}
581
582#[cfg(test)]
585pub(crate) fn env_lock() -> std::sync::MutexGuard<'static, ()> {
586 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
587 ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593
594 #[test]
595 fn label_grammar() {
596 for ok in ["a", "astra2", "m27", "work.eu", "x_y-z", "0", &"a".repeat(32)] {
597 assert!(AccountLabel::parse(ok).is_ok(), "{ok} must be valid");
598 }
599 for bad in [
600 "",
601 " ",
602 "default",
603 "auto",
604 "Astra",
605 "-lead",
606 ".dot",
607 "with space",
608 "a@b",
609 "a/b",
610 "../x",
611 "émoji",
612 &"a".repeat(33),
613 ] {
614 assert!(AccountLabel::parse(bad).is_err(), "{bad:?} must be rejected");
615 }
616 }
617
618 #[test]
619 fn account_parse_default_and_named() {
620 assert!(Account::parse("").is_err(), "explicit empty account must error");
621 assert!(Account::parse(" ").is_err());
622 assert_eq!(Account::parse("default").unwrap(), Account::Default);
623 assert_eq!(Account::parse(" default ").unwrap(), Account::Default);
624 assert_eq!(Account::parse("astra2").unwrap().label_str(), "astra2");
625 assert!(Account::parse("Bad").is_err());
626 assert!(Account::parse("auto").is_err(), "auto is a selector, not an account");
627 }
628
629 #[test]
630 fn storage_key_round_trip() {
631 let default = CredentialRef::default_for(OAuthProviderId::OpenAiCodex);
632 assert_eq!(default.storage_key(), "openai-codex");
633 let named = CredentialRef::new(
634 OAuthProviderId::OpenAiCodex,
635 Account::named("astra2").unwrap(),
636 );
637 assert_eq!(named.storage_key(), "openai-codex@astra2");
638 assert_eq!(
639 CredentialRef::parse_storage_key("openai-codex").unwrap(),
640 default
641 );
642 assert_eq!(
643 CredentialRef::parse_storage_key("openai-codex@astra2").unwrap(),
644 named
645 );
646 assert_eq!(
647 CredentialRef::parse_storage_key("anthropic@m27").unwrap().provider,
648 OAuthProviderId::Anthropic
649 );
650 }
651
652 #[test]
653 fn parse_storage_key_rejects_non_oauth_and_malformed() {
654 for key in [
655 "groq",
656 "aws-bedrock",
657 "local",
658 "unknown@x",
659 "openai-codex@",
660 "openai-codex@default",
661 "openai-codex@auto",
662 "openai-codex@Bad",
663 "openai-codex@a@b",
664 "@astra2",
665 "",
666 ] {
667 assert!(
668 CredentialRef::parse_storage_key(key).is_none(),
669 "{key:?} must not parse"
670 );
671 }
672 }
673
674 #[test]
675 fn key_belongs_to_provider_prefix_is_exact() {
676 assert!(CredentialRef::key_belongs_to("kimi-code", OAuthProviderId::KimiCode));
677 assert!(CredentialRef::key_belongs_to("kimi-code@BAD", OAuthProviderId::KimiCode));
678 assert!(!CredentialRef::key_belongs_to("kimi", OAuthProviderId::KimiCode));
679 assert!(!CredentialRef::key_belongs_to("kimi-code2", OAuthProviderId::KimiCode));
680 }
681
682 #[test]
683 fn env_and_config_key_names() {
684 assert_eq!(
685 account_env_var(OAuthProviderId::OpenAiCodex),
686 "SYNAPS_ACCOUNT_OPENAI_CODEX"
687 );
688 assert_eq!(account_env_var(OAuthProviderId::Xai), "SYNAPS_ACCOUNT_XAI_AUTH");
689 assert_eq!(
690 account_config_key(OAuthProviderId::KimiCode),
691 "auth.account.kimi-code"
692 );
693 }
694
695 #[test]
696 fn policy_from_config_map_fails_closed_on_invalid() {
697 let mut map = BTreeMap::new();
698 map.insert("openai-codex".to_string(), "astra2".to_string());
699 map.insert("anthropic".to_string(), "auto".to_string());
700 map.insert("kimi-code".to_string(), "Bad Label".to_string());
701 map.insert("xai-auth".to_string(), "".to_string());
702 map.insert("groq".to_string(), "x".to_string());
703 let (policy, warnings) = AccountPolicy::from_config_map(&map);
704 assert_eq!(
705 policy.selector(OAuthProviderId::OpenAiCodex),
706 AccountSelector::Account(Account::named("astra2").unwrap())
707 );
708 assert_eq!(
709 policy.selector(OAuthProviderId::Anthropic),
710 AccountSelector::Auto
711 );
712 assert!(
713 matches!(
714 policy.selector(OAuthProviderId::KimiCode),
715 AccountSelector::Invalid { .. }
716 ),
717 "malformed label must fail closed, never default"
718 );
719 assert!(matches!(
720 policy.resolve(OAuthProviderId::KimiCode),
721 Err(BrokerError::InvalidAccount(_))
722 ));
723 assert_eq!(
724 policy.selector(OAuthProviderId::Xai),
725 AccountSelector::Account(Account::Default),
726 "empty value means unset"
727 );
728 assert_eq!(warnings.len(), 2, "{warnings:?}");
729 assert!(warnings.iter().any(|w| w.contains("kimi-code")));
730 assert!(warnings.iter().any(|w| w.contains("groq")));
731 let err = policy.resolve(OAuthProviderId::KimiCode).unwrap_err().to_string();
732 assert!(err.contains("auth.account.kimi-code"), "{err}");
733 }
734
735 #[test]
736 fn env_overlay_overrides_config_and_fails_closed_on_garbage() {
737 let _guard = env_lock();
739 let var = account_env_var(OAuthProviderId::GitHubCopilot);
740 let mut map = BTreeMap::new();
741 map.insert("github-copilot".to_string(), "cfg".to_string());
742 std::env::set_var(&var, "envlabel");
743 let (policy, _) = AccountPolicy::from_config_map(&map);
744 let policy = policy.with_env_overlay();
745 assert_eq!(
746 policy.resolve(OAuthProviderId::GitHubCopilot).unwrap().storage_key(),
747 "github-copilot@envlabel"
748 );
749 std::env::set_var(&var, "NOT VALID");
750 let (policy, _) = AccountPolicy::from_config_map(&map);
751 let policy = policy.with_env_overlay();
752 assert!(matches!(
753 policy.resolve(OAuthProviderId::GitHubCopilot),
754 Err(BrokerError::InvalidAccount(_))
755 ));
756 std::env::set_var(&var, "");
757 let (policy, _) = AccountPolicy::from_config_map(&map);
758 let policy = policy.with_env_overlay();
759 assert_eq!(
760 policy.resolve(OAuthProviderId::GitHubCopilot).unwrap().storage_key(),
761 "github-copilot@cfg",
762 "empty env value is unset"
763 );
764 std::env::remove_var(&var);
765 }
766
767 #[test]
768 fn policy_resolve_default_named_and_auto() {
769 let policy = AccountPolicy::new()
770 .with(
771 OAuthProviderId::OpenAiCodex,
772 AccountSelector::Account(Account::named("b").unwrap()),
773 )
774 .with(OAuthProviderId::Anthropic, AccountSelector::Auto);
775 assert_eq!(
776 policy.resolve(OAuthProviderId::OpenAiCodex).unwrap().storage_key(),
777 "openai-codex@b"
778 );
779 assert_eq!(
780 policy.resolve(OAuthProviderId::KimiCode).unwrap().storage_key(),
781 "kimi-code"
782 );
783 assert!(matches!(
784 policy.resolve(OAuthProviderId::Anthropic),
785 Err(BrokerError::NoAccountAvailable { .. })
786 ));
787 }
788
789 #[test]
790 fn credential_ref_serde_round_trip_and_validation() {
791 let cred = CredentialRef::new(OAuthProviderId::KimiCode, Account::named("m27").unwrap());
792 let json = serde_json::to_value(&cred).unwrap();
793 assert_eq!(json, serde_json::json!({"provider": "kimi-code", "account": "m27"}));
794 assert_eq!(serde_json::from_value::<CredentialRef>(json).unwrap(), cred);
795 assert!(serde_json::from_str::<CredentialRef>(
796 r#"{"provider":"groq","account":"default"}"#
797 )
798 .is_err());
799 assert!(serde_json::from_str::<CredentialRef>(
800 r#"{"provider":"kimi-code","account":"Bad"}"#
801 )
802 .is_err());
803 }
804
805 #[test]
806 fn account_serde_round_trip_and_validation() {
807 let json = serde_json::to_string(&Account::named("astra2").unwrap()).unwrap();
808 assert_eq!(json, "\"astra2\"");
809 assert_eq!(
810 serde_json::from_str::<Account>("\"default\"").unwrap(),
811 Account::Default
812 );
813 assert!(serde_json::from_str::<Account>("\"Bad\"").is_err());
814 }
815
816 #[test]
817 fn summary_has_no_secret_fields() {
818 let summary = AccountSummary {
819 identity: Some("user@example.com".into()),
820 expires: 1,
821 added_at: Some(2),
822 selected: true,
823 ..AccountSummary::new(OAuthProviderId::OpenAiCodex, &Account::named("astra2").unwrap())
824 .with_account_id(Some("2b2f0000-aaaa-bbbb"))
825 };
826 let json = serde_json::to_value(&summary).unwrap();
827 let keys: Vec<&str> = json.as_object().unwrap().keys().map(String::as_str).collect();
828 for forbidden in ["access", "refresh", "token", "key"] {
829 assert!(!keys.contains(&forbidden));
830 }
831 assert_eq!(json["account_id_prefix"], "2b2f0000");
832 assert_eq!(summary.credential_ref().unwrap().storage_key(), "openai-codex@astra2");
833 let fp = summary.seat_fingerprint.clone().unwrap();
835 assert_eq!(fp.len(), 64);
836 assert!(!fp.contains("2b2f0000"));
837 assert_eq!(
838 Some(fp),
839 seat_fingerprint(OAuthProviderId::OpenAiCodex, "2b2f0000-aaaa-bbbb")
840 );
841 let legacy: AccountSummary =
843 serde_json::from_str(r#"{"provider":"openai-codex","label":"x"}"#).unwrap();
844 assert_eq!(legacy.seat_fingerprint, None);
845 }
846
847 #[test]
848 fn seat_fingerprint_is_stable_per_provider_and_full_id() {
849 let a = seat_fingerprint(OAuthProviderId::OpenAiCodex, "acct_1").unwrap();
850 assert_eq!(seat_fingerprint(OAuthProviderId::OpenAiCodex, " acct_1 ").unwrap(), a);
851 assert_ne!(seat_fingerprint(OAuthProviderId::OpenAiCodex, "acct_10").unwrap(), a);
852 assert_ne!(seat_fingerprint(OAuthProviderId::Anthropic, "acct_1").unwrap(), a);
853 assert_eq!(seat_fingerprint(OAuthProviderId::OpenAiCodex, " "), None);
854 let alias = AccountSummary::new(OAuthProviderId::OpenAiCodex, &Account::Default)
856 .with_account_id(Some("acct_1"));
857 assert_eq!(alias.seat_fingerprint.as_deref(), Some(a.as_str()));
858 assert_eq!(alias.account_id_prefix.as_deref(), Some("acct_1"));
859 }
860
861 #[test]
862 fn seat_identity_prefers_account_id_and_never_prints_material() {
863 let creds = |refresh: &str, id: Option<&str>| super::super::OAuthCredentials {
864 auth_type: "oauth".into(),
865 refresh: refresh.into(),
866 access: "access-SECRET".into(),
867 expires: 0,
868 account_id: id.map(str::to_string),
869 };
870 let p = OAuthProviderId::OpenAiCodex;
871 let a1 = SeatIdentity::of(p, &creds("r1-SECRET", Some("acct_1")));
873 let a2 = SeatIdentity::of(p, &creds("r2-SECRET", Some("acct_1")));
874 assert_eq!(a1, a2);
875 assert_eq!(a1.seat_fingerprint(), seat_fingerprint(p, "acct_1").as_deref());
876 assert_ne!(a1, SeatIdentity::of(p, &creds("r1-SECRET", Some("acct_2"))));
878 let b1 = SeatIdentity::of(p, &creds("r1-SECRET", None));
880 let b2 = SeatIdentity::of(p, &creds("r2-SECRET", None));
881 assert_ne!(b1, b2);
882 assert_eq!(b1, SeatIdentity::of(p, &creds("r1-SECRET", None)));
883 assert_eq!(b1.seat_fingerprint(), None);
884 for id in [&a1, &b1] {
885 let shown = format!("{id:?}{}", id.as_str());
886 assert!(!shown.contains("SECRET") && !shown.contains("acct_1"), "{shown}");
887 }
888 }
889}