Skip to main content

autumn_admin_plugin/
tokens.rs

1//! Admin panel model for scoped API tokens (`api_tokens`).
2//!
3//! Registers a management page at `/admin/api-tokens/` to create, list, and
4//! revoke scoped service tokens (issue #1158). The raw token is shown **once**
5//! at creation and is never stored or re-displayed; listing exposes only
6//! non-secret metadata (name, principal, scopes, expiry, last-used, revoked).
7
8use autumn_web::auth::{generate_raw_token, hash_api_token, scopes_from_json};
9use chrono::{DateTime, NaiveDateTime, Utc};
10use serde_json::Value;
11
12use crate::{
13    AdminError, AdminField, AdminFieldKind, AdminFuture, AdminModel, ListParams, ListResult,
14};
15
16/// Admin panel model for scoped API tokens.
17///
18/// # Postgres only
19///
20/// This model reads and writes `api_tokens`. That table is Postgres-only.
21/// Its SQL uses `ILIKE`, `::type` casts and writable CTEs, which `SQLite` does
22/// not have. On `SQLite` every method refuses with an error that names this
23/// model. The plugin core is backend-agnostic: register your own
24/// [`AdminModel`](crate::AdminModel)s there instead. See the crate README.
25///
26/// Register with the admin plugin to get a token management UI at
27/// `/admin/api-tokens/`:
28///
29/// ```rust,ignore
30/// use autumn_admin_plugin::{AdminPlugin, tokens::TokenAdminModel};
31///
32/// autumn_web::app()
33///     .plugin(AdminPlugin::new().register(TokenAdminModel::default()))
34///     .run()
35///     .await;
36/// ```
37///
38/// Requires the managed `api_tokens` table (run `autumn migrate`).
39#[derive(Debug, Default, Clone)]
40pub struct TokenAdminModel;
41
42impl AdminModel for TokenAdminModel {
43    fn slug(&self) -> &'static str {
44        "api-tokens"
45    }
46
47    fn display_name(&self) -> &'static str {
48        "API Token"
49    }
50
51    fn display_name_plural(&self) -> &'static str {
52        "API Tokens"
53    }
54
55    fn fields(&self) -> Vec<AdminField> {
56        vec![
57            AdminField::new("name", AdminFieldKind::Text)
58                .label("Name")
59                .searchable(),
60            AdminField::new("principal_id", AdminFieldKind::Text)
61                .label("Principal")
62                .searchable()
63                .create_only(),
64            AdminField::new("scopes", AdminFieldKind::TextArea)
65                .label("Scopes (JSON array)")
66                .optional(),
67            AdminField::new("expires_at", AdminFieldKind::DateTime)
68                .label("Expires At (UTC)")
69                .optional()
70                .create_only(),
71            AdminField::new("last_used_at", AdminFieldKind::DateTime)
72                .label("Last Used")
73                .readonly()
74                .optional()
75                .hide_from_list(),
76            AdminField::new("revoked_at", AdminFieldKind::DateTime)
77                .label("Revoked At")
78                .readonly()
79                .optional(),
80            // Only populated in the response from `create` — the raw token,
81            // surfaced once at issuance and never stored or re-displayed.
82            AdminField::new("token", AdminFieldKind::Text)
83                .label("Token (copy now — shown once)")
84                .readonly()
85                .optional()
86                .hide_from_list(),
87        ]
88    }
89
90    fn record_display(&self, record: &Value) -> String {
91        record
92            .get("name")
93            .and_then(Value::as_str)
94            .filter(|n| !n.is_empty())
95            .map_or_else(|| "API Token".to_owned(), |n| format!("Token: {n}"))
96    }
97
98    fn list(
99        &self,
100        pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
101        params: ListParams,
102    ) -> AdminFuture<'_, ListResult> {
103        use diesel_async::RunQueryDsl;
104
105        let pool = pool.clone();
106        Box::pin(async move {
107            crate::traits::require_postgres("TokenAdminModel")?;
108            let mut conn = pool
109                .get()
110                .await
111                .map_err(|e| AdminError::Database(e.to_string()))?;
112
113            let per_page = params.per_page;
114            let (offset, limit) = params.sql_offset_limit();
115            let search_pattern = format!("%{}%", params.search.as_deref().unwrap_or(""));
116
117            let total: i64 = diesel::sql_query(
118                "SELECT COUNT(*) FROM api_tokens \
119                 WHERE (name ILIKE $1 OR principal_id ILIKE $1)",
120            )
121            .bind::<diesel::sql_types::Text, _>(&search_pattern)
122            .get_result::<CountRow>(&mut conn)
123            .await
124            .map_or(0, |r| r.count);
125
126            let records: Vec<Value> = diesel::sql_query(
127                "SELECT id, name, principal_id, scopes::text AS scopes, created_at, \
128                        expires_at, last_used_at, revoked_at \
129                 FROM api_tokens \
130                 WHERE (name ILIKE $1 OR principal_id ILIKE $1) \
131                 ORDER BY id DESC \
132                 LIMIT $2 OFFSET $3",
133            )
134            .bind::<diesel::sql_types::Text, _>(&search_pattern)
135            .bind::<diesel::sql_types::BigInt, _>(limit)
136            .bind::<diesel::sql_types::BigInt, _>(offset)
137            .load::<TokenRow>(&mut conn)
138            .await
139            .map(|rows| rows.into_iter().map(TokenRow::into_json).collect())
140            .map_err(|e| AdminError::Database(e.to_string()))?;
141
142            Ok(ListResult {
143                total: u64::try_from(total).unwrap_or(0),
144                page: params.page,
145                per_page,
146                records,
147            })
148        })
149    }
150
151    fn get(
152        &self,
153        pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
154        id: i64,
155    ) -> AdminFuture<'_, Option<Value>> {
156        use diesel::OptionalExtension as _;
157        use diesel_async::RunQueryDsl;
158
159        let pool = pool.clone();
160        Box::pin(async move {
161            crate::traits::require_postgres("TokenAdminModel")?;
162            let mut conn = pool
163                .get()
164                .await
165                .map_err(|e| AdminError::Database(e.to_string()))?;
166            diesel::sql_query(
167                "SELECT id, name, principal_id, scopes::text AS scopes, created_at, \
168                        expires_at, last_used_at, revoked_at \
169                 FROM api_tokens WHERE id = $1",
170            )
171            .bind::<diesel::sql_types::BigInt, _>(id)
172            .get_result::<TokenRow>(&mut conn)
173            .await
174            .optional()
175            .map(|r| r.map(TokenRow::into_json))
176            .map_err(|e| AdminError::Database(e.to_string()))
177        })
178    }
179
180    fn create(
181        &self,
182        pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
183        data: Value,
184    ) -> AdminFuture<'_, Value> {
185        use diesel_async::RunQueryDsl;
186
187        let pool = pool.clone();
188        Box::pin(async move {
189            crate::traits::require_postgres("TokenAdminModel")?;
190            let principal_id = data
191                .get("principal_id")
192                .and_then(Value::as_str)
193                .filter(|p| !p.is_empty())
194                .ok_or_else(|| AdminError::Validation("'principal_id' is required".into()))?;
195            let name = data.get("name").and_then(Value::as_str).unwrap_or("");
196            let scopes = parse_scopes(data.get("scopes"))?;
197            let expires_at = parse_expires_at(data.get("expires_at"))?;
198
199            let raw = generate_raw_token();
200            let hash = hash_api_token(&raw);
201            let scopes_json = serde_json::Value::Array(
202                scopes
203                    .iter()
204                    .map(|s| serde_json::Value::String(s.clone()))
205                    .collect(),
206            )
207            .to_string();
208            let expires_at_naive = expires_at.map(|dt| dt.naive_utc());
209
210            let mut conn = pool
211                .get()
212                .await
213                .map_err(|e| AdminError::Database(e.to_string()))?;
214            // Atomic INSERT RETURNING: the row's server-assigned columns (id,
215            // created_at, …) come back in the same round-trip as the INSERT so
216            // there is no window in which the token exists in the DB but its
217            // metadata has not been returned to the caller.
218            let row: TokenRow = diesel::sql_query(
219                "INSERT INTO api_tokens (token_hash, principal_id, name, scopes, expires_at) \
220                 VALUES ($1, $2, $3, $4::jsonb, $5) \
221                 RETURNING id, name, principal_id, scopes::text AS scopes, \
222                           created_at, expires_at, last_used_at, revoked_at",
223            )
224            .bind::<diesel::sql_types::Text, _>(&hash)
225            .bind::<diesel::sql_types::Text, _>(principal_id)
226            .bind::<diesel::sql_types::Text, _>(name)
227            .bind::<diesel::sql_types::Text, _>(&scopes_json)
228            .bind::<diesel::sql_types::Nullable<diesel::sql_types::Timestamp>, _>(expires_at_naive)
229            .get_result::<TokenRow>(&mut conn)
230            .await
231            .map_err(|e| AdminError::Database(e.to_string()))?;
232
233            let mut json = row.into_json();
234            if let Value::Object(map) = &mut json {
235                map.insert("token".to_owned(), Value::String(raw));
236            }
237            Ok(json)
238        })
239    }
240
241    fn update(
242        &self,
243        pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
244        id: i64,
245        data: Value,
246    ) -> AdminFuture<'_, Value> {
247        use diesel_async::RunQueryDsl;
248
249        let pool = pool.clone();
250        Box::pin(async move {
251            crate::traits::require_postgres("TokenAdminModel")?;
252            // A token's secret/principal are immutable; only the human-readable
253            // name and granted scopes are editable after issuance.
254            let name = data.get("name").and_then(Value::as_str).unwrap_or("");
255            let scopes = parse_scopes(data.get("scopes"))?;
256            let scopes_json =
257                serde_json::Value::Array(scopes.into_iter().map(Value::String).collect())
258                    .to_string();
259
260            let mut conn = pool
261                .get()
262                .await
263                .map_err(|e| AdminError::Database(e.to_string()))?;
264            diesel::sql_query("UPDATE api_tokens SET name = $1, scopes = $2::jsonb WHERE id = $3")
265                .bind::<diesel::sql_types::Text, _>(name)
266                .bind::<diesel::sql_types::Text, _>(&scopes_json)
267                .bind::<diesel::sql_types::BigInt, _>(id)
268                .execute(&mut conn)
269                .await
270                .map_err(|e| AdminError::Database(e.to_string()))?;
271
272            self.get(&pool, id)
273                .await?
274                .ok_or_else(|| AdminError::Validation(format!("token {id} not found")))
275        })
276    }
277
278    fn delete(
279        &self,
280        pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
281        id: i64,
282    ) -> AdminFuture<'_, ()> {
283        use diesel_async::RunQueryDsl;
284
285        let pool = pool.clone();
286        Box::pin(async move {
287            crate::traits::require_postgres("TokenAdminModel")?;
288            // "Delete" a token means revoke it: keep the audit row, stop it
289            // authenticating. Idempotent — re-revoking is a no-op.
290            let mut conn = pool
291                .get()
292                .await
293                .map_err(|e| AdminError::Database(e.to_string()))?;
294            diesel::sql_query(
295                "UPDATE api_tokens SET revoked_at = NOW() AT TIME ZONE 'utc' \
296                 WHERE id = $1 AND revoked_at IS NULL",
297            )
298            .bind::<diesel::sql_types::BigInt, _>(id)
299            .execute(&mut conn)
300            .await
301            .map_err(|e| AdminError::Database(e.to_string()))?;
302            Ok(())
303        })
304    }
305
306    fn execute_action(
307        &self,
308        pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
309        action: &str,
310        ids: Vec<i64>,
311    ) -> AdminFuture<'_, u64> {
312        // `TokenAdminModel` never declares soft delete (`supports_soft_delete`
313        // is the trait default, `false`), so `actions()` (traits.rs) only
314        // ever offers `"delete"` — the admin UI can't reach `"restore"` or
315        // `"purge"` for this model. Only `"delete"` needs the batched fast
316        // path below; `"restore"`, `"purge"`, and any other action name fall
317        // through to the shared `dispatch_restore_purge_or_unhandled` helper
318        // (traits.rs), which a direct or out-of-band `execute_action` call
319        // still reaches for the same "unhandled action" (or
320        // soft-delete-unsupported) error it always got.
321        if action == "delete" {
322            let pool = pool.clone();
323            return Box::pin(async move {
324                // The batched form binds a Postgres array. SQLite has no array
325                // bind type. `backend_select!` keeps one arm and drops the
326                // other, so the array never reaches the SQLite type-checker
327                // (issue #2108).
328                //
329                // The SQLite arm keeps the crate compiling, and refuses.
330                // TokenAdminModel is Postgres-only, so there is no
331                // correct SQLite statement to fall back to.
332                ::autumn_web::backend_select! {
333                    pg => {{
334                        use diesel_async::RunQueryDsl;
335
336                        // Batch every id into ONE round trip instead of the trait
337                        // default's one-`UPDATE`-per-id loop (an operator selecting
338                        // hundreds of rows in the admin list and clicking "Delete
339                        // selected" otherwise costs hundreds of statements and pool
340                        // checkouts for what is, on the wire, one predicate). Same
341                        // idempotent semantics as `delete()`: an id that doesn't
342                        // exist, or is already revoked, is silently a no-op for that
343                        // id.
344                        //
345                        // The returned count matches the *ids submitted*, not rows
346                        // actually changed, exactly like the loop this replaces
347                        // (which incremented its counter once per id regardless of
348                        // whether that id's `UPDATE` matched a row) — a duplicate or
349                        // already-revoked id was, and still is, counted as "applied".
350                        let mut conn = pool
351                            .get()
352                            .await
353                            .map_err(|e| AdminError::Database(e.to_string()))?;
354                        diesel::sql_query(
355                            "UPDATE api_tokens SET revoked_at = NOW() AT TIME ZONE 'utc' \
356                             WHERE id = ANY($1) AND revoked_at IS NULL",
357                        )
358                        .bind::<diesel::sql_types::Array<diesel::sql_types::BigInt>, _>(&ids)
359                        .execute(&mut conn)
360                        .await
361                        .map_err(|e| AdminError::Database(e.to_string()))?;
362                        Ok(u64::try_from(ids.len()).unwrap_or(u64::MAX))
363                    }},
364                    sqlite => {{
365                        let _ = (&pool, &ids);
366                        crate::traits::require_postgres("TokenAdminModel").map(|()| 0)
367                    }},
368                }
369            });
370        }
371
372        crate::traits::dispatch_restore_purge_or_unhandled(self, pool, action, ids)
373    }
374}
375
376/// Parse the `scopes` form value (JSON-array text or a JSON array) into a flat
377/// list of scope strings.
378fn parse_scopes(value: Option<&Value>) -> Result<Vec<String>, AdminError> {
379    match value {
380        None | Some(Value::Null) => Ok(Vec::new()),
381        Some(v @ Value::Array(_)) => Ok(scopes_from_json(v)),
382        Some(Value::String(s)) => {
383            let trimmed = s.trim();
384            if trimmed.is_empty() {
385                return Ok(Vec::new());
386            }
387            let parsed: Vec<String> = serde_json::from_str(trimmed).map_err(|_| {
388                AdminError::Validation(
389                    "'scopes' must be a JSON array of strings, e.g. [\"posts:read\"]".into(),
390                )
391            })?;
392            Ok(parsed)
393        }
394        Some(_) => Err(AdminError::Validation(
395            "'scopes' must be a JSON array of strings".into(),
396        )),
397    }
398}
399
400/// Parse the optional `expires_at` form value into a UTC instant.
401fn parse_expires_at(value: Option<&Value>) -> Result<Option<DateTime<Utc>>, AdminError> {
402    let Some(s) = value.and_then(Value::as_str) else {
403        return Ok(None);
404    };
405    let s = s.trim();
406    if s.is_empty() {
407        return Ok(None);
408    }
409    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
410        return Ok(Some(dt.with_timezone(&Utc)));
411    }
412    for fmt in ["%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M:%S"] {
413        if let Ok(naive) = NaiveDateTime::parse_from_str(s, fmt) {
414            return Ok(Some(DateTime::from_naive_utc_and_offset(naive, Utc)));
415        }
416    }
417    Err(AdminError::Validation(format!(
418        "'expires_at' is not a valid timestamp: {s}"
419    )))
420}
421
422#[derive(diesel::QueryableByName)]
423struct CountRow {
424    #[diesel(sql_type = diesel::sql_types::BigInt)]
425    count: i64,
426}
427
428#[derive(diesel::QueryableByName)]
429struct TokenRow {
430    #[diesel(sql_type = diesel::sql_types::BigInt)]
431    id: i64,
432    #[diesel(sql_type = diesel::sql_types::Text)]
433    name: String,
434    #[diesel(sql_type = diesel::sql_types::Text)]
435    principal_id: String,
436    #[diesel(sql_type = diesel::sql_types::Text)]
437    scopes: String,
438    #[diesel(sql_type = diesel::sql_types::Timestamp)]
439    created_at: NaiveDateTime,
440    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Timestamp>)]
441    expires_at: Option<NaiveDateTime>,
442    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Timestamp>)]
443    last_used_at: Option<NaiveDateTime>,
444    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Timestamp>)]
445    revoked_at: Option<NaiveDateTime>,
446}
447
448impl TokenRow {
449    fn into_json(self) -> Value {
450        let scopes: Value = serde_json::from_str(&self.scopes).unwrap_or(Value::Array(Vec::new()));
451        serde_json::json!({
452            "id": self.id,
453            "name": self.name,
454            "principal_id": self.principal_id,
455            "scopes": scopes,
456            "created_at": self.created_at.and_utc().to_rfc3339(),
457            "expires_at": self.expires_at.map(|d| d.and_utc().to_rfc3339()),
458            "last_used_at": self.last_used_at.map(|d| d.and_utc().to_rfc3339()),
459            "revoked_at": self.revoked_at.map(|d| d.and_utc().to_rfc3339()),
460        })
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[test]
469    fn model_metadata() {
470        let m = TokenAdminModel;
471        assert_eq!(m.slug(), "api-tokens");
472        assert_eq!(m.display_name_plural(), "API Tokens");
473        let field_names: Vec<_> = m.fields().iter().map(|f| f.name).collect();
474        assert!(field_names.contains(&"scopes"));
475        assert!(field_names.contains(&"principal_id"));
476        // No secret column is ever a field except the one-time create token.
477        assert!(!field_names.contains(&"token_hash"));
478    }
479
480    #[test]
481    fn record_display_uses_name_when_present() {
482        let m = TokenAdminModel;
483        let with_name = serde_json::json!({"name": "ci-token"});
484        assert_eq!(m.record_display(&with_name), "Token: ci-token");
485    }
486
487    #[test]
488    fn record_display_falls_back_when_name_missing_or_empty() {
489        let m = TokenAdminModel;
490        assert_eq!(m.record_display(&serde_json::json!({})), "API Token");
491        assert_eq!(
492            m.record_display(&serde_json::json!({"name": ""})),
493            "API Token"
494        );
495    }
496
497    #[test]
498    fn parse_scopes_accepts_json_text_and_arrays() {
499        assert_eq!(parse_scopes(None).unwrap(), Vec::<String>::new());
500        // Explicit JSON null → empty (same as absent)
501        assert_eq!(
502            parse_scopes(Some(&Value::Null)).unwrap(),
503            Vec::<String>::new()
504        );
505        // Empty string → empty
506        assert_eq!(
507            parse_scopes(Some(&Value::String(String::new()))).unwrap(),
508            Vec::<String>::new()
509        );
510        assert_eq!(
511            parse_scopes(Some(&Value::String("[\"a\",\"b\"]".into()))).unwrap(),
512            vec!["a".to_owned(), "b".to_owned()]
513        );
514        assert_eq!(
515            parse_scopes(Some(&serde_json::json!(["x"]))).unwrap(),
516            vec!["x".to_owned()]
517        );
518        assert!(parse_scopes(Some(&Value::String("not json".into()))).is_err());
519        // Non-string, non-array, non-null → error
520        assert!(parse_scopes(Some(&Value::Bool(true))).is_err());
521    }
522
523    #[test]
524    fn parse_expires_at_handles_formats_and_empty() {
525        assert!(parse_expires_at(None).unwrap().is_none());
526        assert!(
527            parse_expires_at(Some(&Value::String(String::new())))
528                .unwrap()
529                .is_none()
530        );
531        // RFC3339 with timezone
532        assert!(
533            parse_expires_at(Some(&Value::String("2026-12-31T23:59:59Z".into())))
534                .unwrap()
535                .is_some()
536        );
537        // Without timezone (naive datetime format)
538        assert!(
539            parse_expires_at(Some(&Value::String("2026-12-31T23:59:59".into())))
540                .unwrap()
541                .is_some()
542        );
543        // Minute-precision format
544        assert!(
545            parse_expires_at(Some(&Value::String("2026-12-31T23:59".into())))
546                .unwrap()
547                .is_some()
548        );
549        assert!(parse_expires_at(Some(&Value::String("nonsense".into()))).is_err());
550    }
551
552    #[test]
553    fn token_row_into_json_omits_secret_and_parses_scopes() {
554        let row = TokenRow {
555            id: 7,
556            name: "ci".into(),
557            principal_id: "service:ci".into(),
558            scopes: "[\"posts:read\"]".into(),
559            created_at: NaiveDateTime::default(),
560            expires_at: None,
561            last_used_at: None,
562            revoked_at: None,
563        };
564        let json = row.into_json();
565        assert_eq!(json["id"], 7);
566        assert_eq!(json["scopes"], serde_json::json!(["posts:read"]));
567        assert!(json.get("token_hash").is_none());
568        assert!(json.get("token").is_none());
569    }
570
571    // ── execute_action fallthrough (restore/purge/unhandled) ──────────
572    //
573    // TokenAdminModel never supports soft delete, so these three branches
574    // always error on the first id without touching the pool —
575    // characterizing them pins the exact error text ahead of routing them
576    // through the shared trait helper (Echo merge with FeatureFlagAdminModel).
577
578    fn dummy_pool()
579    -> diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection> {
580        use diesel_async::pooled_connection::AsyncDieselConnectionManager;
581        use diesel_async::pooled_connection::deadpool::Pool;
582        let mgr = AsyncDieselConnectionManager::<::autumn_web::RuntimeConnection>::new(
583            "postgresql://test",
584        );
585        Pool::builder(mgr).build().expect("build pool")
586    }
587
588    #[tokio::test]
589    async fn execute_action_restore_errors_soft_delete_unsupported() {
590        let model = TokenAdminModel;
591        let pool = dummy_pool();
592        let err = model
593            .execute_action(&pool, "restore", vec![1, 2])
594            .await
595            .expect_err("restore must fail: model does not support soft delete");
596        assert!(
597            matches!(err, AdminError::Other(_)),
598            "must be AdminError::Other: {err:?}"
599        );
600        assert!(format!("{err:?}").contains("does not support soft delete"));
601    }
602
603    #[tokio::test]
604    async fn execute_action_purge_errors_soft_delete_unsupported() {
605        let model = TokenAdminModel;
606        let pool = dummy_pool();
607        let err = model
608            .execute_action(&pool, "purge", vec![1])
609            .await
610            .expect_err("purge must fail: model does not support soft delete");
611        assert!(
612            matches!(err, AdminError::Other(_)),
613            "must be AdminError::Other: {err:?}"
614        );
615        assert!(format!("{err:?}").contains("does not support soft delete"));
616    }
617
618    #[tokio::test]
619    async fn execute_action_unhandled_action_errors_with_action_name() {
620        let model = TokenAdminModel;
621        let pool = dummy_pool();
622        let err = model
623            .execute_action(&pool, "archive", vec![1])
624            .await
625            .expect_err("unknown action must error");
626        assert!(
627            matches!(err, AdminError::Other(_)),
628            "must be AdminError::Other: {err:?}"
629        );
630        assert!(format!("{err:?}").contains("unhandled bulk action 'archive'"));
631    }
632}