Skip to main content

pgroles_operator/
crd.rs

1//! Custom Resource Definition for `PostgresPolicy`.
2//!
3//! Defines the `pgroles.io/v1alpha1` CRD that the operator watches.
4//! The spec mirrors the CLI manifest schema with additional fields for
5//! database connection and reconciliation scheduling.
6
7use kube::CustomResource;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use std::collections::{BTreeMap, BTreeSet};
11
12use pgroles_core::manifest::{
13    DefaultPrivilege, Grant, Membership, ObjectType, Privilege, RoleRetirement, SchemaBinding,
14};
15
16/// Valid PostgreSQL SSL modes for connection params.
17pub const VALID_SSL_MODES: &[&str] = &[
18    "disable",
19    "allow",
20    "prefer",
21    "require",
22    "verify-ca",
23    "verify-full",
24];
25
26// ---------------------------------------------------------------------------
27// CRD spec
28// ---------------------------------------------------------------------------
29
30/// Spec for a `PostgresPolicy` custom resource.
31///
32/// Defines the desired state of PostgreSQL roles, grants, default privileges,
33/// and memberships for a single database connection.
34#[derive(CustomResource, Debug, Clone, Serialize, Deserialize, JsonSchema)]
35#[kube(
36    group = "pgroles.io",
37    version = "v1alpha1",
38    kind = "PostgresPolicy",
39    namespaced,
40    status = "PostgresPolicyStatus",
41    shortname = "pgr",
42    category = "pgroles",
43    printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#,
44    printcolumn = r#"{"name":"Mode","type":"string","jsonPath":".spec.mode"}"#,
45    printcolumn = r#"{"name":"Recon","type":"string","jsonPath":".spec.reconciliation_mode","priority":1}"#,
46    printcolumn = r#"{"name":"Drift","type":"string","jsonPath":".status.conditions[?(@.type==\"Drifted\")].status"}"#,
47    printcolumn = r#"{"name":"Changes","type":"integer","jsonPath":".status.change_summary.total"}"#,
48    printcolumn = r#"{"name":"Last Reconcile","type":"date","jsonPath":".status.last_successful_reconcile_time"}"#,
49    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
50)]
51pub struct PostgresPolicySpec {
52    /// Database connection configuration.
53    pub connection: ConnectionSpec,
54
55    /// Reconciliation interval (e.g. "5m", "1h"). Defaults to "5m".
56    #[serde(default = "default_interval")]
57    pub interval: String,
58
59    /// Suspend reconciliation when true. Defaults to false.
60    #[serde(default)]
61    pub suspend: bool,
62
63    /// Reconciliation mode: `apply` executes SQL, `plan` computes drift only.
64    #[serde(default)]
65    pub mode: PolicyMode,
66
67    /// Convergence strategy: how aggressively to converge the database.
68    ///
69    /// - `authoritative` (default): full convergence — anything not in the
70    ///   manifest is revoked/dropped.
71    /// - `additive`: only grant, never revoke — safe for incremental adoption.
72    /// - `adopt`: manage declared roles fully, but never drop undeclared roles.
73    #[serde(default)]
74    pub reconciliation_mode: CrdReconciliationMode,
75
76    /// Default owner for ALTER DEFAULT PRIVILEGES (e.g. "app_owner").
77    #[serde(default)]
78    pub default_owner: Option<String>,
79
80    /// Reusable privilege profiles.
81    #[serde(default)]
82    pub profiles: std::collections::HashMap<String, ProfileSpec>,
83
84    /// Schema bindings that expand profiles into concrete roles/grants.
85    #[serde(default)]
86    pub schemas: Vec<SchemaBinding>,
87
88    /// One-off role definitions.
89    #[serde(default)]
90    pub roles: Vec<RoleSpec>,
91
92    /// One-off grants.
93    #[serde(default)]
94    pub grants: Vec<Grant>,
95
96    /// One-off default privileges.
97    #[serde(default)]
98    pub default_privileges: Vec<DefaultPrivilege>,
99
100    /// Membership edges.
101    #[serde(default)]
102    pub memberships: Vec<Membership>,
103
104    /// Explicit role-retirement workflows for roles that should be removed.
105    #[serde(default)]
106    pub retirements: Vec<RoleRetirement>,
107
108    /// Approval mode for plans: `auto` or `manual`.
109    /// When `manual`, plans require explicit approval before execution.
110    /// When `auto`, plans are approved and applied immediately.
111    /// When omitted, inferred from `mode`: `apply` → `auto`, `plan` → `manual`.
112    /// This ensures backward compatibility for existing `mode: apply` users.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub approval: Option<ApprovalMode>,
115}
116
117fn default_interval() -> String {
118    "5m".to_string()
119}
120
121/// Policy reconcile mode.
122#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
123#[serde(rename_all = "lowercase")]
124pub enum PolicyMode {
125    #[default]
126    Apply,
127    Plan,
128}
129
130/// Convergence strategy for how aggressively to converge the database.
131#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
132#[serde(rename_all = "lowercase")]
133pub enum CrdReconciliationMode {
134    /// Full convergence — the manifest is the entire truth.
135    #[default]
136    Authoritative,
137    /// Only grant, never revoke — safe for incremental adoption.
138    Additive,
139    /// Manage declared roles fully, but never drop undeclared roles.
140    Adopt,
141}
142
143/// Approval mode for plans generated by this policy.
144#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
145pub enum ApprovalMode {
146    /// Plans require explicit approval annotation before execution.
147    #[serde(rename = "manual")]
148    Manual,
149    /// Plans are approved and applied automatically.
150    #[serde(rename = "auto")]
151    Auto,
152}
153
154impl PostgresPolicySpec {
155    /// Resolve the effective approval mode, inferring from `mode` when not set.
156    /// `apply` → `Auto` (backward compat), `plan` → `Manual`.
157    pub fn effective_approval(&self) -> ApprovalMode {
158        match &self.approval {
159            Some(mode) => mode.clone(),
160            None => match self.mode {
161                PolicyMode::Apply => ApprovalMode::Auto,
162                PolicyMode::Plan => ApprovalMode::Manual,
163            },
164        }
165    }
166}
167
168// ---------------------------------------------------------------------------
169// Well-known annotations and labels
170// ---------------------------------------------------------------------------
171
172/// Annotation key used to approve a `PostgresPolicyPlan`.
173pub const PLAN_APPROVED_ANNOTATION: &str = "pgroles.io/approved";
174
175/// Annotation key used to reject a `PostgresPolicyPlan`.
176pub const PLAN_REJECTED_ANNOTATION: &str = "pgroles.io/rejected";
177
178/// Label key for the parent policy name on plan resources.
179pub const LABEL_POLICY: &str = "pgroles.io/policy";
180
181/// Label key for the managed database identity on plan resources.
182pub const LABEL_DATABASE_IDENTITY: &str = "pgroles.io/database-identity";
183
184/// Label key for the plan name on SQL storage resources.
185pub const LABEL_PLAN: &str = "pgroles.io/plan";
186
187impl From<CrdReconciliationMode> for pgroles_core::diff::ReconciliationMode {
188    fn from(crd: CrdReconciliationMode) -> Self {
189        match crd {
190            CrdReconciliationMode::Authoritative => {
191                pgroles_core::diff::ReconciliationMode::Authoritative
192            }
193            CrdReconciliationMode::Additive => pgroles_core::diff::ReconciliationMode::Additive,
194            CrdReconciliationMode::Adopt => pgroles_core::diff::ReconciliationMode::Adopt,
195        }
196    }
197}
198
199/// Database connection configuration.
200///
201/// Supports two mutually exclusive modes:
202///
203/// **Mode 1 — Single URL** (backward-compatible):
204/// ```yaml
205/// connection:
206///   secretRef: { name: my-secret }
207///   secretKey: DATABASE_URL        # optional, defaults to DATABASE_URL
208/// ```
209///
210/// **Mode 2 — Structured params** (for Zalando/CNPG/PGO secrets):
211/// ```yaml
212/// connection:
213///   params:
214///     host: my-cluster-postgres
215///     port: 5432
216///     dbname: mydb
217///     usernameSecret: { name: zalando-creds, key: username }
218///     passwordSecret: { name: zalando-creds, key: password }
219/// ```
220#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
221#[serde(rename_all = "camelCase")]
222pub struct ConnectionSpec {
223    /// Reference to a Kubernetes Secret containing a connection URL.
224    /// Mutually exclusive with `params`.
225    #[serde(default)]
226    pub secret_ref: Option<SecretReference>,
227
228    /// Key within the Secret to read. Defaults to `DATABASE_URL`.
229    /// Only used with `secretRef`.
230    #[serde(default)]
231    pub secret_key: Option<String>,
232
233    /// Structured connection parameters. Each field is either a plain string
234    /// or a reference to a Secret key. Mutually exclusive with `secretRef`.
235    #[serde(default)]
236    pub params: Option<ConnectionParams>,
237}
238
239impl ConnectionSpec {
240    /// Effective secret key for URL mode. Defaults to `DATABASE_URL`.
241    pub fn effective_secret_key(&self) -> &str {
242        self.secret_key.as_deref().unwrap_or("DATABASE_URL")
243    }
244
245    /// Collect all Secret names referenced by this connection spec.
246    pub fn collect_secret_names(&self, names: &mut BTreeSet<String>) {
247        if let Some(ref secret_ref) = self.secret_ref {
248            names.insert(secret_ref.name.clone());
249        }
250        if let Some(ref params) = self.params {
251            for sel in [
252                &params.host_secret,
253                &params.port_secret,
254                &params.dbname_secret,
255                &params.username_secret,
256                &params.password_secret,
257                &params.ssl_mode_secret,
258            ]
259            .into_iter()
260            .flatten()
261            {
262                names.insert(sel.name.clone());
263            }
264        }
265    }
266
267    /// Deterministic identity key for this connection spec.
268    ///
269    /// - URL mode: `{secret_ref.name}/{secret_key}`
270    /// - Params mode: canonical representation of the params
271    ///
272    /// Uses `\0` as field separator since null bytes cannot appear in K8s names
273    /// or secret values, avoiding ambiguity from colons in literal values.
274    /// Deterministic identity key for per-database locking and conflict detection.
275    ///
276    /// Identifies the target database (host + port + dbname) but NOT the
277    /// credentials. Two policies targeting the same database with different
278    /// users should still be considered as targeting the same database for
279    /// locking and overlap checks.
280    pub fn identity_key(&self) -> String {
281        if let Some(ref secret_ref) = self.secret_ref {
282            format!("{}/{}", secret_ref.name, self.effective_secret_key())
283        } else if let Some(ref params) = self.params {
284            let port_part = params
285                .port
286                .as_ref()
287                .map(|p| format!("literal={p}"))
288                .or_else(|| {
289                    params
290                        .port_secret
291                        .as_ref()
292                        .map(|s| format!("secret={}\0{}", s.name, s.key))
293                })
294                .unwrap_or_else(|| "5432".to_string());
295            format!(
296                "params\0{}\0{}\0{}",
297                field_identity_repr(&params.host, &params.host_secret),
298                field_identity_repr(&params.dbname, &params.dbname_secret),
299                port_part,
300            )
301        } else {
302            "invalid-connection".to_string()
303        }
304    }
305
306    /// Cache key for pool lookup. Includes ALL connection params so that any
307    /// configuration change (credentials, sslMode, host, etc.) invalidates
308    /// the cached pool. This is strictly more specific than `identity_key`.
309    pub fn cache_key(&self, namespace: &str) -> String {
310        if let Some(ref params) = self.params {
311            let user_part = field_identity_repr(&params.username, &params.username_secret);
312            let pass_part = field_identity_repr(&params.password, &params.password_secret);
313            let ssl_part = params
314                .ssl_mode
315                .as_ref()
316                .map(|v| format!("literal={v}"))
317                .or_else(|| {
318                    params
319                        .ssl_mode_secret
320                        .as_ref()
321                        .map(|s| format!("secret={}\0{}", s.name, s.key))
322                })
323                .unwrap_or_default();
324            format!(
325                "{namespace}/{}\0user={user_part}\0pass={pass_part}\0ssl={ssl_part}",
326                self.identity_key()
327            )
328        } else {
329            format!("{namespace}/{}", self.identity_key())
330        }
331    }
332}
333
334/// Deterministic string representation for a literal/secret field pair.
335///
336/// Uses a `literal=` / `secret=` prefix scheme so that a literal value
337/// can never collide with a secret reference representation.
338fn field_identity_repr(literal: &Option<String>, secret: &Option<SecretKeySelector>) -> String {
339    if let Some(value) = literal {
340        format!("literal={value}")
341    } else if let Some(sel) = secret {
342        format!("secret={}\0{}", sel.name, sel.key)
343    } else {
344        String::new()
345    }
346}
347
348/// Structured connection parameters for building a PostgreSQL connection URL.
349///
350/// Each field supports either a literal value or a reference to a Kubernetes
351/// Secret key. For each parameter, set either the literal field or the
352/// corresponding `*Secret` field — not both.
353///
354/// ```yaml
355/// # Zalando pattern — literals for non-sensitive, secrets for credentials
356/// params:
357///   host: my-cluster-postgres
358///   port: 5432
359///   dbname: mydb
360///   sslMode: require
361///   usernameSecret:
362///     name: pg-creds
363///     key: username
364///   passwordSecret:
365///     name: pg-creds
366///     key: password
367/// ```
368#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
369#[serde(rename_all = "camelCase")]
370pub struct ConnectionParams {
371    /// PostgreSQL host as a literal value.
372    #[serde(default)]
373    pub host: Option<String>,
374    /// PostgreSQL host from a Secret key.
375    #[serde(default)]
376    pub host_secret: Option<SecretKeySelector>,
377
378    /// Port as a literal value. Defaults to 5432 if neither port nor portSecret is set.
379    #[serde(default)]
380    pub port: Option<u16>,
381    /// Port from a Secret key.
382    #[serde(default)]
383    pub port_secret: Option<SecretKeySelector>,
384
385    /// Database name as a literal value.
386    #[serde(default)]
387    pub dbname: Option<String>,
388    /// Database name from a Secret key.
389    #[serde(default)]
390    pub dbname_secret: Option<SecretKeySelector>,
391
392    /// Username as a literal value.
393    #[serde(default)]
394    pub username: Option<String>,
395    /// Username from a Secret key.
396    #[serde(default)]
397    pub username_secret: Option<SecretKeySelector>,
398
399    /// Password as a literal value (not recommended for production).
400    #[serde(default)]
401    pub password: Option<String>,
402    /// Password from a Secret key (recommended).
403    #[serde(default)]
404    pub password_secret: Option<SecretKeySelector>,
405
406    /// SSL mode as a literal value.
407    #[serde(default)]
408    pub ssl_mode: Option<String>,
409    /// SSL mode from a Secret key.
410    #[serde(default)]
411    pub ssl_mode_secret: Option<SecretKeySelector>,
412}
413
414/// Reference to a specific key within a Kubernetes Secret.
415#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
416pub struct SecretKeySelector {
417    /// Name of the Secret.
418    pub name: String,
419    /// Key within the Secret.
420    pub key: String,
421}
422
423/// Reference to a Kubernetes Secret in the same namespace.
424#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
425pub struct SecretReference {
426    /// Name of the Secret.
427    pub name: String,
428}
429
430/// A reusable privilege profile (CRD-compatible version).
431///
432/// This mirrors `pgroles_core::manifest::Profile` but derives `JsonSchema`.
433#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
434pub struct ProfileSpec {
435    #[serde(default)]
436    pub login: Option<bool>,
437
438    #[serde(default)]
439    pub inherit: Option<bool>,
440
441    #[serde(default)]
442    pub grants: Vec<ProfileGrantSpec>,
443
444    #[serde(default)]
445    pub default_privileges: Vec<DefaultPrivilegeGrantSpec>,
446}
447
448/// Grant template within a profile.
449#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
450pub struct ProfileGrantSpec {
451    pub privileges: Vec<Privilege>,
452    #[serde(alias = "on")]
453    pub object: ProfileObjectTargetSpec,
454}
455
456/// Object target within a profile.
457#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
458pub struct ProfileObjectTargetSpec {
459    #[serde(rename = "type")]
460    pub object_type: ObjectType,
461    #[serde(default)]
462    pub name: Option<String>,
463}
464
465/// Default privilege grant within a profile.
466#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
467pub struct DefaultPrivilegeGrantSpec {
468    #[serde(default)]
469    pub role: Option<String>,
470    pub privileges: Vec<Privilege>,
471    pub on_type: ObjectType,
472}
473
474/// A concrete role definition (CRD-compatible version).
475#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
476pub struct RoleSpec {
477    pub name: String,
478    #[serde(default)]
479    pub login: Option<bool>,
480    #[serde(default)]
481    pub superuser: Option<bool>,
482    #[serde(default)]
483    pub createdb: Option<bool>,
484    #[serde(default)]
485    pub createrole: Option<bool>,
486    #[serde(default)]
487    pub inherit: Option<bool>,
488    #[serde(default)]
489    pub replication: Option<bool>,
490    #[serde(default)]
491    pub bypassrls: Option<bool>,
492    #[serde(default)]
493    pub connection_limit: Option<i32>,
494    #[serde(default)]
495    pub comment: Option<String>,
496    /// Password source for this role. Either a reference to an existing Secret
497    /// or a request for the operator to generate one.
498    #[serde(default)]
499    pub password: Option<PasswordSpec>,
500    /// Password expiration timestamp (ISO 8601, e.g. "2025-12-31T00:00:00Z").
501    #[serde(default)]
502    pub password_valid_until: Option<String>,
503}
504
505/// Password configuration: either reference an existing Secret or have the
506/// operator generate a password and create a Secret.
507///
508/// Exactly one of `secretRef` or `generate` must be set.
509///
510/// ```yaml
511/// # Read from existing Secret:
512/// password:
513///   secretRef: { name: role-passwords }
514///   secretKey: password-user
515///
516/// # Operator generates and manages a Secret:
517/// password:
518///   generate:
519///     length: 48
520/// ```
521#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
522#[serde(rename_all = "camelCase")]
523pub struct PasswordSpec {
524    /// Reference to an existing Kubernetes Secret containing the password.
525    /// Mutually exclusive with `generate`.
526    #[serde(default)]
527    pub secret_ref: Option<SecretReference>,
528    /// Key within the referenced Secret. Defaults to the role name.
529    /// Only used with `secretRef`.
530    #[serde(default)]
531    pub secret_key: Option<String>,
532    /// Generate a random password and store it in a new Kubernetes Secret.
533    /// Mutually exclusive with `secretRef`.
534    #[serde(default)]
535    pub generate: Option<GeneratePasswordSpec>,
536}
537
538impl PasswordSpec {
539    /// Returns true if this is a reference to an existing Secret.
540    pub fn is_secret_ref(&self) -> bool {
541        self.secret_ref.is_some()
542    }
543
544    /// Returns true if this is a request to generate a password.
545    pub fn is_generate(&self) -> bool {
546        self.generate.is_some()
547    }
548}
549
550/// Configuration for operator-generated passwords.
551#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
552#[serde(rename_all = "camelCase")]
553pub struct GeneratePasswordSpec {
554    /// Password length. Defaults to 32. Minimum 16, maximum 128.
555    #[serde(default)]
556    pub length: Option<u32>,
557    /// Override the generated Secret name. Defaults to `{policy}-pgr-{role}`.
558    #[serde(default)]
559    pub secret_name: Option<String>,
560    /// Key within the generated Secret. Defaults to `password`.
561    #[serde(default)]
562    pub secret_key: Option<String>,
563}
564
565#[derive(Debug, Clone, thiserror::Error)]
566pub enum PasswordValidationError {
567    #[error("role \"{role}\" has a password but login is not enabled")]
568    PasswordWithoutLogin { role: String },
569
570    #[error("role \"{role}\" password must set exactly one of secretRef or generate")]
571    InvalidPasswordMode { role: String },
572
573    #[error("role \"{role}\" password.generate.length must be between {min} and {max}")]
574    InvalidGeneratedLength { role: String, min: u32, max: u32 },
575
576    #[error(
577        "role \"{role}\" password.generate.secretName \"{name}\" is not a valid Kubernetes Secret name"
578    )]
579    InvalidGeneratedSecretName { role: String, name: String },
580
581    #[error("role \"{role}\" password {field} \"{key}\" is not a valid Kubernetes Secret data key")]
582    InvalidSecretKey {
583        role: String,
584        field: &'static str,
585        key: String,
586    },
587
588    #[error(
589        "role \"{role}\" password.generate.secretKey \"{key}\" is reserved for the SCRAM verifier"
590    )]
591    ReservedGeneratedSecretKey { role: String, key: String },
592}
593
594/// Errors from connection spec validation.
595#[derive(Debug, Clone, thiserror::Error)]
596pub enum ConnectionValidationError {
597    #[error("connection: exactly one of secretRef or params must be set, but both were provided")]
598    BothModesSet,
599
600    #[error("connection: exactly one of secretRef or params must be set, but neither was provided")]
601    NeitherModeSet,
602
603    #[error("connection.params.{field}: secret {detail}")]
604    EmptySecretKeyRef { field: String, detail: String },
605
606    #[error(
607        "connection.params.sslMode: \"{value}\" is not valid (expected one of: disable, allow, prefer, require, verify-ca, verify-full)"
608    )]
609    InvalidSslMode { value: String },
610
611    #[error("connection.params.{field}: literal value must not be empty or whitespace-only")]
612    EmptyLiteral { field: String },
613
614    #[error("connection.params: exactly one of {field} or {field}Secret must be set")]
615    NeitherFieldSet { field: String },
616
617    #[error(
618        "connection.params: only one of {field} or {field}Secret may be set, but both were provided"
619    )]
620    BothFieldsSet { field: String },
621}
622
623/// Validate a Kubernetes Secret name per RFC 1123 DNS subdomain rules:
624/// lowercase alpha start, alphanumeric end, body allows lowercase alpha,
625/// digits, `-`, and `.`.
626fn is_valid_secret_name(name: &str) -> bool {
627    if name.is_empty() || name.len() > crate::password::MAX_SECRET_NAME_LENGTH {
628        return false;
629    }
630    let bytes = name.as_bytes();
631    // RFC 1123: must start with a lowercase letter.
632    if !bytes[0].is_ascii_lowercase() {
633        return false;
634    }
635    if !bytes[bytes.len() - 1].is_ascii_lowercase() && !bytes[bytes.len() - 1].is_ascii_digit() {
636        return false;
637    }
638    bytes
639        .iter()
640        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-' || *b == b'.')
641}
642
643fn is_valid_secret_key(key: &str) -> bool {
644    !key.is_empty()
645        && key
646            .bytes()
647            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
648}
649
650// ---------------------------------------------------------------------------
651// CRD status
652// ---------------------------------------------------------------------------
653
654/// Status of a `PostgresPolicy` resource.
655#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
656pub struct PostgresPolicyStatus {
657    /// Standard Kubernetes conditions.
658    #[serde(default)]
659    pub conditions: Vec<PolicyCondition>,
660
661    /// The `.metadata.generation` that was last successfully reconciled.
662    #[serde(default)]
663    pub observed_generation: Option<i64>,
664
665    /// The `.metadata.generation` that was last attempted.
666    #[serde(default)]
667    pub last_attempted_generation: Option<i64>,
668
669    /// ISO 8601 timestamp of the last successful reconciliation.
670    #[serde(default)]
671    pub last_successful_reconcile_time: Option<String>,
672
673    /// Deprecated alias retained for compatibility with older status readers.
674    #[serde(default)]
675    pub last_reconcile_time: Option<String>,
676
677    /// Summary of changes applied in the last reconciliation.
678    #[serde(default)]
679    pub change_summary: Option<ChangeSummary>,
680
681    /// The reconciliation mode used for the last successful reconcile.
682    #[serde(default)]
683    pub last_reconcile_mode: Option<PolicyMode>,
684
685    /// Planned SQL for the last successful plan-mode reconcile.
686    #[serde(default)]
687    pub planned_sql: Option<String>,
688
689    /// Whether `planned_sql` was truncated to fit safely in status.
690    #[serde(default)]
691    pub planned_sql_truncated: bool,
692
693    /// Canonical identity of the managed database target.
694    #[serde(default)]
695    pub managed_database_identity: Option<String>,
696
697    /// Roles claimed by this policy's declared ownership scope.
698    #[serde(default)]
699    pub owned_roles: Vec<String>,
700
701    /// Schemas claimed by this policy's declared ownership scope.
702    #[serde(default)]
703    pub owned_schemas: Vec<String>,
704
705    /// Last reconcile error message, if any.
706    #[serde(default)]
707    pub last_error: Option<String>,
708
709    /// Last applied password source version for each password-managed role.
710    #[serde(default)]
711    pub applied_password_source_versions: BTreeMap<String, String>,
712
713    /// Consecutive transient operational failures used for exponential backoff.
714    #[serde(default)]
715    pub transient_failure_count: i32,
716
717    /// Reference to the current/latest plan for this policy.
718    #[serde(default)]
719    pub current_plan_ref: Option<PlanReference>,
720}
721
722/// A condition on the `PostgresPolicy` resource.
723#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
724pub struct PolicyCondition {
725    /// Type of condition: "Ready", "Reconciling", "Degraded".
726    #[serde(rename = "type")]
727    pub condition_type: String,
728
729    /// Status: "True", "False", or "Unknown".
730    pub status: String,
731
732    /// Human-readable reason for the condition.
733    #[serde(default)]
734    pub reason: Option<String>,
735
736    /// Human-readable message.
737    #[serde(default)]
738    pub message: Option<String>,
739
740    /// Last time the condition transitioned.
741    #[serde(default)]
742    pub last_transition_time: Option<String>,
743}
744
745/// Reference to a `PostgresPolicyPlan` resource.
746#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
747pub struct PlanReference {
748    pub name: String,
749}
750
751/// Summary of changes applied during reconciliation.
752#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
753#[serde(default)]
754pub struct ChangeSummary {
755    #[serde(default)]
756    pub roles_created: i32,
757    #[serde(default)]
758    pub roles_altered: i32,
759    #[serde(default)]
760    pub schemas_created: i32,
761    #[serde(default)]
762    pub schema_owners_altered: i32,
763    #[serde(default)]
764    pub roles_dropped: i32,
765    #[serde(default)]
766    pub sessions_terminated: i32,
767    #[serde(default)]
768    pub grants_added: i32,
769    #[serde(default)]
770    pub grants_revoked: i32,
771    #[serde(default)]
772    pub default_privileges_set: i32,
773    #[serde(default)]
774    pub default_privileges_revoked: i32,
775    #[serde(default)]
776    pub members_added: i32,
777    #[serde(default)]
778    pub members_removed: i32,
779    #[serde(default)]
780    pub passwords_set: i32,
781    #[serde(default)]
782    pub total: i32,
783}
784
785// ---------------------------------------------------------------------------
786// PostgresPolicyPlan CRD
787// ---------------------------------------------------------------------------
788
789/// Spec for a `PostgresPolicyPlan` custom resource.
790///
791/// Represents a computed reconciliation plan for a `PostgresPolicy`. Plans are
792/// created by the operator and may require explicit approval before execution.
793#[derive(CustomResource, Debug, Clone, Serialize, Deserialize, JsonSchema)]
794#[kube(
795    group = "pgroles.io",
796    version = "v1alpha1",
797    kind = "PostgresPolicyPlan",
798    namespaced,
799    status = "PostgresPolicyPlanStatus",
800    shortname = "pgplan",
801    category = "pgroles",
802    printcolumn = r#"{"name":"Policy","type":"string","jsonPath":".spec.policyRef.name"}"#,
803    printcolumn = r#"{"name":"Mode","type":"string","jsonPath":".spec.reconciliationMode"}"#,
804    printcolumn = r#"{"name":"Approved","type":"string","jsonPath":".status.conditions[?(@.type==\"Approved\")].status"}"#,
805    printcolumn = r#"{"name":"Changes","type":"integer","jsonPath":".status.changeSummary.total"}"#,
806    printcolumn = r#"{"name":"SQL Stmts","type":"integer","jsonPath":".status.sqlStatements","priority":1}"#,
807    printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
808    printcolumn = r#"{"name":"SQL","type":"string","jsonPath":".status.sqlRef.name","priority":1}"#,
809    printcolumn = r#"{"name":"Hash","type":"string","jsonPath":".status.sqlHash","priority":1}"#,
810    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
811)]
812#[serde(rename_all = "camelCase")]
813pub struct PostgresPolicyPlanSpec {
814    /// Reference to the policy that generated this plan.
815    pub policy_ref: PolicyPlanRef,
816    /// The policy's `.metadata.generation` at plan time.
817    pub policy_generation: i64,
818    /// Reconciliation mode used for this plan.
819    pub reconciliation_mode: CrdReconciliationMode,
820    /// Roles that this plan covers.
821    #[serde(default)]
822    pub owned_roles: Vec<String>,
823    /// Schemas that this plan covers.
824    #[serde(default)]
825    pub owned_schemas: Vec<String>,
826    /// Database identity string for disambiguation in multi-db setups.
827    pub managed_database_identity: String,
828}
829
830/// Reference to the parent `PostgresPolicy` that generated a plan.
831#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
832pub struct PolicyPlanRef {
833    pub name: String,
834}
835
836/// Status of a `PostgresPolicyPlan` resource.
837#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
838#[serde(rename_all = "camelCase")]
839pub struct PostgresPolicyPlanStatus {
840    /// Phase: Pending, Approved, Applying, Applied, Failed, Superseded.
841    #[serde(default)]
842    pub phase: PlanPhase,
843    /// Standard conditions: Computed, Approved, Applied.
844    #[serde(default)]
845    pub conditions: Vec<PolicyCondition>,
846    /// Summary of changes in this plan.
847    #[serde(default)]
848    pub change_summary: Option<ChangeSummary>,
849    /// Reference to ConfigMap containing the full SQL (for large plans).
850    #[serde(default)]
851    pub sql_ref: Option<SqlRef>,
852    /// Inline SQL for small plans (below a size threshold).
853    #[serde(default)]
854    pub sql_inline: Option<String>,
855    /// True when the SQL preview was truncated because the full redacted SQL
856    /// could not be persisted within Kubernetes object limits.
857    #[serde(default)]
858    pub sql_truncated: bool,
859    /// Timestamp when the plan was computed.
860    #[serde(default)]
861    pub computed_at: Option<String>,
862    /// Timestamp when the plan was applied (if applicable).
863    #[serde(default)]
864    pub applied_at: Option<String>,
865    /// Error message if apply failed.
866    #[serde(default)]
867    pub last_error: Option<String>,
868    /// SHA-256 hash of the planned SQL, used to detect duplicate plans.
869    /// If a newly computed plan has the same hash as the current pending plan,
870    /// the operator can skip creating a redundant plan.
871    #[serde(default)]
872    pub sql_hash: Option<String>,
873    /// Timestamp when the plan entered Applying phase (for stuck detection).
874    #[serde(default)]
875    pub applying_since: Option<String>,
876    /// Timestamp when the plan entered Failed phase (for dedup window).
877    #[serde(default)]
878    pub failed_at: Option<String>,
879    /// Number of SQL statements in the plan (after wildcard expansion).
880    /// May be significantly larger than `changeSummary.total` when wildcard
881    /// grants expand to many per-object statements.
882    #[serde(default)]
883    pub sql_statements: Option<i64>,
884    /// SHA-256 hash of the redacted SQL preview bytes. This is for storage
885    /// integrity only; approval and deduplication continue to use `sql_hash`.
886    #[serde(default)]
887    pub redacted_sql_hash: Option<String>,
888    /// Uncompressed byte length of the redacted SQL preview.
889    #[serde(default)]
890    pub sql_original_bytes: Option<i64>,
891    /// Stored byte length of the SQL preview after inline/truncation/compression.
892    #[serde(default)]
893    pub sql_stored_bytes: Option<i64>,
894}
895
896/// Reference to a ConfigMap containing SQL for a plan.
897#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
898pub struct SqlRef {
899    pub name: String,
900    pub key: String,
901    /// Compression used for the referenced SQL content. Missing means older
902    /// uncompressed ConfigMap data.
903    #[serde(default, skip_serializing_if = "Option::is_none")]
904    pub compression: Option<SqlCompression>,
905}
906
907/// Compression format used for persisted plan SQL previews.
908#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
909#[serde(rename_all = "lowercase")]
910pub enum SqlCompression {
911    Gzip,
912}
913
914/// Phase of a `PostgresPolicyPlan`.
915#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
916pub enum PlanPhase {
917    #[default]
918    Pending,
919    Approved,
920    Applying,
921    Applied,
922    Failed,
923    Superseded,
924    Rejected,
925}
926
927impl std::fmt::Display for PlanPhase {
928    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
929        match self {
930            PlanPhase::Pending => write!(f, "Pending"),
931            PlanPhase::Approved => write!(f, "Approved"),
932            PlanPhase::Applying => write!(f, "Applying"),
933            PlanPhase::Applied => write!(f, "Applied"),
934            PlanPhase::Failed => write!(f, "Failed"),
935            PlanPhase::Superseded => write!(f, "Superseded"),
936            PlanPhase::Rejected => write!(f, "Rejected"),
937        }
938    }
939}
940
941// ---------------------------------------------------------------------------
942// Conflict detection
943// ---------------------------------------------------------------------------
944
945/// Canonical target identity for conflict detection between policies.
946#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
947pub struct DatabaseIdentity(String);
948
949impl DatabaseIdentity {
950    /// Create a database identity from the namespace and connection spec's identity key.
951    pub fn from_connection(namespace: &str, connection: &ConnectionSpec) -> Self {
952        Self(format!("{namespace}/{}", connection.identity_key()))
953    }
954
955    pub fn as_str(&self) -> &str {
956        &self.0
957    }
958}
959
960/// Conservative ownership claims for a policy.
961#[derive(Debug, Clone, Default, PartialEq, Eq)]
962pub struct OwnershipClaims {
963    pub roles: BTreeSet<String>,
964    pub schemas: BTreeSet<String>,
965}
966
967impl OwnershipClaims {
968    pub fn overlaps(&self, other: &Self) -> bool {
969        !self.roles.is_disjoint(&other.roles) || !self.schemas.is_disjoint(&other.schemas)
970    }
971
972    pub fn overlap_summary(&self, other: &Self) -> String {
973        let overlapping_roles: Vec<_> = self.roles.intersection(&other.roles).cloned().collect();
974        let overlapping_schemas: Vec<_> =
975            self.schemas.intersection(&other.schemas).cloned().collect();
976
977        let mut parts = Vec::new();
978        if !overlapping_roles.is_empty() {
979            parts.push(format!("roles: {}", overlapping_roles.join(", ")));
980        }
981        if !overlapping_schemas.is_empty() {
982            parts.push(format!("schemas: {}", overlapping_schemas.join(", ")));
983        }
984
985        parts.join("; ")
986    }
987}
988
989// ---------------------------------------------------------------------------
990// Secret name helpers
991// ---------------------------------------------------------------------------
992
993impl PostgresPolicySpec {
994    pub fn validate_password_specs(
995        &self,
996        policy_name: &str,
997    ) -> Result<(), PasswordValidationError> {
998        for role in &self.roles {
999            let Some(password) = &role.password else {
1000                continue;
1001            };
1002
1003            if role.login != Some(true) {
1004                return Err(PasswordValidationError::PasswordWithoutLogin {
1005                    role: role.name.clone(),
1006                });
1007            }
1008
1009            match (&password.secret_ref, &password.generate) {
1010                (Some(_), None) => {
1011                    let secret_key = password.secret_key.as_deref().unwrap_or(&role.name);
1012                    if !is_valid_secret_key(secret_key) {
1013                        return Err(PasswordValidationError::InvalidSecretKey {
1014                            role: role.name.clone(),
1015                            field: "secretKey",
1016                            key: secret_key.to_string(),
1017                        });
1018                    }
1019                }
1020                (None, Some(generate)) => {
1021                    if let Some(length) = generate.length
1022                        && !(crate::password::MIN_PASSWORD_LENGTH
1023                            ..=crate::password::MAX_PASSWORD_LENGTH)
1024                            .contains(&length)
1025                    {
1026                        return Err(PasswordValidationError::InvalidGeneratedLength {
1027                            role: role.name.clone(),
1028                            min: crate::password::MIN_PASSWORD_LENGTH,
1029                            max: crate::password::MAX_PASSWORD_LENGTH,
1030                        });
1031                    }
1032
1033                    let secret_name =
1034                        crate::password::generated_secret_name(policy_name, &role.name, generate);
1035                    if !is_valid_secret_name(&secret_name) {
1036                        return Err(PasswordValidationError::InvalidGeneratedSecretName {
1037                            role: role.name.clone(),
1038                            name: secret_name,
1039                        });
1040                    }
1041
1042                    let secret_key = crate::password::generated_secret_key(generate);
1043                    if !is_valid_secret_key(&secret_key) {
1044                        return Err(PasswordValidationError::InvalidSecretKey {
1045                            role: role.name.clone(),
1046                            field: "generate.secretKey",
1047                            key: secret_key,
1048                        });
1049                    }
1050                    if secret_key == crate::password::GENERATED_VERIFIER_KEY {
1051                        return Err(PasswordValidationError::ReservedGeneratedSecretKey {
1052                            role: role.name.clone(),
1053                            key: secret_key,
1054                        });
1055                    }
1056                }
1057                _ => {
1058                    return Err(PasswordValidationError::InvalidPasswordMode {
1059                        role: role.name.clone(),
1060                    });
1061                }
1062            }
1063        }
1064
1065        Ok(())
1066    }
1067
1068    /// Validate the connection spec.
1069    ///
1070    /// Ensures exactly one of `secretRef` or `params` is set, and that params
1071    /// mode has all required fields with valid values.
1072    pub fn validate_connection_spec(&self) -> Result<(), ConnectionValidationError> {
1073        let conn = &self.connection;
1074        match (&conn.secret_ref, &conn.params) {
1075            (Some(_), None) => {
1076                // URL mode — valid.
1077                Ok(())
1078            }
1079            (None, Some(params)) => {
1080                // Validate a required field pair: exactly one must be set.
1081                fn validate_required_field(
1082                    field: &str,
1083                    literal: &Option<String>,
1084                    secret: &Option<SecretKeySelector>,
1085                ) -> Result<(), ConnectionValidationError> {
1086                    match (literal, secret) {
1087                        (Some(_), Some(_)) => {
1088                            return Err(ConnectionValidationError::BothFieldsSet {
1089                                field: field.to_string(),
1090                            });
1091                        }
1092                        (None, None) => {
1093                            return Err(ConnectionValidationError::NeitherFieldSet {
1094                                field: field.to_string(),
1095                            });
1096                        }
1097                        (Some(s), None) => {
1098                            if s.trim().is_empty() {
1099                                return Err(ConnectionValidationError::EmptyLiteral {
1100                                    field: field.to_string(),
1101                                });
1102                            }
1103                        }
1104                        (None, Some(sel)) => {
1105                            validate_secret_selector(field, sel)?;
1106                        }
1107                    }
1108                    Ok(())
1109                }
1110
1111                // Validate an optional field pair: at most one may be set.
1112                fn validate_optional_field(
1113                    field: &str,
1114                    literal: &Option<impl AsRef<str>>,
1115                    secret: &Option<SecretKeySelector>,
1116                ) -> Result<(), ConnectionValidationError> {
1117                    let has_literal = literal.is_some();
1118                    if has_literal && secret.is_some() {
1119                        return Err(ConnectionValidationError::BothFieldsSet {
1120                            field: field.to_string(),
1121                        });
1122                    }
1123                    if let Some(s) = literal
1124                        && s.as_ref().trim().is_empty()
1125                    {
1126                        return Err(ConnectionValidationError::EmptyLiteral {
1127                            field: field.to_string(),
1128                        });
1129                    }
1130                    if let Some(sel) = secret {
1131                        validate_secret_selector(field, sel)?;
1132                    }
1133                    Ok(())
1134                }
1135
1136                fn validate_secret_selector(
1137                    field: &str,
1138                    sel: &SecretKeySelector,
1139                ) -> Result<(), ConnectionValidationError> {
1140                    if sel.name.trim().is_empty() {
1141                        return Err(ConnectionValidationError::EmptySecretKeyRef {
1142                            field: field.to_string(),
1143                            detail: "name must not be empty".to_string(),
1144                        });
1145                    }
1146                    if sel.key.trim().is_empty() {
1147                        return Err(ConnectionValidationError::EmptySecretKeyRef {
1148                            field: field.to_string(),
1149                            detail: "key must not be empty".to_string(),
1150                        });
1151                    }
1152                    Ok(())
1153                }
1154
1155                // Required fields: host, dbname, username, password.
1156                validate_required_field("host", &params.host, &params.host_secret)?;
1157                validate_required_field("dbname", &params.dbname, &params.dbname_secret)?;
1158                validate_required_field("username", &params.username, &params.username_secret)?;
1159                validate_required_field("password", &params.password, &params.password_secret)?;
1160
1161                // Optional fields: port, sslMode.
1162                // Port is u16 so we wrap it for the generic check.
1163                let port_str = params.port.map(|p| p.to_string());
1164                validate_optional_field("port", &port_str, &params.port_secret)?;
1165
1166                validate_optional_field("sslMode", &params.ssl_mode, &params.ssl_mode_secret)?;
1167
1168                // Validate sslMode value if it's a literal.
1169                if let Some(value) = &params.ssl_mode
1170                    && !VALID_SSL_MODES.contains(&value.as_str())
1171                {
1172                    return Err(ConnectionValidationError::InvalidSslMode {
1173                        value: value.clone(),
1174                    });
1175                }
1176
1177                Ok(())
1178            }
1179            (Some(_), Some(_)) => Err(ConnectionValidationError::BothModesSet),
1180            (None, None) => Err(ConnectionValidationError::NeitherModeSet),
1181        }
1182    }
1183
1184    /// All Kubernetes Secret names referenced by this spec.
1185    ///
1186    /// Includes the connection Secret, password `secretRef` Secrets, and
1187    /// generated password Secrets. Used by the controller to trigger
1188    /// reconciliation when any of these Secrets change (or are deleted).
1189    pub fn referenced_secret_names(&self, policy_name: &str) -> BTreeSet<String> {
1190        let mut names = BTreeSet::new();
1191        // Connection secrets — either URL mode or structured params.
1192        self.connection.collect_secret_names(&mut names);
1193        for role in &self.roles {
1194            if let Some(pw) = &role.password {
1195                if let Some(secret_ref) = &pw.secret_ref {
1196                    names.insert(secret_ref.name.clone());
1197                }
1198                if let Some(gen_spec) = &pw.generate {
1199                    let secret_name =
1200                        crate::password::generated_secret_name(policy_name, &role.name, gen_spec);
1201                    names.insert(secret_name);
1202                }
1203            }
1204        }
1205        names
1206    }
1207}
1208
1209// ---------------------------------------------------------------------------
1210// Conversion: CRD spec → core manifest types
1211// ---------------------------------------------------------------------------
1212
1213impl PostgresPolicySpec {
1214    /// Convert the CRD spec into a `PolicyManifest` for use with the core library.
1215    pub fn to_policy_manifest(&self) -> pgroles_core::manifest::PolicyManifest {
1216        use pgroles_core::manifest::{
1217            DefaultPrivilegeGrant, MemberSpec, PolicyManifest, Profile, ProfileGrant,
1218            ProfileObjectTarget, RoleDefinition,
1219        };
1220
1221        let profiles = self
1222            .profiles
1223            .iter()
1224            .map(|(name, spec)| {
1225                let profile = Profile {
1226                    login: spec.login,
1227                    inherit: spec.inherit,
1228                    grants: spec
1229                        .grants
1230                        .iter()
1231                        .map(|g| ProfileGrant {
1232                            privileges: g.privileges.clone(),
1233                            object: ProfileObjectTarget {
1234                                object_type: g.object.object_type,
1235                                name: g.object.name.clone(),
1236                            },
1237                        })
1238                        .collect(),
1239                    default_privileges: spec
1240                        .default_privileges
1241                        .iter()
1242                        .map(|dp| DefaultPrivilegeGrant {
1243                            role: dp.role.clone(),
1244                            privileges: dp.privileges.clone(),
1245                            on_type: dp.on_type,
1246                        })
1247                        .collect(),
1248                };
1249                (name.clone(), profile)
1250            })
1251            .collect();
1252
1253        let roles = self
1254            .roles
1255            .iter()
1256            .map(|r| RoleDefinition {
1257                name: r.name.clone(),
1258                login: r.login,
1259                superuser: r.superuser,
1260                createdb: r.createdb,
1261                createrole: r.createrole,
1262                inherit: r.inherit,
1263                replication: r.replication,
1264                bypassrls: r.bypassrls,
1265                connection_limit: r.connection_limit,
1266                comment: r.comment.clone(),
1267                password: None, // K8s passwords are resolved separately via Secret refs
1268                password_valid_until: r.password_valid_until.clone(),
1269            })
1270            .collect();
1271
1272        // Memberships need MemberSpec conversion — the core type should
1273        // already be compatible since we use it directly in the CRD spec.
1274        // But we need to ensure the serde aliases work. Let's rebuild to be safe.
1275        let memberships = self
1276            .memberships
1277            .iter()
1278            .map(|m| pgroles_core::manifest::Membership {
1279                role: m.role.clone(),
1280                members: m
1281                    .members
1282                    .iter()
1283                    .map(|ms| MemberSpec {
1284                        name: ms.name.clone(),
1285                        inherit: ms.inherit,
1286                        admin: ms.admin,
1287                    })
1288                    .collect(),
1289            })
1290            .collect();
1291
1292        PolicyManifest {
1293            default_owner: self.default_owner.clone(),
1294            auth_providers: Vec::new(),
1295            profiles,
1296            schemas: self.schemas.clone(),
1297            roles,
1298            grants: self.grants.clone(),
1299            default_privileges: self.default_privileges.clone(),
1300            memberships,
1301            retirements: self.retirements.clone(),
1302        }
1303    }
1304
1305    /// Derive a conservative ownership claim set from the policy spec.
1306    ///
1307    /// This intentionally claims all declared/expanded roles and all referenced
1308    /// schemas so overlapping policies are rejected safely.
1309    pub fn ownership_claims(
1310        &self,
1311    ) -> Result<OwnershipClaims, pgroles_core::manifest::ManifestError> {
1312        let manifest = self.to_policy_manifest();
1313        let expanded = pgroles_core::manifest::expand_manifest(&manifest)?;
1314
1315        let mut roles: BTreeSet<String> = expanded.roles.into_iter().map(|r| r.name).collect();
1316        let mut schemas: BTreeSet<String> = self.schemas.iter().map(|s| s.name.clone()).collect();
1317
1318        roles.extend(manifest.retirements.into_iter().map(|r| r.role));
1319        roles.extend(manifest.grants.iter().map(|g| g.role.clone()));
1320        roles.extend(
1321            manifest
1322                .default_privileges
1323                .iter()
1324                .flat_map(|dp| dp.grant.iter().filter_map(|grant| grant.role.clone())),
1325        );
1326        roles.extend(manifest.memberships.iter().map(|m| m.role.clone()));
1327        roles.extend(
1328            manifest
1329                .memberships
1330                .iter()
1331                .flat_map(|m| m.members.iter().map(|member| member.name.clone())),
1332        );
1333
1334        schemas.extend(
1335            manifest
1336                .grants
1337                .iter()
1338                .filter_map(|g| match g.object.object_type {
1339                    ObjectType::Database => None,
1340                    ObjectType::Schema => g.object.name.clone(),
1341                    _ => g.object.schema.clone(),
1342                }),
1343        );
1344        schemas.extend(
1345            manifest
1346                .default_privileges
1347                .iter()
1348                .map(|dp| dp.schema.clone()),
1349        );
1350
1351        Ok(OwnershipClaims { roles, schemas })
1352    }
1353}
1354
1355// ---------------------------------------------------------------------------
1356// Status helpers
1357// ---------------------------------------------------------------------------
1358
1359impl PostgresPolicyStatus {
1360    /// Set a condition, replacing any existing condition of the same type.
1361    ///
1362    /// If the condition's `status` value has not changed, the existing
1363    /// `last_transition_time` is preserved (per Kubernetes condition conventions).
1364    pub fn set_condition(&mut self, new: PolicyCondition) {
1365        if let Some(existing) = self
1366            .conditions
1367            .iter()
1368            .find(|c| c.condition_type == new.condition_type)
1369            && existing.status == new.status
1370        {
1371            // Status unchanged — preserve the existing transition time.
1372            let mut updated = new;
1373            updated.last_transition_time = existing.last_transition_time.clone();
1374            self.conditions
1375                .retain(|c| c.condition_type != updated.condition_type);
1376            self.conditions.push(updated);
1377            return;
1378        }
1379        // New condition or status changed — use the new timestamp.
1380        self.conditions
1381            .retain(|c| c.condition_type != new.condition_type);
1382        self.conditions.push(new);
1383    }
1384}
1385
1386/// Create a timestamp string in ISO 8601 / RFC 3339 format.
1387pub fn now_rfc3339() -> String {
1388    // Use k8s-openapi's chrono re-export or manual formatting.
1389    // For simplicity, use the system time.
1390    use std::time::SystemTime;
1391    let now = SystemTime::now()
1392        .duration_since(SystemTime::UNIX_EPOCH)
1393        .unwrap_or_default();
1394    // Format as simplified ISO 8601
1395    let secs = now.as_secs();
1396    let days = secs / 86400;
1397    let remaining = secs % 86400;
1398    let hours = remaining / 3600;
1399    let minutes = (remaining % 3600) / 60;
1400    let seconds = remaining % 60;
1401
1402    // Convert days since epoch to date (simplified — good enough for status)
1403    let (year, month, day) = days_to_date(days);
1404    format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
1405}
1406
1407/// Convert days since Unix epoch to (year, month, day).
1408pub fn days_to_date(days_since_epoch: u64) -> (u64, u64, u64) {
1409    // Civil calendar algorithm from Howard Hinnant
1410    let z = days_since_epoch + 719468;
1411    let era = z / 146097;
1412    let doe = z - era * 146097;
1413    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1414    let y = yoe + era * 400;
1415    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1416    let mp = (5 * doy + 2) / 153;
1417    let d = doy - (153 * mp + 2) / 5 + 1;
1418    let m = if mp < 10 { mp + 3 } else { mp - 9 };
1419    let y = if m <= 2 { y + 1 } else { y };
1420    (y, m, d)
1421}
1422
1423/// Helper to create a "Ready" condition.
1424pub fn ready_condition(status: bool, reason: &str, message: &str) -> PolicyCondition {
1425    PolicyCondition {
1426        condition_type: "Ready".to_string(),
1427        status: if status { "True" } else { "False" }.to_string(),
1428        reason: Some(reason.to_string()),
1429        message: Some(message.to_string()),
1430        last_transition_time: Some(now_rfc3339()),
1431    }
1432}
1433
1434/// Helper to create a "Reconciling" condition.
1435pub fn reconciling_condition(message: &str) -> PolicyCondition {
1436    PolicyCondition {
1437        condition_type: "Reconciling".to_string(),
1438        status: "True".to_string(),
1439        reason: Some("Reconciling".to_string()),
1440        message: Some(message.to_string()),
1441        last_transition_time: Some(now_rfc3339()),
1442    }
1443}
1444
1445/// Helper to create a "Degraded" condition.
1446pub fn degraded_condition(reason: &str, message: &str) -> PolicyCondition {
1447    PolicyCondition {
1448        condition_type: "Degraded".to_string(),
1449        status: "True".to_string(),
1450        reason: Some(reason.to_string()),
1451        message: Some(message.to_string()),
1452        last_transition_time: Some(now_rfc3339()),
1453    }
1454}
1455
1456/// Helper to create a "Paused" condition.
1457pub fn paused_condition(message: &str) -> PolicyCondition {
1458    PolicyCondition {
1459        condition_type: "Paused".to_string(),
1460        status: "True".to_string(),
1461        reason: Some("Suspended".to_string()),
1462        message: Some(message.to_string()),
1463        last_transition_time: Some(now_rfc3339()),
1464    }
1465}
1466
1467/// Helper to create a "Conflict" condition.
1468pub fn conflict_condition(reason: &str, message: &str) -> PolicyCondition {
1469    PolicyCondition {
1470        condition_type: "Conflict".to_string(),
1471        status: "True".to_string(),
1472        reason: Some(reason.to_string()),
1473        message: Some(message.to_string()),
1474        last_transition_time: Some(now_rfc3339()),
1475    }
1476}
1477
1478/// Helper to create a "Drifted" condition.
1479pub fn drifted_condition(status: bool, reason: &str, message: &str) -> PolicyCondition {
1480    PolicyCondition {
1481        condition_type: "Drifted".to_string(),
1482        status: if status { "True" } else { "False" }.to_string(),
1483        reason: Some(reason.to_string()),
1484        message: Some(message.to_string()),
1485        last_transition_time: Some(now_rfc3339()),
1486    }
1487}
1488
1489// ---------------------------------------------------------------------------
1490// Tests
1491// ---------------------------------------------------------------------------
1492
1493#[cfg(test)]
1494mod tests {
1495    use super::*;
1496    use kube::CustomResourceExt;
1497
1498    #[test]
1499    fn crd_generates_valid_schema() {
1500        let crd = PostgresPolicy::crd();
1501        let yaml = serde_yaml::to_string(&crd).expect("CRD should serialize to YAML");
1502        assert!(yaml.contains("pgroles.io"), "group should be pgroles.io");
1503        assert!(yaml.contains("v1alpha1"), "version should be v1alpha1");
1504        assert!(
1505            yaml.contains("PostgresPolicy"),
1506            "kind should be PostgresPolicy"
1507        );
1508        assert!(
1509            yaml.contains("\"mode\"") || yaml.contains(" mode:"),
1510            "schema should declare spec.mode"
1511        );
1512        assert!(
1513            yaml.contains("\"object\"") || yaml.contains(" object:"),
1514            "schema should declare grant object targets using object"
1515        );
1516    }
1517
1518    #[test]
1519    fn spec_to_policy_manifest_roundtrip() {
1520        let spec = PostgresPolicySpec {
1521            connection: ConnectionSpec {
1522                secret_ref: Some(SecretReference {
1523                    name: "pg-secret".to_string(),
1524                }),
1525                secret_key: Some("DATABASE_URL".to_string()),
1526                params: None,
1527            },
1528            interval: "5m".to_string(),
1529            suspend: false,
1530            mode: PolicyMode::Apply,
1531            reconciliation_mode: CrdReconciliationMode::default(),
1532            default_owner: Some("app_owner".to_string()),
1533            profiles: std::collections::HashMap::new(),
1534            schemas: vec![],
1535            roles: vec![RoleSpec {
1536                name: "analytics".to_string(),
1537                login: Some(true),
1538                superuser: None,
1539                createdb: None,
1540                createrole: None,
1541                inherit: None,
1542                replication: None,
1543                bypassrls: None,
1544                connection_limit: None,
1545                comment: Some("test role".to_string()),
1546                password: None,
1547                password_valid_until: None,
1548            }],
1549            grants: vec![],
1550            default_privileges: vec![],
1551            memberships: vec![],
1552            retirements: vec![RoleRetirement {
1553                role: "legacy-app".to_string(),
1554                reassign_owned_to: Some("app_owner".to_string()),
1555                drop_owned: true,
1556                terminate_sessions: true,
1557            }],
1558            approval: None,
1559        };
1560
1561        let manifest = spec.to_policy_manifest();
1562        assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
1563        assert_eq!(manifest.roles.len(), 1);
1564        assert_eq!(manifest.roles[0].name, "analytics");
1565        assert_eq!(manifest.roles[0].login, Some(true));
1566        assert_eq!(manifest.roles[0].comment, Some("test role".to_string()));
1567        assert_eq!(manifest.retirements.len(), 1);
1568        assert_eq!(manifest.retirements[0].role, "legacy-app");
1569        assert_eq!(
1570            manifest.retirements[0].reassign_owned_to.as_deref(),
1571            Some("app_owner")
1572        );
1573        assert!(manifest.retirements[0].drop_owned);
1574        assert!(manifest.retirements[0].terminate_sessions);
1575    }
1576
1577    #[test]
1578    fn spec_to_policy_manifest_preserves_profile_inherit() {
1579        let spec = PostgresPolicySpec {
1580            connection: ConnectionSpec {
1581                secret_ref: Some(SecretReference {
1582                    name: "pg-secret".to_string(),
1583                }),
1584                secret_key: Some("DATABASE_URL".to_string()),
1585                params: None,
1586            },
1587            interval: "5m".to_string(),
1588            suspend: false,
1589            mode: PolicyMode::Apply,
1590            reconciliation_mode: CrdReconciliationMode::default(),
1591            default_owner: None,
1592            profiles: std::collections::HashMap::from([(
1593                "editor".to_string(),
1594                ProfileSpec {
1595                    login: Some(false),
1596                    inherit: Some(false),
1597                    grants: vec![],
1598                    default_privileges: vec![],
1599                },
1600            )]),
1601            schemas: vec![],
1602            roles: vec![],
1603            grants: vec![],
1604            default_privileges: vec![],
1605            memberships: vec![],
1606            retirements: vec![],
1607            approval: None,
1608        };
1609
1610        let manifest = spec.to_policy_manifest();
1611        assert_eq!(manifest.profiles["editor"].login, Some(false));
1612        assert_eq!(manifest.profiles["editor"].inherit, Some(false));
1613    }
1614
1615    #[test]
1616    fn status_set_condition_replaces_existing() {
1617        let mut status = PostgresPolicyStatus::default();
1618
1619        status.set_condition(ready_condition(false, "Pending", "Initial"));
1620        assert_eq!(status.conditions.len(), 1);
1621        assert_eq!(status.conditions[0].status, "False");
1622
1623        status.set_condition(ready_condition(true, "Reconciled", "All good"));
1624        assert_eq!(status.conditions.len(), 1);
1625        assert_eq!(status.conditions[0].status, "True");
1626        assert_eq!(status.conditions[0].reason.as_deref(), Some("Reconciled"));
1627    }
1628
1629    #[test]
1630    fn status_set_condition_adds_new_type() {
1631        let mut status = PostgresPolicyStatus::default();
1632
1633        status.set_condition(ready_condition(true, "OK", "ready"));
1634        status.set_condition(degraded_condition("Error", "something broke"));
1635
1636        assert_eq!(status.conditions.len(), 2);
1637    }
1638
1639    #[test]
1640    fn paused_condition_has_expected_shape() {
1641        let paused = paused_condition("paused by spec");
1642        assert_eq!(paused.condition_type, "Paused");
1643        assert_eq!(paused.status, "True");
1644        assert_eq!(paused.reason.as_deref(), Some("Suspended"));
1645    }
1646
1647    #[test]
1648    fn ownership_claims_include_expanded_roles_and_schemas() {
1649        let mut profiles = std::collections::HashMap::new();
1650        profiles.insert(
1651            "editor".to_string(),
1652            ProfileSpec {
1653                login: Some(false),
1654                inherit: None,
1655                grants: vec![],
1656                default_privileges: vec![],
1657            },
1658        );
1659
1660        let spec = PostgresPolicySpec {
1661            connection: ConnectionSpec {
1662                secret_ref: Some(SecretReference {
1663                    name: "pg-secret".to_string(),
1664                }),
1665                secret_key: Some("DATABASE_URL".to_string()),
1666                params: None,
1667            },
1668            interval: "5m".to_string(),
1669            suspend: false,
1670            mode: PolicyMode::Apply,
1671            reconciliation_mode: CrdReconciliationMode::default(),
1672            default_owner: None,
1673            profiles,
1674            schemas: vec![SchemaBinding {
1675                name: "inventory".to_string(),
1676                profiles: vec!["editor".to_string()],
1677                role_pattern: "{schema}-{profile}".to_string(),
1678                owner: None,
1679            }],
1680            roles: vec![RoleSpec {
1681                name: "app-service".to_string(),
1682                login: Some(true),
1683                superuser: None,
1684                createdb: None,
1685                createrole: None,
1686                inherit: None,
1687                replication: None,
1688                bypassrls: None,
1689                connection_limit: None,
1690                comment: None,
1691                password: None,
1692                password_valid_until: None,
1693            }],
1694            grants: vec![],
1695            default_privileges: vec![],
1696            memberships: vec![],
1697            retirements: vec![RoleRetirement {
1698                role: "legacy-app".to_string(),
1699                reassign_owned_to: None,
1700                drop_owned: false,
1701                terminate_sessions: false,
1702            }],
1703            approval: None,
1704        };
1705
1706        let claims = spec.ownership_claims().unwrap();
1707        assert!(claims.roles.contains("inventory-editor"));
1708        assert!(claims.roles.contains("app-service"));
1709        assert!(claims.roles.contains("legacy-app"));
1710        assert!(claims.schemas.contains("inventory"));
1711    }
1712
1713    #[test]
1714    fn ownership_overlap_summary_reports_roles_and_schemas() {
1715        let mut left = OwnershipClaims::default();
1716        left.roles.insert("analytics".to_string());
1717        left.schemas.insert("reporting".to_string());
1718
1719        let mut right = OwnershipClaims::default();
1720        right.roles.insert("analytics".to_string());
1721        right.schemas.insert("reporting".to_string());
1722        right.schemas.insert("other".to_string());
1723
1724        assert!(left.overlaps(&right));
1725        let summary = left.overlap_summary(&right);
1726        assert!(summary.contains("roles: analytics"));
1727        assert!(summary.contains("schemas: reporting"));
1728    }
1729
1730    #[test]
1731    fn database_identity_uses_namespace_and_identity_key() {
1732        let conn = ConnectionSpec {
1733            secret_ref: Some(SecretReference {
1734                name: "db-creds".to_string(),
1735            }),
1736            secret_key: Some("DATABASE_URL".to_string()),
1737            params: None,
1738        };
1739        let identity = DatabaseIdentity::from_connection("prod", &conn);
1740        assert_eq!(identity.as_str(), "prod/db-creds/DATABASE_URL");
1741    }
1742
1743    #[test]
1744    fn identity_key_same_database_different_users_are_equal() {
1745        // Two policies targeting the same database but with different users
1746        // should have the SAME identity key (for locking/conflict detection).
1747        let user_a = ConnectionSpec {
1748            secret_ref: None,
1749            secret_key: None,
1750            params: Some(ConnectionParams {
1751                host: Some("my-host".into()),
1752                host_secret: None,
1753                port: None,
1754                port_secret: None,
1755                dbname: Some("mydb".into()),
1756                dbname_secret: None,
1757                username: Some("alice".into()),
1758                username_secret: None,
1759                password: Some("pass-a".into()),
1760                password_secret: None,
1761                ssl_mode: None,
1762                ssl_mode_secret: None,
1763            }),
1764        };
1765        let user_b = ConnectionSpec {
1766            secret_ref: None,
1767            secret_key: None,
1768            params: Some(ConnectionParams {
1769                host: Some("my-host".into()),
1770                host_secret: None,
1771                port: None,
1772                port_secret: None,
1773                dbname: Some("mydb".into()),
1774                dbname_secret: None,
1775                username: Some("bob".into()),
1776                username_secret: None,
1777                password: Some("pass-b".into()),
1778                password_secret: None,
1779                ssl_mode: None,
1780                ssl_mode_secret: None,
1781            }),
1782        };
1783
1784        assert_eq!(
1785            user_a.identity_key(),
1786            user_b.identity_key(),
1787            "same database with different users should have the same identity key"
1788        );
1789        // But cache keys should differ (different credentials = different pool).
1790        assert_ne!(
1791            user_a.cache_key("default"),
1792            user_b.cache_key("default"),
1793            "different credentials should produce different cache keys"
1794        );
1795    }
1796
1797    #[test]
1798    fn cache_key_no_collision_between_literal_and_secret_username() {
1799        // A literal username containing "secret=" should not collide with a
1800        // real secret reference in the cache key.
1801        let literal_conn = ConnectionSpec {
1802            secret_ref: None,
1803            secret_key: None,
1804            params: Some(ConnectionParams {
1805                host: Some("my-host".into()),
1806                host_secret: None,
1807                port: None,
1808                port_secret: None,
1809                dbname: Some("mydb".into()),
1810                dbname_secret: None,
1811                username: Some("secret=creds\0password".into()),
1812                username_secret: None,
1813                password: Some("pass".into()),
1814                password_secret: None,
1815                ssl_mode: None,
1816                ssl_mode_secret: None,
1817            }),
1818        };
1819        let secret_conn = ConnectionSpec {
1820            secret_ref: None,
1821            secret_key: None,
1822            params: Some(ConnectionParams {
1823                host: Some("my-host".into()),
1824                host_secret: None,
1825                port: None,
1826                port_secret: None,
1827                dbname: Some("mydb".into()),
1828                dbname_secret: None,
1829                username: None,
1830                username_secret: Some(SecretKeySelector {
1831                    name: "creds".into(),
1832                    key: "password".into(),
1833                }),
1834                password: Some("pass".into()),
1835                password_secret: None,
1836                ssl_mode: None,
1837                ssl_mode_secret: None,
1838            }),
1839        };
1840
1841        assert_ne!(
1842            literal_conn.cache_key("default"),
1843            secret_conn.cache_key("default"),
1844            "literal and secret ref should produce different cache keys"
1845        );
1846    }
1847
1848    #[test]
1849    fn cache_key_includes_ssl_mode() {
1850        let conn_no_ssl = ConnectionSpec {
1851            secret_ref: None,
1852            secret_key: None,
1853            params: Some(ConnectionParams {
1854                host: Some("host".into()),
1855                host_secret: None,
1856                port: None,
1857                port_secret: None,
1858                dbname: Some("db".into()),
1859                dbname_secret: None,
1860                username: Some("user".into()),
1861                username_secret: None,
1862                password: Some("pass".into()),
1863                password_secret: None,
1864                ssl_mode: None,
1865                ssl_mode_secret: None,
1866            }),
1867        };
1868        let conn_with_ssl = ConnectionSpec {
1869            secret_ref: None,
1870            secret_key: None,
1871            params: Some(ConnectionParams {
1872                host: Some("host".into()),
1873                host_secret: None,
1874                port: None,
1875                port_secret: None,
1876                dbname: Some("db".into()),
1877                dbname_secret: None,
1878                username: Some("user".into()),
1879                username_secret: None,
1880                password: Some("pass".into()),
1881                password_secret: None,
1882                ssl_mode: Some("require".into()),
1883                ssl_mode_secret: None,
1884            }),
1885        };
1886
1887        assert_ne!(
1888            conn_no_ssl.cache_key("ns"),
1889            conn_with_ssl.cache_key("ns"),
1890            "cache key should differ when sslMode is present"
1891        );
1892    }
1893
1894    #[test]
1895    fn validate_connection_rejects_empty_literal_host() {
1896        let spec = spec_with_connection(ConnectionSpec {
1897            secret_ref: None,
1898            secret_key: None,
1899            params: Some(ConnectionParams {
1900                host: Some("".into()),
1901                host_secret: None,
1902                port: None,
1903                port_secret: None,
1904                dbname: Some("mydb".into()),
1905                dbname_secret: None,
1906                username: Some("user".into()),
1907                username_secret: None,
1908                password: Some("pass".into()),
1909                password_secret: None,
1910                ssl_mode: None,
1911                ssl_mode_secret: None,
1912            }),
1913        });
1914
1915        let err = spec.validate_connection_spec().unwrap_err();
1916        assert!(
1917            matches!(err, ConnectionValidationError::EmptyLiteral { ref field } if field == "host"),
1918            "expected EmptyLiteral for host, got: {err}"
1919        );
1920    }
1921
1922    #[test]
1923    fn validate_connection_rejects_whitespace_literal_dbname() {
1924        let spec = spec_with_connection(ConnectionSpec {
1925            secret_ref: None,
1926            secret_key: None,
1927            params: Some(ConnectionParams {
1928                host: Some("host".into()),
1929                host_secret: None,
1930                port: None,
1931                port_secret: None,
1932                dbname: Some("  ".into()),
1933                dbname_secret: None,
1934                username: Some("user".into()),
1935                username_secret: None,
1936                password: Some("pass".into()),
1937                password_secret: None,
1938                ssl_mode: None,
1939                ssl_mode_secret: None,
1940            }),
1941        });
1942
1943        let err = spec.validate_connection_spec().unwrap_err();
1944        assert!(
1945            matches!(err, ConnectionValidationError::EmptyLiteral { ref field } if field == "dbname"),
1946            "expected EmptyLiteral for dbname, got: {err}"
1947        );
1948    }
1949
1950    /// Helper to build a minimal spec with the given connection and no roles/grants.
1951    fn spec_with_connection(connection: ConnectionSpec) -> PostgresPolicySpec {
1952        PostgresPolicySpec {
1953            connection,
1954            interval: "5m".into(),
1955            suspend: false,
1956            mode: PolicyMode::Apply,
1957            reconciliation_mode: CrdReconciliationMode::default(),
1958            default_owner: None,
1959            profiles: Default::default(),
1960            schemas: vec![],
1961            roles: vec![],
1962            grants: vec![],
1963            default_privileges: vec![],
1964            memberships: vec![],
1965            retirements: vec![],
1966            approval: None,
1967        }
1968    }
1969
1970    fn url_mode_connection() -> ConnectionSpec {
1971        ConnectionSpec {
1972            secret_ref: Some(SecretReference {
1973                name: "pg-creds".into(),
1974            }),
1975            secret_key: Some("DATABASE_URL".into()),
1976            params: None,
1977        }
1978    }
1979
1980    fn params_mode_connection() -> ConnectionSpec {
1981        ConnectionSpec {
1982            secret_ref: None,
1983            secret_key: None,
1984            params: Some(ConnectionParams {
1985                host: Some("my-postgres".into()),
1986                host_secret: None,
1987                port: None,
1988                port_secret: None,
1989                dbname: Some("mydb".into()),
1990                dbname_secret: None,
1991                username: None,
1992                username_secret: Some(SecretKeySelector {
1993                    name: "pg-creds".into(),
1994                    key: "username".into(),
1995                }),
1996                password: None,
1997                password_secret: Some(SecretKeySelector {
1998                    name: "pg-creds".into(),
1999                    key: "password".into(),
2000                }),
2001                ssl_mode: None,
2002                ssl_mode_secret: None,
2003            }),
2004        }
2005    }
2006
2007    // -- Connection validation tests -----------------------------------------
2008
2009    #[test]
2010    fn validate_connection_accepts_url_mode() {
2011        let spec = spec_with_connection(url_mode_connection());
2012        assert!(spec.validate_connection_spec().is_ok());
2013    }
2014
2015    #[test]
2016    fn validate_connection_accepts_params_mode() {
2017        let spec = spec_with_connection(params_mode_connection());
2018        assert!(spec.validate_connection_spec().is_ok());
2019    }
2020
2021    #[test]
2022    fn validate_connection_rejects_both_modes_set() {
2023        let spec = spec_with_connection(ConnectionSpec {
2024            secret_ref: Some(SecretReference {
2025                name: "pg-creds".into(),
2026            }),
2027            secret_key: None,
2028            params: Some(ConnectionParams {
2029                host: Some("host".into()),
2030                host_secret: None,
2031                port: None,
2032                port_secret: None,
2033                dbname: Some("db".into()),
2034                dbname_secret: None,
2035                username: Some("user".into()),
2036                username_secret: None,
2037                password: Some("pass".into()),
2038                password_secret: None,
2039                ssl_mode: None,
2040                ssl_mode_secret: None,
2041            }),
2042        });
2043        assert!(matches!(
2044            spec.validate_connection_spec(),
2045            Err(ConnectionValidationError::BothModesSet)
2046        ));
2047    }
2048
2049    #[test]
2050    fn validate_connection_rejects_neither_mode_set() {
2051        let spec = spec_with_connection(ConnectionSpec {
2052            secret_ref: None,
2053            secret_key: None,
2054            params: None,
2055        });
2056        assert!(spec.validate_connection_spec().is_err());
2057    }
2058
2059    #[test]
2060    fn validate_connection_rejects_invalid_ssl_mode() {
2061        let spec = spec_with_connection(ConnectionSpec {
2062            secret_ref: None,
2063            secret_key: None,
2064            params: Some(ConnectionParams {
2065                host: Some("host".into()),
2066                host_secret: None,
2067                port: None,
2068                port_secret: None,
2069                dbname: Some("db".into()),
2070                dbname_secret: None,
2071                username: Some("user".into()),
2072                username_secret: None,
2073                password: Some("pass".into()),
2074                password_secret: None,
2075                ssl_mode: Some("invalid-mode".into()),
2076                ssl_mode_secret: None,
2077            }),
2078        });
2079        assert!(spec.validate_connection_spec().is_err());
2080    }
2081
2082    #[test]
2083    fn validate_connection_accepts_valid_ssl_modes() {
2084        for mode in &[
2085            "disable",
2086            "allow",
2087            "prefer",
2088            "require",
2089            "verify-ca",
2090            "verify-full",
2091        ] {
2092            let spec = spec_with_connection(ConnectionSpec {
2093                secret_ref: None,
2094                secret_key: None,
2095                params: Some(ConnectionParams {
2096                    host: Some("host".into()),
2097                    host_secret: None,
2098                    port: None,
2099                    port_secret: None,
2100                    dbname: Some("db".into()),
2101                    dbname_secret: None,
2102                    username: Some("user".into()),
2103                    username_secret: None,
2104                    password: Some("pass".into()),
2105                    password_secret: None,
2106                    ssl_mode: Some((*mode).into()),
2107                    ssl_mode_secret: None,
2108                }),
2109            });
2110            assert!(
2111                spec.validate_connection_spec().is_ok(),
2112                "sslMode '{mode}' should be accepted"
2113            );
2114        }
2115    }
2116
2117    #[test]
2118    fn validate_connection_rejects_empty_secret_name() {
2119        let spec = spec_with_connection(ConnectionSpec {
2120            secret_ref: None,
2121            secret_key: None,
2122            params: Some(ConnectionParams {
2123                host: Some("host".into()),
2124                host_secret: None,
2125                port: None,
2126                port_secret: None,
2127                dbname: Some("db".into()),
2128                dbname_secret: None,
2129                username: None,
2130                username_secret: Some(SecretKeySelector {
2131                    name: "".into(),
2132                    key: "username".into(),
2133                }),
2134                password: Some("pass".into()),
2135                password_secret: None,
2136                ssl_mode: None,
2137                ssl_mode_secret: None,
2138            }),
2139        });
2140        assert!(spec.validate_connection_spec().is_err());
2141    }
2142
2143    #[test]
2144    fn validate_connection_rejects_both_literal_and_secret_for_same_field() {
2145        let spec = spec_with_connection(ConnectionSpec {
2146            secret_ref: None,
2147            secret_key: None,
2148            params: Some(ConnectionParams {
2149                host: Some("host".into()),
2150                host_secret: Some(SecretKeySelector {
2151                    name: "s".into(),
2152                    key: "k".into(),
2153                }),
2154                port: None,
2155                port_secret: None,
2156                dbname: Some("db".into()),
2157                dbname_secret: None,
2158                username: Some("user".into()),
2159                username_secret: None,
2160                password: Some("pass".into()),
2161                password_secret: None,
2162                ssl_mode: None,
2163                ssl_mode_secret: None,
2164            }),
2165        });
2166        assert!(matches!(
2167            spec.validate_connection_spec(),
2168            Err(ConnectionValidationError::BothFieldsSet { ref field }) if field == "host"
2169        ));
2170    }
2171
2172    #[test]
2173    fn validate_connection_rejects_neither_literal_nor_secret_for_required_field() {
2174        let spec = spec_with_connection(ConnectionSpec {
2175            secret_ref: None,
2176            secret_key: None,
2177            params: Some(ConnectionParams {
2178                host: None,
2179                host_secret: None,
2180                port: None,
2181                port_secret: None,
2182                dbname: Some("db".into()),
2183                dbname_secret: None,
2184                username: Some("user".into()),
2185                username_secret: None,
2186                password: Some("pass".into()),
2187                password_secret: None,
2188                ssl_mode: None,
2189                ssl_mode_secret: None,
2190            }),
2191        });
2192        assert!(matches!(
2193            spec.validate_connection_spec(),
2194            Err(ConnectionValidationError::NeitherFieldSet { ref field }) if field == "host"
2195        ));
2196    }
2197
2198    // -- ConnectionSpec backward compatibility --------------------------------
2199
2200    #[test]
2201    fn connection_spec_backward_compat_url_mode() {
2202        // The old format with required secretRef should still deserialize.
2203        let yaml = r#"
2204secretRef:
2205  name: pg-creds
2206secretKey: DATABASE_URL
2207"#;
2208        let conn: ConnectionSpec = serde_yaml::from_str(yaml).unwrap();
2209        assert!(conn.secret_ref.is_some());
2210        assert_eq!(conn.effective_secret_key(), "DATABASE_URL");
2211        assert!(conn.params.is_none());
2212    }
2213
2214    #[test]
2215    fn connection_spec_backward_compat_default_secret_key() {
2216        let yaml = r#"
2217secretRef:
2218  name: pg-creds
2219"#;
2220        let conn: ConnectionSpec = serde_yaml::from_str(yaml).unwrap();
2221        assert_eq!(conn.effective_secret_key(), "DATABASE_URL");
2222    }
2223
2224    #[test]
2225    fn connection_spec_params_mode_deserializes_keycloak_style() {
2226        let yaml = r#"
2227params:
2228  host: my-postgres
2229  port: 5432
2230  dbname: mydb
2231  usernameSecret:
2232    name: creds
2233    key: username
2234  passwordSecret:
2235    name: creds
2236    key: password
2237  sslMode: require
2238"#;
2239        let conn: ConnectionSpec = serde_yaml::from_str(yaml).unwrap();
2240        assert!(conn.secret_ref.is_none());
2241        let params = conn.params.unwrap();
2242        assert_eq!(params.host.as_deref(), Some("my-postgres"));
2243        assert_eq!(params.port, Some(5432));
2244        assert!(params.username_secret.is_some());
2245        assert_eq!(params.username_secret.as_ref().unwrap().name, "creds");
2246        assert_eq!(params.ssl_mode.as_deref(), Some("require"));
2247    }
2248
2249    #[test]
2250    fn connection_spec_params_mode_all_secrets() {
2251        // CNPG/PGO pattern — everything from one secret.
2252        let yaml = r#"
2253params:
2254  hostSecret:
2255    name: cluster-app
2256    key: host
2257  portSecret:
2258    name: cluster-app
2259    key: port
2260  dbnameSecret:
2261    name: cluster-app
2262    key: dbname
2263  usernameSecret:
2264    name: cluster-app
2265    key: user
2266  passwordSecret:
2267    name: cluster-app
2268    key: password
2269"#;
2270        let conn: ConnectionSpec = serde_yaml::from_str(yaml).unwrap();
2271        let params = conn.params.unwrap();
2272        assert!(params.host.is_none());
2273        assert!(params.host_secret.is_some());
2274        assert_eq!(params.host_secret.as_ref().unwrap().name, "cluster-app");
2275        assert!(params.port.is_none());
2276        assert!(params.port_secret.is_some());
2277    }
2278
2279    // -- referenced_secret_names with params mode ----------------------------
2280
2281    #[test]
2282    fn referenced_secret_names_includes_params_secrets() {
2283        let spec = spec_with_connection(params_mode_connection());
2284        let names = spec.referenced_secret_names("test-policy");
2285        assert!(
2286            names.contains("pg-creds"),
2287            "should include the credential secret from params"
2288        );
2289    }
2290
2291    #[test]
2292    fn referenced_secret_names_deduplicates_across_modes() {
2293        // Same secret name used in both connection and password secretRef.
2294        let mut spec = spec_with_connection(params_mode_connection());
2295        spec.roles = vec![RoleSpec {
2296            name: "app".into(),
2297            login: Some(true),
2298            password: Some(PasswordSpec {
2299                secret_ref: Some(SecretReference {
2300                    name: "pg-creds".into(),
2301                }),
2302                secret_key: Some("app-password".into()),
2303                generate: None,
2304            }),
2305            password_valid_until: None,
2306            superuser: None,
2307            createdb: None,
2308            createrole: None,
2309            inherit: None,
2310            replication: None,
2311            bypassrls: None,
2312            connection_limit: None,
2313            comment: None,
2314        }];
2315        let names = spec.referenced_secret_names("test-policy");
2316        // pg-creds appears in both connection params and password — should be deduped.
2317        assert_eq!(
2318            names.iter().filter(|n| *n == "pg-creds").count(),
2319            1,
2320            "BTreeSet should deduplicate"
2321        );
2322    }
2323
2324    // -- ConnectionParams port default ---------------------------------------
2325
2326    #[test]
2327    fn connection_params_port_defaults_to_none() {
2328        let yaml = r#"
2329params:
2330  host: my-host
2331  dbname: mydb
2332  username: user
2333  password: pass
2334"#;
2335        let conn: ConnectionSpec = serde_yaml::from_str(yaml).unwrap();
2336        let params = conn.params.unwrap();
2337        assert!(
2338            params.port.is_none(),
2339            "port should default to None (resolved as 5432 at runtime)"
2340        );
2341        assert!(
2342            params.port_secret.is_none(),
2343            "portSecret should also default to None"
2344        );
2345    }
2346
2347    #[test]
2348    fn now_rfc3339_produces_valid_format() {
2349        let ts = now_rfc3339();
2350        // Should match YYYY-MM-DDTHH:MM:SSZ
2351        assert!(ts.len() == 20, "expected 20 chars, got {}: {ts}", ts.len());
2352        assert!(ts.ends_with('Z'), "should end with Z: {ts}");
2353        assert_eq!(&ts[4..5], "-", "should have dash at pos 4: {ts}");
2354        assert_eq!(&ts[10..11], "T", "should have T at pos 10: {ts}");
2355    }
2356
2357    #[test]
2358    fn ready_condition_true_has_expected_shape() {
2359        let cond = ready_condition(true, "Reconciled", "All changes applied");
2360        assert_eq!(cond.condition_type, "Ready");
2361        assert_eq!(cond.status, "True");
2362        assert_eq!(cond.reason.as_deref(), Some("Reconciled"));
2363        assert_eq!(cond.message.as_deref(), Some("All changes applied"));
2364        assert!(cond.last_transition_time.is_some());
2365    }
2366
2367    #[test]
2368    fn ready_condition_false_has_expected_shape() {
2369        let cond = ready_condition(false, "InvalidSpec", "bad manifest");
2370        assert_eq!(cond.condition_type, "Ready");
2371        assert_eq!(cond.status, "False");
2372        assert_eq!(cond.reason.as_deref(), Some("InvalidSpec"));
2373        assert_eq!(cond.message.as_deref(), Some("bad manifest"));
2374    }
2375
2376    #[test]
2377    fn degraded_condition_has_expected_shape() {
2378        let cond = degraded_condition("InvalidSpec", "expansion failed");
2379        assert_eq!(cond.condition_type, "Degraded");
2380        assert_eq!(cond.status, "True");
2381        assert_eq!(cond.reason.as_deref(), Some("InvalidSpec"));
2382        assert_eq!(cond.message.as_deref(), Some("expansion failed"));
2383        assert!(cond.last_transition_time.is_some());
2384    }
2385
2386    #[test]
2387    fn reconciling_condition_has_expected_shape() {
2388        let cond = reconciling_condition("Reconciliation in progress");
2389        assert_eq!(cond.condition_type, "Reconciling");
2390        assert_eq!(cond.status, "True");
2391        assert_eq!(cond.reason.as_deref(), Some("Reconciling"));
2392        assert_eq!(cond.message.as_deref(), Some("Reconciliation in progress"));
2393        assert!(cond.last_transition_time.is_some());
2394    }
2395
2396    #[test]
2397    fn conflict_condition_has_expected_shape() {
2398        let cond = conflict_condition("ConflictingPolicy", "overlaps with ns/other");
2399        assert_eq!(cond.condition_type, "Conflict");
2400        assert_eq!(cond.status, "True");
2401        assert_eq!(cond.reason.as_deref(), Some("ConflictingPolicy"));
2402        assert_eq!(cond.message.as_deref(), Some("overlaps with ns/other"));
2403        assert!(cond.last_transition_time.is_some());
2404    }
2405
2406    #[test]
2407    fn ownership_claims_no_overlap() {
2408        let mut left = OwnershipClaims::default();
2409        left.roles.insert("analytics".to_string());
2410        left.schemas.insert("reporting".to_string());
2411
2412        let mut right = OwnershipClaims::default();
2413        right.roles.insert("billing".to_string());
2414        right.schemas.insert("payments".to_string());
2415
2416        assert!(!left.overlaps(&right));
2417        let summary = left.overlap_summary(&right);
2418        assert!(summary.is_empty());
2419    }
2420
2421    #[test]
2422    fn ownership_claims_partial_role_overlap() {
2423        let mut left = OwnershipClaims::default();
2424        left.roles.insert("analytics".to_string());
2425        left.roles.insert("reporting-viewer".to_string());
2426
2427        let mut right = OwnershipClaims::default();
2428        right.roles.insert("analytics".to_string());
2429        right.roles.insert("other-role".to_string());
2430
2431        assert!(left.overlaps(&right));
2432        let summary = left.overlap_summary(&right);
2433        assert!(summary.contains("roles: analytics"));
2434        assert!(!summary.contains("schemas"));
2435    }
2436
2437    #[test]
2438    fn ownership_claims_empty_is_disjoint() {
2439        let left = OwnershipClaims::default();
2440        let right = OwnershipClaims::default();
2441        assert!(!left.overlaps(&right));
2442    }
2443
2444    #[test]
2445    fn database_identity_equality() {
2446        let conn_a = ConnectionSpec {
2447            secret_ref: Some(SecretReference {
2448                name: "db-creds".to_string(),
2449            }),
2450            secret_key: Some("DATABASE_URL".to_string()),
2451            params: None,
2452        };
2453        let a = DatabaseIdentity::from_connection("prod", &conn_a);
2454        let b = DatabaseIdentity::from_connection("prod", &conn_a);
2455        let c = DatabaseIdentity::from_connection("staging", &conn_a);
2456        assert_eq!(a, b);
2457        assert_ne!(a, c);
2458    }
2459
2460    #[test]
2461    fn database_identity_different_key() {
2462        let conn_a = ConnectionSpec {
2463            secret_ref: Some(SecretReference {
2464                name: "db-creds".to_string(),
2465            }),
2466            secret_key: Some("DATABASE_URL".to_string()),
2467            params: None,
2468        };
2469        let conn_b = ConnectionSpec {
2470            secret_ref: Some(SecretReference {
2471                name: "db-creds".to_string(),
2472            }),
2473            secret_key: Some("CUSTOM_URL".to_string()),
2474            params: None,
2475        };
2476        let a = DatabaseIdentity::from_connection("prod", &conn_a);
2477        let b = DatabaseIdentity::from_connection("prod", &conn_b);
2478        assert_ne!(a, b);
2479    }
2480
2481    #[test]
2482    fn status_default_has_empty_conditions() {
2483        let status = PostgresPolicyStatus::default();
2484        assert!(status.conditions.is_empty());
2485        assert!(status.observed_generation.is_none());
2486        assert!(status.last_attempted_generation.is_none());
2487        assert!(status.last_successful_reconcile_time.is_none());
2488        assert!(status.change_summary.is_none());
2489        assert!(status.managed_database_identity.is_none());
2490        assert!(status.owned_roles.is_empty());
2491        assert!(status.owned_schemas.is_empty());
2492        assert!(status.last_error.is_none());
2493        assert!(status.applied_password_source_versions.is_empty());
2494    }
2495
2496    #[test]
2497    fn status_degraded_workflow_sets_ready_false_and_degraded_true() {
2498        let mut status = PostgresPolicyStatus::default();
2499
2500        // Simulate a failed reconciliation: Ready=False + Degraded=True
2501        status.set_condition(ready_condition(false, "InvalidSpec", "bad manifest"));
2502        status.set_condition(degraded_condition("InvalidSpec", "bad manifest"));
2503        status
2504            .conditions
2505            .retain(|c| c.condition_type != "Reconciling" && c.condition_type != "Paused");
2506        status.change_summary = None;
2507        status.last_error = Some("bad manifest".to_string());
2508
2509        // Verify Ready=False
2510        let ready = status
2511            .conditions
2512            .iter()
2513            .find(|c| c.condition_type == "Ready")
2514            .expect("should have Ready condition");
2515        assert_eq!(ready.status, "False");
2516        assert_eq!(ready.reason.as_deref(), Some("InvalidSpec"));
2517
2518        // Verify Degraded=True
2519        let degraded = status
2520            .conditions
2521            .iter()
2522            .find(|c| c.condition_type == "Degraded")
2523            .expect("should have Degraded condition");
2524        assert_eq!(degraded.status, "True");
2525        assert_eq!(degraded.reason.as_deref(), Some("InvalidSpec"));
2526
2527        // Verify last_error is set
2528        assert_eq!(status.last_error.as_deref(), Some("bad manifest"));
2529    }
2530
2531    #[test]
2532    fn status_conflict_workflow() {
2533        let mut status = PostgresPolicyStatus::default();
2534
2535        // Simulate a conflict
2536        let msg = "policy ownership overlaps with staging/other on database target prod/db/URL";
2537        status.set_condition(ready_condition(false, "ConflictingPolicy", msg));
2538        status.set_condition(conflict_condition("ConflictingPolicy", msg));
2539        status.set_condition(degraded_condition("ConflictingPolicy", msg));
2540        status
2541            .conditions
2542            .retain(|c| c.condition_type != "Reconciling");
2543        status.last_error = Some(msg.to_string());
2544
2545        // Verify Conflict=True
2546        let conflict = status
2547            .conditions
2548            .iter()
2549            .find(|c| c.condition_type == "Conflict")
2550            .expect("should have Conflict condition");
2551        assert_eq!(conflict.status, "True");
2552        assert_eq!(conflict.reason.as_deref(), Some("ConflictingPolicy"));
2553
2554        // Verify Ready=False
2555        let ready = status
2556            .conditions
2557            .iter()
2558            .find(|c| c.condition_type == "Ready")
2559            .expect("should have Ready condition");
2560        assert_eq!(ready.status, "False");
2561
2562        // Verify Degraded=True
2563        let degraded = status
2564            .conditions
2565            .iter()
2566            .find(|c| c.condition_type == "Degraded")
2567            .expect("should have Degraded condition");
2568        assert_eq!(degraded.status, "True");
2569    }
2570
2571    #[test]
2572    fn status_successful_reconcile_records_generation_and_time() {
2573        let mut status = PostgresPolicyStatus::default();
2574        let generation = Some(3_i64);
2575        let summary = ChangeSummary {
2576            roles_created: 2,
2577            total: 2,
2578            ..Default::default()
2579        };
2580
2581        // Simulate a successful reconciliation
2582        status.set_condition(ready_condition(true, "Reconciled", "All changes applied"));
2583        status.conditions.retain(|c| {
2584            c.condition_type != "Reconciling"
2585                && c.condition_type != "Degraded"
2586                && c.condition_type != "Conflict"
2587                && c.condition_type != "Paused"
2588        });
2589        status.observed_generation = generation;
2590        status.last_attempted_generation = generation;
2591        status.last_successful_reconcile_time = Some(now_rfc3339());
2592        status.last_reconcile_time = Some(now_rfc3339());
2593        status.change_summary = Some(summary);
2594        status.last_error = None;
2595
2596        // Verify Ready=True
2597        let ready = status
2598            .conditions
2599            .iter()
2600            .find(|c| c.condition_type == "Ready")
2601            .expect("should have Ready condition");
2602        assert_eq!(ready.status, "True");
2603        assert_eq!(ready.reason.as_deref(), Some("Reconciled"));
2604
2605        // Verify generation recorded
2606        assert_eq!(status.observed_generation, Some(3));
2607        assert_eq!(status.last_attempted_generation, Some(3));
2608
2609        // Verify timestamps set
2610        assert!(status.last_successful_reconcile_time.is_some());
2611        assert!(status.last_reconcile_time.is_some());
2612
2613        // Verify summary
2614        let summary = status.change_summary.as_ref().unwrap();
2615        assert_eq!(summary.roles_created, 2);
2616        assert_eq!(summary.total, 2);
2617
2618        // Verify no error
2619        assert!(status.last_error.is_none());
2620
2621        // Verify no Degraded/Conflict/Paused/Reconciling conditions
2622        assert!(
2623            status
2624                .conditions
2625                .iter()
2626                .all(|c| c.condition_type != "Degraded"
2627                    && c.condition_type != "Conflict"
2628                    && c.condition_type != "Paused"
2629                    && c.condition_type != "Reconciling")
2630        );
2631    }
2632
2633    #[test]
2634    fn status_suspended_workflow() {
2635        let mut status = PostgresPolicyStatus::default();
2636        let generation = Some(2_i64);
2637
2638        // Simulate a suspended reconciliation
2639        status.set_condition(paused_condition("Reconciliation suspended by spec"));
2640        status.set_condition(ready_condition(
2641            false,
2642            "Suspended",
2643            "Reconciliation suspended by spec",
2644        ));
2645        status
2646            .conditions
2647            .retain(|c| c.condition_type != "Reconciling");
2648        status.last_attempted_generation = generation;
2649        status.last_error = None;
2650
2651        // Verify Paused=True
2652        let paused = status
2653            .conditions
2654            .iter()
2655            .find(|c| c.condition_type == "Paused")
2656            .expect("should have Paused condition");
2657        assert_eq!(paused.status, "True");
2658
2659        // Verify Ready=False with Suspended reason
2660        let ready = status
2661            .conditions
2662            .iter()
2663            .find(|c| c.condition_type == "Ready")
2664            .expect("should have Ready condition");
2665        assert_eq!(ready.status, "False");
2666        assert_eq!(ready.reason.as_deref(), Some("Suspended"));
2667
2668        // Verify no Reconciling condition
2669        assert!(
2670            !status
2671                .conditions
2672                .iter()
2673                .any(|c| c.condition_type == "Reconciling")
2674        );
2675    }
2676
2677    #[test]
2678    fn status_transitions_from_degraded_to_ready() {
2679        let mut status = PostgresPolicyStatus::default();
2680
2681        // First, set degraded state
2682        status.set_condition(ready_condition(false, "InvalidSpec", "error"));
2683        status.set_condition(degraded_condition("InvalidSpec", "error"));
2684        status.last_error = Some("error".to_string());
2685
2686        assert_eq!(status.conditions.len(), 2);
2687
2688        // Then, resolve to ready
2689        status.set_condition(ready_condition(true, "Reconciled", "All changes applied"));
2690        status.conditions.retain(|c| {
2691            c.condition_type != "Reconciling"
2692                && c.condition_type != "Degraded"
2693                && c.condition_type != "Conflict"
2694                && c.condition_type != "Paused"
2695        });
2696        status.last_error = None;
2697
2698        // Verify Ready=True
2699        let ready = status
2700            .conditions
2701            .iter()
2702            .find(|c| c.condition_type == "Ready")
2703            .expect("should have Ready condition");
2704        assert_eq!(ready.status, "True");
2705
2706        // Verify Degraded removed
2707        assert!(
2708            !status
2709                .conditions
2710                .iter()
2711                .any(|c| c.condition_type == "Degraded")
2712        );
2713
2714        // Verify only Ready condition remains
2715        assert_eq!(status.conditions.len(), 1);
2716
2717        // Verify error cleared
2718        assert!(status.last_error.is_none());
2719    }
2720
2721    #[test]
2722    fn change_summary_default_is_all_zero() {
2723        let summary = ChangeSummary::default();
2724        assert_eq!(summary.roles_created, 0);
2725        assert_eq!(summary.roles_altered, 0);
2726        assert_eq!(summary.roles_dropped, 0);
2727        assert_eq!(summary.sessions_terminated, 0);
2728        assert_eq!(summary.grants_added, 0);
2729        assert_eq!(summary.grants_revoked, 0);
2730        assert_eq!(summary.default_privileges_set, 0);
2731        assert_eq!(summary.default_privileges_revoked, 0);
2732        assert_eq!(summary.members_added, 0);
2733        assert_eq!(summary.members_removed, 0);
2734        assert_eq!(summary.total, 0);
2735    }
2736
2737    #[test]
2738    fn status_serializes_to_json() {
2739        let mut status = PostgresPolicyStatus::default();
2740        status.set_condition(ready_condition(true, "Reconciled", "done"));
2741        status.observed_generation = Some(5);
2742        status.managed_database_identity = Some("ns/secret/key".to_string());
2743        status.owned_roles = vec!["role-a".to_string(), "role-b".to_string()];
2744        status.owned_schemas = vec!["public".to_string()];
2745        status.change_summary = Some(ChangeSummary {
2746            roles_created: 1,
2747            total: 1,
2748            ..Default::default()
2749        });
2750
2751        let json = serde_json::to_string(&status).expect("should serialize");
2752        assert!(json.contains("\"Reconciled\""));
2753        assert!(json.contains("\"observed_generation\":5"));
2754        assert!(json.contains("\"role-a\""));
2755        assert!(json.contains("\"ns/secret/key\""));
2756    }
2757
2758    #[test]
2759    fn crd_spec_deserializes_from_yaml() {
2760        let yaml = r#"
2761connection:
2762  secretRef:
2763    name: pg-credentials
2764interval: "10m"
2765default_owner: app_owner
2766profiles:
2767  editor:
2768    grants:
2769      - privileges: [USAGE]
2770        object: { type: schema }
2771      - privileges: [SELECT, INSERT, UPDATE, DELETE]
2772        object: { type: table, name: "*" }
2773    default_privileges:
2774      - privileges: [SELECT, INSERT, UPDATE, DELETE]
2775        on_type: table
2776schemas:
2777  - name: inventory
2778    profiles: [editor]
2779roles:
2780  - name: analytics
2781    login: true
2782grants:
2783  - role: analytics
2784    privileges: [CONNECT]
2785    object: { type: database, name: mydb }
2786memberships:
2787  - role: inventory-editor
2788    members:
2789      - name: analytics
2790retirements:
2791  - role: legacy-app
2792    reassign_owned_to: app_owner
2793    drop_owned: true
2794    terminate_sessions: true
2795"#;
2796        let spec: PostgresPolicySpec = serde_yaml::from_str(yaml).expect("should deserialize");
2797        assert_eq!(spec.interval, "10m");
2798        assert_eq!(spec.default_owner, Some("app_owner".to_string()));
2799        assert_eq!(spec.profiles.len(), 1);
2800        assert!(spec.profiles.contains_key("editor"));
2801        assert_eq!(spec.schemas.len(), 1);
2802        assert_eq!(spec.roles.len(), 1);
2803        assert_eq!(spec.grants.len(), 1);
2804        assert_eq!(spec.memberships.len(), 1);
2805        assert_eq!(spec.retirements.len(), 1);
2806        assert_eq!(spec.retirements[0].role, "legacy-app");
2807        assert!(spec.retirements[0].terminate_sessions);
2808    }
2809
2810    #[test]
2811    fn referenced_secret_names_includes_connection_secret() {
2812        let spec = PostgresPolicySpec {
2813            connection: ConnectionSpec {
2814                secret_ref: Some(SecretReference {
2815                    name: "pg-conn".to_string(),
2816                }),
2817                secret_key: Some("DATABASE_URL".to_string()),
2818                params: None,
2819            },
2820            interval: "5m".to_string(),
2821            suspend: false,
2822            mode: PolicyMode::Apply,
2823            reconciliation_mode: CrdReconciliationMode::default(),
2824            default_owner: None,
2825            profiles: std::collections::HashMap::new(),
2826            schemas: vec![],
2827            roles: vec![],
2828            grants: vec![],
2829            default_privileges: vec![],
2830            memberships: vec![],
2831            retirements: vec![],
2832            approval: None,
2833        };
2834
2835        let names = spec.referenced_secret_names("test-policy");
2836        assert!(names.contains("pg-conn"));
2837        assert_eq!(names.len(), 1);
2838    }
2839
2840    #[test]
2841    fn referenced_secret_names_includes_password_secrets() {
2842        let spec = PostgresPolicySpec {
2843            connection: ConnectionSpec {
2844                secret_ref: Some(SecretReference {
2845                    name: "pg-conn".to_string(),
2846                }),
2847                secret_key: Some("DATABASE_URL".to_string()),
2848                params: None,
2849            },
2850            interval: "5m".to_string(),
2851            suspend: false,
2852            mode: PolicyMode::Apply,
2853            reconciliation_mode: CrdReconciliationMode::default(),
2854            default_owner: None,
2855            profiles: std::collections::HashMap::new(),
2856            schemas: vec![],
2857            roles: vec![
2858                RoleSpec {
2859                    name: "role-a".to_string(),
2860                    login: Some(true),
2861                    password: Some(PasswordSpec {
2862                        secret_ref: Some(SecretReference {
2863                            name: "role-passwords".to_string(),
2864                        }),
2865                        secret_key: Some("role-a".to_string()),
2866                        generate: None,
2867                    }),
2868                    password_valid_until: None,
2869                    superuser: None,
2870                    createdb: None,
2871                    createrole: None,
2872                    inherit: None,
2873                    replication: None,
2874                    bypassrls: None,
2875                    connection_limit: None,
2876                    comment: None,
2877                },
2878                RoleSpec {
2879                    name: "role-b".to_string(),
2880                    login: Some(true),
2881                    password: Some(PasswordSpec {
2882                        secret_ref: Some(SecretReference {
2883                            name: "other-secret".to_string(),
2884                        }),
2885                        secret_key: None,
2886                        generate: None,
2887                    }),
2888                    password_valid_until: None,
2889                    superuser: None,
2890                    createdb: None,
2891                    createrole: None,
2892                    inherit: None,
2893                    replication: None,
2894                    bypassrls: None,
2895                    connection_limit: None,
2896                    comment: None,
2897                },
2898                RoleSpec {
2899                    name: "role-c".to_string(),
2900                    login: None,
2901                    password: None,
2902                    password_valid_until: None,
2903                    superuser: None,
2904                    createdb: None,
2905                    createrole: None,
2906                    inherit: None,
2907                    replication: None,
2908                    bypassrls: None,
2909                    connection_limit: None,
2910                    comment: None,
2911                },
2912            ],
2913            grants: vec![],
2914            default_privileges: vec![],
2915            memberships: vec![],
2916            retirements: vec![],
2917            approval: None,
2918        };
2919
2920        let names = spec.referenced_secret_names("test-policy");
2921        assert!(
2922            names.contains("pg-conn"),
2923            "should include connection secret"
2924        );
2925        assert!(
2926            names.contains("role-passwords"),
2927            "should include role-a password secret"
2928        );
2929        assert!(
2930            names.contains("other-secret"),
2931            "should include role-b password secret"
2932        );
2933        assert_eq!(names.len(), 3);
2934    }
2935
2936    #[test]
2937    fn validate_password_specs_rejects_password_without_login() {
2938        let spec = PostgresPolicySpec {
2939            connection: ConnectionSpec {
2940                secret_ref: Some(SecretReference {
2941                    name: "pg-conn".to_string(),
2942                }),
2943                secret_key: Some("DATABASE_URL".to_string()),
2944                params: None,
2945            },
2946            interval: "5m".to_string(),
2947            suspend: false,
2948            mode: PolicyMode::Apply,
2949            reconciliation_mode: CrdReconciliationMode::default(),
2950            default_owner: None,
2951            profiles: std::collections::HashMap::new(),
2952            schemas: vec![],
2953            roles: vec![RoleSpec {
2954                name: "app-user".to_string(),
2955                login: Some(false),
2956                superuser: None,
2957                createdb: None,
2958                createrole: None,
2959                inherit: None,
2960                replication: None,
2961                bypassrls: None,
2962                connection_limit: None,
2963                comment: None,
2964                password: Some(PasswordSpec {
2965                    secret_ref: Some(SecretReference {
2966                        name: "role-passwords".to_string(),
2967                    }),
2968                    secret_key: None,
2969                    generate: None,
2970                }),
2971                password_valid_until: None,
2972            }],
2973            grants: vec![],
2974            default_privileges: vec![],
2975            memberships: vec![],
2976            retirements: vec![],
2977            approval: None,
2978        };
2979
2980        assert!(matches!(
2981            spec.validate_password_specs("test-policy"),
2982            Err(PasswordValidationError::PasswordWithoutLogin { ref role }) if role == "app-user"
2983        ));
2984    }
2985
2986    #[test]
2987    fn validate_password_specs_rejects_password_with_login_omitted() {
2988        let spec = PostgresPolicySpec {
2989            connection: ConnectionSpec {
2990                secret_ref: Some(SecretReference {
2991                    name: "pg-conn".to_string(),
2992                }),
2993                secret_key: Some("DATABASE_URL".to_string()),
2994                params: None,
2995            },
2996            interval: "5m".to_string(),
2997            suspend: false,
2998            mode: PolicyMode::Apply,
2999            reconciliation_mode: CrdReconciliationMode::default(),
3000            default_owner: None,
3001            profiles: std::collections::HashMap::new(),
3002            schemas: vec![],
3003            roles: vec![RoleSpec {
3004                name: "app-user".to_string(),
3005                login: None, // omitted, not explicitly false
3006                superuser: None,
3007                createdb: None,
3008                createrole: None,
3009                inherit: None,
3010                replication: None,
3011                bypassrls: None,
3012                connection_limit: None,
3013                comment: None,
3014                password: Some(PasswordSpec {
3015                    secret_ref: Some(SecretReference {
3016                        name: "role-passwords".to_string(),
3017                    }),
3018                    secret_key: None,
3019                    generate: None,
3020                }),
3021                password_valid_until: None,
3022            }],
3023            grants: vec![],
3024            default_privileges: vec![],
3025            memberships: vec![],
3026            retirements: vec![],
3027            approval: None,
3028        };
3029
3030        assert!(matches!(
3031            spec.validate_password_specs("test-policy"),
3032            Err(PasswordValidationError::PasswordWithoutLogin { ref role }) if role == "app-user"
3033        ));
3034    }
3035
3036    #[test]
3037    fn validate_password_specs_rejects_invalid_password_mode() {
3038        let spec = PostgresPolicySpec {
3039            connection: ConnectionSpec {
3040                secret_ref: Some(SecretReference {
3041                    name: "pg-conn".to_string(),
3042                }),
3043                secret_key: Some("DATABASE_URL".to_string()),
3044                params: None,
3045            },
3046            interval: "5m".to_string(),
3047            suspend: false,
3048            mode: PolicyMode::Apply,
3049            reconciliation_mode: CrdReconciliationMode::default(),
3050            default_owner: None,
3051            profiles: std::collections::HashMap::new(),
3052            schemas: vec![],
3053            roles: vec![RoleSpec {
3054                name: "app-user".to_string(),
3055                login: Some(true),
3056                superuser: None,
3057                createdb: None,
3058                createrole: None,
3059                inherit: None,
3060                replication: None,
3061                bypassrls: None,
3062                connection_limit: None,
3063                comment: None,
3064                password: Some(PasswordSpec {
3065                    secret_ref: Some(SecretReference {
3066                        name: "role-passwords".to_string(),
3067                    }),
3068                    secret_key: None,
3069                    generate: Some(GeneratePasswordSpec {
3070                        length: Some(32),
3071                        secret_name: None,
3072                        secret_key: None,
3073                    }),
3074                }),
3075                password_valid_until: None,
3076            }],
3077            grants: vec![],
3078            default_privileges: vec![],
3079            memberships: vec![],
3080            retirements: vec![],
3081            approval: None,
3082        };
3083
3084        assert!(matches!(
3085            spec.validate_password_specs("test-policy"),
3086            Err(PasswordValidationError::InvalidPasswordMode { ref role }) if role == "app-user"
3087        ));
3088    }
3089
3090    #[test]
3091    fn validate_password_specs_rejects_invalid_generated_length() {
3092        let spec = PostgresPolicySpec {
3093            connection: ConnectionSpec {
3094                secret_ref: Some(SecretReference {
3095                    name: "pg-conn".to_string(),
3096                }),
3097                secret_key: Some("DATABASE_URL".to_string()),
3098                params: None,
3099            },
3100            interval: "5m".to_string(),
3101            suspend: false,
3102            mode: PolicyMode::Apply,
3103            reconciliation_mode: CrdReconciliationMode::default(),
3104            default_owner: None,
3105            profiles: std::collections::HashMap::new(),
3106            schemas: vec![],
3107            roles: vec![RoleSpec {
3108                name: "app-user".to_string(),
3109                login: Some(true),
3110                superuser: None,
3111                createdb: None,
3112                createrole: None,
3113                inherit: None,
3114                replication: None,
3115                bypassrls: None,
3116                connection_limit: None,
3117                comment: None,
3118                password: Some(PasswordSpec {
3119                    secret_ref: None,
3120                    secret_key: None,
3121                    generate: Some(GeneratePasswordSpec {
3122                        length: Some(8),
3123                        secret_name: None,
3124                        secret_key: None,
3125                    }),
3126                }),
3127                password_valid_until: None,
3128            }],
3129            grants: vec![],
3130            default_privileges: vec![],
3131            memberships: vec![],
3132            retirements: vec![],
3133            approval: None,
3134        };
3135
3136        assert!(matches!(
3137            spec.validate_password_specs("test-policy"),
3138            Err(PasswordValidationError::InvalidGeneratedLength { ref role, .. }) if role == "app-user"
3139        ));
3140    }
3141
3142    #[test]
3143    fn validate_password_specs_rejects_invalid_generated_secret_key() {
3144        let spec = PostgresPolicySpec {
3145            connection: ConnectionSpec {
3146                secret_ref: Some(SecretReference {
3147                    name: "pg-conn".to_string(),
3148                }),
3149                secret_key: Some("DATABASE_URL".to_string()),
3150                params: None,
3151            },
3152            interval: "5m".to_string(),
3153            suspend: false,
3154            mode: PolicyMode::Apply,
3155            reconciliation_mode: CrdReconciliationMode::default(),
3156            default_owner: None,
3157            profiles: std::collections::HashMap::new(),
3158            schemas: vec![],
3159            roles: vec![RoleSpec {
3160                name: "app-user".to_string(),
3161                login: Some(true),
3162                superuser: None,
3163                createdb: None,
3164                createrole: None,
3165                inherit: None,
3166                replication: None,
3167                bypassrls: None,
3168                connection_limit: None,
3169                comment: None,
3170                password: Some(PasswordSpec {
3171                    secret_ref: None,
3172                    secret_key: None,
3173                    generate: Some(GeneratePasswordSpec {
3174                        length: Some(32),
3175                        secret_name: None,
3176                        secret_key: Some("bad/key".to_string()),
3177                    }),
3178                }),
3179                password_valid_until: None,
3180            }],
3181            grants: vec![],
3182            default_privileges: vec![],
3183            memberships: vec![],
3184            retirements: vec![],
3185            approval: None,
3186        };
3187
3188        assert!(matches!(
3189            spec.validate_password_specs("test-policy"),
3190            Err(PasswordValidationError::InvalidSecretKey { ref role, field, .. })
3191                if role == "app-user" && field == "generate.secretKey"
3192        ));
3193    }
3194
3195    #[test]
3196    fn validate_password_specs_rejects_invalid_generated_secret_name() {
3197        let spec = PostgresPolicySpec {
3198            connection: ConnectionSpec {
3199                secret_ref: Some(SecretReference {
3200                    name: "pg-conn".to_string(),
3201                }),
3202                secret_key: Some("DATABASE_URL".to_string()),
3203                params: None,
3204            },
3205            interval: "5m".to_string(),
3206            suspend: false,
3207            mode: PolicyMode::Apply,
3208            reconciliation_mode: CrdReconciliationMode::default(),
3209            default_owner: None,
3210            profiles: std::collections::HashMap::new(),
3211            schemas: vec![],
3212            roles: vec![RoleSpec {
3213                name: "app-user".to_string(),
3214                login: Some(true),
3215                superuser: None,
3216                createdb: None,
3217                createrole: None,
3218                inherit: None,
3219                replication: None,
3220                bypassrls: None,
3221                connection_limit: None,
3222                comment: None,
3223                password: Some(PasswordSpec {
3224                    secret_ref: None,
3225                    secret_key: None,
3226                    generate: Some(GeneratePasswordSpec {
3227                        length: Some(32),
3228                        secret_name: Some("Bad_Name".to_string()),
3229                        secret_key: None,
3230                    }),
3231                }),
3232                password_valid_until: None,
3233            }],
3234            grants: vec![],
3235            default_privileges: vec![],
3236            memberships: vec![],
3237            retirements: vec![],
3238            approval: None,
3239        };
3240
3241        assert!(matches!(
3242            spec.validate_password_specs("test-policy"),
3243            Err(PasswordValidationError::InvalidGeneratedSecretName { ref role, .. }) if role == "app-user"
3244        ));
3245    }
3246
3247    #[test]
3248    fn validate_password_specs_rejects_reserved_generated_secret_key() {
3249        let spec = PostgresPolicySpec {
3250            connection: ConnectionSpec {
3251                secret_ref: Some(SecretReference {
3252                    name: "pg-conn".to_string(),
3253                }),
3254                secret_key: Some("DATABASE_URL".to_string()),
3255                params: None,
3256            },
3257            interval: "5m".to_string(),
3258            suspend: false,
3259            mode: PolicyMode::Apply,
3260            reconciliation_mode: CrdReconciliationMode::default(),
3261            default_owner: None,
3262            profiles: std::collections::HashMap::new(),
3263            schemas: vec![],
3264            roles: vec![RoleSpec {
3265                name: "app-user".to_string(),
3266                login: Some(true),
3267                superuser: None,
3268                createdb: None,
3269                createrole: None,
3270                inherit: None,
3271                replication: None,
3272                bypassrls: None,
3273                connection_limit: None,
3274                comment: None,
3275                password: Some(PasswordSpec {
3276                    secret_ref: None,
3277                    secret_key: None,
3278                    generate: Some(GeneratePasswordSpec {
3279                        length: Some(32),
3280                        secret_name: None,
3281                        secret_key: Some("verifier".to_string()),
3282                    }),
3283                }),
3284                password_valid_until: None,
3285            }],
3286            grants: vec![],
3287            default_privileges: vec![],
3288            memberships: vec![],
3289            retirements: vec![],
3290            approval: None,
3291        };
3292
3293        assert!(matches!(
3294            spec.validate_password_specs("test-policy"),
3295            Err(PasswordValidationError::ReservedGeneratedSecretKey { ref role, ref key })
3296                if role == "app-user" && key == "verifier"
3297        ));
3298    }
3299
3300    #[test]
3301    fn plan_crd_generates_valid_schema() {
3302        let crd = PostgresPolicyPlan::crd();
3303        let yaml = serde_yaml::to_string(&crd).expect("CRD should serialize to YAML");
3304        assert!(yaml.contains("pgroles.io"), "group should be pgroles.io");
3305        assert!(yaml.contains("v1alpha1"), "version should be v1alpha1");
3306        assert!(
3307            yaml.contains("PostgresPolicyPlan"),
3308            "kind should be PostgresPolicyPlan"
3309        );
3310        assert!(yaml.contains("pgplan"), "should have shortname pgplan");
3311    }
3312
3313    #[test]
3314    fn plan_phase_display() {
3315        assert_eq!(PlanPhase::Pending.to_string(), "Pending");
3316        assert_eq!(PlanPhase::Approved.to_string(), "Approved");
3317        assert_eq!(PlanPhase::Applying.to_string(), "Applying");
3318        assert_eq!(PlanPhase::Applied.to_string(), "Applied");
3319        assert_eq!(PlanPhase::Failed.to_string(), "Failed");
3320        assert_eq!(PlanPhase::Superseded.to_string(), "Superseded");
3321    }
3322
3323    #[test]
3324    fn plan_phase_default_is_pending() {
3325        assert_eq!(PlanPhase::default(), PlanPhase::Pending);
3326    }
3327
3328    #[test]
3329    fn effective_approval_infers_from_mode() {
3330        let base = PostgresPolicySpec {
3331            connection: ConnectionSpec {
3332                secret_ref: Some(SecretReference {
3333                    name: "test".into(),
3334                }),
3335                secret_key: Some("DATABASE_URL".into()),
3336                params: None,
3337            },
3338            interval: "5m".into(),
3339            suspend: false,
3340            mode: PolicyMode::Apply,
3341            reconciliation_mode: CrdReconciliationMode::Authoritative,
3342            default_owner: None,
3343            profiles: Default::default(),
3344            schemas: vec![],
3345            roles: vec![],
3346            grants: vec![],
3347            default_privileges: vec![],
3348            memberships: vec![],
3349            retirements: vec![],
3350            approval: None,
3351        };
3352
3353        // apply mode with no explicit approval → Auto
3354        assert_eq!(base.effective_approval(), ApprovalMode::Auto);
3355
3356        // plan mode with no explicit approval → Manual
3357        let plan = PostgresPolicySpec {
3358            mode: PolicyMode::Plan,
3359            ..base.clone()
3360        };
3361        assert_eq!(plan.effective_approval(), ApprovalMode::Manual);
3362
3363        // explicit Manual overrides apply mode
3364        let explicit = PostgresPolicySpec {
3365            approval: Some(ApprovalMode::Manual),
3366            ..base.clone()
3367        };
3368        assert_eq!(explicit.effective_approval(), ApprovalMode::Manual);
3369    }
3370
3371    #[test]
3372    fn approval_mode_serde_roundtrip() {
3373        // Deserialize
3374        let manual: ApprovalMode = serde_json::from_str("\"manual\"").unwrap();
3375        assert_eq!(manual, ApprovalMode::Manual);
3376        let auto: ApprovalMode = serde_json::from_str("\"auto\"").unwrap();
3377        assert_eq!(auto, ApprovalMode::Auto);
3378
3379        // Serialize back
3380        let manual_json = serde_json::to_value(&ApprovalMode::Manual).unwrap();
3381        assert_eq!(manual_json, serde_json::Value::String("manual".to_string()));
3382        let auto_json = serde_json::to_value(&ApprovalMode::Auto).unwrap();
3383        assert_eq!(auto_json, serde_json::Value::String("auto".to_string()));
3384    }
3385
3386    #[test]
3387    fn plan_status_default_is_empty() {
3388        let status = PostgresPolicyPlanStatus::default();
3389        assert_eq!(status.phase, PlanPhase::Pending);
3390        assert!(status.conditions.is_empty());
3391        assert!(status.change_summary.is_none());
3392        assert!(status.sql_ref.is_none());
3393        assert!(status.sql_inline.is_none());
3394        assert!(status.computed_at.is_none());
3395        assert!(status.applied_at.is_none());
3396        assert!(status.last_error.is_none());
3397    }
3398
3399    #[test]
3400    fn spec_without_approval_field_deserializes_as_none() {
3401        let json = serde_json::json!({
3402            "connection": {
3403                "secretRef": { "name": "pg-secret" },
3404                "secretKey": "DATABASE_URL"
3405            },
3406            "interval": "5m",
3407            "suspend": false,
3408            "mode": "apply",
3409            "reconciliation_mode": "authoritative"
3410        });
3411
3412        let spec: PostgresPolicySpec =
3413            serde_json::from_value(json).expect("should deserialize without approval field");
3414        assert!(
3415            spec.approval.is_none(),
3416            "approval should be None when omitted"
3417        );
3418        assert_eq!(
3419            spec.effective_approval(),
3420            ApprovalMode::Auto,
3421            "effective_approval should infer Auto from apply mode"
3422        );
3423    }
3424
3425    #[test]
3426    fn status_without_current_plan_ref_deserializes_as_none() {
3427        let json = serde_json::json!({
3428            "conditions": [],
3429            "owned_roles": [],
3430            "owned_schemas": []
3431        });
3432
3433        let status: PostgresPolicyStatus =
3434            serde_json::from_value(json).expect("should deserialize without current_plan_ref");
3435        assert!(
3436            status.current_plan_ref.is_none(),
3437            "current_plan_ref should be None when omitted"
3438        );
3439    }
3440
3441    #[test]
3442    fn effective_approval_explicit_auto_overrides_plan_mode() {
3443        let spec = PostgresPolicySpec {
3444            connection: ConnectionSpec {
3445                secret_ref: Some(SecretReference {
3446                    name: "test".into(),
3447                }),
3448                secret_key: Some("DATABASE_URL".into()),
3449                params: None,
3450            },
3451            interval: "5m".into(),
3452            suspend: false,
3453            mode: PolicyMode::Plan,
3454            reconciliation_mode: CrdReconciliationMode::Authoritative,
3455            default_owner: None,
3456            profiles: Default::default(),
3457            schemas: vec![],
3458            roles: vec![],
3459            grants: vec![],
3460            default_privileges: vec![],
3461            memberships: vec![],
3462            retirements: vec![],
3463            approval: Some(ApprovalMode::Auto),
3464        };
3465
3466        assert_eq!(
3467            spec.effective_approval(),
3468            ApprovalMode::Auto,
3469            "explicit Auto should override Plan mode's default of Manual"
3470        );
3471    }
3472
3473    #[test]
3474    fn plan_phase_rejected_display() {
3475        assert_eq!(PlanPhase::Rejected.to_string(), "Rejected");
3476    }
3477
3478    #[test]
3479    fn plan_phase_all_variants_display() {
3480        let variants = [
3481            PlanPhase::Pending,
3482            PlanPhase::Approved,
3483            PlanPhase::Applying,
3484            PlanPhase::Applied,
3485            PlanPhase::Failed,
3486            PlanPhase::Superseded,
3487            PlanPhase::Rejected,
3488        ];
3489        for variant in &variants {
3490            let display = variant.to_string();
3491            assert!(
3492                !display.is_empty(),
3493                "PlanPhase::{variant:?} should have non-empty Display output"
3494            );
3495        }
3496    }
3497
3498    #[test]
3499    fn plan_status_defaults() {
3500        let status = PostgresPolicyPlanStatus::default();
3501        assert_eq!(status.phase, PlanPhase::Pending);
3502        assert!(status.conditions.is_empty());
3503        assert!(status.sql_ref.is_none());
3504        assert!(status.sql_hash.is_none());
3505        assert!(status.sql_inline.is_none());
3506        assert!(!status.sql_truncated);
3507        assert!(status.redacted_sql_hash.is_none());
3508        assert!(status.sql_original_bytes.is_none());
3509        assert!(status.sql_stored_bytes.is_none());
3510        assert!(status.change_summary.is_none());
3511        assert!(status.computed_at.is_none());
3512        assert!(status.applied_at.is_none());
3513        assert!(status.last_error.is_none());
3514    }
3515
3516    #[test]
3517    fn sql_ref_missing_compression_deserializes_as_uncompressed_legacy_shape() {
3518        let json = serde_json::json!({
3519            "name": "legacy-plan-sql",
3520            "key": "plan.sql"
3521        });
3522
3523        let sql_ref: SqlRef = serde_json::from_value(json).expect("legacy SqlRef should decode");
3524
3525        assert_eq!(sql_ref.name, "legacy-plan-sql");
3526        assert_eq!(sql_ref.key, "plan.sql");
3527        assert_eq!(sql_ref.compression, None);
3528    }
3529
3530    #[test]
3531    fn plan_spec_camel_case_serialization() {
3532        let spec = PostgresPolicyPlanSpec {
3533            policy_ref: PolicyPlanRef {
3534                name: "my-policy".into(),
3535            },
3536            policy_generation: 3,
3537            reconciliation_mode: CrdReconciliationMode::Authoritative,
3538            owned_roles: vec!["role-a".into()],
3539            owned_schemas: vec!["public".into()],
3540            managed_database_identity: "ns/secret/key".into(),
3541        };
3542
3543        let json = serde_json::to_value(&spec).expect("should serialize to JSON");
3544        let obj = json.as_object().expect("should be a JSON object");
3545
3546        assert!(
3547            obj.contains_key("policyRef"),
3548            "should use camelCase: policyRef"
3549        );
3550        assert!(
3551            obj.contains_key("policyGeneration"),
3552            "should use camelCase: policyGeneration"
3553        );
3554        assert!(
3555            obj.contains_key("reconciliationMode"),
3556            "should use camelCase: reconciliationMode"
3557        );
3558        assert!(
3559            obj.contains_key("ownedRoles"),
3560            "should use camelCase: ownedRoles"
3561        );
3562        assert!(
3563            obj.contains_key("ownedSchemas"),
3564            "should use camelCase: ownedSchemas"
3565        );
3566        assert!(
3567            obj.contains_key("managedDatabaseIdentity"),
3568            "should use camelCase: managedDatabaseIdentity"
3569        );
3570    }
3571}