chore(e2e): add ESLint + Prettier; fix real findings; dedupe BASE
The Playwright suite had no linter and no formatter — only tsc. Add flat-config ESLint
(typescript-eslint, type-aware) and Prettier (2-space, matching the suite's style).
Rules keep the ones that catch real TEST bugs and drop the noise:
- no-floating-promises KEPT — an un-awaited request/assertion can let a test end before it runs,
passing vacuously. It caught one: the SSE reader loop in sse-listener is now explicitly `void`.
- no-unused-vars KEPT — caught three dead bindings (an unused adminToken fixture arg, an unused
`api` arg, an unused JPEG_MAGIC import), all removed.
- no-explicit-any OFF — all test code; `any` is the honest type for an untyped res.json() body or
a page.evaluate() return.
- no-empty-pattern OFF — Playwright's dependency-free fixtures are `async ({}, use) => {}`.
Refactor: `const BASE = process.env.E2E_FRONTEND_URL ?? '...'` was redeclared verbatim in 23
files — extracted to helpers/env.ts and imported, so a port/scheme change is one edit not a sweep.
Then `prettier --write`. Verified: eslint clean, tsc clean, prettier clean, desktop suite 210
passed / 1 skipped. (One mobile spec flaked once under retries:0 — a pre-existing cross-test
reflow-timing vector from the flakiness audit, not this change: the each-key edit is stable across
16 isolated runs and a clean full mobile re-run.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -26,7 +26,7 @@ test.describe('Auth — admin login', () => {
|
||||
};
|
||||
return {
|
||||
sessionRole: decodeRole(sessionStorage.getItem('eventsnap_jwt')),
|
||||
localToken: localStorage.getItem('eventsnap_jwt')
|
||||
localToken: localStorage.getItem('eventsnap_jwt'),
|
||||
};
|
||||
});
|
||||
expect(stores.sessionRole).toBe('admin');
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
|
||||
test.describe('Navigation — back chevrons', () => {
|
||||
test('/recover back chevron navigates to /feed (which redirects to /join when unauth)', async ({ page }) => {
|
||||
test('/recover back chevron navigates to /feed (which redirects to /join when unauth)', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/recover');
|
||||
const back = page.getByTestId('recover-back');
|
||||
await expect(back).toBeVisible();
|
||||
@@ -15,7 +17,11 @@ test.describe('Navigation — back chevrons', () => {
|
||||
await page.waitForURL(/\/(join|feed)$/);
|
||||
});
|
||||
|
||||
test('/export back chevron returns the authenticated guest to /feed', async ({ page, guest, signIn }) => {
|
||||
test('/export back chevron returns the authenticated guest to /feed', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('ExportBack');
|
||||
await signIn(page, g);
|
||||
await page.goto('/export');
|
||||
|
||||
@@ -41,7 +41,10 @@ test.describe('Auth — join flow', () => {
|
||||
await page.waitForURL('**/feed', { timeout: 5_000 });
|
||||
});
|
||||
|
||||
test('returning guest, new device: same name shows the inline recovery form', async ({ page, guest }) => {
|
||||
test('returning guest, new device: same name shows the inline recovery form', async ({
|
||||
page,
|
||||
guest,
|
||||
}) => {
|
||||
const original = await guest('Charlie');
|
||||
|
||||
// Brand-new browser context (cleared storage) — landing on /join with same name
|
||||
|
||||
@@ -7,7 +7,11 @@ import { AccountPage } from '../../page-objects';
|
||||
import { readStorage } from '../../helpers/storage-helpers';
|
||||
|
||||
test.describe('Auth — leave event', () => {
|
||||
test('leave event clears localStorage and redirects to /join', async ({ page, guest, signIn }) => {
|
||||
test('leave event clears localStorage and redirects to /join', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const h = await guest('Jens');
|
||||
await signIn(page, h);
|
||||
|
||||
|
||||
@@ -5,14 +5,13 @@
|
||||
* revoked token must stop authenticating.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
const ctx = (jwt: string) =>
|
||||
fetch(`${BASE}/api/v1/me/context`, { headers: { Authorization: `Bearer ${jwt}` } });
|
||||
|
||||
test.describe('Auth — sign out everywhere', () => {
|
||||
test('DELETE /sessions revokes ALL of the caller\'s sessions, not just the current one', async ({
|
||||
test("DELETE /sessions revokes ALL of the caller's sessions, not just the current one", async ({
|
||||
api,
|
||||
guest,
|
||||
db,
|
||||
@@ -41,7 +40,7 @@ test.describe('Auth — sign out everywhere', () => {
|
||||
expect(await db.countSessionsForUser(g.userId)).toBe(0);
|
||||
});
|
||||
|
||||
test('one user signing out everywhere does not touch another user\'s sessions', async ({
|
||||
test("one user signing out everywhere does not touch another user's sessions", async ({
|
||||
guest,
|
||||
}) => {
|
||||
const a = await guest('SignsOut');
|
||||
|
||||
@@ -13,7 +13,10 @@ import { JoinPage, RecoverPage } from '../../page-objects';
|
||||
import { clearAllStorage } from '../../helpers/storage-helpers';
|
||||
|
||||
test.describe('Auth — PIN auto-submit', () => {
|
||||
test('inline recovery: 4th digit auto-submits and navigates to /feed', async ({ page, guest }) => {
|
||||
test('inline recovery: 4th digit auto-submits and navigates to /feed', async ({
|
||||
page,
|
||||
guest,
|
||||
}) => {
|
||||
const original = await guest('AutoInline');
|
||||
await clearAllStorage(page);
|
||||
|
||||
@@ -30,7 +33,10 @@ test.describe('Auth — PIN auto-submit', () => {
|
||||
await page.waitForURL('**/feed', { timeout: 5_000 });
|
||||
});
|
||||
|
||||
test('/recover: 4th digit auto-submits when the name is already filled in', async ({ page, guest }) => {
|
||||
test('/recover: 4th digit auto-submits when the name is already filled in', async ({
|
||||
page,
|
||||
guest,
|
||||
}) => {
|
||||
const original = await guest('AutoRecover');
|
||||
await clearAllStorage(page);
|
||||
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
* - a host reset REVOKES the target's sessions (the forgotten/compromised device is logged out)
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
const requestReset = (displayName: string) =>
|
||||
fetch(`${BASE}/api/v1/recover/request`, {
|
||||
@@ -59,9 +58,8 @@ test.describe('PIN reset — in-app request lifecycle', () => {
|
||||
api,
|
||||
host,
|
||||
db,
|
||||
adminToken,
|
||||
}) => {
|
||||
// The admin user exists (adminToken logged them in). Their display name is "Admin" by
|
||||
// The admin user exists (the host fixture logs an admin in to create it). Its display name is "Admin" by
|
||||
// convention; request a reset for it and confirm the queue stays empty — the INSERT filters
|
||||
// `role <> 'admin'`, so queuing a reset for an admin would be a privilege-relevant leak.
|
||||
const admin = (await api.listUsers(host.jwt)).find((u: any) => u.role === 'admin');
|
||||
|
||||
@@ -31,14 +31,25 @@ test.describe('Upload — gallery path', () => {
|
||||
const h = await guest('Uploader2');
|
||||
const { uploadRaw } = await import('../../helpers/upload-client');
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const r1 = await uploadRaw(h.jwt, readFileSync(SAMPLE_JPG), { filename: 'a.jpg', contentType: 'image/jpeg' });
|
||||
const r2 = await uploadRaw(h.jwt, readFileSync(SAMPLE_JPG_2), { filename: 'b.jpg', contentType: 'image/jpeg' });
|
||||
const r1 = await uploadRaw(h.jwt, readFileSync(SAMPLE_JPG), {
|
||||
filename: 'a.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
const r2 = await uploadRaw(h.jwt, readFileSync(SAMPLE_JPG_2), {
|
||||
filename: 'b.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
expect(r1.status).toBe(201);
|
||||
expect(r2.status).toBe(201);
|
||||
await expect.poll(() => db.countUploadsForUser(h.userId), { timeout: 10_000 }).toBe(2);
|
||||
});
|
||||
|
||||
test('UI flow: FAB → UploadSheet → /upload → submit drives a real XHR upload', async ({ page, guest, signIn, db }) => {
|
||||
test('UI flow: FAB → UploadSheet → /upload → submit drives a real XHR upload', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
db,
|
||||
}) => {
|
||||
// Previously fixme'd: the UI queue never fired a POST. Root cause was NOT a
|
||||
// navigation/blob timing quirk but an IndexedDB upgrade bug — the v1→v2
|
||||
// `upgrade` callback opened a *new* transaction, which throws during a
|
||||
@@ -74,13 +85,20 @@ test.describe('Upload — gallery path', () => {
|
||||
const { id } = await res.json();
|
||||
|
||||
// Wait up to 10s for the compression worker's SSE.
|
||||
const evt = await sse.waitForEvent('upload-processed', (e) => {
|
||||
const data = typeof e.data === 'string' ? safeJson(e.data) : e.data;
|
||||
return data?.upload_id === id || data?.id === id;
|
||||
}, 10_000).catch(() => null);
|
||||
const evt = await sse
|
||||
.waitForEvent(
|
||||
'upload-processed',
|
||||
(e) => {
|
||||
const data = typeof e.data === 'string' ? safeJson(e.data) : e.data;
|
||||
return data?.upload_id === id || data?.id === id;
|
||||
},
|
||||
10_000
|
||||
)
|
||||
.catch(() => null);
|
||||
|
||||
// If the SSE didn't arrive in 10s (slow CI, debug mode), at least we know the upload was accepted.
|
||||
if (!evt) console.warn('[finding] upload-processed SSE did not arrive within 10s for upload', id);
|
||||
if (!evt)
|
||||
console.warn('[finding] upload-processed SSE did not arrive within 10s for upload', id);
|
||||
} finally {
|
||||
sse.stop();
|
||||
}
|
||||
@@ -88,5 +106,9 @@ test.describe('Upload — gallery path', () => {
|
||||
});
|
||||
|
||||
function safeJson(s: string) {
|
||||
try { return JSON.parse(s); } catch { return s; }
|
||||
try {
|
||||
return JSON.parse(s);
|
||||
} catch {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ import { test, expect } from '../../fixtures/test';
|
||||
import { join } from 'node:path';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { uploadPausedMidStream } from '../../helpers/upload-client';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
const SAMPLE_BYTES = readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg'));
|
||||
const SIZE = SAMPLE_BYTES.byteLength;
|
||||
|
||||
@@ -64,7 +64,10 @@ async function setLimitTo(
|
||||
targetBytes: number
|
||||
): Promise<number> {
|
||||
const q = await quotaOf(jwt);
|
||||
expect(q.free_disk_bytes, 'the disk must be readable, else quota fails OPEN and proves nothing').toBeTruthy();
|
||||
expect(
|
||||
q.free_disk_bytes,
|
||||
'the disk must be readable, else quota fails OPEN and proves nothing'
|
||||
).toBeTruthy();
|
||||
const active = Math.max(q.active_uploaders, 1);
|
||||
const tolerance = (targetBytes * active) / (q.free_disk_bytes as number);
|
||||
await api.patchConfig(adminToken, { quota_tolerance: tolerance.toExponential(12) });
|
||||
@@ -98,7 +101,9 @@ test.describe('Upload — storage quota enforcement', () => {
|
||||
|
||||
// Nothing was accounted, and nothing reached the feed.
|
||||
expect((await quotaOf(g.jwt)).used_bytes).toBe(0);
|
||||
const feed = await (await fetch(BASE + '/api/v1/feed', { headers: { Authorization: `Bearer ${g.jwt}` } })).json();
|
||||
const feed = await (
|
||||
await fetch(BASE + '/api/v1/feed', { headers: { Authorization: `Bearer ${g.jwt}` } })
|
||||
).json();
|
||||
expect(feed.uploads).toHaveLength(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,11 @@ const SAMPLE_JPG = join(process.cwd(), 'fixtures', 'media', 'sample.jpg');
|
||||
const SAMPLE_BYTES = readFileSync(SAMPLE_JPG);
|
||||
|
||||
test.describe('Upload — rate limit', () => {
|
||||
test('4th upload in one hour returns 429 with Retry-After', async ({ api, adminToken, guest }) => {
|
||||
test('4th upload in one hour returns 429 with Retry-After', async ({
|
||||
api,
|
||||
adminToken,
|
||||
guest,
|
||||
}) => {
|
||||
// Enable inside the test body, AFTER all auto-fixtures (truncate) have run,
|
||||
// so the config can't be reset out from under us by the ordering of hooks
|
||||
// vs fixtures.
|
||||
@@ -53,7 +57,11 @@ test.describe('Upload — rate limit', () => {
|
||||
expect(limited.headers.get('retry-after')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('flipping upload_rate_enabled off bypasses the limit', async ({ api, adminToken, guest }) => {
|
||||
test('flipping upload_rate_enabled off bypasses the limit', async ({
|
||||
api,
|
||||
adminToken,
|
||||
guest,
|
||||
}) => {
|
||||
await api.patchConfig(adminToken, { upload_rate_enabled: 'false' });
|
||||
|
||||
const h = await guest('NoQuota');
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { seedUpload } from '../../helpers/seed';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
test.describe('Comments — UI round-trip (CR1)', () => {
|
||||
test('a comment typed in the lightbox persists to the backend', async ({
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { SseListener } from '../../helpers/sse-listener';
|
||||
import { seedUpload, seedComment, findFeedRow } from '../../helpers/seed';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
async function like(jwt: string, uploadId: string): Promise<number> {
|
||||
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}/like`, {
|
||||
@@ -20,7 +19,10 @@ async function like(jwt: string, uploadId: string): Promise<number> {
|
||||
}
|
||||
|
||||
test.describe('Feed — like + comment', () => {
|
||||
test('a like counts once per user and toggles off on repeat (no double-count)', async ({ api, guest }) => {
|
||||
test('a like counts once per user and toggles off on repeat (no double-count)', async ({
|
||||
api,
|
||||
guest,
|
||||
}) => {
|
||||
const author = await guest('Author');
|
||||
const liker = await guest('Liker');
|
||||
const uploadId = await seedUpload(author.jwt);
|
||||
|
||||
@@ -11,7 +11,11 @@ import { test, expect } from '../../fixtures/test';
|
||||
import { trackStreamOpens } from '../../helpers/sse';
|
||||
|
||||
test.describe('Feed — SSE behavior', () => {
|
||||
test('backgrounding then foregrounding the tab opens a fresh SSE connection', async ({ page, guest, signIn }) => {
|
||||
test('backgrounding then foregrounding the tab opens a fresh SSE connection', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('SseReconnect');
|
||||
const streamOpens = trackStreamOpens(page);
|
||||
|
||||
@@ -25,7 +29,10 @@ test.describe('Feed — SSE behavior', () => {
|
||||
// new stream opens while hidden.
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => true });
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' });
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
get: () => 'hidden',
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
// Snapshot the count AFTER backgrounding — this baselines out the initial open (and
|
||||
@@ -36,7 +43,10 @@ test.describe('Feed — SSE behavior', () => {
|
||||
// Foreground again → connectSse() mints a new ticket and opens a new EventSource.
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false });
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' });
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
get: () => 'visible',
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
|
||||
|
||||
@@ -33,7 +33,10 @@ test.describe('Feed — error toast on user action failures', () => {
|
||||
route.fulfill({
|
||||
status: 429,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'rate_limited', message: 'Zu viele Anfragen — bitte kurz warten.' }),
|
||||
body: JSON.stringify({
|
||||
error: 'rate_limited',
|
||||
message: 'Zu viele Anfragen — bitte kurz warten.',
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -5,13 +5,16 @@
|
||||
* for a guest who's already viewing the feed.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
test.describe('Host — event lock', () => {
|
||||
test('closing the event via API sets uploads_locked_at; opening clears it', async ({ host, api }) => {
|
||||
test('closing the event via API sets uploads_locked_at; opening clears it', async ({
|
||||
host,
|
||||
api,
|
||||
}) => {
|
||||
// The frontend doesn't (yet) render a per-guest "uploads locked" banner on
|
||||
// the feed — that's the journey §9 banner, currently a UX gap. We assert
|
||||
// the API + DB contract here and leave the banner check for once it ships.
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
|
||||
await api.closeEvent(host.jwt);
|
||||
const evRes = await fetch(`${BASE}/api/v1/host/event`, {
|
||||
@@ -38,8 +41,11 @@ test.describe('Host — event lock', () => {
|
||||
// event (USER_JOURNEYS §9.3, FEATURES capability matrix). Only new uploads are
|
||||
// rejected. (An earlier revision froze social interaction too; that contradicted
|
||||
// the documented behavior and was reverted.)
|
||||
test('a closed event still allows likes and comments, but blocks new uploads', async ({ api, host, guest }) => {
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
test('a closed event still allows likes and comments, but blocks new uploads', async ({
|
||||
api,
|
||||
host,
|
||||
guest,
|
||||
}) => {
|
||||
const g = await guest('SocialLocked');
|
||||
|
||||
// Upload while still open so there's a target to interact with.
|
||||
|
||||
@@ -7,7 +7,11 @@ import { test, expect } from '../../fixtures/test';
|
||||
import { seedUpload, seedComment } from '../../helpers/seed';
|
||||
|
||||
test.describe('Host — moderation API', () => {
|
||||
test('ban always hides — a banned user is is_banned AND uploads_hidden (USER_JOURNEYS §9.5)', async ({ api, host, guest }) => {
|
||||
test('ban always hides — a banned user is is_banned AND uploads_hidden (USER_JOURNEYS §9.5)', async ({
|
||||
api,
|
||||
host,
|
||||
guest,
|
||||
}) => {
|
||||
// Ban is unconditionally a hide: a banned user's content is "gone" everywhere. The endpoint
|
||||
// takes no body and there is no per-request opt-out (the legacy hide_uploads flag is gone).
|
||||
const target = await guest('Banned2');
|
||||
@@ -23,11 +27,14 @@ test.describe('Host — moderation API', () => {
|
||||
await api.banUser(host.jwt, target.userId);
|
||||
|
||||
// Direct fetch — multipart body shape is just a marker; the auth middleware should reject before parsing.
|
||||
const res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/upload', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${target.jwt}` },
|
||||
body: new FormData(),
|
||||
});
|
||||
const res = await fetch(
|
||||
(process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/upload',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${target.jwt}` },
|
||||
body: new FormData(),
|
||||
}
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
@@ -87,15 +94,20 @@ test.describe('Host — live role/ban revocation (H1)', () => {
|
||||
|
||||
test('a banned host cannot unban themselves', async ({ api, adminToken, host }) => {
|
||||
await api.banUser(adminToken, host.userId);
|
||||
await expect(
|
||||
api.unbanUser(host.jwt, host.userId, { expectedStatus: [204] })
|
||||
).rejects.toThrow(/→ 403/);
|
||||
await expect(api.unbanUser(host.jwt, host.userId, { expectedStatus: [204] })).rejects.toThrow(
|
||||
/→ 403/
|
||||
);
|
||||
});
|
||||
|
||||
// H1 / USER_JOURNEYS §10: a banned user keeps *read* access but every write is
|
||||
// rejected with 403. The ban is enforced on write handlers + Require{Host,Admin},
|
||||
// NOT in the base extractor (which would wrongly block reads too).
|
||||
test('a banned user keeps read access but is blocked from writes', async ({ api, host, guest, db }) => {
|
||||
test('a banned user keeps read access but is blocked from writes', async ({
|
||||
api,
|
||||
host,
|
||||
guest,
|
||||
db,
|
||||
}) => {
|
||||
const base = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
const target = await guest('BannedRW');
|
||||
const auth = (jwt: string) => ({ Authorization: `Bearer ${jwt}` });
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { seedUpload } from '../../helpers/seed';
|
||||
import { SseListener } from '../../helpers/sse-listener';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
test.describe('Host — live SSE eviction (H3)', () => {
|
||||
test('self-deleting an upload broadcasts upload-deleted', async ({ guest }) => {
|
||||
@@ -24,17 +23,10 @@ test.describe('Host — live SSE eviction (H3)', () => {
|
||||
});
|
||||
expect(res.status).toBe(204);
|
||||
|
||||
await sse.waitForEvent(
|
||||
'upload-deleted',
|
||||
(e) => e.data.upload_id === uploadId
|
||||
);
|
||||
await sse.waitForEvent('upload-deleted', (e) => e.data.upload_id === uploadId);
|
||||
});
|
||||
|
||||
test('banning a user broadcasts user-hidden', async ({
|
||||
api,
|
||||
host,
|
||||
guest,
|
||||
}) => {
|
||||
test('banning a user broadcasts user-hidden', async ({ api, host, guest }) => {
|
||||
const target = await guest('HideTarget');
|
||||
await seedUpload(target.jwt, { caption: 'should vanish' });
|
||||
|
||||
@@ -43,10 +35,7 @@ test.describe('Host — live SSE eviction (H3)', () => {
|
||||
|
||||
await api.banUser(host.jwt, target.userId);
|
||||
|
||||
await sse.waitForEvent(
|
||||
'user-hidden',
|
||||
(e) => e.data.user_id === target.userId
|
||||
);
|
||||
await sse.waitForEvent('user-hidden', (e) => e.data.user_id === target.userId);
|
||||
});
|
||||
|
||||
// Frontend regression: the broadcasts above are inert if the client never
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
* guest can't hit host/admin; host can't hit admin.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
test.describe('Authorization escalation guards', () => {
|
||||
test('guest → POST /host/event/close returns 403', async ({ guest }) => {
|
||||
|
||||
@@ -19,8 +19,7 @@
|
||||
* still download the keepsake) is BY DESIGN, and is asserted in 07-adversarial/authorization-deep.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
type Method = 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||
interface Route {
|
||||
@@ -37,23 +36,66 @@ const ROUTES: Route[] = [
|
||||
{ method: 'GET', path: () => '/api/v1/host/event', name: 'GET /host/event' },
|
||||
{ method: 'POST', path: () => '/api/v1/host/event/close', name: 'POST /host/event/close' },
|
||||
{ method: 'POST', path: () => '/api/v1/host/event/open', name: 'POST /host/event/open' },
|
||||
{ method: 'POST', path: () => '/api/v1/host/gallery/release', name: 'POST /host/gallery/release' },
|
||||
{
|
||||
method: 'POST',
|
||||
path: () => '/api/v1/host/gallery/release',
|
||||
name: 'POST /host/gallery/release',
|
||||
},
|
||||
{ method: 'POST', path: () => '/api/v1/host/export/rebuild', name: 'POST /host/export/rebuild' },
|
||||
{ method: 'GET', path: () => '/api/v1/host/users', name: 'GET /host/users' },
|
||||
{ method: 'POST', path: (v) => `/api/v1/host/users/${v}/ban`, name: 'POST /host/users/{id}/ban', body: {} },
|
||||
{ method: 'POST', path: (v) => `/api/v1/host/users/${v}/unban`, name: 'POST /host/users/{id}/unban', body: {} },
|
||||
{ method: 'PATCH', path: (v) => `/api/v1/host/users/${v}/role`, name: 'PATCH /host/users/{id}/role', body: { role: 'host' } },
|
||||
{ method: 'POST', path: (v) => `/api/v1/host/users/${v}/pin-reset`, name: 'POST /host/users/{id}/pin-reset', body: {} },
|
||||
{ method: 'GET', path: () => '/api/v1/host/pin-reset-requests', name: 'GET /host/pin-reset-requests' },
|
||||
{ method: 'DELETE', path: (v) => `/api/v1/host/pin-reset-requests/${v}`, name: 'DELETE /host/pin-reset-requests/{id}' },
|
||||
{
|
||||
method: 'POST',
|
||||
path: (v) => `/api/v1/host/users/${v}/ban`,
|
||||
name: 'POST /host/users/{id}/ban',
|
||||
body: {},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: (v) => `/api/v1/host/users/${v}/unban`,
|
||||
name: 'POST /host/users/{id}/unban',
|
||||
body: {},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
path: (v) => `/api/v1/host/users/${v}/role`,
|
||||
name: 'PATCH /host/users/{id}/role',
|
||||
body: { role: 'host' },
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: (v) => `/api/v1/host/users/${v}/pin-reset`,
|
||||
name: 'POST /host/users/{id}/pin-reset',
|
||||
body: {},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: () => '/api/v1/host/pin-reset-requests',
|
||||
name: 'GET /host/pin-reset-requests',
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
path: (v) => `/api/v1/host/pin-reset-requests/${v}`,
|
||||
name: 'DELETE /host/pin-reset-requests/{id}',
|
||||
},
|
||||
{ method: 'DELETE', path: (v) => `/api/v1/host/upload/${v}`, name: 'DELETE /host/upload/{id}' },
|
||||
{ method: 'DELETE', path: (v) => `/api/v1/host/comment/${v}`, name: 'DELETE /host/comment/{id}' },
|
||||
|
||||
// ── Admin surface ───────────────────────────────────────────────────────
|
||||
{ method: 'GET', path: () => '/api/v1/admin/stats', name: 'GET /admin/stats', adminOnly: true },
|
||||
{ method: 'GET', path: () => '/api/v1/admin/config', name: 'GET /admin/config', adminOnly: true },
|
||||
{ method: 'PATCH', path: () => '/api/v1/admin/config', name: 'PATCH /admin/config', adminOnly: true, body: { quota_enabled: 'false' } },
|
||||
{ method: 'GET', path: () => '/api/v1/admin/export/jobs', name: 'GET /admin/export/jobs', adminOnly: true },
|
||||
{
|
||||
method: 'PATCH',
|
||||
path: () => '/api/v1/admin/config',
|
||||
name: 'PATCH /admin/config',
|
||||
adminOnly: true,
|
||||
body: { quota_enabled: 'false' },
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: () => '/api/v1/admin/export/jobs',
|
||||
name: 'GET /admin/export/jobs',
|
||||
adminOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
async function call(route: Route, jwt: string, victimId: string): Promise<number> {
|
||||
@@ -89,7 +131,10 @@ test.describe('Authorization sweep — every privileged route', () => {
|
||||
|
||||
const status = await call(route, host.jwt, victim.userId);
|
||||
|
||||
expect(status, `${route.name} is admin-only and must reject a host with 403, got ${status}`).toBe(403);
|
||||
expect(
|
||||
status,
|
||||
`${route.name} is admin-only and must reject a host with 403, got ${status}`
|
||||
).toBe(403);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -121,9 +166,10 @@ test.describe('Authorization sweep — every privileged route', () => {
|
||||
headers: route.body !== undefined ? { 'Content-Type': 'application/json' } : {},
|
||||
...(route.body !== undefined ? { body: JSON.stringify(route.body) } : {}),
|
||||
});
|
||||
expect([401, 403], `${route.name} must reject an anonymous caller, got ${res.status}`).toContain(
|
||||
res.status
|
||||
);
|
||||
expect(
|
||||
[401, 403],
|
||||
`${route.name} must reject an anonymous caller, got ${res.status}`
|
||||
).toContain(res.status);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,21 +13,27 @@ test.describe('Admin — config API', () => {
|
||||
await api.patchConfig(adminToken, { max_image_size_mb: '20' });
|
||||
});
|
||||
|
||||
test('non-numeric value for a numeric key is rejected', async ({ api, adminToken }) => {
|
||||
const res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ max_image_size_mb: 'not-a-number' }),
|
||||
});
|
||||
test('non-numeric value for a numeric key is rejected', async ({ adminToken }) => {
|
||||
const res = await fetch(
|
||||
(process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config',
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ max_image_size_mb: 'not-a-number' }),
|
||||
}
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('unknown config key is rejected (whitelist enforced)', async ({ adminToken }) => {
|
||||
const res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ totally_fake_key: '1' }),
|
||||
});
|
||||
const res = await fetch(
|
||||
(process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config',
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ totally_fake_key: '1' }),
|
||||
}
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
@@ -40,16 +46,23 @@ test.describe('Admin — config API', () => {
|
||||
cfg = await api.getConfig(adminToken);
|
||||
expect(cfg.upload_rate_enabled).toBe('false');
|
||||
|
||||
const res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ upload_rate_enabled: 'maybe' }),
|
||||
});
|
||||
const res = await fetch(
|
||||
(process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config',
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ upload_rate_enabled: 'maybe' }),
|
||||
}
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('privacy_note round-trips verbatim, preserving whitespace + newlines', async ({ api, adminToken }) => {
|
||||
const note = ' Datenschutz\n • Wir verwenden keine Cookies.\n • Alles bleibt im Browser.\n\n— Dein Host';
|
||||
test('privacy_note round-trips verbatim, preserving whitespace + newlines', async ({
|
||||
api,
|
||||
adminToken,
|
||||
}) => {
|
||||
const note =
|
||||
' Datenschutz\n • Wir verwenden keine Cookies.\n • Alles bleibt im Browser.\n\n— Dein Host';
|
||||
await api.patchConfig(adminToken, { privacy_note: note });
|
||||
const cfg = await api.getConfig(adminToken);
|
||||
expect(cfg.privacy_note).toBe(note);
|
||||
@@ -58,7 +71,11 @@ test.describe('Admin — config API', () => {
|
||||
});
|
||||
|
||||
test.describe('Admin — stats', () => {
|
||||
test('GET /admin/stats returns matching counts after seeding users', async ({ api, adminToken, guest }) => {
|
||||
test('GET /admin/stats returns matching counts after seeding users', async ({
|
||||
api,
|
||||
adminToken,
|
||||
guest,
|
||||
}) => {
|
||||
await guest('Stat1');
|
||||
await guest('Stat2');
|
||||
await guest('Stat3');
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { seedUpload } from '../../helpers/seed';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
test.describe('Export — no public leak (CR2)', () => {
|
||||
test('a real export is downloadable only via the gated endpoint, never via /media', async ({
|
||||
@@ -26,7 +25,10 @@ test.describe('Export — no public leak (CR2)', () => {
|
||||
await seedUpload(host.jwt, { caption: 'in the export' });
|
||||
|
||||
// Host releases the gallery → spawns the real zip/html export jobs.
|
||||
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, { method: 'POST', headers: bearer });
|
||||
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, {
|
||||
method: 'POST',
|
||||
headers: bearer,
|
||||
});
|
||||
expect(rel.status).toBe(204);
|
||||
|
||||
// Wait for the real zip job to finish writing the archive to disk.
|
||||
@@ -49,7 +51,10 @@ test.describe('Export — no public leak (CR2)', () => {
|
||||
|
||||
// …but IS retrievable via the gated single-use ticket endpoint. This proves the
|
||||
// 404 above means "not public", not merely "no file was produced".
|
||||
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, { method: 'POST', headers: bearer });
|
||||
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
|
||||
method: 'POST',
|
||||
headers: bearer,
|
||||
});
|
||||
const { ticket } = await ticketRes.json();
|
||||
const dl = await fetch(`${BASE}/api/v1/export/zip?ticket=${encodeURIComponent(ticket)}`);
|
||||
expect(dl.status).toBe(200);
|
||||
|
||||
@@ -14,8 +14,7 @@ import { test, expect } from '../../fixtures/test';
|
||||
import { uploadRaw } from '../../helpers/upload-client';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
test.describe('Export — video streaming (P4)', () => {
|
||||
test('a real video upload is streamed into Memories.zip (not dropped)', async ({ host }) => {
|
||||
@@ -29,7 +28,10 @@ test.describe('Export — video streaming (P4)', () => {
|
||||
const { id } = await up.json();
|
||||
|
||||
// Release the gallery → spawns the real zip + html export jobs.
|
||||
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, { method: 'POST', headers: bearer });
|
||||
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, {
|
||||
method: 'POST',
|
||||
headers: bearer,
|
||||
});
|
||||
expect(rel.status).toBe(204);
|
||||
|
||||
// Wait for the HTML (Memories.zip) job — that's the one whose media staging P4 changed.
|
||||
@@ -44,7 +46,10 @@ test.describe('Export — video streaming (P4)', () => {
|
||||
.toBe('done');
|
||||
|
||||
// Download Memories.zip via the gated single-use ticket.
|
||||
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, { method: 'POST', headers: bearer });
|
||||
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
|
||||
method: 'POST',
|
||||
headers: bearer,
|
||||
});
|
||||
const { ticket } = await ticketRes.json();
|
||||
const dl = await fetch(`${BASE}/api/v1/export/html?ticket=${encodeURIComponent(ticket)}`);
|
||||
expect(dl.status).toBe(200);
|
||||
|
||||
@@ -11,7 +11,11 @@ import { ExportPage } from '../../page-objects';
|
||||
const SLUG = 'e2e-test-event';
|
||||
|
||||
test.describe('Export — release and download', () => {
|
||||
test('/export shows the "not yet available" state before release', async ({ page, guest, signIn }) => {
|
||||
test('/export shows the "not yet available" state before release', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('PreRelease');
|
||||
await signIn(page, g);
|
||||
const exportPage = new ExportPage(page);
|
||||
@@ -55,9 +59,12 @@ test.describe('Export — release and download', () => {
|
||||
test('export status API reflects released flag', async ({ guest, db }) => {
|
||||
const g = await guest('ReleaseQuery');
|
||||
|
||||
let res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/export/status', {
|
||||
headers: { Authorization: `Bearer ${g.jwt}` },
|
||||
});
|
||||
let res = await fetch(
|
||||
(process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/export/status',
|
||||
{
|
||||
headers: { Authorization: `Bearer ${g.jwt}` },
|
||||
}
|
||||
);
|
||||
let body: any = await res.json();
|
||||
expect(body.released).toBe(false);
|
||||
|
||||
@@ -65,9 +72,12 @@ test.describe('Export — release and download', () => {
|
||||
await db.fakeExportJob(SLUG, 'zip', 'done');
|
||||
await db.fakeExportJob(SLUG, 'html', 'done');
|
||||
|
||||
res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/export/status', {
|
||||
headers: { Authorization: `Bearer ${g.jwt}` },
|
||||
});
|
||||
res = await fetch(
|
||||
(process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/export/status',
|
||||
{
|
||||
headers: { Authorization: `Bearer ${g.jwt}` },
|
||||
}
|
||||
);
|
||||
body = await res.json();
|
||||
expect(body.released).toBe(true);
|
||||
expect(body.zip.status).toBe('done');
|
||||
|
||||
@@ -8,23 +8,28 @@
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { ADMIN_PASSWORD } from '../../fixtures/api-client';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
/** RFC-4648 base64url with no padding. */
|
||||
function b64u(s: string) {
|
||||
return Buffer.from(s).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
|
||||
return Buffer.from(s)
|
||||
.toString('base64')
|
||||
.replace(/=+$/, '')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_');
|
||||
}
|
||||
|
||||
test.describe('Adversarial — JWT', () => {
|
||||
test('alg:none token claiming admin role is rejected', async () => {
|
||||
const header = b64u(JSON.stringify({ alg: 'none', typ: 'JWT' }));
|
||||
const payload = b64u(JSON.stringify({
|
||||
sub: '00000000-0000-0000-0000-000000000000',
|
||||
role: 'admin',
|
||||
event_id: '00000000-0000-0000-0000-000000000000',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
}));
|
||||
const payload = b64u(
|
||||
JSON.stringify({
|
||||
sub: '00000000-0000-0000-0000-000000000000',
|
||||
role: 'admin',
|
||||
event_id: '00000000-0000-0000-0000-000000000000',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
})
|
||||
);
|
||||
const token = `${header}.${payload}.`;
|
||||
const res = await fetch(`${BASE}/api/v1/admin/config`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -44,7 +49,9 @@ test.describe('Adversarial — JWT', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test('JWT with payload-tampered role=admin (re-encoded payload, original signature) is rejected', async ({ guest }) => {
|
||||
test('JWT with payload-tampered role=admin (re-encoded payload, original signature) is rejected', async ({
|
||||
guest,
|
||||
}) => {
|
||||
const g = await guest('RolePromote');
|
||||
const parts = g.jwt.split('.');
|
||||
const original = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
||||
@@ -111,7 +118,9 @@ test.describe('Adversarial — PIN brute-force', () => {
|
||||
expect(correct.status).toBe(429);
|
||||
});
|
||||
|
||||
test('parallel wrong-PIN attempts still lock the account (counter is not lost to the race)', async ({ guest }) => {
|
||||
test('parallel wrong-PIN attempts still lock the account (counter is not lost to the race)', async ({
|
||||
guest,
|
||||
}) => {
|
||||
const g = await guest('BruteParallel');
|
||||
const wrong = g.pin === '0000' ? '1111' : '0000';
|
||||
|
||||
@@ -125,7 +134,10 @@ test.describe('Adversarial — PIN brute-force', () => {
|
||||
)
|
||||
);
|
||||
const statuses = attempts.map((r) => r.status);
|
||||
expect(statuses.filter((s) => s === 200), 'a wrong PIN must never authenticate').toHaveLength(0);
|
||||
expect(
|
||||
statuses.filter((s) => s === 200),
|
||||
'a wrong PIN must never authenticate'
|
||||
).toHaveLength(0);
|
||||
|
||||
// The in-flight requests all read `pin_locked_until` before any of them wrote it, so
|
||||
// *which* of the 10 come back 429 is genuinely racy and can't be asserted. What is NOT
|
||||
@@ -140,7 +152,10 @@ test.describe('Adversarial — PIN brute-force', () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: g.displayName, pin: g.pin }),
|
||||
});
|
||||
expect(correct.status, 'after 10 wrong PINs the account must be locked, even for the right PIN').toBe(429);
|
||||
expect(
|
||||
correct.status,
|
||||
'after 10 wrong PINs the account must be locked, even for the right PIN'
|
||||
).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -188,7 +203,10 @@ test.describe('Adversarial — admin password brute-force', () => {
|
||||
}
|
||||
|
||||
// The password path actually ran (budget existed) before the limiter engaged...
|
||||
expect(statuses[0], 'first attempt should be a normal wrong-password 401, not a spurious 429').toBe(401);
|
||||
expect(
|
||||
statuses[0],
|
||||
'first attempt should be a normal wrong-password 401, not a spurious 429'
|
||||
).toBe(401);
|
||||
// ...and the limiter DID engage within the window. Delete the throttle and this is never true.
|
||||
expect(
|
||||
statuses.some((s) => s === 429),
|
||||
@@ -218,7 +236,10 @@ test.describe('Adversarial — admin password brute-force', () => {
|
||||
|
||||
// The right password, while throttled, must STILL be refused — the limiter is checked before
|
||||
// the bcrypt verify, so a valid credential does not buy a way around a brute-force lockout.
|
||||
expect((await tryLogin(ADMIN_PASSWORD)).status, 'a throttled IP is refused even with the correct password').toBe(429);
|
||||
expect(
|
||||
(await tryLogin(ADMIN_PASSWORD)).status,
|
||||
'a throttled IP is refused even with the correct password'
|
||||
).toBe(429);
|
||||
});
|
||||
|
||||
test('the throttle is gated: with the limiter disabled, a burst is NOT rate-limited', async ({
|
||||
@@ -235,6 +256,9 @@ test.describe('Adversarial — admin password brute-force', () => {
|
||||
const statuses: number[] = [];
|
||||
for (let i = 0; i < 8; i++) statuses.push((await tryLogin('wrong-' + i)).status);
|
||||
|
||||
expect(statuses.every((s) => s === 401), 'with admin_login_rate_enabled=false no attempt should be 429').toBe(true);
|
||||
expect(
|
||||
statuses.every((s) => s === 401),
|
||||
'with admin_login_rate_enabled=false no attempt should be 429'
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,15 +6,16 @@
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { seedUpload, seedComment, listComments, findFeedRow } from '../../helpers/seed';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
test.describe('Adversarial — deep authorization', () => {
|
||||
// IDOR: user B must not be able to delete user A's REAL comment. This exercises the
|
||||
// ownership guard (`comment.user_id != auth.user_id` → 403) — the previous version fired
|
||||
// at the all-zeros UUID, which 404s at the lookup BEFORE that guard runs, so it never
|
||||
// tested authorization at all.
|
||||
test('user B cannot delete user A\'s comment (real resource → 403, comment survives)', async ({ guest }) => {
|
||||
test("user B cannot delete user A's comment (real resource → 403, comment survives)", async ({
|
||||
guest,
|
||||
}) => {
|
||||
const a = await guest('CommentOwnerA');
|
||||
const b = await guest('AttackerB');
|
||||
|
||||
@@ -42,7 +43,7 @@ test.describe('Adversarial — deep authorization', () => {
|
||||
});
|
||||
|
||||
// IDOR: user B must not be able to delete user A's REAL upload.
|
||||
test('user B cannot delete user A\'s upload (403, upload survives)', async ({ guest, db }) => {
|
||||
test("user B cannot delete user A's upload (403, upload survives)", async ({ guest, db }) => {
|
||||
const a = await guest('UploadOwnerA');
|
||||
const b = await guest('AttackerB2');
|
||||
|
||||
@@ -60,7 +61,7 @@ test.describe('Adversarial — deep authorization', () => {
|
||||
});
|
||||
|
||||
// IDOR: user B must not be able to edit (re-caption / re-tag) user A's upload.
|
||||
test('user B cannot edit user A\'s upload caption (403, caption unchanged)', async ({ guest }) => {
|
||||
test("user B cannot edit user A's upload caption (403, caption unchanged)", async ({ guest }) => {
|
||||
const a = await guest('UploadOwnerA2');
|
||||
const b = await guest('AttackerB3');
|
||||
|
||||
@@ -75,7 +76,9 @@ test.describe('Adversarial — deep authorization', () => {
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
// No state change: the caption A set is intact.
|
||||
const feedRes = await fetch(`${BASE}/api/v1/feed`, { headers: { Authorization: `Bearer ${a.jwt}` } });
|
||||
const feedRes = await fetch(`${BASE}/api/v1/feed`, {
|
||||
headers: { Authorization: `Bearer ${a.jwt}` },
|
||||
});
|
||||
const row = findFeedRow(await feedRes.json(), uploadId);
|
||||
expect(row?.caption).toBe('original caption');
|
||||
});
|
||||
@@ -115,7 +118,11 @@ test.describe('Adversarial — deep authorization', () => {
|
||||
expect(await listComments(host.jwt, uploadId)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('banned user can still read the feed (read-only access preserved)', async ({ api, host, guest }) => {
|
||||
test('banned user can still read the feed (read-only access preserved)', async ({
|
||||
api,
|
||||
host,
|
||||
guest,
|
||||
}) => {
|
||||
const target = await guest('BannedRead');
|
||||
await api.banUser(host.jwt, target.userId);
|
||||
|
||||
@@ -126,7 +133,11 @@ test.describe('Adversarial — deep authorization', () => {
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('host cannot delete another host\'s session via /api/v1/session', async ({ api, host, guest }) => {
|
||||
test("host cannot delete another host's session via /api/v1/session", async ({
|
||||
api,
|
||||
host,
|
||||
guest,
|
||||
}) => {
|
||||
const otherHost = await guest('OtherHost');
|
||||
// Promote so they have a host JWT to play with.
|
||||
await api.setRole(host.jwt, otherHost.userId, 'host');
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { mintSseTicket } from '../../helpers/sse';
|
||||
import { seedUpload } from '../../helpers/seed';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
test.describe('Adversarial — small-scale abuse', () => {
|
||||
// Note: the truncate auto-fixture resets every rate-limit toggle back to false
|
||||
@@ -64,7 +63,9 @@ test.describe('Adversarial — small-scale abuse', () => {
|
||||
expect(res.status, '500 chars is the documented maximum and must be accepted').toBe(201);
|
||||
});
|
||||
|
||||
test('10 MB comment body never reaches the handler (body-size limit rejects it)', async ({ guest }) => {
|
||||
test('10 MB comment body never reaches the handler (body-size limit rejects it)', async ({
|
||||
guest,
|
||||
}) => {
|
||||
const g = await guest('BigComment');
|
||||
const uploadId = await seedUpload(g.jwt);
|
||||
const huge = 'A'.repeat(10 * 1024 * 1024);
|
||||
@@ -87,7 +88,9 @@ test.describe('Adversarial — small-scale abuse', () => {
|
||||
|
||||
const controllers = tickets.map(() => new AbortController());
|
||||
const requests = tickets.map((ticket, i) =>
|
||||
fetch(`${BASE}/api/v1/stream?ticket=${encodeURIComponent(ticket)}`, { signal: controllers[i].signal })
|
||||
fetch(`${BASE}/api/v1/stream?ticket=${encodeURIComponent(ticket)}`, {
|
||||
signal: controllers[i].signal,
|
||||
})
|
||||
);
|
||||
const responses = await Promise.all(requests);
|
||||
// All accepted (or some rate-limited — both fine).
|
||||
|
||||
Binary file not shown.
@@ -6,8 +6,7 @@
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { seedUpload } from '../../helpers/seed';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
test.describe('Media gating — moderation revokes preview access (F2)', () => {
|
||||
test('preview served via gated alias, blocked directly, and 404 after delete', async ({
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
* still work.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
function patchRole(token: string, userId: string, role: string) {
|
||||
return fetch(`${BASE}/api/v1/host/users/${userId}/role`, {
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { mintSseTicket, openStream } from '../../helpers/sse';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
test.describe('Adversarial — SSE ticket abuse', () => {
|
||||
test('minting a ticket requires authentication', async () => {
|
||||
|
||||
@@ -14,12 +14,15 @@ test.describe('Adversarial — UI render escape', () => {
|
||||
const r = await api.join(payload);
|
||||
|
||||
await page.goto('/');
|
||||
await page.evaluate(({ j, u, n, p }) => {
|
||||
localStorage.setItem('eventsnap_jwt', j);
|
||||
localStorage.setItem('eventsnap_user_id', u);
|
||||
localStorage.setItem('eventsnap_display_name', n);
|
||||
localStorage.setItem('eventsnap_pin', p);
|
||||
}, { j: r.jwt, u: r.user_id, n: payload, p: r.pin });
|
||||
await page.evaluate(
|
||||
({ j, u, n, p }) => {
|
||||
localStorage.setItem('eventsnap_jwt', j);
|
||||
localStorage.setItem('eventsnap_user_id', u);
|
||||
localStorage.setItem('eventsnap_display_name', n);
|
||||
localStorage.setItem('eventsnap_pin', p);
|
||||
},
|
||||
{ j: r.jwt, u: r.user_id, n: payload, p: r.pin }
|
||||
);
|
||||
|
||||
page.on('dialog', (d) => {
|
||||
throw new Error(`Dialog fired: ${d.message()}`);
|
||||
@@ -31,7 +34,9 @@ test.describe('Adversarial — UI render escape', () => {
|
||||
// the *absence* of a <b> at that point passes on a page that never rendered the name
|
||||
// at all — a false green on an XSS test. Prove the payload actually reached the DOM
|
||||
// (as escaped text) before concluding anything about how it was rendered.
|
||||
await expect(page.getByText(payload, { exact: false }).first()).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText(payload, { exact: false }).first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
const fired = await page.evaluate(() => (window as any).__xssFired === true);
|
||||
expect(fired).toBe(false);
|
||||
@@ -42,17 +47,23 @@ test.describe('Adversarial — UI render escape', () => {
|
||||
await expect(page.locator('script:has-text("__xssFired")')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('rendering of a known SQL-injection-shaped name does not break the page', async ({ page, api }) => {
|
||||
test('rendering of a known SQL-injection-shaped name does not break the page', async ({
|
||||
page,
|
||||
api,
|
||||
}) => {
|
||||
const payload = `'); DROP TABLE users; --`;
|
||||
const r = await api.join(payload);
|
||||
|
||||
await page.goto('/');
|
||||
await page.evaluate(({ j, u, n, p }) => {
|
||||
localStorage.setItem('eventsnap_jwt', j);
|
||||
localStorage.setItem('eventsnap_user_id', u);
|
||||
localStorage.setItem('eventsnap_display_name', n);
|
||||
localStorage.setItem('eventsnap_pin', p);
|
||||
}, { j: r.jwt, u: r.user_id, n: payload, p: r.pin });
|
||||
await page.evaluate(
|
||||
({ j, u, n, p }) => {
|
||||
localStorage.setItem('eventsnap_jwt', j);
|
||||
localStorage.setItem('eventsnap_user_id', u);
|
||||
localStorage.setItem('eventsnap_display_name', n);
|
||||
localStorage.setItem('eventsnap_pin', p);
|
||||
},
|
||||
{ j: r.jwt, u: r.user_id, n: payload, p: r.pin }
|
||||
);
|
||||
|
||||
await page.goto('/account');
|
||||
// Page renders.
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { seedUpload, seedComment } from '../../helpers/seed';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
/**
|
||||
* Every payload sets `window.__x = 1` if it executes. The marker is deliberately terse:
|
||||
@@ -36,7 +35,10 @@ const XSS_PAYLOADS = [
|
||||
// payload is edited past it, we want a loud failure here rather than six silent no-ops.
|
||||
const NAME_MAX = 50;
|
||||
for (const p of XSS_PAYLOADS) {
|
||||
if (p.length > NAME_MAX) throw new Error(`XSS payload exceeds the ${NAME_MAX}-char display-name cap and would never be stored: ${p}`);
|
||||
if (p.length > NAME_MAX)
|
||||
throw new Error(
|
||||
`XSS payload exceeds the ${NAME_MAX}-char display-name cap and would never be stored: ${p}`
|
||||
);
|
||||
}
|
||||
|
||||
const SQLI_PAYLOADS = [
|
||||
@@ -48,7 +50,10 @@ const SQLI_PAYLOADS = [
|
||||
|
||||
test.describe('Adversarial — input injection (display name)', () => {
|
||||
for (const payload of XSS_PAYLOADS) {
|
||||
test(`name with XSS payload ${JSON.stringify(payload).slice(0, 40)} never executes`, async ({ api, page }) => {
|
||||
test(`name with XSS payload ${JSON.stringify(payload).slice(0, 40)} never executes`, async ({
|
||||
api,
|
||||
page,
|
||||
}) => {
|
||||
// No try/catch escape hatch: every payload is short enough to be accepted, so a
|
||||
// rejection here is a real failure (the payload would never be rendered, and the
|
||||
// "nothing executed" assertions below would be vacuous).
|
||||
@@ -77,7 +82,9 @@ test.describe('Adversarial — input injection (display name)', () => {
|
||||
|
||||
// Render guard: confirm the payload actually reached the DOM as escaped text,
|
||||
// so a "nothing fired" pass can't be because the name was never rendered.
|
||||
await expect(page.getByText(payload, { exact: false }).first()).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText(payload, { exact: false }).first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
const fired = await page.evaluate(() => (window as any).__x === 1);
|
||||
expect(fired, 'window.__x should never be set').toBe(false);
|
||||
@@ -92,7 +99,11 @@ test.describe('Adversarial — input injection (display name)', () => {
|
||||
|
||||
test.describe('Adversarial — stored XSS (caption)', () => {
|
||||
for (const payload of XSS_PAYLOADS) {
|
||||
test(`caption with XSS payload ${JSON.stringify(payload).slice(0, 40)} renders inert`, async ({ guest, page, signIn }) => {
|
||||
test(`caption with XSS payload ${JSON.stringify(payload).slice(0, 40)} renders inert`, async ({
|
||||
guest,
|
||||
page,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('CapXss');
|
||||
// A trailing marker lets us wait until the caption has actually rendered before
|
||||
// asserting nothing fired — otherwise a caption that never rendered would pass vacuously.
|
||||
@@ -100,17 +111,30 @@ test.describe('Adversarial — stored XSS (caption)', () => {
|
||||
expect(id).toMatch(/^[0-9a-f-]{36}$/);
|
||||
|
||||
const dialogs: string[] = [];
|
||||
page.on('dialog', (d) => { dialogs.push(d.message()); d.dismiss().catch(() => {}); });
|
||||
page.on('dialog', (d) => {
|
||||
dialogs.push(d.message());
|
||||
d.dismiss().catch(() => {});
|
||||
});
|
||||
|
||||
await signIn(page, g);
|
||||
// Wait for the caption text to land in the DOM (escaped, as literal text).
|
||||
await expect(page.getByText('CAPMARK', { exact: false }).first()).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText('CAPMARK', { exact: false }).first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
expect(await page.evaluate(() => (window as any).__x === 1), 'caption XSS must not fire').toBe(false);
|
||||
expect(
|
||||
await page.evaluate(() => (window as any).__x === 1),
|
||||
'caption XSS must not fire'
|
||||
).toBe(false);
|
||||
expect(dialogs, 'no dialogs from a caption').toHaveLength(0);
|
||||
// The payload must be inert text, not a live element / script.
|
||||
expect(await page.locator('img[onerror]').count(), 'no live onerror img from caption').toBe(0);
|
||||
expect(await page.locator('script:has-text("window.__x")').count(), 'no executable script from caption').toBe(0);
|
||||
expect(await page.locator('img[onerror]').count(), 'no live onerror img from caption').toBe(
|
||||
0
|
||||
);
|
||||
expect(
|
||||
await page.locator('script:has-text("window.__x")').count(),
|
||||
'no executable script from caption'
|
||||
).toBe(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -118,23 +142,30 @@ test.describe('Adversarial — stored XSS (caption)', () => {
|
||||
test.describe('Adversarial — stored XSS (comment)', () => {
|
||||
// The two payloads that actually execute on render (script injection via innerHTML
|
||||
// does not) — enough to prove the comment body is escaped without a slow 6× lightbox loop.
|
||||
const COMMENT_PAYLOADS = [
|
||||
`<img src=x onerror="window.__x=1">`,
|
||||
`"><svg onload="window.__x=1">`,
|
||||
];
|
||||
const COMMENT_PAYLOADS = [`<img src=x onerror="window.__x=1">`, `"><svg onload="window.__x=1">`];
|
||||
for (const payload of COMMENT_PAYLOADS) {
|
||||
test(`comment with XSS payload ${JSON.stringify(payload).slice(0, 40)} renders inert`, async ({ guest, page, signIn }) => {
|
||||
test(`comment with XSS payload ${JSON.stringify(payload).slice(0, 40)} renders inert`, async ({
|
||||
guest,
|
||||
page,
|
||||
signIn,
|
||||
}) => {
|
||||
const author = await guest('CmtXss');
|
||||
const id = await seedUpload(author.jwt, { caption: 'pic CAPMARK' });
|
||||
// Post the XSS comment via the API (verbatim storage).
|
||||
await seedComment(author.jwt, id, `${payload} CMTMARK`);
|
||||
|
||||
const dialogs: string[] = [];
|
||||
page.on('dialog', (d) => { dialogs.push(d.message()); d.dismiss().catch(() => {}); });
|
||||
page.on('dialog', (d) => {
|
||||
dialogs.push(d.message());
|
||||
d.dismiss().catch(() => {});
|
||||
});
|
||||
|
||||
await signIn(page, author);
|
||||
// Open the lightbox (which loads + renders comments).
|
||||
const imageButton = page.locator('article').filter({ hasText: 'CAPMARK' }).first()
|
||||
const imageButton = page
|
||||
.locator('article')
|
||||
.filter({ hasText: 'CAPMARK' })
|
||||
.first()
|
||||
.getByRole('button', { name: 'Bild vergrößern' });
|
||||
await expect(imageButton).toBeVisible({ timeout: 10_000 });
|
||||
await imageButton.click();
|
||||
@@ -142,18 +173,28 @@ test.describe('Adversarial — stored XSS (comment)', () => {
|
||||
const lightbox = page.locator('[role="dialog"][aria-labelledby="lightbox-title"]');
|
||||
await expect(lightbox).toBeVisible();
|
||||
// Wait until the comment (marker) has rendered.
|
||||
await expect(lightbox.getByText('CMTMARK', { exact: false })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(lightbox.getByText('CMTMARK', { exact: false })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
expect(await page.evaluate(() => (window as any).__x === 1), 'comment XSS must not fire').toBe(false);
|
||||
expect(
|
||||
await page.evaluate(() => (window as any).__x === 1),
|
||||
'comment XSS must not fire'
|
||||
).toBe(false);
|
||||
expect(dialogs, 'no dialogs from a comment').toHaveLength(0);
|
||||
expect(await page.locator('img[onerror]').count(), 'no live onerror img from comment').toBe(0);
|
||||
expect(await page.locator('img[onerror]').count(), 'no live onerror img from comment').toBe(
|
||||
0
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Adversarial — input injection (SQL-injection patterns)', () => {
|
||||
for (const payload of SQLI_PAYLOADS) {
|
||||
test(`SQL-shaped name ${JSON.stringify(payload).slice(0, 40)} round-trips without breaking the DB`, async ({ api, adminToken }) => {
|
||||
test(`SQL-shaped name ${JSON.stringify(payload).slice(0, 40)} round-trips without breaking the DB`, async ({
|
||||
api,
|
||||
adminToken,
|
||||
}) => {
|
||||
const res = await api.join(payload);
|
||||
expect(res.jwt).toBeTruthy();
|
||||
|
||||
@@ -196,7 +237,10 @@ test.describe('Adversarial — input length & encoding', () => {
|
||||
expect([400, 201, 409]).toContain(res.status);
|
||||
});
|
||||
|
||||
test('Unicode RTL override character in name does not corrupt rendering', async ({ api, page }) => {
|
||||
test('Unicode RTL override character in name does not corrupt rendering', async ({
|
||||
api,
|
||||
page,
|
||||
}) => {
|
||||
const rtlName = `AliceeciVlA`; // U+202E RIGHT-TO-LEFT OVERRIDE
|
||||
const r = await api.join(rtlName);
|
||||
expect(r.user_id).toMatch(/^[0-9a-f-]{36}$/);
|
||||
|
||||
@@ -16,7 +16,11 @@ test.describe('Browser chaos — environment', () => {
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test('localStorage quota exhausted — writing JWT does not crash the app', async ({ page, guest, signIn }) => {
|
||||
test('localStorage quota exhausted — writing JWT does not crash the app', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('QuotaFull');
|
||||
|
||||
// Pre-fill localStorage with junk to push us near the quota.
|
||||
@@ -46,7 +50,11 @@ test.describe('Browser chaos — environment', () => {
|
||||
expect(errors.filter((e) => !/storage|quota/i.test(e.message))).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('hostile extension simulation: CSS hiding bottom nav does not break navigation', async ({ page, guest, signIn }) => {
|
||||
test('hostile extension simulation: CSS hiding bottom nav does not break navigation', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('HostileCss');
|
||||
await signIn(page, g);
|
||||
await page.goto('/feed');
|
||||
@@ -59,7 +67,11 @@ test.describe('Browser chaos — environment', () => {
|
||||
await expect(page).toHaveURL(/\/account$/);
|
||||
});
|
||||
|
||||
test('clock skew: browser ahead by 1 hour — JWT still valid (no nbf claim)', async ({ page, guest, signIn }) => {
|
||||
test('clock skew: browser ahead by 1 hour — JWT still valid (no nbf claim)', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('ClockSkew');
|
||||
|
||||
// Override Date.now() to be 1h in the future BEFORE the JWT check.
|
||||
@@ -74,7 +86,11 @@ test.describe('Browser chaos — environment', () => {
|
||||
await expect(page.getByRole('link', { name: 'Galerie' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('clock skew: browser behind by 2 days — JWT exp may be in the past, app handles 401', async ({ page, guest, signIn }) => {
|
||||
test('clock skew: browser behind by 2 days — JWT exp may be in the past, app handles 401', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('ClockBack');
|
||||
|
||||
await page.addInitScript(() => {
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
|
||||
test.describe('Browser chaos — IndexedDB', () => {
|
||||
test('IndexedDB cleared mid-session does not break navigation', async ({ page, guest, signIn }) => {
|
||||
test('IndexedDB cleared mid-session does not break navigation', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('IdbPurge');
|
||||
await signIn(page, g);
|
||||
await page.goto('/feed');
|
||||
@@ -16,11 +20,12 @@ test.describe('Browser chaos — IndexedDB', () => {
|
||||
// Drop every IndexedDB database the app might use.
|
||||
const dbs = (await (indexedDB as any).databases?.()) ?? [];
|
||||
await Promise.all(
|
||||
dbs.map(({ name }: { name: string }) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const req = indexedDB.deleteDatabase(name);
|
||||
req.onsuccess = req.onerror = req.onblocked = () => resolve();
|
||||
})
|
||||
dbs.map(
|
||||
({ name }: { name: string }) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const req = indexedDB.deleteDatabase(name);
|
||||
req.onsuccess = req.onerror = req.onblocked = () => resolve();
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,12 @@ import { test, expect } from '../../fixtures/test';
|
||||
import { trackStreamOpens } from '../../helpers/sse';
|
||||
|
||||
test.describe('Browser chaos — multi-tab', () => {
|
||||
test('same user in two tabs — each tab establishes its own SSE stream', async ({ page, context, guest, signIn }) => {
|
||||
test('same user in two tabs — each tab establishes its own SSE stream', async ({
|
||||
page,
|
||||
context,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('Twin');
|
||||
|
||||
// Count each tab's own EventSource open. Asserting *connection establishment* is
|
||||
@@ -27,7 +32,10 @@ test.describe('Browser chaos — multi-tab', () => {
|
||||
await tab2.close();
|
||||
});
|
||||
|
||||
test('two different users in separate browser contexts have isolated localStorage', async ({ browser, guest }) => {
|
||||
test('two different users in separate browser contexts have isolated localStorage', async ({
|
||||
browser,
|
||||
guest,
|
||||
}) => {
|
||||
const a = await guest('IsoA');
|
||||
const b = await guest('IsoB');
|
||||
|
||||
@@ -37,20 +45,26 @@ test.describe('Browser chaos — multi-tab', () => {
|
||||
const pageB = await ctxB.newPage();
|
||||
|
||||
await pageA.goto('http://localhost:3101/');
|
||||
await pageA.evaluate(({ jwt, pin, userId, name }) => {
|
||||
localStorage.setItem('eventsnap_jwt', jwt);
|
||||
localStorage.setItem('eventsnap_pin', pin);
|
||||
localStorage.setItem('eventsnap_user_id', userId);
|
||||
localStorage.setItem('eventsnap_display_name', name);
|
||||
}, { jwt: a.jwt, pin: a.pin, userId: a.userId, name: a.displayName });
|
||||
await pageA.evaluate(
|
||||
({ jwt, pin, userId, name }) => {
|
||||
localStorage.setItem('eventsnap_jwt', jwt);
|
||||
localStorage.setItem('eventsnap_pin', pin);
|
||||
localStorage.setItem('eventsnap_user_id', userId);
|
||||
localStorage.setItem('eventsnap_display_name', name);
|
||||
},
|
||||
{ jwt: a.jwt, pin: a.pin, userId: a.userId, name: a.displayName }
|
||||
);
|
||||
|
||||
await pageB.goto('http://localhost:3101/');
|
||||
await pageB.evaluate(({ jwt, pin, userId, name }) => {
|
||||
localStorage.setItem('eventsnap_jwt', jwt);
|
||||
localStorage.setItem('eventsnap_pin', pin);
|
||||
localStorage.setItem('eventsnap_user_id', userId);
|
||||
localStorage.setItem('eventsnap_display_name', name);
|
||||
}, { jwt: b.jwt, pin: b.pin, userId: b.userId, name: b.displayName });
|
||||
await pageB.evaluate(
|
||||
({ jwt, pin, userId, name }) => {
|
||||
localStorage.setItem('eventsnap_jwt', jwt);
|
||||
localStorage.setItem('eventsnap_pin', pin);
|
||||
localStorage.setItem('eventsnap_user_id', userId);
|
||||
localStorage.setItem('eventsnap_display_name', name);
|
||||
},
|
||||
{ jwt: b.jwt, pin: b.pin, userId: b.userId, name: b.displayName }
|
||||
);
|
||||
|
||||
// Each context sees only its own user.
|
||||
const aUid = await pageA.evaluate(() => localStorage.getItem('eventsnap_user_id'));
|
||||
@@ -63,7 +77,11 @@ test.describe('Browser chaos — multi-tab', () => {
|
||||
await ctxB.close();
|
||||
});
|
||||
|
||||
test('localStorage is shared across tabs of the same context (real browser behavior)', async ({ context, guest, signIn }) => {
|
||||
test('localStorage is shared across tabs of the same context (real browser behavior)', async ({
|
||||
context,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
// Real browsers share localStorage across tabs of the same origin. Tab A's
|
||||
// removeItem is instantly visible in tab B's localStorage. The UX gap to
|
||||
// document is that tab B's React/Svelte state isn't *re-rendered* until the
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
|
||||
test.describe('Browser chaos — network', () => {
|
||||
test('offline → reconnect — page does not crash and bottom nav is responsive', async ({ page, guest, signIn }) => {
|
||||
test('offline → reconnect — page does not crash and bottom nav is responsive', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('Offline1');
|
||||
await signIn(page, g);
|
||||
await page.goto('/feed');
|
||||
@@ -16,7 +20,10 @@ test.describe('Browser chaos — network', () => {
|
||||
|
||||
await page.context().setOffline(true);
|
||||
// Tap the bottom nav — should not raise unhandled errors.
|
||||
await page.getByRole('link', { name: 'Konto' }).click().catch(() => {});
|
||||
await page
|
||||
.getByRole('link', { name: 'Konto' })
|
||||
.click()
|
||||
.catch(() => {});
|
||||
await page.waitForTimeout(500);
|
||||
await page.context().setOffline(false);
|
||||
|
||||
@@ -27,7 +34,11 @@ test.describe('Browser chaos — network', () => {
|
||||
expect(errors.filter((e) => !e.message.toLowerCase().includes('fetch'))).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('slow 3G simulation — initial nav completes within reasonable bound', async ({ page, guest, signIn }) => {
|
||||
test('slow 3G simulation — initial nav completes within reasonable bound', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('Slow3g');
|
||||
|
||||
// 50ms latency on every request, applied to /api/* only so navigation isn't catastrophic.
|
||||
@@ -41,7 +52,11 @@ test.describe('Browser chaos — network', () => {
|
||||
await expect(page.getByRole('link', { name: 'Galerie' })).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('intermittent API failures during navigation — UI surfaces an error state', async ({ page, guest, signIn }) => {
|
||||
test('intermittent API failures during navigation — UI surfaces an error state', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('FlakyApi');
|
||||
|
||||
let count = 0;
|
||||
|
||||
@@ -9,7 +9,11 @@ import { test, expect } from '../../fixtures/test';
|
||||
import { readStorage, clearLocalStorage, clearAllStorage } from '../../helpers/storage-helpers';
|
||||
|
||||
test.describe('Browser chaos — storage purge', () => {
|
||||
test('localStorage.clear() mid-session → next nav goes to /join, no crash', async ({ page, guest, signIn }) => {
|
||||
test('localStorage.clear() mid-session → next nav goes to /join, no crash', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('Purge1');
|
||||
await signIn(page, g);
|
||||
await page.goto('/feed');
|
||||
@@ -31,7 +35,11 @@ test.describe('Browser chaos — storage purge', () => {
|
||||
expect(['/join', '/feed', '/recover', '/']).toContain(url.pathname);
|
||||
});
|
||||
|
||||
test('cookies cleared mid-session — JWT in localStorage still works (no cookie dependency)', async ({ page, guest, signIn }) => {
|
||||
test('cookies cleared mid-session — JWT in localStorage still works (no cookie dependency)', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('Purge2');
|
||||
await signIn(page, g);
|
||||
await page.goto('/feed');
|
||||
@@ -48,7 +56,11 @@ test.describe('Browser chaos — storage purge', () => {
|
||||
expect(stillAuthed).toBe(200);
|
||||
});
|
||||
|
||||
test('sessionStorage cleared has no effect on auth (auth lives in localStorage)', async ({ page, guest, signIn }) => {
|
||||
test('sessionStorage cleared has no effect on auth (auth lives in localStorage)', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('Purge3');
|
||||
await signIn(page, g);
|
||||
await page.goto('/feed');
|
||||
@@ -74,7 +86,11 @@ test.describe('Browser chaos — storage purge', () => {
|
||||
await page.waitForURL(/admin\/login|join/, { timeout: 5_000 });
|
||||
});
|
||||
|
||||
test('PIN survives clearAuth (intentional per auth.ts comment)', async ({ page, guest, signIn }) => {
|
||||
test('PIN survives clearAuth (intentional per auth.ts comment)', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('PurgePin');
|
||||
await signIn(page, g);
|
||||
await page.goto('/account');
|
||||
|
||||
@@ -34,18 +34,25 @@ test.describe('Mobile a11y — focus trap on LightboxModal', () => {
|
||||
await trigger.click();
|
||||
|
||||
// Lightbox is role="dialog" aria-modal="true".
|
||||
const lightbox = page.locator('[role="dialog"][aria-modal="true"]').filter({ has: page.locator('img, video') });
|
||||
const lightbox = page
|
||||
.locator('[role="dialog"][aria-modal="true"]')
|
||||
.filter({ has: page.locator('img, video') });
|
||||
await expect(lightbox).toBeVisible();
|
||||
|
||||
// After opening, focus should be inside the lightbox (trap autoFocus moves
|
||||
// focus to the first focusable). Verify by checking activeElement is
|
||||
// contained.
|
||||
await expect.poll(async () => {
|
||||
return await page.evaluate(() => {
|
||||
const dlg = document.querySelector('[role="dialog"][aria-modal="true"]');
|
||||
return !!dlg && dlg.contains(document.activeElement);
|
||||
});
|
||||
}, { timeout: 2_000 }).toBe(true);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
return await page.evaluate(() => {
|
||||
const dlg = document.querySelector('[role="dialog"][aria-modal="true"]');
|
||||
return !!dlg && dlg.contains(document.activeElement);
|
||||
});
|
||||
},
|
||||
{ timeout: 2_000 }
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
// Escape dismisses the lightbox.
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
@@ -26,12 +26,18 @@ async function seedUpload(token: string, caption = 'Doubletap fixture'): Promise
|
||||
contentType: 'image/jpeg',
|
||||
caption,
|
||||
});
|
||||
if (res.status !== 201) throw new Error(`Upload seed failed (${res.status}): ${await res.text()}`);
|
||||
if (res.status !== 201)
|
||||
throw new Error(`Upload seed failed (${res.status}): ${await res.text()}`);
|
||||
return (await res.json()) as { id: string };
|
||||
}
|
||||
|
||||
test.describe('Mobile — double-tap gesture', () => {
|
||||
test('double-tap on a feed card image button registers a like', async ({ api, page, guest, signIn }) => {
|
||||
test('double-tap on a feed card image button registers a like', async ({
|
||||
api,
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const author = await guest('DtAuthor');
|
||||
const liker = await guest('DtLiker');
|
||||
const { id: uploadId } = await seedUpload(author.jwt, 'Double-tap me');
|
||||
@@ -40,7 +46,8 @@ test.describe('Mobile — double-tap gesture', () => {
|
||||
await page.goto('/feed');
|
||||
|
||||
// Locate the image button inside the card. The aria-label is "Bild vergrößern".
|
||||
const imageButton = page.locator('article')
|
||||
const imageButton = page
|
||||
.locator('article')
|
||||
.filter({ hasText: author.displayName })
|
||||
.first()
|
||||
.getByRole('button', { name: 'Bild vergrößern' });
|
||||
@@ -50,16 +57,26 @@ test.describe('Mobile — double-tap gesture', () => {
|
||||
|
||||
// Wait for the optimistic increment OR the SSE-confirmed count. We assert via the
|
||||
// API to avoid coupling to specific DOM markup for the like badge.
|
||||
await expect.poll(async () => {
|
||||
const feed = await api.getFeed(liker.jwt);
|
||||
// Backend returns { uploads: [...], next_cursor }.
|
||||
const list: any[] = feed.uploads ?? feed.items ?? feed;
|
||||
const row = Array.isArray(list) ? list.find((u: any) => u.id === uploadId) : undefined;
|
||||
return row?.like_count ?? row?.likes ?? 0;
|
||||
}, { timeout: 5_000 }).toBeGreaterThanOrEqual(1);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const feed = await api.getFeed(liker.jwt);
|
||||
// Backend returns { uploads: [...], next_cursor }.
|
||||
const list: any[] = feed.uploads ?? feed.items ?? feed;
|
||||
const row = Array.isArray(list) ? list.find((u: any) => u.id === uploadId) : undefined;
|
||||
return row?.like_count ?? row?.likes ?? 0;
|
||||
},
|
||||
{ timeout: 5_000 }
|
||||
)
|
||||
.toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('double-tap inside the lightbox triggers the heart-burst (like recorded)', async ({ api, page, guest, signIn }) => {
|
||||
test('double-tap inside the lightbox triggers the heart-burst (like recorded)', async ({
|
||||
api,
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const author = await guest('LbAuthor');
|
||||
const liker = await guest('LbLiker');
|
||||
const { id: uploadId } = await seedUpload(author.jwt, 'Lightbox heart');
|
||||
@@ -68,7 +85,8 @@ test.describe('Mobile — double-tap gesture', () => {
|
||||
await page.goto('/feed');
|
||||
|
||||
// Open the lightbox by clicking the image button.
|
||||
const imageButton = page.locator('article')
|
||||
const imageButton = page
|
||||
.locator('article')
|
||||
.filter({ hasText: author.displayName })
|
||||
.first()
|
||||
.getByRole('button', { name: 'Bild vergrößern' });
|
||||
@@ -88,12 +106,17 @@ test.describe('Mobile — double-tap gesture', () => {
|
||||
|
||||
await doubleTap(page, media);
|
||||
|
||||
await expect.poll(async () => {
|
||||
const feed = await api.getFeed(liker.jwt);
|
||||
// Backend returns { uploads: [...], next_cursor }.
|
||||
const list: any[] = feed.uploads ?? feed.items ?? feed;
|
||||
const row = Array.isArray(list) ? list.find((u: any) => u.id === uploadId) : undefined;
|
||||
return row?.like_count ?? row?.likes ?? 0;
|
||||
}, { timeout: 5_000 }).toBeGreaterThanOrEqual(1);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const feed = await api.getFeed(liker.jwt);
|
||||
// Backend returns { uploads: [...], next_cursor }.
|
||||
const list: any[] = feed.uploads ?? feed.items ?? feed;
|
||||
const row = Array.isArray(list) ? list.find((u: any) => u.id === uploadId) : undefined;
|
||||
return row?.like_count ?? row?.likes ?? 0;
|
||||
},
|
||||
{ timeout: 5_000 }
|
||||
)
|
||||
.toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* helper.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { uploadRaw, JPEG_MAGIC } from '../../helpers/upload-client';
|
||||
import { uploadRaw } from '../../helpers/upload-client';
|
||||
import { longPress } from '../../helpers/touch';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
@@ -54,7 +54,11 @@ test.describe('Mobile — long-press gesture', () => {
|
||||
await expect(sheet.getByRole('button', { name: /abbrechen/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('a quick tap (< 500 ms) does NOT open the ContextSheet — only opens the lightbox', async ({ page, guest, signIn }) => {
|
||||
test('a quick tap (< 500 ms) does NOT open the ContextSheet — only opens the lightbox', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('Lp2');
|
||||
await seedUpload(g.jwt, 'Quick tap');
|
||||
await signIn(page, g);
|
||||
@@ -68,10 +72,16 @@ test.describe('Mobile — long-press gesture', () => {
|
||||
|
||||
// Within 1 s, the ContextSheet must not be open (aria-modal is set only when
|
||||
// open). A quick tap opens the lightbox instead, which is a different element.
|
||||
await expect(page.locator('[data-testid="context-sheet"][aria-modal="true"]')).toHaveCount(0, { timeout: 1_000 });
|
||||
await expect(page.locator('[data-testid="context-sheet"][aria-modal="true"]')).toHaveCount(0, {
|
||||
timeout: 1_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('long-press suppresses the click that lands at pointerup (no double-open of lightbox)', async ({ page, guest, signIn }) => {
|
||||
test('long-press suppresses the click that lands at pointerup (no double-open of lightbox)', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('Lp3');
|
||||
await seedUpload(g.jwt, 'Suppress click');
|
||||
await signIn(page, g);
|
||||
@@ -85,6 +95,8 @@ test.describe('Mobile — long-press gesture', () => {
|
||||
// The longpress action sets `suppressNextClick = true` — so the lightbox
|
||||
// (separate role=dialog) should NOT appear in addition to the context sheet.
|
||||
// Exactly one aria-modal=true dialog should be open: the context sheet.
|
||||
await expect(page.locator('[role="dialog"][aria-modal="true"]')).toHaveCount(1, { timeout: 2_000 });
|
||||
await expect(page.locator('[role="dialog"][aria-modal="true"]')).toHaveCount(1, {
|
||||
timeout: 2_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,11 @@ import { join } from 'node:path';
|
||||
const SAMPLE_JPG = join(process.cwd(), 'fixtures', 'media', 'sample.jpg');
|
||||
|
||||
test.describe('Mobile — planned gestures (fixme until shipped)', () => {
|
||||
test.fixme('swipe left in lightbox navigates to next filtered item', async ({ page, guest, signIn }) => {
|
||||
test.fixme('swipe left in lightbox navigates to next filtered item', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const author = await guest('SwipeAuthor');
|
||||
// Seed two uploads so there's a "next" to navigate to.
|
||||
for (const cap of ['First', 'Second']) {
|
||||
@@ -35,7 +39,10 @@ test.describe('Mobile — planned gestures (fixme until shipped)', () => {
|
||||
await page.goto('/feed');
|
||||
|
||||
// Open the lightbox on the first card.
|
||||
const firstImage = page.locator('article').filter({ hasText: 'First' }).getByRole('button', { name: 'Bild vergrößern' });
|
||||
const firstImage = page
|
||||
.locator('article')
|
||||
.filter({ hasText: 'First' })
|
||||
.getByRole('button', { name: 'Bild vergrößern' });
|
||||
await firstImage.click();
|
||||
const lightbox = page.getByRole('dialog');
|
||||
await expect(lightbox).toBeVisible();
|
||||
@@ -96,11 +103,7 @@ test.describe('Mobile — planned gestures (fixme until shipped)', () => {
|
||||
const box = await page.locator('body').boundingBox();
|
||||
if (!box) throw new Error('body not visible');
|
||||
// Pull down from the top of the viewport.
|
||||
await swipe(
|
||||
page,
|
||||
{ x: box.x + box.width / 2, y: 20 },
|
||||
{ x: box.x + box.width / 2, y: 200 }
|
||||
);
|
||||
await swipe(page, { x: box.x + box.width / 2, y: 20 }, { x: box.x + box.width / 2, y: 200 });
|
||||
|
||||
await page.waitForTimeout(1_000);
|
||||
expect(deltaCalled).toBe(true);
|
||||
|
||||
@@ -17,24 +17,38 @@ import { test, expect } from '../../fixtures/test';
|
||||
import { inlineStyle } from '../../helpers/touch';
|
||||
|
||||
test.describe('Mobile — safe-area insets', () => {
|
||||
test('bottom nav declares safe-area-inset-bottom in its inline style', async ({ page, guest, signIn }) => {
|
||||
test('bottom nav declares safe-area-inset-bottom in its inline style', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('SafeAreaNav');
|
||||
await signIn(page, g);
|
||||
await page.goto('/feed');
|
||||
|
||||
const nav = page.locator('nav').filter({ has: page.getByRole('link', { name: 'Galerie' }) }).first();
|
||||
const nav = page
|
||||
.locator('nav')
|
||||
.filter({ has: page.getByRole('link', { name: 'Galerie' }) })
|
||||
.first();
|
||||
await expect(nav).toBeVisible();
|
||||
|
||||
const style = await inlineStyle(nav);
|
||||
expect(style).toContain('env(safe-area-inset-bottom)');
|
||||
});
|
||||
|
||||
test('bottom nav stays flush with viewport bottom (no large gap)', async ({ page, guest, signIn }) => {
|
||||
test('bottom nav stays flush with viewport bottom (no large gap)', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('SafeAreaFlush');
|
||||
await signIn(page, g);
|
||||
await page.goto('/feed');
|
||||
|
||||
const nav = page.locator('nav').filter({ has: page.getByRole('link', { name: 'Galerie' }) }).first();
|
||||
const nav = page
|
||||
.locator('nav')
|
||||
.filter({ has: page.getByRole('link', { name: 'Galerie' }) })
|
||||
.first();
|
||||
const viewport = page.viewportSize();
|
||||
if (!viewport) throw new Error('No viewport size set on this project');
|
||||
const box = await nav.boundingBox();
|
||||
@@ -48,7 +62,11 @@ test.describe('Mobile — safe-area insets', () => {
|
||||
// (A vacuous `/join` probe that only asserted `Array.isArray(...)` — always true —
|
||||
// was removed; the real sheet-level env() check is the structural test below.)
|
||||
|
||||
test('upload sheet and context sheet both honor env() (structural check)', async ({ page, guest, signIn }) => {
|
||||
test('upload sheet and context sheet both honor env() (structural check)', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('SafeAreaSheets');
|
||||
await signIn(page, g);
|
||||
await page.goto('/feed');
|
||||
@@ -57,9 +75,9 @@ test.describe('Mobile — safe-area insets', () => {
|
||||
await page.getByRole('button', { name: 'Hochladen' }).click();
|
||||
// Even if the sheet is offscreen / hidden, the style attribute is present in the DOM.
|
||||
const hits: number = await page.evaluate(() => {
|
||||
return Array.from(document.querySelectorAll<HTMLElement>('[style]'))
|
||||
.filter((el) => (el.getAttribute('style') ?? '').includes('env(safe-area-inset-bottom)'))
|
||||
.length;
|
||||
return Array.from(document.querySelectorAll<HTMLElement>('[style]')).filter((el) =>
|
||||
(el.getAttribute('style') ?? '').includes('env(safe-area-inset-bottom)')
|
||||
).length;
|
||||
});
|
||||
expect(hits).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
@@ -24,7 +24,11 @@ test.describe('Mobile a11y — sheets dismiss on Escape', () => {
|
||||
await expect(sheet).not.toBeVisible({ timeout: 2_000 });
|
||||
});
|
||||
|
||||
test('leave-confirm sheet (built on ConfirmSheet) closes on Escape', async ({ page, guest, signIn }) => {
|
||||
test('leave-confirm sheet (built on ConfirmSheet) closes on Escape', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('LeaveEsc');
|
||||
await signIn(page, g);
|
||||
await page.goto('/account');
|
||||
|
||||
@@ -16,8 +16,12 @@ const MIN_TOUCH = 44;
|
||||
|
||||
async function assertTouchTarget(box: { width: number; height: number } | null, name: string) {
|
||||
if (!box) throw new Error(`${name} not visible — no bounding box`);
|
||||
expect.soft(box.width, `${name} width ≥ ${MIN_TOUCH}px (got ${box.width})`).toBeGreaterThanOrEqual(MIN_TOUCH);
|
||||
expect.soft(box.height, `${name} height ≥ ${MIN_TOUCH}px (got ${box.height})`).toBeGreaterThanOrEqual(MIN_TOUCH);
|
||||
expect
|
||||
.soft(box.width, `${name} width ≥ ${MIN_TOUCH}px (got ${box.width})`)
|
||||
.toBeGreaterThanOrEqual(MIN_TOUCH);
|
||||
expect
|
||||
.soft(box.height, `${name} height ≥ ${MIN_TOUCH}px (got ${box.height})`)
|
||||
.toBeGreaterThanOrEqual(MIN_TOUCH);
|
||||
}
|
||||
|
||||
test.describe('Mobile — touch target audit', () => {
|
||||
@@ -55,6 +59,9 @@ test.describe('Mobile — touch target audit', () => {
|
||||
await expect(page.getByTestId('pin-modal')).toBeVisible();
|
||||
|
||||
await assertTouchTarget(await page.getByTestId('pin-copy').boundingBox(), 'PIN copy button');
|
||||
await assertTouchTarget(await page.getByTestId('continue-to-feed').boundingBox(), 'Continue-to-feed button');
|
||||
await assertTouchTarget(
|
||||
await page.getByTestId('continue-to-feed').boundingBox(),
|
||||
'Continue-to-feed button'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
|
||||
test.describe('Mobile — upload composer cancel confirmation', () => {
|
||||
test('typing a caption then tapping X opens the discard ConfirmSheet', async ({ page, guest, signIn }) => {
|
||||
test('typing a caption then tapping X opens the discard ConfirmSheet', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('CancelConf');
|
||||
await signIn(page, g);
|
||||
|
||||
@@ -31,7 +35,11 @@ test.describe('Mobile — upload composer cancel confirmation', () => {
|
||||
await expect(caption).toHaveValue(/a meaningful caption/);
|
||||
});
|
||||
|
||||
test('with no content, tapping X navigates directly to /feed', async ({ page, guest, signIn }) => {
|
||||
test('with no content, tapping X navigates directly to /feed', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('CancelEmpty');
|
||||
await signIn(page, g);
|
||||
await page.goto('/upload');
|
||||
|
||||
@@ -16,13 +16,20 @@ const VIEWPORTS = [
|
||||
|
||||
test.describe('Mobile — viewport reflow', () => {
|
||||
for (const vp of VIEWPORTS) {
|
||||
test(`bottom nav remains usable at ${vp.name} (${vp.width}×${vp.height})`, async ({ page, guest, signIn }) => {
|
||||
test(`bottom nav remains usable at ${vp.name} (${vp.width}×${vp.height})`, async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest(`Reflow_${vp.width}x${vp.height}`);
|
||||
await signIn(page, g);
|
||||
await page.setViewportSize({ width: vp.width, height: vp.height });
|
||||
await page.goto('/feed');
|
||||
|
||||
const nav = page.locator('nav').filter({ has: page.getByRole('link', { name: 'Galerie' }) }).first();
|
||||
const nav = page
|
||||
.locator('nav')
|
||||
.filter({ has: page.getByRole('link', { name: 'Galerie' }) })
|
||||
.first();
|
||||
const fab = page.getByRole('button', { name: 'Hochladen' });
|
||||
|
||||
await expect(nav).toBeVisible();
|
||||
@@ -40,11 +47,15 @@ test.describe('Mobile — viewport reflow', () => {
|
||||
if (!fabBox) throw new Error('FAB has no bounding box');
|
||||
const fabMidX = fabBox.x + fabBox.width / 2;
|
||||
const expectedMid = vp.width / 2;
|
||||
expect.soft(Math.abs(fabMidX - expectedMid)).toBeLessThanOrEqual(vp.width * 0.30);
|
||||
expect.soft(Math.abs(fabMidX - expectedMid)).toBeLessThanOrEqual(vp.width * 0.3);
|
||||
});
|
||||
}
|
||||
|
||||
test('rotation portrait → landscape preserves auth + bottom nav', async ({ page, guest, signIn }) => {
|
||||
test('rotation portrait → landscape preserves auth + bottom nav', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('Rotate');
|
||||
await signIn(page, g);
|
||||
await page.goto('/feed');
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { seedUpload } from '../../helpers/seed';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
async function feedDelta(jwt: string, since: string): Promise<any> {
|
||||
const res = await fetch(`${BASE}/api/v1/feed/delta?since=${encodeURIComponent(since)}`, {
|
||||
@@ -40,9 +39,10 @@ test.describe('Realtime — ban replays in the reconnect delta (H1 audit fix)',
|
||||
|
||||
// A delta from the pre-ban cursor MUST surface the ban so the client can evict.
|
||||
const afterBan = await feedDelta(host.jwt, cursorBefore);
|
||||
expect(afterBan.hidden_user_ids, 'ban replayed to a client that missed the live event').toContain(
|
||||
g.userId
|
||||
);
|
||||
expect(
|
||||
afterBan.hidden_user_ids,
|
||||
'ban replayed to a client that missed the live event'
|
||||
).toContain(g.userId);
|
||||
|
||||
// And a delta from a cursor AFTER the ban must NOT re-surface it (the `>= since` filter
|
||||
// means a client already past the ban isn't told again forever).
|
||||
|
||||
@@ -44,8 +44,8 @@ import { join } from 'node:path';
|
||||
import { seedUpload } from '../../helpers/seed';
|
||||
import { uploadRaw, uploadPausedMidStream } from '../../helpers/upload-client';
|
||||
import { SseListener } from '../../helpers/sse-listener';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
const SLUG = 'e2e-test-event';
|
||||
const N_UPLOADS = 6;
|
||||
/** A ZIP holding no entries: just the 22-byte end-of-central-directory record. */
|
||||
@@ -74,7 +74,9 @@ function post(path: string, jwt: string) {
|
||||
}
|
||||
|
||||
async function exportStatus(jwt: string): Promise<any> {
|
||||
const res = await fetch(BASE + '/api/v1/export/status', { headers: { Authorization: `Bearer ${jwt}` } });
|
||||
const res = await fetch(BASE + '/api/v1/export/status', {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -85,10 +87,13 @@ async function mintTicket(jwt: string): Promise<string> {
|
||||
|
||||
async function waitExportDone(jwt: string) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const s = await exportStatus(jwt);
|
||||
return s.released === true && s.zip?.status === 'done' && s.html?.status === 'done';
|
||||
}, { timeout: 60_000, intervals: [500] })
|
||||
.poll(
|
||||
async () => {
|
||||
const s = await exportStatus(jwt);
|
||||
return s.released === true && s.zip?.status === 'done' && s.html?.status === 'done';
|
||||
},
|
||||
{ timeout: 60_000, intervals: [500] }
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
@@ -151,10 +156,14 @@ test.describe('Flow re-review — reopen→re-release export integrity + stale-k
|
||||
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
|
||||
|
||||
// Post-release uploads must be rejected (belt-and-suspenders on top of the lock).
|
||||
const rejected = await uploadRaw(host.jwt, readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg')), {
|
||||
filename: 'late.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
const rejected = await uploadRaw(
|
||||
host.jwt,
|
||||
readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg')),
|
||||
{
|
||||
filename: 'late.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
}
|
||||
);
|
||||
expect(rejected.status).toBe(403);
|
||||
|
||||
// Churn: reopen (retires the generation) then re-release, several times in quick
|
||||
@@ -418,7 +427,10 @@ test.describe('Flow re-review — reopen→re-release export integrity + stale-k
|
||||
listing.some((n) => n.includes(takedown)),
|
||||
'the taken-down photo must be gone from the regenerated keepsake'
|
||||
).toBe(false);
|
||||
expect(listing.some((n) => n.includes(keep)), 'the kept photo survives').toBe(true);
|
||||
expect(
|
||||
listing.some((n) => n.includes(keep)),
|
||||
'the kept photo survives'
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('BANNING a guest after release removes their photos from the keepsake', async ({
|
||||
@@ -451,7 +463,10 @@ test.describe('Flow re-review — reopen→re-release export integrity + stale-k
|
||||
listing.some((n) => n.includes(badPhoto)),
|
||||
"a banned guest's photo must not survive in the keepsake everyone downloads"
|
||||
).toBe(false);
|
||||
expect(listing.some((n) => n.includes(goodPhoto)), 'everyone else keeps their photos').toBe(true);
|
||||
expect(
|
||||
listing.some((n) => n.includes(goodPhoto)),
|
||||
'everyone else keeps their photos'
|
||||
).toBe(true);
|
||||
expect(listing).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -543,7 +558,9 @@ test.describe('Flow re-review — reopen→re-release export integrity + stale-k
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// ... and it bumped the epoch (the viewer must rebuild) ...
|
||||
await expect.poll(async () => await eventEpoch(), { timeout: 10_000 }).toBeGreaterThan(eventEpochBefore);
|
||||
await expect
|
||||
.poll(async () => await eventEpoch(), { timeout: 10_000 })
|
||||
.toBeGreaterThan(eventEpochBefore);
|
||||
|
||||
// ... while the ZIP was CARRIED FORWARD, not rebuilt: its row rides the new epoch (the media
|
||||
// didn't change, so a full ZIP rebuild would be wasted work).
|
||||
@@ -663,7 +680,9 @@ test.describe('Flow re-review — reopen→re-release export integrity + stale-k
|
||||
|
||||
// Stuck: the keepsake is not downloadable and no amount of re-releasing helps.
|
||||
const ticket = await mintTicket(host.jwt);
|
||||
expect((await fetch(BASE + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket))).status).toBe(404);
|
||||
expect(
|
||||
(await fetch(BASE + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket))).status
|
||||
).toBe(404);
|
||||
// ("Galerie wurde bereits freigegeben." — this is the dead end the rebuild endpoint exists for.)
|
||||
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(400);
|
||||
|
||||
|
||||
@@ -75,9 +75,7 @@ test.describe('Flow re-review — offline upload auto-resume (B1)', () => {
|
||||
// While offline: nothing may have reached the server, and — the crux of the
|
||||
// fix — the item must be parked as `pending`, NOT burned to `error`/`blocked`
|
||||
// (which on the old code would require a manual retry tap).
|
||||
await expect
|
||||
.poll(() => queueStatuses(page), { timeout: 8_000 })
|
||||
.toEqual(['pending']);
|
||||
await expect.poll(() => queueStatuses(page), { timeout: 8_000 }).toEqual(['pending']);
|
||||
expect(await db.countUploadsForUser(g.userId)).toBe(0);
|
||||
|
||||
// Reconnect. The `online` listener must resume the queue automatically — we do
|
||||
@@ -85,16 +83,17 @@ test.describe('Flow re-review — offline upload auto-resume (B1)', () => {
|
||||
await page.context().setOffline(false);
|
||||
|
||||
// The queued upload is now created server-side without any manual interaction.
|
||||
await expect
|
||||
.poll(() => db.countUploadsForUser(g.userId), { timeout: 20_000 })
|
||||
.toBe(1);
|
||||
await expect.poll(() => db.countUploadsForUser(g.userId), { timeout: 20_000 }).toBe(1);
|
||||
|
||||
// And the queue item ends terminal-good (done) or is drained — never stuck in error.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const s = await queueStatuses(page);
|
||||
return s.every((x) => x === 'done') || s.length === 0;
|
||||
}, { timeout: 10_000 })
|
||||
.poll(
|
||||
async () => {
|
||||
const s = await queueStatuses(page);
|
||||
return s.every((x) => x === 'done') || s.length === 0;
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 SAMPLE = () => readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg'));
|
||||
|
||||
@@ -36,7 +37,6 @@ test.describe('Upload — locked event uses a distinct, reversible 403 (audit fi
|
||||
});
|
||||
|
||||
test('released gallery → 403 uploads_locked (also reversible via reopen)', async ({ host }) => {
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${host.jwt}` },
|
||||
|
||||
Reference in New Issue
Block a user