Skip to main content

pgroles_cli/
lib.rs

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