1use 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
19pub 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
29pub fn parse(yaml: &str) -> Result<PolicyManifest> {
35 manifest::parse_manifest(yaml).map_err(|err| anyhow::anyhow!("{err}"))
36}
37
38pub 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
44pub 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
73pub struct ValidatedManifest {
75 pub manifest: PolicyManifest,
76 pub expanded: ExpandedManifest,
77 pub desired: RoleGraph,
78}
79
80pub 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
119pub struct ValidatedBundle {
121 pub bundle: PolicyBundle,
122 pub documents: Vec<PolicyDocument>,
123 pub composed: ComposedPolicy,
124}
125
126pub fn compute_plan(current: &RoleGraph, desired: &RoleGraph) -> Vec<Change> {
132 diff::diff(current, desired)
133}
134
135pub 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
146pub fn apply_role_retirements(changes: Vec<Change>, retirements: &[RoleRetirement]) -> Vec<Change> {
148 diff::apply_role_retirements(changes, retirements)
149}
150
151pub 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
158pub 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
166pub fn format_plan_sql(changes: &[Change]) -> String {
172 sql::render_all(changes)
173}
174
175pub 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
183pub 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
189pub 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#[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 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 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 pub fn is_empty(&self) -> bool {
271 self.total() == 0
272 }
273
274 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
337pub 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
364pub fn format_bundle_validation_result(validated: &ValidatedBundle) -> String {
366 let mut output = String::new();
367 output.push_str("Policy bundle is valid.\n");
368 output.push_str(&format!(
369 " {} source document(s) loaded\n",
370 validated.documents.len()
371 ));
372 output.push_str(&format!(
373 " {} shared profile(s) defined\n",
374 validated.bundle.shared.profiles.len()
375 ));
376 output.push_str(&format!(
377 " {} schema(s) defined\n",
378 validated.composed.expanded.schemas.len()
379 ));
380 output.push_str(&format!(
381 " {} role(s) defined\n",
382 validated.composed.expanded.roles.len()
383 ));
384 output.push_str(&format!(
385 " {} grant(s) defined\n",
386 validated.composed.expanded.grants.len()
387 ));
388 output.push_str(&format!(
389 " {} default privilege(s) defined\n",
390 validated.composed.expanded.default_privileges.len()
391 ));
392 output.push_str(&format!(
393 " {} membership(s) defined\n",
394 validated.composed.expanded.memberships.len()
395 ));
396 output
397}
398
399pub fn format_managed_scope_summary(scope: &ManagedScope) -> String {
401 let mut output = String::new();
402 output.push_str("Managed scope:\n");
403 output.push_str(&format!(" {} role(s)\n", scope.roles.len()));
404 output.push_str(&format!(" {} schema(s)\n", scope.schemas.len()));
405
406 let owner_schemas: Vec<&str> = scope
407 .schemas
408 .iter()
409 .filter_map(|(schema, managed)| managed.owner.then_some(schema.as_str()))
410 .collect();
411 let binding_schemas: Vec<&str> = scope
412 .schemas
413 .iter()
414 .filter_map(|(schema, managed)| managed.bindings.then_some(schema.as_str()))
415 .collect();
416
417 output.push_str(&format!(
418 " owner-managed schema(s): {}\n",
419 owner_schemas.len()
420 ));
421 if !owner_schemas.is_empty() {
422 output.push_str(&format!(" owner scope: {}\n", owner_schemas.join(", ")));
423 }
424
425 output.push_str(&format!(
426 " binding-managed schema(s): {}\n",
427 binding_schemas.len()
428 ));
429 if !binding_schemas.is_empty() {
430 output.push_str(&format!(
431 " binding scope: {}\n",
432 binding_schemas.join(", ")
433 ));
434 }
435
436 output
437}
438
439pub fn format_role_graph_summary(graph: &RoleGraph) -> String {
445 let mut output = String::new();
446 output.push_str(&format!("Roles: {}\n", graph.roles.len()));
447 for (name, state) in &graph.roles {
448 let login_marker = if state.login { "LOGIN" } else { "NOLOGIN" };
449 output.push_str(&format!(" {name} ({login_marker})\n"));
450 }
451 output.push_str(&format!("Schemas: {}\n", graph.schemas.len()));
452 for (name, state) in &graph.schemas {
453 match &state.owner {
454 Some(owner) => output.push_str(&format!(" {name} (owner: {owner})\n")),
455 None => output.push_str(&format!(" {name}\n")),
456 }
457 }
458 output.push_str(&format!("Grants: {}\n", graph.grants.len()));
459 output.push_str(&format!(
460 "Default privileges: {}\n",
461 graph.default_privileges.len()
462 ));
463 output.push_str(&format!("Memberships: {}\n", graph.memberships.len()));
464 for edge in &graph.memberships {
465 output.push_str(&format!(" {} -> {}\n", edge.member, edge.role));
466 }
467 output
468}
469
470#[cfg(test)]
475mod tests {
476 use super::*;
477 use pgroles_core::ownership::ManagedSchemaScope;
478
479 const MINIMAL_MANIFEST: &str = r#"
480default_owner: app_owner
481
482schemas:
483 - name: analytics
484 owner: app_owner
485 profiles: []
486
487roles:
488 - name: analytics
489 login: true
490 comment: "Analytics read-only role"
491
492grants:
493 - role: analytics
494 privileges: [CONNECT]
495 object: { type: database, name: mydb }
496"#;
497
498 const PROFILE_MANIFEST: &str = r#"
499default_owner: app_owner
500
501profiles:
502 editor:
503 grants:
504 - privileges: [USAGE]
505 object: { type: schema }
506 - privileges: [SELECT, INSERT, UPDATE, DELETE]
507 object: { type: table, name: "*" }
508 default_privileges:
509 - privileges: [SELECT, INSERT, UPDATE, DELETE]
510 on_type: table
511 viewer:
512 grants:
513 - privileges: [USAGE]
514 object: { type: schema }
515 - privileges: [SELECT]
516 object: { type: table, name: "*" }
517 default_privileges:
518 - privileges: [SELECT]
519 on_type: table
520
521schemas:
522 - name: inventory
523 profiles: [editor, viewer]
524 - name: catalog
525 profiles: [viewer]
526
527roles:
528 - name: app-service
529 login: true
530
531grants:
532 - role: app-service
533 privileges: [CONNECT]
534 object: { type: database, name: mydb }
535
536memberships:
537 - role: inventory-editor
538 members:
539 - name: app-service
540"#;
541
542 const INVALID_YAML: &str = r#"
543this is: [not: valid yaml: [[
544"#;
545
546 const UNDEFINED_PROFILE: &str = r#"
547profiles:
548 editor:
549 grants: []
550
551schemas:
552 - name: myschema
553 profiles: [nonexistent]
554"#;
555
556 #[test]
561 fn parse_valid_manifest() {
562 let result = parse(MINIMAL_MANIFEST);
563 assert!(result.is_ok());
564 let manifest = result.unwrap();
565 assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
566 assert_eq!(manifest.roles.len(), 1);
567 assert_eq!(manifest.roles[0].name, "analytics");
568 }
569
570 #[test]
571 fn parse_invalid_yaml() {
572 let result = parse(INVALID_YAML);
573 assert!(result.is_err());
574 let err_msg = result.unwrap_err().to_string();
575 assert!(err_msg.contains("YAML parse error"), "got: {err_msg}");
576 }
577
578 #[test]
583 fn expand_profile_manifest() {
584 let expanded = parse_and_expand(PROFILE_MANIFEST).unwrap();
585
586 assert_eq!(expanded.schemas.len(), 2);
587 assert_eq!(expanded.roles.len(), 4);
589
590 let role_names: Vec<&str> = expanded.roles.iter().map(|r| r.name.as_str()).collect();
591 assert!(role_names.contains(&"inventory-editor"));
592 assert!(role_names.contains(&"inventory-viewer"));
593 assert!(role_names.contains(&"catalog-viewer"));
594 assert!(role_names.contains(&"app-service"));
595 }
596
597 #[test]
598 fn expand_undefined_profile_fails() {
599 let result = parse_and_expand(UNDEFINED_PROFILE);
600 assert!(result.is_err());
601 let err_msg = result.unwrap_err().to_string();
602 assert!(
603 err_msg.contains("nonexistent"),
604 "expected error about 'nonexistent' profile, got: {err_msg}"
605 );
606 }
607
608 #[test]
613 fn validate_builds_role_graph() {
614 let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
615
616 assert_eq!(validated.desired.roles.len(), 4);
618 assert!(validated.desired.roles.contains_key("inventory-editor"));
619 assert!(validated.desired.roles.contains_key("app-service"));
620
621 assert!(!validated.desired.grants.is_empty());
623
624 assert!(!validated.desired.memberships.is_empty());
626 }
627
628 #[test]
633 fn plan_from_empty_creates_roles() {
634 let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
635 let current = RoleGraph::default(); let changes = compute_plan(¤t, &validated.desired);
638 assert!(!changes.is_empty());
639
640 let summary = PlanSummary::from_changes(&changes);
641 assert_eq!(summary.roles_created, 4); assert_eq!(summary.schemas_created, 2); assert!(summary.grants > 0);
644 assert!(!summary.is_empty());
645 }
646
647 #[test]
648 fn plan_no_changes_when_in_sync() {
649 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
650 let current = validated.desired.clone();
652
653 let changes = compute_plan(¤t, &validated.desired);
654 let summary = PlanSummary::from_changes(&changes);
655 assert!(summary.is_empty());
656 assert_eq!(summary.total(), 0);
657 }
658
659 #[test]
660 fn format_plan_sql_produces_sql() {
661 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
662 let current = RoleGraph::default();
663 let changes = compute_plan(¤t, &validated.desired);
664
665 let sql_output = format_plan_sql(&changes);
666 assert!(
667 sql_output.contains("CREATE SCHEMA"),
668 "expected CREATE SCHEMA in: {sql_output}"
669 );
670 assert!(
671 sql_output.contains("CREATE ROLE"),
672 "expected CREATE ROLE in: {sql_output}"
673 );
674 assert!(
675 sql_output.contains("\"analytics\""),
676 "expected quoted role name in: {sql_output}"
677 );
678 }
679
680 #[test]
681 fn planned_role_drops_only_returns_drop_changes() {
682 let changes = vec![
683 Change::CreateRole {
684 name: "new-role".to_string(),
685 state: pgroles_core::model::RoleState::default(),
686 },
687 Change::DropRole {
688 name: "old-role".to_string(),
689 },
690 Change::DropRole {
691 name: "stale-role".to_string(),
692 },
693 ];
694
695 assert_eq!(
696 planned_role_drops(&changes),
697 vec!["old-role".to_string(), "stale-role".to_string()]
698 );
699 }
700
701 #[test]
702 fn apply_role_retirements_updates_plan_summary() {
703 let changes = apply_role_retirements(
704 vec![Change::DropRole {
705 name: "legacy-app".to_string(),
706 }],
707 &[pgroles_core::manifest::RoleRetirement {
708 role: "legacy-app".to_string(),
709 reassign_owned_to: Some("app-owner".to_string()),
710 drop_owned: true,
711 terminate_sessions: true,
712 }],
713 );
714
715 let summary = PlanSummary::from_changes(&changes);
716 assert_eq!(summary.roles_dropped, 1);
717 assert_eq!(summary.sessions_terminated, 1);
718 assert_eq!(summary.ownerships_reassigned, 1);
719 assert_eq!(summary.owned_objects_dropped, 1);
720 assert_eq!(summary.total(), 4);
721 }
722
723 #[test]
728 fn plan_summary_display_empty() {
729 let summary = PlanSummary::default();
730 let display = summary.to_string();
731 assert!(display.contains("No changes needed"));
732 }
733
734 #[test]
735 fn plan_summary_display_with_changes() {
736 let summary = PlanSummary {
737 roles_created: 2,
738 schemas_created: 1,
739 grants: 5,
740 members_added: 1,
741 ..Default::default()
742 };
743 let display = summary.to_string();
744 assert!(display.contains("9 change(s)"), "got: {display}");
745 assert!(display.contains("2 role(s) to create"), "got: {display}");
746 assert!(display.contains("1 schema(s) to create"), "got: {display}");
747 assert!(display.contains("5 grant(s) to add"), "got: {display}");
748 assert!(display.contains("1 membership(s) to add"), "got: {display}");
749 assert!(!display.contains("to drop"), "got: {display}");
751 assert!(!display.contains("to revoke"), "got: {display}");
752 }
753
754 #[test]
759 fn validation_result_shows_counts() {
760 let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
761 let output = format_validation_result(&validated);
762 assert!(output.contains("Manifest is valid"), "got: {output}");
763 assert!(output.contains("2 schema(s)"), "got: {output}");
764 assert!(output.contains("4 role(s)"), "got: {output}");
765 }
766
767 #[test]
768 fn managed_scope_summary_lists_owner_and_binding_facets() {
769 let scope = ManagedScope {
770 roles: ["app".to_string(), "app_owner".to_string()]
771 .into_iter()
772 .collect(),
773 schemas: [
774 (
775 "inventory".to_string(),
776 ManagedSchemaScope {
777 owner: true,
778 bindings: true,
779 },
780 ),
781 (
782 "audit".to_string(),
783 ManagedSchemaScope {
784 owner: true,
785 bindings: false,
786 },
787 ),
788 ]
789 .into_iter()
790 .collect(),
791 };
792
793 let output = format_managed_scope_summary(&scope);
794
795 assert!(output.contains("Managed scope:"), "got: {output}");
796 assert!(output.contains("2 role(s)"), "got: {output}");
797 assert!(output.contains("2 schema(s)"), "got: {output}");
798 assert!(
799 output.contains("owner scope: audit, inventory"),
800 "got: {output}"
801 );
802 assert!(output.contains("binding scope: inventory"), "got: {output}");
803 }
804
805 #[test]
810 fn read_nonexistent_file_fails() {
811 let result = read_manifest_file(Path::new("/tmp/nonexistent-pgroles-test.yaml"));
812 assert!(result.is_err());
813 let err_msg = format!("{:#}", result.unwrap_err());
814 assert!(
815 err_msg.contains("failed to read manifest file"),
816 "got: {err_msg}"
817 );
818 }
819
820 #[test]
825 fn role_graph_summary_format() {
826 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
827 let summary = format_role_graph_summary(&validated.desired);
828 assert!(summary.contains("Roles: 1"), "got: {summary}");
829 assert!(summary.contains("Schemas: 1"), "got: {summary}");
830 assert!(summary.contains("analytics (LOGIN)"), "got: {summary}");
831 }
832
833 #[test]
838 fn has_structural_changes_true_for_non_password_changes() {
839 let summary = PlanSummary {
840 roles_created: 1,
841 schemas_created: 1,
842 grants: 2,
843 ..Default::default()
844 };
845 assert!(summary.has_structural_changes());
846 }
847
848 #[test]
849 fn has_structural_changes_false_for_password_only() {
850 let summary = PlanSummary {
851 passwords_set: 3,
852 ..Default::default()
853 };
854 assert!(
855 !summary.has_structural_changes(),
856 "password-only plan should NOT be considered structural drift"
857 );
858 }
859
860 #[test]
861 fn has_structural_changes_true_for_mixed() {
862 let summary = PlanSummary {
863 roles_created: 1,
864 passwords_set: 2,
865 ..Default::default()
866 };
867 assert!(
868 summary.has_structural_changes(),
869 "mixed plan with structural + password changes IS structural drift"
870 );
871 }
872
873 #[test]
874 fn has_structural_changes_false_for_empty() {
875 let summary = PlanSummary::default();
876 assert!(!summary.has_structural_changes());
877 }
878
879 #[test]
880 fn plan_summary_displays_password_count() {
881 let summary = PlanSummary {
882 passwords_set: 2,
883 roles_created: 1,
884 ..Default::default()
885 };
886 let display = summary.to_string();
887 assert!(display.contains("2 password(s) to set"), "got: {display}");
888 assert!(display.contains("3 change(s)"), "got: {display}");
889 }
890
891 #[test]
896 fn additive_mode_filters_revokes_from_plan() {
897 use pgroles_core::diff::{ReconciliationMode, filter_changes};
898 use pgroles_core::model::RoleState;
899
900 let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
901
902 let mut current = validated.desired.clone();
903 current
904 .roles
905 .insert("stale-role".to_string(), RoleState::default());
906
907 let changes = compute_plan(¤t, &validated.desired);
908 assert!(changes.iter().any(|c| matches!(
909 c,
910 pgroles_core::diff::Change::DropRole { name } if name == "stale-role"
911 )));
912
913 let filtered = filter_changes(changes, ReconciliationMode::Additive);
914 assert!(
915 !filtered
916 .iter()
917 .any(|c| matches!(c, pgroles_core::diff::Change::DropRole { .. })),
918 "additive mode should filter out DropRole"
919 );
920 }
921
922 #[test]
923 fn adopt_mode_filters_drops_but_keeps_revokes() {
924 use pgroles_core::diff::{ReconciliationMode, filter_changes};
925 use pgroles_core::manifest::{ObjectType, Privilege};
926 use pgroles_core::model::{GrantKey, GrantState, RoleState};
927 use std::collections::BTreeSet;
928
929 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
930
931 let mut current = validated.desired.clone();
932 current
933 .roles
934 .insert("stale-role".to_string(), RoleState::default());
935 current.grants.insert(
936 GrantKey {
937 role: "analytics".to_string(),
938 object_type: ObjectType::Table,
939 schema: Some("public".to_string()),
940 name: Some("*".to_string()),
941 },
942 GrantState {
943 privileges: BTreeSet::from([Privilege::Select]),
944 },
945 );
946
947 let changes = compute_plan(¤t, &validated.desired);
948
949 let filtered = filter_changes(changes, ReconciliationMode::Adopt);
950 assert!(
951 !filtered
952 .iter()
953 .any(|c| matches!(c, pgroles_core::diff::Change::DropRole { .. })),
954 "adopt mode should filter out DropRole"
955 );
956 assert!(
957 filtered
958 .iter()
959 .any(|c| matches!(c, pgroles_core::diff::Change::Revoke { .. })),
960 "adopt mode should keep Revoke changes"
961 );
962 }
963 #[test]
968 fn plan_json_produces_valid_json() {
969 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
970 let current = RoleGraph::default();
971 let changes = compute_plan(¤t, &validated.desired);
972
973 let json_output = format_plan_json(&changes).unwrap();
974 let parsed: serde_json::Value = serde_json::from_str(&json_output).unwrap();
976 assert!(parsed.is_array());
977 let text = json_output.to_string();
979 assert!(text.contains("CreateRole"), "got: {text}");
980 assert!(text.contains("analytics"), "got: {text}");
981 }
982
983 #[test]
984 fn format_plan_json_redacts_passwords() {
985 let changes = vec![Change::SetPassword {
986 name: "app-svc".to_string(),
987 password: "super-secret".to_string(),
988 }];
989
990 let json = format_plan_json(&changes).expect("json formatting should succeed");
991 assert!(json.contains("[REDACTED]"), "got: {json}");
992 assert!(!json.contains("super-secret"), "got: {json}");
993 }
994
995 #[test]
996 fn bundle_plan_json_includes_scope_and_ownership_annotations() {
997 let bundle = composition::parse_policy_bundle(
998 r#"
999sources:
1000 - file: app.yaml
1001"#,
1002 )
1003 .unwrap();
1004 let documents = vec![composition::PolicyDocument {
1005 source: "app.yaml".to_string(),
1006 fragment: composition::parse_policy_fragment(
1007 r#"
1008policy:
1009 name: app
1010scope:
1011 roles: [app]
1012roles:
1013 - name: app
1014 login: false
1015"#,
1016 )
1017 .unwrap(),
1018 }];
1019 let composed = composition::compose_bundle(&bundle, &documents).unwrap();
1020 let changes = compute_plan(&RoleGraph::default(), &composed.desired);
1021
1022 let json_output = format_bundle_plan_json(&changes, &composed).unwrap();
1023 let parsed: serde_json::Value = serde_json::from_str(&json_output).unwrap();
1024
1025 assert!(parsed.is_object());
1026 assert_eq!(parsed["schema_version"], "pgroles.bundle_plan.v1");
1027 assert_eq!(parsed["managed_scope"]["roles"][0], "app");
1028 assert_eq!(parsed["changes"][0]["owner"]["document"], "app");
1029 assert_eq!(parsed["changes"][0]["owner"]["managed_key"]["kind"], "role");
1030 assert_eq!(parsed["changes"][0]["owner"]["managed_key"]["name"], "app");
1031 }
1032
1033 #[test]
1034 fn format_plan_sql_redacts_passwords() {
1035 let changes = vec![Change::SetPassword {
1036 name: "app-svc".to_string(),
1037 password: "super-secret".to_string(),
1038 }];
1039
1040 let sql = format_plan_sql_with_context(&changes, &sql::SqlContext::default());
1041 assert!(sql.contains("[REDACTED]"), "got: {sql}");
1042 assert!(!sql.contains("super-secret"), "got: {sql}");
1043 }
1044
1045 #[test]
1046 fn format_applied_uses_applied_header() {
1047 let summary = PlanSummary {
1048 roles_created: 1,
1049 schemas_created: 1,
1050 grants: 2,
1051 ..Default::default()
1052 };
1053
1054 let display = summary.format_applied();
1055 assert!(
1056 display.starts_with("Applied: 4 change(s)\n"),
1057 "got: {display}"
1058 );
1059 assert!(display.contains("1 role(s) to create"), "got: {display}");
1060 assert!(display.contains("1 schema(s) to create"), "got: {display}");
1061 assert!(display.contains("2 grant(s) to add"), "got: {display}");
1062 assert!(!display.contains("Plan:"), "got: {display}");
1063 }
1064}