Skip to main content

pgroles_core/
diff.rs

1//! Convergent diff engine.
2//!
3//! Compares two [`RoleGraph`] instances (current vs desired) and produces an
4//! ordered list of [`Change`] operations needed to bring the database from
5//! its current state to the desired state.
6//!
7//! The model is convergent: anything present in the current state but absent
8//! from the desired state is revoked/dropped. This is the Terraform-style
9//! "manifest is the entire truth" approach.
10
11use std::collections::BTreeSet;
12
13use crate::manifest::{ObjectType, Privilege, RoleRetirement};
14use crate::model::{DefaultPrivKey, GrantKey, MembershipEdge, RoleAttribute, RoleGraph, RoleState};
15
16// ---------------------------------------------------------------------------
17// Change enum
18// ---------------------------------------------------------------------------
19
20/// A single change to be applied to the database.
21///
22/// Changes are produced in dependency order by [`diff`]:
23/// 1. Create roles (before granting anything to them)
24/// 2. Alter roles (attribute changes)
25/// 3. Grant privileges
26/// 4. Set default privileges
27/// 5. Remove memberships
28/// 6. Add memberships
29/// 7. Revoke default privileges
30/// 8. Revoke privileges
31/// 9. Drop roles (after revoking everything from them)
32#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
33pub enum Change {
34    /// Create a new role with the given attributes.
35    CreateRole { name: String, state: RoleState },
36
37    /// Alter an existing role's attributes.
38    AlterRole {
39        name: String,
40        attributes: Vec<RoleAttribute>,
41    },
42
43    /// Update a role's comment (via COMMENT ON ROLE).
44    SetComment {
45        name: String,
46        comment: Option<String>,
47    },
48
49    /// Grant privileges on an object to a role.
50    Grant {
51        role: String,
52        privileges: BTreeSet<Privilege>,
53        object_type: ObjectType,
54        schema: Option<String>,
55        name: Option<String>,
56    },
57
58    /// Revoke privileges on an object from a role.
59    Revoke {
60        role: String,
61        privileges: BTreeSet<Privilege>,
62        object_type: ObjectType,
63        schema: Option<String>,
64        name: Option<String>,
65    },
66
67    /// Set default privileges (ALTER DEFAULT PRIVILEGES ... GRANT ...).
68    SetDefaultPrivilege {
69        owner: String,
70        schema: String,
71        on_type: ObjectType,
72        grantee: String,
73        privileges: BTreeSet<Privilege>,
74    },
75
76    /// Revoke default privileges (ALTER DEFAULT PRIVILEGES ... REVOKE ...).
77    RevokeDefaultPrivilege {
78        owner: String,
79        schema: String,
80        on_type: ObjectType,
81        grantee: String,
82        privileges: BTreeSet<Privilege>,
83    },
84
85    /// Grant membership (GRANT role TO member).
86    AddMember {
87        role: String,
88        member: String,
89        inherit: bool,
90        admin: bool,
91    },
92
93    /// Revoke membership (REVOKE role FROM member).
94    RemoveMember { role: String, member: String },
95
96    /// Reassign owned objects to a successor role before drop.
97    ReassignOwned { from_role: String, to_role: String },
98
99    /// Drop owned objects and revoke remaining privileges before drop.
100    DropOwned { role: String },
101
102    /// Terminate other active sessions before dropping a role.
103    TerminateSessions { role: String },
104
105    /// Set a role's password using a SCRAM-SHA-256 verifier.
106    ///
107    /// The `password` field contains a pre-computed SCRAM-SHA-256 verifier
108    /// string (not cleartext). PostgreSQL detects the `SCRAM-SHA-256$` prefix
109    /// and stores it directly without re-hashing.
110    ///
111    /// This change is injected by [`inject_password_changes`] after the core
112    /// diff engine runs. The diff engine itself does not handle passwords
113    /// because they cannot be read back from the database for comparison.
114    SetPassword { name: String, password: String },
115
116    /// Drop a role.
117    DropRole { name: String },
118}
119
120// ---------------------------------------------------------------------------
121// Reconciliation modes
122// ---------------------------------------------------------------------------
123
124/// Controls how aggressively pgroles converges the database to the manifest.
125///
126/// The diff engine always computes the full set of changes. The reconciliation
127/// mode acts as a **post-filter** on the resulting `Vec<Change>`, stripping
128/// out changes that the operator does not want applied.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
130pub enum ReconciliationMode {
131    /// Full convergence — the manifest is the entire truth.
132    ///
133    /// All changes (creates, alters, grants, revokes, drops) are applied.
134    /// Anything present in the database but absent from the manifest is
135    /// revoked or dropped.
136    #[default]
137    Authoritative,
138
139    /// Only grant, never revoke — safe for incremental adoption.
140    ///
141    /// Additive mode filters out all destructive changes:
142    /// - `Revoke` / `RevokeDefaultPrivilege`
143    /// - `RemoveMember`
144    /// - `DropRole` and its retirement steps (`TerminateSessions`,
145    ///   `ReassignOwned`, `DropOwned`)
146    ///
147    /// Use this when onboarding pgroles into an existing environment where
148    /// you want to guarantee that no existing access is removed.
149    Additive,
150
151    /// Manage declared resources fully, but never drop undeclared roles.
152    ///
153    /// Adopt mode is identical to authoritative **except** that it filters out
154    /// `DropRole` and associated retirement steps (`TerminateSessions`,
155    /// `ReassignOwned`, `DropOwned`). Revokes within the managed scope are
156    /// still applied.
157    ///
158    /// Use this for brownfield onboarding where you want full privilege
159    /// convergence for declared roles but don't want pgroles to drop roles
160    /// it doesn't know about.
161    Adopt,
162}
163
164impl std::fmt::Display for ReconciliationMode {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        match self {
167            ReconciliationMode::Authoritative => write!(f, "authoritative"),
168            ReconciliationMode::Additive => write!(f, "additive"),
169            ReconciliationMode::Adopt => write!(f, "adopt"),
170        }
171    }
172}
173
174/// Filter a list of changes according to the reconciliation mode.
175///
176/// - **Authoritative**: returns all changes unmodified.
177/// - **Additive**: strips revokes, membership removals, role drops, and
178///   retirement cleanup steps.
179/// - **Adopt**: strips role drops and retirement cleanup steps, but keeps
180///   revokes and membership removals.
181pub fn filter_changes(changes: Vec<Change>, mode: ReconciliationMode) -> Vec<Change> {
182    match mode {
183        ReconciliationMode::Authoritative => changes,
184        ReconciliationMode::Additive => changes
185            .into_iter()
186            .filter(|change| !is_destructive(change))
187            .collect(),
188        ReconciliationMode::Adopt => changes
189            .into_iter()
190            .filter(|change| !is_role_drop_or_retirement(change))
191            .collect(),
192    }
193}
194
195/// Returns `true` for any change that removes access or drops a role.
196fn is_destructive(change: &Change) -> bool {
197    matches!(
198        change,
199        Change::Revoke { .. }
200            | Change::RevokeDefaultPrivilege { .. }
201            | Change::RemoveMember { .. }
202            | Change::DropRole { .. }
203            | Change::DropOwned { .. }
204            | Change::ReassignOwned { .. }
205            | Change::TerminateSessions { .. }
206    )
207}
208
209/// Returns `true` for role drops and their associated retirement cleanup steps.
210fn is_role_drop_or_retirement(change: &Change) -> bool {
211    matches!(
212        change,
213        Change::DropRole { .. }
214            | Change::DropOwned { .. }
215            | Change::ReassignOwned { .. }
216            | Change::TerminateSessions { .. }
217    )
218}
219
220// ---------------------------------------------------------------------------
221// Diff function
222// ---------------------------------------------------------------------------
223
224/// Compute the list of changes needed to bring `current` to `desired`.
225///
226/// Changes are ordered so that dependencies are respected:
227/// creates before grants, revokes before drops, etc.
228pub fn diff(current: &RoleGraph, desired: &RoleGraph) -> Vec<Change> {
229    let mut creates = Vec::new();
230    let mut alters = Vec::new();
231    let mut grants = Vec::new();
232    let mut set_defaults = Vec::new();
233    let mut add_members = Vec::new();
234    let mut remove_members = Vec::new();
235    let mut revoke_defaults = Vec::new();
236    let mut revokes = Vec::new();
237    let mut drops = Vec::new();
238
239    // ----- Roles -----
240
241    // Roles in desired but not in current → CREATE
242    for (name, desired_state) in &desired.roles {
243        match current.roles.get(name) {
244            None => {
245                creates.push(Change::CreateRole {
246                    name: name.clone(),
247                    state: desired_state.clone(),
248                });
249            }
250            Some(current_state) => {
251                // Role exists — check for attribute changes
252                let attribute_changes = current_state.changed_attributes(desired_state);
253                if !attribute_changes.is_empty() {
254                    alters.push(Change::AlterRole {
255                        name: name.clone(),
256                        attributes: attribute_changes,
257                    });
258                }
259                // Check comment change
260                if current_state.comment != desired_state.comment {
261                    alters.push(Change::SetComment {
262                        name: name.clone(),
263                        comment: desired_state.comment.clone(),
264                    });
265                }
266            }
267        }
268    }
269
270    // Roles in current but not in desired → DROP
271    for name in current.roles.keys() {
272        if !desired.roles.contains_key(name) {
273            drops.push(Change::DropRole { name: name.clone() });
274        }
275    }
276
277    // ----- Grants -----
278
279    diff_grants(current, desired, &mut grants, &mut revokes);
280
281    // ----- Default privileges -----
282
283    diff_default_privileges(current, desired, &mut set_defaults, &mut revoke_defaults);
284
285    // ----- Memberships -----
286
287    diff_memberships(current, desired, &mut add_members, &mut remove_members);
288
289    // ----- Assemble in dependency order -----
290    let mut changes = Vec::new();
291    changes.extend(creates);
292    changes.extend(alters);
293    changes.extend(grants);
294    changes.extend(set_defaults);
295    changes.extend(remove_members);
296    changes.extend(add_members);
297    changes.extend(revoke_defaults);
298    changes.extend(revokes);
299    changes.extend(drops);
300    changes
301}
302
303/// Augment a diff plan with explicit role-retirement actions.
304///
305/// Retirement steps are inserted immediately before the matching `DropRole`
306/// so the final plan remains dependency-safe:
307/// `TERMINATE SESSIONS` → `REASSIGN OWNED` → `DROP OWNED` → `DROP ROLE`.
308pub fn apply_role_retirements(changes: Vec<Change>, retirements: &[RoleRetirement]) -> Vec<Change> {
309    if retirements.is_empty() {
310        return changes;
311    }
312
313    let retirement_by_role: std::collections::BTreeMap<&str, &RoleRetirement> = retirements
314        .iter()
315        .map(|retirement| (retirement.role.as_str(), retirement))
316        .collect();
317
318    let mut planned = Vec::with_capacity(changes.len());
319    for change in changes {
320        if let Change::DropRole { name } = &change
321            && let Some(retirement) = retirement_by_role.get(name.as_str())
322        {
323            if retirement.terminate_sessions {
324                planned.push(Change::TerminateSessions { role: name.clone() });
325            }
326            if let Some(successor) = &retirement.reassign_owned_to {
327                planned.push(Change::ReassignOwned {
328                    from_role: name.clone(),
329                    to_role: successor.clone(),
330                });
331            }
332            if retirement.drop_owned {
333                planned.push(Change::DropOwned { role: name.clone() });
334            }
335        }
336        planned.push(change);
337    }
338
339    planned
340}
341
342// ---------------------------------------------------------------------------
343// Password injection
344// ---------------------------------------------------------------------------
345
346/// Resolve password sources from environment variables.
347///
348/// Returns a map of role name → resolved password for every role that declares
349/// a `password.from_env` source. Returns an error if a referenced environment
350/// variable is not set.
351pub fn resolve_passwords(
352    roles: &[crate::manifest::RoleDefinition],
353) -> Result<std::collections::BTreeMap<String, String>, PasswordResolutionError> {
354    let mut resolved = std::collections::BTreeMap::new();
355    for role in roles {
356        if let Some(source) = &role.password {
357            let value = std::env::var(&source.from_env).map_err(|_| {
358                PasswordResolutionError::MissingEnvVar {
359                    role: role.name.clone(),
360                    env_var: source.from_env.clone(),
361                }
362            })?;
363            if value.is_empty() {
364                return Err(PasswordResolutionError::EmptyPassword {
365                    role: role.name.clone(),
366                    env_var: source.from_env.clone(),
367                });
368            }
369            resolved.insert(role.name.clone(), value);
370        }
371    }
372    Ok(resolved)
373}
374
375/// Errors that can occur during password resolution.
376#[derive(Debug, thiserror::Error)]
377pub enum PasswordResolutionError {
378    #[error("environment variable \"{env_var}\" for role \"{role}\" password is not set")]
379    MissingEnvVar { role: String, env_var: String },
380
381    #[error("environment variable \"{env_var}\" for role \"{role}\" password is empty")]
382    EmptyPassword { role: String, env_var: String },
383}
384
385/// Inject `SetPassword` changes into a plan for roles that declare passwords.
386///
387/// For newly created roles, the `SetPassword` is inserted immediately after the
388/// `CreateRole`. For existing roles with a password source, a `SetPassword` is
389/// appended after all creates/alters (ensuring the role exists).
390///
391/// Cleartext passwords are converted to SCRAM-SHA-256 verifiers before being
392/// placed in `SetPassword` changes, so the cleartext never appears in generated
393/// SQL. PostgreSQL detects the `SCRAM-SHA-256$` prefix and stores the verifier
394/// directly.
395///
396/// This function should be called after `diff()` and `apply_role_retirements()`.
397pub fn inject_password_changes(
398    changes: Vec<Change>,
399    resolved_passwords: &std::collections::BTreeMap<String, String>,
400) -> Vec<Change> {
401    if resolved_passwords.is_empty() {
402        return changes;
403    }
404
405    // Track which roles have CreateRole in the plan (newly created roles).
406    let created_roles: std::collections::BTreeSet<String> = changes
407        .iter()
408        .filter_map(|c| match c {
409            Change::CreateRole { name, .. } => Some(name.clone()),
410            _ => None,
411        })
412        .collect();
413
414    let mut result = Vec::with_capacity(changes.len() + resolved_passwords.len());
415
416    // Insert SetPassword immediately after CreateRole for new roles.
417    for change in changes {
418        if let Change::CreateRole { ref name, .. } = change
419            && let Some(password) = resolved_passwords.get(name.as_str())
420        {
421            let role_name = name.clone();
422            let verifier =
423                crate::scram::compute_verifier(password, crate::scram::DEFAULT_ITERATIONS);
424            result.push(change);
425            result.push(Change::SetPassword {
426                name: role_name,
427                password: verifier,
428            });
429            continue;
430        }
431        result.push(change);
432    }
433
434    // For existing roles (not newly created), append SetPassword after all creates/alters.
435    for (role_name, password) in resolved_passwords {
436        if !created_roles.contains(role_name) {
437            let verifier =
438                crate::scram::compute_verifier(password, crate::scram::DEFAULT_ITERATIONS);
439            result.push(Change::SetPassword {
440                name: role_name.clone(),
441                password: verifier,
442            });
443        }
444    }
445
446    result
447}
448
449// ---------------------------------------------------------------------------
450// Grant diffing
451// ---------------------------------------------------------------------------
452
453fn diff_grants(
454    current: &RoleGraph,
455    desired: &RoleGraph,
456    grants_out: &mut Vec<Change>,
457    revokes_out: &mut Vec<Change>,
458) {
459    // Grants in desired but not in current → GRANT (full set)
460    // Grants in both → diff the privilege sets
461    for (key, desired_state) in &desired.grants {
462        match current.grants.get(key) {
463            None => {
464                // Entirely new grant target — grant the full set
465                grants_out.push(change_grant(key, &desired_state.privileges));
466            }
467            Some(current_state) => {
468                // Grant target exists — find privileges to add/remove
469                let to_add: BTreeSet<Privilege> = desired_state
470                    .privileges
471                    .difference(&current_state.privileges)
472                    .copied()
473                    .collect();
474                let to_remove: BTreeSet<Privilege> = current_state
475                    .privileges
476                    .difference(&desired_state.privileges)
477                    .copied()
478                    .collect();
479
480                if !to_add.is_empty() {
481                    grants_out.push(change_grant(key, &to_add));
482                }
483                if !to_remove.is_empty() {
484                    revokes_out.push(change_revoke(key, &to_remove));
485                }
486            }
487        }
488    }
489
490    // Grant targets in current but not in desired → REVOKE all
491    for (key, current_state) in &current.grants {
492        if !desired.grants.contains_key(key) {
493            revokes_out.push(change_revoke(key, &current_state.privileges));
494        }
495    }
496}
497
498fn change_grant(key: &GrantKey, privileges: &BTreeSet<Privilege>) -> Change {
499    Change::Grant {
500        role: key.role.clone(),
501        privileges: privileges.clone(),
502        object_type: key.object_type,
503        schema: key.schema.clone(),
504        name: key.name.clone(),
505    }
506}
507
508fn change_revoke(key: &GrantKey, privileges: &BTreeSet<Privilege>) -> Change {
509    Change::Revoke {
510        role: key.role.clone(),
511        privileges: privileges.clone(),
512        object_type: key.object_type,
513        schema: key.schema.clone(),
514        name: key.name.clone(),
515    }
516}
517
518// ---------------------------------------------------------------------------
519// Default privilege diffing
520// ---------------------------------------------------------------------------
521
522fn diff_default_privileges(
523    current: &RoleGraph,
524    desired: &RoleGraph,
525    set_out: &mut Vec<Change>,
526    revoke_out: &mut Vec<Change>,
527) {
528    for (key, desired_state) in &desired.default_privileges {
529        match current.default_privileges.get(key) {
530            None => {
531                set_out.push(change_set_default(key, &desired_state.privileges));
532            }
533            Some(current_state) => {
534                let to_add: BTreeSet<Privilege> = desired_state
535                    .privileges
536                    .difference(&current_state.privileges)
537                    .copied()
538                    .collect();
539                let to_remove: BTreeSet<Privilege> = current_state
540                    .privileges
541                    .difference(&desired_state.privileges)
542                    .copied()
543                    .collect();
544
545                if !to_add.is_empty() {
546                    set_out.push(change_set_default(key, &to_add));
547                }
548                if !to_remove.is_empty() {
549                    revoke_out.push(change_revoke_default(key, &to_remove));
550                }
551            }
552        }
553    }
554
555    for (key, current_state) in &current.default_privileges {
556        if !desired.default_privileges.contains_key(key) {
557            revoke_out.push(change_revoke_default(key, &current_state.privileges));
558        }
559    }
560}
561
562fn change_set_default(key: &DefaultPrivKey, privileges: &BTreeSet<Privilege>) -> Change {
563    Change::SetDefaultPrivilege {
564        owner: key.owner.clone(),
565        schema: key.schema.clone(),
566        on_type: key.on_type,
567        grantee: key.grantee.clone(),
568        privileges: privileges.clone(),
569    }
570}
571
572fn change_revoke_default(key: &DefaultPrivKey, privileges: &BTreeSet<Privilege>) -> Change {
573    Change::RevokeDefaultPrivilege {
574        owner: key.owner.clone(),
575        schema: key.schema.clone(),
576        on_type: key.on_type,
577        grantee: key.grantee.clone(),
578        privileges: privileges.clone(),
579    }
580}
581
582// ---------------------------------------------------------------------------
583// Membership diffing
584// ---------------------------------------------------------------------------
585
586fn diff_memberships(
587    current: &RoleGraph,
588    desired: &RoleGraph,
589    add_out: &mut Vec<Change>,
590    remove_out: &mut Vec<Change>,
591) {
592    // We compare memberships by (role, member) as the key.
593    // If inherit/admin flags changed, we remove and re-add.
594
595    // Build lookup maps: (role, member) → MembershipEdge
596    let current_map: std::collections::BTreeMap<(&str, &str), &MembershipEdge> = current
597        .memberships
598        .iter()
599        .map(|edge| ((edge.role.as_str(), edge.member.as_str()), edge))
600        .collect();
601    let desired_map: std::collections::BTreeMap<(&str, &str), &MembershipEdge> = desired
602        .memberships
603        .iter()
604        .map(|edge| ((edge.role.as_str(), edge.member.as_str()), edge))
605        .collect();
606
607    // Desired but not current → add
608    // Desired and current but different flags → remove + add
609    for (&(role, member), &desired_edge) in &desired_map {
610        match current_map.get(&(role, member)) {
611            None => {
612                add_out.push(Change::AddMember {
613                    role: desired_edge.role.clone(),
614                    member: desired_edge.member.clone(),
615                    inherit: desired_edge.inherit,
616                    admin: desired_edge.admin,
617                });
618            }
619            Some(current_edge) => {
620                if current_edge.inherit != desired_edge.inherit
621                    || current_edge.admin != desired_edge.admin
622                {
623                    // Flags changed — revoke and re-grant
624                    remove_out.push(Change::RemoveMember {
625                        role: current_edge.role.clone(),
626                        member: current_edge.member.clone(),
627                    });
628                    add_out.push(Change::AddMember {
629                        role: desired_edge.role.clone(),
630                        member: desired_edge.member.clone(),
631                        inherit: desired_edge.inherit,
632                        admin: desired_edge.admin,
633                    });
634                }
635            }
636        }
637    }
638
639    // Current but not desired → remove
640    for &(role, member) in current_map.keys() {
641        if !desired_map.contains_key(&(role, member)) {
642            remove_out.push(Change::RemoveMember {
643                role: role.to_string(),
644                member: member.to_string(),
645            });
646        }
647    }
648}
649
650// ---------------------------------------------------------------------------
651// Tests
652// ---------------------------------------------------------------------------
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657    use crate::model::{DefaultPrivState, GrantState};
658
659    /// Helper: build an empty graph.
660    fn empty_graph() -> RoleGraph {
661        RoleGraph::default()
662    }
663
664    #[test]
665    fn diff_empty_to_empty_is_empty() {
666        let changes = diff(&empty_graph(), &empty_graph());
667        assert!(changes.is_empty());
668    }
669
670    #[test]
671    fn diff_creates_new_roles() {
672        let current = empty_graph();
673        let mut desired = empty_graph();
674        desired
675            .roles
676            .insert("new-role".to_string(), RoleState::default());
677
678        let changes = diff(&current, &desired);
679        assert_eq!(changes.len(), 1);
680        assert!(matches!(&changes[0], Change::CreateRole { name, .. } if name == "new-role"));
681    }
682
683    #[test]
684    fn diff_drops_removed_roles() {
685        let mut current = empty_graph();
686        current
687            .roles
688            .insert("old-role".to_string(), RoleState::default());
689        let desired = empty_graph();
690
691        let changes = diff(&current, &desired);
692        assert_eq!(changes.len(), 1);
693        assert!(matches!(&changes[0], Change::DropRole { name } if name == "old-role"));
694    }
695
696    #[test]
697    fn diff_alters_changed_role_attributes() {
698        let mut current = empty_graph();
699        current
700            .roles
701            .insert("role1".to_string(), RoleState::default());
702
703        let mut desired = empty_graph();
704        desired.roles.insert(
705            "role1".to_string(),
706            RoleState {
707                login: true,
708                ..RoleState::default()
709            },
710        );
711
712        let changes = diff(&current, &desired);
713        assert_eq!(changes.len(), 1);
714        match &changes[0] {
715            Change::AlterRole { name, attributes } => {
716                assert_eq!(name, "role1");
717                assert!(attributes.contains(&RoleAttribute::Login(true)));
718            }
719            other => panic!("expected AlterRole, got: {other:?}"),
720        }
721    }
722
723    #[test]
724    fn diff_grants_new_privileges() {
725        let current = empty_graph();
726        let mut desired = empty_graph();
727        let key = GrantKey {
728            role: "r1".to_string(),
729            object_type: ObjectType::Table,
730            schema: Some("public".to_string()),
731            name: Some("*".to_string()),
732        };
733        desired.grants.insert(
734            key,
735            GrantState {
736                privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
737            },
738        );
739
740        let changes = diff(&current, &desired);
741        assert_eq!(changes.len(), 1);
742        match &changes[0] {
743            Change::Grant {
744                role, privileges, ..
745            } => {
746                assert_eq!(role, "r1");
747                assert!(privileges.contains(&Privilege::Select));
748                assert!(privileges.contains(&Privilege::Insert));
749            }
750            other => panic!("expected Grant, got: {other:?}"),
751        }
752    }
753
754    #[test]
755    fn diff_revokes_removed_privileges() {
756        let mut current = empty_graph();
757        let key = GrantKey {
758            role: "r1".to_string(),
759            object_type: ObjectType::Table,
760            schema: Some("public".to_string()),
761            name: Some("*".to_string()),
762        };
763        current.grants.insert(
764            key.clone(),
765            GrantState {
766                privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
767            },
768        );
769
770        let mut desired = empty_graph();
771        desired.grants.insert(
772            key,
773            GrantState {
774                privileges: BTreeSet::from([Privilege::Select]),
775            },
776        );
777
778        let changes = diff(&current, &desired);
779        assert_eq!(changes.len(), 1);
780        match &changes[0] {
781            Change::Revoke {
782                role, privileges, ..
783            } => {
784                assert_eq!(role, "r1");
785                assert!(privileges.contains(&Privilege::Insert));
786                assert!(!privileges.contains(&Privilege::Select));
787            }
788            other => panic!("expected Revoke, got: {other:?}"),
789        }
790    }
791
792    #[test]
793    fn diff_revokes_entire_grant_target_when_absent_from_desired() {
794        let mut current = empty_graph();
795        let key = GrantKey {
796            role: "r1".to_string(),
797            object_type: ObjectType::Schema,
798            schema: None,
799            name: Some("myschema".to_string()),
800        };
801        current.grants.insert(
802            key,
803            GrantState {
804                privileges: BTreeSet::from([Privilege::Usage]),
805            },
806        );
807        let desired = empty_graph();
808
809        let changes = diff(&current, &desired);
810        assert_eq!(changes.len(), 1);
811        assert!(matches!(&changes[0], Change::Revoke { role, .. } if role == "r1"));
812    }
813
814    #[test]
815    fn diff_adds_memberships() {
816        let current = empty_graph();
817        let mut desired = empty_graph();
818        desired.memberships.insert(MembershipEdge {
819            role: "editors".to_string(),
820            member: "user@example.com".to_string(),
821            inherit: true,
822            admin: false,
823        });
824
825        let changes = diff(&current, &desired);
826        assert_eq!(changes.len(), 1);
827        match &changes[0] {
828            Change::AddMember {
829                role,
830                member,
831                inherit,
832                admin,
833            } => {
834                assert_eq!(role, "editors");
835                assert_eq!(member, "user@example.com");
836                assert!(*inherit);
837                assert!(!admin);
838            }
839            other => panic!("expected AddMember, got: {other:?}"),
840        }
841    }
842
843    #[test]
844    fn diff_removes_memberships() {
845        let mut current = empty_graph();
846        current.memberships.insert(MembershipEdge {
847            role: "editors".to_string(),
848            member: "old@example.com".to_string(),
849            inherit: true,
850            admin: false,
851        });
852        let desired = empty_graph();
853
854        let changes = diff(&current, &desired);
855        assert_eq!(changes.len(), 1);
856        assert!(
857            matches!(&changes[0], Change::RemoveMember { role, member } if role == "editors" && member == "old@example.com")
858        );
859    }
860
861    #[test]
862    fn diff_re_grants_membership_when_flags_change() {
863        let mut current = empty_graph();
864        current.memberships.insert(MembershipEdge {
865            role: "editors".to_string(),
866            member: "user@example.com".to_string(),
867            inherit: true,
868            admin: false,
869        });
870
871        let mut desired = empty_graph();
872        desired.memberships.insert(MembershipEdge {
873            role: "editors".to_string(),
874            member: "user@example.com".to_string(),
875            inherit: true,
876            admin: true, // changed!
877        });
878
879        let changes = diff(&current, &desired);
880        // Should produce remove + add
881        assert_eq!(changes.len(), 2);
882        assert!(matches!(
883            &changes[0],
884            Change::RemoveMember { role, member }
885                if role == "editors" && member == "user@example.com"
886        ));
887        assert!(matches!(
888            &changes[1],
889            Change::AddMember {
890                role,
891                member,
892                admin: true,
893                ..
894            } if role == "editors" && member == "user@example.com"
895        ));
896    }
897
898    #[test]
899    fn diff_default_privileges_add_and_revoke() {
900        let mut current = empty_graph();
901        let key = DefaultPrivKey {
902            owner: "app_owner".to_string(),
903            schema: "inventory".to_string(),
904            on_type: ObjectType::Table,
905            grantee: "inventory-editor".to_string(),
906        };
907        current.default_privileges.insert(
908            key.clone(),
909            DefaultPrivState {
910                privileges: BTreeSet::from([Privilege::Select, Privilege::Delete]),
911            },
912        );
913
914        let mut desired = empty_graph();
915        desired.default_privileges.insert(
916            key,
917            DefaultPrivState {
918                privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
919            },
920        );
921
922        let changes = diff(&current, &desired);
923        // Should add INSERT and revoke DELETE
924        assert_eq!(changes.len(), 2);
925        assert!(changes.iter().any(|c| matches!(
926            c,
927            Change::SetDefaultPrivilege { privileges, .. } if privileges.contains(&Privilege::Insert)
928        )));
929        assert!(changes.iter().any(|c| matches!(
930            c,
931            Change::RevokeDefaultPrivilege { privileges, .. } if privileges.contains(&Privilege::Delete)
932        )));
933    }
934
935    #[test]
936    fn diff_ordering_creates_before_drops() {
937        let mut current = empty_graph();
938        current
939            .roles
940            .insert("old-role".to_string(), RoleState::default());
941
942        let mut desired = empty_graph();
943        desired
944            .roles
945            .insert("new-role".to_string(), RoleState::default());
946
947        let changes = diff(&current, &desired);
948        assert_eq!(changes.len(), 2);
949
950        // Creates should come before drops
951        let create_idx = changes
952            .iter()
953            .position(|c| matches!(c, Change::CreateRole { .. }))
954            .unwrap();
955        let drop_idx = changes
956            .iter()
957            .position(|c| matches!(c, Change::DropRole { .. }))
958            .unwrap();
959        assert!(create_idx < drop_idx);
960    }
961
962    #[test]
963    fn diff_identical_graphs_produce_no_changes() {
964        let mut graph = empty_graph();
965        graph
966            .roles
967            .insert("role1".to_string(), RoleState::default());
968        graph.grants.insert(
969            GrantKey {
970                role: "role1".to_string(),
971                object_type: ObjectType::Table,
972                schema: Some("public".to_string()),
973                name: Some("*".to_string()),
974            },
975            GrantState {
976                privileges: BTreeSet::from([Privilege::Select]),
977            },
978        );
979        graph.memberships.insert(MembershipEdge {
980            role: "role1".to_string(),
981            member: "user@example.com".to_string(),
982            inherit: true,
983            admin: false,
984        });
985
986        let changes = diff(&graph, &graph);
987        assert!(
988            changes.is_empty(),
989            "identical graphs should produce no changes"
990        );
991    }
992
993    /// Integration test: round-trip from manifest → expand → model → diff
994    #[test]
995    fn manifest_to_diff_integration() {
996        use crate::manifest::{expand_manifest, parse_manifest};
997        use crate::model::RoleGraph;
998
999        let yaml = r#"
1000default_owner: app_owner
1001
1002profiles:
1003  editor:
1004    grants:
1005      - privileges: [USAGE]
1006        object: { type: schema }
1007      - privileges: [SELECT, INSERT, UPDATE, DELETE]
1008        object: { type: table, name: "*" }
1009    default_privileges:
1010      - privileges: [SELECT, INSERT, UPDATE, DELETE]
1011        on_type: table
1012
1013schemas:
1014  - name: inventory
1015    profiles: [editor]
1016
1017memberships:
1018  - role: inventory-editor
1019    members:
1020      - name: "user@example.com"
1021"#;
1022        let manifest = parse_manifest(yaml).unwrap();
1023        let expanded = expand_manifest(&manifest).unwrap();
1024        let desired =
1025            RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
1026
1027        // Current state is empty — everything should be created
1028        let current = RoleGraph::default();
1029        let changes = diff(&current, &desired);
1030
1031        // Should have: 1 CreateRole, 2 Grants, 1 SetDefaultPrivilege, 1 AddMember
1032        let create_count = changes
1033            .iter()
1034            .filter(|c| matches!(c, Change::CreateRole { .. }))
1035            .count();
1036        let grant_count = changes
1037            .iter()
1038            .filter(|c| matches!(c, Change::Grant { .. }))
1039            .count();
1040        let dp_count = changes
1041            .iter()
1042            .filter(|c| matches!(c, Change::SetDefaultPrivilege { .. }))
1043            .count();
1044        let member_count = changes
1045            .iter()
1046            .filter(|c| matches!(c, Change::AddMember { .. }))
1047            .count();
1048
1049        assert_eq!(create_count, 1);
1050        assert_eq!(grant_count, 2); // schema USAGE + table *
1051        assert_eq!(dp_count, 1);
1052        assert_eq!(member_count, 1);
1053
1054        // Diffing desired against itself should produce no changes
1055        let no_changes = diff(&desired, &desired);
1056        assert!(no_changes.is_empty());
1057    }
1058
1059    // -----------------------------------------------------------------------
1060    // filter_changes — ReconciliationMode tests
1061    // -----------------------------------------------------------------------
1062
1063    /// Build a representative change list covering every Change variant.
1064    fn all_change_variants() -> Vec<Change> {
1065        vec![
1066            Change::CreateRole {
1067                name: "new-role".to_string(),
1068                state: RoleState::default(),
1069            },
1070            Change::AlterRole {
1071                name: "altered-role".to_string(),
1072                attributes: vec![RoleAttribute::Login(true)],
1073            },
1074            Change::SetComment {
1075                name: "commented-role".to_string(),
1076                comment: Some("hello".to_string()),
1077            },
1078            Change::Grant {
1079                role: "r1".to_string(),
1080                privileges: BTreeSet::from([Privilege::Select]),
1081                object_type: ObjectType::Table,
1082                schema: Some("public".to_string()),
1083                name: Some("*".to_string()),
1084            },
1085            Change::Revoke {
1086                role: "r1".to_string(),
1087                privileges: BTreeSet::from([Privilege::Insert]),
1088                object_type: ObjectType::Table,
1089                schema: Some("public".to_string()),
1090                name: Some("*".to_string()),
1091            },
1092            Change::SetDefaultPrivilege {
1093                owner: "owner".to_string(),
1094                schema: "public".to_string(),
1095                on_type: ObjectType::Table,
1096                grantee: "r1".to_string(),
1097                privileges: BTreeSet::from([Privilege::Select]),
1098            },
1099            Change::RevokeDefaultPrivilege {
1100                owner: "owner".to_string(),
1101                schema: "public".to_string(),
1102                on_type: ObjectType::Table,
1103                grantee: "r1".to_string(),
1104                privileges: BTreeSet::from([Privilege::Delete]),
1105            },
1106            Change::AddMember {
1107                role: "editors".to_string(),
1108                member: "user@example.com".to_string(),
1109                inherit: true,
1110                admin: false,
1111            },
1112            Change::RemoveMember {
1113                role: "editors".to_string(),
1114                member: "old@example.com".to_string(),
1115            },
1116            Change::TerminateSessions {
1117                role: "retired-role".to_string(),
1118            },
1119            Change::ReassignOwned {
1120                from_role: "retired-role".to_string(),
1121                to_role: "successor".to_string(),
1122            },
1123            Change::DropOwned {
1124                role: "retired-role".to_string(),
1125            },
1126            Change::DropRole {
1127                name: "retired-role".to_string(),
1128            },
1129        ]
1130    }
1131
1132    #[test]
1133    fn filter_authoritative_keeps_all_changes() {
1134        let changes = all_change_variants();
1135        let original_len = changes.len();
1136        let filtered = filter_changes(changes, ReconciliationMode::Authoritative);
1137        assert_eq!(filtered.len(), original_len);
1138    }
1139
1140    #[test]
1141    fn filter_additive_keeps_only_constructive_changes() {
1142        let filtered = filter_changes(all_change_variants(), ReconciliationMode::Additive);
1143
1144        // Should keep: CreateRole, AlterRole, SetComment, Grant, SetDefaultPrivilege, AddMember
1145        assert_eq!(filtered.len(), 6);
1146
1147        // Verify no destructive changes remain
1148        for change in &filtered {
1149            assert!(
1150                !matches!(
1151                    change,
1152                    Change::Revoke { .. }
1153                        | Change::RevokeDefaultPrivilege { .. }
1154                        | Change::RemoveMember { .. }
1155                        | Change::DropRole { .. }
1156                        | Change::DropOwned { .. }
1157                        | Change::ReassignOwned { .. }
1158                        | Change::TerminateSessions { .. }
1159                ),
1160                "additive mode should not contain destructive change: {change:?}"
1161            );
1162        }
1163
1164        // Verify constructive changes are present
1165        assert!(
1166            filtered
1167                .iter()
1168                .any(|c| matches!(c, Change::CreateRole { .. }))
1169        );
1170        assert!(
1171            filtered
1172                .iter()
1173                .any(|c| matches!(c, Change::AlterRole { .. }))
1174        );
1175        assert!(
1176            filtered
1177                .iter()
1178                .any(|c| matches!(c, Change::SetComment { .. }))
1179        );
1180        assert!(filtered.iter().any(|c| matches!(c, Change::Grant { .. })));
1181        assert!(
1182            filtered
1183                .iter()
1184                .any(|c| matches!(c, Change::SetDefaultPrivilege { .. }))
1185        );
1186        assert!(
1187            filtered
1188                .iter()
1189                .any(|c| matches!(c, Change::AddMember { .. }))
1190        );
1191    }
1192
1193    #[test]
1194    fn filter_adopt_keeps_revokes_but_not_drops() {
1195        let filtered = filter_changes(all_change_variants(), ReconciliationMode::Adopt);
1196
1197        // Should keep everything except: DropRole, DropOwned, ReassignOwned, TerminateSessions
1198        assert_eq!(filtered.len(), 9);
1199
1200        // Verify no role-drop/retirement changes remain
1201        for change in &filtered {
1202            assert!(
1203                !matches!(
1204                    change,
1205                    Change::DropRole { .. }
1206                        | Change::DropOwned { .. }
1207                        | Change::ReassignOwned { .. }
1208                        | Change::TerminateSessions { .. }
1209                ),
1210                "adopt mode should not contain drop/retirement change: {change:?}"
1211            );
1212        }
1213
1214        // Verify revokes ARE still present (unlike additive)
1215        assert!(filtered.iter().any(|c| matches!(c, Change::Revoke { .. })));
1216        assert!(
1217            filtered
1218                .iter()
1219                .any(|c| matches!(c, Change::RevokeDefaultPrivilege { .. }))
1220        );
1221        assert!(
1222            filtered
1223                .iter()
1224                .any(|c| matches!(c, Change::RemoveMember { .. }))
1225        );
1226    }
1227
1228    #[test]
1229    fn filter_additive_with_empty_input() {
1230        let filtered = filter_changes(vec![], ReconciliationMode::Additive);
1231        assert!(filtered.is_empty());
1232    }
1233
1234    #[test]
1235    fn filter_additive_only_destructive_changes_yields_empty() {
1236        let changes = vec![
1237            Change::Revoke {
1238                role: "r1".to_string(),
1239                privileges: BTreeSet::from([Privilege::Select]),
1240                object_type: ObjectType::Table,
1241                schema: Some("public".to_string()),
1242                name: Some("*".to_string()),
1243            },
1244            Change::DropRole {
1245                name: "old-role".to_string(),
1246            },
1247        ];
1248        let filtered = filter_changes(changes, ReconciliationMode::Additive);
1249        assert!(filtered.is_empty());
1250    }
1251
1252    #[test]
1253    fn filter_adopt_preserves_ordering() {
1254        let changes = vec![
1255            Change::CreateRole {
1256                name: "new-role".to_string(),
1257                state: RoleState::default(),
1258            },
1259            Change::Grant {
1260                role: "new-role".to_string(),
1261                privileges: BTreeSet::from([Privilege::Select]),
1262                object_type: ObjectType::Table,
1263                schema: Some("public".to_string()),
1264                name: Some("*".to_string()),
1265            },
1266            Change::Revoke {
1267                role: "existing-role".to_string(),
1268                privileges: BTreeSet::from([Privilege::Insert]),
1269                object_type: ObjectType::Table,
1270                schema: Some("public".to_string()),
1271                name: Some("*".to_string()),
1272            },
1273            Change::DropRole {
1274                name: "old-role".to_string(),
1275            },
1276        ];
1277
1278        let filtered = filter_changes(changes, ReconciliationMode::Adopt);
1279        assert_eq!(filtered.len(), 3);
1280        assert!(matches!(&filtered[0], Change::CreateRole { name, .. } if name == "new-role"));
1281        assert!(matches!(&filtered[1], Change::Grant { .. }));
1282        assert!(matches!(&filtered[2], Change::Revoke { .. }));
1283    }
1284
1285    #[test]
1286    fn reconciliation_mode_display() {
1287        assert_eq!(
1288            ReconciliationMode::Authoritative.to_string(),
1289            "authoritative"
1290        );
1291        assert_eq!(ReconciliationMode::Additive.to_string(), "additive");
1292        assert_eq!(ReconciliationMode::Adopt.to_string(), "adopt");
1293    }
1294
1295    #[test]
1296    fn reconciliation_mode_default_is_authoritative() {
1297        assert_eq!(
1298            ReconciliationMode::default(),
1299            ReconciliationMode::Authoritative
1300        );
1301    }
1302
1303    // -----------------------------------------------------------------------
1304    // apply_role_retirements tests
1305    // -----------------------------------------------------------------------
1306
1307    #[test]
1308    fn apply_role_retirements_inserts_cleanup_before_drop() {
1309        let changes = vec![
1310            Change::Grant {
1311                role: "analytics".to_string(),
1312                privileges: BTreeSet::from([Privilege::Select]),
1313                object_type: ObjectType::Table,
1314                schema: Some("public".to_string()),
1315                name: Some("*".to_string()),
1316            },
1317            Change::DropRole {
1318                name: "old-app".to_string(),
1319            },
1320        ];
1321
1322        let planned = apply_role_retirements(
1323            changes,
1324            &[crate::manifest::RoleRetirement {
1325                role: "old-app".to_string(),
1326                reassign_owned_to: Some("successor".to_string()),
1327                drop_owned: true,
1328                terminate_sessions: true,
1329            }],
1330        );
1331
1332        assert!(matches!(planned[0], Change::Grant { .. }));
1333        assert!(matches!(
1334            planned[1],
1335            Change::TerminateSessions { ref role } if role == "old-app"
1336        ));
1337        assert!(matches!(
1338            planned[2],
1339            Change::ReassignOwned {
1340                ref from_role,
1341                ref to_role
1342            } if from_role == "old-app" && to_role == "successor"
1343        ));
1344        assert!(matches!(
1345            planned[3],
1346            Change::DropOwned { ref role } if role == "old-app"
1347        ));
1348        assert!(matches!(
1349            planned[4],
1350            Change::DropRole { ref name } if name == "old-app"
1351        ));
1352    }
1353
1354    #[test]
1355    fn inject_password_for_new_role() {
1356        let changes = vec![Change::CreateRole {
1357            name: "app-svc".to_string(),
1358            state: RoleState::default(),
1359        }];
1360
1361        let mut passwords = std::collections::BTreeMap::new();
1362        passwords.insert("app-svc".to_string(), "secret123".to_string());
1363
1364        let result = inject_password_changes(changes, &passwords);
1365        assert_eq!(result.len(), 2);
1366        assert!(matches!(&result[0], Change::CreateRole { name, .. } if name == "app-svc"));
1367        assert!(
1368            matches!(&result[1], Change::SetPassword { name, password } if name == "app-svc" && password.starts_with("SCRAM-SHA-256$"))
1369        );
1370    }
1371
1372    #[test]
1373    fn inject_password_for_existing_role() {
1374        // No CreateRole — role already exists. Only grants change.
1375        let changes = vec![Change::Grant {
1376            role: "app-svc".to_string(),
1377            privileges: BTreeSet::from([crate::manifest::Privilege::Select]),
1378            object_type: crate::manifest::ObjectType::Table,
1379            schema: Some("public".to_string()),
1380            name: Some("*".to_string()),
1381        }];
1382
1383        let mut passwords = std::collections::BTreeMap::new();
1384        passwords.insert("app-svc".to_string(), "secret123".to_string());
1385
1386        let result = inject_password_changes(changes, &passwords);
1387        assert_eq!(result.len(), 2);
1388        assert!(matches!(&result[0], Change::Grant { .. }));
1389        assert!(
1390            matches!(&result[1], Change::SetPassword { name, password } if name == "app-svc" && password.starts_with("SCRAM-SHA-256$"))
1391        );
1392    }
1393
1394    #[test]
1395    fn inject_password_empty_passwords_is_noop() {
1396        let changes = vec![Change::CreateRole {
1397            name: "app-svc".to_string(),
1398            state: RoleState::default(),
1399        }];
1400
1401        let passwords = std::collections::BTreeMap::new();
1402        let result = inject_password_changes(changes.clone(), &passwords);
1403        assert_eq!(result.len(), 1);
1404    }
1405
1406    #[test]
1407    fn resolve_passwords_missing_env_var() {
1408        let roles = vec![crate::manifest::RoleDefinition {
1409            name: "app-svc".to_string(),
1410            login: Some(true),
1411            password: Some(crate::manifest::PasswordSource {
1412                from_env: "PGROLES_TEST_MISSING_VAR_9a8b7c6d".to_string(),
1413            }),
1414            password_valid_until: None,
1415            superuser: None,
1416            createdb: None,
1417            createrole: None,
1418            inherit: None,
1419            replication: None,
1420            bypassrls: None,
1421            connection_limit: None,
1422            comment: None,
1423        }];
1424
1425        // Ensure the env var does not exist.
1426        // SAFETY: test-only, unique var name avoids conflicts with parallel tests.
1427        unsafe { std::env::remove_var("PGROLES_TEST_MISSING_VAR_9a8b7c6d") };
1428
1429        let result = resolve_passwords(&roles);
1430        assert!(result.is_err());
1431        let err = result.unwrap_err();
1432        assert!(
1433            matches!(err, PasswordResolutionError::MissingEnvVar { ref role, ref env_var }
1434                if role == "app-svc" && env_var == "PGROLES_TEST_MISSING_VAR_9a8b7c6d"),
1435            "expected MissingEnvVar, got: {err:?}"
1436        );
1437    }
1438
1439    #[test]
1440    fn resolve_passwords_empty_env_var() {
1441        let roles = vec![crate::manifest::RoleDefinition {
1442            name: "app-svc".to_string(),
1443            login: Some(true),
1444            password: Some(crate::manifest::PasswordSource {
1445                from_env: "PGROLES_TEST_EMPTY_VAR_1a2b3c4d".to_string(),
1446            }),
1447            password_valid_until: None,
1448            superuser: None,
1449            createdb: None,
1450            createrole: None,
1451            inherit: None,
1452            replication: None,
1453            bypassrls: None,
1454            connection_limit: None,
1455            comment: None,
1456        }];
1457
1458        // Set the env var to an empty string.
1459        // SAFETY: test-only, unique var name avoids conflicts with parallel tests.
1460        unsafe { std::env::set_var("PGROLES_TEST_EMPTY_VAR_1a2b3c4d", "") };
1461
1462        let result = resolve_passwords(&roles);
1463
1464        // Clean up.
1465        unsafe { std::env::remove_var("PGROLES_TEST_EMPTY_VAR_1a2b3c4d") };
1466
1467        assert!(result.is_err());
1468        let err = result.unwrap_err();
1469        assert!(
1470            matches!(err, PasswordResolutionError::EmptyPassword { ref role, ref env_var }
1471                if role == "app-svc" && env_var == "PGROLES_TEST_EMPTY_VAR_1a2b3c4d"),
1472            "expected EmptyPassword, got: {err:?}"
1473        );
1474    }
1475
1476    #[test]
1477    fn resolve_passwords_happy_path() {
1478        let roles = vec![crate::manifest::RoleDefinition {
1479            name: "app-svc".to_string(),
1480            login: Some(true),
1481            password: Some(crate::manifest::PasswordSource {
1482                from_env: "PGROLES_TEST_RESOLVE_VAR_5e6f7g8h".to_string(),
1483            }),
1484            password_valid_until: None,
1485            superuser: None,
1486            createdb: None,
1487            createrole: None,
1488            inherit: None,
1489            replication: None,
1490            bypassrls: None,
1491            connection_limit: None,
1492            comment: None,
1493        }];
1494
1495        // SAFETY: test-only, unique var name avoids conflicts with parallel tests.
1496        unsafe { std::env::set_var("PGROLES_TEST_RESOLVE_VAR_5e6f7g8h", "my_secret_pw") };
1497
1498        let result = resolve_passwords(&roles);
1499
1500        unsafe { std::env::remove_var("PGROLES_TEST_RESOLVE_VAR_5e6f7g8h") };
1501
1502        let resolved = result.expect("should succeed");
1503        assert_eq!(resolved.len(), 1);
1504        assert_eq!(resolved["app-svc"], "my_secret_pw");
1505    }
1506
1507    #[test]
1508    fn resolve_passwords_skips_roles_without_password() {
1509        let roles = vec![crate::manifest::RoleDefinition {
1510            name: "no-password".to_string(),
1511            login: Some(true),
1512            password: None,
1513            password_valid_until: None,
1514            superuser: None,
1515            createdb: None,
1516            createrole: None,
1517            inherit: None,
1518            replication: None,
1519            bypassrls: None,
1520            connection_limit: None,
1521            comment: None,
1522        }];
1523
1524        let result = resolve_passwords(&roles);
1525        let resolved = result.expect("should succeed");
1526        assert!(resolved.is_empty());
1527    }
1528
1529    #[test]
1530    fn inject_password_multiple_roles() {
1531        let changes = vec![
1532            Change::CreateRole {
1533                name: "role-a".to_string(),
1534                state: RoleState::default(),
1535            },
1536            Change::CreateRole {
1537                name: "role-b".to_string(),
1538                state: RoleState::default(),
1539            },
1540            Change::Grant {
1541                role: "role-c".to_string(),
1542                privileges: BTreeSet::from([crate::manifest::Privilege::Select]),
1543                object_type: crate::manifest::ObjectType::Table,
1544                schema: Some("public".to_string()),
1545                name: Some("*".to_string()),
1546            },
1547        ];
1548
1549        let mut passwords = std::collections::BTreeMap::new();
1550        passwords.insert("role-a".to_string(), "pw-a".to_string());
1551        passwords.insert("role-b".to_string(), "pw-b".to_string());
1552        passwords.insert("role-c".to_string(), "pw-c".to_string());
1553
1554        let result = inject_password_changes(changes, &passwords);
1555
1556        // role-a: CreateRole, SetPassword (inline)
1557        // role-b: CreateRole, SetPassword (inline)
1558        // role-c: Grant (existing role — SetPassword appended at end)
1559        assert_eq!(result.len(), 6, "expected 6 changes, got: {result:?}");
1560        assert!(matches!(&result[0], Change::CreateRole { name, .. } if name == "role-a"));
1561        assert!(matches!(&result[1], Change::SetPassword { name, .. } if name == "role-a"));
1562        assert!(matches!(&result[2], Change::CreateRole { name, .. } if name == "role-b"));
1563        assert!(matches!(&result[3], Change::SetPassword { name, .. } if name == "role-b"));
1564        assert!(matches!(&result[4], Change::Grant { .. }));
1565        assert!(matches!(&result[5], Change::SetPassword { name, .. } if name == "role-c"));
1566    }
1567
1568    #[test]
1569    fn diff_detects_valid_until_change() {
1570        let mut current = empty_graph();
1571        current.roles.insert(
1572            "r1".to_string(),
1573            RoleState {
1574                login: true,
1575                ..RoleState::default()
1576            },
1577        );
1578
1579        let mut desired = empty_graph();
1580        desired.roles.insert(
1581            "r1".to_string(),
1582            RoleState {
1583                login: true,
1584                password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
1585                ..RoleState::default()
1586            },
1587        );
1588
1589        let changes = diff(&current, &desired);
1590        assert_eq!(changes.len(), 1);
1591        match &changes[0] {
1592            Change::AlterRole { name, attributes } => {
1593                assert_eq!(name, "r1");
1594                assert!(attributes.contains(&RoleAttribute::ValidUntil(Some(
1595                    "2025-12-31T00:00:00Z".to_string()
1596                ))));
1597            }
1598            other => panic!("expected AlterRole, got: {other:?}"),
1599        }
1600    }
1601
1602    #[test]
1603    fn diff_detects_valid_until_removal() {
1604        let mut current = empty_graph();
1605        current.roles.insert(
1606            "r1".to_string(),
1607            RoleState {
1608                login: true,
1609                password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
1610                ..RoleState::default()
1611            },
1612        );
1613
1614        let mut desired = empty_graph();
1615        desired.roles.insert(
1616            "r1".to_string(),
1617            RoleState {
1618                login: true,
1619                ..RoleState::default()
1620            },
1621        );
1622
1623        let changes = diff(&current, &desired);
1624        assert_eq!(changes.len(), 1);
1625        match &changes[0] {
1626            Change::AlterRole { name, attributes } => {
1627                assert_eq!(name, "r1");
1628                assert!(attributes.contains(&RoleAttribute::ValidUntil(None)));
1629            }
1630            other => panic!("expected AlterRole, got: {other:?}"),
1631        }
1632    }
1633}