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) => match key {
495            Some("role_pattern") => s == DEFAULT_ROLE_PATTERN,
496            _ => false,
497        },
498        _ => false,
499    }
500}
501
502/// Format bundle validation results for human-readable output.
503pub fn format_bundle_validation_result(validated: &ValidatedBundle) -> String {
504    let mut output = String::new();
505    output.push_str("Policy bundle is valid.\n");
506    output.push_str(&format!(
507        "  {} source document(s) loaded\n",
508        validated.documents.len()
509    ));
510    output.push_str(&format!(
511        "  {} shared profile(s) defined\n",
512        validated.bundle.shared.profiles.len()
513    ));
514    output.push_str(&format!(
515        "  {} schema(s) defined\n",
516        validated.composed.expanded.schemas.len()
517    ));
518    output.push_str(&format!(
519        "  {} role(s) defined\n",
520        validated.composed.expanded.roles.len()
521    ));
522    output.push_str(&format!(
523        "  {} grant(s) defined\n",
524        validated.composed.expanded.grants.len()
525    ));
526    output.push_str(&format!(
527        "  {} default privilege(s) defined\n",
528        validated.composed.expanded.default_privileges.len()
529    ));
530    output.push_str(&format!(
531        "  {} membership(s) defined\n",
532        validated.composed.expanded.memberships.len()
533    ));
534    output
535}
536
537/// Format a composed managed scope for human-readable debug output.
538pub fn format_managed_scope_summary(scope: &ManagedScope) -> String {
539    let mut output = String::new();
540    output.push_str("Managed scope:\n");
541    output.push_str(&format!("  {} role(s)\n", scope.roles.len()));
542    output.push_str(&format!("  {} schema(s)\n", scope.schemas.len()));
543
544    let owner_schemas: Vec<&str> = scope
545        .schemas
546        .iter()
547        .filter_map(|(schema, managed)| managed.owner.then_some(schema.as_str()))
548        .collect();
549    let binding_schemas: Vec<&str> = scope
550        .schemas
551        .iter()
552        .filter_map(|(schema, managed)| managed.bindings.then_some(schema.as_str()))
553        .collect();
554
555    output.push_str(&format!(
556        "  owner-managed schema(s): {}\n",
557        owner_schemas.len()
558    ));
559    if !owner_schemas.is_empty() {
560        output.push_str(&format!("  owner scope: {}\n", owner_schemas.join(", ")));
561    }
562
563    output.push_str(&format!(
564        "  binding-managed schema(s): {}\n",
565        binding_schemas.len()
566    ));
567    if !binding_schemas.is_empty() {
568        output.push_str(&format!(
569            "  binding scope: {}\n",
570            binding_schemas.join(", ")
571        ));
572    }
573
574    output
575}
576
577// ---------------------------------------------------------------------------
578// Inspect output formatting
579// ---------------------------------------------------------------------------
580
581/// Format a RoleGraph as a human-readable summary.
582pub fn format_role_graph_summary(graph: &RoleGraph) -> String {
583    let mut output = String::new();
584    output.push_str(&format!("Roles: {}\n", graph.roles.len()));
585    for (name, state) in &graph.roles {
586        let login_marker = if state.login { "LOGIN" } else { "NOLOGIN" };
587        output.push_str(&format!("  {name} ({login_marker})\n"));
588    }
589    output.push_str(&format!("Schemas: {}\n", graph.schemas.len()));
590    for (name, state) in &graph.schemas {
591        match &state.owner {
592            Some(owner) => output.push_str(&format!("  {name} (owner: {owner})\n")),
593            None => output.push_str(&format!("  {name}\n")),
594        }
595    }
596    output.push_str(&format!("Grants: {}\n", graph.grants.len()));
597    output.push_str(&format!(
598        "Default privileges: {}\n",
599        graph.default_privileges.len()
600    ));
601    output.push_str(&format!("Memberships: {}\n", graph.memberships.len()));
602    for edge in &graph.memberships {
603        output.push_str(&format!("  {} -> {}\n", edge.member, edge.role));
604    }
605    output
606}
607
608// ---------------------------------------------------------------------------
609// Tests
610// ---------------------------------------------------------------------------
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615    use pgroles_core::ownership::ManagedSchemaScope;
616
617    const MINIMAL_MANIFEST: &str = r#"
618default_owner: app_owner
619
620schemas:
621  - name: analytics
622    owner: app_owner
623    profiles: []
624
625roles:
626  - name: analytics
627    login: true
628    comment: "Analytics read-only role"
629
630grants:
631  - role: analytics
632    privileges: [CONNECT]
633    object: { type: database, name: mydb }
634"#;
635
636    const PROFILE_MANIFEST: &str = r#"
637default_owner: app_owner
638
639profiles:
640  editor:
641    grants:
642      - privileges: [USAGE]
643        object: { type: schema }
644      - privileges: [SELECT, INSERT, UPDATE, DELETE]
645        object: { type: table, name: "*" }
646    default_privileges:
647      - privileges: [SELECT, INSERT, UPDATE, DELETE]
648        on_type: table
649  viewer:
650    grants:
651      - privileges: [USAGE]
652        object: { type: schema }
653      - privileges: [SELECT]
654        object: { type: table, name: "*" }
655    default_privileges:
656      - privileges: [SELECT]
657        on_type: table
658
659schemas:
660  - name: inventory
661    profiles: [editor, viewer]
662  - name: catalog
663    profiles: [viewer]
664
665roles:
666  - name: app-service
667    login: true
668
669grants:
670  - role: app-service
671    privileges: [CONNECT]
672    object: { type: database, name: mydb }
673
674memberships:
675  - role: inventory-editor
676    members:
677      - name: app-service
678"#;
679
680    const INVALID_YAML: &str = r#"
681this is: [not: valid yaml: [[
682"#;
683
684    const UNDEFINED_PROFILE: &str = r#"
685profiles:
686  editor:
687    grants: []
688
689schemas:
690  - name: myschema
691    profiles: [nonexistent]
692"#;
693
694    // -----------------------------------------------------------------------
695    // parse
696    // -----------------------------------------------------------------------
697
698    #[test]
699    fn parse_valid_manifest() {
700        let result = parse(MINIMAL_MANIFEST);
701        assert!(result.is_ok());
702        let manifest = result.unwrap();
703        assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
704        assert_eq!(manifest.roles.len(), 1);
705        assert_eq!(manifest.roles[0].name, "analytics");
706    }
707
708    #[test]
709    fn parse_invalid_yaml() {
710        let result = parse(INVALID_YAML);
711        assert!(result.is_err());
712        let err_msg = result.unwrap_err().to_string();
713        assert!(err_msg.contains("YAML parse error"), "got: {err_msg}");
714    }
715
716    // -----------------------------------------------------------------------
717    // parse_and_expand
718    // -----------------------------------------------------------------------
719
720    #[test]
721    fn expand_profile_manifest() {
722        let expanded = parse_and_expand(PROFILE_MANIFEST).unwrap();
723
724        assert_eq!(expanded.schemas.len(), 2);
725        // inventory-editor, inventory-viewer, catalog-viewer, app-service
726        assert_eq!(expanded.roles.len(), 4);
727
728        let role_names: Vec<&str> = expanded.roles.iter().map(|r| r.name.as_str()).collect();
729        assert!(role_names.contains(&"inventory-editor"));
730        assert!(role_names.contains(&"inventory-viewer"));
731        assert!(role_names.contains(&"catalog-viewer"));
732        assert!(role_names.contains(&"app-service"));
733    }
734
735    #[test]
736    fn expand_undefined_profile_fails() {
737        let result = parse_and_expand(UNDEFINED_PROFILE);
738        assert!(result.is_err());
739        let err_msg = result.unwrap_err().to_string();
740        assert!(
741            err_msg.contains("nonexistent"),
742            "expected error about 'nonexistent' profile, got: {err_msg}"
743        );
744    }
745
746    // -----------------------------------------------------------------------
747    // validate_manifest
748    // -----------------------------------------------------------------------
749
750    #[test]
751    fn validate_builds_role_graph() {
752        let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
753
754        // Check the desired graph has the expected roles
755        assert_eq!(validated.desired.roles.len(), 4);
756        assert!(validated.desired.roles.contains_key("inventory-editor"));
757        assert!(validated.desired.roles.contains_key("app-service"));
758
759        // Check grants were expanded
760        assert!(!validated.desired.grants.is_empty());
761
762        // Check memberships
763        assert!(!validated.desired.memberships.is_empty());
764    }
765
766    // -----------------------------------------------------------------------
767    // compute_plan + format
768    // -----------------------------------------------------------------------
769
770    #[test]
771    fn plan_from_empty_creates_roles() {
772        let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
773        let current = RoleGraph::default(); // empty database
774
775        let changes = compute_plan(&current, &validated.desired);
776        assert!(!changes.is_empty());
777
778        let summary = PlanSummary::from_changes(&changes);
779        assert_eq!(summary.roles_created, 4); // inventory-editor, inventory-viewer, catalog-viewer, app-service
780        assert_eq!(summary.schemas_created, 2); // inventory, catalog
781        assert!(summary.grants > 0);
782        assert!(!summary.is_empty());
783    }
784
785    #[test]
786    fn plan_no_changes_when_in_sync() {
787        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
788        // Simulate a DB that already has the desired state
789        let current = validated.desired.clone();
790
791        let changes = compute_plan(&current, &validated.desired);
792        let summary = PlanSummary::from_changes(&changes);
793        assert!(summary.is_empty());
794        assert_eq!(summary.total(), 0);
795    }
796
797    #[test]
798    fn format_plan_sql_produces_sql() {
799        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
800        let current = RoleGraph::default();
801        let changes = compute_plan(&current, &validated.desired);
802
803        let sql_output = format_plan_sql(&changes);
804        assert!(
805            sql_output.contains("CREATE SCHEMA"),
806            "expected CREATE SCHEMA in: {sql_output}"
807        );
808        assert!(
809            sql_output.contains("CREATE ROLE"),
810            "expected CREATE ROLE in: {sql_output}"
811        );
812        assert!(
813            sql_output.contains("\"analytics\""),
814            "expected quoted role name in: {sql_output}"
815        );
816    }
817
818    #[test]
819    fn planned_role_drops_only_returns_drop_changes() {
820        let changes = vec![
821            Change::CreateRole {
822                name: "new-role".to_string(),
823                state: pgroles_core::model::RoleState::default(),
824            },
825            Change::DropRole {
826                name: "old-role".to_string(),
827            },
828            Change::DropRole {
829                name: "stale-role".to_string(),
830            },
831        ];
832
833        assert_eq!(
834            planned_role_drops(&changes),
835            vec!["old-role".to_string(), "stale-role".to_string()]
836        );
837    }
838
839    #[test]
840    fn apply_role_retirements_updates_plan_summary() {
841        let changes = apply_role_retirements(
842            vec![Change::DropRole {
843                name: "legacy-app".to_string(),
844            }],
845            &[pgroles_core::manifest::RoleRetirement {
846                role: "legacy-app".to_string(),
847                reassign_owned_to: Some("app-owner".to_string()),
848                drop_owned: true,
849                terminate_sessions: true,
850            }],
851        );
852
853        let summary = PlanSummary::from_changes(&changes);
854        assert_eq!(summary.roles_dropped, 1);
855        assert_eq!(summary.sessions_terminated, 1);
856        assert_eq!(summary.ownerships_reassigned, 1);
857        assert_eq!(summary.owned_objects_dropped, 1);
858        assert_eq!(summary.total(), 4);
859    }
860
861    // -----------------------------------------------------------------------
862    // PlanSummary display
863    // -----------------------------------------------------------------------
864
865    #[test]
866    fn plan_summary_display_empty() {
867        let summary = PlanSummary::default();
868        let display = summary.to_string();
869        assert!(display.contains("No changes needed"));
870    }
871
872    #[test]
873    fn plan_summary_display_with_changes() {
874        let summary = PlanSummary {
875            roles_created: 2,
876            schemas_created: 1,
877            grants: 5,
878            members_added: 1,
879            ..Default::default()
880        };
881        let display = summary.to_string();
882        assert!(display.contains("9 change(s)"), "got: {display}");
883        assert!(display.contains("2 role(s) to create"), "got: {display}");
884        assert!(display.contains("1 schema(s) to create"), "got: {display}");
885        assert!(display.contains("5 grant(s) to add"), "got: {display}");
886        assert!(display.contains("1 membership(s) to add"), "got: {display}");
887        // Should not mention zero-count items
888        assert!(!display.contains("to drop"), "got: {display}");
889        assert!(!display.contains("to revoke"), "got: {display}");
890    }
891
892    // -----------------------------------------------------------------------
893    // format_validation_result
894    // -----------------------------------------------------------------------
895
896    #[test]
897    fn validation_result_shows_counts() {
898        let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
899        let output = format_validation_result(&validated);
900        assert!(output.contains("Manifest is valid"), "got: {output}");
901        assert!(output.contains("2 schema(s)"), "got: {output}");
902        assert!(output.contains("4 role(s)"), "got: {output}");
903    }
904
905    #[test]
906    fn managed_scope_summary_lists_owner_and_binding_facets() {
907        let scope = ManagedScope {
908            roles: ["app".to_string(), "app_owner".to_string()]
909                .into_iter()
910                .collect(),
911            schemas: [
912                (
913                    "inventory".to_string(),
914                    ManagedSchemaScope {
915                        owner: true,
916                        bindings: true,
917                    },
918                ),
919                (
920                    "audit".to_string(),
921                    ManagedSchemaScope {
922                        owner: true,
923                        bindings: false,
924                    },
925                ),
926            ]
927            .into_iter()
928            .collect(),
929        };
930
931        let output = format_managed_scope_summary(&scope);
932
933        assert!(output.contains("Managed scope:"), "got: {output}");
934        assert!(output.contains("2 role(s)"), "got: {output}");
935        assert!(output.contains("2 schema(s)"), "got: {output}");
936        assert!(
937            output.contains("owner scope: audit, inventory"),
938            "got: {output}"
939        );
940        assert!(output.contains("binding scope: inventory"), "got: {output}");
941    }
942
943    // -----------------------------------------------------------------------
944    // read_manifest_file
945    // -----------------------------------------------------------------------
946
947    #[test]
948    fn read_nonexistent_file_fails() {
949        let result = read_manifest_file(Path::new("/tmp/nonexistent-pgroles-test.yaml"));
950        assert!(result.is_err());
951        let err_msg = format!("{:#}", result.unwrap_err());
952        assert!(
953            err_msg.contains("failed to read manifest file"),
954            "got: {err_msg}"
955        );
956    }
957
958    // -----------------------------------------------------------------------
959    // format_role_graph_summary
960    // -----------------------------------------------------------------------
961
962    #[test]
963    fn role_graph_summary_format() {
964        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
965        let summary = format_role_graph_summary(&validated.desired);
966        assert!(summary.contains("Roles: 1"), "got: {summary}");
967        assert!(summary.contains("Schemas: 1"), "got: {summary}");
968        assert!(summary.contains("analytics (LOGIN)"), "got: {summary}");
969    }
970
971    // -----------------------------------------------------------------------
972    // has_structural_changes — password-only drift detection
973    // -----------------------------------------------------------------------
974
975    #[test]
976    fn has_structural_changes_true_for_non_password_changes() {
977        let summary = PlanSummary {
978            roles_created: 1,
979            schemas_created: 1,
980            grants: 2,
981            ..Default::default()
982        };
983        assert!(summary.has_structural_changes());
984    }
985
986    #[test]
987    fn has_structural_changes_false_for_password_only() {
988        let summary = PlanSummary {
989            passwords_set: 3,
990            ..Default::default()
991        };
992        assert!(
993            !summary.has_structural_changes(),
994            "password-only plan should NOT be considered structural drift"
995        );
996    }
997
998    #[test]
999    fn has_structural_changes_true_for_mixed() {
1000        let summary = PlanSummary {
1001            roles_created: 1,
1002            passwords_set: 2,
1003            ..Default::default()
1004        };
1005        assert!(
1006            summary.has_structural_changes(),
1007            "mixed plan with structural + password changes IS structural drift"
1008        );
1009    }
1010
1011    #[test]
1012    fn has_structural_changes_false_for_empty() {
1013        let summary = PlanSummary::default();
1014        assert!(!summary.has_structural_changes());
1015    }
1016
1017    #[test]
1018    fn plan_summary_displays_password_count() {
1019        let summary = PlanSummary {
1020            passwords_set: 2,
1021            roles_created: 1,
1022            ..Default::default()
1023        };
1024        let display = summary.to_string();
1025        assert!(display.contains("2 password(s) to set"), "got: {display}");
1026        assert!(display.contains("3 change(s)"), "got: {display}");
1027    }
1028
1029    // -----------------------------------------------------------------------
1030    // ReconciliationMode integration through compute_plan + filter
1031    // -----------------------------------------------------------------------
1032
1033    #[test]
1034    fn additive_mode_filters_revokes_from_plan() {
1035        use pgroles_core::diff::{ReconciliationMode, filter_changes};
1036        use pgroles_core::model::RoleState;
1037
1038        let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
1039
1040        let mut current = validated.desired.clone();
1041        current
1042            .roles
1043            .insert("stale-role".to_string(), RoleState::default());
1044
1045        let changes = compute_plan(&current, &validated.desired);
1046        assert!(changes.iter().any(|c| matches!(
1047            c,
1048            pgroles_core::diff::Change::DropRole { name } if name == "stale-role"
1049        )));
1050
1051        let filtered = filter_changes(changes, ReconciliationMode::Additive);
1052        assert!(
1053            !filtered
1054                .iter()
1055                .any(|c| matches!(c, pgroles_core::diff::Change::DropRole { .. })),
1056            "additive mode should filter out DropRole"
1057        );
1058    }
1059
1060    #[test]
1061    fn adopt_mode_filters_drops_but_keeps_revokes() {
1062        use pgroles_core::diff::{ReconciliationMode, filter_changes};
1063        use pgroles_core::manifest::{ObjectType, Privilege};
1064        use pgroles_core::model::{GrantKey, GrantState, RoleState};
1065        use std::collections::BTreeSet;
1066
1067        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
1068
1069        let mut current = validated.desired.clone();
1070        current
1071            .roles
1072            .insert("stale-role".to_string(), RoleState::default());
1073        current.grants.insert(
1074            GrantKey {
1075                role: "analytics".to_string(),
1076                object_type: ObjectType::Table,
1077                schema: Some("public".to_string()),
1078                name: Some("*".to_string()),
1079            },
1080            GrantState {
1081                privileges: BTreeSet::from([Privilege::Select]),
1082            },
1083        );
1084
1085        let changes = compute_plan(&current, &validated.desired);
1086
1087        let filtered = filter_changes(changes, ReconciliationMode::Adopt);
1088        assert!(
1089            !filtered
1090                .iter()
1091                .any(|c| matches!(c, pgroles_core::diff::Change::DropRole { .. })),
1092            "adopt mode should filter out DropRole"
1093        );
1094        assert!(
1095            filtered
1096                .iter()
1097                .any(|c| matches!(c, pgroles_core::diff::Change::Revoke { .. })),
1098            "adopt mode should keep Revoke changes"
1099        );
1100    }
1101    // -----------------------------------------------------------------------
1102    // format_plan_json
1103    // -----------------------------------------------------------------------
1104
1105    #[test]
1106    fn plan_json_produces_valid_json() {
1107        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
1108        let current = RoleGraph::default();
1109        let changes = compute_plan(&current, &validated.desired);
1110
1111        let json_output = format_plan_json(&changes).unwrap();
1112        // Should be parseable JSON
1113        let parsed: serde_json::Value = serde_json::from_str(&json_output).unwrap();
1114        assert!(parsed.is_array());
1115        // Should contain CreateRole
1116        let text = json_output.to_string();
1117        assert!(text.contains("CreateRole"), "got: {text}");
1118        assert!(text.contains("analytics"), "got: {text}");
1119    }
1120
1121    #[test]
1122    fn format_plan_json_redacts_passwords() {
1123        let changes = vec![Change::SetPassword {
1124            name: "app-svc".to_string(),
1125            password: "super-secret".to_string(),
1126        }];
1127
1128        let json = format_plan_json(&changes).expect("json formatting should succeed");
1129        assert!(json.contains("[REDACTED]"), "got: {json}");
1130        assert!(!json.contains("super-secret"), "got: {json}");
1131    }
1132
1133    #[test]
1134    fn bundle_plan_json_includes_scope_and_ownership_annotations() {
1135        let bundle = composition::parse_policy_bundle(
1136            r#"
1137sources:
1138  - file: app.yaml
1139"#,
1140        )
1141        .unwrap();
1142        let documents = vec![composition::PolicyDocument {
1143            source: "app.yaml".to_string(),
1144            fragment: composition::parse_policy_fragment(
1145                r#"
1146policy:
1147  name: app
1148scope:
1149  roles: [app]
1150roles:
1151  - name: app
1152    login: false
1153"#,
1154            )
1155            .unwrap(),
1156        }];
1157        let composed = composition::compose_bundle(&bundle, &documents).unwrap();
1158        let changes = compute_plan(&RoleGraph::default(), &composed.desired);
1159
1160        let json_output = format_bundle_plan_json(&changes, &composed).unwrap();
1161        let parsed: serde_json::Value = serde_json::from_str(&json_output).unwrap();
1162
1163        assert!(parsed.is_object());
1164        assert_eq!(parsed["schema_version"], "pgroles.bundle_plan.v1");
1165        assert_eq!(parsed["managed_scope"]["roles"][0], "app");
1166        assert_eq!(parsed["changes"][0]["owner"]["document"], "app");
1167        assert_eq!(parsed["changes"][0]["owner"]["managed_key"]["kind"], "role");
1168        assert_eq!(parsed["changes"][0]["owner"]["managed_key"]["name"], "app");
1169    }
1170
1171    #[test]
1172    fn format_plan_sql_redacts_passwords() {
1173        let changes = vec![Change::SetPassword {
1174            name: "app-svc".to_string(),
1175            password: "super-secret".to_string(),
1176        }];
1177
1178        let sql = format_plan_sql_with_context(&changes, &sql::SqlContext::default());
1179        assert!(sql.contains("[REDACTED]"), "got: {sql}");
1180        assert!(!sql.contains("super-secret"), "got: {sql}");
1181    }
1182
1183    #[test]
1184    fn format_applied_uses_applied_header() {
1185        let summary = PlanSummary {
1186            roles_created: 1,
1187            schemas_created: 1,
1188            grants: 2,
1189            ..Default::default()
1190        };
1191
1192        let display = summary.format_applied();
1193        assert!(
1194            display.starts_with("Applied: 4 change(s)\n"),
1195            "got: {display}"
1196        );
1197        assert!(display.contains("1 role(s) to create"), "got: {display}");
1198        assert!(display.contains("1 schema(s) to create"), "got: {display}");
1199        assert!(display.contains("2 grant(s) to add"), "got: {display}");
1200        assert!(!display.contains("Plan:"), "got: {display}");
1201    }
1202
1203    // -----------------------------------------------------------------------
1204    // format_rendered_bundle
1205    // -----------------------------------------------------------------------
1206
1207    fn validated_bundle_for_render() -> ValidatedBundle {
1208        let bundle = composition::parse_policy_bundle(
1209            r#"
1210sources:
1211  - file: platform.yaml
1212  - file: app.yaml
1213"#,
1214        )
1215        .unwrap();
1216        let documents = vec![
1217            composition::PolicyDocument {
1218                source: "platform.yaml".to_string(),
1219                fragment: composition::parse_policy_fragment(
1220                    r#"
1221policy:
1222  name: platform
1223scope:
1224  roles: [app_owner]
1225  schemas:
1226    - name: inventory
1227      facets: [owner]
1228roles:
1229  - name: app_owner
1230    login: false
1231schemas:
1232  - name: inventory
1233    owner: app_owner
1234"#,
1235                )
1236                .unwrap(),
1237            },
1238            composition::PolicyDocument {
1239                source: "app.yaml".to_string(),
1240                fragment: composition::parse_policy_fragment(
1241                    r#"
1242policy:
1243  name: app
1244scope:
1245  roles: [app_service]
1246roles:
1247  - name: app_service
1248    login: true
1249"#,
1250                )
1251                .unwrap(),
1252            },
1253        ];
1254        let composed = composition::compose_bundle(&bundle, &documents).unwrap();
1255        ValidatedBundle {
1256            bundle,
1257            documents,
1258            composed,
1259        }
1260    }
1261
1262    #[test]
1263    fn rendered_bundle_round_trips_to_equivalent_expansion() {
1264        let validated = validated_bundle_for_render();
1265        let rendered = format_rendered_bundle(&validated, "bundle.yaml", true).unwrap();
1266
1267        let reparsed = validate_manifest(&rendered).expect("rendered output must validate");
1268
1269        // Stronger than just role-count: every role, schema, grant, and
1270        // default-privilege key from the composed manifest must be present
1271        // after a render -> parse -> expand round trip.
1272        use std::collections::BTreeSet;
1273        let original_roles: BTreeSet<_> = validated
1274            .composed
1275            .expanded
1276            .roles
1277            .iter()
1278            .map(|r| r.name.clone())
1279            .collect();
1280        let rendered_roles: BTreeSet<_> = reparsed
1281            .expanded
1282            .roles
1283            .iter()
1284            .map(|r| r.name.clone())
1285            .collect();
1286        assert_eq!(rendered_roles, original_roles);
1287
1288        let original_schemas: BTreeSet<_> = validated
1289            .composed
1290            .expanded
1291            .schemas
1292            .iter()
1293            .map(|s| s.name.clone())
1294            .collect();
1295        let rendered_schemas: BTreeSet<_> = reparsed
1296            .expanded
1297            .schemas
1298            .iter()
1299            .map(|s| s.name.clone())
1300            .collect();
1301        assert_eq!(rendered_schemas, original_schemas);
1302
1303        assert_eq!(
1304            reparsed.expanded.grants.len(),
1305            validated.composed.expanded.grants.len()
1306        );
1307        assert_eq!(
1308            reparsed.expanded.default_privileges.len(),
1309            validated.composed.expanded.default_privileges.len()
1310        );
1311        assert_eq!(
1312            reparsed.expanded.memberships.len(),
1313            validated.composed.expanded.memberships.len()
1314        );
1315    }
1316
1317    #[test]
1318    fn rendered_bundle_header_records_source_and_fragments() {
1319        let validated = validated_bundle_for_render();
1320        let rendered = format_rendered_bundle(&validated, "prod.yaml", true).unwrap();
1321
1322        assert!(rendered.starts_with("# Rendered by `pgroles render-bundle`."));
1323        assert!(rendered.contains("# Source bundle: prod.yaml"));
1324        assert!(rendered.contains("#   - platform.yaml (platform)"));
1325        assert!(rendered.contains("#   - app.yaml (app)"));
1326    }
1327
1328    #[test]
1329    fn rendered_bundle_header_records_manifest_schema_version() {
1330        // The schema-version marker is the diagnostic anchor that lets
1331        // users tell "the rendered file is stale because someone edited
1332        // the bundle" apart from "the rendered file is stale because
1333        // pgroles upgraded to a new manifest schema". It must always appear
1334        // in the header.
1335        let validated = validated_bundle_for_render();
1336        let rendered = format_rendered_bundle(&validated, "bundle.yaml", true).unwrap();
1337        assert!(
1338            rendered.contains(&format!("# Manifest schema: {RENDERED_MANIFEST_SCHEMA}")),
1339            "header must record manifest schema, got: {rendered}"
1340        );
1341    }
1342
1343    #[test]
1344    fn rendered_bundle_strips_empty_collections_and_nulls() {
1345        let validated = validated_bundle_for_render();
1346        let rendered = format_rendered_bundle(&validated, "bundle.yaml", false).unwrap();
1347
1348        // Empty top-level collections defaulted by serde must not appear.
1349        assert!(
1350            !rendered.contains("auth_providers: []"),
1351            "rendered output must not include empty auth_providers, got: {rendered}"
1352        );
1353        assert!(
1354            !rendered.contains("grants: []"),
1355            "rendered output must not include empty grants, got: {rendered}"
1356        );
1357        assert!(
1358            !rendered.contains("default_privileges: []"),
1359            "rendered output must not include empty default_privileges, got: {rendered}"
1360        );
1361        assert!(
1362            !rendered.contains("memberships: []"),
1363            "rendered output must not include empty memberships, got: {rendered}"
1364        );
1365        assert!(
1366            !rendered.contains("profiles: {}"),
1367            "rendered output must not include empty top-level profiles, got: {rendered}"
1368        );
1369        assert!(
1370            !rendered.contains("retirements: []"),
1371            "rendered output must not include empty retirements, got: {rendered}"
1372        );
1373        // Profile Option fields default to None — they must not serialize as `null`.
1374        assert!(
1375            !rendered.contains("login: null"),
1376            "rendered output must not include null login, got: {rendered}"
1377        );
1378        assert!(
1379            !rendered.contains("inherit: null"),
1380            "rendered output must not include null inherit, got: {rendered}"
1381        );
1382        // The default role_pattern must be elided so it doesn't churn under
1383        // future default changes.
1384        assert!(
1385            !rendered.contains("role_pattern:"),
1386            "rendered output must elide default role_pattern, got: {rendered}"
1387        );
1388    }
1389
1390    #[test]
1391    fn rendered_bundle_no_header_emits_only_yaml() {
1392        let validated = validated_bundle_for_render();
1393        let rendered = format_rendered_bundle(&validated, "bundle.yaml", false).unwrap();
1394
1395        assert!(
1396            !rendered.starts_with('#'),
1397            "without header, output should start with YAML, got: {rendered}"
1398        );
1399        // Still a valid manifest.
1400        validate_manifest(&rendered).expect("rendered output must validate");
1401    }
1402
1403    #[test]
1404    fn rendered_bundle_is_deterministic() {
1405        let validated = validated_bundle_for_render();
1406        let first = format_rendered_bundle(&validated, "bundle.yaml", true).unwrap();
1407        let second = format_rendered_bundle(&validated, "bundle.yaml", true).unwrap();
1408        assert_eq!(first, second);
1409    }
1410
1411    #[test]
1412    fn rendered_bundle_preserves_required_empty_sequences() {
1413        // Membership.members and Grant.privileges are required fields (no
1414        // `#[serde(default)]`). The renderer must NOT strip them even when
1415        // empty, or the rendered YAML fails to deserialize as a manifest.
1416        let bundle = composition::parse_policy_bundle(
1417            r#"
1418sources:
1419  - file: app.yaml
1420"#,
1421        )
1422        .unwrap();
1423        let documents = vec![composition::PolicyDocument {
1424            source: "app.yaml".to_string(),
1425            fragment: composition::parse_policy_fragment(
1426                r#"
1427policy:
1428  name: app
1429scope:
1430  roles: [empty_group]
1431roles:
1432  - name: empty_group
1433    login: false
1434memberships:
1435  - role: empty_group
1436    members: []
1437"#,
1438            )
1439            .unwrap(),
1440        }];
1441        let composed = composition::compose_bundle(&bundle, &documents).unwrap();
1442        let validated = ValidatedBundle {
1443            bundle,
1444            documents,
1445            composed,
1446        };
1447
1448        let rendered = format_rendered_bundle(&validated, "bundle.yaml", false).unwrap();
1449
1450        // The required `members:` key must remain even when its value is `[]`.
1451        assert!(
1452            rendered.contains("members:"),
1453            "required `members` field must not be stripped, got: {rendered}"
1454        );
1455
1456        // And the round trip must succeed.
1457        validate_manifest(&rendered)
1458            .expect("rendered output with empty required sequence must still parse");
1459    }
1460
1461    #[test]
1462    fn rendered_bundle_preserves_referenced_empty_profiles() {
1463        // An empty profile body is valid and still meaningful when a schema
1464        // references it: expansion creates the schema/profile role using the
1465        // default role pattern. The renderer may strip the profile's defaulted
1466        // fields, but must keep the named profile entry itself.
1467        let bundle = composition::parse_policy_bundle(
1468            r#"
1469shared:
1470  profiles:
1471    noop: {}
1472sources:
1473  - file: app.yaml
1474"#,
1475        )
1476        .unwrap();
1477        let documents = vec![composition::PolicyDocument {
1478            source: "app.yaml".to_string(),
1479            fragment: composition::parse_policy_fragment(
1480                r#"
1481policy:
1482  name: app
1483scope:
1484  schemas:
1485    - name: inventory
1486      facets: [bindings]
1487schemas:
1488  - name: inventory
1489    profiles: [noop]
1490"#,
1491            )
1492            .unwrap(),
1493        }];
1494        let composed = composition::compose_bundle(&bundle, &documents).unwrap();
1495        let validated = ValidatedBundle {
1496            bundle,
1497            documents,
1498            composed,
1499        };
1500
1501        let rendered = format_rendered_bundle(&validated, "bundle.yaml", false).unwrap();
1502
1503        assert!(
1504            rendered.contains("noop: {}"),
1505            "empty referenced profile must be preserved, got: {rendered}"
1506        );
1507        let reparsed = validate_manifest(&rendered)
1508            .expect("rendered output with an empty referenced profile must still parse");
1509        assert!(
1510            reparsed
1511                .expanded
1512                .roles
1513                .iter()
1514                .any(|role| role.name == "inventory-noop"),
1515            "empty profile must still expand to its schema/profile role"
1516        );
1517    }
1518}