1use serde_json::Value;
9
10use crate::{
11 AdminError, AdminField, AdminFieldKind, AdminFuture, AdminModel, ListParams, ListResult,
12 SelectOption,
13};
14
15#[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 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 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 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 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 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 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 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 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
549fn 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#[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#[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 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 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 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 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 let result = validate_string_array(r#"[ "user:1" , "user:2" ]"#, "actor_allowlist");
798 assert_eq!(result.unwrap(), r#"["user:1","user:2"]"#);
799 }
800}