Three specs asserted the OLD policy — that three wrong PINs lock an account — which is exactly the behaviour the previous commit removed, because that threshold sat below the per-(IP, name) throttle ceiling and so let any single IP lock any guest whose display name is readable off the feed. Rewritten to assert the distinction the fix introduces, which a status code alone cannot show: both tiers answer 429, but only the account lock costs the VICTIM. The new specs read the row via db.isPinLocked rather than the response, so: - one IP hammering /recover is throttled and the account stays UNLOCKED; - a distributed guesser (counter preloaded via db.setFailedPinAttempts, since no single source can reach the threshold any more) still trips the lock, and it holds even against the correct PIN; - concurrent wrong PINs are all counted — the atomicity property the old parallel test was really about, now asserted on the counter instead of inferred from a 429 that the throttle could equally have produced. The UI spec asserts the user-visible half: after four wrong PINs Dave can still get into his own account. It also now types the PIN digit by digit rather than filling and clicking, because the 4th digit auto-submits (pin-auto-submit.spec.ts) and doing both raced the button's disabled state. The adversarial spec enables rate_limits_enabled for its own run — it is off by default in this environment, so without that the throttle tier would silently not be exercised — and restores it in afterEach so it cannot leak into other specs sharing the stack. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
234 lines
8.0 KiB
TypeScript
234 lines
8.0 KiB
TypeScript
/**
|
|
* Direct PostgreSQL escape hatch for setting up states the public API doesn't
|
|
* expose — e.g. forcing a user into the locked-PIN state to assert the 429
|
|
* recovery path, or expiring sessions for chaos tests.
|
|
*
|
|
* Most tests should NOT use this: prefer `ApiClient` so the tests exercise
|
|
* the same code paths real users do. Reach for direct SQL only when the API
|
|
* can't get you where you need to go.
|
|
*/
|
|
import { Client } from 'pg';
|
|
|
|
const CONN = {
|
|
host: process.env.E2E_DB_HOST ?? 'localhost',
|
|
port: Number(process.env.E2E_DB_PORT ?? '55432'),
|
|
user: process.env.E2E_DB_USER ?? 'eventsnap_test',
|
|
password: process.env.E2E_DB_PASSWORD ?? 'eventsnap_test',
|
|
database: process.env.E2E_DB_NAME ?? 'eventsnap_test',
|
|
};
|
|
|
|
async function withClient<T>(fn: (c: Client) => Promise<T>): Promise<T> {
|
|
const client = new Client(CONN);
|
|
await client.connect();
|
|
try {
|
|
return await fn(client);
|
|
} finally {
|
|
await client.end();
|
|
}
|
|
}
|
|
|
|
export const db = {
|
|
async lockUserPin(userId: string, minutesFromNow = 15) {
|
|
await withClient((c) =>
|
|
c.query(
|
|
`UPDATE "user" SET pin_locked_until = NOW() + ($2 || ' minutes')::interval, failed_pin_attempts = 3 WHERE id = $1`,
|
|
[userId, String(minutesFromNow)]
|
|
)
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Is this user's account currently PIN-locked?
|
|
*
|
|
* Distinguishes the two ways /recover can answer 429 — the per-(IP, name) throttle, which
|
|
* costs the attacker, and the account lock, which costs the VICTIM. Only the second one is
|
|
* weaponizable, so a test asserting "a single IP cannot lock a guest out" has to look at the
|
|
* row, not at the status code.
|
|
*/
|
|
async isPinLocked(userId: string): Promise<boolean> {
|
|
return withClient(async (c) => {
|
|
const r = await c.query<{ locked: boolean }>(
|
|
`SELECT (pin_locked_until IS NOT NULL AND pin_locked_until > NOW()) AS locked
|
|
FROM "user" WHERE id = $1`,
|
|
[userId]
|
|
);
|
|
return r.rows[0]?.locked ?? false;
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Preload the wrong-PIN streak, standing in for failures that arrived from other IPs.
|
|
*
|
|
* The account lock is deliberately out of reach of any single source, so a test that wants to
|
|
* exercise it has to simulate the distributed case rather than hammer from one address.
|
|
* `last_failed_pin_at` is set to now so the 15-minute decay does not immediately reset it.
|
|
*/
|
|
async setFailedPinAttempts(userId: string, attempts: number) {
|
|
await withClient((c) =>
|
|
c.query(
|
|
`UPDATE "user" SET failed_pin_attempts = $2, last_failed_pin_at = NOW() WHERE id = $1`,
|
|
[userId, attempts]
|
|
)
|
|
);
|
|
},
|
|
|
|
/** Current wrong-PIN streak. Decays after 15 minutes — see User::increment_failed_pin. */
|
|
async failedPinAttempts(userId: string): Promise<number> {
|
|
return withClient(async (c) => {
|
|
const r = await c.query<{ failed_pin_attempts: number }>(
|
|
`SELECT failed_pin_attempts FROM "user" WHERE id = $1`,
|
|
[userId]
|
|
);
|
|
return r.rows[0]?.failed_pin_attempts ?? 0;
|
|
});
|
|
},
|
|
|
|
async expireSession(userId: string) {
|
|
await withClient((c) =>
|
|
c.query(`UPDATE session SET expires_at = NOW() - interval '1 hour' WHERE user_id = $1`, [
|
|
userId,
|
|
])
|
|
);
|
|
},
|
|
|
|
async setUploadCompressionStatus(
|
|
uploadId: string,
|
|
status: 'pending' | 'processing' | 'done' | 'failed'
|
|
) {
|
|
await withClient((c) =>
|
|
c.query(`UPDATE upload SET compression_status = $2 WHERE id = $1`, [uploadId, status])
|
|
);
|
|
},
|
|
|
|
async compressionStatus(uploadId: string): Promise<string | null> {
|
|
return withClient(async (c) => {
|
|
const r = await c.query<{ compression_status: string }>(
|
|
`SELECT compression_status FROM upload WHERE id = $1`,
|
|
[uploadId]
|
|
);
|
|
return r.rows[0]?.compression_status ?? null;
|
|
});
|
|
},
|
|
|
|
/** Which revision of the derivative pipeline produced this row's preview/display. */
|
|
async derivativesRev(uploadId: string): Promise<number | null> {
|
|
return withClient(async (c) => {
|
|
const r = await c.query<{ derivatives_rev: number }>(
|
|
`SELECT derivatives_rev FROM upload WHERE id = $1`,
|
|
[uploadId]
|
|
);
|
|
return r.rows[0]?.derivatives_rev ?? null;
|
|
});
|
|
},
|
|
|
|
async countUploadsForUser(userId: string): Promise<number> {
|
|
return withClient(async (c) => {
|
|
const r = await c.query<{ count: string }>(
|
|
`SELECT COUNT(*)::text AS count FROM upload WHERE user_id = $1 AND deleted_at IS NULL`,
|
|
[userId]
|
|
);
|
|
return Number(r.rows[0].count);
|
|
});
|
|
},
|
|
|
|
async countSessionsForUser(userId: string): Promise<number> {
|
|
return withClient(async (c) => {
|
|
const r = await c.query<{ count: string }>(
|
|
`SELECT COUNT(*)::text AS count FROM session WHERE user_id = $1`,
|
|
[userId]
|
|
);
|
|
return Number(r.rows[0].count);
|
|
});
|
|
},
|
|
|
|
async countPinResetRequestsForUser(userId: string): Promise<number> {
|
|
return withClient(async (c) => {
|
|
const r = await c.query<{ count: string }>(
|
|
`SELECT COUNT(*)::text AS count FROM pin_reset_request WHERE user_id = $1`,
|
|
[userId]
|
|
);
|
|
return Number(r.rows[0].count);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Overstate an upload's recorded size.
|
|
*
|
|
* The keepsake size estimate and the low-disk threshold are pure SQL over
|
|
* `original_size_bytes` — no file is read — so this is the lever for driving "the keepsake
|
|
* would not fit" without a genuinely full disk. The bytes on disk are unchanged; only the
|
|
* accounting the warning reads from moves.
|
|
*/
|
|
async setUploadSizeBytes(uploadId: string, bytes: number) {
|
|
await withClient((c) =>
|
|
c.query(`UPDATE upload SET original_size_bytes = $2 WHERE id = $1`, [uploadId, bytes])
|
|
);
|
|
},
|
|
|
|
async setExportReleased(slug: string, released: boolean) {
|
|
await withClient((c) =>
|
|
c.query(`UPDATE event SET export_released_at = $2 WHERE slug = $1`, [
|
|
slug,
|
|
released ? new Date() : null,
|
|
])
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Make an export "ready" (or not) in the epoch model. There is no `export_zip_ready` column any
|
|
* more — readiness is DERIVED (`released AND job.epoch = event.export_epoch AND status='done'`),
|
|
* so a job is ready exactly when its row carries the event's live epoch. To make a `done` job NOT
|
|
* ready we retire it to a dead epoch (-1), which is what a reopen effectively does.
|
|
*
|
|
* `file_path` is deliberately left NULL, so a "ready" job with no file on disk still exercises
|
|
* the download's missing-file 404 branch.
|
|
*/
|
|
async setExportZipReady(slug: string, ready: boolean) {
|
|
await withClient((c) =>
|
|
c.query(
|
|
`UPDATE export_job ej
|
|
SET epoch = CASE WHEN $2 THEN e.export_epoch ELSE -1 END
|
|
FROM event e
|
|
WHERE e.id = ej.event_id AND e.slug = $1 AND ej.type = 'zip'`,
|
|
[slug, ready]
|
|
)
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Insert a pre-baked export job row to skip the (slow) real compression path. Stamped with the
|
|
* event's CURRENT epoch so it counts as the live generation.
|
|
*/
|
|
async fakeExportJob(
|
|
eventSlug: string,
|
|
type: 'zip' | 'html',
|
|
status: 'pending' | 'running' | 'done' | 'failed',
|
|
errorMessage: string | null = null
|
|
) {
|
|
await withClient(async (c) => {
|
|
const ev = await c.query<{ id: string; export_epoch: string }>(
|
|
`SELECT id, export_epoch FROM event WHERE slug = $1`,
|
|
[eventSlug]
|
|
);
|
|
if (ev.rows.length === 0) throw new Error(`No event with slug ${eventSlug}`);
|
|
await c.query(
|
|
`INSERT INTO export_job (event_id, type, status, progress_pct, completed_at, epoch,
|
|
error_message)
|
|
VALUES ($1, $2::export_type, $3::export_status, $4, $5, $6, $7)
|
|
ON CONFLICT (event_id, type) DO UPDATE
|
|
SET status = EXCLUDED.status, progress_pct = EXCLUDED.progress_pct,
|
|
epoch = EXCLUDED.epoch, error_message = EXCLUDED.error_message`,
|
|
[
|
|
ev.rows[0].id,
|
|
type,
|
|
status,
|
|
status === 'done' ? 100 : 0,
|
|
status === 'done' ? new Date() : null,
|
|
ev.rows[0].export_epoch,
|
|
errorMessage,
|
|
]
|
|
);
|
|
});
|
|
},
|
|
};
|