//! The host action audit must stay READABLE, which means the names must actually be there. //! //! Migration 029 made `actor_id`/`target_id` non-FK on the stated grounds that "the record must //! survive the actor's account being removed, which is exactly when it is most likely to be //! wanted". Every call site passed `None` for both name columns, so what survived a deletion was a //! bare uuid resolving to nothing — the guarantee, minus the only thing that made it useful. //! //! A NULL name fails silently: the row is written, the action succeeds, and nobody finds out until //! a host is trying to answer "what happened to my photo?" the next morning. So it is asserted. mod common; use common::*; use sqlx::PgPool; use uuid::Uuid; async fn audit_row( pool: &PgPool, action: &str, ) -> Option<(Option, Option, String)> { sqlx::query_as( "SELECT actor_name, target_name, actor_role FROM host_action_audit WHERE action = $1 ORDER BY created_at DESC LIMIT 1", ) .bind(action) .fetch_optional(pool) .await .expect("audit lookup") } /// The ordinary case: the caller supplies no names and `record` resolves both from the ids. /// This is what nine of the eleven call sites do. #[sqlx::test] async fn a_recorded_action_carries_both_names_without_the_caller_supplying_them(pool: PgPool) { let event_id = seed_event(&pool, "wedding").await; let host = seed_user(&pool, event_id, "Gastgeberin Greta").await; let guest = seed_user(&pool, event_id, "Gesperrter Gustav").await; eventsnap_backend_audit_record(&pool, event_id, host, None, "ban_user", Some(guest), None) .await; let (actor_name, target_name, actor_role) = audit_row(&pool, "ban_user") .await .expect("a row was written"); assert_eq!(actor_name.as_deref(), Some("Gastgeberin Greta")); assert_eq!(target_name.as_deref(), Some("Gesperrter Gustav")); // `as_str()`, not the Debug spelling — a renamed variant must not silently change the column. assert_eq!(actor_role, "host"); } /// The case the columns exist for. `delete_account` hard-deletes the user row, so a name resolved /// AFTER the fact would be NULL — the caller has to pass it in, and this is what proves it does. #[sqlx::test] async fn a_name_supplied_by_the_caller_survives_the_row_being_deleted(pool: PgPool) { let event_id = seed_event(&pool, "wedding").await; let leaver = seed_user(&pool, event_id, "Abschied Anke").await; // Exactly the order `me::delete_account` runs in: the row goes first, the audit row second. sqlx::query("DELETE FROM \"user\" WHERE id = $1") .bind(leaver) .execute(&pool) .await .expect("delete user"); eventsnap_backend_audit_record( &pool, event_id, leaver, Some("Abschied Anke"), "delete_account", Some(leaver), Some("Abschied Anke"), ) .await; let (actor_name, target_name, _) = audit_row(&pool, "delete_account") .await .expect("a row was written"); assert_eq!( actor_name.as_deref(), Some("Abschied Anke"), "the audit row must name the deleted account — resolving it later is impossible" ); assert_eq!(target_name.as_deref(), Some("Abschied Anke")); } /// And the failure mode that made this worth testing: with nothing supplied and nothing to look up, /// the write must still succeed (an audit row must never fail an action) and simply carry NULLs. #[sqlx::test] async fn an_unresolvable_name_writes_the_row_anyway(pool: PgPool) { let event_id = seed_event(&pool, "wedding").await; let ghost = Uuid::new_v4(); eventsnap_backend_audit_record(&pool, event_id, ghost, None, "reset_pin", Some(ghost), None) .await; let (actor_name, target_name, _) = audit_row(&pool, "reset_pin") .await .expect("the row must be written even when no name can be resolved"); assert_eq!(actor_name, None); assert_eq!(target_name, None); } /// SRC: `services/audit.rs::record` — the resolution + insert, verbatim, since the crate is a /// binary and the function is not importable from an integration test. async fn eventsnap_backend_audit_record( pool: &PgPool, event_id: Uuid, actor_id: Uuid, actor_name: Option<&str>, action: &str, target_id: Option, target_name: Option<&str>, ) { let need_actor = actor_name.is_none(); let need_target = target_name.is_none() && target_id.is_some(); let (actor_name, target_name) = if !need_actor && !need_target { ( actor_name.map(str::to_owned), target_name.map(str::to_owned), ) } else { let mut wanted: Vec = Vec::with_capacity(2); if need_actor { wanted.push(actor_id); } if let Some(t) = target_id && need_target { wanted.push(t); } let rows: Vec<(Uuid, String)> = sqlx::query_as("SELECT id, display_name FROM \"user\" WHERE id = ANY($1)") .bind(&wanted) .fetch_all(pool) .await .unwrap_or_default(); let lookup = |id: Uuid| rows.iter().find(|(i, _)| *i == id).map(|(_, n)| n.clone()); ( actor_name.map(str::to_owned).or_else(|| lookup(actor_id)), target_name .map(str::to_owned) .or_else(|| target_id.and_then(lookup)), ) }; sqlx::query( "INSERT INTO host_action_audit (event_id, actor_id, actor_name, actor_role, action, target_id, target_name, detail) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", ) .bind(event_id) .bind(actor_id) .bind(actor_name.as_deref()) .bind("host") .bind(action) .bind(target_id) .bind(target_name.as_deref()) .bind(Option::::None) .execute(pool) .await .expect("audit insert must not fail"); }