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, serde::Serialize)]
263#[serde(tag = "reason", rename_all = "snake_case")]
264pub enum RotationIssue {
265 Overdue {
267 key: String,
268 last_rotated: String,
269 interval_days: u32,
270 overdue_days: i64,
271 },
272 NoBaseline { key: String, interval_days: u32 },
274 Expired {
276 key: String,
277 expired_at: String,
278 days_ago: i64,
279 },
280 ExpiringSoon {
282 key: String,
283 expires_at: String,
284 days_left: i64,
285 },
286 RevokePending { key: String, since: String },
290 BadTimestamp {
292 key: String,
293 field: &'static str,
294 value: String,
295 },
296}
297
298pub fn rotation_health(
304 vault: &types::Vault,
305 now: chrono::DateTime<chrono::Utc>,
306) -> Vec<RotationIssue> {
307 use chrono::Duration;
308
309 let mut issues = Vec::new();
310 for (key, entry) in &vault.schema {
311 if let Some(days) = entry.rotation_interval_days {
313 match &entry.updated {
314 Some(ts) => match parse_ts(ts) {
315 Some(updated) => {
316 let due = updated + Duration::days(i64::from(days));
317 if now > due {
318 issues.push(RotationIssue::Overdue {
319 key: key.clone(),
320 last_rotated: ts.clone(),
321 interval_days: days,
322 overdue_days: (now - due).num_days(),
323 });
324 }
325 }
326 None => issues.push(RotationIssue::BadTimestamp {
327 key: key.clone(),
328 field: "updated",
329 value: ts.clone(),
330 }),
331 },
332 None => issues.push(RotationIssue::NoBaseline {
333 key: key.clone(),
334 interval_days: days,
335 }),
336 }
337 }
338
339 if let Some(ts) = &entry.expires_at {
341 match parse_ts(ts) {
342 Some(expiry) if now >= expiry => issues.push(RotationIssue::Expired {
343 key: key.clone(),
344 expired_at: ts.clone(),
345 days_ago: (now - expiry).num_days(),
346 }),
347 Some(expiry) if expiry - now <= Duration::days(EXPIRY_WARN_DAYS) => {
348 issues.push(RotationIssue::ExpiringSoon {
349 key: key.clone(),
350 expires_at: ts.clone(),
351 days_left: (expiry - now).num_days(),
352 });
353 }
354 Some(_) => {}
355 None => issues.push(RotationIssue::BadTimestamp {
356 key: key.clone(),
357 field: "expires_at",
358 value: ts.clone(),
359 }),
360 }
361 }
362
363 if let Some(since) = &entry.revoked_at {
366 issues.push(RotationIssue::RevokePending {
367 key: key.clone(),
368 since: since.clone(),
369 });
370 }
371 }
372 issues
373}
374
375fn parse_ts(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
377 chrono::DateTime::parse_from_rfc3339(s)
378 .ok()
379 .map(|dt| dt.with_timezone(&chrono::Utc))
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385 use crate::testutil::*;
386 use std::collections::HashMap;
387
388 #[test]
389 fn add_secret_shared() {
390 let (secret, _) = generate_keypair();
391 let identity = make_identity(&secret);
392 let mut vault = empty_vault();
393 let mut murk = empty_murk();
394
395 let needs_hint = add_secret(
396 &mut vault,
397 &mut murk,
398 "KEY",
399 "value",
400 None,
401 false,
402 &[],
403 &identity,
404 );
405
406 assert!(needs_hint);
407 assert_eq!(murk.values["KEY"].as_str(), "value");
408 assert!(vault.schema.contains_key("KEY"));
409 assert!(vault.schema["KEY"].description.is_empty());
410 }
411
412 #[test]
413 fn add_secret_with_description() {
414 let (secret, _) = generate_keypair();
415 let identity = make_identity(&secret);
416 let mut vault = empty_vault();
417 let mut murk = empty_murk();
418
419 let needs_hint = add_secret(
420 &mut vault,
421 &mut murk,
422 "KEY",
423 "value",
424 Some("a desc"),
425 false,
426 &[],
427 &identity,
428 );
429
430 assert!(!needs_hint);
431 assert_eq!(vault.schema["KEY"].description, "a desc");
432 }
433
434 #[test]
435 fn add_secret_scoped() {
436 let (secret, pubkey) = generate_keypair();
437 let identity = make_identity(&secret);
438 let mut vault = empty_vault();
439 let mut murk = empty_murk();
440
441 add_secret(
442 &mut vault,
443 &mut murk,
444 "KEY",
445 "scoped_val",
446 None,
447 true,
448 &[],
449 &identity,
450 );
451
452 assert!(!murk.values.contains_key("KEY"));
453 assert_eq!(murk.private["KEY"][&pubkey].as_str(), "scoped_val");
454 }
455
456 #[test]
457 fn add_secret_merges_tags() {
458 let (secret, _) = generate_keypair();
459 let identity = make_identity(&secret);
460 let mut vault = empty_vault();
461 let mut murk = empty_murk();
462
463 let tags1 = vec!["db".into()];
464 add_secret(
465 &mut vault, &mut murk, "KEY", "v1", None, false, &tags1, &identity,
466 );
467 assert_eq!(vault.schema["KEY"].tags, vec!["db"]);
468
469 let tags2 = vec!["backend".into()];
470 add_secret(
471 &mut vault, &mut murk, "KEY", "v2", None, false, &tags2, &identity,
472 );
473 assert_eq!(vault.schema["KEY"].tags, vec!["db", "backend"]);
474
475 let tags3 = vec!["db".into()];
477 add_secret(
478 &mut vault, &mut murk, "KEY", "v3", None, false, &tags3, &identity,
479 );
480 assert_eq!(vault.schema["KEY"].tags, vec!["db", "backend"]);
481 }
482
483 #[test]
484 fn add_secret_updates_existing_desc() {
485 let (secret, _) = generate_keypair();
486 let identity = make_identity(&secret);
487 let mut vault = empty_vault();
488 let mut murk = empty_murk();
489
490 add_secret(
491 &mut vault,
492 &mut murk,
493 "KEY",
494 "v1",
495 Some("old"),
496 false,
497 &[],
498 &identity,
499 );
500 add_secret(
501 &mut vault,
502 &mut murk,
503 "KEY",
504 "v2",
505 Some("new"),
506 false,
507 &[],
508 &identity,
509 );
510 assert_eq!(vault.schema["KEY"].description, "new");
511 }
512
513 #[test]
514 fn remove_secret_clears_all() {
515 let mut vault = empty_vault();
516 vault.schema.insert(
517 "KEY".into(),
518 types::SchemaEntry {
519 description: "desc".into(),
520 example: None,
521 tags: vec![],
522 ..Default::default()
523 },
524 );
525 let mut murk = empty_murk();
526 murk.values.insert("KEY".into(), secret("val"));
527 let mut scoped = HashMap::new();
528 scoped.insert("age1pk".into(), secret("scoped_val"));
529 murk.private.insert("KEY".into(), scoped);
530
531 remove_secret(&mut vault, &mut murk, "KEY");
532
533 assert!(!murk.values.contains_key("KEY"));
534 assert!(!murk.private.contains_key("KEY"));
535 assert!(!vault.schema.contains_key("KEY"));
536 }
537
538 #[test]
539 fn get_secret_shared_value() {
540 let mut murk = empty_murk();
541 murk.values.insert("KEY".into(), secret("shared_val"));
542
543 assert_eq!(get_secret(&murk, "KEY", "age1pk"), Some("shared_val"));
544 }
545
546 #[test]
547 fn get_secret_scoped_overrides_shared() {
548 let mut murk = empty_murk();
549 murk.values.insert("KEY".into(), secret("shared_val"));
550 let mut scoped = HashMap::new();
551 scoped.insert("age1pk".into(), secret("scoped_val"));
552 murk.private.insert("KEY".into(), scoped);
553
554 assert_eq!(get_secret(&murk, "KEY", "age1pk"), Some("scoped_val"));
555 }
556
557 #[test]
558 fn get_secret_missing_returns_none() {
559 let murk = empty_murk();
560 assert_eq!(get_secret(&murk, "NONEXISTENT", "age1pk"), None);
561 }
562
563 #[test]
564 fn list_keys_no_filter() {
565 let mut vault = empty_vault();
566 vault.schema.insert(
567 "A".into(),
568 types::SchemaEntry {
569 description: String::new(),
570 example: None,
571 tags: vec![],
572 ..Default::default()
573 },
574 );
575 vault.schema.insert(
576 "B".into(),
577 types::SchemaEntry {
578 description: String::new(),
579 example: None,
580 tags: vec![],
581 ..Default::default()
582 },
583 );
584
585 let keys = list_keys(&vault, &[]);
586 assert_eq!(keys, vec!["A", "B"]);
587 }
588
589 #[test]
590 fn list_keys_with_tag_filter() {
591 let mut vault = empty_vault();
592 vault.schema.insert(
593 "A".into(),
594 types::SchemaEntry {
595 description: String::new(),
596 example: None,
597 tags: vec!["db".into()],
598 ..Default::default()
599 },
600 );
601 vault.schema.insert(
602 "B".into(),
603 types::SchemaEntry {
604 description: String::new(),
605 example: None,
606 tags: vec!["api".into()],
607 ..Default::default()
608 },
609 );
610 vault.schema.insert(
611 "C".into(),
612 types::SchemaEntry {
613 description: String::new(),
614 example: None,
615 tags: vec![],
616 ..Default::default()
617 },
618 );
619
620 let keys = list_keys(&vault, &["db".into()]);
621 assert_eq!(keys, vec!["A"]);
622 }
623
624 #[test]
625 fn list_keys_no_matches() {
626 let mut vault = empty_vault();
627 vault.schema.insert(
628 "A".into(),
629 types::SchemaEntry {
630 description: String::new(),
631 example: None,
632 tags: vec!["db".into()],
633 ..Default::default()
634 },
635 );
636
637 let keys = list_keys(&vault, &["nonexistent".into()]);
638 assert!(keys.is_empty());
639 }
640
641 #[test]
642 fn describe_key_creates_new() {
643 let mut vault = empty_vault();
644 describe_key(
645 &mut vault,
646 "KEY",
647 "a description",
648 Some("example"),
649 &["tag".into()],
650 None,
651 None,
652 );
653
654 assert_eq!(vault.schema["KEY"].description, "a description");
655 assert_eq!(vault.schema["KEY"].example.as_deref(), Some("example"));
656 assert_eq!(vault.schema["KEY"].tags, vec!["tag"]);
657 }
658
659 #[test]
660 fn describe_key_updates_existing() {
661 let mut vault = empty_vault();
662 vault.schema.insert(
663 "KEY".into(),
664 types::SchemaEntry {
665 description: "old".into(),
666 example: Some("old_ex".into()),
667 tags: vec!["old_tag".into()],
668 ..Default::default()
669 },
670 );
671
672 describe_key(
673 &mut vault,
674 "KEY",
675 "new",
676 None,
677 &["new_tag".into()],
678 None,
679 None,
680 );
681
682 assert_eq!(vault.schema["KEY"].description, "new");
683 assert_eq!(vault.schema["KEY"].example, None);
684 assert_eq!(vault.schema["KEY"].tags, vec!["new_tag"]);
685 }
686
687 #[test]
688 fn describe_key_preserves_tags_if_empty() {
689 let mut vault = empty_vault();
690 vault.schema.insert(
691 "KEY".into(),
692 types::SchemaEntry {
693 description: "old".into(),
694 example: None,
695 tags: vec!["keep".into()],
696 ..Default::default()
697 },
698 );
699
700 describe_key(&mut vault, "KEY", "new desc", None, &[], None, None);
701
702 assert_eq!(vault.schema["KEY"].tags, vec!["keep"]);
703 }
704
705 #[test]
708 fn add_secret_overwrite_shared_with_scoped() {
709 let (secret, pubkey) = generate_keypair();
710 let identity = make_identity(&secret);
711 let mut vault = empty_vault();
712 let mut murk = empty_murk();
713
714 add_secret(
715 &mut vault,
716 &mut murk,
717 "KEY",
718 "shared_val",
719 None,
720 false,
721 &[],
722 &identity,
723 );
724 assert_eq!(murk.values["KEY"].as_str(), "shared_val");
725
726 add_secret(
727 &mut vault,
728 &mut murk,
729 "KEY",
730 "scoped_val",
731 None,
732 true,
733 &[],
734 &identity,
735 );
736 assert_eq!(murk.values["KEY"].as_str(), "shared_val");
738 assert_eq!(murk.private["KEY"][&pubkey].as_str(), "scoped_val");
739 }
740
741 #[test]
742 fn add_secret_empty_value() {
743 let (secret, _) = generate_keypair();
744 let identity = make_identity(&secret);
745 let mut vault = empty_vault();
746 let mut murk = empty_murk();
747
748 add_secret(
749 &mut vault,
750 &mut murk,
751 "KEY",
752 "",
753 None,
754 false,
755 &[],
756 &identity,
757 );
758 assert_eq!(murk.values["KEY"].as_str(), "");
759 }
760
761 #[test]
762 fn import_secrets_basic() {
763 let mut vault = empty_vault();
764 let mut murk = empty_murk();
765
766 let pairs = vec![
767 ("KEY1".into(), Zeroizing::new("val1".into())),
768 ("KEY2".into(), Zeroizing::new("val2".into())),
769 ];
770 let imported = import_secrets(&mut vault, &mut murk, &pairs);
771
772 assert_eq!(imported, vec!["KEY1", "KEY2"]);
773 assert_eq!(murk.values["KEY1"].as_str(), "val1");
774 assert_eq!(murk.values["KEY2"].as_str(), "val2");
775 assert!(vault.schema.contains_key("KEY1"));
776 assert!(vault.schema.contains_key("KEY2"));
777 }
778
779 #[test]
780 fn import_secrets_existing_schema_preserved() {
781 let mut vault = empty_vault();
782 vault.schema.insert(
783 "KEY1".into(),
784 types::SchemaEntry {
785 description: "existing desc".into(),
786 example: Some("ex".into()),
787 tags: vec!["tag".into()],
788 ..Default::default()
789 },
790 );
791 let mut murk = empty_murk();
792
793 let pairs = vec![("KEY1".into(), Zeroizing::new("new_val".into()))];
794 import_secrets(&mut vault, &mut murk, &pairs);
795
796 assert_eq!(murk.values["KEY1"].as_str(), "new_val");
797 assert_eq!(vault.schema["KEY1"].description, "existing desc");
798 }
799
800 #[test]
801 fn import_secrets_empty() {
802 let mut vault = empty_vault();
803 let mut murk = empty_murk();
804 let imported = import_secrets(&mut vault, &mut murk, &[]);
805 assert!(imported.is_empty());
806 }
807
808 #[test]
809 fn remove_secret_nonexistent() {
810 let mut vault = empty_vault();
811 let mut murk = empty_murk();
812
813 remove_secret(&mut vault, &mut murk, "NONEXISTENT");
815 }
816
817 #[test]
820 fn describe_key_sets_rotation_and_expiry_on_new_key() {
821 let mut vault = empty_vault();
822 describe_key(
823 &mut vault,
824 "TOKEN",
825 "api token",
826 None,
827 &[],
828 Some(Some(90)),
829 Some(Some("2026-09-01T23:59:59Z")),
830 );
831 let e = &vault.schema["TOKEN"];
832 assert_eq!(e.rotation_interval_days, Some(90));
833 assert_eq!(e.expires_at.as_deref(), Some("2026-09-01T23:59:59Z"));
834 }
835
836 #[test]
837 fn describe_key_rotation_patch_is_sticky_and_clearable() {
838 let mut vault = empty_vault();
839 describe_key(&mut vault, "K", "d", None, &[], Some(Some(30)), None);
840 assert_eq!(vault.schema["K"].rotation_interval_days, Some(30));
841
842 describe_key(&mut vault, "K", "d2", None, &[], None, None);
844 assert_eq!(vault.schema["K"].rotation_interval_days, Some(30));
845
846 describe_key(&mut vault, "K", "d3", None, &[], Some(None), None);
848 assert_eq!(vault.schema["K"].rotation_interval_days, None);
849 }
850
851 fn ts(s: &str) -> chrono::DateTime<chrono::Utc> {
852 chrono::DateTime::parse_from_rfc3339(s)
853 .unwrap()
854 .with_timezone(&chrono::Utc)
855 }
856
857 fn vault_with(entry: types::SchemaEntry) -> types::Vault {
858 let mut v = empty_vault();
859 v.schema.insert("K".into(), entry);
860 v
861 }
862
863 #[test]
864 fn rotation_health_flags_overdue() {
865 let vault = vault_with(types::SchemaEntry {
866 updated: Some("2026-01-01T00:00:00Z".into()),
867 rotation_interval_days: Some(30),
868 ..Default::default()
869 });
870 let issues = rotation_health(&vault, ts("2026-03-02T00:00:00Z"));
872 assert_eq!(issues.len(), 1);
873 assert!(matches!(
874 &issues[0],
875 RotationIssue::Overdue { key, overdue_days, .. } if key == "K" && *overdue_days == 30
876 ));
877 }
878
879 #[test]
880 fn rotation_health_silent_when_within_interval() {
881 let vault = vault_with(types::SchemaEntry {
882 updated: Some("2026-01-01T00:00:00Z".into()),
883 rotation_interval_days: Some(90),
884 ..Default::default()
885 });
886 assert!(rotation_health(&vault, ts("2026-02-01T00:00:00Z")).is_empty());
887 }
888
889 #[test]
890 fn rotation_health_flags_no_baseline() {
891 let vault = vault_with(types::SchemaEntry {
892 rotation_interval_days: Some(30),
893 ..Default::default()
894 });
895 assert!(matches!(
896 &rotation_health(&vault, ts("2026-03-02T00:00:00Z"))[0],
897 RotationIssue::NoBaseline {
898 interval_days: 30,
899 ..
900 }
901 ));
902 }
903
904 #[test]
905 fn rotation_health_flags_expired_and_expiring_soon() {
906 let expired = vault_with(types::SchemaEntry {
907 expires_at: Some("2026-01-01T00:00:00Z".into()),
908 ..Default::default()
909 });
910 assert!(matches!(
911 &rotation_health(&expired, ts("2026-01-11T00:00:00Z"))[0],
912 RotationIssue::Expired { days_ago: 10, .. }
913 ));
914
915 let soon = vault_with(types::SchemaEntry {
916 expires_at: Some("2026-01-10T00:00:00Z".into()),
917 ..Default::default()
918 });
919 assert!(matches!(
920 &rotation_health(&soon, ts("2026-01-01T00:00:00Z"))[0],
921 RotationIssue::ExpiringSoon { days_left: 9, .. }
922 ));
923
924 assert!(rotation_health(&soon, ts("2025-06-01T00:00:00Z")).is_empty());
926 }
927
928 #[test]
929 fn rotation_health_flags_bad_timestamp() {
930 let vault = vault_with(types::SchemaEntry {
931 expires_at: Some("not-a-date".into()),
932 ..Default::default()
933 });
934 assert!(matches!(
935 &rotation_health(&vault, ts("2026-01-01T00:00:00Z"))[0],
936 RotationIssue::BadTimestamp {
937 field: "expires_at",
938 ..
939 }
940 ));
941 }
942
943 #[test]
944 fn rotation_health_flags_revoke_pending() {
945 let vault = vault_with(types::SchemaEntry {
948 revoked_at: Some("2026-06-18T00:00:00Z".into()),
949 ..Default::default()
950 });
951 assert!(matches!(
952 &rotation_health(&vault, ts("2030-01-01T00:00:00Z"))[0],
953 RotationIssue::RevokePending { key, since }
954 if key == "K" && since == "2026-06-18T00:00:00Z"
955 ));
956 }
957
958 #[test]
959 fn mark_revoked_stamps_only_existing_schema_entries() {
960 let mut vault = vault_with(types::SchemaEntry::default());
961 mark_revoked(
962 &mut vault,
963 &["K".into(), "ABSENT".into()],
964 ts("2026-06-18T12:00:00Z"),
965 );
966 assert_eq!(
967 vault.schema["K"].revoked_at.as_deref(),
968 Some("2026-06-18T12:00:00Z")
969 );
970 assert!(!vault.schema.contains_key("ABSENT"));
972 }
973
974 #[test]
975 fn value_write_clears_revoke_marker() {
976 let mut vault = vault_with(types::SchemaEntry {
979 description: "d".into(),
980 revoked_at: Some("2026-06-18T00:00:00Z".into()),
981 ..Default::default()
982 });
983 let mut murk = empty_murk();
984 import_secrets(&mut vault, &mut murk, &[("K".into(), secret("new"))]);
985 assert_eq!(vault.schema["K"].revoked_at, None);
986 assert!(rotation_health(&vault, ts("2030-01-01T00:00:00Z")).is_empty());
987 }
988}