fix(upload): a retry after release returns the stored photo instead of refusing it
The idempotency key was only readable as a multipart FIELD, and a field cannot be read until the body is being parsed — which happens after the lock/release pre-flight. So the replay was unreachable in exactly the case it exists for: the photo commits → the response is lost on the way back (the flaky-wifi failure the key was added for) → the host releases the gallery at the end of the night → the phone's retry answers `gallery_released`. The guest is told a photo that is sitting in the gallery was never sent. And the remedy the client offers is destructive: `open_event` clears `export_released_at` AND bumps `export_epoch`, retiring the whole keepsake generation and forcing a multi-GB rebuild on a 2-vCPU box at midnight — to re-send a photo that was never missing. Several guests on one flaky evening make this likely to happen at least once. The key is now also sent as `X-Client-Upload-Id`, which arrives with the request line, so the answer is knowable before anything is decided about locks. The multipart field stays for the concurrent case and as a fallback. Placed ahead of the hourly rate limiter too, which was the same mistake one layer up: a 40-photo burst with two retries apiece exhausted the guest's hour on uploads that had all committed the first time. The body is still drained rather than abandoned — replying before reading it makes the proxy see a broken pipe and turn a clean 200 into a 502. The spec carries its own control: a DIFFERENT photo is asserted to still be refused with `gallery_released` after the release, so the replay cannot be green merely because the gate was open.
This commit is contained in:
85
e2e/specs/02-upload/retry-after-release.spec.ts
Normal file
85
e2e/specs/02-upload/retry-after-release.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* A retry of a photo that was ALREADY STORED must return that photo — even after the gallery has
|
||||
* been released.
|
||||
*
|
||||
* The idempotency key arrives as a multipart field as well, and there is a replay for it in the
|
||||
* handler; but a field cannot be read until the body is being parsed, which happens after the
|
||||
* lock/release pre-flight. So the replay was unreachable in precisely the case that matters:
|
||||
*
|
||||
* the photo commits → the response is lost on the way back (the flaky-wifi failure the key
|
||||
* exists for) → the host releases the gallery at the end of the night → the phone retries →
|
||||
* `gallery_released`.
|
||||
*
|
||||
* The guest is then told a photo that is sitting in the gallery was never sent, and the remedy the
|
||||
* client offers — ask the hosts to reopen — bumps `export_epoch`, retiring the whole keepsake and
|
||||
* forcing a rebuild, to re-send something that was never missing.
|
||||
*
|
||||
* The key is now also sent as `X-Client-Upload-Id`, which arrives with the request line.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { uploadRaw } from '../../helpers/upload-client';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
const SLUG = 'e2e-test-event';
|
||||
|
||||
function sample(): Buffer {
|
||||
return readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg'));
|
||||
}
|
||||
|
||||
test.describe('Upload — a retry after release replays instead of refusing', () => {
|
||||
test('the stored photo comes back, and the guest is not told to reopen the gallery', async ({
|
||||
guest,
|
||||
db,
|
||||
}) => {
|
||||
const g = await guest('WiederholerWilli');
|
||||
const key = crypto.randomUUID();
|
||||
|
||||
// 1. The upload commits. In the real failure the guest never sees this response.
|
||||
const first = await uploadRaw(g.jwt, sample(), {
|
||||
filename: 'a.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
clientUploadId: key,
|
||||
});
|
||||
expect(first.status).toBe(201);
|
||||
const original = await first.json();
|
||||
|
||||
// 2. The host releases the gallery — the end-of-event action every queue runs into.
|
||||
await db.setExportReleased(SLUG, true);
|
||||
|
||||
// A DIFFERENT photo must still be refused: this is the control that proves the release is
|
||||
// actually in effect, so the replay below is not just "the gate was open all along".
|
||||
const stranger = await uploadRaw(g.jwt, sample(), {
|
||||
filename: 'b.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
clientUploadId: crypto.randomUUID(),
|
||||
});
|
||||
expect(stranger.status, 'a genuinely new upload must still be refused after release').toBe(403);
|
||||
expect((await stranger.json()).error).toBe('gallery_released');
|
||||
|
||||
// 3. The phone retries the FIRST photo. It is already in the gallery, so the honest answer is
|
||||
// the stored row — not "the gallery is closed".
|
||||
const retry = await uploadRaw(g.jwt, sample(), {
|
||||
filename: 'a.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
clientUploadId: key,
|
||||
});
|
||||
expect(
|
||||
retry.status,
|
||||
'a retry of an already-stored photo must be replayed, not refused with gallery_released'
|
||||
).toBe(200);
|
||||
const replayed = await retry.json();
|
||||
expect(replayed.id, 'the replay must return the ORIGINAL upload, not a new one').toBe(
|
||||
original.id
|
||||
);
|
||||
|
||||
// 4. And no second row was created — the whole point of the key.
|
||||
const feed = await fetch(`${BASE}/api/v1/feed?limit=100`, {
|
||||
headers: { Authorization: `Bearer ${g.jwt}` },
|
||||
});
|
||||
const items: any[] = (await feed.json()).uploads ?? [];
|
||||
const mine = items.filter((u) => u.id === original.id);
|
||||
expect(mine.length, 'the photo must appear exactly once').toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user