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
547pub(crate) fn account_id_prefix(account_id: &str) -> Option<String> {
549 let trimmed = account_id.trim();
550 if trimmed.is_empty() {
551 return None;
552 }
553 Some(trimmed.chars().take(8).collect())
554}
555
556#[cfg(test)]
559pub(crate) fn env_lock() -> std::sync::MutexGuard<'static, ()> {
560 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
561 ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567
568 #[test]
569 fn label_grammar() {
570 for ok in ["a", "astra2", "m27", "work.eu", "x_y-z", "0", &"a".repeat(32)] {
571 assert!(AccountLabel::parse(ok).is_ok(), "{ok} must be valid");
572 }
573 for bad in [
574 "",
575 " ",
576 "default",
577 "auto",
578 "Astra",
579 "-lead",
580 ".dot",
581 "with space",
582 "a@b",
583 "a/b",
584 "../x",
585 "émoji",
586 &"a".repeat(33),
587 ] {
588 assert!(AccountLabel::parse(bad).is_err(), "{bad:?} must be rejected");
589 }
590 }
591
592 #[test]
593 fn account_parse_default_and_named() {
594 assert!(Account::parse("").is_err(), "explicit empty account must error");
595 assert!(Account::parse(" ").is_err());
596 assert_eq!(Account::parse("default").unwrap(), Account::Default);
597 assert_eq!(Account::parse(" default ").unwrap(), Account::Default);
598 assert_eq!(Account::parse("astra2").unwrap().label_str(), "astra2");
599 assert!(Account::parse("Bad").is_err());
600 assert!(Account::parse("auto").is_err(), "auto is a selector, not an account");
601 }
602
603 #[test]
604 fn storage_key_round_trip() {
605 let default = CredentialRef::default_for(OAuthProviderId::OpenAiCodex);
606 assert_eq!(default.storage_key(), "openai-codex");
607 let named = CredentialRef::new(
608 OAuthProviderId::OpenAiCodex,
609 Account::named("astra2").unwrap(),
610 );
611 assert_eq!(named.storage_key(), "openai-codex@astra2");
612 assert_eq!(
613 CredentialRef::parse_storage_key("openai-codex").unwrap(),
614 default
615 );
616 assert_eq!(
617 CredentialRef::parse_storage_key("openai-codex@astra2").unwrap(),
618 named
619 );
620 assert_eq!(
621 CredentialRef::parse_storage_key("anthropic@m27").unwrap().provider,
622 OAuthProviderId::Anthropic
623 );
624 }
625
626 #[test]
627 fn parse_storage_key_rejects_non_oauth_and_malformed() {
628 for key in [
629 "groq",
630 "aws-bedrock",
631 "local",
632 "unknown@x",
633 "openai-codex@",
634 "openai-codex@default",
635 "openai-codex@auto",
636 "openai-codex@Bad",
637 "openai-codex@a@b",
638 "@astra2",
639 "",
640 ] {
641 assert!(
642 CredentialRef::parse_storage_key(key).is_none(),
643 "{key:?} must not parse"
644 );
645 }
646 }
647
648 #[test]
649 fn key_belongs_to_provider_prefix_is_exact() {
650 assert!(CredentialRef::key_belongs_to("kimi-code", OAuthProviderId::KimiCode));
651 assert!(CredentialRef::key_belongs_to("kimi-code@BAD", OAuthProviderId::KimiCode));
652 assert!(!CredentialRef::key_belongs_to("kimi", OAuthProviderId::KimiCode));
653 assert!(!CredentialRef::key_belongs_to("kimi-code2", OAuthProviderId::KimiCode));
654 }
655
656 #[test]
657 fn env_and_config_key_names() {
658 assert_eq!(
659 account_env_var(OAuthProviderId::OpenAiCodex),
660 "SYNAPS_ACCOUNT_OPENAI_CODEX"
661 );
662 assert_eq!(account_env_var(OAuthProviderId::Xai), "SYNAPS_ACCOUNT_XAI_AUTH");
663 assert_eq!(
664 account_config_key(OAuthProviderId::KimiCode),
665 "auth.account.kimi-code"
666 );
667 }
668
669 #[test]
670 fn policy_from_config_map_fails_closed_on_invalid() {
671 let mut map = BTreeMap::new();
672 map.insert("openai-codex".to_string(), "astra2".to_string());
673 map.insert("anthropic".to_string(), "auto".to_string());
674 map.insert("kimi-code".to_string(), "Bad Label".to_string());
675 map.insert("xai-auth".to_string(), "".to_string());
676 map.insert("groq".to_string(), "x".to_string());
677 let (policy, warnings) = AccountPolicy::from_config_map(&map);
678 assert_eq!(
679 policy.selector(OAuthProviderId::OpenAiCodex),
680 AccountSelector::Account(Account::named("astra2").unwrap())
681 );
682 assert_eq!(
683 policy.selector(OAuthProviderId::Anthropic),
684 AccountSelector::Auto
685 );
686 assert!(
687 matches!(
688 policy.selector(OAuthProviderId::KimiCode),
689 AccountSelector::Invalid { .. }
690 ),
691 "malformed label must fail closed, never default"
692 );
693 assert!(matches!(
694 policy.resolve(OAuthProviderId::KimiCode),
695 Err(BrokerError::InvalidAccount(_))
696 ));
697 assert_eq!(
698 policy.selector(OAuthProviderId::Xai),
699 AccountSelector::Account(Account::Default),
700 "empty value means unset"
701 );
702 assert_eq!(warnings.len(), 2, "{warnings:?}");
703 assert!(warnings.iter().any(|w| w.contains("kimi-code")));
704 assert!(warnings.iter().any(|w| w.contains("groq")));
705 let err = policy.resolve(OAuthProviderId::KimiCode).unwrap_err().to_string();
706 assert!(err.contains("auth.account.kimi-code"), "{err}");
707 }
708
709 #[test]
710 fn env_overlay_overrides_config_and_fails_closed_on_garbage() {
711 let _guard = env_lock();
713 let var = account_env_var(OAuthProviderId::GitHubCopilot);
714 let mut map = BTreeMap::new();
715 map.insert("github-copilot".to_string(), "cfg".to_string());
716 std::env::set_var(&var, "envlabel");
717 let (policy, _) = AccountPolicy::from_config_map(&map);
718 let policy = policy.with_env_overlay();
719 assert_eq!(
720 policy.resolve(OAuthProviderId::GitHubCopilot).unwrap().storage_key(),
721 "github-copilot@envlabel"
722 );
723 std::env::set_var(&var, "NOT VALID");
724 let (policy, _) = AccountPolicy::from_config_map(&map);
725 let policy = policy.with_env_overlay();
726 assert!(matches!(
727 policy.resolve(OAuthProviderId::GitHubCopilot),
728 Err(BrokerError::InvalidAccount(_))
729 ));
730 std::env::set_var(&var, "");
731 let (policy, _) = AccountPolicy::from_config_map(&map);
732 let policy = policy.with_env_overlay();
733 assert_eq!(
734 policy.resolve(OAuthProviderId::GitHubCopilot).unwrap().storage_key(),
735 "github-copilot@cfg",
736 "empty env value is unset"
737 );
738 std::env::remove_var(&var);
739 }
740
741 #[test]
742 fn policy_resolve_default_named_and_auto() {
743 let policy = AccountPolicy::new()
744 .with(
745 OAuthProviderId::OpenAiCodex,
746 AccountSelector::Account(Account::named("b").unwrap()),
747 )
748 .with(OAuthProviderId::Anthropic, AccountSelector::Auto);
749 assert_eq!(
750 policy.resolve(OAuthProviderId::OpenAiCodex).unwrap().storage_key(),
751 "openai-codex@b"
752 );
753 assert_eq!(
754 policy.resolve(OAuthProviderId::KimiCode).unwrap().storage_key(),
755 "kimi-code"
756 );
757 assert!(matches!(
758 policy.resolve(OAuthProviderId::Anthropic),
759 Err(BrokerError::NoAccountAvailable { .. })
760 ));
761 }
762
763 #[test]
764 fn credential_ref_serde_round_trip_and_validation() {
765 let cred = CredentialRef::new(OAuthProviderId::KimiCode, Account::named("m27").unwrap());
766 let json = serde_json::to_value(&cred).unwrap();
767 assert_eq!(json, serde_json::json!({"provider": "kimi-code", "account": "m27"}));
768 assert_eq!(serde_json::from_value::<CredentialRef>(json).unwrap(), cred);
769 assert!(serde_json::from_str::<CredentialRef>(
770 r#"{"provider":"groq","account":"default"}"#
771 )
772 .is_err());
773 assert!(serde_json::from_str::<CredentialRef>(
774 r#"{"provider":"kimi-code","account":"Bad"}"#
775 )
776 .is_err());
777 }
778
779 #[test]
780 fn account_serde_round_trip_and_validation() {
781 let json = serde_json::to_string(&Account::named("astra2").unwrap()).unwrap();
782 assert_eq!(json, "\"astra2\"");
783 assert_eq!(
784 serde_json::from_str::<Account>("\"default\"").unwrap(),
785 Account::Default
786 );
787 assert!(serde_json::from_str::<Account>("\"Bad\"").is_err());
788 }
789
790 #[test]
791 fn summary_has_no_secret_fields() {
792 let summary = AccountSummary {
793 identity: Some("user@example.com".into()),
794 expires: 1,
795 added_at: Some(2),
796 selected: true,
797 ..AccountSummary::new(OAuthProviderId::OpenAiCodex, &Account::named("astra2").unwrap())
798 .with_account_id(Some("2b2f0000-aaaa-bbbb"))
799 };
800 let json = serde_json::to_value(&summary).unwrap();
801 let keys: Vec<&str> = json.as_object().unwrap().keys().map(String::as_str).collect();
802 for forbidden in ["access", "refresh", "token", "key"] {
803 assert!(!keys.contains(&forbidden));
804 }
805 assert_eq!(json["account_id_prefix"], "2b2f0000");
806 assert_eq!(summary.credential_ref().unwrap().storage_key(), "openai-codex@astra2");
807 let fp = summary.seat_fingerprint.clone().unwrap();
809 assert_eq!(fp.len(), 64);
810 assert!(!fp.contains("2b2f0000"));
811 assert_eq!(
812 Some(fp),
813 seat_fingerprint(OAuthProviderId::OpenAiCodex, "2b2f0000-aaaa-bbbb")
814 );
815 let legacy: AccountSummary =
817 serde_json::from_str(r#"{"provider":"openai-codex","label":"x"}"#).unwrap();
818 assert_eq!(legacy.seat_fingerprint, None);
819 }
820
821 #[test]
822 fn seat_fingerprint_is_stable_per_provider_and_full_id() {
823 let a = seat_fingerprint(OAuthProviderId::OpenAiCodex, "acct_1").unwrap();
824 assert_eq!(seat_fingerprint(OAuthProviderId::OpenAiCodex, " acct_1 ").unwrap(), a);
825 assert_ne!(seat_fingerprint(OAuthProviderId::OpenAiCodex, "acct_10").unwrap(), a);
826 assert_ne!(seat_fingerprint(OAuthProviderId::Anthropic, "acct_1").unwrap(), a);
827 assert_eq!(seat_fingerprint(OAuthProviderId::OpenAiCodex, " "), None);
828 let alias = AccountSummary::new(OAuthProviderId::OpenAiCodex, &Account::Default)
830 .with_account_id(Some("acct_1"));
831 assert_eq!(alias.seat_fingerprint.as_deref(), Some(a.as_str()));
832 assert_eq!(alias.account_id_prefix.as_deref(), Some("acct_1"));
833 }
834
835 #[test]
836 fn seat_identity_prefers_account_id_and_never_prints_material() {
837 let creds = |refresh: &str, id: Option<&str>| super::super::OAuthCredentials {
838 auth_type: "oauth".into(),
839 refresh: refresh.into(),
840 access: "access-SECRET".into(),
841 expires: 0,
842 account_id: id.map(str::to_string),
843 };
844 let p = OAuthProviderId::OpenAiCodex;
845 let a1 = SeatIdentity::of(p, &creds("r1-SECRET", Some("acct_1")));
847 let a2 = SeatIdentity::of(p, &creds("r2-SECRET", Some("acct_1")));
848 assert_eq!(a1, a2);
849 assert_eq!(a1.seat_fingerprint(), seat_fingerprint(p, "acct_1").as_deref());
850 assert_ne!(a1, SeatIdentity::of(p, &creds("r1-SECRET", Some("acct_2"))));
852 let b1 = SeatIdentity::of(p, &creds("r1-SECRET", None));
854 let b2 = SeatIdentity::of(p, &creds("r2-SECRET", None));
855 assert_ne!(b1, b2);
856 assert_eq!(b1, SeatIdentity::of(p, &creds("r1-SECRET", None)));
857 assert_eq!(b1.seat_fingerprint(), None);
858 for id in [&a1, &b1] {
859 let shown = format!("{id:?}{}", id.as_str());
860 assert!(!shown.contains("SECRET") && !shown.contains("acct_1"), "{shown}");
861 }
862 }
863}