Skip to main content

rustio_admin/admin/
audit.rs

1//! Admin action log — every create / update / delete driven through
2//! the admin writes a row to `rustio_admin_actions`. The audit trail
3//! powers two user-visible surfaces:
4//!
5//! - `GET /admin/history` — project-wide timeline.
6//! - `GET /admin/<model>/<id>/history` — per-object history.
7//!
8//! ## Integrity
9//!
10//! [`record`] rejects entries that are missing any of `user_id`,
11//! `model_name`, or `object_id`. The caller gets an
12//! [`Error::Internal`] so the admin handler can fail loudly rather
13//! than silently losing the audit trail.
14
15use chrono::{DateTime, Utc};
16use sqlx::Row as _;
17
18use crate::error::{Error, Result};
19use crate::orm::Db;
20
21pub(crate) const CREATE_TABLE_SQL: &str = "CREATE TABLE IF NOT EXISTS rustio_admin_actions (
22    id          BIGSERIAL   PRIMARY KEY,
23    user_id     BIGINT      NOT NULL REFERENCES rustio_users(id) ON DELETE CASCADE,
24    action_type TEXT        NOT NULL,
25    model_name  TEXT        NOT NULL,
26    object_id   BIGINT      NOT NULL,
27    timestamp   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
28    ip_address  TEXT,
29    summary     TEXT        NOT NULL DEFAULT ''
30)";
31
32pub(crate) const CREATE_MODEL_INDEX_SQL: &str =
33    "CREATE INDEX IF NOT EXISTS rustio_admin_actions_model_idx \
34     ON rustio_admin_actions(model_name, object_id)";
35
36pub(crate) const CREATE_TIMESTAMP_INDEX_SQL: &str =
37    "CREATE INDEX IF NOT EXISTS rustio_admin_actions_timestamp_idx \
38     ON rustio_admin_actions(timestamp DESC)";
39
40/// Ensure the `rustio_admin_actions` table and its indexes exist.
41/// Idempotent. Depends on `rustio_users` existing first.
42///
43/// 0.4.0 lifecycle additions: `metadata` JSONB, `correlation_id`, and
44/// `session_id`. The framework will populate these as recovery flows
45/// land in R1+; existing audit rows from 0.3.x stay valid with NULLs.
46pub async fn ensure_table(db: &Db) -> Result<()> {
47    sqlx::query(CREATE_TABLE_SQL).execute(db.pool()).await?;
48    sqlx::query(CREATE_MODEL_INDEX_SQL)
49        .execute(db.pool())
50        .await?;
51    sqlx::query(CREATE_TIMESTAMP_INDEX_SQL)
52        .execute(db.pool())
53        .await?;
54
55    // R0 (0.4.0) lifecycle additions — additive, idempotent.
56    sqlx::query("ALTER TABLE rustio_admin_actions ADD COLUMN IF NOT EXISTS metadata JSONB")
57        .execute(db.pool())
58        .await?;
59    sqlx::query("ALTER TABLE rustio_admin_actions ADD COLUMN IF NOT EXISTS correlation_id TEXT")
60        .execute(db.pool())
61        .await?;
62    sqlx::query("ALTER TABLE rustio_admin_actions ADD COLUMN IF NOT EXISTS session_id BIGINT")
63        .execute(db.pool())
64        .await?;
65    sqlx::query(
66        "CREATE INDEX IF NOT EXISTS rustio_admin_actions_correlation_idx \
67         ON rustio_admin_actions (correlation_id) WHERE correlation_id IS NOT NULL",
68    )
69    .execute(db.pool())
70    .await?;
71    sqlx::query(
72        "CREATE INDEX IF NOT EXISTS rustio_admin_actions_session_idx \
73         ON rustio_admin_actions (session_id) WHERE session_id IS NOT NULL",
74    )
75    .execute(db.pool())
76    .await?;
77
78    Ok(())
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum ActionType {
83    Create,
84    Update,
85    Delete,
86}
87
88impl ActionType {
89    pub fn as_str(self) -> &'static str {
90        match self {
91            Self::Create => "create",
92            Self::Update => "update",
93            Self::Delete => "delete",
94        }
95    }
96
97    pub fn parse(s: &str) -> Option<Self> {
98        match s {
99            "create" => Some(Self::Create),
100            "update" => Some(Self::Update),
101            "delete" => Some(Self::Delete),
102            _ => None,
103        }
104    }
105
106    pub fn label(self) -> &'static str {
107        match self {
108            Self::Create => "Created",
109            Self::Update => "Updated",
110            Self::Delete => "Deleted",
111        }
112    }
113
114    pub fn pill_class(self) -> &'static str {
115        match self {
116            Self::Create => "badge-success",
117            Self::Update => "badge-neutral",
118            Self::Delete => "badge-danger",
119        }
120    }
121}
122
123#[derive(Debug, Clone)]
124pub struct AdminAction {
125    pub id: i64,
126    pub user_id: i64,
127    pub user_email: Option<String>,
128    pub action_type: String,
129    pub model_name: String,
130    pub object_id: i64,
131    pub timestamp: DateTime<Utc>,
132    pub ip_address: Option<String>,
133    pub summary: String,
134}
135
136pub struct LogEntry<'a> {
137    pub user_id: i64,
138    pub action_type: ActionType,
139    pub model_name: &'a str,
140    pub object_id: i64,
141    pub ip_address: Option<&'a str>,
142    pub summary: String,
143    /// Per-request UUID (R0). All audit rows written under one HTTP
144    /// request share this id so a future `/admin/history/<id>` page
145    /// can reconstruct the chain of events ("admin reset password →
146    /// all sessions revoked → security email dispatched").
147    pub correlation_id: Option<&'a str>,
148    /// The session that performed the action, when applicable. CLI
149    /// emergency actions write `None`.
150    pub session_id: Option<i64>,
151    /// Structured before/after / extra metadata. JSONB column.
152    pub metadata: Option<serde_json::Value>,
153    /// When `Some`, supersedes `action_type.as_str()` as the
154    /// persisted `rustio_admin_actions.action_type` string. Set via
155    /// [`LogEntry::with_event`]; the `action_type` field becomes a
156    /// placeholder in that case (the convention is to pass
157    /// `ActionType::Update`). Used by R1+ recovery / authority /
158    /// identity emissions that need the richer typed vocabulary —
159    /// see `DESIGN_AUDIT.md` §3 + `DESIGN_RECOVERY.md` §6.
160    pub event: Option<AuditEvent>,
161}
162
163impl<'a> LogEntry<'a> {
164    /// Builder helper for the common case (every field that R0
165    /// added defaults to `None`). Existing call sites can migrate
166    /// incrementally.
167    pub fn new(user_id: i64, action_type: ActionType, model_name: &'a str, object_id: i64) -> Self {
168        Self {
169            user_id,
170            action_type,
171            model_name,
172            object_id,
173            ip_address: None,
174            summary: String::new(),
175            correlation_id: None,
176            session_id: None,
177            metadata: None,
178            event: None,
179        }
180    }
181
182    /// Promote this entry's persisted `action_type` string from the
183    /// legacy [`ActionType`] (create/update/delete) trio to the
184    /// richer typed [`AuditEvent`]. The `action_type` field becomes
185    /// a placeholder; the convention is to pass `ActionType::Update`
186    /// to [`Self::new`] and chain `.with_event(...)`.
187    ///
188    /// ```ignore
189    /// let entry = LogEntry::new(user_id, ActionType::Update, "user", user_id)
190    ///     .with_event(AuditEvent::PasswordChangedSelf);
191    /// ```
192    ///
193    /// Use this for framework-internal authority + identity +
194    /// recovery audit rows per `DESIGN_AUDIT.md` §3 +
195    /// `DESIGN_RECOVERY.md` §6. Project code that records generic
196    /// CRUD on its own models continues to use [`Self::new`] alone
197    /// with the legacy `ActionType` trio.
198    pub fn with_event(mut self, event: AuditEvent) -> Self {
199        self.event = Some(event);
200        self
201    }
202
203    /// Resolve the persisted `action_type` string. The `event`
204    /// override wins when set; otherwise the legacy `action_type`
205    /// trio's lowercase string is used. Pulled out as a small helper
206    /// so the `record()` insert and any future read-side rendering
207    /// share one resolution rule.
208    pub(crate) fn resolved_action_type(&self) -> &'static str {
209        match self.event {
210            Some(e) => e.as_str(),
211            None => self.action_type.as_str(),
212        }
213    }
214}
215
216/// Write one row to the action log. Validates required fields before
217/// touching the DB so a broken audit pipeline becomes visible.
218pub async fn record(db: &Db, entry: LogEntry<'_>) -> Result<()> {
219    if entry.user_id <= 0 {
220        return Err(Error::Internal("admin audit: missing user_id".to_string()));
221    }
222    if entry.model_name.trim().is_empty() {
223        return Err(Error::Internal(
224            "admin audit: missing model_name".to_string(),
225        ));
226    }
227    if entry.object_id <= 0 {
228        return Err(Error::Internal(
229            "admin audit: missing object_id".to_string(),
230        ));
231    }
232
233    let now = Utc::now();
234    let action_type_str = entry.resolved_action_type();
235    sqlx::query(
236        "INSERT INTO rustio_admin_actions
237             (user_id, action_type, model_name, object_id, timestamp, ip_address, summary,
238              correlation_id, session_id, metadata)
239         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
240    )
241    .bind(entry.user_id)
242    .bind(action_type_str)
243    .bind(entry.model_name)
244    .bind(entry.object_id)
245    .bind(now)
246    .bind(entry.ip_address)
247    .bind(&entry.summary)
248    .bind(entry.correlation_id)
249    .bind(entry.session_id)
250    .bind(entry.metadata.as_ref())
251    .execute(db.pool())
252    .await?;
253    Ok(())
254}
255
256/// Typed representation of every audit `action_type` the framework
257/// emits for authority + identity + recovery actions.
258///
259/// **Public-API stability (0.5.0):** the enum is `pub` from R1
260/// onwards (doctrine 18). External consumers — SIEM tooling, custom
261/// dashboards, integration tests — can match on these variants
262/// instead of (or in addition to) the persisted strings. The
263/// `as_str()` mapping is the single canonical boundary between the
264/// typed surface and the `rustio_admin_actions.action_type` TEXT
265/// column. Every existing variant's string is locked-in by the
266/// `audit_event_existing_variants_have_stable_strings` test below;
267/// renaming a string is a breaking change requiring a major version
268/// bump.
269///
270/// **Coexistence with `ActionType`:** the legacy
271/// `ActionType::{Create, Update, Delete}` trio writes the strings
272/// `"create" / "update" / "delete"`, used for generic CRUD on
273/// project-registered models. `AuditEvent` strings are richer
274/// (`"user_created"`, `"password_reset_self_consume"`, …) and used
275/// for the framework's own authority + identity + recovery surfaces.
276/// The two vocabularies are disjoint by design;
277/// `action_type_and_audit_event_vocabularies_dont_collide` asserts
278/// the disjointness.
279///
280/// **Future-extensibility:** `#[non_exhaustive]` lets future
281/// R-phases (R2 / R3 / R4) add variants without breaking external
282/// matchers. Variants whose call-sites haven't shipped yet are
283/// listed here in anticipation — `as_str()` returns the canonical
284/// string regardless of whether anything emits it. The roadmap
285/// in `DESIGN_RECOVERY.md` §16 + `ROADMAP.md` covers when each
286/// variant lights up.
287#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
288#[non_exhaustive]
289pub enum AuditEvent {
290    // ---- User / Group authority CRUD (R0+) ----
291    UserCreated,
292    UserUpdated,
293    UserDeleted,
294    GroupCreated,
295    GroupUpdated,
296    GroupDeleted,
297    // ---- Password lifecycle (R1+) ----
298    /// Authenticated user changed their own password via
299    /// `/admin/password_change`. R1 commit #11 wires emission.
300    PasswordChangedSelf,
301    /// Anonymous user requested a password-reset email via
302    /// `/admin/forgot-password`. R1 commit #7 wires emission.
303    PasswordResetSelfRequest,
304    /// Anonymous user consumed a reset token + set a new password
305    /// via `/admin/reset-password/<token>`. R1 commit #7 wires
306    /// emission.
307    PasswordResetSelfConsume,
308    /// An administrator reset another user's password. R2 wires
309    /// emission via the dedicated `/admin/users/<id>/reset-password`
310    /// route.
311    PasswordResetByOther,
312    // ---- Account state (R2+) ----
313    AccountLocked,
314    AccountUnlocked,
315    // ---- MFA (R3+) ----
316    MfaEnabled,
317    MfaDisabled,
318    MfaResetByOther,
319    // ---- Session lifecycle (R0/R1+) ----
320    SessionsRevokedSelf,
321    SessionsRevokedByOther,
322    SessionLogout,
323    // ---- Layer-3 CLI (R4+) ----
324    EmergencyRecovery,
325}
326
327impl AuditEvent {
328    /// Stable lowercase identifier persisted as
329    /// `rustio_admin_actions.action_type`.
330    ///
331    /// **Stability contract:** every string returned here is
332    /// part of the public API from 0.5.0 onwards. Existing values
333    /// are locked-in by
334    /// `audit_event_existing_variants_have_stable_strings` and
335    /// changing one is a breaking change requiring a major bump.
336    /// New `AuditEvent` variants may be added in minor versions
337    /// (the enum is `#[non_exhaustive]`); each new variant ships
338    /// with its locked string from day one.
339    pub const fn as_str(self) -> &'static str {
340        match self {
341            Self::UserCreated => "user_created",
342            Self::UserUpdated => "user_updated",
343            Self::UserDeleted => "user_deleted",
344            Self::GroupCreated => "group_created",
345            Self::GroupUpdated => "group_updated",
346            Self::GroupDeleted => "group_deleted",
347            Self::PasswordChangedSelf => "password_changed_self",
348            Self::PasswordResetSelfRequest => "password_reset_self_request",
349            Self::PasswordResetSelfConsume => "password_reset_self_consume",
350            Self::PasswordResetByOther => "password_reset_by_other",
351            Self::AccountLocked => "account_locked",
352            Self::AccountUnlocked => "account_unlocked",
353            Self::MfaEnabled => "mfa_enabled",
354            Self::MfaDisabled => "mfa_disabled",
355            Self::MfaResetByOther => "mfa_reset_by_other",
356            Self::SessionsRevokedSelf => "sessions_revoked_self",
357            Self::SessionsRevokedByOther => "sessions_revoked_by_other",
358            Self::SessionLogout => "session_logout",
359            Self::EmergencyRecovery => "emergency_recovery",
360        }
361    }
362}
363
364/// Fetch the most recent `limit` admin actions, newest first.
365pub async fn recent(
366    db: &Db,
367    limit: i64,
368    model_filter: Option<&str>,
369    action_filter: Option<&str>,
370) -> Result<Vec<AdminAction>> {
371    let mut sql = String::from(
372        "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
373                a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary
374         FROM rustio_admin_actions a
375         LEFT JOIN rustio_users u ON u.id = a.user_id",
376    );
377    let mut clauses: Vec<String> = Vec::new();
378    let mut param_idx: usize = 1;
379    if model_filter.is_some() {
380        clauses.push(format!("a.model_name = ${param_idx}"));
381        param_idx += 1;
382    }
383    if action_filter.is_some() {
384        clauses.push(format!("a.action_type = ${param_idx}"));
385        param_idx += 1;
386    }
387    if !clauses.is_empty() {
388        sql.push_str(" WHERE ");
389        sql.push_str(&clauses.join(" AND "));
390    }
391    sql.push_str(&format!(
392        " ORDER BY a.timestamp DESC, a.id DESC LIMIT ${param_idx}"
393    ));
394
395    let mut q = sqlx::query(&sql);
396    if let Some(m) = model_filter {
397        q = q.bind(m);
398    }
399    if let Some(a) = action_filter {
400        q = q.bind(a);
401    }
402    q = q.bind(limit);
403
404    let rows = q.fetch_all(db.pool()).await?;
405    rows.iter().map(row_to_action).collect()
406}
407
408/// All actions for one `(model, object_id)`, newest first.
409pub async fn for_object(db: &Db, model_name: &str, object_id: i64) -> Result<Vec<AdminAction>> {
410    let rows = sqlx::query(
411        "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
412                a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary
413         FROM rustio_admin_actions a
414         LEFT JOIN rustio_users u ON u.id = a.user_id
415         WHERE a.model_name = $1 AND a.object_id = $2
416         ORDER BY a.timestamp DESC, a.id DESC",
417    )
418    .bind(model_name)
419    .bind(object_id)
420    .fetch_all(db.pool())
421    .await?;
422    rows.iter().map(row_to_action).collect()
423}
424
425fn row_to_action(r: &sqlx::postgres::PgRow) -> Result<AdminAction> {
426    Ok(AdminAction {
427        id: r.try_get("id")?,
428        user_id: r.try_get("user_id")?,
429        user_email: r.try_get("user_email")?,
430        action_type: r.try_get("action_type")?,
431        model_name: r.try_get("model_name")?,
432        object_id: r.try_get("object_id")?,
433        timestamp: r.try_get("timestamp")?,
434        ip_address: r.try_get("ip_address")?,
435        summary: r.try_get("summary")?,
436    })
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    /// Single source of truth for every `AuditEvent` variant the
444    /// framework currently exposes. Drift tests below iterate over
445    /// this constant; adding a new variant means adding it here.
446    /// CHANGELOG / DESIGN_AUDIT.md call out variant additions.
447    const ALL_AUDIT_EVENTS: &[AuditEvent] = &[
448        AuditEvent::UserCreated,
449        AuditEvent::UserUpdated,
450        AuditEvent::UserDeleted,
451        AuditEvent::GroupCreated,
452        AuditEvent::GroupUpdated,
453        AuditEvent::GroupDeleted,
454        AuditEvent::PasswordChangedSelf,
455        AuditEvent::PasswordResetSelfRequest,
456        AuditEvent::PasswordResetSelfConsume,
457        AuditEvent::PasswordResetByOther,
458        AuditEvent::AccountLocked,
459        AuditEvent::AccountUnlocked,
460        AuditEvent::MfaEnabled,
461        AuditEvent::MfaDisabled,
462        AuditEvent::MfaResetByOther,
463        AuditEvent::SessionsRevokedSelf,
464        AuditEvent::SessionsRevokedByOther,
465        AuditEvent::SessionLogout,
466        AuditEvent::EmergencyRecovery,
467    ];
468
469    /// Drift test (doctrine 18): every variant's `as_str()` is
470    /// unique. Catches copy-paste collisions when adding variants
471    /// — `password_reset_self_request` vs
472    /// `password_reset_self_consume` are easy to mis-paste.
473    #[test]
474    fn audit_event_strings_are_unique() {
475        let mut set = std::collections::HashSet::new();
476        for &e in ALL_AUDIT_EVENTS {
477            assert!(set.insert(e.as_str()), "duplicate as_str() for {e:?}");
478        }
479        assert_eq!(set.len(), ALL_AUDIT_EVENTS.len());
480    }
481
482    /// Every `AuditEvent` string is snake_case ASCII. Future SIEM
483    /// integrations tokenise on these — keep them pre-normalised.
484    #[test]
485    fn audit_event_strings_are_snake_case() {
486        for &e in ALL_AUDIT_EVENTS {
487            let s = e.as_str();
488            assert!(!s.is_empty(), "{e:?} as_str is empty");
489            assert!(
490                s.chars()
491                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'),
492                "{e:?}.as_str() = {s:?} is not snake_case"
493            );
494        }
495    }
496
497    /// R1 commit #6: `PasswordChangedSelf` maps to the locked string
498    /// `"password_changed_self"`. The string is part of the public
499    /// API contract from 0.5.0; renaming requires a major bump.
500    #[test]
501    fn audit_event_password_changed_self_maps_correctly() {
502        assert_eq!(
503            AuditEvent::PasswordChangedSelf.as_str(),
504            "password_changed_self"
505        );
506    }
507
508    /// Stability contract for the public API: every existing
509    /// variant's string value is locked-in here. A change to any of
510    /// these strings is a breaking change requiring a major bump
511    /// (the persisted `rustio_admin_actions.action_type` column
512    /// would have rows referencing the old string from prior
513    /// installations). New variants may extend this list; existing
514    /// rows must keep their strings.
515    #[test]
516    fn audit_event_existing_variants_have_stable_strings() {
517        assert_eq!(AuditEvent::UserCreated.as_str(), "user_created");
518        assert_eq!(AuditEvent::UserUpdated.as_str(), "user_updated");
519        assert_eq!(AuditEvent::UserDeleted.as_str(), "user_deleted");
520        assert_eq!(AuditEvent::GroupCreated.as_str(), "group_created");
521        assert_eq!(AuditEvent::GroupUpdated.as_str(), "group_updated");
522        assert_eq!(AuditEvent::GroupDeleted.as_str(), "group_deleted");
523        assert_eq!(
524            AuditEvent::PasswordChangedSelf.as_str(),
525            "password_changed_self"
526        );
527        assert_eq!(
528            AuditEvent::PasswordResetSelfRequest.as_str(),
529            "password_reset_self_request"
530        );
531        assert_eq!(
532            AuditEvent::PasswordResetSelfConsume.as_str(),
533            "password_reset_self_consume"
534        );
535        assert_eq!(
536            AuditEvent::PasswordResetByOther.as_str(),
537            "password_reset_by_other"
538        );
539        assert_eq!(AuditEvent::AccountLocked.as_str(), "account_locked");
540        assert_eq!(AuditEvent::AccountUnlocked.as_str(), "account_unlocked");
541        assert_eq!(AuditEvent::MfaEnabled.as_str(), "mfa_enabled");
542        assert_eq!(AuditEvent::MfaDisabled.as_str(), "mfa_disabled");
543        assert_eq!(AuditEvent::MfaResetByOther.as_str(), "mfa_reset_by_other");
544        assert_eq!(
545            AuditEvent::SessionsRevokedSelf.as_str(),
546            "sessions_revoked_self"
547        );
548        assert_eq!(
549            AuditEvent::SessionsRevokedByOther.as_str(),
550            "sessions_revoked_by_other"
551        );
552        assert_eq!(AuditEvent::SessionLogout.as_str(), "session_logout");
553        assert_eq!(AuditEvent::EmergencyRecovery.as_str(), "emergency_recovery");
554    }
555
556    /// `ActionType` and `AuditEvent` are intentionally separate
557    /// vocabularies — `ActionType` writes generic CRUD strings
558    /// (`"create" / "update" / "delete"`) for project-registered
559    /// models; `AuditEvent` writes the framework's richer authority,
560    /// identity, and recovery vocabulary. The two namespaces must
561    /// stay disjoint so a SIEM consumer can route on the string
562    /// alone without disambiguation.
563    #[test]
564    fn action_type_and_audit_event_vocabularies_dont_collide() {
565        let action_type_strs = [
566            ActionType::Create.as_str(),
567            ActionType::Update.as_str(),
568            ActionType::Delete.as_str(),
569        ];
570        let mut set = std::collections::HashSet::new();
571        for s in action_type_strs {
572            assert!(set.insert(s), "duplicate ActionType string {s:?}");
573        }
574        for &e in ALL_AUDIT_EVENTS {
575            assert!(
576                set.insert(e.as_str()),
577                "AuditEvent::{:?} ({:?}) collides with ActionType",
578                e,
579                e.as_str()
580            );
581        }
582        assert_eq!(set.len(), action_type_strs.len() + ALL_AUDIT_EVENTS.len());
583    }
584
585    // ---- LogEntry::with_event ----
586
587    #[test]
588    fn log_entry_with_event_overrides_action_type_persistence() {
589        // Without with_event(), the legacy ActionType wins.
590        let entry = LogEntry::new(1, ActionType::Update, "user", 1);
591        assert_eq!(entry.resolved_action_type(), "update");
592
593        // with_event() promotes to the richer AuditEvent string.
594        let entry = LogEntry::new(1, ActionType::Update, "user", 1)
595            .with_event(AuditEvent::PasswordChangedSelf);
596        assert_eq!(entry.resolved_action_type(), "password_changed_self");
597
598        // Different events resolve to their canonical string.
599        let entry = LogEntry::new(1, ActionType::Update, "user", 1)
600            .with_event(AuditEvent::PasswordResetSelfRequest);
601        assert_eq!(entry.resolved_action_type(), "password_reset_self_request");
602
603        let entry = LogEntry::new(1, ActionType::Update, "user", 1)
604            .with_event(AuditEvent::PasswordResetSelfConsume);
605        assert_eq!(entry.resolved_action_type(), "password_reset_self_consume");
606    }
607
608    #[test]
609    fn log_entry_default_event_is_none() {
610        // Backwards-compat: legacy callers continue to work.
611        let entry = LogEntry::new(1, ActionType::Create, "post", 99);
612        assert!(entry.event.is_none());
613        assert_eq!(entry.resolved_action_type(), "create");
614    }
615
616    /// The legacy `ActionType::parse` is a partial parser — it only
617    /// recognises the original create/update/delete trio. Strings
618    /// emitted by `AuditEvent` (and any free-form legacy strings
619    /// already in older `rustio_admin_actions` rows) return `None`,
620    /// which the render layer maps to a neutral pill class without
621    /// panicking. This pins the property so a future change to
622    /// `ActionType::parse` doesn't accidentally start matching
623    /// AuditEvent strings.
624    #[test]
625    fn legacy_action_type_parser_returns_none_on_unknown_strings() {
626        // Legacy trio still parses.
627        assert_eq!(ActionType::parse("create"), Some(ActionType::Create));
628        assert_eq!(ActionType::parse("update"), Some(ActionType::Update));
629        assert_eq!(ActionType::parse("delete"), Some(ActionType::Delete));
630
631        // Every AuditEvent string is unrecognised by the legacy
632        // parser — the render layer falls through to "badge-neutral"
633        // for these, which is the documented behaviour.
634        for &e in ALL_AUDIT_EVENTS {
635            assert!(
636                ActionType::parse(e.as_str()).is_none(),
637                "ActionType::parse should not recognise AuditEvent string {:?}",
638                e.as_str()
639            );
640        }
641
642        // Pure garbage and free-form legacy strings.
643        assert!(ActionType::parse("garbage").is_none());
644        assert!(ActionType::parse("").is_none());
645        assert!(ActionType::parse("CREATE").is_none()); // case-sensitive
646    }
647}