Skip to main content

tensor_vault/
dependency.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Secret-to-secret dependency tracking via graph edges.
3
4use std::collections::{HashSet, VecDeque};
5
6use graph_engine::{Direction, GraphEngine, PropertyValue};
7use serde::{Deserialize, Serialize};
8
9use crate::{Result, VaultError};
10
11/// Edge type for secret dependencies.
12const DEPENDS_ON_EDGE: &str = "SECRET_DEPENDS_ON";
13
14/// Report of secrets and agents affected by a change to a root secret.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ImpactReport {
17    /// The root secret that was analyzed.
18    pub root_secret: String,
19    /// All secrets transitively depending on the root.
20    pub affected_secrets: Vec<String>,
21    /// Agents with access to any affected secret.
22    pub affected_agents: Vec<String>,
23    /// Maximum depth of the dependency chain.
24    pub depth: usize,
25}
26
27/// Information about a single dependency edge.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct DependencyInfo {
30    /// The parent secret (depended upon).
31    pub parent: String,
32    /// The child secret (depends on parent).
33    pub child: String,
34    /// When the dependency was created (unix millis).
35    pub created_at_ms: i64,
36}
37
38/// Weight classification for dependency edges.
39#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
40pub enum DependencyWeight {
41    /// The dependency is critical -- failure cascades immediately.
42    Critical,
43    /// High-severity dependency that should be rotated promptly.
44    High,
45    /// Medium-severity dependency with moderate impact.
46    Medium,
47    /// Low-severity dependency with minimal impact.
48    Low,
49}
50
51impl DependencyWeight {
52    /// Numeric value for scoring.
53    #[must_use]
54    pub fn value(self) -> f64 {
55        match self {
56            Self::Critical => 1.0,
57            Self::High => 0.7,
58            Self::Medium => 0.4,
59            Self::Low => 0.1,
60        }
61    }
62
63    /// Parse from float stored in graph property.
64    fn from_float(v: f64) -> Self {
65        if v >= 0.9 {
66            Self::Critical
67        } else if v >= 0.6 {
68            Self::High
69        } else if v >= 0.3 {
70            Self::Medium
71        } else {
72            Self::Low
73        }
74    }
75}
76
77/// A downstream secret with weight and impact score.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct WeightedAffectedSecret {
80    /// The secret key affected by the root change.
81    pub secret: String,
82    /// How many edges from the root to this secret.
83    pub depth: usize,
84    /// Weight classification of the inbound dependency edge.
85    pub edge_weight: DependencyWeight,
86    /// Computed impact score (weight / depth).
87    pub impact_score: f64,
88}
89
90/// Weighted impact analysis report.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct WeightedImpactReport {
93    /// The root secret whose change was analyzed.
94    pub root_secret: String,
95    /// All downstream secrets with weight and impact metadata.
96    pub affected_secrets: Vec<WeightedAffectedSecret>,
97    /// Agents that have access to any affected secret.
98    pub affected_agents: Vec<String>,
99    /// Maximum depth reached in the dependency graph.
100    pub max_depth: usize,
101    /// Sum of all individual impact scores.
102    pub total_impact_score: f64,
103}
104
105/// A step in a prioritized rotation plan.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct RotationStep {
108    /// The secret key to rotate at this step.
109    pub secret: String,
110    /// Depth of this secret in the dependency chain.
111    pub depth: usize,
112    /// Priority score (higher means rotate sooner).
113    pub priority: f64,
114    /// Weight classification of the dependency edge.
115    pub weight: DependencyWeight,
116}
117
118/// Prioritized rotation plan for cascading secret changes.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct RotationPlan {
121    /// The root secret that triggered the rotation cascade.
122    pub root_secret: String,
123    /// Ordered list of rotation steps, highest priority first.
124    pub rotation_order: Vec<RotationStep>,
125    /// Total number of secrets requiring rotation.
126    pub total_secrets: usize,
127}
128
129/// Check for cycles before adding a dependency edge.
130fn would_create_cycle(graph: &GraphEngine, parent_node: u64, child_node: u64) -> bool {
131    // If adding child -> parent edge for DEPENDS_ON, check if parent can reach child
132    // (which would create a cycle)
133    if parent_node == child_node {
134        return true;
135    }
136
137    // BFS from child to see if we can reach parent via DEPENDS_ON edges
138    let mut visited = HashSet::new();
139    let mut queue = VecDeque::new();
140    queue.push_back(child_node);
141    visited.insert(child_node);
142
143    while let Some(current) = queue.pop_front() {
144        if let Ok(edges) = graph.edges_of(current, Direction::Outgoing) {
145            for edge in edges {
146                if edge.edge_type != DEPENDS_ON_EDGE {
147                    continue;
148                }
149                let target = if edge.from == current {
150                    edge.to
151                } else {
152                    edge.from
153                };
154                if target == parent_node {
155                    return true;
156                }
157                if visited.insert(target) {
158                    queue.push_back(target);
159                }
160            }
161        }
162    }
163
164    false
165}
166
167/// Add a dependency: child depends on parent.
168///
169/// # Errors
170///
171/// Returns `VaultError::CyclicDependency` if the new edge would create a cycle,
172/// or `VaultError::GraphError` if the underlying graph operation fails.
173pub fn add_dependency(
174    graph: &GraphEngine,
175    parent_node_key: &str,
176    child_node_key: &str,
177    timestamp: i64,
178) -> Result<()> {
179    let parent_id = find_or_create_dep_node(graph, parent_node_key);
180    let child_id = find_or_create_dep_node(graph, child_node_key);
181
182    if would_create_cycle(graph, parent_id, child_id) {
183        return Err(VaultError::CyclicDependency(format!(
184            "adding dependency from '{child_node_key}' on '{parent_node_key}' would create a cycle"
185        )));
186    }
187
188    let mut props = std::collections::HashMap::new();
189    props.insert("created_at_ms".to_string(), PropertyValue::Int(timestamp));
190
191    graph
192        .create_edge(parent_id, child_id, DEPENDS_ON_EDGE, props, true)
193        .map_err(|e| VaultError::GraphError(e.to_string()))?;
194
195    Ok(())
196}
197
198/// Remove a dependency edge between parent and child.
199///
200/// # Errors
201///
202/// Returns `VaultError::GraphError` if deleting the edge fails.
203pub fn remove_dependency(
204    graph: &GraphEngine,
205    parent_node_key: &str,
206    child_node_key: &str,
207) -> Result<()> {
208    let Some(parent_id) = find_dep_node(graph, parent_node_key) else {
209        return Ok(());
210    };
211    let Some(child_id) = find_dep_node(graph, child_node_key) else {
212        return Ok(());
213    };
214
215    if let Ok(edges) = graph.edges_of(parent_id, Direction::Outgoing) {
216        for edge in edges {
217            if edge.edge_type == DEPENDS_ON_EDGE && edge.to == child_id {
218                graph
219                    .delete_edge(edge.id)
220                    .map_err(|e| VaultError::GraphError(e.to_string()))?;
221            }
222        }
223    }
224
225    Ok(())
226}
227
228/// Get direct children (secrets that depend on this one).
229pub fn get_dependencies(graph: &GraphEngine, node_key: &str) -> Vec<String> {
230    let Some(node_id) = find_dep_node(graph, node_key) else {
231        return Vec::new();
232    };
233
234    let mut children = Vec::new();
235    if let Ok(edges) = graph.edges_of(node_id, Direction::Outgoing) {
236        for edge in edges {
237            if edge.edge_type != DEPENDS_ON_EDGE {
238                continue;
239            }
240            let target = if edge.from == node_id {
241                edge.to
242            } else {
243                edge.from
244            };
245            if let Some(key) = node_entity_key(graph, target) {
246                children.push(key);
247            }
248        }
249    }
250
251    children
252}
253
254/// Get direct parents (secrets this one depends on).
255pub fn get_dependents(graph: &GraphEngine, node_key: &str) -> Vec<String> {
256    let Some(node_id) = find_dep_node(graph, node_key) else {
257        return Vec::new();
258    };
259
260    let mut parents = Vec::new();
261    if let Ok(edges) = graph.edges_of(node_id, Direction::Incoming) {
262        for edge in edges {
263            if edge.edge_type != DEPENDS_ON_EDGE {
264                continue;
265            }
266            let source = if edge.to == node_id {
267                edge.from
268            } else {
269                edge.to
270            };
271            if let Some(key) = node_entity_key(graph, source) {
272                parents.push(key);
273            }
274        }
275    }
276
277    parents
278}
279
280/// Transitive BFS to find all affected secrets downstream.
281pub fn impact_analysis(graph: &GraphEngine, node_key: &str) -> ImpactReport {
282    let mut affected = Vec::new();
283    let mut max_depth = 0;
284
285    let Some(start_id) = find_dep_node(graph, node_key) else {
286        return ImpactReport {
287            root_secret: node_key.to_string(),
288            affected_secrets: Vec::new(),
289            affected_agents: Vec::new(),
290            depth: 0,
291        };
292    };
293
294    let mut visited = HashSet::new();
295    visited.insert(start_id);
296    let mut queue: VecDeque<(u64, usize)> = VecDeque::new();
297    queue.push_back((start_id, 0));
298
299    while let Some((current, depth)) = queue.pop_front() {
300        if let Ok(edges) = graph.edges_of(current, Direction::Outgoing) {
301            for edge in edges {
302                if edge.edge_type != DEPENDS_ON_EDGE {
303                    continue;
304                }
305                let target = if edge.from == current {
306                    edge.to
307                } else {
308                    edge.from
309                };
310                if visited.insert(target) {
311                    let new_depth = depth + 1;
312                    if new_depth > max_depth {
313                        max_depth = new_depth;
314                    }
315                    if let Some(key) = node_entity_key(graph, target) {
316                        affected.push(key);
317                    }
318                    queue.push_back((target, new_depth));
319                }
320            }
321        }
322    }
323
324    // Collect agents with access to affected secrets (look for VAULT_ACCESS edges)
325    let mut agents = HashSet::new();
326    for node_id in &visited {
327        if let Ok(edges) = graph.edges_of(*node_id, Direction::Incoming) {
328            for edge in edges {
329                if edge.edge_type.starts_with("VAULT_ACCESS") {
330                    let source = if edge.to == *node_id {
331                        edge.from
332                    } else {
333                        edge.to
334                    };
335                    if let Some(key) = node_entity_key(graph, source) {
336                        if !key.starts_with("vault_secret:") {
337                            agents.insert(key);
338                        }
339                    }
340                }
341            }
342        }
343    }
344
345    ImpactReport {
346        root_secret: node_key.to_string(),
347        affected_secrets: affected,
348        affected_agents: agents.into_iter().collect(),
349        depth: max_depth,
350    }
351}
352
353/// Add a weighted dependency: child depends on parent with a given weight.
354///
355/// # Errors
356///
357/// Returns `VaultError::CyclicDependency` if the new edge would create a cycle,
358/// or `VaultError::GraphError` if the underlying graph operation fails.
359pub fn add_weighted_dependency(
360    graph: &GraphEngine,
361    parent_key: &str,
362    child_key: &str,
363    weight: DependencyWeight,
364    description: Option<&str>,
365    timestamp: i64,
366) -> Result<()> {
367    let parent_id = find_or_create_dep_node(graph, parent_key);
368    let child_id = find_or_create_dep_node(graph, child_key);
369
370    if would_create_cycle(graph, parent_id, child_id) {
371        return Err(VaultError::CyclicDependency(format!(
372            "adding dependency from '{child_key}' on '{parent_key}' would create a cycle"
373        )));
374    }
375
376    let mut props = std::collections::HashMap::new();
377    props.insert("created_at_ms".to_string(), PropertyValue::Int(timestamp));
378    props.insert(
379        "dep_weight".to_string(),
380        PropertyValue::Float(weight.value()),
381    );
382    if let Some(desc) = description {
383        props.insert(
384            "dep_desc".to_string(),
385            PropertyValue::String(desc.to_string()),
386        );
387    }
388
389    graph
390        .create_edge(parent_id, child_id, DEPENDS_ON_EDGE, props, true)
391        .map_err(|e| VaultError::GraphError(e.to_string()))?;
392
393    Ok(())
394}
395
396/// Transitive BFS with weights to find all affected secrets downstream.
397#[must_use]
398pub fn weighted_impact_analysis(graph: &GraphEngine, node_key: &str) -> WeightedImpactReport {
399    let Some(start_id) = find_dep_node(graph, node_key) else {
400        return WeightedImpactReport {
401            root_secret: node_key.to_string(),
402            affected_secrets: Vec::new(),
403            affected_agents: Vec::new(),
404            max_depth: 0,
405            total_impact_score: 0.0,
406        };
407    };
408
409    let mut affected = Vec::new();
410    let mut max_depth = 0;
411    let mut total_impact = 0.0;
412
413    let mut visited = HashSet::new();
414    visited.insert(start_id);
415    let mut queue: VecDeque<(u64, usize)> = VecDeque::new();
416    queue.push_back((start_id, 0));
417
418    while let Some((current, depth)) = queue.pop_front() {
419        if let Ok(edges) = graph.edges_of(current, Direction::Outgoing) {
420            for edge in edges {
421                if edge.edge_type != DEPENDS_ON_EDGE {
422                    continue;
423                }
424                let target = if edge.from == current {
425                    edge.to
426                } else {
427                    edge.from
428                };
429                if visited.insert(target) {
430                    let new_depth = depth + 1;
431                    if new_depth > max_depth {
432                        max_depth = new_depth;
433                    }
434
435                    let weight = edge
436                        .properties
437                        .get("dep_weight")
438                        .and_then(|p| match p {
439                            PropertyValue::Float(f) => Some(DependencyWeight::from_float(*f)),
440                            _ => None,
441                        })
442                        .unwrap_or(DependencyWeight::Medium);
443
444                    #[allow(clippy::cast_precision_loss)] // depth will never exceed 2^52
445                    let impact_score = weight.value() / new_depth as f64;
446                    total_impact += impact_score;
447
448                    if let Some(key) = node_entity_key(graph, target) {
449                        affected.push(WeightedAffectedSecret {
450                            secret: key,
451                            depth: new_depth,
452                            edge_weight: weight,
453                            impact_score,
454                        });
455                    }
456                    queue.push_back((target, new_depth));
457                }
458            }
459        }
460    }
461
462    // Collect affected agents
463    let mut agents = HashSet::new();
464    for node_id in &visited {
465        if let Ok(edges) = graph.edges_of(*node_id, Direction::Incoming) {
466            for edge in edges {
467                if edge.edge_type.starts_with("VAULT_ACCESS") {
468                    let source = if edge.to == *node_id {
469                        edge.from
470                    } else {
471                        edge.to
472                    };
473                    if let Some(key) = node_entity_key(graph, source) {
474                        if !key.starts_with("vault_secret:") {
475                            agents.insert(key);
476                        }
477                    }
478                }
479            }
480        }
481    }
482
483    WeightedImpactReport {
484        root_secret: node_key.to_string(),
485        affected_secrets: affected,
486        affected_agents: agents.into_iter().collect(),
487        max_depth,
488        total_impact_score: total_impact,
489    }
490}
491
492/// Generate a prioritized rotation plan based on weighted dependencies.
493#[must_use]
494pub fn rotation_plan(graph: &GraphEngine, root_key: &str) -> RotationPlan {
495    let report = weighted_impact_analysis(graph, root_key);
496
497    let mut steps: Vec<RotationStep> = report
498        .affected_secrets
499        .into_iter()
500        .map(|s| RotationStep {
501            secret: s.secret,
502            depth: s.depth,
503            priority: s.impact_score,
504            weight: s.edge_weight,
505        })
506        .collect();
507
508    // Sort by priority descending (highest impact first)
509    steps.sort_by(|a, b| {
510        b.priority
511            .partial_cmp(&a.priority)
512            .unwrap_or(std::cmp::Ordering::Equal)
513    });
514
515    let total = steps.len();
516    RotationPlan {
517        root_secret: root_key.to_string(),
518        rotation_order: steps,
519        total_secrets: total,
520    }
521}
522
523fn find_dep_node(graph: &GraphEngine, key: &str) -> Option<u64> {
524    graph
525        .find_nodes_by_property("entity_key", &PropertyValue::String(key.to_string()))
526        .ok()
527        .and_then(|nodes| nodes.first().map(|n| n.id))
528}
529
530fn find_or_create_dep_node(graph: &GraphEngine, key: &str) -> u64 {
531    if let Some(id) = find_dep_node(graph, key) {
532        return id;
533    }
534
535    let mut props = std::collections::HashMap::new();
536    props.insert(
537        "entity_key".to_string(),
538        PropertyValue::String(key.to_string()),
539    );
540    graph.create_node("VaultEntity", props).unwrap_or(0)
541}
542
543fn node_entity_key(graph: &GraphEngine, node_id: u64) -> Option<String> {
544    graph.get_node(node_id).ok().and_then(|node| {
545        if let Some(PropertyValue::String(key)) = node.properties.get("entity_key") {
546            Some(key.clone())
547        } else {
548            None
549        }
550    })
551}
552
553#[cfg(test)]
554mod tests {
555    use std::sync::Arc;
556
557    use super::*;
558
559    fn test_graph() -> Arc<GraphEngine> {
560        Arc::new(GraphEngine::new())
561    }
562
563    #[test]
564    fn test_add_and_get_dependencies() {
565        let graph = test_graph();
566        add_dependency(&graph, "secret:db_password", "secret:app_config", 1000).unwrap();
567
568        let children = get_dependencies(&graph, "secret:db_password");
569        assert_eq!(children, vec!["secret:app_config"]);
570
571        let parents = get_dependents(&graph, "secret:app_config");
572        assert_eq!(parents, vec!["secret:db_password"]);
573    }
574
575    #[test]
576    fn test_remove_dependency() {
577        let graph = test_graph();
578        add_dependency(&graph, "secret:a", "secret:b", 1000).unwrap();
579        assert_eq!(get_dependencies(&graph, "secret:a").len(), 1);
580
581        remove_dependency(&graph, "secret:a", "secret:b").unwrap();
582        assert!(get_dependencies(&graph, "secret:a").is_empty());
583    }
584
585    #[test]
586    fn test_cycle_detection_self_reference() {
587        let graph = test_graph();
588        let result = add_dependency(&graph, "secret:a", "secret:a", 1000);
589        assert!(matches!(result, Err(VaultError::CyclicDependency(_))));
590    }
591
592    #[test]
593    fn test_cycle_detection_two_node() {
594        let graph = test_graph();
595        add_dependency(&graph, "secret:a", "secret:b", 1000).unwrap();
596        let result = add_dependency(&graph, "secret:b", "secret:a", 2000);
597        assert!(matches!(result, Err(VaultError::CyclicDependency(_))));
598    }
599
600    #[test]
601    fn test_cycle_detection_three_node() {
602        let graph = test_graph();
603        add_dependency(&graph, "secret:a", "secret:b", 1000).unwrap();
604        add_dependency(&graph, "secret:b", "secret:c", 2000).unwrap();
605        let result = add_dependency(&graph, "secret:c", "secret:a", 3000);
606        assert!(matches!(result, Err(VaultError::CyclicDependency(_))));
607    }
608
609    #[test]
610    fn test_impact_analysis_single_level() {
611        let graph = test_graph();
612        add_dependency(&graph, "secret:root", "secret:child1", 1000).unwrap();
613        add_dependency(&graph, "secret:root", "secret:child2", 2000).unwrap();
614
615        let report = impact_analysis(&graph, "secret:root");
616        assert_eq!(report.root_secret, "secret:root");
617        assert_eq!(report.affected_secrets.len(), 2);
618        assert_eq!(report.depth, 1);
619    }
620
621    #[test]
622    fn test_impact_analysis_transitive() {
623        let graph = test_graph();
624        add_dependency(&graph, "secret:a", "secret:b", 1000).unwrap();
625        add_dependency(&graph, "secret:b", "secret:c", 2000).unwrap();
626        add_dependency(&graph, "secret:c", "secret:d", 3000).unwrap();
627
628        let report = impact_analysis(&graph, "secret:a");
629        assert_eq!(report.affected_secrets.len(), 3);
630        assert_eq!(report.depth, 3);
631    }
632
633    #[test]
634    fn test_impact_analysis_diamond() {
635        let graph = test_graph();
636        // A -> B, A -> C, B -> D, C -> D
637        add_dependency(&graph, "secret:a", "secret:b", 1000).unwrap();
638        add_dependency(&graph, "secret:a", "secret:c", 2000).unwrap();
639        add_dependency(&graph, "secret:b", "secret:d", 3000).unwrap();
640        add_dependency(&graph, "secret:c", "secret:d", 4000).unwrap();
641
642        let report = impact_analysis(&graph, "secret:a");
643        assert_eq!(report.affected_secrets.len(), 3); // b, c, d (each counted once)
644        assert_eq!(report.depth, 2);
645    }
646
647    #[test]
648    fn test_impact_analysis_no_deps() {
649        let graph = test_graph();
650        let report = impact_analysis(&graph, "secret:isolated");
651        assert!(report.affected_secrets.is_empty());
652        assert_eq!(report.depth, 0);
653    }
654
655    #[test]
656    fn test_multiple_children() {
657        let graph = test_graph();
658        add_dependency(&graph, "secret:parent", "secret:c1", 1000).unwrap();
659        add_dependency(&graph, "secret:parent", "secret:c2", 2000).unwrap();
660        add_dependency(&graph, "secret:parent", "secret:c3", 3000).unwrap();
661
662        let children = get_dependencies(&graph, "secret:parent");
663        assert_eq!(children.len(), 3);
664    }
665
666    #[test]
667    fn test_multiple_parents() {
668        let graph = test_graph();
669        add_dependency(&graph, "secret:p1", "secret:child", 1000).unwrap();
670        add_dependency(&graph, "secret:p2", "secret:child", 2000).unwrap();
671
672        let parents = get_dependents(&graph, "secret:child");
673        assert_eq!(parents.len(), 2);
674    }
675
676    #[test]
677    fn test_get_deps_nonexistent() {
678        let graph = test_graph();
679        let children = get_dependencies(&graph, "secret:nonexistent");
680        assert!(children.is_empty());
681    }
682
683    #[test]
684    fn test_remove_nonexistent_dependency() {
685        let graph = test_graph();
686        // Should not error
687        remove_dependency(&graph, "secret:x", "secret:y").unwrap();
688    }
689
690    #[test]
691    fn test_no_false_positive_cycles_for_siblings() {
692        let graph = test_graph();
693        // A -> B and A -> C should not prevent B -> D or C -> D
694        add_dependency(&graph, "secret:a", "secret:b", 1000).unwrap();
695        add_dependency(&graph, "secret:a", "secret:c", 2000).unwrap();
696        add_dependency(&graph, "secret:b", "secret:d", 3000).unwrap();
697        add_dependency(&graph, "secret:c", "secret:d", 4000).unwrap();
698    }
699
700    #[test]
701    fn test_dependency_info_structure() {
702        let info = DependencyInfo {
703            parent: "secret:parent".to_string(),
704            child: "secret:child".to_string(),
705            created_at_ms: 12345,
706        };
707        assert_eq!(info.parent, "secret:parent");
708        assert_eq!(info.child, "secret:child");
709        assert_eq!(info.created_at_ms, 12345);
710    }
711
712    #[test]
713    fn test_impact_report_serialization() {
714        let report = ImpactReport {
715            root_secret: "secret:root".to_string(),
716            affected_secrets: vec!["secret:a".to_string()],
717            affected_agents: vec!["user:alice".to_string()],
718            depth: 1,
719        };
720        let json = serde_json::to_string(&report).unwrap();
721        let deserialized: ImpactReport = serde_json::from_str(&json).unwrap();
722        assert_eq!(deserialized.root_secret, report.root_secret);
723    }
724
725    #[test]
726    fn test_add_weighted_dep() {
727        let graph = test_graph();
728        add_weighted_dependency(
729            &graph,
730            "secret:a",
731            "secret:b",
732            DependencyWeight::Critical,
733            Some("critical link"),
734            1000,
735        )
736        .unwrap();
737
738        let children = get_dependencies(&graph, "secret:a");
739        assert_eq!(children, vec!["secret:b"]);
740    }
741
742    #[test]
743    fn test_weighted_cycle_detection() {
744        let graph = test_graph();
745        add_weighted_dependency(
746            &graph,
747            "secret:a",
748            "secret:b",
749            DependencyWeight::High,
750            None,
751            1000,
752        )
753        .unwrap();
754        let result = add_weighted_dependency(
755            &graph,
756            "secret:b",
757            "secret:a",
758            DependencyWeight::High,
759            None,
760            2000,
761        );
762        assert!(matches!(result, Err(VaultError::CyclicDependency(_))));
763    }
764
765    #[test]
766    fn test_weighted_impact_single() {
767        let graph = test_graph();
768        add_weighted_dependency(
769            &graph,
770            "secret:root",
771            "secret:child",
772            DependencyWeight::Critical,
773            None,
774            1000,
775        )
776        .unwrap();
777
778        let report = weighted_impact_analysis(&graph, "secret:root");
779        assert_eq!(report.affected_secrets.len(), 1);
780        assert_eq!(report.affected_secrets[0].secret, "secret:child");
781        assert_eq!(report.affected_secrets[0].depth, 1);
782        assert!((report.affected_secrets[0].edge_weight.value() - 1.0).abs() < f64::EPSILON);
783    }
784
785    #[test]
786    fn test_weighted_impact_mixed() {
787        let graph = test_graph();
788        add_weighted_dependency(
789            &graph,
790            "secret:a",
791            "secret:b",
792            DependencyWeight::Critical,
793            None,
794            1000,
795        )
796        .unwrap();
797        add_weighted_dependency(
798            &graph,
799            "secret:a",
800            "secret:c",
801            DependencyWeight::Low,
802            None,
803            2000,
804        )
805        .unwrap();
806
807        let report = weighted_impact_analysis(&graph, "secret:a");
808        assert_eq!(report.affected_secrets.len(), 2);
809        assert!(report.total_impact_score > 0.0);
810    }
811
812    #[test]
813    fn test_impact_score_calculation() {
814        let graph = test_graph();
815        add_weighted_dependency(
816            &graph,
817            "secret:a",
818            "secret:b",
819            DependencyWeight::Critical,
820            None,
821            1000,
822        )
823        .unwrap();
824
825        let report = weighted_impact_analysis(&graph, "secret:a");
826        // impact_score = weight.value() / depth = 1.0 / 1 = 1.0
827        assert!((report.affected_secrets[0].impact_score - 1.0).abs() < f64::EPSILON);
828    }
829
830    #[test]
831    fn test_rotation_plan_chain() {
832        let graph = test_graph();
833        add_weighted_dependency(
834            &graph,
835            "secret:a",
836            "secret:b",
837            DependencyWeight::Critical,
838            None,
839            1000,
840        )
841        .unwrap();
842        add_weighted_dependency(
843            &graph,
844            "secret:b",
845            "secret:c",
846            DependencyWeight::High,
847            None,
848            2000,
849        )
850        .unwrap();
851
852        let plan = rotation_plan(&graph, "secret:a");
853        assert_eq!(plan.total_secrets, 2);
854        assert_eq!(plan.rotation_order.len(), 2);
855    }
856
857    #[test]
858    fn test_rotation_plan_diamond() {
859        let graph = test_graph();
860        add_weighted_dependency(
861            &graph,
862            "secret:a",
863            "secret:b",
864            DependencyWeight::High,
865            None,
866            1000,
867        )
868        .unwrap();
869        add_weighted_dependency(
870            &graph,
871            "secret:a",
872            "secret:c",
873            DependencyWeight::Medium,
874            None,
875            2000,
876        )
877        .unwrap();
878        add_weighted_dependency(
879            &graph,
880            "secret:b",
881            "secret:d",
882            DependencyWeight::Low,
883            None,
884            3000,
885        )
886        .unwrap();
887        add_weighted_dependency(
888            &graph,
889            "secret:c",
890            "secret:d",
891            DependencyWeight::Low,
892            None,
893            4000,
894        )
895        .unwrap();
896
897        let plan = rotation_plan(&graph, "secret:a");
898        assert_eq!(plan.total_secrets, 3); // b, c, d
899    }
900
901    #[test]
902    fn test_rotation_critical_first() {
903        let graph = test_graph();
904        add_weighted_dependency(
905            &graph,
906            "secret:a",
907            "secret:low",
908            DependencyWeight::Low,
909            None,
910            1000,
911        )
912        .unwrap();
913        add_weighted_dependency(
914            &graph,
915            "secret:a",
916            "secret:critical",
917            DependencyWeight::Critical,
918            None,
919            2000,
920        )
921        .unwrap();
922
923        let plan = rotation_plan(&graph, "secret:a");
924        assert_eq!(plan.rotation_order.len(), 2);
925        // Critical should come first (higher priority)
926        assert_eq!(plan.rotation_order[0].secret, "secret:critical");
927    }
928}