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