Skip to main content

autumn_admin_plugin/
feature_flags.rs

1//! Admin panel model for `autumn_feature_flags`.
2//!
3//! Registers a flag management page at `/admin/feature-flags/` with:
4//! - List view: key, enabled status, rollout %, actor allowlist, history link
5//! - Edit view: toggle enabled, set `rollout_pct`, manage allowlists
6//! - History tab: per-flag audit trail from `feature_flag_changes`
7
8use serde_json::Value;
9
10use crate::{
11    AdminError, AdminField, AdminFieldKind, AdminFuture, AdminModel, ListParams, ListResult,
12    SelectOption,
13};
14
15/// Admin panel model for feature flags.
16///
17/// Register this model with the admin plugin to get a flag management UI
18/// at `/admin/feature-flags/`:
19///
20/// ```rust,ignore
21/// use autumn_admin_plugin::{prelude::*, AdminPlugin};
22/// use autumn_admin_plugin::feature_flags::FeatureFlagAdminModel;
23///
24/// autumn_web::app()
25///     .plugin(
26///         AdminPlugin::new()
27///             .register(FeatureFlagAdminModel::default()),
28///     )
29///     .run()
30///     .await;
31/// ```
32#[derive(Debug, Default, Clone)]
33pub struct FeatureFlagAdminModel;
34
35impl AdminModel for FeatureFlagAdminModel {
36    fn slug(&self) -> &'static str {
37        "feature-flags"
38    }
39
40    fn display_name(&self) -> &'static str {
41        "Feature Flag"
42    }
43
44    fn display_name_plural(&self) -> &'static str {
45        "Feature Flags"
46    }
47
48    fn fields(&self) -> Vec<AdminField> {
49        vec![
50            AdminField::new("key", AdminFieldKind::Text)
51                .label("Flag Key")
52                .searchable(),
53            AdminField::new("description", AdminFieldKind::TextArea)
54                .label("Description")
55                .optional()
56                .searchable(),
57            AdminField::new("enabled", AdminFieldKind::Boolean).label("Globally Enabled"),
58            AdminField::new(
59                "rollout_pct",
60                AdminFieldKind::Select(vec![
61                    SelectOption {
62                        value: "0".into(),
63                        label: "Off (0%)".into(),
64                    },
65                    SelectOption {
66                        value: "10".into(),
67                        label: "10%".into(),
68                    },
69                    SelectOption {
70                        value: "25".into(),
71                        label: "25%".into(),
72                    },
73                    SelectOption {
74                        value: "50".into(),
75                        label: "50%".into(),
76                    },
77                    SelectOption {
78                        value: "75".into(),
79                        label: "75%".into(),
80                    },
81                    SelectOption {
82                        value: "100".into(),
83                        label: "All (100%)".into(),
84                    },
85                ]),
86            )
87            .label("Rollout %")
88            .optional(),
89            AdminField::new("actor_allowlist", AdminFieldKind::TextArea)
90                .label("Actor Allowlist (JSON array)")
91                .optional()
92                .hide_from_list(),
93            AdminField::new("group_allowlist", AdminFieldKind::TextArea)
94                .label("Group Allowlist (JSON array)")
95                .optional()
96                .hide_from_list(),
97            AdminField::new("updated_at", AdminFieldKind::DateTime)
98                .label("Last Updated")
99                .readonly()
100                .optional(),
101        ]
102    }
103
104    fn record_display(&self, record: &Value) -> String {
105        record
106            .get("key")
107            .and_then(|v| v.as_str())
108            .map_or_else(|| "Feature Flag".to_owned(), |k| format!("Flag: {k}"))
109    }
110
111    fn list(
112        &self,
113        pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
114        params: ListParams,
115    ) -> AdminFuture<'_, ListResult> {
116        use diesel_async::RunQueryDsl;
117
118        let pool = pool.clone();
119        Box::pin(async move {
120            let mut conn = pool
121                .get()
122                .await
123                .map_err(|e| AdminError::Database(e.to_string()))?;
124
125            let per_page = params.per_page;
126            let offset = if per_page == 0 {
127                0
128            } else {
129                params.page.saturating_sub(1) * per_page
130            };
131            let limit = if per_page == 0 {
132                i64::MAX
133            } else {
134                i64::try_from(per_page).unwrap_or(i64::MAX)
135            };
136
137            // Parameterized search — `%` alone matches everything (no search case).
138            let search_pattern = format!("%{}%", params.search.as_deref().unwrap_or(""));
139
140            let total: i64 = diesel::sql_query(
141                "SELECT COUNT(*) FROM autumn_feature_flags \
142                 WHERE (key ILIKE $1 OR COALESCE(description,'') ILIKE $1)",
143            )
144            .bind::<diesel::sql_types::Text, _>(&search_pattern)
145            .get_result::<CountRow>(&mut conn)
146            .await
147            .map_or(0, |r| r.count);
148
149            let records: Vec<Value> = diesel::sql_query(
150                "SELECT id, key, description, enabled, rollout_pct, \
151                        actor_allowlist, group_allowlist, updated_at \
152                 FROM autumn_feature_flags \
153                 WHERE (key ILIKE $1 OR COALESCE(description,'') ILIKE $1) \
154                 ORDER BY key \
155                 LIMIT $2 OFFSET $3",
156            )
157            .bind::<diesel::sql_types::Text, _>(&search_pattern)
158            .bind::<diesel::sql_types::BigInt, _>(limit)
159            .bind::<diesel::sql_types::BigInt, _>(i64::try_from(offset).unwrap_or(0))
160            .load::<FlagRow>(&mut conn)
161            .await
162            .map(|rows| rows.into_iter().map(FlagRow::into_json).collect())
163            .map_err(|e| AdminError::Database(e.to_string()))?;
164
165            Ok(ListResult {
166                total: u64::try_from(total).unwrap_or(0),
167                page: params.page,
168                per_page,
169                records,
170            })
171        })
172    }
173
174    fn get(
175        &self,
176        pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
177        id: i64,
178    ) -> AdminFuture<'_, Option<Value>> {
179        use diesel::prelude::*;
180        use diesel_async::RunQueryDsl;
181
182        let pool = pool.clone();
183        Box::pin(async move {
184            let mut conn = pool
185                .get()
186                .await
187                .map_err(|e| AdminError::Database(e.to_string()))?;
188
189            diesel::sql_query(
190                "SELECT id, key, description, enabled, rollout_pct, \
191                        actor_allowlist, group_allowlist, updated_at \
192                 FROM autumn_feature_flags WHERE id = $1",
193            )
194            .bind::<diesel::sql_types::BigInt, _>(id)
195            .get_result::<FlagRow>(&mut conn)
196            .await
197            .optional()
198            .map(|r| r.map(FlagRow::into_json))
199            .map_err(|e| AdminError::Database(e.to_string()))
200        })
201    }
202
203    fn create(
204        &self,
205        pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
206        data: Value,
207    ) -> AdminFuture<'_, Value> {
208        use diesel_async::RunQueryDsl;
209
210        let pool = pool.clone();
211        Box::pin(async move {
212            let mut conn = pool
213                .get()
214                .await
215                .map_err(|e| AdminError::Database(e.to_string()))?;
216
217            let key = data
218                .get("key")
219                .and_then(Value::as_str)
220                .ok_or_else(|| AdminError::Validation("'key' is required".into()))?;
221            let enabled = data
222                .get("enabled")
223                .and_then(Value::as_bool)
224                .unwrap_or(false);
225            // Select widget sends strings ("25"), direct API sends numbers.
226            let mut rollout_pct = data
227                .get("rollout_pct")
228                .and_then(|v| {
229                    v.as_i64()
230                        .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
231                })
232                .unwrap_or(0)
233                .clamp(0, 100);
234            let description = data.get("description").and_then(Value::as_str);
235            let actor_allowlist = validate_string_array(
236                data.get("actor_allowlist")
237                    .and_then(Value::as_str)
238                    .unwrap_or("[]"),
239                "actor_allowlist",
240            )?;
241            let group_allowlist = validate_string_array(
242                data.get("group_allowlist")
243                    .and_then(Value::as_str)
244                    .unwrap_or("[]"),
245                "group_allowlist",
246            )?;
247
248            // "Globally Enabled" with empty allowlists means globally on for all
249            // actors — promote rollout_pct to 100 so the evaluator agrees.
250            // When non-empty allowlists are provided, don't promote: the intent
251            // is allowlist-only access, not global rollout.
252            let has_allowlist = actor_allowlist != "[]" || group_allowlist != "[]";
253            if enabled && rollout_pct == 0 && !has_allowlist {
254                rollout_pct = 100;
255            }
256
257            let mutation = if enabled { "enabled" } else { "disabled" };
258
259            // A CTE combines the INSERT and audit-log write into one atomic
260            // statement.  Using a plain INSERT (no ON CONFLICT) means a duplicate
261            // key rejects with a validation error rather than silently overwriting
262            // a live flag via the admin "new record" form.
263            let row = diesel::sql_query(
264                "WITH inserted AS ( \
265                     INSERT INTO autumn_feature_flags \
266                         (key, description, enabled, rollout_pct, \
267                          actor_allowlist, group_allowlist) \
268                     VALUES ($1, $2, $3, $4, $5, $6) \
269                     RETURNING id, key, description, enabled, rollout_pct, \
270                               actor_allowlist, group_allowlist, updated_at \
271                 ), \
272                 _audit AS ( \
273                     INSERT INTO feature_flag_changes (key, mutation, actor) \
274                     SELECT key, $7, NULL FROM inserted \
275                 ) \
276                 SELECT id, key, description, enabled, rollout_pct, \
277                        actor_allowlist, group_allowlist, updated_at \
278                 FROM inserted",
279            )
280            .bind::<diesel::sql_types::Text, _>(key)
281            .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
282                description.map(str::to_owned),
283            )
284            .bind::<diesel::sql_types::Bool, _>(enabled)
285            .bind::<diesel::sql_types::SmallInt, _>(i16::try_from(rollout_pct).unwrap_or(0))
286            .bind::<diesel::sql_types::Text, _>(actor_allowlist)
287            .bind::<diesel::sql_types::Text, _>(group_allowlist)
288            .bind::<diesel::sql_types::Text, _>(mutation)
289            .get_result::<FlagRow>(&mut conn)
290            .await
291            .map_err(|e| {
292                if matches!(
293                    e,
294                    diesel::result::Error::DatabaseError(
295                        diesel::result::DatabaseErrorKind::UniqueViolation,
296                        _
297                    )
298                ) {
299                    AdminError::Validation(format!("a flag with key '{key}' already exists"))
300                } else {
301                    AdminError::Database(e.to_string())
302                }
303            })?;
304
305            Ok(FlagRow::into_json(row))
306        })
307    }
308
309    fn update(
310        &self,
311        pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
312        id: i64,
313        data: Value,
314    ) -> AdminFuture<'_, Value> {
315        use diesel_async::RunQueryDsl;
316
317        let pool = pool.clone();
318        Box::pin(async move {
319            let mut conn = pool
320                .get()
321                .await
322                .map_err(|e| AdminError::Database(e.to_string()))?;
323
324            let key = data
325                .get("key")
326                .and_then(Value::as_str)
327                .ok_or_else(|| AdminError::Validation("'key' is required".into()))?;
328            let enabled = data
329                .get("enabled")
330                .and_then(Value::as_bool)
331                .unwrap_or(false);
332            let mut rollout_pct = data
333                .get("rollout_pct")
334                .and_then(|v| {
335                    v.as_i64()
336                        .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
337                })
338                .unwrap_or(0)
339                .clamp(0, 100);
340            let description = data.get("description").and_then(Value::as_str);
341            let actor_allowlist = validate_string_array(
342                data.get("actor_allowlist")
343                    .and_then(Value::as_str)
344                    .unwrap_or("[]"),
345                "actor_allowlist",
346            )?;
347            let group_allowlist = validate_string_array(
348                data.get("group_allowlist")
349                    .and_then(Value::as_str)
350                    .unwrap_or("[]"),
351                "group_allowlist",
352            )?;
353            let has_allowlist = actor_allowlist != "[]" || group_allowlist != "[]";
354            if enabled && rollout_pct == 0 && !has_allowlist {
355                rollout_pct = 100;
356            }
357
358            let mutation = if enabled { "enabled" } else { "disabled" };
359
360            // A CTE combines the key lookup, UPDATE, and both audit-log writes
361            // into one atomic statement.  'old_row' reads the pre-update key so
362            // a rename emits a 'deleted' invalidation for the old name;
363            // '_audit_rename' is a no-op when the key is unchanged.
364            let row = diesel::sql_query(
365                "WITH old_row AS ( \
366                     SELECT key FROM autumn_feature_flags WHERE id = $1 \
367                 ), \
368                 updated AS ( \
369                     UPDATE autumn_feature_flags \
370                     SET key = $2, description = $3, enabled = $4, rollout_pct = $5, \
371                         actor_allowlist = $6, group_allowlist = $7, updated_at = NOW() \
372                     WHERE id = $1 \
373                     RETURNING id, key, description, enabled, rollout_pct, \
374                               actor_allowlist, group_allowlist, updated_at \
375                 ), \
376                 _audit_rename AS ( \
377                     INSERT INTO feature_flag_changes (key, mutation, actor) \
378                     SELECT old_row.key, 'deleted', NULL \
379                     FROM old_row \
380                     WHERE old_row.key != $2 \
381                 ), \
382                 _audit_rename_breadcrumb AS ( \
383                     INSERT INTO feature_flag_changes (key, mutation, actor) \
384                     SELECT $2, 'renamed_from=' || old_row.key, NULL \
385                     FROM old_row \
386                     WHERE old_row.key != $2 \
387                 ), \
388                 _audit AS ( \
389                     INSERT INTO feature_flag_changes (key, mutation, actor) \
390                     SELECT key, $8, NULL FROM updated \
391                 ) \
392                 SELECT id, key, description, enabled, rollout_pct, \
393                        actor_allowlist, group_allowlist, updated_at \
394                 FROM updated",
395            )
396            .bind::<diesel::sql_types::BigInt, _>(id)
397            .bind::<diesel::sql_types::Text, _>(key)
398            .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
399                description.map(str::to_owned),
400            )
401            .bind::<diesel::sql_types::Bool, _>(enabled)
402            .bind::<diesel::sql_types::SmallInt, _>(i16::try_from(rollout_pct).unwrap_or(0))
403            .bind::<diesel::sql_types::Text, _>(actor_allowlist)
404            .bind::<diesel::sql_types::Text, _>(group_allowlist)
405            .bind::<diesel::sql_types::Text, _>(mutation)
406            .get_result::<FlagRow>(&mut conn)
407            .await
408            .map_err(|e| AdminError::Database(e.to_string()))?;
409
410            Ok(FlagRow::into_json(row))
411        })
412    }
413
414    fn delete(
415        &self,
416        pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
417        id: i64,
418    ) -> AdminFuture<'_, ()> {
419        use diesel_async::RunQueryDsl;
420
421        let pool = pool.clone();
422        Box::pin(async move {
423            let mut conn = pool
424                .get()
425                .await
426                .map_err(|e| AdminError::Database(e.to_string()))?;
427
428            // A CTE combines the DELETE and audit-log write into one atomic
429            // statement so that cache invalidation always fires together with
430            // the row removal.
431            diesel::sql_query(
432                "WITH deleted AS ( \
433                     DELETE FROM autumn_feature_flags WHERE id = $1 RETURNING key \
434                 ), \
435                 _audit AS ( \
436                     INSERT INTO feature_flag_changes (key, mutation, actor) \
437                     SELECT key, 'deleted', NULL FROM deleted \
438                 ) \
439                 SELECT COUNT(*) AS count FROM deleted",
440            )
441            .bind::<diesel::sql_types::BigInt, _>(id)
442            .get_result::<CountRow>(&mut conn)
443            .await
444            .map_err(|e| AdminError::Database(e.to_string()))?;
445
446            Ok(())
447        })
448    }
449
450    fn has_history(&self) -> bool {
451        true
452    }
453
454    fn get_history<'a>(
455        &'a self,
456        pool: &'a diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
457        record_id: i64,
458        page: u64,
459        per_page: u64,
460    ) -> crate::AdminFuture<'a, crate::AdminHistoryPage> {
461        use diesel::prelude::*;
462        use diesel_async::RunQueryDsl;
463
464        let pool = pool.clone();
465        Box::pin(async move {
466            let mut conn = pool
467                .get()
468                .await
469                .map_err(|e| AdminError::Database(e.to_string()))?;
470
471            // Resolve the flag key by its stable integer id.
472            let key: Option<String> =
473                diesel::sql_query("SELECT key FROM autumn_feature_flags WHERE id = $1")
474                    .bind::<diesel::sql_types::BigInt, _>(record_id)
475                    .get_result::<KeyRow>(&mut conn)
476                    .await
477                    .optional()
478                    .unwrap_or(None)
479                    .map(|r| r.key);
480
481            let Some(key) = key else {
482                return Ok(crate::AdminHistoryPage {
483                    entries: vec![],
484                    total: 0,
485                    page,
486                    per_page,
487                });
488            };
489
490            // Follow the full rename ancestry, not just one level.  Each rename
491            // writes (new_key, 'renamed_from=old_key') into feature_flag_changes;
492            // the recursive CTE walks those breadcrumbs until no more predecessors
493            // are found, so an a→b→c chain shows all three keys' histories.
494            let ancestor_cte = "WITH RECURSIVE ancestors AS ( \
495                SELECT $1::text AS key \
496                UNION \
497                SELECT regexp_replace(ffc.mutation, '^renamed_from=', '') \
498                FROM feature_flag_changes ffc \
499                JOIN ancestors a ON ffc.key = a.key \
500                WHERE ffc.mutation LIKE 'renamed_from=%' \
501            )";
502
503            let count: i64 = diesel::sql_query(format!(
504                "{ancestor_cte} \
505                 SELECT COUNT(*) FROM feature_flag_changes \
506                 WHERE key IN (SELECT key FROM ancestors)",
507            ))
508            .bind::<diesel::sql_types::Text, _>(&key)
509            .get_result::<CountRow>(&mut conn)
510            .await
511            .map_or(0, |r| r.count);
512
513            let offset = (page.saturating_sub(1)) * per_page;
514            let entries: Vec<crate::AdminHistoryEntry> = diesel::sql_query(format!(
515                "{ancestor_cte} \
516                 SELECT id, mutation AS op, actor, changed_at \
517                 FROM feature_flag_changes \
518                 WHERE key IN (SELECT key FROM ancestors) \
519                 ORDER BY changed_at DESC \
520                 LIMIT $2 OFFSET $3",
521            ))
522            .bind::<diesel::sql_types::Text, _>(&key)
523            .bind::<diesel::sql_types::BigInt, _>(i64::try_from(per_page).unwrap_or(i64::MAX))
524            .bind::<diesel::sql_types::BigInt, _>(i64::try_from(offset).unwrap_or(0))
525            .load::<HistoryRow>(&mut conn)
526            .await
527            .unwrap_or_default()
528            .into_iter()
529            .map(|r| crate::AdminHistoryEntry {
530                id: r.id,
531                actor: r.actor.unwrap_or_else(|| "cli".to_owned()),
532                op: r.op,
533                request_id: None,
534                changes: vec![],
535                recorded_at: r.changed_at,
536            })
537            .collect();
538
539            Ok(crate::AdminHistoryPage {
540                entries,
541                total: u64::try_from(count).unwrap_or(0),
542                page,
543                per_page,
544            })
545        })
546    }
547}
548
549// ── Helpers ───────────────────────────────────────────────────────────────────
550
551/// Parse `raw` as a JSON array of strings and re-serialise to canonical form.
552///
553/// An empty string is treated as `[]`.  A trailing comma (`["a",]`), a
554/// non-array value, or mixed element types are all rejected with a validation
555/// error so bad data never reaches the database column that later casts to
556/// `::jsonb`.
557fn validate_string_array(raw: &str, field: &str) -> Result<String, AdminError> {
558    let trimmed = raw.trim();
559    if trimmed.is_empty() {
560        return Ok("[]".to_owned());
561    }
562    match serde_json::from_str::<Vec<serde_json::Value>>(trimmed) {
563        Ok(arr) if arr.iter().all(serde_json::Value::is_string) => {
564            Ok(serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_owned()))
565        }
566        Ok(_) => Err(AdminError::Validation(format!(
567            "'{field}' must be a JSON array of strings (e.g. [\"user:42\"])"
568        ))),
569        Err(_) => Err(AdminError::Validation(format!(
570            "'{field}' must be valid JSON (e.g. [\"user:42\"]); check for trailing commas"
571        ))),
572    }
573}
574
575// ── Row types ─────────────────────────────────────────────────────────────────
576
577#[derive(diesel::QueryableByName)]
578struct CountRow {
579    #[diesel(sql_type = diesel::sql_types::BigInt)]
580    count: i64,
581}
582
583#[derive(diesel::QueryableByName)]
584struct KeyRow {
585    #[diesel(sql_type = diesel::sql_types::Text)]
586    key: String,
587}
588
589#[derive(diesel::QueryableByName)]
590struct FlagRow {
591    #[diesel(sql_type = diesel::sql_types::BigInt)]
592    id: i64,
593    #[diesel(sql_type = diesel::sql_types::Text)]
594    key: String,
595    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
596    description: Option<String>,
597    #[diesel(sql_type = diesel::sql_types::Bool)]
598    enabled: bool,
599    #[diesel(sql_type = diesel::sql_types::SmallInt)]
600    rollout_pct: i16,
601    #[diesel(sql_type = diesel::sql_types::Text)]
602    actor_allowlist: String,
603    #[diesel(sql_type = diesel::sql_types::Text)]
604    group_allowlist: String,
605    #[diesel(sql_type = diesel::sql_types::Timestamptz)]
606    updated_at: chrono::DateTime<chrono::Utc>,
607}
608
609impl FlagRow {
610    fn into_json(self) -> Value {
611        serde_json::json!({
612            "id": self.id,
613            "key": self.key,
614            "description": self.description,
615            "enabled": self.enabled,
616            "rollout_pct": self.rollout_pct,
617            "actor_allowlist": self.actor_allowlist,
618            "group_allowlist": self.group_allowlist,
619            "updated_at": self.updated_at.to_rfc3339(),
620        })
621    }
622}
623
624#[derive(diesel::QueryableByName)]
625struct HistoryRow {
626    #[diesel(sql_type = diesel::sql_types::BigInt)]
627    id: i64,
628    #[diesel(sql_type = diesel::sql_types::Text)]
629    op: String,
630    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
631    actor: Option<String>,
632    #[diesel(sql_type = diesel::sql_types::Timestamptz)]
633    changed_at: chrono::DateTime<chrono::Utc>,
634}
635
636// ── Tests ─────────────────────────────────────────────────────────────────────
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    #[test]
643    fn feature_flag_admin_model_slug_is_feature_flags() {
644        let model = FeatureFlagAdminModel;
645        assert_eq!(model.slug(), "feature-flags");
646    }
647
648    #[test]
649    fn feature_flag_admin_model_has_correct_display_names() {
650        let model = FeatureFlagAdminModel;
651        assert_eq!(model.display_name(), "Feature Flag");
652        assert_eq!(model.display_name_plural(), "Feature Flags");
653    }
654
655    #[test]
656    fn feature_flag_admin_fields_include_required_columns() {
657        let model = FeatureFlagAdminModel;
658        let fields = model.fields();
659        let names: Vec<&str> = fields.iter().map(|f| f.name).collect();
660        assert!(names.contains(&"key"), "must have key field");
661        assert!(names.contains(&"enabled"), "must have enabled field");
662        assert!(
663            names.contains(&"rollout_pct"),
664            "must have rollout_pct field"
665        );
666        assert!(
667            names.contains(&"actor_allowlist"),
668            "must have actor_allowlist field"
669        );
670    }
671
672    #[test]
673    fn feature_flag_admin_model_has_history() {
674        let model = FeatureFlagAdminModel;
675        assert!(
676            model.has_history(),
677            "feature flag admin must expose history"
678        );
679    }
680
681    #[test]
682    fn record_display_uses_flag_key() {
683        let model = FeatureFlagAdminModel;
684        let record = serde_json::json!({"key": "beta_inbox", "enabled": false});
685        assert_eq!(model.record_display(&record), "Flag: beta_inbox");
686    }
687
688    #[test]
689    fn record_display_fallback_when_no_key() {
690        let model = FeatureFlagAdminModel;
691        let record = serde_json::json!({});
692        assert_eq!(model.record_display(&record), "Feature Flag");
693    }
694
695    #[test]
696    fn globally_enabled_with_zero_rollout_promotes_to_100() {
697        // When the admin checks "Globally Enabled" but leaves Rollout % at the
698        // default 0%, the saved rollout_pct must be 100 so the evaluator
699        // (which requires rollout_pct >= 100 for global access) works correctly.
700        //
701        // This is a pure logic test — it doesn't hit the DB; it just verifies
702        // that the promotion happens before the SQL bind.
703        let enabled = true;
704        let submitted_rollout: i64 = 0;
705        let mut rollout_pct = submitted_rollout.clamp(0, 100);
706        if enabled && rollout_pct == 0 {
707            rollout_pct = 100;
708        }
709        assert_eq!(
710            rollout_pct, 100,
711            "enabled=true + rollout=0 must be promoted to rollout=100"
712        );
713    }
714
715    #[test]
716    fn globally_enabled_with_explicit_rollout_is_preserved() {
717        // If the admin explicitly sets 25% rollout AND checks "Globally Enabled",
718        // the rollout should stay at 25 (not promoted to 100).
719        let enabled = true;
720        let submitted_rollout: i64 = 25;
721        let mut rollout_pct = submitted_rollout.clamp(0, 100);
722        if enabled && rollout_pct == 0 {
723            rollout_pct = 100;
724        }
725        assert_eq!(
726            rollout_pct, 25,
727            "enabled=true + explicit rollout=25 must be preserved"
728        );
729    }
730
731    #[test]
732    fn disabled_with_zero_rollout_is_not_promoted() {
733        // Kill-switch (enabled=false) with rollout=0 must stay at 0.
734        let enabled = false;
735        let submitted_rollout: i64 = 0;
736        let mut rollout_pct = submitted_rollout.clamp(0, 100);
737        let has_allowlist = false;
738        if enabled && rollout_pct == 0 && !has_allowlist {
739            rollout_pct = 100;
740        }
741        assert_eq!(rollout_pct, 0, "kill-switch must not promote rollout_pct");
742    }
743
744    #[test]
745    fn enabled_with_zero_rollout_and_non_empty_allowlist_is_not_promoted() {
746        // When the admin creates an allowlist-only flag (enabled=true, rollout=0%,
747        // actor_allowlist non-empty), rollout_pct must NOT be promoted to 100 —
748        // that would expose the flag to everyone instead of just listed actors.
749        let enabled = true;
750        let submitted_rollout: i64 = 0;
751        let actor_allowlist = r#"["user:42"]"#;
752        let group_allowlist = "[]";
753        let mut rollout_pct = submitted_rollout.clamp(0, 100);
754        let has_allowlist = actor_allowlist != "[]" || group_allowlist != "[]";
755        if enabled && rollout_pct == 0 && !has_allowlist {
756            rollout_pct = 100;
757        }
758        assert_eq!(
759            rollout_pct, 0,
760            "allowlist-only flag must not have rollout_pct promoted to 100"
761        );
762    }
763
764    #[test]
765    fn validate_string_array_accepts_valid_array() {
766        let result = validate_string_array(r#"["user:1","user:2"]"#, "actor_allowlist");
767        assert!(result.is_ok(), "valid array must be accepted: {result:?}");
768    }
769
770    #[test]
771    fn validate_string_array_accepts_empty_string_as_empty_array() {
772        let result = validate_string_array("", "actor_allowlist");
773        assert_eq!(result.unwrap(), "[]");
774    }
775
776    #[test]
777    fn validate_string_array_rejects_trailing_comma() {
778        let result = validate_string_array(r#"["user:42",]"#, "actor_allowlist");
779        assert!(result.is_err(), "trailing comma must be rejected");
780    }
781
782    #[test]
783    fn validate_string_array_rejects_non_array() {
784        let result = validate_string_array("user:42", "actor_allowlist");
785        assert!(result.is_err(), "bare string must be rejected");
786    }
787
788    #[test]
789    fn validate_string_array_rejects_array_with_non_string_elements() {
790        let result = validate_string_array("[1, 2, 3]", "actor_allowlist");
791        assert!(result.is_err(), "integer elements must be rejected");
792    }
793
794    #[test]
795    fn validate_string_array_normalises_output() {
796        // Re-serialisation removes extra whitespace and produces canonical JSON.
797        let result = validate_string_array(r#"[ "user:1" ,  "user:2" ]"#, "actor_allowlist");
798        assert_eq!(result.unwrap(), r#"["user:1","user:2"]"#);
799    }
800}