1mod types;
21mod vault;
22
23use std::path::PathBuf;
24
25pub use types::{
26 AccessContext, AccessPolicy, BrowserStore, Cookie, CredentialValue, Secret, SecretEntry,
27 SecretKind, WebStorage,
28};
29
30pub struct SecretsManager {
32 pub(crate) vault_path: PathBuf,
34 pub(crate) key_path: PathBuf,
36 pub(crate) password: Option<String>,
38 pub(crate) vault: Option<securestore::SecretsManager>,
40 pub(crate) agent_access_enabled: bool,
42}
43
44impl SecretsManager {
45 pub fn new(credentials_dir: impl Into<PathBuf>) -> Self {
50 let dir: PathBuf = credentials_dir.into();
51 Self {
52 vault_path: dir.join("secrets.json"),
53 key_path: dir.join("secrets.key"),
54 password: None,
55 vault: None,
56 agent_access_enabled: false,
57 }
58 }
59
60 pub fn with_password(credentials_dir: impl Into<PathBuf>, password: String) -> Self {
63 let dir: PathBuf = credentials_dir.into();
64 Self {
65 vault_path: dir.join("secrets.json"),
66 key_path: dir.join("secrets.key"),
67 password: Some(password),
68 vault: None,
69 agent_access_enabled: false,
70 }
71 }
72
73 pub fn set_password(&mut self, password: String) {
79 self.password = Some(password);
80 self.vault = None;
83 }
84
85 pub fn clear_password(&mut self) {
88 self.password = None;
89 self.vault = None;
90 }
91
92 pub fn locked(credentials_dir: impl Into<PathBuf>) -> Self {
98 let dir: PathBuf = credentials_dir.into();
99 Self {
100 vault_path: dir.join("secrets.json"),
101 key_path: dir.join("secrets.key"),
102 password: None,
103 vault: None,
104 agent_access_enabled: false,
105 }
106 }
107
108 pub fn is_locked(&self) -> bool {
115 self.vault.is_none()
116 && self.password.is_none()
117 && !self.key_path.exists()
118 && self.vault_path.exists()
119 }
120
121 pub fn password(&self) -> Option<&str> {
126 self.password.as_deref()
127 }
128
129 pub fn set_agent_access(&mut self, enabled: bool) {
133 self.agent_access_enabled = enabled;
134 }
135
136 pub fn has_agent_access(&self) -> bool {
138 self.agent_access_enabled
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145 use std::path::Path;
146 use std::sync::atomic::{AtomicU32, Ordering};
147 use totp_rs::{Algorithm, Secret as TotpSecret, TOTP};
148
149 static TEST_COUNTER: AtomicU32 = AtomicU32::new(0);
150
151 fn temp_dir() -> PathBuf {
152 let id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
153 let dir =
154 std::env::temp_dir().join(format!("rustyclaw_test_{}_{}", std::process::id(), id));
155 let _ = std::fs::remove_dir_all(&dir);
156 std::fs::create_dir_all(&dir).unwrap();
157 dir
158 }
159
160 #[test]
161 fn test_secrets_manager_creation() {
162 let dir = temp_dir();
163 let manager = SecretsManager::new(&dir);
164 assert!(!manager.has_agent_access());
165
166 assert!(!dir.join("secrets.json").exists());
168 let _ = std::fs::remove_dir_all(&dir);
169 }
170
171 #[test]
172 fn test_agent_access_control() {
173 let dir = temp_dir();
174 let mut manager = SecretsManager::new(&dir);
175 assert!(!manager.has_agent_access());
176
177 manager.set_agent_access(true);
178 assert!(manager.has_agent_access());
179
180 manager.set_agent_access(false);
181 assert!(!manager.has_agent_access());
182 let _ = std::fs::remove_dir_all(&dir);
183 }
184
185 #[test]
186 fn test_store_and_retrieve() {
187 let dir = temp_dir();
188 let mut manager = SecretsManager::new(&dir);
189 manager.set_agent_access(true);
190
191 manager.store_secret("api_key", "hunter2").unwrap();
192 assert!(Path::new(&dir.join("secrets.json")).exists());
193 assert!(Path::new(&dir.join("secrets.key")).exists());
194
195 let val = manager.get_secret("api_key", false).unwrap();
196 assert_eq!(val, Some("hunter2".to_string()));
197
198 let missing = manager.get_secret("nope", true).unwrap();
200 assert_eq!(missing, None);
201 let _ = std::fs::remove_dir_all(&dir);
202 }
203
204 #[test]
205 fn test_list_and_delete() {
206 let dir = temp_dir();
207 let mut manager = SecretsManager::new(&dir);
208 manager.set_agent_access(true);
209
210 manager.store_secret("a", "1").unwrap();
211 manager.store_secret("b", "2").unwrap();
212
213 let mut keys = manager.list_secrets();
214 keys.sort();
215 assert_eq!(keys, vec!["a".to_string(), "b".to_string()]);
216
217 manager.delete_secret("a").unwrap();
218 let keys = manager.list_secrets();
219 assert_eq!(keys, vec!["b".to_string()]);
220 let _ = std::fs::remove_dir_all(&dir);
221 }
222
223 #[test]
224 fn test_access_denied_without_approval() {
225 let dir = temp_dir();
226 let mut manager = SecretsManager::new(&dir);
227 manager.store_secret("secret", "value").unwrap();
228
229 let val = manager.get_secret("secret", false).unwrap();
231 assert_eq!(val, None);
232
233 let val = manager.get_secret("secret", true).unwrap();
235 assert_eq!(val, Some("value".to_string()));
236 let _ = std::fs::remove_dir_all(&dir);
237 }
238
239 #[test]
240 fn test_reload_from_disk() {
241 let dir = temp_dir();
242
243 {
245 let mut m = SecretsManager::new(&dir);
246 m.store_secret("persist", "yes").unwrap();
247 }
248
249 {
251 let mut m = SecretsManager::new(&dir);
252 m.set_agent_access(true);
253 let val = m.get_secret("persist", false).unwrap();
254 assert_eq!(val, Some("yes".to_string()));
255 }
256 let _ = std::fs::remove_dir_all(&dir);
257 }
258
259 #[test]
260 fn test_password_based_vault() {
261 let dir = temp_dir();
262
263 {
265 let mut m = SecretsManager::with_password(&dir, "s3cret".to_string());
266 m.store_secret("token", "abc123").unwrap();
267 }
268
269 assert!(dir.join("secrets.json").exists());
271 assert!(!dir.join("secrets.key").exists());
272
273 {
275 let mut m = SecretsManager::with_password(&dir, "s3cret".to_string());
276 m.set_agent_access(true);
277 let val = m.get_secret("token", false).unwrap();
278 assert_eq!(val, Some("abc123".to_string()));
279 }
280
281 {
283 let mut m = SecretsManager::with_password(&dir, "wrong".to_string());
284 assert!(m.get_secret("token", true).is_err());
285 }
286
287 let _ = std::fs::remove_dir_all(&dir);
288 }
289
290 #[test]
291 fn test_change_password() {
292 let dir = temp_dir();
293
294 {
296 let mut m = SecretsManager::new(&dir);
297 m.store_secret("api_key", "sk-abc").unwrap();
298 m.store_secret("token", "tok-xyz").unwrap();
299 }
300 assert!(dir.join("secrets.json").exists());
301 assert!(dir.join("secrets.key").exists());
302
303 {
305 let mut m = SecretsManager::new(&dir);
306 m.change_password("newpass".to_string()).unwrap();
307 }
308
309 assert!(!dir.join("secrets.key").exists());
311
312 {
314 let mut m = SecretsManager::with_password(&dir, "newpass".to_string());
315 m.set_agent_access(true);
316 assert_eq!(
317 m.get_secret("api_key", false).unwrap(),
318 Some("sk-abc".to_string())
319 );
320 assert_eq!(
321 m.get_secret("token", false).unwrap(),
322 Some("tok-xyz".to_string())
323 );
324 }
325
326 {
329 let mut m = SecretsManager::with_password(&dir, "wrong".to_string());
330 assert!(m.get_secret("api_key", true).is_err());
331 }
332
333 let _ = std::fs::remove_dir_all(&dir);
334 }
335
336 #[test]
337 fn test_change_password_between_passwords() {
338 let dir = temp_dir();
339
340 {
342 let mut m = SecretsManager::with_password(&dir, "old_pw".to_string());
343 m.store_secret("secret", "value123").unwrap();
344 }
345
346 {
348 let mut m = SecretsManager::with_password(&dir, "old_pw".to_string());
349 m.change_password("new_pw".to_string()).unwrap();
350 }
351
352 {
354 let mut m = SecretsManager::with_password(&dir, "new_pw".to_string());
355 m.set_agent_access(true);
356 assert_eq!(
357 m.get_secret("secret", false).unwrap(),
358 Some("value123".to_string())
359 );
360 }
361
362 {
364 let mut m = SecretsManager::with_password(&dir, "old_pw".to_string());
365 assert!(m.get_secret("secret", true).is_err());
366 }
367
368 let _ = std::fs::remove_dir_all(&dir);
369 }
370
371 #[test]
372 fn test_totp_setup_and_verify() {
373 let dir = temp_dir();
374 let mut manager = SecretsManager::new(&dir);
375 manager.set_agent_access(true);
376
377 assert!(!manager.has_totp());
379
380 let url = manager.setup_totp("testuser").unwrap();
382 assert!(url.starts_with("otpauth://totp/"));
383 assert!(url.contains("RustyClaw"));
384 assert!(manager.has_totp());
385
386 let encoded = manager
388 .get_secret(SecretsManager::TOTP_SECRET_KEY, true)
389 .unwrap()
390 .unwrap();
391 let secret = TotpSecret::Encoded(encoded);
392 let secret_bytes = secret.to_bytes().unwrap();
393 let totp = TOTP::new(
394 Algorithm::SHA1,
395 6,
396 1,
397 30,
398 secret_bytes,
399 Some("RustyClaw".to_string()),
400 "testuser".to_string(),
401 )
402 .unwrap();
403 let now = std::time::SystemTime::now()
404 .duration_since(std::time::UNIX_EPOCH)
405 .unwrap()
406 .as_secs();
407 let code = totp.generate(now);
408
409 assert!(manager.verify_totp(&code).unwrap());
410
411 assert!(!manager.verify_totp("000000").unwrap());
413
414 manager.remove_totp().unwrap();
416 assert!(!manager.has_totp());
417
418 let _ = std::fs::remove_dir_all(&dir);
419 }
420
421 #[test]
424 fn test_store_and_retrieve_api_key() {
425 let dir = temp_dir();
426 let mut m = SecretsManager::new(&dir);
427
428 let entry = SecretEntry {
429 label: "Anthropic".to_string(),
430 kind: SecretKind::ApiKey,
431 policy: AccessPolicy::WithApproval,
432 description: None,
433 disabled: false,
434 };
435 m.store_credential("anthropic_key", &entry, "sk-ant-12345", None)
436 .unwrap();
437
438 let ctx = AccessContext {
439 user_approved: true,
440 ..Default::default()
441 };
442 let (meta, val) = m.get_credential("anthropic_key", &ctx).unwrap().unwrap();
443 assert_eq!(meta.kind, SecretKind::ApiKey);
444 assert_eq!(meta.label, "Anthropic");
445 match val {
446 CredentialValue::Single(v) => assert_eq!(v, "sk-ant-12345"),
447 _ => panic!("Expected Single"),
448 }
449 let _ = std::fs::remove_dir_all(&dir);
450 }
451
452 #[test]
453 fn test_store_and_retrieve_username_password() {
454 let dir = temp_dir();
455 let mut m = SecretsManager::new(&dir);
456
457 let entry = SecretEntry {
458 label: "Registry".to_string(),
459 kind: SecretKind::UsernamePassword,
460 policy: AccessPolicy::Always,
461 description: None,
462 disabled: false,
463 };
464 m.store_credential("registry", &entry, "s3cret", Some("admin"))
465 .unwrap();
466
467 let ctx = AccessContext::default();
468 let (_, val) = m.get_credential("registry", &ctx).unwrap().unwrap();
469 match val {
470 CredentialValue::UserPass { username, password } => {
471 assert_eq!(username, "admin");
472 assert_eq!(password, "s3cret");
473 }
474 _ => panic!("Expected UserPass"),
475 }
476 let _ = std::fs::remove_dir_all(&dir);
477 }
478
479 #[test]
480 fn test_store_http_passkey() {
481 let dir = temp_dir();
482 let mut m = SecretsManager::new(&dir);
483
484 let entry = SecretEntry {
485 label: "WebAuthn passkey".to_string(),
486 kind: SecretKind::HttpPasskey,
487 policy: AccessPolicy::WithAuth,
488 description: Some("FIDO2 credential".to_string()),
489 disabled: false,
490 };
491 m.store_credential("passkey1", &entry, "cred-id-base64", None)
492 .unwrap();
493
494 let ctx = AccessContext {
496 user_approved: true,
497 ..Default::default()
498 };
499 assert!(m.get_credential("passkey1", &ctx).is_err());
500
501 let ctx = AccessContext {
503 authenticated: true,
504 ..Default::default()
505 };
506 let (meta, val) = m.get_credential("passkey1", &ctx).unwrap().unwrap();
507 assert_eq!(meta.kind, SecretKind::HttpPasskey);
508 match val {
509 CredentialValue::Single(v) => assert_eq!(v, "cred-id-base64"),
510 _ => panic!("Expected Single"),
511 }
512 let _ = std::fs::remove_dir_all(&dir);
513 }
514
515 #[test]
516 fn test_generate_ssh_key() {
517 let dir = temp_dir();
518 let mut m = SecretsManager::new(&dir);
519
520 let pubkey = m
521 .generate_ssh_key(
522 "rustyclaw_agent",
523 "rustyclaw@agent",
524 AccessPolicy::WithApproval,
525 )
526 .unwrap();
527
528 assert!(pubkey.starts_with("ssh-ed25519 "));
529 assert!(pubkey.contains("rustyclaw@agent"));
530
531 let ctx = AccessContext {
533 user_approved: true,
534 ..Default::default()
535 };
536 let (meta, val) = m.get_credential("rustyclaw_agent", &ctx).unwrap().unwrap();
537 assert_eq!(meta.kind, SecretKind::SshKey);
538 match val {
539 CredentialValue::SshKeyPair {
540 private_key,
541 public_key,
542 } => {
543 assert!(private_key.contains("BEGIN OPENSSH PRIVATE KEY"));
544 assert!(public_key.starts_with("ssh-ed25519 "));
545 }
546 _ => panic!("Expected SshKeyPair"),
547 }
548
549 m.delete_credential("rustyclaw_agent").unwrap();
551
552 let _ = std::fs::remove_dir_all(&dir);
553 }
554
555 #[test]
556 fn test_list_credentials() {
557 let dir = temp_dir();
558 let mut m = SecretsManager::new(&dir);
559
560 let e1 = SecretEntry {
561 label: "Key A".to_string(),
562 kind: SecretKind::ApiKey,
563 policy: AccessPolicy::Always,
564 description: None,
565 disabled: false,
566 };
567 let e2 = SecretEntry {
568 label: "Key B".to_string(),
569 kind: SecretKind::Token,
570 policy: AccessPolicy::WithApproval,
571 description: None,
572 disabled: false,
573 };
574 m.store_credential("a", &e1, "val_a", None).unwrap();
575 m.store_credential("b", &e2, "val_b", None).unwrap();
576
577 m.store_secret("legacy_key", "legacy_val").unwrap();
579
580 let creds = m.list_credentials();
581 let names: Vec<&str> = creds.iter().map(|(n, _)| n.as_str()).collect();
582 assert!(names.contains(&"a"));
583 assert!(names.contains(&"b"));
584 assert!(!names.contains(&"legacy_key"));
585 assert_eq!(creds.len(), 2);
586
587 let _ = std::fs::remove_dir_all(&dir);
588 }
589
590 #[test]
591 fn test_list_all_entries_includes_legacy_keys() {
592 let dir = temp_dir();
593 let mut m = SecretsManager::new(&dir);
594
595 let entry = SecretEntry {
597 label: "Typed".to_string(),
598 kind: SecretKind::ApiKey,
599 policy: AccessPolicy::Always,
600 description: None,
601 disabled: false,
602 };
603 m.store_credential("typed_one", &entry, "val", None)
604 .unwrap();
605
606 m.store_secret("ANTHROPIC_API_KEY", "sk-ant-xxx").unwrap();
608 m.store_secret("MY_CUSTOM_SECRET", "custom-val").unwrap();
609
610 m.store_secret("__init", "").unwrap();
612
613 let all = m.list_all_entries();
614 let names: Vec<&str> = all.iter().map(|(n, _)| n.as_str()).collect();
615
616 assert!(names.contains(&"typed_one"));
618 assert!(names.contains(&"ANTHROPIC_API_KEY"));
620 let anth = all.iter().find(|(n, _)| n == "ANTHROPIC_API_KEY").unwrap();
621 assert_eq!(anth.1.kind, SecretKind::ApiKey);
622 assert!(anth.1.label.contains("Anthropic"));
623
624 assert!(names.contains(&"MY_CUSTOM_SECRET"));
626 let custom = all.iter().find(|(n, _)| n == "MY_CUSTOM_SECRET").unwrap();
627 assert_eq!(custom.1.kind, SecretKind::Other);
628
629 assert!(!names.contains(&"__init"));
631
632 assert!(
634 !names
635 .iter()
636 .any(|n| n.starts_with("cred:") || n.starts_with("val:"))
637 );
638
639 let _ = std::fs::remove_dir_all(&dir);
640 }
641
642 #[test]
645 fn test_policy_always() {
646 let dir = temp_dir();
647 let mut m = SecretsManager::new(&dir);
648 let entry = SecretEntry {
649 label: "open".to_string(),
650 kind: SecretKind::Token,
651 policy: AccessPolicy::Always,
652 description: None,
653 disabled: false,
654 };
655 m.store_credential("open_tok", &entry, "val", None).unwrap();
656
657 let ctx = AccessContext::default();
659 assert!(m.get_credential("open_tok", &ctx).unwrap().is_some());
660
661 let _ = std::fs::remove_dir_all(&dir);
662 }
663
664 #[test]
665 fn test_policy_with_approval_denied() {
666 let dir = temp_dir();
667 let mut m = SecretsManager::new(&dir);
668 let entry = SecretEntry {
669 label: "guarded".to_string(),
670 kind: SecretKind::ApiKey,
671 policy: AccessPolicy::WithApproval,
672 description: None,
673 disabled: false,
674 };
675 m.store_credential("guarded", &entry, "val", None).unwrap();
676
677 let ctx = AccessContext::default();
679 assert!(m.get_credential("guarded", &ctx).is_err());
680
681 let ctx = AccessContext {
683 user_approved: true,
684 ..Default::default()
685 };
686 assert!(m.get_credential("guarded", &ctx).unwrap().is_some());
687
688 m.set_agent_access(true);
690 let ctx = AccessContext::default();
691 assert!(m.get_credential("guarded", &ctx).unwrap().is_some());
692
693 let _ = std::fs::remove_dir_all(&dir);
694 }
695
696 #[test]
697 fn test_policy_with_auth() {
698 let dir = temp_dir();
699 let mut m = SecretsManager::new(&dir);
700 let entry = SecretEntry {
701 label: "high-sec".to_string(),
702 kind: SecretKind::ApiKey,
703 policy: AccessPolicy::WithAuth,
704 description: None,
705 disabled: false,
706 };
707 m.store_credential("hs", &entry, "val", None).unwrap();
708
709 let ctx = AccessContext {
711 user_approved: true,
712 ..Default::default()
713 };
714 assert!(m.get_credential("hs", &ctx).is_err());
715
716 let ctx = AccessContext {
717 authenticated: true,
718 ..Default::default()
719 };
720 assert!(m.get_credential("hs", &ctx).unwrap().is_some());
721
722 let _ = std::fs::remove_dir_all(&dir);
723 }
724
725 #[test]
726 fn test_policy_skill_only() {
727 let dir = temp_dir();
728 let mut m = SecretsManager::new(&dir);
729 let entry = SecretEntry {
730 label: "deploy-key".to_string(),
731 kind: SecretKind::Token,
732 policy: AccessPolicy::SkillOnly(vec!["deploy".to_string(), "ci".to_string()]),
733 description: None,
734 disabled: false,
735 };
736 m.store_credential("dk", &entry, "val", None).unwrap();
737
738 let ctx = AccessContext {
740 user_approved: true,
741 ..Default::default()
742 };
743 assert!(m.get_credential("dk", &ctx).is_err());
744
745 let ctx = AccessContext {
747 active_skill: Some("build".to_string()),
748 ..Default::default()
749 };
750 assert!(m.get_credential("dk", &ctx).is_err());
751
752 let ctx = AccessContext {
754 active_skill: Some("deploy".to_string()),
755 ..Default::default()
756 };
757 assert!(m.get_credential("dk", &ctx).unwrap().is_some());
758
759 let _ = std::fs::remove_dir_all(&dir);
760 }
761
762 #[test]
763 fn test_delete_credential() {
764 let dir = temp_dir();
765 let mut m = SecretsManager::new(&dir);
766 let entry = SecretEntry {
767 label: "tmp".to_string(),
768 kind: SecretKind::Token,
769 policy: AccessPolicy::Always,
770 description: None,
771 disabled: false,
772 };
773 m.store_credential("tmp", &entry, "val", None).unwrap();
774 assert_eq!(m.list_credentials().len(), 1);
775
776 m.delete_credential("tmp").unwrap();
777 assert_eq!(m.list_credentials().len(), 0);
778
779 let ctx = AccessContext::default();
781 assert!(m.get_credential("tmp", &ctx).unwrap().is_none());
782
783 let _ = std::fs::remove_dir_all(&dir);
784 }
785
786 #[test]
789 fn test_store_and_retrieve_form_autofill() {
790 let dir = temp_dir();
791 let mut m = SecretsManager::new(&dir);
792
793 let entry = SecretEntry {
794 label: "Shipping address".to_string(),
795 kind: SecretKind::FormAutofill,
796 policy: AccessPolicy::WithApproval,
797 description: Some("https://example.com/checkout".to_string()),
798 disabled: false,
799 };
800 let mut fields = std::collections::BTreeMap::new();
801 fields.insert("name".to_string(), "Ada Lovelace".to_string());
802 fields.insert("email".to_string(), "ada@example.com".to_string());
803 fields.insert("phone".to_string(), "+1-555-0100".to_string());
804 fields.insert("address".to_string(), "1 Infinite Loop".to_string());
805
806 m.store_form_autofill("shipping", &entry, &fields).unwrap();
807
808 let ctx = AccessContext {
809 user_approved: true,
810 ..Default::default()
811 };
812 let (meta, val) = m.get_credential("shipping", &ctx).unwrap().unwrap();
813 assert_eq!(meta.kind, SecretKind::FormAutofill);
814 assert_eq!(meta.label, "Shipping address");
815 match val {
816 CredentialValue::FormFields(f) => {
817 assert_eq!(f.len(), 4);
818 assert_eq!(f["name"], "Ada Lovelace");
819 assert_eq!(f["email"], "ada@example.com");
820 }
821 _ => panic!("Expected FormFields"),
822 }
823 let _ = std::fs::remove_dir_all(&dir);
824 }
825
826 #[test]
827 fn test_store_and_retrieve_payment_method() {
828 let dir = temp_dir();
829 let mut m = SecretsManager::new(&dir);
830
831 let entry = SecretEntry {
832 label: "Visa ending 4242".to_string(),
833 kind: SecretKind::PaymentMethod,
834 policy: AccessPolicy::WithAuth,
835 description: None,
836 disabled: false,
837 };
838 let mut extra = std::collections::BTreeMap::new();
839 extra.insert("billing_zip".to_string(), "94025".to_string());
840
841 m.store_payment_method(
842 "visa_4242",
843 &entry,
844 "A. Lovelace",
845 "4242424242424242",
846 "12/28",
847 "123",
848 &extra,
849 )
850 .unwrap();
851
852 let ctx = AccessContext {
854 user_approved: true,
855 ..Default::default()
856 };
857 assert!(m.get_credential("visa_4242", &ctx).is_err());
858
859 let ctx = AccessContext {
860 authenticated: true,
861 ..Default::default()
862 };
863 let (meta, val) = m.get_credential("visa_4242", &ctx).unwrap().unwrap();
864 assert_eq!(meta.kind, SecretKind::PaymentMethod);
865 match val {
866 CredentialValue::PaymentCard {
867 cardholder,
868 number,
869 expiry,
870 cvv,
871 extra,
872 } => {
873 assert_eq!(cardholder, "A. Lovelace");
874 assert_eq!(number, "4242424242424242");
875 assert_eq!(expiry, "12/28");
876 assert_eq!(cvv, "123");
877 assert_eq!(extra["billing_zip"], "94025");
878 }
879 _ => panic!("Expected PaymentCard"),
880 }
881
882 m.delete_credential("visa_4242").unwrap();
884 assert_eq!(m.list_credentials().len(), 0);
885
886 let _ = std::fs::remove_dir_all(&dir);
887 }
888
889 #[test]
890 fn test_store_and_retrieve_secure_note() {
891 let dir = temp_dir();
892 let mut m = SecretsManager::new(&dir);
893
894 let entry = SecretEntry {
895 label: "Recovery codes".to_string(),
896 kind: SecretKind::SecureNote,
897 policy: AccessPolicy::WithAuth,
898 description: Some("GitHub 2FA backup codes".to_string()),
899 disabled: false,
900 };
901 let note = "abcde-12345\nfghij-67890\nklmno-13579";
902 m.store_credential("gh_recovery", &entry, note, None)
903 .unwrap();
904
905 let ctx = AccessContext {
906 authenticated: true,
907 ..Default::default()
908 };
909 let (meta, val) = m.get_credential("gh_recovery", &ctx).unwrap().unwrap();
910 assert_eq!(meta.kind, SecretKind::SecureNote);
911 assert_eq!(
912 meta.description,
913 Some("GitHub 2FA backup codes".to_string())
914 );
915 match val {
916 CredentialValue::Single(v) => assert_eq!(v, note),
917 _ => panic!("Expected Single"),
918 }
919 let _ = std::fs::remove_dir_all(&dir);
920 }
921
922 #[test]
923 fn test_form_autofill_delete_cleans_fields() {
924 let dir = temp_dir();
925 let mut m = SecretsManager::new(&dir);
926
927 let entry = SecretEntry {
928 label: "Login form".to_string(),
929 kind: SecretKind::FormAutofill,
930 policy: AccessPolicy::Always,
931 description: None,
932 disabled: false,
933 };
934 let mut fields = std::collections::BTreeMap::new();
935 fields.insert("user".to_string(), "alice".to_string());
936 m.store_form_autofill("login", &entry, &fields).unwrap();
937 assert_eq!(m.list_credentials().len(), 1);
938
939 m.delete_credential("login").unwrap();
940 assert_eq!(m.list_credentials().len(), 0);
941
942 m.set_agent_access(true);
944 let raw = m.get_secret("val:login:fields", false).unwrap();
945 assert_eq!(raw, None);
946
947 let _ = std::fs::remove_dir_all(&dir);
948 }
949
950 #[test]
951 fn test_disable_and_reenable_credential() {
952 let dir = temp_dir();
953 let mut m = SecretsManager::new(&dir);
954
955 let entry = SecretEntry {
956 label: "my key".to_string(),
957 kind: SecretKind::ApiKey,
958 policy: AccessPolicy::Always,
959 description: None,
960 disabled: false,
961 };
962 m.store_credential("k", &entry, "secret", None).unwrap();
963
964 let ctx = AccessContext::default();
966 assert!(m.get_credential("k", &ctx).unwrap().is_some());
967
968 m.set_credential_disabled("k", true).unwrap();
970 assert!(m.get_credential("k", &ctx).is_err());
971
972 let creds = m.list_credentials();
974 assert_eq!(creds.len(), 1);
975 assert!(creds[0].1.disabled);
976
977 m.set_credential_disabled("k", false).unwrap();
979 assert!(m.get_credential("k", &ctx).unwrap().is_some());
980
981 let _ = std::fs::remove_dir_all(&dir);
982 }
983
984 #[test]
985 fn test_disable_legacy_key_promotes_to_typed() {
986 let dir = temp_dir();
987 let mut m = SecretsManager::new(&dir);
988
989 m.store_secret("MY_BARE_KEY", "bare_val").unwrap();
991
992 m.set_credential_disabled("MY_BARE_KEY", true).unwrap();
994
995 let all = m.list_all_entries();
996 let bare = all.iter().find(|(n, _)| n == "MY_BARE_KEY").unwrap();
997 assert!(bare.1.disabled);
998
999 let _ = std::fs::remove_dir_all(&dir);
1000 }
1001
1002 #[test]
1003 fn test_set_credential_policy() {
1004 let dir = temp_dir();
1005 let mut m = SecretsManager::new(&dir);
1006
1007 let entry = SecretEntry {
1008 label: "my key".to_string(),
1009 kind: SecretKind::ApiKey,
1010 policy: AccessPolicy::WithApproval,
1011 description: None,
1012 disabled: false,
1013 };
1014 m.store_credential("k", &entry, "secret", None).unwrap();
1015
1016 let creds = m.list_credentials();
1018 assert_eq!(creds[0].1.policy, AccessPolicy::WithApproval);
1019
1020 m.set_credential_policy("k", AccessPolicy::Always).unwrap();
1022 let creds = m.list_credentials();
1023 assert_eq!(creds[0].1.policy, AccessPolicy::Always);
1024
1025 m.set_credential_policy("k", AccessPolicy::WithAuth)
1027 .unwrap();
1028 let creds = m.list_credentials();
1029 assert_eq!(creds[0].1.policy, AccessPolicy::WithAuth);
1030
1031 m.set_credential_policy("k", AccessPolicy::SkillOnly(vec!["web".to_string()]))
1033 .unwrap();
1034 let creds = m.list_credentials();
1035 assert_eq!(
1036 creds[0].1.policy,
1037 AccessPolicy::SkillOnly(vec!["web".to_string()])
1038 );
1039
1040 m.set_credential_policy("k", AccessPolicy::WithApproval)
1042 .unwrap();
1043 let creds = m.list_credentials();
1044 assert_eq!(creds[0].1.policy, AccessPolicy::WithApproval);
1045
1046 let _ = std::fs::remove_dir_all(&dir);
1047 }
1048
1049 #[test]
1050 fn test_set_policy_legacy_key_promotes_to_typed() {
1051 let dir = temp_dir();
1052 let mut m = SecretsManager::new(&dir);
1053
1054 m.store_secret("LEGACY_KEY", "legacy_val").unwrap();
1056
1057 m.set_credential_policy("LEGACY_KEY", AccessPolicy::Always)
1059 .unwrap();
1060
1061 let all = m.list_all_entries();
1062 let entry = all.iter().find(|(n, _)| n == "LEGACY_KEY").unwrap();
1063 assert_eq!(entry.1.policy, AccessPolicy::Always);
1064
1065 let _ = std::fs::remove_dir_all(&dir);
1066 }
1067}