Skip to main content

pgroles_cli/
lib.rs

1//! Testable CLI logic for pgroles.
2//!
3//! All pure functions that don't require a live database connection live here.
4//! The binary (`main.rs`) delegates to these, making validation, plan formatting,
5//! and output rendering fully unit-testable.
6
7use std::path::Path;
8
9use anyhow::{Context, Result};
10
11use pgroles_core::diff::{self, Change};
12use pgroles_core::manifest::{self, ExpandedManifest, PolicyManifest, RoleRetirement};
13use pgroles_core::model::RoleGraph;
14use pgroles_core::sql;
15
16// ---------------------------------------------------------------------------
17// File loading
18// ---------------------------------------------------------------------------
19
20/// Read a manifest file from disk and return the raw YAML string.
21pub fn read_manifest_file(path: &Path) -> Result<String> {
22    std::fs::read_to_string(path)
23        .with_context(|| format!("failed to read manifest file: {}", path.display()))
24}
25
26// ---------------------------------------------------------------------------
27// Validation pipeline (pure — no DB)
28// ---------------------------------------------------------------------------
29
30/// Parse and validate a YAML string into a `PolicyManifest`.
31pub fn parse(yaml: &str) -> Result<PolicyManifest> {
32    manifest::parse_manifest(yaml).map_err(|err| anyhow::anyhow!("{err}"))
33}
34
35/// Parse, validate, and expand a manifest YAML string into an `ExpandedManifest`.
36pub fn parse_and_expand(yaml: &str) -> Result<ExpandedManifest> {
37    let policy_manifest = parse(yaml)?;
38    manifest::expand_manifest(&policy_manifest).map_err(|err| anyhow::anyhow!("{err}"))
39}
40
41/// Full validation: parse, expand, and build a RoleGraph from a manifest string.
42/// Returns the expanded manifest and the desired RoleGraph.
43pub fn validate_manifest(yaml: &str) -> Result<ValidatedManifest> {
44    let policy_manifest = parse(yaml)?;
45    let expanded =
46        manifest::expand_manifest(&policy_manifest).map_err(|err| anyhow::anyhow!("{err}"))?;
47
48    let default_owner = policy_manifest.default_owner.as_deref();
49    let desired = RoleGraph::from_expanded(&expanded, default_owner)
50        .map_err(|err| anyhow::anyhow!("{err}"))?;
51
52    Ok(ValidatedManifest {
53        manifest: policy_manifest,
54        expanded,
55        desired,
56    })
57}
58
59/// The result of successfully validating a manifest.
60pub struct ValidatedManifest {
61    pub manifest: PolicyManifest,
62    pub expanded: ExpandedManifest,
63    pub desired: RoleGraph,
64}
65
66// ---------------------------------------------------------------------------
67// Plan computation (pure — given both role graphs)
68// ---------------------------------------------------------------------------
69
70/// Compute the list of changes needed to bring `current` state to `desired` state.
71pub fn compute_plan(current: &RoleGraph, desired: &RoleGraph) -> Vec<Change> {
72    diff::diff(current, desired)
73}
74
75/// Collect the role names that the current plan intends to drop.
76pub fn planned_role_drops(changes: &[Change]) -> Vec<String> {
77    changes
78        .iter()
79        .filter_map(|change| match change {
80            Change::DropRole { name } => Some(name.clone()),
81            _ => None,
82        })
83        .collect()
84}
85
86/// Insert explicit retirement actions before any matching role drops.
87pub fn apply_role_retirements(changes: Vec<Change>, retirements: &[RoleRetirement]) -> Vec<Change> {
88    diff::apply_role_retirements(changes, retirements)
89}
90
91/// Resolve password sources from environment variables for roles that declare them.
92pub fn resolve_passwords(
93    expanded: &ExpandedManifest,
94) -> Result<std::collections::BTreeMap<String, String>> {
95    diff::resolve_passwords(&expanded.roles).map_err(|err| anyhow::anyhow!("{err}"))
96}
97
98/// Inject `SetPassword` changes into a plan for roles with resolved passwords.
99pub fn inject_password_changes(
100    changes: Vec<Change>,
101    resolved_passwords: &std::collections::BTreeMap<String, String>,
102) -> Vec<Change> {
103    diff::inject_password_changes(changes, resolved_passwords)
104}
105
106// ---------------------------------------------------------------------------
107// Output formatting
108// ---------------------------------------------------------------------------
109
110/// Format a plan as SQL statements.
111pub fn format_plan_sql(changes: &[Change]) -> String {
112    sql::render_all(changes)
113}
114
115/// Format a plan as SQL statements using an explicit SQL context.
116pub fn format_plan_sql_with_context(changes: &[Change], ctx: &sql::SqlContext) -> String {
117    sql::render_all_with_context(&redacted_changes(changes), ctx)
118}
119
120/// Format a plan as JSON for machine consumption.
121pub fn format_plan_json(changes: &[Change]) -> Result<String> {
122    serde_json::to_string_pretty(&redacted_changes(changes)).map_err(|err| anyhow::anyhow!("{err}"))
123}
124
125fn redacted_changes(changes: &[Change]) -> Vec<Change> {
126    changes
127        .iter()
128        .map(|change| match change {
129            Change::SetPassword { name, .. } => Change::SetPassword {
130                name: name.clone(),
131                password: "[REDACTED]".to_string(),
132            },
133            other => other.clone(),
134        })
135        .collect()
136}
137
138/// Summary statistics for a plan.
139#[derive(Debug, Default, PartialEq, Eq)]
140pub struct PlanSummary {
141    pub roles_created: usize,
142    pub roles_altered: usize,
143    pub roles_dropped: usize,
144    pub comments_changed: usize,
145    pub sessions_terminated: usize,
146    pub ownerships_reassigned: usize,
147    pub owned_objects_dropped: usize,
148    pub grants: usize,
149    pub revokes: usize,
150    pub default_privileges_set: usize,
151    pub default_privileges_revoked: usize,
152    pub members_added: usize,
153    pub members_removed: usize,
154    pub passwords_set: usize,
155}
156
157impl PlanSummary {
158    /// Compute summary statistics from a list of changes.
159    pub fn from_changes(changes: &[Change]) -> Self {
160        let mut summary = Self::default();
161        for change in changes {
162            match change {
163                Change::CreateRole { .. } => summary.roles_created += 1,
164                Change::AlterRole { .. } => summary.roles_altered += 1,
165                Change::DropRole { .. } => summary.roles_dropped += 1,
166                Change::SetComment { .. } => summary.comments_changed += 1,
167                Change::TerminateSessions { .. } => summary.sessions_terminated += 1,
168                Change::ReassignOwned { .. } => summary.ownerships_reassigned += 1,
169                Change::DropOwned { .. } => summary.owned_objects_dropped += 1,
170                Change::Grant { .. } => summary.grants += 1,
171                Change::Revoke { .. } => summary.revokes += 1,
172                Change::SetDefaultPrivilege { .. } => summary.default_privileges_set += 1,
173                Change::RevokeDefaultPrivilege { .. } => summary.default_privileges_revoked += 1,
174                Change::AddMember { .. } => summary.members_added += 1,
175                Change::RemoveMember { .. } => summary.members_removed += 1,
176                Change::SetPassword { .. } => summary.passwords_set += 1,
177            }
178        }
179        summary
180    }
181
182    /// Total number of changes in the plan.
183    pub fn total(&self) -> usize {
184        self.roles_created
185            + self.roles_altered
186            + self.roles_dropped
187            + self.comments_changed
188            + self.sessions_terminated
189            + self.ownerships_reassigned
190            + self.owned_objects_dropped
191            + self.grants
192            + self.revokes
193            + self.default_privileges_set
194            + self.default_privileges_revoked
195            + self.members_added
196            + self.members_removed
197            + self.passwords_set
198    }
199
200    /// True if the plan has no changes.
201    pub fn is_empty(&self) -> bool {
202        self.total() == 0
203    }
204
205    /// True if the plan has structural drift (excluding password-only changes).
206    ///
207    /// Password changes always appear in plans because passwords cannot be read
208    /// back from PostgreSQL for comparison. This method allows CI gates
209    /// (`--exit-code`) to distinguish real drift from password-only changes.
210    pub fn has_structural_changes(&self) -> bool {
211        self.total() - self.passwords_set > 0
212    }
213
214    pub fn format_plan(&self) -> String {
215        self.format_with_header("Plan")
216    }
217
218    pub fn format_applied(&self) -> String {
219        self.format_with_header("Applied")
220    }
221
222    fn format_with_header(&self, header: &str) -> String {
223        if self.is_empty() {
224            return "No changes needed. Database is in sync with manifest.".to_string();
225        }
226
227        let mut output = String::new();
228        output.push_str(&format!("{header}: {} change(s)\n", self.total()));
229
230        let items: Vec<(&str, usize)> = vec![
231            ("role(s) to create", self.roles_created),
232            ("role(s) to alter", self.roles_altered),
233            ("role(s) to drop", self.roles_dropped),
234            ("comment(s) to change", self.comments_changed),
235            ("session termination step(s)", self.sessions_terminated),
236            ("ownership reassignment(s)", self.ownerships_reassigned),
237            ("DROP OWNED cleanup step(s)", self.owned_objects_dropped),
238            ("grant(s) to add", self.grants),
239            ("grant(s) to revoke", self.revokes),
240            ("default privilege(s) to set", self.default_privileges_set),
241            (
242                "default privilege(s) to revoke",
243                self.default_privileges_revoked,
244            ),
245            ("membership(s) to add", self.members_added),
246            ("membership(s) to remove", self.members_removed),
247            ("password(s) to set", self.passwords_set),
248        ];
249
250        for (label, count) in items {
251            if count > 0 {
252                output.push_str(&format!("  {count} {label}\n"));
253            }
254        }
255
256        output
257    }
258}
259
260impl std::fmt::Display for PlanSummary {
261    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262        write!(f, "{}", self.format_plan())
263    }
264}
265
266/// Format validation results for human-readable output.
267pub fn format_validation_result(validated: &ValidatedManifest) -> String {
268    let mut output = String::new();
269    output.push_str("Manifest is valid.\n");
270    output.push_str(&format!(
271        "  {} role(s) defined\n",
272        validated.expanded.roles.len()
273    ));
274    output.push_str(&format!(
275        "  {} grant(s) defined\n",
276        validated.expanded.grants.len()
277    ));
278    output.push_str(&format!(
279        "  {} default privilege(s) defined\n",
280        validated.expanded.default_privileges.len()
281    ));
282    output.push_str(&format!(
283        "  {} membership(s) defined\n",
284        validated.expanded.memberships.len()
285    ));
286    output
287}
288
289// ---------------------------------------------------------------------------
290// Inspect output formatting
291// ---------------------------------------------------------------------------
292
293/// Format a RoleGraph as a human-readable summary.
294pub fn format_role_graph_summary(graph: &RoleGraph) -> String {
295    let mut output = String::new();
296    output.push_str(&format!("Roles: {}\n", graph.roles.len()));
297    output.push_str(&format!("Grants: {}\n", graph.grants.len()));
298    output.push_str(&format!(
299        "Default privileges: {}\n",
300        graph.default_privileges.len()
301    ));
302    output.push_str(&format!("Memberships: {}\n", graph.memberships.len()));
303    output
304}
305
306// ---------------------------------------------------------------------------
307// Tests
308// ---------------------------------------------------------------------------
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    const MINIMAL_MANIFEST: &str = r#"
315default_owner: app_owner
316
317roles:
318  - name: analytics
319    login: true
320    comment: "Analytics read-only role"
321
322grants:
323  - role: analytics
324    privileges: [CONNECT]
325    object: { type: database, name: mydb }
326"#;
327
328    const PROFILE_MANIFEST: &str = r#"
329default_owner: app_owner
330
331profiles:
332  editor:
333    grants:
334      - privileges: [USAGE]
335        object: { type: schema }
336      - privileges: [SELECT, INSERT, UPDATE, DELETE]
337        object: { type: table, name: "*" }
338    default_privileges:
339      - privileges: [SELECT, INSERT, UPDATE, DELETE]
340        on_type: table
341  viewer:
342    grants:
343      - privileges: [USAGE]
344        object: { type: schema }
345      - privileges: [SELECT]
346        object: { type: table, name: "*" }
347    default_privileges:
348      - privileges: [SELECT]
349        on_type: table
350
351schemas:
352  - name: inventory
353    profiles: [editor, viewer]
354  - name: catalog
355    profiles: [viewer]
356
357roles:
358  - name: app-service
359    login: true
360
361grants:
362  - role: app-service
363    privileges: [CONNECT]
364    object: { type: database, name: mydb }
365
366memberships:
367  - role: inventory-editor
368    members:
369      - name: app-service
370"#;
371
372    const INVALID_YAML: &str = r#"
373this is: [not: valid yaml: [[
374"#;
375
376    const UNDEFINED_PROFILE: &str = r#"
377profiles:
378  editor:
379    grants: []
380
381schemas:
382  - name: myschema
383    profiles: [nonexistent]
384"#;
385
386    // -----------------------------------------------------------------------
387    // parse
388    // -----------------------------------------------------------------------
389
390    #[test]
391    fn parse_valid_manifest() {
392        let result = parse(MINIMAL_MANIFEST);
393        assert!(result.is_ok());
394        let manifest = result.unwrap();
395        assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
396        assert_eq!(manifest.roles.len(), 1);
397        assert_eq!(manifest.roles[0].name, "analytics");
398    }
399
400    #[test]
401    fn parse_invalid_yaml() {
402        let result = parse(INVALID_YAML);
403        assert!(result.is_err());
404        let err_msg = result.unwrap_err().to_string();
405        assert!(err_msg.contains("YAML parse error"), "got: {err_msg}");
406    }
407
408    // -----------------------------------------------------------------------
409    // parse_and_expand
410    // -----------------------------------------------------------------------
411
412    #[test]
413    fn expand_profile_manifest() {
414        let expanded = parse_and_expand(PROFILE_MANIFEST).unwrap();
415
416        // inventory-editor, inventory-viewer, catalog-viewer, app-service
417        assert_eq!(expanded.roles.len(), 4);
418
419        let role_names: Vec<&str> = expanded.roles.iter().map(|r| r.name.as_str()).collect();
420        assert!(role_names.contains(&"inventory-editor"));
421        assert!(role_names.contains(&"inventory-viewer"));
422        assert!(role_names.contains(&"catalog-viewer"));
423        assert!(role_names.contains(&"app-service"));
424    }
425
426    #[test]
427    fn expand_undefined_profile_fails() {
428        let result = parse_and_expand(UNDEFINED_PROFILE);
429        assert!(result.is_err());
430        let err_msg = result.unwrap_err().to_string();
431        assert!(
432            err_msg.contains("nonexistent"),
433            "expected error about 'nonexistent' profile, got: {err_msg}"
434        );
435    }
436
437    // -----------------------------------------------------------------------
438    // validate_manifest
439    // -----------------------------------------------------------------------
440
441    #[test]
442    fn validate_builds_role_graph() {
443        let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
444
445        // Check the desired graph has the expected roles
446        assert_eq!(validated.desired.roles.len(), 4);
447        assert!(validated.desired.roles.contains_key("inventory-editor"));
448        assert!(validated.desired.roles.contains_key("app-service"));
449
450        // Check grants were expanded
451        assert!(!validated.desired.grants.is_empty());
452
453        // Check memberships
454        assert!(!validated.desired.memberships.is_empty());
455    }
456
457    // -----------------------------------------------------------------------
458    // compute_plan + format
459    // -----------------------------------------------------------------------
460
461    #[test]
462    fn plan_from_empty_creates_roles() {
463        let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
464        let current = RoleGraph::default(); // empty database
465
466        let changes = compute_plan(&current, &validated.desired);
467        assert!(!changes.is_empty());
468
469        let summary = PlanSummary::from_changes(&changes);
470        assert_eq!(summary.roles_created, 4); // inventory-editor, inventory-viewer, catalog-viewer, app-service
471        assert!(summary.grants > 0);
472        assert!(!summary.is_empty());
473    }
474
475    #[test]
476    fn plan_no_changes_when_in_sync() {
477        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
478        // Simulate a DB that already has the desired state
479        let current = validated.desired.clone();
480
481        let changes = compute_plan(&current, &validated.desired);
482        let summary = PlanSummary::from_changes(&changes);
483        assert!(summary.is_empty());
484        assert_eq!(summary.total(), 0);
485    }
486
487    #[test]
488    fn format_plan_sql_produces_sql() {
489        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
490        let current = RoleGraph::default();
491        let changes = compute_plan(&current, &validated.desired);
492
493        let sql_output = format_plan_sql(&changes);
494        assert!(
495            sql_output.contains("CREATE ROLE"),
496            "expected CREATE ROLE in: {sql_output}"
497        );
498        assert!(
499            sql_output.contains("\"analytics\""),
500            "expected quoted role name in: {sql_output}"
501        );
502    }
503
504    #[test]
505    fn planned_role_drops_only_returns_drop_changes() {
506        let changes = vec![
507            Change::CreateRole {
508                name: "new-role".to_string(),
509                state: pgroles_core::model::RoleState::default(),
510            },
511            Change::DropRole {
512                name: "old-role".to_string(),
513            },
514            Change::DropRole {
515                name: "stale-role".to_string(),
516            },
517        ];
518
519        assert_eq!(
520            planned_role_drops(&changes),
521            vec!["old-role".to_string(), "stale-role".to_string()]
522        );
523    }
524
525    #[test]
526    fn apply_role_retirements_updates_plan_summary() {
527        let changes = apply_role_retirements(
528            vec![Change::DropRole {
529                name: "legacy-app".to_string(),
530            }],
531            &[pgroles_core::manifest::RoleRetirement {
532                role: "legacy-app".to_string(),
533                reassign_owned_to: Some("app-owner".to_string()),
534                drop_owned: true,
535                terminate_sessions: true,
536            }],
537        );
538
539        let summary = PlanSummary::from_changes(&changes);
540        assert_eq!(summary.roles_dropped, 1);
541        assert_eq!(summary.sessions_terminated, 1);
542        assert_eq!(summary.ownerships_reassigned, 1);
543        assert_eq!(summary.owned_objects_dropped, 1);
544        assert_eq!(summary.total(), 4);
545    }
546
547    // -----------------------------------------------------------------------
548    // PlanSummary display
549    // -----------------------------------------------------------------------
550
551    #[test]
552    fn plan_summary_display_empty() {
553        let summary = PlanSummary::default();
554        let display = summary.to_string();
555        assert!(display.contains("No changes needed"));
556    }
557
558    #[test]
559    fn plan_summary_display_with_changes() {
560        let summary = PlanSummary {
561            roles_created: 2,
562            grants: 5,
563            members_added: 1,
564            ..Default::default()
565        };
566        let display = summary.to_string();
567        assert!(display.contains("8 change(s)"), "got: {display}");
568        assert!(display.contains("2 role(s) to create"), "got: {display}");
569        assert!(display.contains("5 grant(s) to add"), "got: {display}");
570        assert!(display.contains("1 membership(s) to add"), "got: {display}");
571        // Should not mention zero-count items
572        assert!(!display.contains("to drop"), "got: {display}");
573        assert!(!display.contains("to revoke"), "got: {display}");
574    }
575
576    // -----------------------------------------------------------------------
577    // format_validation_result
578    // -----------------------------------------------------------------------
579
580    #[test]
581    fn validation_result_shows_counts() {
582        let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
583        let output = format_validation_result(&validated);
584        assert!(output.contains("Manifest is valid"), "got: {output}");
585        assert!(output.contains("4 role(s)"), "got: {output}");
586    }
587
588    // -----------------------------------------------------------------------
589    // read_manifest_file
590    // -----------------------------------------------------------------------
591
592    #[test]
593    fn read_nonexistent_file_fails() {
594        let result = read_manifest_file(Path::new("/tmp/nonexistent-pgroles-test.yaml"));
595        assert!(result.is_err());
596        let err_msg = format!("{:#}", result.unwrap_err());
597        assert!(
598            err_msg.contains("failed to read manifest file"),
599            "got: {err_msg}"
600        );
601    }
602
603    // -----------------------------------------------------------------------
604    // format_role_graph_summary
605    // -----------------------------------------------------------------------
606
607    #[test]
608    fn role_graph_summary_format() {
609        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
610        let summary = format_role_graph_summary(&validated.desired);
611        assert!(summary.contains("Roles: 1"), "got: {summary}");
612    }
613
614    // -----------------------------------------------------------------------
615    // has_structural_changes — password-only drift detection
616    // -----------------------------------------------------------------------
617
618    #[test]
619    fn has_structural_changes_true_for_non_password_changes() {
620        let summary = PlanSummary {
621            roles_created: 1,
622            grants: 2,
623            ..Default::default()
624        };
625        assert!(summary.has_structural_changes());
626    }
627
628    #[test]
629    fn has_structural_changes_false_for_password_only() {
630        let summary = PlanSummary {
631            passwords_set: 3,
632            ..Default::default()
633        };
634        assert!(
635            !summary.has_structural_changes(),
636            "password-only plan should NOT be considered structural drift"
637        );
638    }
639
640    #[test]
641    fn has_structural_changes_true_for_mixed() {
642        let summary = PlanSummary {
643            roles_created: 1,
644            passwords_set: 2,
645            ..Default::default()
646        };
647        assert!(
648            summary.has_structural_changes(),
649            "mixed plan with structural + password changes IS structural drift"
650        );
651    }
652
653    #[test]
654    fn has_structural_changes_false_for_empty() {
655        let summary = PlanSummary::default();
656        assert!(!summary.has_structural_changes());
657    }
658
659    #[test]
660    fn plan_summary_displays_password_count() {
661        let summary = PlanSummary {
662            passwords_set: 2,
663            roles_created: 1,
664            ..Default::default()
665        };
666        let display = summary.to_string();
667        assert!(display.contains("2 password(s) to set"), "got: {display}");
668        assert!(display.contains("3 change(s)"), "got: {display}");
669    }
670
671    // -----------------------------------------------------------------------
672    // ReconciliationMode integration through compute_plan + filter
673    // -----------------------------------------------------------------------
674
675    #[test]
676    fn additive_mode_filters_revokes_from_plan() {
677        use pgroles_core::diff::{ReconciliationMode, filter_changes};
678        use pgroles_core::model::RoleState;
679
680        let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
681
682        let mut current = validated.desired.clone();
683        current
684            .roles
685            .insert("stale-role".to_string(), RoleState::default());
686
687        let changes = compute_plan(&current, &validated.desired);
688        assert!(changes.iter().any(|c| matches!(
689            c,
690            pgroles_core::diff::Change::DropRole { name } if name == "stale-role"
691        )));
692
693        let filtered = filter_changes(changes, ReconciliationMode::Additive);
694        assert!(
695            !filtered
696                .iter()
697                .any(|c| matches!(c, pgroles_core::diff::Change::DropRole { .. })),
698            "additive mode should filter out DropRole"
699        );
700    }
701
702    #[test]
703    fn adopt_mode_filters_drops_but_keeps_revokes() {
704        use pgroles_core::diff::{ReconciliationMode, filter_changes};
705        use pgroles_core::manifest::{ObjectType, Privilege};
706        use pgroles_core::model::{GrantKey, GrantState, RoleState};
707        use std::collections::BTreeSet;
708
709        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
710
711        let mut current = validated.desired.clone();
712        current
713            .roles
714            .insert("stale-role".to_string(), RoleState::default());
715        current.grants.insert(
716            GrantKey {
717                role: "analytics".to_string(),
718                object_type: ObjectType::Table,
719                schema: Some("public".to_string()),
720                name: Some("*".to_string()),
721            },
722            GrantState {
723                privileges: BTreeSet::from([Privilege::Select]),
724            },
725        );
726
727        let changes = compute_plan(&current, &validated.desired);
728
729        let filtered = filter_changes(changes, ReconciliationMode::Adopt);
730        assert!(
731            !filtered
732                .iter()
733                .any(|c| matches!(c, pgroles_core::diff::Change::DropRole { .. })),
734            "adopt mode should filter out DropRole"
735        );
736        assert!(
737            filtered
738                .iter()
739                .any(|c| matches!(c, pgroles_core::diff::Change::Revoke { .. })),
740            "adopt mode should keep Revoke changes"
741        );
742    }
743    // -----------------------------------------------------------------------
744    // format_plan_json
745    // -----------------------------------------------------------------------
746
747    #[test]
748    fn plan_json_produces_valid_json() {
749        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
750        let current = RoleGraph::default();
751        let changes = compute_plan(&current, &validated.desired);
752
753        let json_output = format_plan_json(&changes).unwrap();
754        // Should be parseable JSON
755        let parsed: serde_json::Value = serde_json::from_str(&json_output).unwrap();
756        assert!(parsed.is_array());
757        // Should contain CreateRole
758        let text = json_output.to_string();
759        assert!(text.contains("CreateRole"), "got: {text}");
760        assert!(text.contains("analytics"), "got: {text}");
761    }
762
763    #[test]
764    fn format_plan_json_redacts_passwords() {
765        let changes = vec![Change::SetPassword {
766            name: "app-svc".to_string(),
767            password: "super-secret".to_string(),
768        }];
769
770        let json = format_plan_json(&changes).expect("json formatting should succeed");
771        assert!(json.contains("[REDACTED]"), "got: {json}");
772        assert!(!json.contains("super-secret"), "got: {json}");
773    }
774
775    #[test]
776    fn format_plan_sql_redacts_passwords() {
777        let changes = vec![Change::SetPassword {
778            name: "app-svc".to_string(),
779            password: "super-secret".to_string(),
780        }];
781
782        let sql = format_plan_sql_with_context(&changes, &sql::SqlContext::default());
783        assert!(sql.contains("[REDACTED]"), "got: {sql}");
784        assert!(!sql.contains("super-secret"), "got: {sql}");
785    }
786
787    #[test]
788    fn format_applied_uses_applied_header() {
789        let summary = PlanSummary {
790            roles_created: 1,
791            grants: 2,
792            ..Default::default()
793        };
794
795        let display = summary.format_applied();
796        assert!(
797            display.starts_with("Applied: 3 change(s)\n"),
798            "got: {display}"
799        );
800        assert!(display.contains("1 role(s) to create"), "got: {display}");
801        assert!(display.contains("2 grant(s) to add"), "got: {display}");
802        assert!(!display.contains("Plan:"), "got: {display}");
803    }
804}