Add property-based tests for redact invariants and push events
Added redact unit tests: idempotence, non-matching passthrough,
identity when no secrets resolved. Added property tests for push
event repo names and updated_refs subtractive invariant.

Assisted-by: GLM-5.1 via pi
change tnprtxslywvmtqlspsuskolzwzttmuvv
commit a945d4edb13906d9964e07981b00af5a613a63cf
author Alpha Chen <alpha@kejadlen.dev>
date
parent mztxrwvx
diff --git a/src/ci/redact.rs b/src/ci/redact.rs
index c4462b5..9f76862 100644
--- a/src/ci/redact.rs
+++ b/src/ci/redact.rs
@@ -224,4 +224,30 @@ mod tests {
         let mut reg = SecretRegistry::new(plain_secrets(&[("key", "hunter2")]));
         assert_eq!(reg.resolve("key").unwrap(), "hunter2");
     }
+
+    #[test]
+    fn redact_is_idempotent() {
+        let mut reg = SecretRegistry::new(plain_secrets(&[("token", "ghp_long_secret_value")]));
+        reg.resolve("token").unwrap();
+        let input = "hello ghp_long_secret_value world";
+        let first = redact(input, &reg);
+        let second = redact(&first, &reg);
+        assert_eq!(first, second);
+    }
+
+    #[test]
+    fn redact_preserves_non_matching_text() {
+        let mut reg = SecretRegistry::new(plain_secrets(&[("token", "ghp_long_secret_value")]));
+        reg.resolve("token").unwrap();
+        let input = "nothing to see here";
+        assert_eq!(redact(input, &reg), input);
+    }
+
+    #[test]
+    fn redact_with_no_resolves_is_identity() {
+        let reg = SecretRegistry::new(plain_secrets(&[("token", "ghp_long_secret_value")]));
+        // No resolve — no redactions registered.
+        let input = "contains ghp_long_secret_value but not resolved";
+        assert_eq!(redact(input, &reg), input);
+    }
 }
diff --git a/tests/property.rs b/tests/property.rs
index 1ddf1aa..d489b64 100644
--- a/tests/property.rs
+++ b/tests/property.rs
@@ -86,3 +86,30 @@ fn secret_string_from_file_strips_one_trailing_newline(tc: TestCase) {
     let expected = content.strip_suffix('\n').unwrap_or(&content).to_string();
     assert_eq!(revealed, expected);
 }
+
+#[hegel::test]
+fn push_event_repo_round_trips_json(tc: TestCase) {
+    // Verify that arbitrary repo names survive JSON serialization.
+    let mut event = tc.draw(push_event());
+    event.repo = tc.draw(text());
+    let json = serde_json::to_string(&event).expect("serialize");
+    let parsed: PushEvent = serde_json::from_str(&json).expect("deserialize");
+    assert_eq!(event, parsed);
+}
+
+#[hegel::test]
+fn push_event_updated_refs_is_subtractive(tc: TestCase) {
+    // updated_refs() can only remove refs (zero-sha deletions),
+    // never add or reorder them.
+    let event = tc.draw(push_event());
+    let kept = event.updated_refs();
+    assert!(kept.len() <= event.refs.len());
+    for kept_ref in &kept {
+        assert!(
+            event
+                .refs
+                .iter()
+                .any(|r| r.r#ref == kept_ref.r#ref && r.new_sha == kept_ref.new_sha)
+        );
+    }
+}