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>
101 lines
4.7 KiB
TypeScript
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);
|
|
});
|
|
});
|