Files
EventSnap/e2e/specs/02-upload/rejection-visible.spec.ts
fabi 32dfe6874a test(e2e): make nine red specs assert the contracts the code actually implements
The e2e suite had never been run during this audit. It failed 9 of 256; seven of those
predated the audit's changes, established by building a stack from a clean HEAD worktree
and running the same specs against it rather than guessing.

Most were stale assertions rather than product defects:

- quota.spec solved for a target limit using the observed uploader count, but the divisor is
  max(active, estimated_guest_count, 1) and that config seeds at 100 — so every limit it
  aimed for came out 100x small and every "within quota" upload 413'd.
- rate-limit-shared-nat destructured `ticket` from a 429 body and fetched with
  `ticket=undefined`, turning the 429 under test into an unrelated 401. It also faked a
  release with no archive on disk, so the mint's pre-check 404'd and the per-day limiter was
  never reached; it now does a real release and asserts 200 rather than "not 429".
- ddos allowed only [200,429] from ten concurrent streams, so it failed on the very defence
  it exercises: four tickets per session survive and the rest correctly 401. Now asserts
  exactly four, which a tightened cap or an inverted eviction order would catch.
- auth-tampering asserted a throttled IP is refused EVEN with the correct password. That
  contract was deliberately removed — it let any phone on the venue NAT lock the operator
  out of their own admin panel, with a circular escape hatch. Inverted, plus a new check
  that a success does not refill an attacker's bucket.
- moderation-ui assumed a ban leaves a comment "stuck on screen"; `list_for_upload` filters
  banned authors, so it is hidden from everyone including the host. Now pins the pair that
  matters — the ban hides it, and the host's permanent removal survives an unban — and the
  UI leg it used to own is restored as a separate test on a reachable comment.

The export specs mint with `?kind=` now that a download ticket is bound to one archive, and
four of them assert the mint's 404 rather than the download's: with the kind always known,
the pre-check refuses up front instead of after charging a daily download for an archive
that cannot be served.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:44:48 +02:00

101 lines
4.7 KiB
TypeScript

/**
* Regression guard — a rejected upload must tell the user something.
*
* `UploadQueue.svelte` was 162 lines of complete, working UI — the only renderer of an
* item's error text, the only "Erneut" retry button, the only rate-limit countdown — and it
* was never imported anywhere, so `retryItem`, `removeItem` and `clearCompleted` were all
* unreachable at runtime. On a terminal rejection the store dropped the blob, wrote a clear
* German reason into `entry.error` with the comment "so the UI shows a clear reason", and
* there was no such UI.
*
* Meanwhile the FAB badge counted only pending/uploading, so a rejected photo decremented it
* exactly as if it had succeeded. Net effect: the photo silently vanished — no toast, no
* queue row, no error text, and it never appeared in the feed.
*/
import { test, expect } from '../../fixtures/test';
import { skipIfNoIdbBlobs } from '../../helpers/webkit';
import { FeedPage, UploadSheet } from '../../page-objects';
import { join } from 'node:path';
const SAMPLE_JPG = join(process.cwd(), 'fixtures', 'media', 'sample.jpg');
test.describe('Upload — a rejected upload is surfaced', () => {
test('a terminally rejected upload toasts, and stays visible in the queue', async ({
page,
api,
host,
guest,
signIn,
browserName,
}) => {
skipIfNoIdbBlobs(browserName);
const g = await guest('RejectedUploader');
await signIn(page, g);
const feed = new FeedPage(page);
const sheet = new UploadSheet(page);
await feed.openUploadSheet();
await sheet.stageFiles([SAMPLE_JPG]);
await sheet.captionInput.waitFor({ state: 'visible', timeout: 10_000 });
// Ban the uploader between staging and sending, so the POST comes back 403 — a
// terminal 4xx the server will keep rejecting, which is the path that purges the blob.
await api.banUser(host.jwt, g.userId);
await sheet.submit();
// 1. The user is told, wherever they are (the flow lands them on /feed).
const toast = page.getByRole('region', { name: 'Benachrichtigungen' });
await expect(toast).toContainText(/sample\.jpg/i, { timeout: 15_000 });
// 2. The queue row survives with its reason and is reachable on /upload — this is what
// the orphaned component made impossible.
await page.goto('/upload');
const queue = page.getByText('Upload-Warteschlange');
await expect(queue, 'the upload queue must be rendered somewhere').toBeVisible({
timeout: 10_000,
});
// The server's reason ("Du bist gesperrt.") must render — it had no UI at all before.
await expect(page.getByText('Du bist gesperrt.')).toBeVisible();
// The chip reads "Fehler", NOT "Gesperrt", and that is the fix rather than a regression.
// A ban used to come back as a generic `forbidden`, which purged the blob and moved the row
// to `blocked` — a terminal state with no retry button. So an unban restored everything
// except the photo that was actually in flight, which is the one the guest cares about.
// It is now a distinct `user_banned` code that PARKS the row (status `error`, blob kept,
// `parkedFor: 'unban'`) and resumes it when `user-shown` arrives.
await expect(page.getByText('Gesperrt', { exact: true })).toHaveCount(0);
// Positively, not just negatively: a chip that rendered empty would satisfy the line above.
await expect(page.getByText('Fehler', { exact: true }).first()).toBeVisible();
// 3. The badge must not read as success. It counted only pending/uploading before, so a
// rejected item dropped it to 0 — indistinguishable from a completed upload.
// The row is now parked (`error` + `parkedFor: 'unban'`) rather than terminally `blocked`,
// so it is the parked count that must be exactly one — and critically the blob must still
// be there, since that is what an unban replays.
await expect
.poll(
() =>
page.evaluate(async () => {
return new Promise<number>((resolve, reject) => {
const req = indexedDB.open('eventsnap-uploads', 3);
req.onerror = () => reject(req.error);
req.onsuccess = () => {
const tx = req.result.transaction('queue', 'readonly');
const all = tx.objectStore('queue').getAll();
all.onsuccess = () =>
resolve(
all.result.filter(
(r: { status: string; parkedFor?: string; blob?: Blob }) =>
r.status === 'error' && r.parkedFor === 'unban' && !!r.blob
).length
);
all.onerror = () => reject(all.error);
};
});
}),
{ timeout: 10_000 }
)
.toBe(1);
});
});