Skip to main content

pgroles_core/
visual.rs

1//! Visualization model and renderers for `RoleGraph`.
2//!
3//! Converts a [`RoleGraph`] into a [`VisualGraph`] — a graph-oriented DTO
4//! suitable for JSON export, DOT/Mermaid rendering, and terminal tree output.
5
6use std::collections::{BTreeMap, BTreeSet};
7use std::fmt::Write as _;
8
9use serde::{Deserialize, Serialize};
10
11use crate::manifest::{ObjectType, Privilege};
12use crate::model::{DefaultPrivKey, GrantKey, RoleGraph};
13
14// ---------------------------------------------------------------------------
15// DTO types
16// ---------------------------------------------------------------------------
17
18/// Top-level visualization graph.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct VisualGraph {
21    pub meta: VisualMeta,
22    pub nodes: Vec<VisualNode>,
23    pub edges: Vec<VisualEdge>,
24}
25
26/// Metadata about the graph.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct VisualMeta {
29    pub source: VisualSource,
30    pub role_count: usize,
31    pub grant_count: usize,
32    pub default_privilege_count: usize,
33    pub membership_count: usize,
34    pub collapsed: bool,
35}
36
37/// Where the graph data came from.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum VisualSource {
41    Desired,
42    Current,
43}
44
45/// A node in the visual graph.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct VisualNode {
48    pub id: String,
49    pub label: String,
50    pub kind: NodeKind,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub managed: Option<bool>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub login: Option<bool>,
55    #[serde(default, skip_serializing_if = "Vec::is_empty")]
56    pub privileges: Vec<String>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub comment: Option<String>,
59}
60
61/// The kind of a visual node.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum NodeKind {
65    Role,
66    ExternalPrincipal,
67    GrantTarget,
68    DefaultPrivilegeTarget,
69}
70
71/// An edge in the visual graph.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct VisualEdge {
74    pub source: String,
75    pub target: String,
76    pub kind: EdgeKind,
77    pub label: String,
78}
79
80/// The kind of a visual edge.
81#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum EdgeKind {
84    Membership,
85    Grant,
86    DefaultPrivilege,
87}
88
89// ---------------------------------------------------------------------------
90// RoleGraph -> VisualGraph transformation
91// ---------------------------------------------------------------------------
92
93/// Build a [`VisualGraph`] from a [`RoleGraph`].
94///
95/// Grant targets are collapsed by schema and object type by default:
96/// individual object names become `schema.tables[*]` etc.
97pub fn build_visual_graph(graph: &RoleGraph, source: VisualSource) -> VisualGraph {
98    let mut nodes: Vec<VisualNode> = Vec::new();
99    let mut edges: Vec<VisualEdge> = Vec::new();
100    let mut node_ids: BTreeSet<String> = BTreeSet::new();
101
102    // Track which members are external (referenced in memberships but not in roles).
103    let managed_role_names: BTreeSet<&str> = graph.roles.keys().map(|name| name.as_str()).collect();
104
105    // --- Role nodes ---
106    for (name, state) in &graph.roles {
107        let node_id = format!("role:{name}");
108        nodes.push(VisualNode {
109            id: node_id.clone(),
110            label: name.clone(),
111            kind: NodeKind::Role,
112            managed: Some(true),
113            login: Some(state.login),
114            privileges: Vec::new(),
115            comment: state.comment.clone(),
116        });
117        node_ids.insert(node_id);
118    }
119
120    // --- External principal nodes (from memberships) ---
121    for edge in &graph.memberships {
122        if !managed_role_names.contains(edge.member.as_str()) {
123            let node_id = format!("external:{}", edge.member);
124            if node_ids.insert(node_id.clone()) {
125                nodes.push(VisualNode {
126                    id: node_id,
127                    label: edge.member.clone(),
128                    kind: NodeKind::ExternalPrincipal,
129                    managed: Some(false),
130                    login: None,
131                    privileges: Vec::new(),
132                    comment: None,
133                });
134            }
135        }
136    }
137
138    // --- Membership edges ---
139    for edge in &graph.memberships {
140        let source_id = if managed_role_names.contains(edge.member.as_str()) {
141            format!("role:{}", edge.member)
142        } else {
143            format!("external:{}", edge.member)
144        };
145        let target_id = format!("role:{}", edge.role);
146
147        let label = membership_label(edge.inherit, edge.admin);
148        edges.push(VisualEdge {
149            source: source_id,
150            target: target_id,
151            kind: EdgeKind::Membership,
152            label,
153        });
154    }
155
156    // --- Collapsed grant target nodes and edges ---
157    // Group grants by (role, object_type, schema_or_db) to collapse.
158    let collapsed_grants = collapse_grants(&graph.grants);
159    for (collapsed_key, privileges) in &collapsed_grants {
160        let node_id = collapsed_key.node_id();
161        if node_ids.insert(node_id.clone()) {
162            nodes.push(VisualNode {
163                id: node_id.clone(),
164                label: collapsed_key.label(),
165                kind: NodeKind::GrantTarget,
166                managed: None,
167                login: None,
168                privileges: privileges.iter().map(|p| p.to_string()).collect(),
169                comment: None,
170            });
171        } else {
172            // Node already exists from another role — merge privileges.
173            if let Some(existing) = nodes.iter_mut().find(|n| n.id == node_id) {
174                for priv_str in privileges.iter().map(|p| p.to_string()) {
175                    if !existing.privileges.contains(&priv_str) {
176                        existing.privileges.push(priv_str);
177                    }
178                }
179                existing.privileges.sort();
180            }
181        }
182
183        let privilege_label = privileges
184            .iter()
185            .map(|p| p.to_string())
186            .collect::<Vec<_>>()
187            .join(",");
188        edges.push(VisualEdge {
189            source: format!("role:{}", collapsed_key.role),
190            target: node_id,
191            kind: EdgeKind::Grant,
192            label: privilege_label,
193        });
194    }
195
196    // --- Default privilege nodes and edges ---
197    for (key, state) in &graph.default_privileges {
198        let node_id = default_priv_node_id(key);
199        let node_label = format!("defaults: {} -> {}.{}s", key.owner, key.schema, key.on_type);
200
201        if node_ids.insert(node_id.clone()) {
202            nodes.push(VisualNode {
203                id: node_id.clone(),
204                label: node_label,
205                kind: NodeKind::DefaultPrivilegeTarget,
206                managed: None,
207                login: None,
208                privileges: state.privileges.iter().map(|p| p.to_string()).collect(),
209                comment: None,
210            });
211        }
212
213        let privilege_label = state
214            .privileges
215            .iter()
216            .map(|p| p.to_string())
217            .collect::<Vec<_>>()
218            .join(",");
219        edges.push(VisualEdge {
220            source: node_id,
221            target: format!("role:{}", key.grantee),
222            kind: EdgeKind::DefaultPrivilege,
223            label: privilege_label,
224        });
225    }
226
227    // Sort nodes and edges for deterministic output.
228    nodes.sort_by(|a, b| a.id.cmp(&b.id));
229    edges.sort_by(|a, b| (&a.source, &a.target, &a.kind).cmp(&(&b.source, &b.target, &b.kind)));
230
231    VisualGraph {
232        meta: VisualMeta {
233            source,
234            role_count: graph.roles.len(),
235            grant_count: graph.grants.len(),
236            default_privilege_count: graph.default_privileges.len(),
237            membership_count: graph.memberships.len(),
238            collapsed: true,
239        },
240        nodes,
241        edges,
242    }
243}
244
245// ---------------------------------------------------------------------------
246// Grant collapsing
247// ---------------------------------------------------------------------------
248
249/// Key for a collapsed grant group: (role, object_type, schema_or_db).
250#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
251struct CollapsedGrantKey {
252    role: String,
253    object_type: ObjectType,
254    /// Schema name for schema-scoped grants, or the database/object name for
255    /// schema-level and database-level grants.
256    scope: String,
257}
258
259impl CollapsedGrantKey {
260    fn node_id(&self) -> String {
261        match self.object_type {
262            ObjectType::Schema => format!("grant:schema:{}:{}", self.scope, self.scope),
263            ObjectType::Database => format!("grant:database:{}:{}", self.scope, self.scope),
264            _ => format!("grant:{}:{}:*", self.object_type, self.scope),
265        }
266    }
267
268    fn label(&self) -> String {
269        match self.object_type {
270            ObjectType::Schema => format!("{}.schema", self.scope),
271            ObjectType::Database => format!("{}.database", self.scope),
272            _ => format!("{}.{}s[*]", self.scope, self.object_type),
273        }
274    }
275}
276
277fn collapse_grants(
278    grants: &BTreeMap<GrantKey, crate::model::GrantState>,
279) -> BTreeMap<CollapsedGrantKey, BTreeSet<Privilege>> {
280    let mut collapsed: BTreeMap<CollapsedGrantKey, BTreeSet<Privilege>> = BTreeMap::new();
281
282    for (key, state) in grants {
283        let scope = match key.object_type {
284            // Schema-level grants: the schema name is in key.name (from manifest expansion).
285            ObjectType::Schema => key
286                .name
287                .as_deref()
288                .or(key.schema.as_deref())
289                .unwrap_or("public")
290                .to_string(),
291            // Database-level grants: the database name is in key.name.
292            ObjectType::Database => key.name.as_deref().unwrap_or("db").to_string(),
293            // Object-level grants: use the schema.
294            _ => key.schema.as_deref().unwrap_or("public").to_string(),
295        };
296
297        let collapsed_key = CollapsedGrantKey {
298            role: key.role.clone(),
299            object_type: key.object_type,
300            scope,
301        };
302
303        collapsed
304            .entry(collapsed_key)
305            .or_default()
306            .extend(&state.privileges);
307    }
308
309    collapsed
310}
311
312fn default_priv_node_id(key: &DefaultPrivKey) -> String {
313    format!(
314        "default:{}:{}:{}:{}",
315        key.owner, key.schema, key.on_type, key.grantee
316    )
317}
318
319fn membership_label(inherit: bool, admin: bool) -> String {
320    let mut parts = vec!["member"];
321    if !inherit {
322        parts.push("NOINHERIT");
323    }
324    if admin {
325        parts.push("ADMIN");
326    }
327    parts.join(", ")
328}
329
330// ---------------------------------------------------------------------------
331// Renderers
332// ---------------------------------------------------------------------------
333
334/// Render the graph as pretty-printed JSON.
335pub fn render_json(graph: &VisualGraph) -> String {
336    serde_json::to_string_pretty(graph).expect("VisualGraph serialization should not fail")
337}
338
339/// Render the graph as Graphviz DOT.
340pub fn render_dot(graph: &VisualGraph) -> String {
341    let mut out = String::new();
342    writeln!(out, "digraph roles {{").unwrap();
343    writeln!(out, "  rankdir=LR;").unwrap();
344    writeln!(out, "  node [fontname=\"sans-serif\" fontsize=10];").unwrap();
345    writeln!(out, "  edge [fontname=\"sans-serif\" fontsize=9];").unwrap();
346    writeln!(out).unwrap();
347
348    for node in &graph.nodes {
349        let dot_id = dot_escape_id(&node.id);
350        let label = dot_escape_label(&node.label);
351        let shape = match node.kind {
352            NodeKind::Role => {
353                if node.login == Some(true) {
354                    "box"
355                } else {
356                    "ellipse"
357                }
358            }
359            NodeKind::ExternalPrincipal => "hexagon",
360            NodeKind::GrantTarget => "note",
361            NodeKind::DefaultPrivilegeTarget => "component",
362        };
363        let style = match node.kind {
364            NodeKind::Role => "filled",
365            NodeKind::ExternalPrincipal => "dashed,filled",
366            NodeKind::GrantTarget => "filled",
367            NodeKind::DefaultPrivilegeTarget => "filled",
368        };
369        let fillcolor = match node.kind {
370            NodeKind::Role => {
371                if node.login == Some(true) {
372                    "#e0f2fe" // light blue for login roles
373                } else {
374                    "#f0fdf4" // light green for group roles
375                }
376            }
377            NodeKind::ExternalPrincipal => "#fef3c7", // light amber
378            NodeKind::GrantTarget => "#f5f5f4",       // stone-100
379            NodeKind::DefaultPrivilegeTarget => "#f0fdfa", // teal-50
380        };
381        writeln!(
382            out,
383            "  {dot_id} [label=\"{label}\" shape={shape} style=\"{style}\" fillcolor=\"{fillcolor}\"];",
384        )
385        .unwrap();
386    }
387
388    writeln!(out).unwrap();
389
390    for edge in &graph.edges {
391        let source = dot_escape_id(&edge.source);
392        let target = dot_escape_id(&edge.target);
393        let label = dot_escape_label(&edge.label);
394        let style = match edge.kind {
395            EdgeKind::Membership => "solid",
396            EdgeKind::Grant => "solid",
397            EdgeKind::DefaultPrivilege => "dashed",
398        };
399        let color = match edge.kind {
400            EdgeKind::Membership => "#1e3a5f",
401            EdgeKind::Grant => "#374151",
402            EdgeKind::DefaultPrivilege => "#0d9488",
403        };
404        writeln!(
405            out,
406            "  {source} -> {target} [label=\"{label}\" style={style} color=\"{color}\" fontcolor=\"{color}\"];",
407        )
408        .unwrap();
409    }
410
411    writeln!(out, "}}").unwrap();
412    out
413}
414
415/// Render the graph as Mermaid flowchart syntax.
416pub fn render_mermaid(graph: &VisualGraph) -> String {
417    let mut out = String::new();
418    writeln!(out, "graph LR").unwrap();
419
420    for node in &graph.nodes {
421        let mermaid_id = mermaid_escape_id(&node.id);
422        let label = mermaid_escape_label(&node.label);
423        let shape = match node.kind {
424            NodeKind::Role => {
425                if node.login == Some(true) {
426                    format!("[{label}]")
427                } else {
428                    format!("([{label}])")
429                }
430            }
431            NodeKind::ExternalPrincipal => format!("{{{{{label}}}}}"),
432            NodeKind::GrantTarget => format!("[/{label}/]"),
433            NodeKind::DefaultPrivilegeTarget => format!("[\\{label}\\]"),
434        };
435        writeln!(out, "  {mermaid_id}{shape}").unwrap();
436    }
437
438    for edge in &graph.edges {
439        let source = mermaid_escape_id(&edge.source);
440        let target = mermaid_escape_id(&edge.target);
441        let label = mermaid_escape_label(&edge.label);
442        let arrow = match edge.kind {
443            EdgeKind::Membership => "-->",
444            EdgeKind::Grant => "-->",
445            EdgeKind::DefaultPrivilege => "-.->",
446        };
447        if label.is_empty() {
448            writeln!(out, "  {source} {arrow} {target}").unwrap();
449        } else {
450            writeln!(out, "  {source} {arrow}|{label}| {target}").unwrap();
451        }
452    }
453
454    out
455}
456
457/// Render the graph as an indented text tree for terminal display.
458pub fn render_tree(graph: &VisualGraph) -> String {
459    let mut out = String::new();
460
461    // Build lookup structures for edges by role.
462    let mut membership_edges: BTreeMap<&str, Vec<&VisualEdge>> = BTreeMap::new();
463    let mut grant_edges: BTreeMap<&str, Vec<&VisualEdge>> = BTreeMap::new();
464    let mut default_priv_edges: BTreeMap<&str, Vec<&VisualEdge>> = BTreeMap::new();
465    let node_map: BTreeMap<&str, &VisualNode> =
466        graph.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
467
468    for edge in &graph.edges {
469        match edge.kind {
470            EdgeKind::Membership => {
471                membership_edges
472                    .entry(edge.target.as_str())
473                    .or_default()
474                    .push(edge);
475            }
476            EdgeKind::Grant => {
477                grant_edges
478                    .entry(edge.source.as_str())
479                    .or_default()
480                    .push(edge);
481            }
482            EdgeKind::DefaultPrivilege => {
483                default_priv_edges
484                    .entry(edge.target.as_str())
485                    .or_default()
486                    .push(edge);
487            }
488        }
489    }
490
491    // Collect role nodes in order.
492    let role_nodes: Vec<&VisualNode> = graph
493        .nodes
494        .iter()
495        .filter(|n| n.kind == NodeKind::Role)
496        .collect();
497
498    for (role_idx, role_node) in role_nodes.iter().enumerate() {
499        let is_last_role = role_idx == role_nodes.len() - 1;
500        let role_connector = if is_last_role { "\u{2514}" } else { "\u{251c}" };
501        let role_tag = if role_node.login == Some(true) {
502            " [LOGIN]"
503        } else {
504            ""
505        };
506        writeln!(
507            out,
508            "{role_connector}\u{2500}\u{2500} {}{role_tag}",
509            role_node.label
510        )
511        .unwrap();
512
513        let child_prefix = if is_last_role { "    " } else { "\u{2502}   " };
514
515        // Count how many sections this role has for connector logic.
516        let members = membership_edges.get(role_node.id.as_str());
517        let grants = grant_edges.get(role_node.id.as_str());
518        let default_privs = default_priv_edges.get(role_node.id.as_str());
519
520        let section_count = members.is_some() as usize
521            + grants.is_some() as usize
522            + default_privs.is_some() as usize;
523        let mut section_idx = 0;
524
525        if let Some(member_list) = members {
526            let is_last_section = section_idx == section_count - 1;
527            render_tree_section(
528                &mut out,
529                child_prefix,
530                is_last_section,
531                "Members",
532                member_list,
533                &node_map,
534                |edge, node_map| {
535                    let label = node_map
536                        .get(edge.source.as_str())
537                        .map(|n| n.label.as_str())
538                        .unwrap_or(&edge.source);
539                    let flags = if edge.label != "member" {
540                        format!(" ({0})", edge.label)
541                    } else {
542                        String::new()
543                    };
544                    format!("{label}{flags}")
545                },
546            );
547            section_idx += 1;
548        }
549
550        if let Some(grant_list) = grants {
551            let is_last_section = section_idx == section_count - 1;
552            render_tree_section(
553                &mut out,
554                child_prefix,
555                is_last_section,
556                "Grants",
557                grant_list,
558                &node_map,
559                |edge, node_map| {
560                    let label = node_map
561                        .get(edge.target.as_str())
562                        .map(|n| n.label.as_str())
563                        .unwrap_or(&edge.target);
564                    format!("{label}: {}", edge.label)
565                },
566            );
567            section_idx += 1;
568        }
569
570        if let Some(dp_list) = default_privs {
571            let is_last_section = section_idx == section_count - 1;
572            render_tree_section(
573                &mut out,
574                child_prefix,
575                is_last_section,
576                "Default Privileges",
577                dp_list,
578                &node_map,
579                |edge, node_map| {
580                    let label = node_map
581                        .get(edge.source.as_str())
582                        .map(|n| n.label.as_str())
583                        .unwrap_or(&edge.source);
584                    format!("{label}: {}", edge.label)
585                },
586            );
587            let _ = section_idx;
588        }
589    }
590
591    // Show external principals.
592    let external_nodes: Vec<&VisualNode> = graph
593        .nodes
594        .iter()
595        .filter(|n| n.kind == NodeKind::ExternalPrincipal)
596        .collect();
597
598    if !external_nodes.is_empty() {
599        writeln!(out).unwrap();
600        writeln!(out, "External principals:").unwrap();
601        for (idx, node) in external_nodes.iter().enumerate() {
602            let is_last = idx == external_nodes.len() - 1;
603            let connector = if is_last { "\u{2514}" } else { "\u{251c}" };
604            writeln!(out, "{connector}\u{2500}\u{2500} {}", node.label).unwrap();
605        }
606    }
607
608    out
609}
610
611/// Render a single section (Members, Grants, Default Privileges) under a role
612/// in the tree output.
613fn render_tree_section(
614    out: &mut String,
615    child_prefix: &str,
616    is_last_section: bool,
617    section_name: &str,
618    edges: &[&VisualEdge],
619    node_map: &BTreeMap<&str, &VisualNode>,
620    format_item: impl Fn(&VisualEdge, &BTreeMap<&str, &VisualNode>) -> String,
621) {
622    let section_connector = if is_last_section {
623        "\u{2514}"
624    } else {
625        "\u{251c}"
626    };
627    let item_prefix = if is_last_section {
628        format!("{child_prefix}    ")
629    } else {
630        format!("{child_prefix}\u{2502}   ")
631    };
632    writeln!(
633        out,
634        "{child_prefix}{section_connector}\u{2500}\u{2500} {section_name}"
635    )
636    .unwrap();
637    for (idx, edge) in edges.iter().enumerate() {
638        let is_last = idx == edges.len() - 1;
639        let connector = if is_last { "\u{2514}" } else { "\u{251c}" };
640        let item_text = format_item(edge, node_map);
641        writeln!(out, "{item_prefix}{connector}\u{2500}\u{2500} {item_text}").unwrap();
642    }
643}
644
645// ---------------------------------------------------------------------------
646// Escape helpers
647// ---------------------------------------------------------------------------
648
649fn dot_escape_id(id: &str) -> String {
650    format!("\"{}\"", id.replace('\\', "\\\\").replace('"', "\\\""))
651}
652
653fn dot_escape_label(label: &str) -> String {
654    label
655        .replace('\\', "\\\\")
656        .replace('"', "\\\"")
657        .replace('\n', "\\n")
658}
659
660fn mermaid_escape_id(id: &str) -> String {
661    // Mermaid IDs must be alphanumeric + hyphens + underscores.
662    // Use distinct substitutions to avoid collisions between IDs that
663    // differ only by punctuation (e.g. "alice@x.com" vs "alice_x.com").
664    let mut out = String::with_capacity(id.len());
665    for ch in id.chars() {
666        match ch {
667            ':' => out.push_str("__"),
668            '.' => out.push_str("_d_"),
669            '@' => out.push_str("_at_"),
670            '*' => out.push_str("_star_"),
671            ' ' => out.push_str("_sp_"),
672            '/' => out.push_str("_sl_"),
673            '\\' => out.push_str("_bs_"),
674            c if c.is_alphanumeric() || c == '-' || c == '_' => out.push(c),
675            _ => {
676                out.push_str(&format!("_x{:02x}_", ch as u32));
677            }
678        }
679    }
680    out
681}
682
683fn mermaid_escape_label(label: &str) -> String {
684    // Mermaid labels: escape quotes and brackets.
685    label.replace('"', "#quot;").replace(['[', ']'], "")
686}
687
688// ---------------------------------------------------------------------------
689// Tests
690// ---------------------------------------------------------------------------
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695    use crate::manifest::{expand_manifest, parse_manifest};
696    use crate::model::RoleGraph;
697
698    fn build_test_graph() -> RoleGraph {
699        let yaml = r#"
700default_owner: app_owner
701
702profiles:
703  editor:
704    grants:
705      - privileges: [USAGE]
706        object: { type: schema }
707      - privileges: [SELECT, INSERT, UPDATE, DELETE]
708        object: { type: table, name: "*" }
709    default_privileges:
710      - privileges: [SELECT, INSERT, UPDATE, DELETE]
711        on_type: table
712
713schemas:
714  - name: orders
715    profiles: [editor]
716
717roles:
718  - name: analytics
719    login: true
720    comment: "Read-only analytics"
721
722grants:
723  - role: analytics
724    privileges: [CONNECT]
725    object: { type: database, name: mydb }
726
727memberships:
728  - role: orders-editor
729    members:
730      - name: "team@example.com"
731      - name: analytics
732"#;
733        let manifest = parse_manifest(yaml).unwrap();
734        let expanded = expand_manifest(&manifest).unwrap();
735        RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap()
736    }
737
738    #[test]
739    fn visual_graph_has_correct_node_count() {
740        let graph = build_test_graph();
741        let visual = build_visual_graph(&graph, VisualSource::Desired);
742
743        // Roles: orders-editor, analytics
744        let role_nodes: Vec<_> = visual
745            .nodes
746            .iter()
747            .filter(|n| n.kind == NodeKind::Role)
748            .collect();
749        assert_eq!(role_nodes.len(), 2);
750
751        // External: team@example.com
752        let external_nodes: Vec<_> = visual
753            .nodes
754            .iter()
755            .filter(|n| n.kind == NodeKind::ExternalPrincipal)
756            .collect();
757        assert_eq!(external_nodes.len(), 1);
758        assert_eq!(external_nodes[0].label, "team@example.com");
759    }
760
761    #[test]
762    fn visual_graph_login_flag_correct() {
763        let graph = build_test_graph();
764        let visual = build_visual_graph(&graph, VisualSource::Desired);
765
766        let analytics = visual
767            .nodes
768            .iter()
769            .find(|n| n.label == "analytics")
770            .unwrap();
771        assert_eq!(analytics.login, Some(true));
772
773        let editor = visual
774            .nodes
775            .iter()
776            .find(|n| n.label == "orders-editor")
777            .unwrap();
778        assert_eq!(editor.login, Some(false));
779    }
780
781    #[test]
782    fn visual_graph_has_membership_edges() {
783        let graph = build_test_graph();
784        let visual = build_visual_graph(&graph, VisualSource::Desired);
785
786        let membership_edges: Vec<_> = visual
787            .edges
788            .iter()
789            .filter(|e| e.kind == EdgeKind::Membership)
790            .collect();
791        assert_eq!(membership_edges.len(), 2);
792    }
793
794    #[test]
795    fn visual_graph_collapses_grants() {
796        let graph = build_test_graph();
797        let visual = build_visual_graph(&graph, VisualSource::Desired);
798
799        let grant_targets: Vec<_> = visual
800            .nodes
801            .iter()
802            .filter(|n| n.kind == NodeKind::GrantTarget)
803            .collect();
804
805        // orders.schema, orders.tables[*], mydb.database
806        assert_eq!(grant_targets.len(), 3, "grant targets: {grant_targets:?}");
807    }
808
809    #[test]
810    fn visual_graph_has_default_privilege_nodes() {
811        let graph = build_test_graph();
812        let visual = build_visual_graph(&graph, VisualSource::Desired);
813
814        let dp_nodes: Vec<_> = visual
815            .nodes
816            .iter()
817            .filter(|n| n.kind == NodeKind::DefaultPrivilegeTarget)
818            .collect();
819        assert_eq!(dp_nodes.len(), 1);
820        assert!(dp_nodes[0].label.contains("app_owner"));
821        assert!(dp_nodes[0].label.contains("orders"));
822    }
823
824    #[test]
825    fn visual_graph_nodes_are_sorted() {
826        let graph = build_test_graph();
827        let visual = build_visual_graph(&graph, VisualSource::Desired);
828
829        let ids: Vec<&str> = visual.nodes.iter().map(|n| n.id.as_str()).collect();
830        let mut sorted_ids = ids.clone();
831        sorted_ids.sort();
832        assert_eq!(ids, sorted_ids, "nodes should be sorted by ID");
833    }
834
835    #[test]
836    fn json_roundtrips() {
837        let graph = build_test_graph();
838        let visual = build_visual_graph(&graph, VisualSource::Desired);
839        let json = render_json(&visual);
840        let deserialized: VisualGraph = serde_json::from_str(&json).unwrap();
841        assert_eq!(deserialized.nodes.len(), visual.nodes.len());
842        assert_eq!(deserialized.edges.len(), visual.edges.len());
843    }
844
845    #[test]
846    fn dot_output_is_valid() {
847        let graph = build_test_graph();
848        let visual = build_visual_graph(&graph, VisualSource::Desired);
849        let dot = render_dot(&visual);
850
851        assert!(dot.starts_with("digraph roles {"));
852        assert!(dot.contains("orders-editor"));
853        assert!(dot.contains("analytics"));
854        assert!(dot.contains("team@example.com"));
855        assert!(dot.ends_with("}\n"));
856    }
857
858    #[test]
859    fn mermaid_output_is_valid() {
860        let graph = build_test_graph();
861        let visual = build_visual_graph(&graph, VisualSource::Desired);
862        let mermaid = render_mermaid(&visual);
863
864        assert!(mermaid.starts_with("graph LR\n"));
865        assert!(mermaid.contains("orders-editor"));
866        assert!(mermaid.contains("analytics"));
867    }
868
869    #[test]
870    fn tree_output_shows_roles() {
871        let graph = build_test_graph();
872        let visual = build_visual_graph(&graph, VisualSource::Desired);
873        let tree = render_tree(&visual);
874
875        assert!(
876            tree.contains("analytics"),
877            "tree should contain analytics role"
878        );
879        assert!(
880            tree.contains("orders-editor"),
881            "tree should contain orders-editor role"
882        );
883        assert!(tree.contains("[LOGIN]"), "tree should show LOGIN tag");
884        assert!(
885            tree.contains("team@example.com"),
886            "tree should show external member"
887        );
888    }
889
890    #[test]
891    fn membership_label_defaults_to_member() {
892        assert_eq!(membership_label(true, false), "member");
893    }
894
895    #[test]
896    fn membership_label_noinherit() {
897        assert_eq!(membership_label(false, false), "member, NOINHERIT");
898    }
899
900    #[test]
901    fn membership_label_admin() {
902        assert_eq!(membership_label(true, true), "member, ADMIN");
903    }
904
905    #[test]
906    fn membership_label_both_flags() {
907        assert_eq!(membership_label(false, true), "member, NOINHERIT, ADMIN");
908    }
909
910    #[test]
911    fn empty_graph_produces_empty_visual() {
912        let graph = RoleGraph::default();
913        let visual = build_visual_graph(&graph, VisualSource::Current);
914        assert!(visual.nodes.is_empty());
915        assert!(visual.edges.is_empty());
916        assert_eq!(visual.meta.role_count, 0);
917    }
918
919    #[test]
920    fn grant_node_privileges_merge_across_roles() {
921        // Two roles granting different privileges to the same schema.tables[*] target.
922        let yaml = r#"
923roles:
924  - name: role-a
925  - name: role-b
926
927grants:
928  - role: role-a
929    privileges: [SELECT]
930    object: { type: table, schema: app, name: "*" }
931  - role: role-b
932    privileges: [SELECT, INSERT, UPDATE]
933    object: { type: table, schema: app, name: "*" }
934"#;
935        let manifest = parse_manifest(yaml).unwrap();
936        let expanded = expand_manifest(&manifest).unwrap();
937        let graph = RoleGraph::from_expanded(&expanded, None).unwrap();
938        let visual = build_visual_graph(&graph, VisualSource::Desired);
939
940        let grant_nodes: Vec<_> = visual
941            .nodes
942            .iter()
943            .filter(|n| n.kind == NodeKind::GrantTarget && n.label.contains("tables"))
944            .collect();
945
946        // Should be one collapsed node, not two.
947        assert_eq!(grant_nodes.len(), 1, "grant nodes: {grant_nodes:?}");
948
949        // The node's privileges should be the union of both roles' privileges.
950        let privs = &grant_nodes[0].privileges;
951        assert!(
952            privs.contains(&"INSERT".to_string()),
953            "missing INSERT in {privs:?}"
954        );
955        assert!(
956            privs.contains(&"SELECT".to_string()),
957            "missing SELECT in {privs:?}"
958        );
959        assert!(
960            privs.contains(&"UPDATE".to_string()),
961            "missing UPDATE in {privs:?}"
962        );
963    }
964
965    #[test]
966    fn mermaid_ids_do_not_collide_for_similar_names() {
967        let id_at = mermaid_escape_id("role:alice@example.com");
968        let id_dot = mermaid_escape_id("role:alice.example.com");
969        let id_under = mermaid_escape_id("role:alice_example_com");
970        assert_ne!(id_at, id_dot, "@ and . should produce different IDs");
971        assert_ne!(id_at, id_under, "@ and _ should produce different IDs");
972        assert_ne!(id_dot, id_under, ". and _ should produce different IDs");
973    }
974}