1use zeroize::Zeroizing;
4
5use crate::{crypto, now_utc, types};
6
7pub fn add_secret(
11 vault: &mut types::Vault,
12 murk: &mut types::Murk,
13 key: &str,
14 value: &str,
15 desc: Option<&str>,
16 scoped: bool,
17 tags: &[String],
18 identity: &crypto::MurkIdentity,
19) -> bool {
20 if scoped {
21 let pubkey = identity.pubkey_string().expect("valid identity has pubkey");
25 murk.private
26 .entry(key.into())
27 .or_default()
28 .insert(pubkey, Zeroizing::new(value.to_owned()));
29 } else {
30 murk.grouped.remove(key);
34 murk.values
35 .insert(key.into(), Zeroizing::new(value.to_owned()));
36 }
37
38 upsert_schema(vault, key, desc, tags)
39}
40
41pub fn add_grouped_secret(
47 vault: &mut types::Vault,
48 murk: &mut types::Murk,
49 key: &str,
50 value: &str,
51 desc: Option<&str>,
52 group: &str,
53 tags: &[String],
54 operator_pubkey: &str,
55) -> Result<bool, crate::error::MurkError> {
56 use crate::error::MurkError;
57
58 let members = murk
59 .groups
60 .get(group)
61 .ok_or_else(|| MurkError::Group(format!("group not found: {group}")))?;
62 if !members.iter().any(|pk| pk == operator_pubkey) {
63 return Err(MurkError::Group(format!(
64 "you must be a member of group \"{group}\" to add secrets to it"
65 )));
66 }
67
68 murk.values.remove(key);
70 let entry = murk.grouped.entry(key.into()).or_default();
71 entry.clear();
72 entry.insert(group.into(), Zeroizing::new(value.to_owned()));
73
74 Ok(upsert_schema(vault, key, desc, tags))
75}
76
77fn upsert_schema(vault: &mut types::Vault, key: &str, desc: Option<&str>, tags: &[String]) -> bool {
81 let is_new = !vault.schema.contains_key(key);
82
83 let now = now_utc();
84 if let Some(entry) = vault.schema.get_mut(key) {
85 if let Some(d) = desc {
86 entry.description = d.into();
87 }
88 if !tags.is_empty() {
89 for t in tags {
90 if !entry.tags.contains(t) {
91 entry.tags.push(t.clone());
92 }
93 }
94 }
95 entry.updated = Some(now);
96 entry.revoked_at = None;
99 } else {
100 vault.schema.insert(
101 key.into(),
102 types::SchemaEntry {
103 description: desc.unwrap_or("").into(),
104 example: None,
105 tags: tags.to_vec(),
106 created: Some(now.clone()),
107 updated: Some(now),
108 ..Default::default()
109 },
110 );
111 }
112
113 is_new && desc.is_none()
114}
115
116pub fn mark_revoked(vault: &mut types::Vault, keys: &[String], now: chrono::DateTime<chrono::Utc>) {
126 let ts = now.format("%Y-%m-%dT%H:%M:%SZ").to_string();
127 for key in keys {
128 if let Some(entry) = vault.schema.get_mut(key) {
129 entry.revoked_at = Some(ts.clone());
130 }
131 }
132}
133
134pub fn remove_secret(vault: &mut types::Vault, murk: &mut types::Murk, key: &str) {
136 murk.values.remove(key);
137 murk.private.remove(key);
138 murk.grouped.remove(key);
139 vault.schema.remove(key);
140}
141
142pub fn get_secret<'a>(murk: &'a types::Murk, key: &str, pubkey: &str) -> Option<&'a str> {
146 if let Some(value) = murk.private.get(key).and_then(|m| m.get(pubkey)) {
147 return Some(value.as_str());
148 }
149 if let Some(value) = murk.grouped.get(key).and_then(|m| m.values().next()) {
150 return Some(value.as_str());
151 }
152 murk.values.get(key).map(|v| v.as_str())
153}
154
155pub fn list_keys<'a>(vault: &'a types::Vault, tags: &[String]) -> Vec<&'a str> {
157 vault
158 .schema
159 .iter()
160 .filter(|(_, entry)| tags.is_empty() || entry.tags.iter().any(|t| tags.contains(t)))
161 .map(|(key, _)| key.as_str())
162 .collect()
163}
164
165pub fn import_secrets(
173 vault: &mut types::Vault,
174 murk: &mut types::Murk,
175 pairs: &[(String, Zeroizing<String>)],
176) -> Vec<String> {
177 let now = now_utc();
178 let mut imported = Vec::new();
179 for (key, value) in pairs {
180 murk.grouped.remove(key);
182 murk.values.insert(key.clone(), value.clone());
183
184 if let Some(entry) = vault.schema.get_mut(key.as_str()) {
185 entry.updated = Some(now.clone());
186 entry.revoked_at = None;
188 } else {
189 vault.schema.insert(
190 key.clone(),
191 types::SchemaEntry {
192 description: String::new(),
193 example: None,
194 tags: vec![],
195 created: Some(now.clone()),
196 updated: Some(now.clone()),
197 ..Default::default()
198 },
199 );
200 }
201
202 imported.push(key.clone());
203 }
204 imported
205}
206
207pub fn describe_key(
215 vault: &mut types::Vault,
216 key: &str,
217 description: &str,
218 example: Option<&str>,
219 tags: &[String],
220 rotation_interval_days: Option<Option<u32>>,
221 expires_at: Option<Option<&str>>,
222) {
223 if let Some(entry) = vault.schema.get_mut(key) {
224 entry.description = description.into();
225 entry.example = example.map(Into::into);
226 if !tags.is_empty() {
227 entry.tags = tags.to_vec();
228 }
229 if let Some(patch) = rotation_interval_days {
230 entry.rotation_interval_days = patch;
231 }
232 if let Some(patch) = expires_at {
233 entry.expires_at = patch.map(Into::into);
234 }
235 } else {
236 let now = now_utc();
237 vault.schema.insert(
238 key.into(),
239 types::SchemaEntry {
240 description: description.into(),
241 example: example.map(Into::into),
242 tags: tags.to_vec(),
243 created: Some(now.clone()),
244 updated: Some(now),
245 rotation_interval_days: rotation_interval_days.flatten(),
248 expires_at: expires_at.flatten().map(Into::into),
249 revoked_at: None,
250 },
251 );
252 }
253}
254
255pub const EXPIRY_WARN_DAYS: i64 = 14;
257
258#[derive(Debug, Clone, PartialEq, Eq)]
260pub enum RotationIssue {
261 Overdue {
263 key: String,
264 last_rotated: String,
265 interval_days: u32,
266 overdue_days: i64,
267 },
268 NoBaseline { key: String, interval_days: u32 },
270 Expired {
272 key: String,
273 expired_at: String,
274 days_ago: i64,
275 },
276 ExpiringSoon {
278 key: String,
279 expires_at: String,
280 days_left: i64,
281 },
282 RevokePending { key: String, since: String },
286 BadTimestamp {
288 key: String,
289 field: &'static str,
290 value: String,
291 },
292}
293
294pub fn rotation_health(
300 vault: &types::Vault,
301 now: chrono::DateTime<chrono::Utc>,
302) -> Vec<RotationIssue> {
303 use chrono::Duration;
304
305 let mut issues = Vec::new();
306 for (key, entry) in &vault.schema {
307 if let Some(days) = entry.rotation_interval_days {
309 match &entry.updated {
310 Some(ts) => match parse_ts(ts) {
311 Some(updated) => {
312 let due = updated + Duration::days(i64::from(days));
313 if now > due {
314 issues.push(RotationIssue::Overdue {
315 key: key.clone(),
316 last_rotated: ts.clone(),
317 interval_days: days,
318 overdue_days: (now - due).num_days(),
319 });
320 }
321 }
322 None => issues.push(RotationIssue::BadTimestamp {
323 key: key.clone(),
324 field: "updated",
325 value: ts.clone(),
326 }),
327 },
328 None => issues.push(RotationIssue::NoBaseline {
329 key: key.clone(),
330 interval_days: days,
331 }),
332 }
333 }
334
335 if let Some(ts) = &entry.expires_at {
337 match parse_ts(ts) {
338 Some(expiry) if now >= expiry => issues.push(RotationIssue::Expired {
339 key: key.clone(),
340 expired_at: ts.clone(),
341 days_ago: (now - expiry).num_days(),
342 }),
343 Some(expiry) if expiry - now <= Duration::days(EXPIRY_WARN_DAYS) => {
344 issues.push(RotationIssue::ExpiringSoon {
345 key: key.clone(),
346 expires_at: ts.clone(),
347 days_left: (expiry - now).num_days(),
348 });
349 }
350 Some(_) => {}
351 None => issues.push(RotationIssue::BadTimestamp {
352 key: key.clone(),
353 field: "expires_at",
354 value: ts.clone(),
355 }),
356 }
357 }
358
359 if let Some(since) = &entry.revoked_at {
362 issues.push(RotationIssue::RevokePending {
363 key: key.clone(),
364 since: since.clone(),
365 });
366 }
367 }
368 issues
369}
370
371fn parse_ts(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
373 chrono::DateTime::parse_from_rfc3339(s)
374 .ok()
375 .map(|dt| dt.with_timezone(&chrono::Utc))
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381 use crate::testutil::*;
382 use std::collections::HashMap;
383
384 #[test]
385 fn add_secret_shared() {
386 let (secret, _) = generate_keypair();
387 let identity = make_identity(&secret);
388 let mut vault = empty_vault();
389 let mut murk = empty_murk();
390
391 let needs_hint = add_secret(
392 &mut vault,
393 &mut murk,
394 "KEY",
395 "value",
396 None,
397 false,
398 &[],
399 &identity,
400 );
401
402 assert!(needs_hint);
403 assert_eq!(murk.values["KEY"].as_str(), "value");
404 assert!(vault.schema.contains_key("KEY"));
405 assert!(vault.schema["KEY"].description.is_empty());
406 }
407
408 #[test]
409 fn add_secret_with_description() {
410 let (secret, _) = generate_keypair();
411 let identity = make_identity(&secret);
412 let mut vault = empty_vault();
413 let mut murk = empty_murk();
414
415 let needs_hint = add_secret(
416 &mut vault,
417 &mut murk,
418 "KEY",
419 "value",
420 Some("a desc"),
421 false,
422 &[],
423 &identity,
424 );
425
426 assert!(!needs_hint);
427 assert_eq!(vault.schema["KEY"].description, "a desc");
428 }
429
430 #[test]
431 fn add_secret_scoped() {
432 let (secret, pubkey) = generate_keypair();
433 let identity = make_identity(&secret);
434 let mut vault = empty_vault();
435 let mut murk = empty_murk();
436
437 add_secret(
438 &mut vault,
439 &mut murk,
440 "KEY",
441 "scoped_val",
442 None,
443 true,
444 &[],
445 &identity,
446 );
447
448 assert!(!murk.values.contains_key("KEY"));
449 assert_eq!(murk.private["KEY"][&pubkey].as_str(), "scoped_val");
450 }
451
452 #[test]
453 fn add_secret_merges_tags() {
454 let (secret, _) = generate_keypair();
455 let identity = make_identity(&secret);
456 let mut vault = empty_vault();
457 let mut murk = empty_murk();
458
459 let tags1 = vec!["db".into()];
460 add_secret(
461 &mut vault, &mut murk, "KEY", "v1", None, false, &tags1, &identity,
462 );
463 assert_eq!(vault.schema["KEY"].tags, vec!["db"]);
464
465 let tags2 = vec!["backend".into()];
466 add_secret(
467 &mut vault, &mut murk, "KEY", "v2", None, false, &tags2, &identity,
468 );
469 assert_eq!(vault.schema["KEY"].tags, vec!["db", "backend"]);
470
471 let tags3 = vec!["db".into()];
473 add_secret(
474 &mut vault, &mut murk, "KEY", "v3", None, false, &tags3, &identity,
475 );
476 assert_eq!(vault.schema["KEY"].tags, vec!["db", "backend"]);
477 }
478
479 #[test]
480 fn add_secret_updates_existing_desc() {
481 let (secret, _) = generate_keypair();
482 let identity = make_identity(&secret);
483 let mut vault = empty_vault();
484 let mut murk = empty_murk();
485
486 add_secret(
487 &mut vault,
488 &mut murk,
489 "KEY",
490 "v1",
491 Some("old"),
492 false,
493 &[],
494 &identity,
495 );
496 add_secret(
497 &mut vault,
498 &mut murk,
499 "KEY",
500 "v2",
501 Some("new"),
502 false,
503 &[],
504 &identity,
505 );
506 assert_eq!(vault.schema["KEY"].description, "new");
507 }
508
509 #[test]
510 fn remove_secret_clears_all() {
511 let mut vault = empty_vault();
512 vault.schema.insert(
513 "KEY".into(),
514 types::SchemaEntry {
515 description: "desc".into(),
516 example: None,
517 tags: vec![],
518 ..Default::default()
519 },
520 );
521 let mut murk = empty_murk();
522 murk.values.insert("KEY".into(), secret("val"));
523 let mut scoped = HashMap::new();
524 scoped.insert("age1pk".into(), secret("scoped_val"));
525 murk.private.insert("KEY".into(), scoped);
526
527 remove_secret(&mut vault, &mut murk, "KEY");
528
529 assert!(!murk.values.contains_key("KEY"));
530 assert!(!murk.private.contains_key("KEY"));
531 assert!(!vault.schema.contains_key("KEY"));
532 }
533
534 #[test]
535 fn get_secret_shared_value() {
536 let mut murk = empty_murk();
537 murk.values.insert("KEY".into(), secret("shared_val"));
538
539 assert_eq!(get_secret(&murk, "KEY", "age1pk"), Some("shared_val"));
540 }
541
542 #[test]
543 fn get_secret_scoped_overrides_shared() {
544 let mut murk = empty_murk();
545 murk.values.insert("KEY".into(), secret("shared_val"));
546 let mut scoped = HashMap::new();
547 scoped.insert("age1pk".into(), secret("scoped_val"));
548 murk.private.insert("KEY".into(), scoped);
549
550 assert_eq!(get_secret(&murk, "KEY", "age1pk"), Some("scoped_val"));
551 }
552
553 #[test]
554 fn get_secret_missing_returns_none() {
555 let murk = empty_murk();
556 assert_eq!(get_secret(&murk, "NONEXISTENT", "age1pk"), None);
557 }
558
559 #[test]
560 fn list_keys_no_filter() {
561 let mut vault = empty_vault();
562 vault.schema.insert(
563 "A".into(),
564 types::SchemaEntry {
565 description: String::new(),
566 example: None,
567 tags: vec![],
568 ..Default::default()
569 },
570 );
571 vault.schema.insert(
572 "B".into(),
573 types::SchemaEntry {
574 description: String::new(),
575 example: None,
576 tags: vec![],
577 ..Default::default()
578 },
579 );
580
581 let keys = list_keys(&vault, &[]);
582 assert_eq!(keys, vec!["A", "B"]);
583 }
584
585 #[test]
586 fn list_keys_with_tag_filter() {
587 let mut vault = empty_vault();
588 vault.schema.insert(
589 "A".into(),
590 types::SchemaEntry {
591 description: String::new(),
592 example: None,
593 tags: vec!["db".into()],
594 ..Default::default()
595 },
596 );
597 vault.schema.insert(
598 "B".into(),
599 types::SchemaEntry {
600 description: String::new(),
601 example: None,
602 tags: vec!["api".into()],
603 ..Default::default()
604 },
605 );
606 vault.schema.insert(
607 "C".into(),
608 types::SchemaEntry {
609 description: String::new(),
610 example: None,
611 tags: vec![],
612 ..Default::default()
613 },
614 );
615
616 let keys = list_keys(&vault, &["db".into()]);
617 assert_eq!(keys, vec!["A"]);
618 }
619
620 #[test]
621 fn list_keys_no_matches() {
622 let mut vault = empty_vault();
623 vault.schema.insert(
624 "A".into(),
625 types::SchemaEntry {
626 description: String::new(),
627 example: None,
628 tags: vec!["db".into()],
629 ..Default::default()
630 },
631 );
632
633 let keys = list_keys(&vault, &["nonexistent".into()]);
634 assert!(keys.is_empty());
635 }
636
637 #[test]
638 fn describe_key_creates_new() {
639 let mut vault = empty_vault();
640 describe_key(
641 &mut vault,
642 "KEY",
643 "a description",
644 Some("example"),
645 &["tag".into()],
646 None,
647 None,
648 );
649
650 assert_eq!(vault.schema["KEY"].description, "a description");
651 assert_eq!(vault.schema["KEY"].example.as_deref(), Some("example"));
652 assert_eq!(vault.schema["KEY"].tags, vec!["tag"]);
653 }
654
655 #[test]
656 fn describe_key_updates_existing() {
657 let mut vault = empty_vault();
658 vault.schema.insert(
659 "KEY".into(),
660 types::SchemaEntry {
661 description: "old".into(),
662 example: Some("old_ex".into()),
663 tags: vec!["old_tag".into()],
664 ..Default::default()
665 },
666 );
667
668 describe_key(
669 &mut vault,
670 "KEY",
671 "new",
672 None,
673 &["new_tag".into()],
674 None,
675 None,
676 );
677
678 assert_eq!(vault.schema["KEY"].description, "new");
679 assert_eq!(vault.schema["KEY"].example, None);
680 assert_eq!(vault.schema["KEY"].tags, vec!["new_tag"]);
681 }
682
683 #[test]
684 fn describe_key_preserves_tags_if_empty() {
685 let mut vault = empty_vault();
686 vault.schema.insert(
687 "KEY".into(),
688 types::SchemaEntry {
689 description: "old".into(),
690 example: None,
691 tags: vec!["keep".into()],
692 ..Default::default()
693 },
694 );
695
696 describe_key(&mut vault, "KEY", "new desc", None, &[], None, None);
697
698 assert_eq!(vault.schema["KEY"].tags, vec!["keep"]);
699 }
700
701 #[test]
704 fn add_secret_overwrite_shared_with_scoped() {
705 let (secret, pubkey) = generate_keypair();
706 let identity = make_identity(&secret);
707 let mut vault = empty_vault();
708 let mut murk = empty_murk();
709
710 add_secret(
711 &mut vault,
712 &mut murk,
713 "KEY",
714 "shared_val",
715 None,
716 false,
717 &[],
718 &identity,
719 );
720 assert_eq!(murk.values["KEY"].as_str(), "shared_val");
721
722 add_secret(
723 &mut vault,
724 &mut murk,
725 "KEY",
726 "scoped_val",
727 None,
728 true,
729 &[],
730 &identity,
731 );
732 assert_eq!(murk.values["KEY"].as_str(), "shared_val");
734 assert_eq!(murk.private["KEY"][&pubkey].as_str(), "scoped_val");
735 }
736
737 #[test]
738 fn add_secret_empty_value() {
739 let (secret, _) = generate_keypair();
740 let identity = make_identity(&secret);
741 let mut vault = empty_vault();
742 let mut murk = empty_murk();
743
744 add_secret(
745 &mut vault,
746 &mut murk,
747 "KEY",
748 "",
749 None,
750 false,
751 &[],
752 &identity,
753 );
754 assert_eq!(murk.values["KEY"].as_str(), "");
755 }
756
757 #[test]
758 fn import_secrets_basic() {
759 let mut vault = empty_vault();
760 let mut murk = empty_murk();
761
762 let pairs = vec![
763 ("KEY1".into(), Zeroizing::new("val1".into())),
764 ("KEY2".into(), Zeroizing::new("val2".into())),
765 ];
766 let imported = import_secrets(&mut vault, &mut murk, &pairs);
767
768 assert_eq!(imported, vec!["KEY1", "KEY2"]);
769 assert_eq!(murk.values["KEY1"].as_str(), "val1");
770 assert_eq!(murk.values["KEY2"].as_str(), "val2");
771 assert!(vault.schema.contains_key("KEY1"));
772 assert!(vault.schema.contains_key("KEY2"));
773 }
774
775 #[test]
776 fn import_secrets_existing_schema_preserved() {
777 let mut vault = empty_vault();
778 vault.schema.insert(
779 "KEY1".into(),
780 types::SchemaEntry {
781 description: "existing desc".into(),
782 example: Some("ex".into()),
783 tags: vec!["tag".into()],
784 ..Default::default()
785 },
786 );
787 let mut murk = empty_murk();
788
789 let pairs = vec![("KEY1".into(), Zeroizing::new("new_val".into()))];
790 import_secrets(&mut vault, &mut murk, &pairs);
791
792 assert_eq!(murk.values["KEY1"].as_str(), "new_val");
793 assert_eq!(vault.schema["KEY1"].description, "existing desc");
794 }
795
796 #[test]
797 fn import_secrets_empty() {
798 let mut vault = empty_vault();
799 let mut murk = empty_murk();
800 let imported = import_secrets(&mut vault, &mut murk, &[]);
801 assert!(imported.is_empty());
802 }
803
804 #[test]
805 fn remove_secret_nonexistent() {
806 let mut vault = empty_vault();
807 let mut murk = empty_murk();
808
809 remove_secret(&mut vault, &mut murk, "NONEXISTENT");
811 }
812
813 #[test]
816 fn describe_key_sets_rotation_and_expiry_on_new_key() {
817 let mut vault = empty_vault();
818 describe_key(
819 &mut vault,
820 "TOKEN",
821 "api token",
822 None,
823 &[],
824 Some(Some(90)),
825 Some(Some("2026-09-01T23:59:59Z")),
826 );
827 let e = &vault.schema["TOKEN"];
828 assert_eq!(e.rotation_interval_days, Some(90));
829 assert_eq!(e.expires_at.as_deref(), Some("2026-09-01T23:59:59Z"));
830 }
831
832 #[test]
833 fn describe_key_rotation_patch_is_sticky_and_clearable() {
834 let mut vault = empty_vault();
835 describe_key(&mut vault, "K", "d", None, &[], Some(Some(30)), None);
836 assert_eq!(vault.schema["K"].rotation_interval_days, Some(30));
837
838 describe_key(&mut vault, "K", "d2", None, &[], None, None);
840 assert_eq!(vault.schema["K"].rotation_interval_days, Some(30));
841
842 describe_key(&mut vault, "K", "d3", None, &[], Some(None), None);
844 assert_eq!(vault.schema["K"].rotation_interval_days, None);
845 }
846
847 fn ts(s: &str) -> chrono::DateTime<chrono::Utc> {
848 chrono::DateTime::parse_from_rfc3339(s)
849 .unwrap()
850 .with_timezone(&chrono::Utc)
851 }
852
853 fn vault_with(entry: types::SchemaEntry) -> types::Vault {
854 let mut v = empty_vault();
855 v.schema.insert("K".into(), entry);
856 v
857 }
858
859 #[test]
860 fn rotation_health_flags_overdue() {
861 let vault = vault_with(types::SchemaEntry {
862 updated: Some("2026-01-01T00:00:00Z".into()),
863 rotation_interval_days: Some(30),
864 ..Default::default()
865 });
866 let issues = rotation_health(&vault, ts("2026-03-02T00:00:00Z"));
868 assert_eq!(issues.len(), 1);
869 assert!(matches!(
870 &issues[0],
871 RotationIssue::Overdue { key, overdue_days, .. } if key == "K" && *overdue_days == 30
872 ));
873 }
874
875 #[test]
876 fn rotation_health_silent_when_within_interval() {
877 let vault = vault_with(types::SchemaEntry {
878 updated: Some("2026-01-01T00:00:00Z".into()),
879 rotation_interval_days: Some(90),
880 ..Default::default()
881 });
882 assert!(rotation_health(&vault, ts("2026-02-01T00:00:00Z")).is_empty());
883 }
884
885 #[test]
886 fn rotation_health_flags_no_baseline() {
887 let vault = vault_with(types::SchemaEntry {
888 rotation_interval_days: Some(30),
889 ..Default::default()
890 });
891 assert!(matches!(
892 &rotation_health(&vault, ts("2026-03-02T00:00:00Z"))[0],
893 RotationIssue::NoBaseline {
894 interval_days: 30,
895 ..
896 }
897 ));
898 }
899
900 #[test]
901 fn rotation_health_flags_expired_and_expiring_soon() {
902 let expired = vault_with(types::SchemaEntry {
903 expires_at: Some("2026-01-01T00:00:00Z".into()),
904 ..Default::default()
905 });
906 assert!(matches!(
907 &rotation_health(&expired, ts("2026-01-11T00:00:00Z"))[0],
908 RotationIssue::Expired { days_ago: 10, .. }
909 ));
910
911 let soon = vault_with(types::SchemaEntry {
912 expires_at: Some("2026-01-10T00:00:00Z".into()),
913 ..Default::default()
914 });
915 assert!(matches!(
916 &rotation_health(&soon, ts("2026-01-01T00:00:00Z"))[0],
917 RotationIssue::ExpiringSoon { days_left: 9, .. }
918 ));
919
920 assert!(rotation_health(&soon, ts("2025-06-01T00:00:00Z")).is_empty());
922 }
923
924 #[test]
925 fn rotation_health_flags_bad_timestamp() {
926 let vault = vault_with(types::SchemaEntry {
927 expires_at: Some("not-a-date".into()),
928 ..Default::default()
929 });
930 assert!(matches!(
931 &rotation_health(&vault, ts("2026-01-01T00:00:00Z"))[0],
932 RotationIssue::BadTimestamp {
933 field: "expires_at",
934 ..
935 }
936 ));
937 }
938
939 #[test]
940 fn rotation_health_flags_revoke_pending() {
941 let vault = vault_with(types::SchemaEntry {
944 revoked_at: Some("2026-06-18T00:00:00Z".into()),
945 ..Default::default()
946 });
947 assert!(matches!(
948 &rotation_health(&vault, ts("2030-01-01T00:00:00Z"))[0],
949 RotationIssue::RevokePending { key, since }
950 if key == "K" && since == "2026-06-18T00:00:00Z"
951 ));
952 }
953
954 #[test]
955 fn mark_revoked_stamps_only_existing_schema_entries() {
956 let mut vault = vault_with(types::SchemaEntry::default());
957 mark_revoked(
958 &mut vault,
959 &["K".into(), "ABSENT".into()],
960 ts("2026-06-18T12:00:00Z"),
961 );
962 assert_eq!(
963 vault.schema["K"].revoked_at.as_deref(),
964 Some("2026-06-18T12:00:00Z")
965 );
966 assert!(!vault.schema.contains_key("ABSENT"));
968 }
969
970 #[test]
971 fn value_write_clears_revoke_marker() {
972 let mut vault = vault_with(types::SchemaEntry {
975 description: "d".into(),
976 revoked_at: Some("2026-06-18T00:00:00Z".into()),
977 ..Default::default()
978 });
979 let mut murk = empty_murk();
980 import_secrets(&mut vault, &mut murk, &[("K".into(), secret("new"))]);
981 assert_eq!(vault.schema["K"].revoked_at, None);
982 assert!(rotation_health(&vault, ts("2030-01-01T00:00:00Z")).is_empty());
983 }
984}