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
11pub mod candidate;
12
13use pgroles_core::composition::{self, ComposedPolicy, PolicyBundle, PolicyDocument};
14use pgroles_core::diff::{self, Change};
15use pgroles_core::manifest::{self, ExpandedManifest, PolicyManifest, RoleRetirement};
16use pgroles_core::model::{DefaultPrivilegeScope, RoleGraph};
17use pgroles_core::ownership::ManagedScope;
18use pgroles_core::report::{self, PlanOutputMode};
19use pgroles_core::sql;
20
21// ---------------------------------------------------------------------------
22// File loading
23// ---------------------------------------------------------------------------
24
25/// Read a manifest file from disk and return the raw YAML string.
26pub fn read_manifest_file(path: &Path) -> Result<String> {
27    std::fs::read_to_string(path)
28        .with_context(|| format!("failed to read manifest file: {}", path.display()))
29}
30
31// ---------------------------------------------------------------------------
32// Validation pipeline (pure — no DB)
33// ---------------------------------------------------------------------------
34
35/// Parse and validate a YAML string into a `PolicyManifest`.
36pub fn parse(yaml: &str) -> Result<PolicyManifest> {
37    manifest::parse_manifest(yaml).map_err(|err| anyhow::anyhow!("{err}"))
38}
39
40/// Parse, validate, and expand a manifest YAML string into an `ExpandedManifest`.
41pub fn parse_and_expand(yaml: &str) -> Result<ExpandedManifest> {
42    let policy_manifest = parse(yaml)?;
43    manifest::expand_manifest(&policy_manifest).map_err(|err| anyhow::anyhow!("{err}"))
44}
45
46/// Full validation: parse, expand, and build a RoleGraph from a manifest string.
47/// Returns the expanded manifest and the desired RoleGraph.
48pub fn validate_manifest(yaml: &str) -> Result<ValidatedManifest> {
49    let policy_manifest = parse(yaml)?;
50
51    if policy_manifest.roles.is_empty()
52        && policy_manifest.schemas.is_empty()
53        && policy_manifest.grants.is_empty()
54        && policy_manifest.memberships.is_empty()
55    {
56        tracing::warn!(
57            "manifest defines no roles, schemas, grants, or memberships — is the file correct?"
58        );
59    }
60
61    let expanded =
62        manifest::expand_manifest(&policy_manifest).map_err(|err| anyhow::anyhow!("{err}"))?;
63
64    let default_owner = policy_manifest.default_owner.as_deref();
65    let desired = RoleGraph::from_expanded(&expanded, default_owner)
66        .map_err(|err| anyhow::anyhow!("{err}"))?;
67
68    Ok(ValidatedManifest {
69        manifest: policy_manifest,
70        expanded,
71        desired,
72    })
73}
74
75/// The result of successfully validating a manifest.
76pub struct ValidatedManifest {
77    pub manifest: PolicyManifest,
78    pub expanded: ExpandedManifest,
79    pub desired: RoleGraph,
80}
81
82/// Load, validate, and compose a policy bundle from disk.
83pub fn validate_bundle_file(path: &Path) -> Result<ValidatedBundle> {
84    let yaml = read_manifest_file(path)?;
85    let bundle = composition::parse_policy_bundle(&yaml).map_err(|err| anyhow::anyhow!("{err}"))?;
86    let documents = load_policy_documents(path, &bundle)?;
87    let composed =
88        composition::compose_bundle(&bundle, &documents).map_err(|err| anyhow::anyhow!("{err}"))?;
89
90    Ok(ValidatedBundle {
91        bundle,
92        documents,
93        composed,
94    })
95}
96
97fn load_policy_documents(path: &Path, bundle: &PolicyBundle) -> Result<Vec<PolicyDocument>> {
98    let base_dir = path
99        .parent()
100        .with_context(|| format!("bundle path has no parent directory: {}", path.display()))?;
101
102    bundle
103        .sources
104        .iter()
105        .map(|source| {
106            let source_path = base_dir.join(&source.file);
107            let yaml = read_manifest_file(&source_path)?;
108            let fragment = composition::parse_policy_fragment(&yaml)
109                .map_err(|err| anyhow::anyhow!("{err}"))
110                .with_context(|| {
111                    format!("failed to parse policy document: {}", source_path.display())
112                })?;
113            Ok(PolicyDocument {
114                source: source.file.clone(),
115                fragment,
116            })
117        })
118        .collect()
119}
120
121/// The result of successfully validating a composed policy bundle.
122pub struct ValidatedBundle {
123    pub bundle: PolicyBundle,
124    pub documents: Vec<PolicyDocument>,
125    pub composed: ComposedPolicy,
126}
127
128// ---------------------------------------------------------------------------
129// Plan computation (pure — given both role graphs)
130// ---------------------------------------------------------------------------
131
132/// Compute the list of changes needed to bring `current` state to `desired` state.
133pub fn compute_plan(current: &RoleGraph, desired: &RoleGraph) -> Vec<Change> {
134    diff::diff(current, desired)
135}
136
137/// Collect the role names that the current plan intends to drop.
138pub fn planned_role_drops(changes: &[Change]) -> Vec<String> {
139    changes
140        .iter()
141        .filter_map(|change| match change {
142            Change::DropRole { name } => Some(name.clone()),
143            _ => None,
144        })
145        .collect()
146}
147
148/// Insert explicit retirement actions before any matching role drops.
149pub fn apply_role_retirements(changes: Vec<Change>, retirements: &[RoleRetirement]) -> Vec<Change> {
150    diff::apply_role_retirements(changes, retirements)
151}
152
153/// Resolve password sources from environment variables for roles that declare them.
154pub fn resolve_passwords(
155    expanded: &ExpandedManifest,
156) -> Result<std::collections::BTreeMap<String, String>> {
157    diff::resolve_passwords(&expanded.roles).map_err(|err| anyhow::anyhow!("{err}"))
158}
159
160/// Inject `SetPassword` changes into a plan for roles with resolved passwords.
161pub fn inject_password_changes(
162    changes: Vec<Change>,
163    resolved_passwords: &std::collections::BTreeMap<String, String>,
164) -> Vec<Change> {
165    diff::inject_password_changes(changes, resolved_passwords)
166}
167
168// ---------------------------------------------------------------------------
169// Output formatting
170// ---------------------------------------------------------------------------
171
172/// Format a plan as SQL statements.
173pub fn format_plan_sql(changes: &[Change]) -> String {
174    sql::render_all(changes)
175}
176
177/// Format a plan as SQL statements using an explicit SQL context.
178pub fn format_plan_sql_with_context(changes: &[Change], ctx: &sql::SqlContext) -> String {
179    sql::render_all_with_context(
180        &report::shape_plan_changes(changes, PlanOutputMode::Redacted),
181        ctx,
182    )
183}
184
185/// Format a plan as JSON for machine consumption.
186pub fn format_plan_json(changes: &[Change]) -> Result<String> {
187    report::render_plan_json(changes, PlanOutputMode::Redacted)
188        .map_err(|err| anyhow::anyhow!("{err}"))
189}
190
191/// Format a bundle plan as JSON with ownership annotations for each change.
192pub fn format_bundle_plan_json(changes: &[Change], composed: &ComposedPolicy) -> Result<String> {
193    report::render_bundle_plan_json(
194        changes,
195        &composed.report_context(),
196        PlanOutputMode::Redacted,
197    )
198    .map_err(|err| anyhow::anyhow!("{err}"))
199}
200
201/// Summary statistics for a plan.
202#[derive(Debug, Default, PartialEq, Eq)]
203pub struct PlanSummary {
204    pub roles_created: usize,
205    pub roles_altered: usize,
206    pub schemas_created: usize,
207    pub schema_owners_altered: usize,
208    pub roles_dropped: usize,
209    pub comments_changed: usize,
210    pub sessions_terminated: usize,
211    pub ownerships_reassigned: usize,
212    pub owned_objects_dropped: usize,
213    pub grants: usize,
214    pub revokes: usize,
215    pub default_privileges_set: usize,
216    pub default_privileges_revoked: usize,
217    /// Global (owner-wide) default privilege changes, counted separately from
218    /// the schema-scoped totals above because they affect every schema in the
219    /// database.
220    pub global_default_privileges_set: usize,
221    pub global_default_privileges_revoked: usize,
222    pub members_added: usize,
223    pub members_removed: usize,
224    pub passwords_set: usize,
225}
226
227impl PlanSummary {
228    /// Compute summary statistics from a list of changes.
229    pub fn from_changes(changes: &[Change]) -> Self {
230        let mut summary = Self::default();
231        for change in changes {
232            match change {
233                Change::CreateRole { .. } => summary.roles_created += 1,
234                Change::CreateSchema { .. } => summary.schemas_created += 1,
235                Change::AlterSchemaOwner { .. } => summary.schema_owners_altered += 1,
236                Change::AlterRole { .. } => summary.roles_altered += 1,
237                Change::DropRole { .. } => summary.roles_dropped += 1,
238                Change::SetComment { .. } => summary.comments_changed += 1,
239                Change::TerminateSessions { .. } => summary.sessions_terminated += 1,
240                Change::ReassignOwned { .. } => summary.ownerships_reassigned += 1,
241                Change::DropOwned { .. } => summary.owned_objects_dropped += 1,
242                Change::Grant { .. } | Change::EnsureSchemaOwnerPrivileges { .. } => {
243                    summary.grants += 1
244                }
245                Change::Revoke { .. } => summary.revokes += 1,
246                Change::SetDefaultPrivilege { scope, .. } => {
247                    if matches!(scope, DefaultPrivilegeScope::Global) {
248                        summary.global_default_privileges_set += 1;
249                    } else {
250                        summary.default_privileges_set += 1;
251                    }
252                }
253                Change::RevokeDefaultPrivilege { scope, .. } => {
254                    if matches!(scope, DefaultPrivilegeScope::Global) {
255                        summary.global_default_privileges_revoked += 1;
256                    } else {
257                        summary.default_privileges_revoked += 1;
258                    }
259                }
260                Change::AddMember { .. } => summary.members_added += 1,
261                Change::RemoveMember { .. } => summary.members_removed += 1,
262                Change::SetPassword { .. } => summary.passwords_set += 1,
263            }
264        }
265        summary
266    }
267
268    /// Total number of changes in the plan.
269    pub fn total(&self) -> usize {
270        self.roles_created
271            + self.roles_altered
272            + self.schemas_created
273            + self.schema_owners_altered
274            + self.roles_dropped
275            + self.comments_changed
276            + self.sessions_terminated
277            + self.ownerships_reassigned
278            + self.owned_objects_dropped
279            + self.grants
280            + self.revokes
281            + self.default_privileges_set
282            + self.default_privileges_revoked
283            + self.global_default_privileges_set
284            + self.global_default_privileges_revoked
285            + self.members_added
286            + self.members_removed
287            + self.passwords_set
288    }
289
290    /// True if the plan has no changes.
291    pub fn is_empty(&self) -> bool {
292        self.total() == 0
293    }
294
295    /// True if the plan has structural drift (excluding password-only changes).
296    ///
297    /// Password changes always appear in plans because passwords cannot be read
298    /// back from PostgreSQL for comparison. This method allows CI gates
299    /// (`--exit-code`) to distinguish real drift from password-only changes.
300    pub fn has_structural_changes(&self) -> bool {
301        self.total() - self.passwords_set > 0
302    }
303
304    pub fn format_plan(&self) -> String {
305        self.format_with_header("Plan")
306    }
307
308    pub fn format_applied(&self) -> String {
309        self.format_with_header("Applied")
310    }
311
312    fn format_with_header(&self, header: &str) -> String {
313        if self.is_empty() {
314            return "No changes needed. Database is in sync with manifest.".to_string();
315        }
316
317        let mut output = String::new();
318        output.push_str(&format!("{header}: {} change(s)\n", self.total()));
319
320        let items: Vec<(&str, usize)> = vec![
321            ("role(s) to create", self.roles_created),
322            ("role(s) to alter", self.roles_altered),
323            ("schema(s) to create", self.schemas_created),
324            ("schema owner change(s)", self.schema_owners_altered),
325            ("role(s) to drop", self.roles_dropped),
326            ("comment(s) to change", self.comments_changed),
327            ("session termination step(s)", self.sessions_terminated),
328            ("ownership reassignment(s)", self.ownerships_reassigned),
329            ("DROP OWNED cleanup step(s)", self.owned_objects_dropped),
330            ("grant(s) to add", self.grants),
331            ("grant(s) to revoke", self.revokes),
332            ("default privilege(s) to set", self.default_privileges_set),
333            (
334                "default privilege(s) to revoke",
335                self.default_privileges_revoked,
336            ),
337            (
338                "GLOBAL default privilege(s) to set (affects every schema)",
339                self.global_default_privileges_set,
340            ),
341            (
342                "GLOBAL default privilege(s) to revoke (affects every schema)",
343                self.global_default_privileges_revoked,
344            ),
345            ("membership(s) to add", self.members_added),
346            ("membership(s) to remove", self.members_removed),
347            ("password(s) to set", self.passwords_set),
348        ];
349
350        for (label, count) in items {
351            if count > 0 {
352                output.push_str(&format!("  {count} {label}\n"));
353            }
354        }
355
356        output
357    }
358}
359
360impl std::fmt::Display for PlanSummary {
361    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
362        write!(f, "{}", self.format_plan())
363    }
364}
365
366/// Format validation results for human-readable output.
367pub fn format_validation_result(validated: &ValidatedManifest) -> String {
368    let mut output = String::new();
369    output.push_str("Manifest is valid.\n");
370    output.push_str(&format!(
371        "  {} schema(s) defined\n",
372        validated.expanded.schemas.len()
373    ));
374    output.push_str(&format!(
375        "  {} role(s) defined\n",
376        validated.expanded.roles.len()
377    ));
378    output.push_str(&format!(
379        "  {} grant(s) defined\n",
380        validated.expanded.grants.len()
381    ));
382    output.push_str(&format!(
383        "  {} default privilege(s) defined\n",
384        validated.expanded.default_privileges.len()
385    ));
386    output.push_str(&format!(
387        "  {} membership(s) defined\n",
388        validated.expanded.memberships.len()
389    ));
390    output
391}
392
393/// Render a validated bundle as a single composed manifest YAML.
394///
395/// When `include_header` is true, the output is prefixed with a YAML comment
396/// block recording the source bundle label, the manifest schema version
397/// against which the body was rendered, and the fragments it composed, for
398/// traceability when the rendered file is committed to a GitOps repo.
399///
400/// `source_label` should be a stable, machine-independent label (e.g. the
401/// bundle file's basename). Callers must NOT pass absolute or `pwd`-relative
402/// paths: the rendered output is intended to be byte-identical across
403/// developer machines and CI runners, and embedding a local filesystem path
404/// in the header would break that contract.
405///
406/// The body is the composed `PolicyManifest` serialized via serde_yaml and
407/// then post-processed to drop noise that would otherwise churn under
408/// upgrades or render in irrelevant places: `null` scalars, empty sequences
409/// (except for required-field keys like `members`/`privileges`/`grant`),
410/// known empty top-level maps, and known-default scalar values (e.g. the
411/// default `role_pattern`). The cleaned output still round-trips through
412/// `pgroles validate -f` / `diff -f` / `apply -f` because the parser fills
413/// the same defaults back in on read.
414pub fn format_rendered_bundle(
415    validated: &ValidatedBundle,
416    source_label: &str,
417    include_header: bool,
418) -> Result<String> {
419    let raw =
420        serde_yaml::to_value(&validated.composed.manifest).map_err(|err| anyhow::anyhow!(err))?;
421    let cleaned = strip_manifest_defaults(raw);
422    let body = serde_yaml::to_string(&cleaned).map_err(|err| anyhow::anyhow!(err))?;
423
424    if !include_header {
425        return Ok(body);
426    }
427
428    let mut header = String::new();
429    header.push_str("# Rendered by `pgroles render-bundle`.\n");
430    header.push_str("# Do not edit by hand — regenerate from the source bundle.\n");
431    header.push_str(&format!("# Source bundle: {source_label}\n"));
432    header.push_str(&format!("# Manifest schema: {RENDERED_MANIFEST_SCHEMA}\n"));
433    header.push_str("# Fragments:\n");
434    for document in &validated.documents {
435        let label = document.fragment.policy.name.as_deref();
436        match label {
437            Some(name) => header.push_str(&format!("#   - {} ({name})\n", document.source)),
438            None => header.push_str(&format!("#   - {}\n", document.source)),
439        }
440    }
441    header.push_str("#\n");
442    Ok(format!("{header}{body}"))
443}
444
445/// Schema identifier for the YAML body emitted by `render-bundle`. Bumped
446/// only on incompatible changes to the `PolicyManifest` serialization shape.
447/// Recorded in the header so `--check` failures after a pgroles upgrade can
448/// be diagnosed as "schema bump → re-render required" rather than mystery
449/// drift, and so consumers parsing the rendered file can detect mismatches.
450pub const RENDERED_MANIFEST_SCHEMA: &str = "pgroles.manifest.v1";
451
452/// The default value of `SchemaBinding::role_pattern`. Kept in sync with
453/// `pgroles_core::manifest::default_role_pattern()`; if that default ever
454/// changes, this needs to follow so the renderer keeps stripping it.
455const DEFAULT_ROLE_PATTERN: &str = "{schema}-{profile}";
456
457/// Recursively strip serde-emitted defaults from a serialized manifest so
458/// the rendered YAML stays focused on author-meaningful content.
459///
460/// Strips:
461/// - `null` scalars (e.g. `login: null` on profiles, `name: null` on grants),
462/// - empty sequences (`memberships: []`, `retirements: []`, …),
463/// - empty top-level maps (`profiles: {}` when no profiles are declared),
464/// - known scalar defaults (currently `role_pattern: "{schema}-{profile}"`).
465///
466/// All stripped fields round-trip on parse because each has a `#[serde(default)]`
467/// on its struct definition, so a re-read produces an equivalent `PolicyManifest`.
468fn strip_manifest_defaults(value: serde_yaml::Value) -> serde_yaml::Value {
469    strip_manifest_defaults_at(value, &[])
470}
471
472fn strip_manifest_defaults_at(value: serde_yaml::Value, path: &[String]) -> serde_yaml::Value {
473    use serde_yaml::Value;
474    match value {
475        Value::Mapping(map) => {
476            let mut out = serde_yaml::Mapping::new();
477            for (k, v) in map {
478                let mut child_path = path.to_vec();
479                if let Some(key) = k.as_str() {
480                    child_path.push(key.to_string());
481                }
482                let cleaned = strip_manifest_defaults_at(v, &child_path);
483                if is_strippable(&child_path, &cleaned) {
484                    continue;
485                }
486                out.insert(k, cleaned);
487            }
488            Value::Mapping(out)
489        }
490        Value::Sequence(seq) => Value::Sequence(
491            seq.into_iter()
492                .map(|item| strip_manifest_defaults_at(item, path))
493                .collect(),
494        ),
495        other => other,
496    }
497}
498
499fn is_strippable(path: &[String], value: &serde_yaml::Value) -> bool {
500    use serde_yaml::Value;
501    let key = path.last().map(String::as_str);
502    match value {
503        Value::Null => true,
504        Value::Sequence(s) if s.is_empty() => {
505            // Some sequence-valued fields in `PolicyManifest` are required
506            // (no `#[serde(default)]` on the struct field) and stripping an
507            // empty value would produce YAML that no longer deserializes
508            // back into the same type. Keep this list aligned with the
509            // struct definitions in `pgroles_core::manifest`:
510            //   - `Grant.privileges`, `ProfileGrant.privileges`,
511            //     `DefaultPrivilegeGrant.privileges`
512            //   - `DefaultPrivilege.grant`
513            //   - `Membership.members`
514            !matches!(key, Some("privileges") | Some("grant") | Some("members"))
515        }
516        Value::Mapping(m) if m.is_empty() => {
517            // Only strip empty maps whose position is known to be a defaulted
518            // manifest field. A named profile such as `profiles.noop: {}` is
519            // semantically meaningful even though its serialized profile body
520            // is empty after defaults are removed.
521            matches!(path, [field] if field == "profiles")
522        }
523        Value::String(s) => {
524            // Only strip `role_pattern` at its actual manifest position
525            // (`schemas[i].role_pattern`). Matching on the leaf key alone
526            // would also strip a role/profile *config parameter* that happens
527            // to be named `role_pattern` with a value equal to the default
528            // pattern string — config maps carry arbitrary user-chosen
529            // PostgreSQL parameter names and must round-trip verbatim.
530            matches!(path, [first, last] if first == "schemas" && last == "role_pattern")
531                && s == DEFAULT_ROLE_PATTERN
532        }
533        _ => false,
534    }
535}
536
537/// Format bundle validation results for human-readable output.
538pub fn format_bundle_validation_result(validated: &ValidatedBundle) -> String {
539    let mut output = String::new();
540    output.push_str("Policy bundle is valid.\n");
541    output.push_str(&format!(
542        "  {} source document(s) loaded\n",
543        validated.documents.len()
544    ));
545    output.push_str(&format!(
546        "  {} shared profile(s) defined\n",
547        validated.bundle.shared.profiles.len()
548    ));
549    output.push_str(&format!(
550        "  {} schema(s) defined\n",
551        validated.composed.expanded.schemas.len()
552    ));
553    output.push_str(&format!(
554        "  {} role(s) defined\n",
555        validated.composed.expanded.roles.len()
556    ));
557    output.push_str(&format!(
558        "  {} grant(s) defined\n",
559        validated.composed.expanded.grants.len()
560    ));
561    output.push_str(&format!(
562        "  {} default privilege(s) defined\n",
563        validated.composed.expanded.default_privileges.len()
564    ));
565    output.push_str(&format!(
566        "  {} membership(s) defined\n",
567        validated.composed.expanded.memberships.len()
568    ));
569    output
570}
571
572/// Format a composed managed scope for human-readable debug output.
573pub fn format_managed_scope_summary(scope: &ManagedScope) -> String {
574    let mut output = String::new();
575    output.push_str("Managed scope:\n");
576    output.push_str(&format!("  {} role(s)\n", scope.roles.len()));
577    output.push_str(&format!("  {} schema(s)\n", scope.schemas.len()));
578
579    let owner_schemas: Vec<&str> = scope
580        .schemas
581        .iter()
582        .filter_map(|(schema, managed)| managed.owner.then_some(schema.as_str()))
583        .collect();
584    let binding_schemas: Vec<&str> = scope
585        .schemas
586        .iter()
587        .filter_map(|(schema, managed)| managed.bindings.then_some(schema.as_str()))
588        .collect();
589
590    output.push_str(&format!(
591        "  owner-managed schema(s): {}\n",
592        owner_schemas.len()
593    ));
594    if !owner_schemas.is_empty() {
595        output.push_str(&format!("  owner scope: {}\n", owner_schemas.join(", ")));
596    }
597
598    output.push_str(&format!(
599        "  binding-managed schema(s): {}\n",
600        binding_schemas.len()
601    ));
602    if !binding_schemas.is_empty() {
603        output.push_str(&format!(
604            "  binding scope: {}\n",
605            binding_schemas.join(", ")
606        ));
607    }
608
609    output
610}
611
612// ---------------------------------------------------------------------------
613// Inspect output formatting
614// ---------------------------------------------------------------------------
615
616/// Format a RoleGraph as a human-readable summary.
617pub fn format_role_graph_summary(graph: &RoleGraph) -> String {
618    let mut output = String::new();
619    output.push_str(&format!("Roles: {}\n", graph.roles.len()));
620    for (name, state) in &graph.roles {
621        let login_marker = if state.login { "LOGIN" } else { "NOLOGIN" };
622        output.push_str(&format!("  {name} ({login_marker})\n"));
623    }
624    output.push_str(&format!("Schemas: {}\n", graph.schemas.len()));
625    for (name, state) in &graph.schemas {
626        match &state.owner {
627            Some(owner) => output.push_str(&format!("  {name} (owner: {owner})\n")),
628            None => output.push_str(&format!("  {name}\n")),
629        }
630    }
631    output.push_str(&format!("Grants: {}\n", graph.grants.len()));
632    output.push_str(&format!(
633        "Default privileges: {}\n",
634        graph.default_privileges.len()
635    ));
636    output.push_str(&format!("Memberships: {}\n", graph.memberships.len()));
637    for edge in &graph.memberships {
638        output.push_str(&format!("  {} -> {}\n", edge.member, edge.role));
639    }
640    output
641}
642
643// ---------------------------------------------------------------------------
644// Tests
645// ---------------------------------------------------------------------------
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650    use pgroles_core::ownership::ManagedSchemaScope;
651
652    const MINIMAL_MANIFEST: &str = r#"
653default_owner: app_owner
654
655schemas:
656  - name: analytics
657    owner: app_owner
658    profiles: []
659
660roles:
661  - name: analytics
662    login: true
663    comment: "Analytics read-only role"
664
665grants:
666  - role: analytics
667    privileges: [CONNECT]
668    object: { type: database, name: mydb }
669"#;
670
671    const PROFILE_MANIFEST: &str = r#"
672default_owner: app_owner
673
674profiles:
675  editor:
676    grants:
677      - privileges: [USAGE]
678        object: { type: schema }
679      - privileges: [SELECT, INSERT, UPDATE, DELETE]
680        object: { type: table, name: "*" }
681    default_privileges:
682      - privileges: [SELECT, INSERT, UPDATE, DELETE]
683        on_type: table
684  viewer:
685    grants:
686      - privileges: [USAGE]
687        object: { type: schema }
688      - privileges: [SELECT]
689        object: { type: table, name: "*" }
690    default_privileges:
691      - privileges: [SELECT]
692        on_type: table
693
694schemas:
695  - name: inventory
696    profiles: [editor, viewer]
697  - name: catalog
698    profiles: [viewer]
699
700roles:
701  - name: app-service
702    login: true
703
704grants:
705  - role: app-service
706    privileges: [CONNECT]
707    object: { type: database, name: mydb }
708
709memberships:
710  - role: inventory-editor
711    members:
712      - name: app-service
713"#;
714
715    const INVALID_YAML: &str = r#"
716this is: [not: valid yaml: [[
717"#;
718
719    const UNDEFINED_PROFILE: &str = r#"
720profiles:
721  editor:
722    grants: []
723
724schemas:
725  - name: myschema
726    profiles: [nonexistent]
727"#;
728
729    // -----------------------------------------------------------------------
730    // parse
731    // -----------------------------------------------------------------------
732
733    #[test]
734    fn parse_valid_manifest() {
735        let result = parse(MINIMAL_MANIFEST);
736        assert!(result.is_ok());
737        let manifest = result.unwrap();
738        assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
739        assert_eq!(manifest.roles.len(), 1);
740        assert_eq!(manifest.roles[0].name, "analytics");
741    }
742
743    #[test]
744    fn parse_invalid_yaml() {
745        let result = parse(INVALID_YAML);
746        assert!(result.is_err());
747        let err_msg = result.unwrap_err().to_string();
748        assert!(err_msg.contains("YAML parse error"), "got: {err_msg}");
749    }
750
751    // -----------------------------------------------------------------------
752    // parse_and_expand
753    // -----------------------------------------------------------------------
754
755    #[test]
756    fn expand_profile_manifest() {
757        let expanded = parse_and_expand(PROFILE_MANIFEST).unwrap();
758
759        assert_eq!(expanded.schemas.len(), 2);
760        // inventory-editor, inventory-viewer, catalog-viewer, app-service
761        assert_eq!(expanded.roles.len(), 4);
762
763        let role_names: Vec<&str> = expanded.roles.iter().map(|r| r.name.as_str()).collect();
764        assert!(role_names.contains(&"inventory-editor"));
765        assert!(role_names.contains(&"inventory-viewer"));
766        assert!(role_names.contains(&"catalog-viewer"));
767        assert!(role_names.contains(&"app-service"));
768    }
769
770    #[test]
771    fn expand_undefined_profile_fails() {
772        let result = parse_and_expand(UNDEFINED_PROFILE);
773        assert!(result.is_err());
774        let err_msg = result.unwrap_err().to_string();
775        assert!(
776            err_msg.contains("nonexistent"),
777            "expected error about 'nonexistent' profile, got: {err_msg}"
778        );
779    }
780
781    // -----------------------------------------------------------------------
782    // validate_manifest
783    // -----------------------------------------------------------------------
784
785    #[test]
786    fn validate_builds_role_graph() {
787        let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
788
789        // Check the desired graph has the expected roles
790        assert_eq!(validated.desired.roles.len(), 4);
791        assert!(validated.desired.roles.contains_key("inventory-editor"));
792        assert!(validated.desired.roles.contains_key("app-service"));
793
794        // Check grants were expanded
795        assert!(!validated.desired.grants.is_empty());
796
797        // Check memberships
798        assert!(!validated.desired.memberships.is_empty());
799    }
800
801    // -----------------------------------------------------------------------
802    // compute_plan + format
803    // -----------------------------------------------------------------------
804
805    #[test]
806    fn plan_from_empty_creates_roles() {
807        let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
808        let current = RoleGraph::default(); // empty database
809
810        let changes = compute_plan(&current, &validated.desired);
811        assert!(!changes.is_empty());
812
813        let summary = PlanSummary::from_changes(&changes);
814        assert_eq!(summary.roles_created, 4); // inventory-editor, inventory-viewer, catalog-viewer, app-service
815        assert_eq!(summary.schemas_created, 2); // inventory, catalog
816        assert!(summary.grants > 0);
817        assert!(!summary.is_empty());
818    }
819
820    #[test]
821    fn plan_no_changes_when_in_sync() {
822        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
823        // Simulate a DB that already has the desired state
824        let current = validated.desired.clone();
825
826        let changes = compute_plan(&current, &validated.desired);
827        let summary = PlanSummary::from_changes(&changes);
828        assert!(summary.is_empty());
829        assert_eq!(summary.total(), 0);
830    }
831
832    #[test]
833    fn format_plan_sql_produces_sql() {
834        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
835        let current = RoleGraph::default();
836        let changes = compute_plan(&current, &validated.desired);
837
838        let sql_output = format_plan_sql(&changes);
839        assert!(
840            sql_output.contains("CREATE SCHEMA"),
841            "expected CREATE SCHEMA in: {sql_output}"
842        );
843        assert!(
844            sql_output.contains("CREATE ROLE"),
845            "expected CREATE ROLE in: {sql_output}"
846        );
847        assert!(
848            sql_output.contains("\"analytics\""),
849            "expected quoted role name in: {sql_output}"
850        );
851    }
852
853    #[test]
854    fn planned_role_drops_only_returns_drop_changes() {
855        let changes = vec![
856            Change::CreateRole {
857                name: "new-role".to_string(),
858                state: pgroles_core::model::RoleState::default(),
859            },
860            Change::DropRole {
861                name: "old-role".to_string(),
862            },
863            Change::DropRole {
864                name: "stale-role".to_string(),
865            },
866        ];
867
868        assert_eq!(
869            planned_role_drops(&changes),
870            vec!["old-role".to_string(), "stale-role".to_string()]
871        );
872    }
873
874    #[test]
875    fn apply_role_retirements_updates_plan_summary() {
876        let changes = apply_role_retirements(
877            vec![Change::DropRole {
878                name: "legacy-app".to_string(),
879            }],
880            &[pgroles_core::manifest::RoleRetirement {
881                role: "legacy-app".to_string(),
882                reassign_owned_to: Some("app-owner".to_string()),
883                drop_owned: true,
884                terminate_sessions: true,
885            }],
886        );
887
888        let summary = PlanSummary::from_changes(&changes);
889        assert_eq!(summary.roles_dropped, 1);
890        assert_eq!(summary.sessions_terminated, 1);
891        assert_eq!(summary.ownerships_reassigned, 1);
892        assert_eq!(summary.owned_objects_dropped, 1);
893        assert_eq!(summary.total(), 4);
894    }
895
896    // -----------------------------------------------------------------------
897    // PlanSummary display
898    // -----------------------------------------------------------------------
899
900    #[test]
901    fn plan_summary_display_empty() {
902        let summary = PlanSummary::default();
903        let display = summary.to_string();
904        assert!(display.contains("No changes needed"));
905    }
906
907    #[test]
908    fn plan_summary_display_with_changes() {
909        let summary = PlanSummary {
910            roles_created: 2,
911            schemas_created: 1,
912            grants: 5,
913            members_added: 1,
914            ..Default::default()
915        };
916        let display = summary.to_string();
917        assert!(display.contains("9 change(s)"), "got: {display}");
918        assert!(display.contains("2 role(s) to create"), "got: {display}");
919        assert!(display.contains("1 schema(s) to create"), "got: {display}");
920        assert!(display.contains("5 grant(s) to add"), "got: {display}");
921        assert!(display.contains("1 membership(s) to add"), "got: {display}");
922        // Should not mention zero-count items
923        assert!(!display.contains("to drop"), "got: {display}");
924        assert!(!display.contains("to revoke"), "got: {display}");
925    }
926
927    // -----------------------------------------------------------------------
928    // format_validation_result
929    // -----------------------------------------------------------------------
930
931    #[test]
932    fn validation_result_shows_counts() {
933        let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
934        let output = format_validation_result(&validated);
935        assert!(output.contains("Manifest is valid"), "got: {output}");
936        assert!(output.contains("2 schema(s)"), "got: {output}");
937        assert!(output.contains("4 role(s)"), "got: {output}");
938    }
939
940    #[test]
941    fn managed_scope_summary_lists_owner_and_binding_facets() {
942        let scope = ManagedScope {
943            roles: ["app".to_string(), "app_owner".to_string()]
944                .into_iter()
945                .collect(),
946            schemas: [
947                (
948                    "inventory".to_string(),
949                    ManagedSchemaScope {
950                        owner: true,
951                        bindings: true,
952                    },
953                ),
954                (
955                    "audit".to_string(),
956                    ManagedSchemaScope {
957                        owner: true,
958                        bindings: false,
959                    },
960                ),
961            ]
962            .into_iter()
963            .collect(),
964        };
965
966        let output = format_managed_scope_summary(&scope);
967
968        assert!(output.contains("Managed scope:"), "got: {output}");
969        assert!(output.contains("2 role(s)"), "got: {output}");
970        assert!(output.contains("2 schema(s)"), "got: {output}");
971        assert!(
972            output.contains("owner scope: audit, inventory"),
973            "got: {output}"
974        );
975        assert!(output.contains("binding scope: inventory"), "got: {output}");
976    }
977
978    // -----------------------------------------------------------------------
979    // read_manifest_file
980    // -----------------------------------------------------------------------
981
982    #[test]
983    fn read_nonexistent_file_fails() {
984        let result = read_manifest_file(Path::new("/tmp/nonexistent-pgroles-test.yaml"));
985        assert!(result.is_err());
986        let err_msg = format!("{:#}", result.unwrap_err());
987        assert!(
988            err_msg.contains("failed to read manifest file"),
989            "got: {err_msg}"
990        );
991    }
992
993    // -----------------------------------------------------------------------
994    // format_role_graph_summary
995    // -----------------------------------------------------------------------
996
997    #[test]
998    fn role_graph_summary_format() {
999        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
1000        let summary = format_role_graph_summary(&validated.desired);
1001        assert!(summary.contains("Roles: 1"), "got: {summary}");
1002        assert!(summary.contains("Schemas: 1"), "got: {summary}");
1003        assert!(summary.contains("analytics (LOGIN)"), "got: {summary}");
1004    }
1005
1006    // -----------------------------------------------------------------------
1007    // has_structural_changes — password-only drift detection
1008    // -----------------------------------------------------------------------
1009
1010    #[test]
1011    fn has_structural_changes_true_for_non_password_changes() {
1012        let summary = PlanSummary {
1013            roles_created: 1,
1014            schemas_created: 1,
1015            grants: 2,
1016            ..Default::default()
1017        };
1018        assert!(summary.has_structural_changes());
1019    }
1020
1021    #[test]
1022    fn has_structural_changes_false_for_password_only() {
1023        let summary = PlanSummary {
1024            passwords_set: 3,
1025            ..Default::default()
1026        };
1027        assert!(
1028            !summary.has_structural_changes(),
1029            "password-only plan should NOT be considered structural drift"
1030        );
1031    }
1032
1033    #[test]
1034    fn has_structural_changes_true_for_mixed() {
1035        let summary = PlanSummary {
1036            roles_created: 1,
1037            passwords_set: 2,
1038            ..Default::default()
1039        };
1040        assert!(
1041            summary.has_structural_changes(),
1042            "mixed plan with structural + password changes IS structural drift"
1043        );
1044    }
1045
1046    #[test]
1047    fn has_structural_changes_false_for_empty() {
1048        let summary = PlanSummary::default();
1049        assert!(!summary.has_structural_changes());
1050    }
1051
1052    #[test]
1053    fn plan_summary_displays_password_count() {
1054        let summary = PlanSummary {
1055            passwords_set: 2,
1056            roles_created: 1,
1057            ..Default::default()
1058        };
1059        let display = summary.to_string();
1060        assert!(display.contains("2 password(s) to set"), "got: {display}");
1061        assert!(display.contains("3 change(s)"), "got: {display}");
1062    }
1063
1064    // -----------------------------------------------------------------------
1065    // ReconciliationMode integration through compute_plan + filter
1066    // -----------------------------------------------------------------------
1067
1068    #[test]
1069    fn additive_mode_filters_revokes_from_plan() {
1070        use pgroles_core::diff::{ReconciliationMode, filter_changes};
1071        use pgroles_core::model::RoleState;
1072
1073        let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
1074
1075        let mut current = validated.desired.clone();
1076        current
1077            .roles
1078            .insert("stale-role".to_string(), RoleState::default());
1079
1080        let changes = compute_plan(&current, &validated.desired);
1081        assert!(changes.iter().any(|c| matches!(
1082            c,
1083            pgroles_core::diff::Change::DropRole { name } if name == "stale-role"
1084        )));
1085
1086        let filtered = filter_changes(changes, ReconciliationMode::Additive);
1087        assert!(
1088            !filtered
1089                .iter()
1090                .any(|c| matches!(c, pgroles_core::diff::Change::DropRole { .. })),
1091            "additive mode should filter out DropRole"
1092        );
1093    }
1094
1095    #[test]
1096    fn adopt_mode_filters_drops_but_keeps_revokes() {
1097        use pgroles_core::diff::{ReconciliationMode, filter_changes};
1098        use pgroles_core::manifest::{ObjectType, Privilege};
1099        use pgroles_core::model::{GrantKey, GrantState, RoleState};
1100        use std::collections::BTreeSet;
1101
1102        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
1103
1104        let mut current = validated.desired.clone();
1105        current
1106            .roles
1107            .insert("stale-role".to_string(), RoleState::default());
1108        current.grants.insert(
1109            GrantKey {
1110                role: "analytics".into(),
1111                object_type: ObjectType::Table,
1112                schema: Some("public".to_string()),
1113                name: Some("*".to_string()),
1114            },
1115            GrantState {
1116                privileges: BTreeSet::from([Privilege::Select]),
1117            },
1118        );
1119
1120        let changes = compute_plan(&current, &validated.desired);
1121
1122        let filtered = filter_changes(changes, ReconciliationMode::Adopt);
1123        assert!(
1124            !filtered
1125                .iter()
1126                .any(|c| matches!(c, pgroles_core::diff::Change::DropRole { .. })),
1127            "adopt mode should filter out DropRole"
1128        );
1129        assert!(
1130            filtered
1131                .iter()
1132                .any(|c| matches!(c, pgroles_core::diff::Change::Revoke { .. })),
1133            "adopt mode should keep Revoke changes"
1134        );
1135    }
1136    // -----------------------------------------------------------------------
1137    // format_plan_json
1138    // -----------------------------------------------------------------------
1139
1140    #[test]
1141    fn plan_json_produces_valid_json() {
1142        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
1143        let current = RoleGraph::default();
1144        let changes = compute_plan(&current, &validated.desired);
1145
1146        let json_output = format_plan_json(&changes).unwrap();
1147        // Should be parseable JSON
1148        let parsed: serde_json::Value = serde_json::from_str(&json_output).unwrap();
1149        assert!(parsed.is_array());
1150        // Should contain CreateRole
1151        let text = json_output.to_string();
1152        assert!(text.contains("CreateRole"), "got: {text}");
1153        assert!(text.contains("analytics"), "got: {text}");
1154    }
1155
1156    #[test]
1157    fn format_plan_json_redacts_passwords() {
1158        let changes = vec![Change::SetPassword {
1159            name: "app-svc".to_string(),
1160            password: "super-secret".to_string(),
1161        }];
1162
1163        let json = format_plan_json(&changes).expect("json formatting should succeed");
1164        assert!(json.contains("[REDACTED]"), "got: {json}");
1165        assert!(!json.contains("super-secret"), "got: {json}");
1166    }
1167
1168    #[test]
1169    fn bundle_plan_json_includes_scope_and_ownership_annotations() {
1170        let bundle = composition::parse_policy_bundle(
1171            r#"
1172sources:
1173  - file: app.yaml
1174"#,
1175        )
1176        .unwrap();
1177        let documents = vec![composition::PolicyDocument {
1178            source: "app.yaml".to_string(),
1179            fragment: composition::parse_policy_fragment(
1180                r#"
1181policy:
1182  name: app
1183scope:
1184  roles: [app]
1185roles:
1186  - name: app
1187    login: false
1188"#,
1189            )
1190            .unwrap(),
1191        }];
1192        let composed = composition::compose_bundle(&bundle, &documents).unwrap();
1193        let changes = compute_plan(&RoleGraph::default(), &composed.desired);
1194
1195        let json_output = format_bundle_plan_json(&changes, &composed).unwrap();
1196        let parsed: serde_json::Value = serde_json::from_str(&json_output).unwrap();
1197
1198        assert!(parsed.is_object());
1199        assert_eq!(
1200            parsed["schema_version"],
1201            pgroles_core::report::BUNDLE_PLAN_SCHEMA_VERSION
1202        );
1203        assert_eq!(parsed["managed_scope"]["roles"][0], "app");
1204        assert_eq!(parsed["changes"][0]["owner"]["document"], "app");
1205        assert_eq!(parsed["changes"][0]["owner"]["managed_key"]["kind"], "role");
1206        assert_eq!(parsed["changes"][0]["owner"]["managed_key"]["name"], "app");
1207    }
1208
1209    #[test]
1210    fn format_plan_sql_redacts_passwords() {
1211        let changes = vec![Change::SetPassword {
1212            name: "app-svc".to_string(),
1213            password: "super-secret".to_string(),
1214        }];
1215
1216        let sql = format_plan_sql_with_context(&changes, &sql::SqlContext::default());
1217        assert!(sql.contains("[REDACTED]"), "got: {sql}");
1218        assert!(!sql.contains("super-secret"), "got: {sql}");
1219    }
1220
1221    #[test]
1222    fn format_applied_uses_applied_header() {
1223        let summary = PlanSummary {
1224            roles_created: 1,
1225            schemas_created: 1,
1226            grants: 2,
1227            ..Default::default()
1228        };
1229
1230        let display = summary.format_applied();
1231        assert!(
1232            display.starts_with("Applied: 4 change(s)\n"),
1233            "got: {display}"
1234        );
1235        assert!(display.contains("1 role(s) to create"), "got: {display}");
1236        assert!(display.contains("1 schema(s) to create"), "got: {display}");
1237        assert!(display.contains("2 grant(s) to add"), "got: {display}");
1238        assert!(!display.contains("Plan:"), "got: {display}");
1239    }
1240
1241    // -----------------------------------------------------------------------
1242    // format_rendered_bundle
1243    // -----------------------------------------------------------------------
1244
1245    fn validated_bundle_for_render() -> ValidatedBundle {
1246        let bundle = composition::parse_policy_bundle(
1247            r#"
1248sources:
1249  - file: platform.yaml
1250  - file: app.yaml
1251"#,
1252        )
1253        .unwrap();
1254        let documents = vec![
1255            composition::PolicyDocument {
1256                source: "platform.yaml".to_string(),
1257                fragment: composition::parse_policy_fragment(
1258                    r#"
1259policy:
1260  name: platform
1261scope:
1262  roles: [app_owner]
1263  schemas:
1264    - name: inventory
1265      facets: [owner]
1266roles:
1267  - name: app_owner
1268    login: false
1269schemas:
1270  - name: inventory
1271    owner: app_owner
1272"#,
1273                )
1274                .unwrap(),
1275            },
1276            composition::PolicyDocument {
1277                source: "app.yaml".to_string(),
1278                fragment: composition::parse_policy_fragment(
1279                    r#"
1280policy:
1281  name: app
1282scope:
1283  roles: [app_service]
1284roles:
1285  - name: app_service
1286    login: true
1287"#,
1288                )
1289                .unwrap(),
1290            },
1291        ];
1292        let composed = composition::compose_bundle(&bundle, &documents).unwrap();
1293        ValidatedBundle {
1294            bundle,
1295            documents,
1296            composed,
1297        }
1298    }
1299
1300    #[test]
1301    fn rendered_bundle_round_trips_to_equivalent_expansion() {
1302        let validated = validated_bundle_for_render();
1303        let rendered = format_rendered_bundle(&validated, "bundle.yaml", true).unwrap();
1304
1305        let reparsed = validate_manifest(&rendered).expect("rendered output must validate");
1306
1307        // Stronger than just role-count: every role, schema, grant, and
1308        // default-privilege key from the composed manifest must be present
1309        // after a render -> parse -> expand round trip.
1310        use std::collections::BTreeSet;
1311        let original_roles: BTreeSet<_> = validated
1312            .composed
1313            .expanded
1314            .roles
1315            .iter()
1316            .map(|r| r.name.clone())
1317            .collect();
1318        let rendered_roles: BTreeSet<_> = reparsed
1319            .expanded
1320            .roles
1321            .iter()
1322            .map(|r| r.name.clone())
1323            .collect();
1324        assert_eq!(rendered_roles, original_roles);
1325
1326        let original_schemas: BTreeSet<_> = validated
1327            .composed
1328            .expanded
1329            .schemas
1330            .iter()
1331            .map(|s| s.name.clone())
1332            .collect();
1333        let rendered_schemas: BTreeSet<_> = reparsed
1334            .expanded
1335            .schemas
1336            .iter()
1337            .map(|s| s.name.clone())
1338            .collect();
1339        assert_eq!(rendered_schemas, original_schemas);
1340
1341        assert_eq!(
1342            reparsed.expanded.grants.len(),
1343            validated.composed.expanded.grants.len()
1344        );
1345        assert_eq!(
1346            reparsed.expanded.default_privileges.len(),
1347            validated.composed.expanded.default_privileges.len()
1348        );
1349        assert_eq!(
1350            reparsed.expanded.memberships.len(),
1351            validated.composed.expanded.memberships.len()
1352        );
1353
1354        // Role config maps must survive the render round trip verbatim —
1355        // guards against default-stripping ever reaching inside `config`.
1356        use std::collections::BTreeMap;
1357        let config_by_role = |expanded: &pgroles_core::manifest::ExpandedManifest| {
1358            expanded
1359                .roles
1360                .iter()
1361                .map(|r| (r.name.clone(), r.config.clone()))
1362                .collect::<BTreeMap<_, _>>()
1363        };
1364        assert_eq!(
1365            config_by_role(&reparsed.expanded),
1366            config_by_role(&validated.composed.expanded)
1367        );
1368    }
1369
1370    #[test]
1371    fn render_preserves_config_parameter_named_role_pattern() {
1372        // A config parameter is an arbitrary user-chosen PostgreSQL setting
1373        // name. One literally named `role_pattern` whose value equals the
1374        // default role pattern string must NOT be stripped by the renderer's
1375        // default-removal pass — only `schemas[i].role_pattern` is a
1376        // strippable manifest default.
1377        let yaml = r#"
1378schemas:
1379  - name: inventory
1380    profiles: []
1381    role_pattern: "{schema}-{profile}"
1382
1383roles:
1384  - name: app
1385    config:
1386      role_pattern: "{schema}-{profile}"
1387"#;
1388        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
1389        let stripped = strip_manifest_defaults(value);
1390        let out = serde_yaml::to_string(&stripped).unwrap();
1391
1392        // The schema-binding default is stripped...
1393        assert_eq!(
1394            out.matches("role_pattern").count(),
1395            1,
1396            "expected exactly the config entry to survive, got:\n{out}"
1397        );
1398        // ...while the config entry survives with its value intact.
1399        let reparsed: serde_yaml::Value = serde_yaml::from_str(&out).unwrap();
1400        let config_value = reparsed["roles"][0]["config"]["role_pattern"]
1401            .as_str()
1402            .expect("config.role_pattern must survive rendering");
1403        assert_eq!(config_value, "{schema}-{profile}");
1404    }
1405
1406    #[test]
1407    fn rendered_bundle_header_records_source_and_fragments() {
1408        let validated = validated_bundle_for_render();
1409        let rendered = format_rendered_bundle(&validated, "prod.yaml", true).unwrap();
1410
1411        assert!(rendered.starts_with("# Rendered by `pgroles render-bundle`."));
1412        assert!(rendered.contains("# Source bundle: prod.yaml"));
1413        assert!(rendered.contains("#   - platform.yaml (platform)"));
1414        assert!(rendered.contains("#   - app.yaml (app)"));
1415    }
1416
1417    #[test]
1418    fn rendered_bundle_header_records_manifest_schema_version() {
1419        // The schema-version marker is the diagnostic anchor that lets
1420        // users tell "the rendered file is stale because someone edited
1421        // the bundle" apart from "the rendered file is stale because
1422        // pgroles upgraded to a new manifest schema". It must always appear
1423        // in the header.
1424        let validated = validated_bundle_for_render();
1425        let rendered = format_rendered_bundle(&validated, "bundle.yaml", true).unwrap();
1426        assert!(
1427            rendered.contains(&format!("# Manifest schema: {RENDERED_MANIFEST_SCHEMA}")),
1428            "header must record manifest schema, got: {rendered}"
1429        );
1430    }
1431
1432    #[test]
1433    fn rendered_bundle_strips_empty_collections_and_nulls() {
1434        let validated = validated_bundle_for_render();
1435        let rendered = format_rendered_bundle(&validated, "bundle.yaml", false).unwrap();
1436
1437        // Empty top-level collections defaulted by serde must not appear.
1438        assert!(
1439            !rendered.contains("auth_providers: []"),
1440            "rendered output must not include empty auth_providers, got: {rendered}"
1441        );
1442        assert!(
1443            !rendered.contains("grants: []"),
1444            "rendered output must not include empty grants, got: {rendered}"
1445        );
1446        assert!(
1447            !rendered.contains("default_privileges: []"),
1448            "rendered output must not include empty default_privileges, got: {rendered}"
1449        );
1450        assert!(
1451            !rendered.contains("memberships: []"),
1452            "rendered output must not include empty memberships, got: {rendered}"
1453        );
1454        assert!(
1455            !rendered.contains("profiles: {}"),
1456            "rendered output must not include empty top-level profiles, got: {rendered}"
1457        );
1458        assert!(
1459            !rendered.contains("retirements: []"),
1460            "rendered output must not include empty retirements, got: {rendered}"
1461        );
1462        // Profile Option fields default to None — they must not serialize as `null`.
1463        assert!(
1464            !rendered.contains("login: null"),
1465            "rendered output must not include null login, got: {rendered}"
1466        );
1467        assert!(
1468            !rendered.contains("inherit: null"),
1469            "rendered output must not include null inherit, got: {rendered}"
1470        );
1471        // The default role_pattern must be elided so it doesn't churn under
1472        // future default changes.
1473        assert!(
1474            !rendered.contains("role_pattern:"),
1475            "rendered output must elide default role_pattern, got: {rendered}"
1476        );
1477    }
1478
1479    #[test]
1480    fn rendered_bundle_no_header_emits_only_yaml() {
1481        let validated = validated_bundle_for_render();
1482        let rendered = format_rendered_bundle(&validated, "bundle.yaml", false).unwrap();
1483
1484        assert!(
1485            !rendered.starts_with('#'),
1486            "without header, output should start with YAML, got: {rendered}"
1487        );
1488        // Still a valid manifest.
1489        validate_manifest(&rendered).expect("rendered output must validate");
1490    }
1491
1492    #[test]
1493    fn rendered_bundle_is_deterministic() {
1494        let validated = validated_bundle_for_render();
1495        let first = format_rendered_bundle(&validated, "bundle.yaml", true).unwrap();
1496        let second = format_rendered_bundle(&validated, "bundle.yaml", true).unwrap();
1497        assert_eq!(first, second);
1498    }
1499
1500    #[test]
1501    fn rendered_bundle_preserves_required_empty_sequences() {
1502        // Membership.members and Grant.privileges are required fields (no
1503        // `#[serde(default)]`). The renderer must NOT strip them even when
1504        // empty, or the rendered YAML fails to deserialize as a manifest.
1505        let bundle = composition::parse_policy_bundle(
1506            r#"
1507sources:
1508  - file: app.yaml
1509"#,
1510        )
1511        .unwrap();
1512        let documents = vec![composition::PolicyDocument {
1513            source: "app.yaml".to_string(),
1514            fragment: composition::parse_policy_fragment(
1515                r#"
1516policy:
1517  name: app
1518scope:
1519  roles: [empty_group]
1520roles:
1521  - name: empty_group
1522    login: false
1523memberships:
1524  - role: empty_group
1525    members: []
1526"#,
1527            )
1528            .unwrap(),
1529        }];
1530        let composed = composition::compose_bundle(&bundle, &documents).unwrap();
1531        let validated = ValidatedBundle {
1532            bundle,
1533            documents,
1534            composed,
1535        };
1536
1537        let rendered = format_rendered_bundle(&validated, "bundle.yaml", false).unwrap();
1538
1539        // The required `members:` key must remain even when its value is `[]`.
1540        assert!(
1541            rendered.contains("members:"),
1542            "required `members` field must not be stripped, got: {rendered}"
1543        );
1544
1545        // And the round trip must succeed.
1546        validate_manifest(&rendered)
1547            .expect("rendered output with empty required sequence must still parse");
1548    }
1549
1550    #[test]
1551    fn rendered_bundle_preserves_referenced_empty_profiles() {
1552        // An empty profile body is valid and still meaningful when a schema
1553        // references it: expansion creates the schema/profile role using the
1554        // default role pattern. The renderer may strip the profile's defaulted
1555        // fields, but must keep the named profile entry itself.
1556        let bundle = composition::parse_policy_bundle(
1557            r#"
1558shared:
1559  profiles:
1560    noop: {}
1561sources:
1562  - file: app.yaml
1563"#,
1564        )
1565        .unwrap();
1566        let documents = vec![composition::PolicyDocument {
1567            source: "app.yaml".to_string(),
1568            fragment: composition::parse_policy_fragment(
1569                r#"
1570policy:
1571  name: app
1572scope:
1573  schemas:
1574    - name: inventory
1575      facets: [bindings]
1576schemas:
1577  - name: inventory
1578    profiles: [noop]
1579"#,
1580            )
1581            .unwrap(),
1582        }];
1583        let composed = composition::compose_bundle(&bundle, &documents).unwrap();
1584        let validated = ValidatedBundle {
1585            bundle,
1586            documents,
1587            composed,
1588        };
1589
1590        let rendered = format_rendered_bundle(&validated, "bundle.yaml", false).unwrap();
1591
1592        assert!(
1593            rendered.contains("noop: {}"),
1594            "empty referenced profile must be preserved, got: {rendered}"
1595        );
1596        let reparsed = validate_manifest(&rendered)
1597            .expect("rendered output with an empty referenced profile must still parse");
1598        assert!(
1599            reparsed
1600                .expanded
1601                .roles
1602                .iter()
1603                .any(|role| role.name == "inventory-noop"),
1604            "empty profile must still expand to its schema/profile role"
1605        );
1606    }
1607}