Closes the coverage gaps the audit flagged and I verified were real:
pin-reset-lifecycle.spec.ts (new): the whole "I forgot my PIN" journey (§4) had only its
403 gate tested. Now: the always-204 non-enumeration contract (unknown name looks
identical to known); dedup (ON CONFLICT); admins are EXCLUDED from the queue; the
3-per-15-min throttle; and a host reset REVOKES the target's sessions (the pre-reset JWT
401s afterward) and clears the request. Sessions are validated per-request against the
DB, so the revoke is observable.
logout-everywhere.spec.ts (new): DELETE /sessions ("sign out everywhere") had ZERO tests.
Proves it revokes ALL of the caller's sessions across two devices (both tokens 401 after),
not just the current one, and doesn't touch another user's sessions.
media-gating: the file claimed delete AND ban-hide both revoke preview access, but only
delete was exercised. Add the ban case — a banned uploader's gated preview 404s, same as
a takedown. (Thumbnail shares the identical find_by_id_visible gate; the seed fixture
produces no thumbnail derivative, so preview is the honest thing to assert.)
export caption test: an edit after release regenerates the viewer (epoch bumps) while the
ZIP is carried forward, not rebuilt — guards the fix in the previous commit.
Helpers: api.listPinResetRequests; db.countSessionsForUser / countPinResetRequestsForUser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
178 lines
6.7 KiB
TypeScript
178 lines
6.7 KiB
TypeScript
/**
|
|
* Tiny typed wrapper around the EventSnap REST API for use inside tests.
|
|
* Used to seed data far faster than driving the UI through every join /
|
|
* upload, and to set up adversarial states (banned users, locked PINs) that
|
|
* the UI cannot reach.
|
|
*
|
|
* Auth: pass `token` on individual calls; no global state.
|
|
*/
|
|
|
|
export const ADMIN_PASSWORD = 'admin-test-pw';
|
|
|
|
export class ApiClient {
|
|
constructor(private baseUrl: string = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') {}
|
|
|
|
private async request<T>(
|
|
method: string,
|
|
path: string,
|
|
opts: { token?: string; body?: unknown; expectedStatus?: number | number[] } = {}
|
|
): Promise<{ status: number; body: T }> {
|
|
const headers: Record<string, string> = {};
|
|
if (opts.token) headers['Authorization'] = `Bearer ${opts.token}`;
|
|
if (opts.body !== undefined) headers['Content-Type'] = 'application/json';
|
|
|
|
const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
|
|
method,
|
|
headers,
|
|
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
});
|
|
|
|
const expected = opts.expectedStatus ?? [200, 201, 204];
|
|
const allowed = Array.isArray(expected) ? expected : [expected];
|
|
let body: unknown = undefined;
|
|
if (res.status !== 204) {
|
|
const text = await res.text();
|
|
try {
|
|
body = text.length > 0 ? JSON.parse(text) : undefined;
|
|
} catch {
|
|
body = text;
|
|
}
|
|
}
|
|
if (!allowed.includes(res.status)) {
|
|
throw new Error(
|
|
`API ${method} ${path} → ${res.status} (expected ${allowed.join('/')}). Body: ${JSON.stringify(body)}`
|
|
);
|
|
}
|
|
return { status: res.status, body: body as T };
|
|
}
|
|
|
|
// ── Auth ───────────────────────────────────────────────────────────────
|
|
async join(displayName: string): Promise<{ jwt: string; pin: string; user_id: string; is_new: boolean }> {
|
|
const { body } = await this.request<any>('POST', '/join', {
|
|
body: { display_name: displayName },
|
|
expectedStatus: [201],
|
|
});
|
|
return body;
|
|
}
|
|
|
|
async recover(displayName: string, pin: string, opts: { expectedStatus?: number | number[] } = {}) {
|
|
return this.request<any>('POST', '/recover', {
|
|
body: { display_name: displayName, pin },
|
|
expectedStatus: opts.expectedStatus ?? [200],
|
|
});
|
|
}
|
|
|
|
async adminLogin(password: string = ADMIN_PASSWORD): Promise<string> {
|
|
const { body } = await this.request<{ jwt: string }>('POST', '/admin/login', {
|
|
body: { password },
|
|
});
|
|
return body.jwt;
|
|
}
|
|
|
|
async logout(token: string) {
|
|
return this.request<void>('DELETE', '/session', { token, expectedStatus: [204] });
|
|
}
|
|
|
|
// ── Test-mode helpers ──────────────────────────────────────────────────
|
|
async truncate(adminToken: string) {
|
|
return this.request<void>('POST', '/admin/__truncate', {
|
|
token: adminToken,
|
|
expectedStatus: [204],
|
|
});
|
|
}
|
|
|
|
// ── Config ─────────────────────────────────────────────────────────────
|
|
async patchConfig(adminToken: string, patch: Record<string, string>) {
|
|
return this.request<void>('PATCH', '/admin/config', {
|
|
token: adminToken,
|
|
body: patch,
|
|
expectedStatus: [204],
|
|
});
|
|
}
|
|
|
|
async getConfig(adminToken: string): Promise<Record<string, string>> {
|
|
const { body } = await this.request<Record<string, string>>('GET', '/admin/config', { token: adminToken });
|
|
return body;
|
|
}
|
|
|
|
// ── Host moderation ────────────────────────────────────────────────────
|
|
async listUsers(token: string) {
|
|
const { body } = await this.request<any[]>('GET', '/host/users', { token });
|
|
return body;
|
|
}
|
|
|
|
async setRole(token: string, userId: string, role: 'guest' | 'host') {
|
|
return this.request<void>('PATCH', `/host/users/${userId}/role`, {
|
|
token,
|
|
body: { role },
|
|
expectedStatus: [200, 204],
|
|
});
|
|
}
|
|
|
|
// A ban ALWAYS hides the user's uploads — the backend takes no body and ignores any
|
|
// `hide_uploads` flag (the old opt-out was removed). No per-request options.
|
|
async banUser(token: string, userId: string) {
|
|
return this.request<void>('POST', `/host/users/${userId}/ban`, {
|
|
token,
|
|
expectedStatus: [200, 204],
|
|
});
|
|
}
|
|
|
|
async unbanUser(token: string, userId: string, opts: { expectedStatus?: number | number[] } = {}) {
|
|
return this.request<void>('POST', `/host/users/${userId}/unban`, {
|
|
token,
|
|
expectedStatus: opts.expectedStatus ?? [200, 204],
|
|
});
|
|
}
|
|
|
|
/** Reset another user's PIN. Returns the plaintext PIN the host must relay once. */
|
|
async resetUserPin(
|
|
token: string,
|
|
userId: string,
|
|
opts: { expectedStatus?: number | number[] } = {}
|
|
): Promise<{ status: number; body: { pin?: string } }> {
|
|
return this.request<{ pin?: string }>('POST', `/host/users/${userId}/pin-reset`, {
|
|
token,
|
|
expectedStatus: opts.expectedStatus ?? [200],
|
|
});
|
|
}
|
|
|
|
async listPinResetRequests(token: string): Promise<any[]> {
|
|
const { body } = await this.request<any[]>('GET', '/host/pin-reset-requests', { token });
|
|
return body;
|
|
}
|
|
|
|
async closeEvent(token: string) {
|
|
return this.request<void>('POST', '/host/event/close', { token, expectedStatus: [200, 204] });
|
|
}
|
|
|
|
async openEvent(token: string) {
|
|
return this.request<void>('POST', '/host/event/open', { token, expectedStatus: [200, 204] });
|
|
}
|
|
|
|
// ── Feed ───────────────────────────────────────────────────────────────
|
|
async getFeed(token: string) {
|
|
const { body } = await this.request<any>('GET', '/feed', { token });
|
|
return body;
|
|
}
|
|
|
|
async getStats(adminToken: string) {
|
|
const { body } = await this.request<any>('GET', '/admin/stats', { token: adminToken });
|
|
return body;
|
|
}
|
|
|
|
// ── Health ─────────────────────────────────────────────────────────────
|
|
async waitForHealth(retries = 60): Promise<void> {
|
|
for (let i = 0; i < retries; i++) {
|
|
try {
|
|
const res = await fetch(`${this.baseUrl}/health`);
|
|
if (res.ok) return;
|
|
} catch {
|
|
/* keep retrying */
|
|
}
|
|
await new Promise((r) => setTimeout(r, 1000));
|
|
}
|
|
throw new Error(`Backend never became healthy at ${this.baseUrl}/health`);
|
|
}
|
|
}
|