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