Files
EventSnap/backend/tests/upload_idempotency.rs
fabi 9b38d31f97 fix(upload): stop a late retry from undoing a host takedown
Migration 026 freed the idempotency key as soon as deleted_at was set, so a
retry after a delete uploads afresh instead of 409ing forever. That rationale
only considered the GUEST deleting. deleted_at is also set by
host_delete_upload, and there the same rule reverses a moderation decision:

  1. Guest uploads; the row commits and the photo appears, but the response
     is lost on the way back — the flaky-wifi case the key exists for — so
     the phone keeps the queue item.
  2. The host takes the photo down. Epoch bumped, keepsake rebuilt without it.
  3. The phone reconnects ten minutes later and retries. The key is free, the
     INSERT succeeds, and the photo is back — in the feed and in the next
     keepsake, under a NEW uuid that matches nothing in the host's moderation
     history, with nothing logged to say a takedown was reversed.

Migration 031 keeps the key claimed for a host takedown and releases it only
for a guest's own delete, so the retry resolves to the duplicate path and is
refused. The refusal now says why ("von den Gastgebern entfernt") rather than
"already processed", which invites another try.

The index predicate and the ON CONFLICT arbiter are changed in lockstep;
these queries are not compile-checked, so a drift between them is a 500 on
exactly the retries the index exists to serve. Verified against a real
Postgres: live retry suppressed, host takedown holds the key, guest delete
releases it. The integration test's copy of the insert is updated too — it is
verbatim by design, and a stale copy would have kept passing.
2026-08-12 09:14:51 +02:00

269 lines
11 KiB
Rust

//! DB-backed tests for the upload idempotency key (migration 022).
//!
//! The guarantee under test is the one thing standing between a lost response and a duplicated
//! wedding photo: a retry of an upload that already committed must NOT create a second row, and
//! must not charge the guest's storage quota twice. The whole mechanism is SQL — a partial unique
//! index plus `ON CONFLICT DO NOTHING` — so it is tested against a real database with the real
//! migrations applied, using the same statements `src/` runs.
mod common;
use common::*;
use sqlx::PgPool;
use uuid::Uuid;
/// SRC: `models/upload.rs::Upload::create` — the insert, verbatim.
///
/// Returns the new row's id, or `None` when the key was already stored. The handler treats
/// `None` as "a concurrent retry won" and replays the stored row instead of committing.
async fn create_upload(
pool: &PgPool,
event_id: Uuid,
user_id: Uuid,
original_path: &str,
client_upload_id: Option<Uuid>,
) -> Option<Uuid> {
let row: Option<(Uuid,)> = sqlx::query_as(
"INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes, caption, client_upload_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (client_upload_id) WHERE client_upload_id IS NOT NULL AND (deleted_at IS NULL OR taken_down_by_host) DO NOTHING
RETURNING id",
)
.bind(event_id)
.bind(user_id)
.bind(original_path)
.bind("image/jpeg")
.bind(1_000i64)
.bind(Option::<String>::None)
.bind(client_upload_id)
.fetch_optional(pool)
.await
.expect("create_upload");
row.map(|(id,)| id)
}
/// SRC: `models/upload.rs::Upload::find_by_client_upload_id` — the lookup, verbatim.
async fn find_by_key(pool: &PgPool, user_id: Uuid, client_upload_id: Uuid) -> Option<Uuid> {
let row: Option<(Uuid,)> = sqlx::query_as(
"SELECT id FROM upload
WHERE client_upload_id = $1 AND user_id = $2 AND deleted_at IS NULL",
)
.bind(client_upload_id)
.bind(user_id)
.fetch_optional(pool)
.await
.expect("find_by_key");
row.map(|(id,)| id)
}
async fn upload_count(pool: &PgPool) -> i64 {
sqlx::query_scalar("SELECT COUNT(*) FROM upload")
.fetch_one(pool)
.await
.expect("upload_count")
}
/// The core guarantee. A phone that loses the response and re-sends the same photo gets the
/// original row back, not a second copy in the gallery and a second charge against its quota.
#[sqlx::test]
async fn the_same_key_can_only_ever_store_one_upload(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await;
let user_id = seed_user(&pool, event_id, "Wackelige Wanda").await;
let key = Uuid::new_v4();
let first = create_upload(&pool, event_id, user_id, "originals/a.jpg", Some(key)).await;
assert!(first.is_some(), "the first attempt must store the upload");
// The retry: same key, and (as after a real re-send) a different file on disk.
let second = create_upload(&pool, event_id, user_id, "originals/b.jpg", Some(key)).await;
assert!(
second.is_none(),
"a retry of a committed upload must not insert a second row"
);
assert_eq!(upload_count(&pool).await, 1);
// And the handler can find the winner to replay it.
assert_eq!(find_by_key(&pool, user_id, key).await, first);
}
/// The index must not over-reach. Two genuinely different photos carry different keys and must
/// both land — this is the ordinary case, and breaking it would silently drop uploads.
#[sqlx::test]
async fn different_keys_are_different_uploads(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await;
let user_id = seed_user(&pool, event_id, "Fleißige Frieda").await;
for _ in 0..5 {
assert!(
create_upload(
&pool,
event_id,
user_id,
"originals/x.jpg",
Some(Uuid::new_v4())
)
.await
.is_some()
);
}
assert_eq!(upload_count(&pool).await, 5);
}
/// The index is PARTIAL, and this is why. Every upload that predates migration 022, and any
/// client that doesn't send a key, carries NULL — if those collided, the first such upload would
/// block every subsequent one and the whole event would fail after one photo.
#[sqlx::test]
async fn uploads_without_a_key_never_collide(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await;
let user_id = seed_user(&pool, event_id, "Alte Anna").await;
for _ in 0..5 {
assert!(
create_upload(&pool, event_id, user_id, "originals/legacy.jpg", None)
.await
.is_some(),
"a NULL key must never be treated as a duplicate"
);
}
assert_eq!(upload_count(&pool).await, 5);
}
/// The lookup is scoped to the owner. The key alone is unique, so this can only matter if a key
/// ever repeated across users — but a replay that handed one guest another guest's upload row
/// would be a data leak, so the scope is asserted rather than assumed.
#[sqlx::test]
async fn the_replay_lookup_never_crosses_users(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await;
let owner = seed_user(&pool, event_id, "Besitzerin Bea").await;
let other = seed_user(&pool, event_id, "Fremder Franz").await;
let key = Uuid::new_v4();
let id = create_upload(&pool, event_id, owner, "originals/a.jpg", Some(key)).await;
assert_eq!(find_by_key(&pool, owner, key).await, id);
assert_eq!(
find_by_key(&pool, other, key).await,
None,
"another guest's retry must not resolve to this upload"
);
}
/// A deleted photo must not be resurrected by a stale queue item. If the guest uploaded, deleted,
/// and their queue then retried the original request, replaying the deleted row would put the
/// photo they removed back in the gallery — so the lookup excludes soft-deleted rows and the
/// retry becomes a fresh upload instead.
#[sqlx::test]
async fn a_deleted_upload_is_not_replayed(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await;
let user_id = seed_user(&pool, event_id, "Reumütige Rita").await;
let key = Uuid::new_v4();
let id = create_upload(&pool, event_id, user_id, "originals/a.jpg", Some(key))
.await
.expect("first insert");
// `taken_down_by_host = FALSE` — the GUEST deleted their own photo. See migration 031 and the
// sibling test below: the two cases must behave differently, and this is the one that frees
// the key.
sqlx::query("UPDATE upload SET deleted_at = NOW(), taken_down_by_host = FALSE WHERE id = $1")
.bind(id)
.execute(&pool)
.await
.expect("soft delete");
assert_eq!(
find_by_key(&pool, user_id, key).await,
None,
"a soft-deleted upload must not be replayed"
);
// The other half of that rule, and the half that was missing (H9). Asserting only that the
// lookup returns None left the index free to disagree with it: migration 022's predicate
// covered soft-deleted rows, so the retry's INSERT hit `ON CONFLICT DO NOTHING` against the
// dead row, the replay lookup above then found nothing, and the handler answered 409 — which
// the client classifies terminal and purges the blob for. The photo was gone from the phone
// AND absent from the gallery, with no way back.
//
// Migration 026 narrowed the index to live rows so a retry after a delete inserts a FRESH
// upload, which is what `find_by_client_upload_id`'s own doc comment always claimed happened.
let retried = create_upload(&pool, event_id, user_id, "originals/a.jpg", Some(key)).await;
assert!(
retried.is_some(),
"a retry after the guest deleted the photo must create a fresh upload, not 409 forever"
);
assert_ne!(
retried,
Some(id),
"the retry must be a new row, not the dead one"
);
assert_eq!(
find_by_key(&pool, user_id, key).await,
retried,
"the live row is the one the replay lookup must now find"
);
}
/// The mirror of the test above, and the case migration 026's rationale did not consider.
///
/// `deleted_at` is set by the guest deleting their own photo AND by `host_delete_upload`. Freeing
/// the idempotency key on both meant a takedown could be silently undone: the guest's response was
/// lost, so their queue still holds the item; the host removes the photo (bumping the keepsake
/// epoch and rebuilding the archive without it); the phone reconnects ten minutes later and
/// retries; the key is free, the INSERT succeeds, and the photo is back in the feed and in the next
/// keepsake — under a NEW uuid that matches nothing in the host's moderation history, with nothing
/// logged to say a takedown was reversed. The host has to find and delete it a second time.
///
/// Migration 031 keeps the key claimed for a host takedown, so the retry resolves to the duplicate
/// path and is refused. Refusing is the correct answer here: the photo was deliberately removed.
#[sqlx::test]
async fn a_host_takedown_is_not_undone_by_a_late_retry(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await;
let user_id = seed_user(&pool, event_id, "Übermütiger Uwe").await;
let key = Uuid::new_v4();
let id = create_upload(&pool, event_id, user_id, "originals/a.jpg", Some(key))
.await
.expect("first insert");
// SRC: `models/upload.rs::Upload::soft_delete_in_event` with `by_host = true`.
sqlx::query("UPDATE upload SET deleted_at = NOW(), taken_down_by_host = TRUE WHERE id = $1")
.bind(id)
.execute(&pool)
.await
.expect("host takedown");
let retried = create_upload(&pool, event_id, user_id, "originals/a.jpg", Some(key)).await;
assert_eq!(
retried, None,
"a retry after a HOST takedown must be suppressed — otherwise the phone silently \
reinstates a photo the hosts removed"
);
let live: i64 = sqlx::query_scalar(
"SELECT count(*) FROM upload WHERE client_upload_id = $1 AND deleted_at IS NULL",
)
.bind(key)
.fetch_one(&pool)
.await
.expect("count");
assert_eq!(live, 0, "the taken-down photo must stay gone");
// And the handler must be able to tell the guest WHY, rather than "already processed".
// SRC: `models/upload.rs::Upload::taken_down_by_client_upload_id`.
let was_taken_down: bool = sqlx::query_scalar(
"SELECT EXISTS (
SELECT 1 FROM upload
WHERE client_upload_id = $1 AND user_id = $2
AND deleted_at IS NOT NULL AND taken_down_by_host
)",
)
.bind(key)
.bind(user_id)
.fetch_one(&pool)
.await
.expect("takedown lookup");
assert!(
was_taken_down,
"the refusal must be attributable to a takedown so the queue can say so"
);
}