Skip to main content

rma_common/suppression/
entry.rs

1//! Suppression entry data structures with enterprise features
2//!
3//! Includes:
4//! - Approval workflows
5//! - Groups and tags
6//! - Scheduled auto-revocation
7//! - Policy compliance
8
9use crate::Severity;
10use serde::{Deserialize, Serialize};
11use std::collections::HashSet;
12use std::path::PathBuf;
13
14/// Status of a suppression entry
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17#[derive(Default)]
18pub enum SuppressionStatus {
19    /// Suppression is active
20    #[default]
21    Active,
22    /// Suppression has expired
23    Expired,
24    /// Suppression was manually revoked
25    Revoked,
26    /// The underlying code has changed (suppression may no longer apply)
27    Stale,
28    /// Pending approval
29    PendingApproval,
30    /// Approval was rejected
31    Rejected,
32    /// Scheduled for auto-revocation
33    ScheduledRevocation,
34}
35
36impl std::fmt::Display for SuppressionStatus {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        match self {
39            SuppressionStatus::Active => write!(f, "active"),
40            SuppressionStatus::Expired => write!(f, "expired"),
41            SuppressionStatus::Revoked => write!(f, "revoked"),
42            SuppressionStatus::Stale => write!(f, "stale"),
43            SuppressionStatus::PendingApproval => write!(f, "pending-approval"),
44            SuppressionStatus::Rejected => write!(f, "rejected"),
45            SuppressionStatus::ScheduledRevocation => write!(f, "scheduled-revocation"),
46        }
47    }
48}
49
50/// Approval status for a suppression
51#[derive(Debug, Clone, Serialize, Deserialize, Default)]
52pub struct ApprovalInfo {
53    /// Whether approval is required
54    pub required: bool,
55    /// List of required approvers (usernames or email patterns)
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub required_approvers: Vec<String>,
58    /// Approvals received
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub approvals: Vec<Approval>,
61    /// Minimum number of approvals required
62    #[serde(default = "default_min_approvals")]
63    pub min_approvals: usize,
64    /// Rejection info if rejected
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub rejection: Option<Rejection>,
67}
68
69fn default_min_approvals() -> usize {
70    1
71}
72
73/// An approval record
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct Approval {
76    /// Who approved
77    pub approver: String,
78    /// When approved (ISO 8601)
79    pub approved_at: String,
80    /// Optional comment
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub comment: Option<String>,
83}
84
85/// A rejection record
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct Rejection {
88    /// Who rejected
89    pub rejector: String,
90    /// When rejected (ISO 8601)
91    pub rejected_at: String,
92    /// Reason for rejection
93    pub reason: String,
94}
95
96impl ApprovalInfo {
97    /// Check if the suppression has sufficient approvals
98    pub fn is_approved(&self) -> bool {
99        if !self.required {
100            return true;
101        }
102        self.approvals.len() >= self.min_approvals
103    }
104
105    /// Check if it was rejected
106    pub fn is_rejected(&self) -> bool {
107        self.rejection.is_some()
108    }
109
110    /// Add an approval
111    pub fn add_approval(&mut self, approver: impl Into<String>, comment: Option<String>) {
112        self.approvals.push(Approval {
113            approver: approver.into(),
114            approved_at: chrono::Utc::now().to_rfc3339(),
115            comment,
116        });
117    }
118
119    /// Reject the suppression
120    pub fn reject(&mut self, rejector: impl Into<String>, reason: impl Into<String>) {
121        self.rejection = Some(Rejection {
122            rejector: rejector.into(),
123            rejected_at: chrono::Utc::now().to_rfc3339(),
124            reason: reason.into(),
125        });
126    }
127}
128
129/// Auto-revocation schedule
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct RevocationSchedule {
132    /// When the auto-revocation is scheduled (ISO 8601)
133    pub scheduled_at: String,
134    /// Reason for scheduled revocation
135    pub reason: String,
136    /// Who scheduled the revocation
137    pub scheduled_by: String,
138    /// Whether to notify before revocation
139    #[serde(default)]
140    pub notify_before: bool,
141    /// Days before revocation to send notification
142    #[serde(default = "default_notify_days")]
143    pub notify_days_before: u32,
144    /// Grace period after scheduled time before actual revocation (hours)
145    #[serde(default)]
146    pub grace_period_hours: u32,
147}
148
149fn default_notify_days() -> u32 {
150    7
151}
152
153impl RevocationSchedule {
154    /// Create a new revocation schedule
155    pub fn new(
156        scheduled_at: impl Into<String>,
157        reason: impl Into<String>,
158        scheduled_by: impl Into<String>,
159    ) -> Self {
160        Self {
161            scheduled_at: scheduled_at.into(),
162            reason: reason.into(),
163            scheduled_by: scheduled_by.into(),
164            notify_before: true,
165            notify_days_before: 7,
166            grace_period_hours: 24,
167        }
168    }
169
170    /// Check if the scheduled time has passed
171    pub fn is_due(&self) -> bool {
172        if let Ok(scheduled) = chrono::DateTime::parse_from_rfc3339(&self.scheduled_at) {
173            let grace = chrono::Duration::hours(self.grace_period_hours as i64);
174            return chrono::Utc::now() >= scheduled + grace;
175        }
176        false
177    }
178
179    /// Check if notification should be sent
180    pub fn should_notify(&self) -> bool {
181        if !self.notify_before {
182            return false;
183        }
184        if let Ok(scheduled) = chrono::DateTime::parse_from_rfc3339(&self.scheduled_at) {
185            let notify_time = scheduled - chrono::Duration::days(self.notify_days_before as i64);
186            let now = chrono::Utc::now();
187            return now >= notify_time && now < scheduled;
188        }
189        false
190    }
191}
192
193/// A suppression entry representing a finding that should be ignored
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct SuppressionEntry {
196    /// Unique identifier for this suppression (UUID)
197    pub id: String,
198
199    /// SHA256 fingerprint of the finding being suppressed
200    pub fingerprint: String,
201
202    /// Rule ID that generated the finding (e.g., "generic/hardcoded-secret")
203    pub rule_id: String,
204
205    /// Path to the file containing the suppressed finding
206    pub file_path: PathBuf,
207
208    /// Hash of the code snippet for staleness detection (security: no raw code stored)
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub snippet_hash: Option<String>,
211
212    /// Hash of surrounding context for additional staleness detection
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub context_hash: Option<String>,
215
216    /// Who created this suppression
217    pub suppressed_by: String,
218
219    /// When this suppression was created (ISO 8601)
220    pub created_at: String,
221
222    /// When this suppression expires (ISO 8601, optional)
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub expires_at: Option<String>,
225
226    /// Reason for the suppression
227    pub reason: String,
228
229    /// Reference to a ticket/issue (e.g., "JIRA-456")
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub ticket_ref: Option<String>,
232
233    /// Current status of the suppression
234    #[serde(default)]
235    pub status: SuppressionStatus,
236
237    /// Original severity of the finding
238    #[serde(default)]
239    pub original_severity: Severity,
240
241    // ========== Enterprise Features ==========
242    /// Tags for categorization
243    #[serde(default, skip_serializing_if = "HashSet::is_empty")]
244    pub tags: HashSet<String>,
245
246    /// Groups this suppression belongs to
247    #[serde(default, skip_serializing_if = "HashSet::is_empty")]
248    pub groups: HashSet<String>,
249
250    /// Approval workflow info
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub approval: Option<ApprovalInfo>,
253
254    /// Scheduled auto-revocation
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub scheduled_revocation: Option<RevocationSchedule>,
257
258    /// Policy that created this suppression (if from policy)
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub policy_id: Option<String>,
261
262    /// Priority level (1-5, 1 being highest)
263    #[serde(default = "default_priority")]
264    pub priority: u8,
265
266    /// Additional metadata
267    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
268    pub metadata: std::collections::HashMap<String, String>,
269
270    /// Version number for optimistic locking
271    #[serde(default)]
272    pub version: u32,
273
274    /// Last modified timestamp
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub updated_at: Option<String>,
277
278    /// Last modified by
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub updated_by: Option<String>,
281}
282
283fn default_priority() -> u8 {
284    3
285}
286
287impl SuppressionEntry {
288    /// Create a new active suppression entry
289    pub fn new(
290        fingerprint: impl Into<String>,
291        rule_id: impl Into<String>,
292        file_path: impl Into<PathBuf>,
293        suppressed_by: impl Into<String>,
294        reason: impl Into<String>,
295    ) -> Self {
296        Self {
297            id: uuid_v4(),
298            fingerprint: fingerprint.into(),
299            rule_id: rule_id.into(),
300            file_path: file_path.into(),
301            snippet_hash: None,
302            context_hash: None,
303            suppressed_by: suppressed_by.into(),
304            created_at: chrono::Utc::now().to_rfc3339(),
305            expires_at: None,
306            reason: reason.into(),
307            ticket_ref: None,
308            status: SuppressionStatus::Active,
309            original_severity: Severity::Warning,
310            tags: HashSet::new(),
311            groups: HashSet::new(),
312            approval: None,
313            scheduled_revocation: None,
314            policy_id: None,
315            priority: 3,
316            metadata: std::collections::HashMap::new(),
317            version: 1,
318            updated_at: None,
319            updated_by: None,
320        }
321    }
322
323    /// Set the snippet hash for staleness detection (from raw snippet)
324    pub fn with_snippet(mut self, snippet: impl AsRef<str>) -> Self {
325        self.snippet_hash = Some(hash_snippet(snippet.as_ref()));
326        self
327    }
328
329    /// Set the snippet hash directly
330    pub fn with_snippet_hash(mut self, hash: impl Into<String>) -> Self {
331        self.snippet_hash = Some(hash.into());
332        self
333    }
334
335    /// Set the context hash for additional staleness detection
336    pub fn with_context_hash(mut self, hash: impl Into<String>) -> Self {
337        self.context_hash = Some(hash.into());
338        self
339    }
340
341    /// Set an expiration date
342    pub fn with_expiration(mut self, expires_at: impl Into<String>) -> Self {
343        self.expires_at = Some(expires_at.into());
344        self
345    }
346
347    /// Set expiration from a duration string (e.g., "90d", "30d", "7d")
348    pub fn with_expiration_days(mut self, days: u32) -> Self {
349        let expires = chrono::Utc::now() + chrono::Duration::days(days as i64);
350        self.expires_at = Some(expires.to_rfc3339());
351        self
352    }
353
354    /// Set a ticket reference
355    pub fn with_ticket(mut self, ticket: impl Into<String>) -> Self {
356        self.ticket_ref = Some(ticket.into());
357        self
358    }
359
360    /// Set the original severity
361    pub fn with_severity(mut self, severity: Severity) -> Self {
362        self.original_severity = severity;
363        self
364    }
365
366    /// Add a tag
367    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
368        self.tags.insert(tag.into());
369        self
370    }
371
372    /// Add multiple tags
373    pub fn with_tags(mut self, tags: impl IntoIterator<Item = impl Into<String>>) -> Self {
374        for tag in tags {
375            self.tags.insert(tag.into());
376        }
377        self
378    }
379
380    /// Add to a group
381    pub fn with_group(mut self, group: impl Into<String>) -> Self {
382        self.groups.insert(group.into());
383        self
384    }
385
386    /// Set priority (1-5)
387    pub fn with_priority(mut self, priority: u8) -> Self {
388        self.priority = priority.clamp(1, 5);
389        self
390    }
391
392    /// Set policy ID
393    pub fn with_policy(mut self, policy_id: impl Into<String>) -> Self {
394        self.policy_id = Some(policy_id.into());
395        self
396    }
397
398    /// Require approval
399    pub fn require_approval(mut self, min_approvals: usize) -> Self {
400        self.approval = Some(ApprovalInfo {
401            required: true,
402            required_approvers: Vec::new(),
403            approvals: Vec::new(),
404            min_approvals,
405            rejection: None,
406        });
407        self.status = SuppressionStatus::PendingApproval;
408        self
409    }
410
411    /// Require approval from specific approvers
412    pub fn require_approval_from(
413        mut self,
414        approvers: impl IntoIterator<Item = impl Into<String>>,
415    ) -> Self {
416        self.approval = Some(ApprovalInfo {
417            required: true,
418            required_approvers: approvers.into_iter().map(|a| a.into()).collect(),
419            approvals: Vec::new(),
420            min_approvals: 1,
421            rejection: None,
422        });
423        self.status = SuppressionStatus::PendingApproval;
424        self
425    }
426
427    /// Schedule auto-revocation
428    pub fn schedule_revocation(
429        mut self,
430        scheduled_at: impl Into<String>,
431        reason: impl Into<String>,
432        scheduled_by: impl Into<String>,
433    ) -> Self {
434        self.scheduled_revocation =
435            Some(RevocationSchedule::new(scheduled_at, reason, scheduled_by));
436        self
437    }
438
439    /// Schedule auto-revocation in N days
440    pub fn schedule_revocation_days(
441        mut self,
442        days: u32,
443        reason: impl Into<String>,
444        scheduled_by: impl Into<String>,
445    ) -> Self {
446        let scheduled = chrono::Utc::now() + chrono::Duration::days(days as i64);
447        self.scheduled_revocation = Some(RevocationSchedule::new(
448            scheduled.to_rfc3339(),
449            reason,
450            scheduled_by,
451        ));
452        self
453    }
454
455    /// Add metadata
456    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
457        self.metadata.insert(key.into(), value.into());
458        self
459    }
460
461    /// Check if the suppression has expired
462    pub fn is_expired(&self) -> bool {
463        if let Some(ref expires_at) = self.expires_at
464            && let Ok(expiry) = chrono::DateTime::parse_from_rfc3339(expires_at)
465        {
466            return expiry < chrono::Utc::now();
467        }
468        false
469    }
470
471    /// Check if the suppression is active (not expired, revoked, or stale)
472    pub fn is_active(&self) -> bool {
473        matches!(self.status, SuppressionStatus::Active) && !self.is_expired()
474    }
475
476    /// Check if approval is pending
477    pub fn is_pending_approval(&self) -> bool {
478        self.status == SuppressionStatus::PendingApproval
479    }
480
481    /// Check if the suppression has been approved
482    pub fn is_approved(&self) -> bool {
483        self.approval
484            .as_ref()
485            .map(|a| a.is_approved())
486            .unwrap_or(true)
487    }
488
489    /// Approve the suppression
490    pub fn approve(&mut self, approver: impl Into<String>, comment: Option<String>) {
491        if let Some(ref mut approval) = self.approval {
492            approval.add_approval(approver, comment);
493            if approval.is_approved() {
494                self.status = SuppressionStatus::Active;
495            }
496        }
497        self.touch("approver");
498    }
499
500    /// Reject the suppression
501    pub fn reject(&mut self, rejector: impl Into<String>, reason: impl Into<String>) {
502        if let Some(ref mut approval) = self.approval {
503            approval.reject(rejector.into(), reason);
504        }
505        self.status = SuppressionStatus::Rejected;
506        self.touch("rejector");
507    }
508
509    /// Mark the suppression as revoked
510    pub fn revoke(&mut self) {
511        self.status = SuppressionStatus::Revoked;
512    }
513
514    /// Mark the suppression as stale
515    pub fn mark_stale(&mut self) {
516        self.status = SuppressionStatus::Stale;
517    }
518
519    /// Reactivate the suppression
520    pub fn reactivate(&mut self, actor: impl Into<String>) {
521        self.status = SuppressionStatus::Active;
522        self.touch(actor);
523    }
524
525    /// Schedule auto-revocation (mutable version)
526    pub fn set_scheduled_revocation(
527        &mut self,
528        scheduled_at: impl Into<String>,
529        reason: impl Into<String>,
530        scheduled_by: impl Into<String>,
531    ) {
532        self.scheduled_revocation = Some(RevocationSchedule::new(
533            scheduled_at,
534            reason,
535            scheduled_by.into(),
536        ));
537        self.status = SuppressionStatus::ScheduledRevocation;
538    }
539
540    /// Cancel scheduled revocation
541    pub fn cancel_scheduled_revocation(&mut self) {
542        self.scheduled_revocation = None;
543        if self.status == SuppressionStatus::ScheduledRevocation {
544            self.status = SuppressionStatus::Active;
545        }
546    }
547
548    /// Add an approval (convenience method for mutable operations)
549    pub fn add_approval(&mut self, approver: impl Into<String>, comment: Option<&str>) {
550        if let Some(ref mut approval) = self.approval {
551            approval.add_approval(approver, comment.map(|s| s.to_string()));
552            if approval.is_approved() {
553                self.status = SuppressionStatus::Active;
554            }
555        }
556        self.touch("approver");
557    }
558
559    /// Update the modification timestamp
560    fn touch(&mut self, actor: impl Into<String>) {
561        self.updated_at = Some(chrono::Utc::now().to_rfc3339());
562        self.updated_by = Some(actor.into());
563        self.version += 1;
564    }
565
566    /// Check if the code has changed (staleness detection)
567    pub fn is_stale(&self, current_snippet: Option<&str>) -> bool {
568        match (&self.snippet_hash, current_snippet) {
569            (Some(original_hash), Some(current)) => {
570                let current_hash = hash_snippet(current);
571                original_hash != &current_hash
572            }
573            (Some(_), None) => true,
574            (None, _) => false,
575        }
576    }
577
578    /// Check if the code has changed using a pre-computed hash
579    pub fn is_stale_by_hash(&self, current_snippet_hash: Option<&str>) -> bool {
580        match (&self.snippet_hash, current_snippet_hash) {
581            (Some(original), Some(current)) => original != current,
582            (Some(_), None) => true,
583            (None, _) => false,
584        }
585    }
586
587    /// Check if scheduled revocation is due
588    pub fn is_revocation_due(&self) -> bool {
589        self.scheduled_revocation
590            .as_ref()
591            .map(|s| s.is_due())
592            .unwrap_or(false)
593    }
594
595    /// Get a human-readable description of time until expiration
596    pub fn time_until_expiry(&self) -> Option<String> {
597        if let Some(ref expires_at) = self.expires_at
598            && let Ok(expiry) = chrono::DateTime::parse_from_rfc3339(expires_at)
599        {
600            let now = chrono::Utc::now();
601            if expiry < now {
602                return Some("expired".to_string());
603            }
604            let duration = expiry.signed_duration_since(now);
605            let days = duration.num_days();
606            if days > 0 {
607                return Some(format!("{}d", days));
608            }
609            let hours = duration.num_hours();
610            if hours > 0 {
611                return Some(format!("{}h", hours));
612            }
613            return Some("< 1h".to_string());
614        }
615        None
616    }
617
618    /// Check if this suppression has a specific tag
619    pub fn has_tag(&self, tag: &str) -> bool {
620        self.tags.contains(tag)
621    }
622
623    /// Check if this suppression is in a specific group
624    pub fn in_group(&self, group: &str) -> bool {
625        self.groups.contains(group)
626    }
627
628    /// Get all tags as a sorted vector
629    pub fn tags_sorted(&self) -> Vec<&str> {
630        let mut tags: Vec<_> = self.tags.iter().map(|s| s.as_str()).collect();
631        tags.sort();
632        tags
633    }
634
635    /// Get all groups as a sorted vector
636    pub fn groups_sorted(&self) -> Vec<&str> {
637        let mut groups: Vec<_> = self.groups.iter().map(|s| s.as_str()).collect();
638        groups.sort();
639        groups
640    }
641}
642
643/// Generate a simple UUID v4 (random)
644fn uuid_v4() -> String {
645    use std::time::{SystemTime, UNIX_EPOCH};
646
647    let timestamp = SystemTime::now()
648        .duration_since(UNIX_EPOCH)
649        .unwrap_or_default()
650        .as_nanos();
651
652    let mut hasher = std::collections::hash_map::DefaultHasher::new();
653    std::hash::Hash::hash(&timestamp, &mut hasher);
654    std::hash::Hash::hash(&std::process::id(), &mut hasher);
655    let random = std::hash::Hasher::finish(&hasher);
656
657    format!(
658        "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
659        (timestamp & 0xFFFFFFFF) as u32,
660        ((timestamp >> 32) & 0xFFFF) as u16,
661        (random & 0xFFF) as u16,
662        ((random >> 12) & 0x3FFF | 0x8000) as u16,
663        (random >> 26) & 0xFFFFFFFFFFFF
664    )
665}
666
667/// Hash a code snippet for secure storage (no raw code in DB)
668pub fn hash_snippet(snippet: &str) -> String {
669    use sha2::{Digest, Sha256};
670
671    // Normalize whitespace before hashing
672    let normalized: String = snippet.split_whitespace().collect::<Vec<_>>().join(" ");
673
674    let mut hasher = Sha256::new();
675    hasher.update(normalized.as_bytes());
676    let result = hasher.finalize();
677
678    format!("{:x}", result)[..16].to_string()
679}
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684
685    #[test]
686    fn test_new_suppression() {
687        let entry = SuppressionEntry::new(
688            "sha256:abc123",
689            "generic/hardcoded-secret",
690            "src/test.rs",
691            "admin",
692            "Test fixture",
693        );
694
695        assert!(!entry.id.is_empty());
696        assert_eq!(entry.fingerprint, "sha256:abc123");
697        assert_eq!(entry.rule_id, "generic/hardcoded-secret");
698        assert_eq!(entry.file_path, PathBuf::from("src/test.rs"));
699        assert_eq!(entry.suppressed_by, "admin");
700        assert_eq!(entry.reason, "Test fixture");
701        assert_eq!(entry.status, SuppressionStatus::Active);
702        assert!(entry.is_active());
703        assert!(entry.snippet_hash.is_none());
704    }
705
706    #[test]
707    fn test_expiration() {
708        let entry = SuppressionEntry::new("sha256:abc123", "rule", "file.rs", "user", "reason")
709            .with_expiration_days(30);
710
711        assert!(!entry.is_expired());
712        assert!(entry.is_active());
713        assert!(entry.expires_at.is_some());
714        assert!(entry.time_until_expiry().is_some());
715    }
716
717    #[test]
718    fn test_staleness_detection() {
719        let entry = SuppressionEntry::new("sha256:abc123", "rule", "file.rs", "user", "reason")
720            .with_snippet("let password = \"secret\";");
721
722        assert!(entry.snippet_hash.is_some());
723        assert!(!entry.is_stale(Some("let password = \"secret\";")));
724        assert!(!entry.is_stale(Some("let  password  =  \"secret\";")));
725        assert!(entry.is_stale(Some("let password = \"different\";")));
726        assert!(entry.is_stale(None));
727    }
728
729    #[test]
730    fn test_hash_snippet() {
731        let hash1 = hash_snippet("let x = 1;");
732        let hash2 = hash_snippet("let x = 1;");
733        let hash3 = hash_snippet("let  x  =  1;");
734        let hash4 = hash_snippet("let y = 2;");
735
736        assert_eq!(hash1, hash2);
737        assert_eq!(hash1, hash3);
738        assert_ne!(hash1, hash4);
739    }
740
741    #[test]
742    fn test_revoke() {
743        let mut entry = SuppressionEntry::new("sha256:abc123", "rule", "file.rs", "user", "reason");
744
745        assert!(entry.is_active());
746        entry.revoke();
747        assert!(!entry.is_active());
748        assert_eq!(entry.status, SuppressionStatus::Revoked);
749    }
750
751    #[test]
752    fn test_tags_and_groups() {
753        let entry = SuppressionEntry::new("sha256:abc123", "rule", "file.rs", "user", "reason")
754            .with_tag("security")
755            .with_tag("false-positive")
756            .with_group("team-backend");
757
758        assert!(entry.has_tag("security"));
759        assert!(entry.has_tag("false-positive"));
760        assert!(!entry.has_tag("other"));
761        assert!(entry.in_group("team-backend"));
762        assert!(!entry.in_group("team-frontend"));
763    }
764
765    #[test]
766    fn test_approval_workflow() {
767        let mut entry = SuppressionEntry::new("sha256:abc123", "rule", "file.rs", "user", "reason")
768            .require_approval(2);
769
770        assert!(entry.is_pending_approval());
771        assert!(!entry.is_approved());
772
773        entry.approve("approver1", Some("Looks good".to_string()));
774        assert!(entry.is_pending_approval()); // Still needs 1 more
775
776        entry.approve("approver2", None);
777        assert!(entry.is_approved());
778        assert!(entry.is_active());
779    }
780
781    #[test]
782    fn test_approval_rejection() {
783        let mut entry = SuppressionEntry::new("sha256:abc123", "rule", "file.rs", "user", "reason")
784            .require_approval(1);
785
786        entry.reject("security-team", "This is a real vulnerability");
787        assert_eq!(entry.status, SuppressionStatus::Rejected);
788        assert!(!entry.is_active());
789    }
790
791    #[test]
792    fn test_scheduled_revocation() {
793        let entry = SuppressionEntry::new("sha256:abc123", "rule", "file.rs", "user", "reason")
794            .schedule_revocation_days(30, "Temporary suppression", "admin");
795
796        assert!(entry.scheduled_revocation.is_some());
797        assert!(!entry.is_revocation_due());
798    }
799
800    #[test]
801    fn test_priority() {
802        let entry = SuppressionEntry::new("sha256:abc123", "rule", "file.rs", "user", "reason")
803            .with_priority(1);
804
805        assert_eq!(entry.priority, 1);
806
807        let entry2 = entry.with_priority(10); // Should clamp to 5
808        assert_eq!(entry2.priority, 5);
809    }
810
811    #[test]
812    fn test_metadata() {
813        let entry = SuppressionEntry::new("sha256:abc123", "rule", "file.rs", "user", "reason")
814            .with_metadata("custom_field", "custom_value")
815            .with_metadata("another", "value");
816
817        assert_eq!(
818            entry.metadata.get("custom_field"),
819            Some(&"custom_value".to_string())
820        );
821        assert_eq!(entry.metadata.get("another"), Some(&"value".to_string()));
822    }
823}