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 = std::env::temp_dir().join(format!("rustyclaw_test_{}_{}", std::process::id(), id));
154 let _ = std::fs::remove_dir_all(&dir);
155 std::fs::create_dir_all(&dir).unwrap();
156 dir
157 }
158
159 #[test]
160 fn test_secrets_manager_creation() {
161 let dir = temp_dir();
162 let manager = SecretsManager::new(&dir);
163 assert!(!manager.has_agent_access());
164
165 assert!(!dir.join("secrets.json").exists());
167 let _ = std::fs::remove_dir_all(&dir);
168 }
169
170 #[test]
171 fn test_agent_access_control() {
172 let dir = temp_dir();
173 let mut manager = SecretsManager::new(&dir);
174 assert!(!manager.has_agent_access());
175
176 manager.set_agent_access(true);
177 assert!(manager.has_agent_access());
178
179 manager.set_agent_access(false);
180 assert!(!manager.has_agent_access());
181 let _ = std::fs::remove_dir_all(&dir);
182 }
183
184 #[test]
185 fn test_store_and_retrieve() {
186 let dir = temp_dir();
187 let mut manager = SecretsManager::new(&dir);
188 manager.set_agent_access(true);
189
190 manager.store_secret("api_key", "hunter2").unwrap();
191 assert!(Path::new(&dir.join("secrets.json")).exists());
192 assert!(Path::new(&dir.join("secrets.key")).exists());
193
194 let val = manager.get_secret("api_key", false).unwrap();
195 assert_eq!(val, Some("hunter2".to_string()));
196
197 let missing = manager.get_secret("nope", true).unwrap();
199 assert_eq!(missing, None);
200 let _ = std::fs::remove_dir_all(&dir);
201 }
202
203 #[test]
204 fn test_list_and_delete() {
205 let dir = temp_dir();
206 let mut manager = SecretsManager::new(&dir);
207 manager.set_agent_access(true);
208
209 manager.store_secret("a", "1").unwrap();
210 manager.store_secret("b", "2").unwrap();
211
212 let mut keys = manager.list_secrets();
213 keys.sort();
214 assert_eq!(keys, vec!["a".to_string(), "b".to_string()]);
215
216 manager.delete_secret("a").unwrap();
217 let keys = manager.list_secrets();
218 assert_eq!(keys, vec!["b".to_string()]);
219 let _ = std::fs::remove_dir_all(&dir);
220 }
221
222 #[test]
223 fn test_access_denied_without_approval() {
224 let dir = temp_dir();
225 let mut manager = SecretsManager::new(&dir);
226 manager.store_secret("secret", "value").unwrap();
227
228 let val = manager.get_secret("secret", false).unwrap();
230 assert_eq!(val, None);
231
232 let val = manager.get_secret("secret", true).unwrap();
234 assert_eq!(val, Some("value".to_string()));
235 let _ = std::fs::remove_dir_all(&dir);
236 }
237
238 #[test]
239 fn test_reload_from_disk() {
240 let dir = temp_dir();
241
242 {
244 let mut m = SecretsManager::new(&dir);
245 m.store_secret("persist", "yes").unwrap();
246 }
247
248 {
250 let mut m = SecretsManager::new(&dir);
251 m.set_agent_access(true);
252 let val = m.get_secret("persist", false).unwrap();
253 assert_eq!(val, Some("yes".to_string()));
254 }
255 let _ = std::fs::remove_dir_all(&dir);
256 }
257
258 #[test]
259 fn test_password_based_vault() {
260 let dir = temp_dir();
261
262 {
264 let mut m = SecretsManager::with_password(&dir, "s3cret".to_string());
265 m.store_secret("token", "abc123").unwrap();
266 }
267
268 assert!(dir.join("secrets.json").exists());
270 assert!(!dir.join("secrets.key").exists());
271
272 {
274 let mut m = SecretsManager::with_password(&dir, "s3cret".to_string());
275 m.set_agent_access(true);
276 let val = m.get_secret("token", false).unwrap();
277 assert_eq!(val, Some("abc123".to_string()));
278 }
279
280 {
282 let mut m = SecretsManager::with_password(&dir, "wrong".to_string());
283 assert!(m.get_secret("token", true).is_err());
284 }
285
286 let _ = std::fs::remove_dir_all(&dir);
287 }
288
289 #[test]
290 fn test_change_password() {
291 let dir = temp_dir();
292
293 {
295 let mut m = SecretsManager::new(&dir);
296 m.store_secret("api_key", "sk-abc").unwrap();
297 m.store_secret("token", "tok-xyz").unwrap();
298 }
299 assert!(dir.join("secrets.json").exists());
300 assert!(dir.join("secrets.key").exists());
301
302 {
304 let mut m = SecretsManager::new(&dir);
305 m.change_password("newpass".to_string()).unwrap();
306 }
307
308 assert!(!dir.join("secrets.key").exists());
310
311 {
313 let mut m = SecretsManager::with_password(&dir, "newpass".to_string());
314 m.set_agent_access(true);
315 assert_eq!(
316 m.get_secret("api_key", false).unwrap(),
317 Some("sk-abc".to_string())
318 );
319 assert_eq!(
320 m.get_secret("token", false).unwrap(),
321 Some("tok-xyz".to_string())
322 );
323 }
324
325 {
328 let mut m = SecretsManager::with_password(&dir, "wrong".to_string());
329 assert!(m.get_secret("api_key", true).is_err());
330 }
331
332 let _ = std::fs::remove_dir_all(&dir);
333 }
334
335 #[test]
336 fn test_change_password_between_passwords() {
337 let dir = temp_dir();
338
339 {
341 let mut m = SecretsManager::with_password(&dir, "old_pw".to_string());
342 m.store_secret("secret", "value123").unwrap();
343 }
344
345 {
347 let mut m = SecretsManager::with_password(&dir, "old_pw".to_string());
348 m.change_password("new_pw".to_string()).unwrap();
349 }
350
351 {
353 let mut m = SecretsManager::with_password(&dir, "new_pw".to_string());
354 m.set_agent_access(true);
355 assert_eq!(
356 m.get_secret("secret", false).unwrap(),
357 Some("value123".to_string())
358 );
359 }
360
361 {
363 let mut m = SecretsManager::with_password(&dir, "old_pw".to_string());
364 assert!(m.get_secret("secret", true).is_err());
365 }
366
367 let _ = std::fs::remove_dir_all(&dir);
368 }
369
370 #[test]
371 fn test_totp_setup_and_verify() {
372 let dir = temp_dir();
373 let mut manager = SecretsManager::new(&dir);
374 manager.set_agent_access(true);
375
376 assert!(!manager.has_totp());
378
379 let url = manager.setup_totp("testuser").unwrap();
381 assert!(url.starts_with("otpauth://totp/"));
382 assert!(url.contains("RustyClaw"));
383 assert!(manager.has_totp());
384
385 let encoded = manager
387 .get_secret(SecretsManager::TOTP_SECRET_KEY, true)
388 .unwrap()
389 .unwrap();
390 let secret = TotpSecret::Encoded(encoded);
391 let secret_bytes = secret.to_bytes().unwrap();
392 let totp = TOTP::new(
393 Algorithm::SHA1,
394 6,
395 1,
396 30,
397 secret_bytes,
398 Some("RustyClaw".to_string()),
399 "testuser".to_string(),
400 )
401 .unwrap();
402 let now = std::time::SystemTime::now()
403 .duration_since(std::time::UNIX_EPOCH)
404 .unwrap()
405 .as_secs();
406 let code = totp.generate(now);
407
408 assert!(manager.verify_totp(&code).unwrap());
409
410 assert!(!manager.verify_totp("000000").unwrap());
412
413 manager.remove_totp().unwrap();
415 assert!(!manager.has_totp());
416
417 let _ = std::fs::remove_dir_all(&dir);
418 }
419
420 #[test]
423 fn test_store_and_retrieve_api_key() {
424 let dir = temp_dir();
425 let mut m = SecretsManager::new(&dir);
426
427 let entry = SecretEntry {
428 label: "Anthropic".to_string(),
429 kind: SecretKind::ApiKey,
430 policy: AccessPolicy::WithApproval,
431 description: None,
432 disabled: false,
433 };
434 m.store_credential("anthropic_key", &entry, "sk-ant-12345", None)
435 .unwrap();
436
437 let ctx = AccessContext {
438 user_approved: true,
439 ..Default::default()
440 };
441 let (meta, val) = m.get_credential("anthropic_key", &ctx).unwrap().unwrap();
442 assert_eq!(meta.kind, SecretKind::ApiKey);
443 assert_eq!(meta.label, "Anthropic");
444 match val {
445 CredentialValue::Single(v) => assert_eq!(v, "sk-ant-12345"),
446 _ => panic!("Expected Single"),
447 }
448 let _ = std::fs::remove_dir_all(&dir);
449 }
450
451 #[test]
452 fn test_store_and_retrieve_username_password() {
453 let dir = temp_dir();
454 let mut m = SecretsManager::new(&dir);
455
456 let entry = SecretEntry {
457 label: "Registry".to_string(),
458 kind: SecretKind::UsernamePassword,
459 policy: AccessPolicy::Always,
460 description: None,
461 disabled: false,
462 };
463 m.store_credential("registry", &entry, "s3cret", Some("admin"))
464 .unwrap();
465
466 let ctx = AccessContext::default();
467 let (_, val) = m.get_credential("registry", &ctx).unwrap().unwrap();
468 match val {
469 CredentialValue::UserPass { username, password } => {
470 assert_eq!(username, "admin");
471 assert_eq!(password, "s3cret");
472 }
473 _ => panic!("Expected UserPass"),
474 }
475 let _ = std::fs::remove_dir_all(&dir);
476 }
477
478 #[test]
479 fn test_store_http_passkey() {
480 let dir = temp_dir();
481 let mut m = SecretsManager::new(&dir);
482
483 let entry = SecretEntry {
484 label: "WebAuthn passkey".to_string(),
485 kind: SecretKind::HttpPasskey,
486 policy: AccessPolicy::WithAuth,
487 description: Some("FIDO2 credential".to_string()),
488 disabled: false,
489 };
490 m.store_credential("passkey1", &entry, "cred-id-base64", None)
491 .unwrap();
492
493 let ctx = AccessContext {
495 user_approved: true,
496 ..Default::default()
497 };
498 assert!(m.get_credential("passkey1", &ctx).is_err());
499
500 let ctx = AccessContext {
502 authenticated: true,
503 ..Default::default()
504 };
505 let (meta, val) = m.get_credential("passkey1", &ctx).unwrap().unwrap();
506 assert_eq!(meta.kind, SecretKind::HttpPasskey);
507 match val {
508 CredentialValue::Single(v) => assert_eq!(v, "cred-id-base64"),
509 _ => panic!("Expected Single"),
510 }
511 let _ = std::fs::remove_dir_all(&dir);
512 }
513
514 #[test]
515 fn test_generate_ssh_key() {
516 let dir = temp_dir();
517 let mut m = SecretsManager::new(&dir);
518
519 let pubkey = m
520 .generate_ssh_key("rustyclaw_agent", "rustyclaw@agent", AccessPolicy::WithApproval)
521 .unwrap();
522
523 assert!(pubkey.starts_with("ssh-ed25519 "));
524 assert!(pubkey.contains("rustyclaw@agent"));
525
526 let ctx = AccessContext {
528 user_approved: true,
529 ..Default::default()
530 };
531 let (meta, val) = m.get_credential("rustyclaw_agent", &ctx).unwrap().unwrap();
532 assert_eq!(meta.kind, SecretKind::SshKey);
533 match val {
534 CredentialValue::SshKeyPair {
535 private_key,
536 public_key,
537 } => {
538 assert!(private_key.contains("BEGIN OPENSSH PRIVATE KEY"));
539 assert!(public_key.starts_with("ssh-ed25519 "));
540 }
541 _ => panic!("Expected SshKeyPair"),
542 }
543
544 m.delete_credential("rustyclaw_agent").unwrap();
546
547 let _ = std::fs::remove_dir_all(&dir);
548 }
549
550 #[test]
551 fn test_list_credentials() {
552 let dir = temp_dir();
553 let mut m = SecretsManager::new(&dir);
554
555 let e1 = SecretEntry {
556 label: "Key A".to_string(),
557 kind: SecretKind::ApiKey,
558 policy: AccessPolicy::Always,
559 description: None,
560 disabled: false,
561 };
562 let e2 = SecretEntry {
563 label: "Key B".to_string(),
564 kind: SecretKind::Token,
565 policy: AccessPolicy::WithApproval,
566 description: None,
567 disabled: false,
568 };
569 m.store_credential("a", &e1, "val_a", None).unwrap();
570 m.store_credential("b", &e2, "val_b", None).unwrap();
571
572 m.store_secret("legacy_key", "legacy_val").unwrap();
574
575 let creds = m.list_credentials();
576 let names: Vec<&str> = creds.iter().map(|(n, _)| n.as_str()).collect();
577 assert!(names.contains(&"a"));
578 assert!(names.contains(&"b"));
579 assert!(!names.contains(&"legacy_key"));
580 assert_eq!(creds.len(), 2);
581
582 let _ = std::fs::remove_dir_all(&dir);
583 }
584
585 #[test]
586 fn test_list_all_entries_includes_legacy_keys() {
587 let dir = temp_dir();
588 let mut m = SecretsManager::new(&dir);
589
590 let entry = SecretEntry {
592 label: "Typed".to_string(),
593 kind: SecretKind::ApiKey,
594 policy: AccessPolicy::Always,
595 description: None,
596 disabled: false,
597 };
598 m.store_credential("typed_one", &entry, "val", None).unwrap();
599
600 m.store_secret("ANTHROPIC_API_KEY", "sk-ant-xxx").unwrap();
602 m.store_secret("MY_CUSTOM_SECRET", "custom-val").unwrap();
603
604 m.store_secret("__init", "").unwrap();
606
607 let all = m.list_all_entries();
608 let names: Vec<&str> = all.iter().map(|(n, _)| n.as_str()).collect();
609
610 assert!(names.contains(&"typed_one"));
612 assert!(names.contains(&"ANTHROPIC_API_KEY"));
614 let anth = all.iter().find(|(n, _)| n == "ANTHROPIC_API_KEY").unwrap();
615 assert_eq!(anth.1.kind, SecretKind::ApiKey);
616 assert!(anth.1.label.contains("Anthropic"));
617
618 assert!(names.contains(&"MY_CUSTOM_SECRET"));
620 let custom = all.iter().find(|(n, _)| n == "MY_CUSTOM_SECRET").unwrap();
621 assert_eq!(custom.1.kind, SecretKind::Other);
622
623 assert!(!names.contains(&"__init"));
625
626 assert!(!names.iter().any(|n| n.starts_with("cred:") || n.starts_with("val:")));
628
629 let _ = std::fs::remove_dir_all(&dir);
630 }
631
632 #[test]
635 fn test_policy_always() {
636 let dir = temp_dir();
637 let mut m = SecretsManager::new(&dir);
638 let entry = SecretEntry {
639 label: "open".to_string(),
640 kind: SecretKind::Token,
641 policy: AccessPolicy::Always,
642 description: None,
643 disabled: false,
644 };
645 m.store_credential("open_tok", &entry, "val", None).unwrap();
646
647 let ctx = AccessContext::default();
649 assert!(m.get_credential("open_tok", &ctx).unwrap().is_some());
650
651 let _ = std::fs::remove_dir_all(&dir);
652 }
653
654 #[test]
655 fn test_policy_with_approval_denied() {
656 let dir = temp_dir();
657 let mut m = SecretsManager::new(&dir);
658 let entry = SecretEntry {
659 label: "guarded".to_string(),
660 kind: SecretKind::ApiKey,
661 policy: AccessPolicy::WithApproval,
662 description: None,
663 disabled: false,
664 };
665 m.store_credential("guarded", &entry, "val", None).unwrap();
666
667 let ctx = AccessContext::default();
669 assert!(m.get_credential("guarded", &ctx).is_err());
670
671 let ctx = AccessContext {
673 user_approved: true,
674 ..Default::default()
675 };
676 assert!(m.get_credential("guarded", &ctx).unwrap().is_some());
677
678 m.set_agent_access(true);
680 let ctx = AccessContext::default();
681 assert!(m.get_credential("guarded", &ctx).unwrap().is_some());
682
683 let _ = std::fs::remove_dir_all(&dir);
684 }
685
686 #[test]
687 fn test_policy_with_auth() {
688 let dir = temp_dir();
689 let mut m = SecretsManager::new(&dir);
690 let entry = SecretEntry {
691 label: "high-sec".to_string(),
692 kind: SecretKind::ApiKey,
693 policy: AccessPolicy::WithAuth,
694 description: None,
695 disabled: false,
696 };
697 m.store_credential("hs", &entry, "val", None).unwrap();
698
699 let ctx = AccessContext {
701 user_approved: true,
702 ..Default::default()
703 };
704 assert!(m.get_credential("hs", &ctx).is_err());
705
706 let ctx = AccessContext {
707 authenticated: true,
708 ..Default::default()
709 };
710 assert!(m.get_credential("hs", &ctx).unwrap().is_some());
711
712 let _ = std::fs::remove_dir_all(&dir);
713 }
714
715 #[test]
716 fn test_policy_skill_only() {
717 let dir = temp_dir();
718 let mut m = SecretsManager::new(&dir);
719 let entry = SecretEntry {
720 label: "deploy-key".to_string(),
721 kind: SecretKind::Token,
722 policy: AccessPolicy::SkillOnly(vec!["deploy".to_string(), "ci".to_string()]),
723 description: None,
724 disabled: false,
725 };
726 m.store_credential("dk", &entry, "val", None).unwrap();
727
728 let ctx = AccessContext {
730 user_approved: true,
731 ..Default::default()
732 };
733 assert!(m.get_credential("dk", &ctx).is_err());
734
735 let ctx = AccessContext {
737 active_skill: Some("build".to_string()),
738 ..Default::default()
739 };
740 assert!(m.get_credential("dk", &ctx).is_err());
741
742 let ctx = AccessContext {
744 active_skill: Some("deploy".to_string()),
745 ..Default::default()
746 };
747 assert!(m.get_credential("dk", &ctx).unwrap().is_some());
748
749 let _ = std::fs::remove_dir_all(&dir);
750 }
751
752 #[test]
753 fn test_delete_credential() {
754 let dir = temp_dir();
755 let mut m = SecretsManager::new(&dir);
756 let entry = SecretEntry {
757 label: "tmp".to_string(),
758 kind: SecretKind::Token,
759 policy: AccessPolicy::Always,
760 description: None,
761 disabled: false,
762 };
763 m.store_credential("tmp", &entry, "val", None).unwrap();
764 assert_eq!(m.list_credentials().len(), 1);
765
766 m.delete_credential("tmp").unwrap();
767 assert_eq!(m.list_credentials().len(), 0);
768
769 let ctx = AccessContext::default();
771 assert!(m.get_credential("tmp", &ctx).unwrap().is_none());
772
773 let _ = std::fs::remove_dir_all(&dir);
774 }
775
776 #[test]
779 fn test_store_and_retrieve_form_autofill() {
780 let dir = temp_dir();
781 let mut m = SecretsManager::new(&dir);
782
783 let entry = SecretEntry {
784 label: "Shipping address".to_string(),
785 kind: SecretKind::FormAutofill,
786 policy: AccessPolicy::WithApproval,
787 description: Some("https://example.com/checkout".to_string()),
788 disabled: false,
789 };
790 let mut fields = std::collections::BTreeMap::new();
791 fields.insert("name".to_string(), "Ada Lovelace".to_string());
792 fields.insert("email".to_string(), "ada@example.com".to_string());
793 fields.insert("phone".to_string(), "+1-555-0100".to_string());
794 fields.insert("address".to_string(), "1 Infinite Loop".to_string());
795
796 m.store_form_autofill("shipping", &entry, &fields).unwrap();
797
798 let ctx = AccessContext {
799 user_approved: true,
800 ..Default::default()
801 };
802 let (meta, val) = m.get_credential("shipping", &ctx).unwrap().unwrap();
803 assert_eq!(meta.kind, SecretKind::FormAutofill);
804 assert_eq!(meta.label, "Shipping address");
805 match val {
806 CredentialValue::FormFields(f) => {
807 assert_eq!(f.len(), 4);
808 assert_eq!(f["name"], "Ada Lovelace");
809 assert_eq!(f["email"], "ada@example.com");
810 }
811 _ => panic!("Expected FormFields"),
812 }
813 let _ = std::fs::remove_dir_all(&dir);
814 }
815
816 #[test]
817 fn test_store_and_retrieve_payment_method() {
818 let dir = temp_dir();
819 let mut m = SecretsManager::new(&dir);
820
821 let entry = SecretEntry {
822 label: "Visa ending 4242".to_string(),
823 kind: SecretKind::PaymentMethod,
824 policy: AccessPolicy::WithAuth,
825 description: None,
826 disabled: false,
827 };
828 let mut extra = std::collections::BTreeMap::new();
829 extra.insert("billing_zip".to_string(), "94025".to_string());
830
831 m.store_payment_method("visa_4242", &entry, "A. Lovelace", "4242424242424242", "12/28", "123", &extra)
832 .unwrap();
833
834 let ctx = AccessContext {
836 user_approved: true,
837 ..Default::default()
838 };
839 assert!(m.get_credential("visa_4242", &ctx).is_err());
840
841 let ctx = AccessContext {
842 authenticated: true,
843 ..Default::default()
844 };
845 let (meta, val) = m.get_credential("visa_4242", &ctx).unwrap().unwrap();
846 assert_eq!(meta.kind, SecretKind::PaymentMethod);
847 match val {
848 CredentialValue::PaymentCard {
849 cardholder,
850 number,
851 expiry,
852 cvv,
853 extra,
854 } => {
855 assert_eq!(cardholder, "A. Lovelace");
856 assert_eq!(number, "4242424242424242");
857 assert_eq!(expiry, "12/28");
858 assert_eq!(cvv, "123");
859 assert_eq!(extra["billing_zip"], "94025");
860 }
861 _ => panic!("Expected PaymentCard"),
862 }
863
864 m.delete_credential("visa_4242").unwrap();
866 assert_eq!(m.list_credentials().len(), 0);
867
868 let _ = std::fs::remove_dir_all(&dir);
869 }
870
871 #[test]
872 fn test_store_and_retrieve_secure_note() {
873 let dir = temp_dir();
874 let mut m = SecretsManager::new(&dir);
875
876 let entry = SecretEntry {
877 label: "Recovery codes".to_string(),
878 kind: SecretKind::SecureNote,
879 policy: AccessPolicy::WithAuth,
880 description: Some("GitHub 2FA backup codes".to_string()),
881 disabled: false,
882 };
883 let note = "abcde-12345\nfghij-67890\nklmno-13579";
884 m.store_credential("gh_recovery", &entry, note, None).unwrap();
885
886 let ctx = AccessContext {
887 authenticated: true,
888 ..Default::default()
889 };
890 let (meta, val) = m.get_credential("gh_recovery", &ctx).unwrap().unwrap();
891 assert_eq!(meta.kind, SecretKind::SecureNote);
892 assert_eq!(meta.description, Some("GitHub 2FA backup codes".to_string()));
893 match val {
894 CredentialValue::Single(v) => assert_eq!(v, note),
895 _ => panic!("Expected Single"),
896 }
897 let _ = std::fs::remove_dir_all(&dir);
898 }
899
900 #[test]
901 fn test_form_autofill_delete_cleans_fields() {
902 let dir = temp_dir();
903 let mut m = SecretsManager::new(&dir);
904
905 let entry = SecretEntry {
906 label: "Login form".to_string(),
907 kind: SecretKind::FormAutofill,
908 policy: AccessPolicy::Always,
909 description: None,
910 disabled: false,
911 };
912 let mut fields = std::collections::BTreeMap::new();
913 fields.insert("user".to_string(), "alice".to_string());
914 m.store_form_autofill("login", &entry, &fields).unwrap();
915 assert_eq!(m.list_credentials().len(), 1);
916
917 m.delete_credential("login").unwrap();
918 assert_eq!(m.list_credentials().len(), 0);
919
920 m.set_agent_access(true);
922 let raw = m.get_secret("val:login:fields", false).unwrap();
923 assert_eq!(raw, None);
924
925 let _ = std::fs::remove_dir_all(&dir);
926 }
927
928 #[test]
929 fn test_disable_and_reenable_credential() {
930 let dir = temp_dir();
931 let mut m = SecretsManager::new(&dir);
932
933 let entry = SecretEntry {
934 label: "my key".to_string(),
935 kind: SecretKind::ApiKey,
936 policy: AccessPolicy::Always,
937 description: None,
938 disabled: false,
939 };
940 m.store_credential("k", &entry, "secret", None).unwrap();
941
942 let ctx = AccessContext::default();
944 assert!(m.get_credential("k", &ctx).unwrap().is_some());
945
946 m.set_credential_disabled("k", true).unwrap();
948 assert!(m.get_credential("k", &ctx).is_err());
949
950 let creds = m.list_credentials();
952 assert_eq!(creds.len(), 1);
953 assert!(creds[0].1.disabled);
954
955 m.set_credential_disabled("k", false).unwrap();
957 assert!(m.get_credential("k", &ctx).unwrap().is_some());
958
959 let _ = std::fs::remove_dir_all(&dir);
960 }
961
962 #[test]
963 fn test_disable_legacy_key_promotes_to_typed() {
964 let dir = temp_dir();
965 let mut m = SecretsManager::new(&dir);
966
967 m.store_secret("MY_BARE_KEY", "bare_val").unwrap();
969
970 m.set_credential_disabled("MY_BARE_KEY", true).unwrap();
972
973 let all = m.list_all_entries();
974 let bare = all.iter().find(|(n, _)| n == "MY_BARE_KEY").unwrap();
975 assert!(bare.1.disabled);
976
977 let _ = std::fs::remove_dir_all(&dir);
978 }
979
980 #[test]
981 fn test_set_credential_policy() {
982 let dir = temp_dir();
983 let mut m = SecretsManager::new(&dir);
984
985 let entry = SecretEntry {
986 label: "my key".to_string(),
987 kind: SecretKind::ApiKey,
988 policy: AccessPolicy::WithApproval,
989 description: None,
990 disabled: false,
991 };
992 m.store_credential("k", &entry, "secret", None).unwrap();
993
994 let creds = m.list_credentials();
996 assert_eq!(creds[0].1.policy, AccessPolicy::WithApproval);
997
998 m.set_credential_policy("k", AccessPolicy::Always).unwrap();
1000 let creds = m.list_credentials();
1001 assert_eq!(creds[0].1.policy, AccessPolicy::Always);
1002
1003 m.set_credential_policy("k", AccessPolicy::WithAuth).unwrap();
1005 let creds = m.list_credentials();
1006 assert_eq!(creds[0].1.policy, AccessPolicy::WithAuth);
1007
1008 m.set_credential_policy("k", AccessPolicy::SkillOnly(vec!["web".to_string()]))
1010 .unwrap();
1011 let creds = m.list_credentials();
1012 assert_eq!(
1013 creds[0].1.policy,
1014 AccessPolicy::SkillOnly(vec!["web".to_string()])
1015 );
1016
1017 m.set_credential_policy("k", AccessPolicy::WithApproval).unwrap();
1019 let creds = m.list_credentials();
1020 assert_eq!(creds[0].1.policy, AccessPolicy::WithApproval);
1021
1022 let _ = std::fs::remove_dir_all(&dir);
1023 }
1024
1025 #[test]
1026 fn test_set_policy_legacy_key_promotes_to_typed() {
1027 let dir = temp_dir();
1028 let mut m = SecretsManager::new(&dir);
1029
1030 m.store_secret("LEGACY_KEY", "legacy_val").unwrap();
1032
1033 m.set_credential_policy("LEGACY_KEY", AccessPolicy::Always).unwrap();
1035
1036 let all = m.list_all_entries();
1037 let entry = all.iter().find(|(n, _)| n == "LEGACY_KEY").unwrap();
1038 assert_eq!(entry.1.policy, AccessPolicy::Always);
1039
1040 let _ = std::fs::remove_dir_all(&dir);
1041 }
1042}