Files
RecipesCam/docker/backend/test/security.mjs
T
3dtours e021f7d1ff web: prove an address with the six digits the letter carries
Signing up mailed a link and nothing else, so a visitor who signed up on one
device and read the mail on another had to leave the page the studio was open
on, or give up and stay a guest. The letter now carries six digits as well, and
the verify dialog — the face a fresh signup already lands on — takes them.

Backend, one row is both proofs. `createEmailVerification` mints the token as it
did and a `randomInt(0, 1_000_000)` code padded to six, and returns `{ token,
code }`; `sendVerification` passes both to the mailer, which puts the code first
and the link second. The code's clock is `created_at + CODE_TTL_S` (15 minutes)
and the link keeps the row's own 24-hour `expires_at`: two clocks over one row,
so the code needs no expiry column of its own. That row's `code` is NULL for
anything minted before this commit, a value no typed guess can match, so an
in-flight link from the old mail still works and its owner simply has no code to
type. `db.ts` adds both columns with `PRAGMA table_info` + `ALTER TABLE` rather
than a rebuild, and sets `attempts` to 0.

`verifyEmailCode(userId, code)` answers 'ok' | 'bad' | 'stale' | 'locked', and
the shape of the answer is the point. 'stale' is both "no live code" and "too
old", so the caller learns nothing about which; 'locked' is the spent-attempts
state, which only a fresh letter leaves. The attempt is counted BEFORE the
comparison is trusted, so an interrupted request cannot hand back a guess nobody
paid for; five (MAX_CODE_ATTEMPTS) is the cap, which is what keeps a six-digit
secret from being walked through at a hundred requests a second. The comparison
itself is `timingSafeEqual` behind a length check, the same pair the password
path uses. On success the row is deleted and `email_verified` set, so the same
row spends the link with the code — one proof, one use.

The route is `POST /api/auth/verify-code`, a POST and not a GET like the link
because a code in a query string lands in every proxy log on the way. It reads
`auth`, not `requirePro`: the whole point of it is the account that has not
passed the gate yet. Input must be exactly six digits before anything else
happens, so the counter only ever counts real guesses; a wrong or stale code is
400, a locked one 429 with `retry-after: 900`, and an already-verified caller
gets 200 without touching the row. No limiter of its own: the cap lives with the
secret on the row, and a fresh code costs one of the three resends an hour, so
five guesses per code is the budget either way.

On the web side `api.verifyCode` posts the code, and the dialog's verify face
swaps its resend button for a code box plus a smaller resend beside it: the box
is `inputMode="numeric"`, `autoComplete="one-time-code"`, `maxLength 6`, and
strips non-digits as they are typed, so the number pad comes up on a phone and
nothing can paste a password into it. The submit button is disabled until six
digits are there. The two answers a visitor can actually act on get sentences of
their own (`auth.codeBad`, `auth.codeLocked`); everything else is shown as it
comes. The link path is untouched and still works, and the dialog keeps its
"Tôi đã xác thực xong" escape in no place at all — it verified nothing, so
closing the dialog and asking again covers the same ground.

The comment in `docker/.env.example` now says the letter carries both, since a
deployment without a relay writes both to the api log.

Verified:
  backend `npm test` — 180 passed, 0 failed. The new section in security.mjs
    drives the route end to end against the source: signup leaves a six-digit
    code beside the link, a wrong code verifies nothing and leaves the account
    unproven, a five-digit body is refused, a signed-out caller cannot type one,
    the mailed code verifies, spending it spends the link, five wrong guesses
    lock the code out and the right code then does not help, a resent letter
    hands out a fresh code that is not locked out by the old guesses, and a code
    aged past its quarter hour is refused.
  otp-code-probe.cjs (scratchpad) — 10 PASS, 0 FAIL on http://localhost:8090
    against the rebuilt app and api, no page errors: a fresh signup lands on the
    verify face with the box ready, a wrong code says so and the account stays a
    guest, the mailed code unlocks PRO, and a proven address is not asked again
    on the next login.
  Regressions, 0 fail: landing-test.cjs 172, pro-gate-test.cjs 27,
    award-column-probe.cjs 18, tone-curve-probe.cjs 33. web tsc --noEmit clean.

ponytail: the code rides `created_at` rather than an `expires_at` of its own, so
the link's 24 hours and the code's 15 minutes are one column read twice; the day
the two need to drift apart independently, the column is the thing to split. The
route carries no per-IP limiter, only the per-row cap — a stranger can burn one
account's five guesses, which costs that owner a resend, and a limiter keyed on
the address would be the next thing to add if that turns out to be cheap for an
attacker. The code is not usable from another browser: it verifies the session
that asked for it, which is the behaviour the request asked for and not a gap.
2026-09-23 20:08:08 +07:00

741 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Security self-check for the API. Boots the real server against a throwaway
// DATA_DIR and exercises the boundaries that matter: who may write, what may be
// written, who may read a photo back, and who may moderate the strip.
//
// npm test (from docker/backend/)
//
// Node only — no test framework, no network beyond loopback.
import { spawn } from 'node:child_process';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const PORT = Number(process.env.TEST_PORT || 3411);
const BASE = `http://127.0.0.1:${PORT}/api`;
const ADMIN_EMAIL = 'admin@test.local';
const DATA_DIR = mkdtempSync(join(tmpdir(), 'recipescam-sec-'));
// A 1x1 PNG, and the first bytes of a JPEG (all the sniffer looks at).
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
const JPEG_HEAD = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(64)]);
let pass = 0;
let fail = 0;
const check = (name, ok, detail = '') => {
if (ok) {
pass++;
console.log(`PASS ${name}`);
} else {
fail++;
console.log(`FAIL ${name}${detail ? ` :: ${detail}` : ''}`);
}
};
// One cookie jar per actor, so "signed in as A" cannot leak into B.
function actor() {
let cookie = '';
return {
get cookie() {
return cookie;
},
async req(path, init = {}) {
const headers = { ...(init.headers ?? {}) };
if (cookie) headers.cookie = cookie;
const res = await fetch(BASE + path, { ...init, headers });
const set = res.headers.getSetCookie?.() ?? (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []);
for (const line of set) {
const value = line.split(';')[0];
if (value.startsWith('rc_session=')) cookie = value.endsWith('=') ? '' : value;
}
const text = await res.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = text;
}
return { status: res.status, headers: res.headers, setCookie: set.join(' | '), body };
},
signup(email, password = 'supersecret1') {
return this.req('/auth/signup', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password }),
});
},
upload(bytes, type) {
return this.req('/photos', { method: 'POST', headers: { 'content-type': type }, body: bytes });
},
avatar(bytes, type) {
return this.req('/auth/avatar', { method: 'POST', headers: { 'content-type': type }, body: bytes });
},
};
}
// A fresh signup proves nothing until the address it gave is confirmed: an
// unverified account is served at the guest tier (see the PRO gate below). The
// suite cannot read the mail, but the token is in the throwaway database and
// the link is the API's own route, so it is followed here for the accounts that
// are exercising something other than the gate.
const Database = (await import('better-sqlite3')).default;
function tokenFor(email) {
const db = new Database(join(DATA_DIR, 'recipescam.db'), { readonly: true });
const row = db
.prepare('SELECT token FROM email_verifications WHERE user_id = (SELECT id FROM users WHERE email = ?)')
.get(email);
db.close();
return row?.token;
}
async function followVerifyLink(email) {
const res = await fetch(`${BASE}/auth/verify?token=${tokenFor(email)}`, { redirect: 'manual' });
if (res.status !== 303) throw new Error(`verify link for ${email} answered ${res.status}`);
}
async function activeSignup(a, email) {
const res = await a.signup(email);
await followVerifyLink(email);
return res;
}
// The same letter carries a code, and the suite reaches it the same way — the
// database is the only reader of the mail here.
function codeFor(email) {
const db = new Database(join(DATA_DIR, 'recipescam.db'), { readonly: true });
const row = db
.prepare('SELECT code FROM email_verifications WHERE user_id = (SELECT id FROM users WHERE email = ?)')
.get(email);
db.close();
return row?.code;
}
// The code's own clock starts at `created_at`, so an old one is made here
// rather than waited for.
function ageCode(email, minutes) {
const db = new Database(join(DATA_DIR, 'recipescam.db'));
db.prepare(
'UPDATE email_verifications SET created_at = ? WHERE user_id = (SELECT id FROM users WHERE email = ?)',
).run(new Date(Date.now() - minutes * 60_000).toISOString(), email);
db.close();
}
// Run the sources, not a possibly stale build: the point of this suite is to
// test the code as written.
const tsx = join(ROOT, 'node_modules/.bin/tsx');
const entry = existsSync(tsx) ? [tsx, 'src/server.ts'] : ['dist/server.js'];
const server = spawn(process.execPath, entry, {
cwd: ROOT,
env: { ...process.env, PORT: String(PORT), DATA_DIR, ADMIN_EMAILS: ADMIN_EMAIL, NODE_ENV: 'test' },
stdio: ['ignore', 'pipe', 'pipe'],
});
let serverLog = '';
server.stdout.on('data', (d) => (serverLog += d));
server.stderr.on('data', (d) => (serverLog += d));
async function waitForServer() {
for (let i = 0; i < 100; i++) {
try {
const res = await fetch(`${BASE}/health`);
if (res.ok) return true;
} catch {
/* not up yet */
}
await new Promise((r) => setTimeout(r, 100));
}
return false;
}
try {
if (!(await waitForServer())) throw new Error(`server never came up:\n${serverLog}`);
const stamp = Date.now();
const admin = actor();
const user = actor();
const other = actor();
// ---- accounts -----------------------------------------------------------
check('health responds', (await fetch(`${BASE}/health`)).ok);
const badEmail = await user.signup('not-an-email');
check('signup rejects a malformed email', badEmail.status === 400, `got ${badEmail.status}`);
const badPw = await user.signup(`short${stamp}@test.local`, 'short');
check('signup rejects a short password', badPw.status === 400, `got ${badPw.status}`);
const adminSignup = await admin.signup(ADMIN_EMAIL);
check('admin account signs up', adminSignup.status === 201, `got ${adminSignup.status}`);
const userSignup = await activeSignup(user, `contributor${stamp}@test.local`);
check('contributor account signs up', userSignup.status === 201, `got ${userSignup.status}`);
await activeSignup(other, `other${stamp}@test.local`);
const cookie = userSignup.setCookie;
check('session cookie is HttpOnly', /HttpOnly/i.test(cookie), cookie);
check('session cookie is SameSite', /SameSite=Lax/i.test(cookie), cookie);
check('session cookie is not Secure over plain http', !/;\s*Secure/i.test(cookie), cookie);
check('session token is 256 bits of hex', /rc_session=[0-9a-f]{64}/.test(cookie), cookie);
// The proxy hands nginx the public scheme; a TLS visitor must get Secure.
const tls = actor();
const tlsSignup = await tls.req('/auth/signup', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-forwarded-proto': 'https' },
body: JSON.stringify({ email: `tls${stamp}@test.local`, password: 'supersecret1' }),
});
check('session cookie is Secure behind https', /;\s*Secure/i.test(tlsSignup.setCookie), tlsSignup.setCookie);
// Documented, not fixed: the 409 is a deliberate UX choice and doubles as an
// account-existence oracle.
const dup = await actor().signup(ADMIN_EMAIL);
check('duplicate signup is a 409 (known user-enumeration oracle)', dup.status === 409, `got ${dup.status}`);
const noUser = await actor().req('/auth/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: `ghost${stamp}@test.local`, password: 'supersecret1' }),
});
check('unknown email and wrong password look identical', noUser.status === 401, `got ${noUser.status}`);
const anonMe = await actor().req('/auth/me');
check('signed out is a 200 with no user', anonMe.status === 200 && anonMe.body?.user === null, JSON.stringify(anonMe.body));
const ownMe = await user.req('/auth/me');
check('/auth/me reports the signed-in account', ownMe.body?.user?.email === `contributor${stamp}@test.local`, JSON.stringify(ownMe.body));
// ---- the PRO gate: an unproven address is a guest -----------------------
// Signing up is not what earns the tier — the address is. Until its link is
// followed the account is a guest with a name: every write and every personal
// listing answers 403, which is what tells the studio to ask for the mail
// rather than for a password.
const jsonHdr = { 'content-type': 'application/json' };
const unproven = actor();
const unprovenEmail = `unproven${stamp}@test.local`;
const unprovenSignup = await unproven.signup(unprovenEmail);
check('a fresh signup is unverified', unprovenSignup.body?.user?.verified === false, JSON.stringify(unprovenSignup.body));
check(
'an unverified account may still ask for its link',
(await unproven.req('/auth/resend-verification', { method: 'POST' })).status === 200,
);
check('an unverified account cannot upload', (await unproven.upload(PNG, 'image/png')).status === 403);
check('an unverified account cannot list a folder', (await unproven.req('/photos/mine')).status === 403);
check(
'an unverified account cannot save a recipe',
(await unproven.req('/recipes', { method: 'POST', headers: jsonHdr, body: JSON.stringify({ name: 'x', recipe: {} }) })).status === 403,
);
check('an unverified account cannot wear an avatar', (await unproven.avatar(PNG, 'image/png')).status === 403);
check('a signed-out caller still gets a 401, not a 403', (await actor().req('/photos/mine')).status === 401);
const unknownLink = await fetch(`${BASE}/auth/verify?token=${'0'.repeat(64)}`, { redirect: 'manual' });
check(
'an unknown link verifies nothing',
unknownLink.status === 303 && unknownLink.headers.get('location')?.endsWith('/?verified=0'),
String(unknownLink.headers.get('location')),
);
const link = tokenFor(unprovenEmail);
check('signup leaves one verification link in the database', typeof link === 'string' && link.length === 64, String(link));
const followed = await fetch(`${BASE}/auth/verify?token=${link}`, { redirect: 'manual' });
check(
'the mailed link verifies the account',
followed.status === 303 && followed.headers.get('location')?.endsWith('/?verified=1'),
String(followed.headers.get('location')),
);
check('the account is verified from then on', (await unproven.req('/auth/me')).body?.user?.verified === true);
check('a verified account may upload', (await unproven.upload(PNG, 'image/png')).status === 201);
const replay = await fetch(`${BASE}/auth/verify?token=${link}`, { redirect: 'manual' });
check('a spent link cannot be followed twice', replay.headers.get('location')?.endsWith('/?verified=0'), String(replay.headers.get('location')));
check('the allowlisted admin needs no letter', (await admin.req('/auth/me')).body?.user?.verified === true);
// ---- the code in the same letter ----------------------------------------
// The link is not the only way to prove an address any more: the letter also
// carries six digits the visitor types into the dialog they signed up on. The
// code is the shorter-lived of the two proofs and the one worth guessing, so
// the checks below are mostly about what a wrong guess costs.
const typed = actor();
const typedEmail = `typed${stamp}@test.local`;
await typed.signup(typedEmail);
const code = codeFor(typedEmail);
check('signup leaves a six-digit code beside the link', /^\d{6}$/.test(String(code)), String(code));
const wrongCode = code === '000000' ? '111111' : '000000';
const type = (a, value) =>
a.req('/auth/verify-code', { method: 'POST', headers: jsonHdr, body: JSON.stringify({ code: value }) });
check('a wrong code verifies nothing', (await type(typed, wrongCode)).status === 400);
check('and leaves the account unproven', (await typed.req('/auth/me')).body?.user?.verified === false);
check('a code of the wrong shape is refused', (await type(typed, '12345')).status === 400);
check('a signed-out caller cannot type a code', (await type(actor(), code)).status === 401);
check('the mailed code verifies the account', (await type(typed, code)).status === 200);
check('the account is verified from then on', (await typed.req('/auth/me')).body?.user?.verified === true);
check('the code being spent spends the link with it', tokenFor(typedEmail) === undefined, String(tokenFor(typedEmail)));
const locked = actor();
const lockedEmail = `locked${stamp}@test.local`;
await locked.signup(lockedEmail);
const lockedCode = codeFor(lockedEmail);
const otherCode = lockedCode === '000000' ? '111111' : '000000';
for (let i = 0; i < 5; i++) await type(locked, otherCode);
const afterLock = await type(locked, lockedCode);
check('five wrong guesses lock the code out', afterLock.status === 429, `got ${afterLock.status}`);
check('and the right code no longer helps', (await locked.req('/auth/me')).body?.user?.verified === false);
await locked.req('/auth/resend-verification', { method: 'POST' });
const freshCode = codeFor(lockedEmail);
check('a resent letter hands out a fresh code', freshCode !== lockedCode && /^\d{6}$/.test(String(freshCode)));
check('that fresh code is not locked out by the old guesses', (await type(locked, freshCode)).status === 200);
const stale = actor();
const staleEmail = `stale${stamp}@test.local`;
await stale.signup(staleEmail);
const staleCode = codeFor(staleEmail);
ageCode(staleEmail, 16); // past CODE_TTL_S
check('a code older than its quarter hour is refused', (await type(stale, staleCode)).status === 400);
check('and leaves the account unproven', (await stale.req('/auth/me')).body?.user?.verified === false);
// ---- rate limiting ------------------------------------------------------
const brute = actor();
const bruteEmail = `brute${stamp}@test.local`;
let limited = 0;
for (let i = 0; i < 21; i++) {
const res = await brute.req('/auth/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: bruteEmail, password: 'wrong-password' }),
});
if (res.status === 429) limited++;
}
check('login attempts are rate limited', limited === 1, `429s: ${limited}`);
// ---- upload boundary ----------------------------------------------------
check('guest cannot upload', (await actor().upload(PNG, 'image/png')).status === 401);
check(
'non-image content-type is refused',
(await user.upload(Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>'), 'image/svg+xml')).status === 415,
);
check(
'svg bytes under an image content-type is refused',
(await user.upload(Buffer.from('<svg onload="alert(1)"/>'), 'image/jpeg')).status === 415,
);
check(
'a declared type that disagrees with the bytes is refused',
(await user.upload(JPEG_HEAD, 'image/png')).status === 415,
);
check('empty body is refused', (await user.upload(Buffer.alloc(0), 'image/png')).status === 400);
const tooBig = Buffer.concat([PNG, Buffer.alloc(13 * 1024 * 1024)]);
check('an over-limit body is refused', (await user.upload(tooBig, 'image/png')).status === 413);
const created = await user.upload(PNG, 'image/png');
check('a signed-in member can upload a real PNG', created.status === 201, JSON.stringify(created.body));
// ---- what the public may read ------------------------------------------
const list = await actor().req('/photos');
const listed = list.body?.photos ?? [];
check('the strip is readable anonymously', list.status === 200);
check(
'the public strip leaks no owner',
listed.length > 0 && !('userId' in listed[0]) && !('email' in listed[0]) && !('file' in listed[0]),
JSON.stringify(listed[0]),
);
const id = created.body?.photo?.id;
const served = await fetch(`${BASE}/photos/${id}/file`);
check('a contributed photo is served', served.status === 200);
check('the served photo keeps its sniffed type', served.headers.get('content-type') === 'image/png');
check('the served photo is nosniff', served.headers.get('x-content-type-options') === 'nosniff');
check('the served photo is sandboxed', (served.headers.get('content-security-policy') ?? '').includes('sandbox'));
check('the bytes round-trip intact', Buffer.from(await served.arrayBuffer()).equals(PNG));
check('an unknown id is a 404', (await fetch(`${BASE}/photos/999999/file`)).status === 404);
check('a non-numeric id is a 404', (await fetch(`${BASE}/photos/abc/file`)).status === 404);
check(
'an id cannot escape the uploads directory',
(await fetch(`${BASE}/photos/..%2f..%2fetc%2fpasswd/file`)).status === 404,
);
// ---- the member's own folder --------------------------------------------
check('a guest has no folder', (await actor().req('/photos/mine')).status === 401);
const folder = await user.req('/photos/mine');
check(
'a member lists their own photos',
folder.status === 200 && (folder.body?.photos ?? []).some((p) => p.id === id),
JSON.stringify(folder.body).slice(0, 120),
);
const stranger = actor();
await activeSignup(stranger, `stranger${stamp}@test.local`);
check("a fresh account's folder is empty", ((await stranger.req('/photos/mine')).body?.photos ?? []).length === 0);
// The strip's own labels ride the query string: the body is the image.
const labels = { tag: '#KODAK_PORTRA_400', title: 'Golden Hour Portrait', meta: 'ISO 400 · GRAIN 35 · WARMTH +18' };
const labelled = await user.req(
`/photos?tag=${encodeURIComponent(labels.tag)}&title=${encodeURIComponent(labels.title)}&meta=${encodeURIComponent(labels.meta)}`,
{ method: 'POST', headers: { 'content-type': 'image/png' }, body: PNG },
);
check(
'an upload carries its strip labels',
labelled.status === 201 &&
labelled.body?.photo?.tag === labels.tag &&
labelled.body?.photo?.title === labels.title &&
labelled.body?.photo?.meta === labels.meta,
JSON.stringify(labelled.body),
);
const labelledId = labelled.body?.photo?.id;
const publicRow = ((await actor().req('/photos')).body?.photos ?? []).find((p) => p.id === labelledId);
check('the labels reach the public strip', publicRow?.tag === labels.tag && publicRow?.meta === labels.meta);
const capped = await user.req(
`/photos?tag=${'x'.repeat(200)}&meta=${encodeURIComponent('bad\u0007line')}`,
{ method: 'POST', headers: { 'content-type': 'image/png' }, body: PNG },
);
check('a label is length-capped', capped.body?.photo?.tag?.length === 64, `len ${capped.body?.photo?.tag?.length}`);
check('a label is control-stripped', capped.body?.photo?.meta === 'bad line', JSON.stringify(capped.body?.photo?.meta));
check(
'a member cannot delete a photo they do not own',
(await stranger.req(`/photos/${labelledId}`, { method: 'DELETE' })).status === 404,
);
check('the stranger’s delete leaves the file alone', (await fetch(`${BASE}/photos/${labelledId}/file`)).status === 200);
const ownDelete = await user.req(`/photos/${labelledId}`, { method: 'DELETE' });
check('a member deletes their own photo', ownDelete.status === 204, `got ${ownDelete.status}`);
check('the deleted file is gone from disk', (await fetch(`${BASE}/photos/${labelledId}/file`)).status === 404);
check(
'the row leaves the folder',
!((await user.req('/photos/mine')).body?.photos ?? []).some((p) => p.id === labelledId),
);
// The curator removes anyone's through the same route — the moderation screen
// keeps its own two admin endpoints, this one just shares the job.
const spare = (await user.upload(PNG, 'image/png')).body?.photo;
check('an admin deletes through the member route too', (await admin.req(`/photos/${spare?.id}`, { method: 'DELETE' })).status === 204);
check('a deleted photo leaves the strip', !((await actor().req('/photos')).body?.photos ?? []).some((p) => p.id === spare?.id));
// ---- moderation ---------------------------------------------------------
check('a guest cannot moderate', (await actor().req('/admin/photos')).status === 401);
const forbidden = await user.req('/admin/photos');
check('a plain member is 403, not 200', forbidden.status === 403, `got ${forbidden.status}`);
const adminList = await admin.req('/admin/photos');
const rows = adminList.body?.photos ?? [];
check('an admin lists contributions', adminList.status === 200 && rows.length > 0);
check('the admin listing carries the owner', rows.some((r) => /@test\.local$/.test(r.email ?? '')));
check(
'a fresh upload lands in the strip section',
JSON.stringify(rows.find((r) => r.id === id)?.slots) === JSON.stringify(['strip']),
);
check('a plain member cannot delete', (await user.req(`/admin/photos/${id}`, { method: 'DELETE' })).status === 403);
// ---- placement ----------------------------------------------------------
const patch = (path, body) =>
admin.req(path, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
check(
'a plain member cannot place a photo',
(await user.req(`/admin/photos/${id}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ slots: ['qr'] }),
})).status === 403,
);
const placed = await patch(`/admin/photos/${id}`, { slots: ['tester', 'creator'] });
check(
'an admin puts a photo in several sections at once',
placed.status === 200 && JSON.stringify(placed.body?.slots) === JSON.stringify(['tester', 'creator']),
JSON.stringify(placed.body),
);
check(
'the sections are public, the owner is not',
JSON.stringify(((await actor().req('/photos')).body?.photos ?? []).find((r) => r.id === id)?.slots) ===
JSON.stringify(['tester', 'creator']),
);
const one = await patch(`/admin/photos/${id}`, { slots: ['strip', 'strip', 'qr'] });
check(
'a repeated section is stored once',
JSON.stringify(one.body?.slots) === JSON.stringify(['strip', 'qr']),
JSON.stringify(one.body),
);
// An empty set is the curator's removal: no landing section draws the row,
// but the uploader keeps it in their folder.
const off = await patch(`/admin/photos/${id}`, { slots: [] });
check('an admin takes a photo off the landing', off.status === 200 && off.body?.slots?.length === 0, JSON.stringify(off.body));
check(
'an off photo is on no landing section',
((await actor().req('/photos')).body?.photos ?? []).find((r) => r.id === id)?.slots?.length === 0,
);
check('its owner still has it in the folder', ((await user.req('/photos/mine')).body?.photos ?? []).some((p) => p.id === id));
check('an unknown slot is refused', (await patch(`/admin/photos/${id}`, { slots: ['nope'] })).status === 400);
check('a bare slot string is refused', (await patch(`/admin/photos/${id}`, { slots: 'strip' })).status === 400);
check('placing an unknown photo is a 404', (await patch('/admin/photos/999999', { slots: ['qr'] })).status === 404);
// ---- preset link --------------------------------------------------------
// The look a photo was uploaded with is the landing QR card's payload: the
// `.recipe` file the app reads back on IMPORT. The curator's `qr` tick is the
// whole permission — nothing else is a link, and the listing exposes only
// whether a look exists, never the look.
const look = { name: 'QR LOOK', baseFilter: 'velvia', adjustments: { contrast: 7 }, frameId: 'none' };
const withLook = await user.req(`/photos?recipe=${encodeURIComponent(JSON.stringify(look))}`, {
method: 'POST',
headers: { 'content-type': 'image/png' },
body: PNG,
});
const presetId = withLook.body?.photo?.id;
check('an upload that carried a look says so', withLook.body?.photo?.hasPreset === true);
check('a photo with no look says no', rows.find((r) => r.id === id)?.hasPreset === false);
const listedRow = ((await actor().req('/photos')).body?.photos ?? []).find((p) => p.id === presetId);
check('the public listing counts looks, never ships them', listedRow?.hasPreset === true && !('recipe' in listedRow));
check('an un-curated photo is not a link', (await actor().req(`/photos/${presetId}/preset.recipe`)).status === 404);
check('a lookless photo is not a link either', (await actor().req(`/photos/${id}/preset.recipe`)).status === 404);
check('an unknown row is a 404', (await actor().req('/photos/999999/preset.recipe')).status === 404);
check('a nonsense id is a 404, not a crash', (await actor().req('/photos/abc/preset.recipe')).status === 404);
await patch(`/admin/photos/${presetId}`, { slots: ['qr'] });
const presetFile = await fetch(`${BASE}/photos/${presetId}/preset.recipe`);
const presetXml = await presetFile.text();
check(
'a curated photo serves its look as a download',
presetFile.status === 200 && /^application\/xml/.test(presetFile.headers.get('content-type') ?? ''),
`got ${presetFile.status}`,
);
check(
'the download carries the row it came from',
presetFile.headers.get('content-disposition') === `attachment; filename="recipescam-${presetId}.recipe"`,
presetFile.headers.get('content-disposition') ?? '',
);
check(
'the file is the app’s own envelope',
/^<\?xml version="1\.0" encoding="UTF-8"\?>\n<recipescam-recipe version="1" algorithm="xor16-v1" salt="[0-9a-f]+">\n {2}<payload>[0-9a-f]+<\/payload>\n<\/recipescam-recipe>\n$/.test(
presetXml,
),
);
const again = await (await fetch(`${BASE}/photos/${presetId}/preset.recipe`)).text();
check(
'every download gets its own salt',
/salt="([0-9a-f]+)"/.exec(again)?.[1] !== /salt="([0-9a-f]+)"/.exec(presetXml)?.[1],
);
// Taking the photo out of the `qr` section takes the link away with it.
await patch(`/admin/photos/${presetId}`, { slots: [] });
check('an un-curated photo loses its link again', (await actor().req(`/photos/${presetId}/preset.recipe`)).status === 404);
await user.req(`/photos/${presetId}`, { method: 'DELETE' });
const deleted = await admin.req(`/admin/photos/${id}`, { method: 'DELETE' });
check('an admin deletes a contribution', deleted.status === 204, `got ${deleted.status}`);
check('the deleted file is gone from disk', (await fetch(`${BASE}/photos/${id}/file`)).status === 404);
check('the deleted row is gone from the listing', !((await admin.req('/admin/photos')).body?.photos ?? []).some((r) => r.id === id));
// ---- quota --------------------------------------------------------------
const quota = actor();
await activeSignup(quota, `quota${stamp}@test.local`);
let last = 0;
for (let i = 0; i < 13; i++) last = (await quota.upload(PNG, 'image/png')).status;
check('uploads are capped per account', last === 429, `13th upload: ${last}`);
// The cap is a member fair-use rule; the curator stocks the landing page from
// one account, so it must not apply to the allowlist.
let adminLast = 0;
for (let i = 0; i < 13; i++) adminLast = (await admin.upload(PNG, 'image/png')).status;
check('the admin is exempt from the member quota', adminLast === 201, `13th admin upload: ${adminLast}`);
const cleared = await admin.req('/admin/photos', { method: 'DELETE' });
check('an admin clears the strip in one call', cleared.status === 200 && cleared.body?.removed > 0, JSON.stringify(cleared.body));
check('the strip is empty afterwards', ((await actor().req('/photos')).body?.photos ?? []).length === 0);
// ---- profile: an account edits itself ----------------------------------
const JSON_HDR = { 'content-type': 'application/json' };
const edit = (a, body) => a.req('/auth/me', { method: 'PATCH', headers: JSON_HDR, body: JSON.stringify(body) });
const member = actor();
await activeSignup(member, `profile${stamp}@test.local`);
const anonEdit = await edit(actor(), { password: 'another-secret-1', currentPassword: 'supersecret1' });
check('a profile edit needs a session', anonEdit.status === 401, `got ${anonEdit.status}`);
const badCurrent = await edit(member, { password: 'another-secret-1', currentPassword: 'not-the-password' });
check('a profile edit needs the current password', badCurrent.status === 403, `got ${badCurrent.status}`);
const takenEmail = await edit(member, { email: ADMIN_EMAIL, currentPassword: 'supersecret1' });
check('a profile edit refuses a taken email', takenEmail.status === 409, `got ${takenEmail.status}`);
const shortNew = await edit(member, { password: 'short', currentPassword: 'supersecret1' });
check('a profile edit refuses a short password', shortNew.status === 400, `got ${shortNew.status}`);
const newEmail = `renamed${stamp}@test.local`;
const renamed = await edit(member, { email: newEmail, currentPassword: 'supersecret1' });
check('an admin-visible profile edit changes the email', renamed.status === 200 && renamed.body?.user?.email === newEmail, JSON.stringify(renamed.body));
// The tier follows the address that earned it: the new one is unproven until
// its own letter is followed, so the account drops back to the guest tier.
check('a changed address is unproven again', renamed.body?.user?.verified === false, JSON.stringify(renamed.body));
check('a changed address loses the writes', (await member.req('/recipes')).status === 403);
await followVerifyLink(newEmail);
check('following the new letter restores the tier', (await member.req('/auth/me')).body?.user?.verified === true);
const login = (email, password) =>
actor().req('/auth/login', { method: 'POST', headers: JSON_HDR, body: JSON.stringify({ email, password }) });
check('the account logs in under the new email', (await login(newEmail, 'supersecret1')).status === 200);
check('the old email no longer logs in', (await login(`profile${stamp}@test.local`, 'supersecret1')).status === 401);
// The session that made the edit is the same row, so it also changes the password.
const newPassword = 'second-secret-1';
const rekeyed = await edit(member, { password: newPassword, currentPassword: 'supersecret1' });
check('an account changes its own password', rekeyed.status === 200, `got ${rekeyed.status}`);
check('the old password stops working', (await login(newEmail, 'supersecret1')).status === 401);
check('the new password works', (await login(newEmail, newPassword)).status === 200);
// ---- admin: the account list -------------------------------------------
const anonUsers = await actor().req('/admin/users');
check('the user list is not public', anonUsers.status === 401, `got ${anonUsers.status}`);
const memberUsers = await member.req('/admin/users');
check('a member cannot read the user list', memberUsers.status === 403, `got ${memberUsers.status}`);
const adminUsers = await admin.req('/admin/users');
const adminRow = (adminUsers.body?.users ?? []).find((u) => u.email === ADMIN_EMAIL);
check('an admin reads the user list', adminUsers.status === 200 && Array.isArray(adminUsers.body?.users), `got ${adminUsers.status}`);
check('the list flags the allowlisted account', adminRow?.admin === true, JSON.stringify(adminRow));
check('the list counts each account’s photos', typeof adminRow?.photos === 'number', JSON.stringify(adminRow));
// ---- avatar: the picture beside the name -------------------------------
const memberId = renamed.body?.user?.id;
const noSession = await actor().avatar(PNG, 'image/png');
check('an avatar upload needs a session', noSession.status === 401, `got ${noSession.status}`);
const badAvatar = await member.avatar(Buffer.from('<svg onload="alert(1)"/>'), 'image/png');
check('an avatar upload sniffs the bytes', badAvatar.status === 415, `got ${badAvatar.status}`);
const gaveAvatar = await member.avatar(PNG, 'image/png');
const avatarUrl = gaveAvatar.body?.user?.avatar;
check('a member uploads an avatar', gaveAvatar.status === 200 && typeof avatarUrl === 'string', JSON.stringify(gaveAvatar.body));
check('the avatar URL points at the account', new RegExp(`^/api/users/${memberId}/avatar\\?v=[0-9a-f]{32}$`).test(String(avatarUrl)), String(avatarUrl));
const servedAvatar = await fetch(`http://127.0.0.1:${PORT}${avatarUrl}`);
check('an avatar is served without a session', servedAvatar.status === 200, `got ${servedAvatar.status}`);
check('an avatar carries its image type', servedAvatar.headers.get('content-type') === 'image/png', String(servedAvatar.headers.get('content-type')));
check('an avatar is cacheable for a long time', (servedAvatar.headers.get('cache-control') ?? '').includes('immutable'), String(servedAvatar.headers.get('cache-control')));
check('the avatar bytes round-trip intact', Buffer.from(await servedAvatar.arrayBuffer()).equals(PNG));
const replaced = await member.avatar(JPEG_HEAD, 'image/jpeg');
const replacedUrl = replaced.body?.user?.avatar;
check('a second avatar replaces the first', replaced.status === 200 && replacedUrl !== avatarUrl, JSON.stringify(replaced.body));
check('the replaced avatar file is gone', !existsSync(join(DATA_DIR, 'avatars', `${String(avatarUrl).split('?v=')[1]}.png`)));
check('the new avatar resolves', (await fetch(`http://127.0.0.1:${PORT}${replacedUrl}`)).status === 200);
const ghost = await actor().req('/users/999999/avatar');
check('an unknown account has no avatar', ghost.status === 404, `got ${ghost.status}`);
// The moderation list has to show the same face beside the email.
const listedUsers = (await admin.req('/admin/users')).body?.users ?? [];
const memberRow = listedUsers.find((u) => u.id === memberId);
check('the user list carries each account’s picture', memberRow?.avatar === replacedUrl, JSON.stringify(memberRow));
// ---- moderation: block, remove, delete an account -----------------------
const target = actor();
await activeSignup(target, `moderated${stamp}@test.local`);
const targetId = (await target.req('/auth/me')).body?.user?.id;
const targetPhoto = (await target.upload(PNG, 'image/png')).body?.photo;
const targetPhotoUrl = `http://127.0.0.1:${PORT}/api/photos/${targetPhoto?.id}/file`;
const moderate = (a, id, body) => a.req(`/admin/users/${id}`, { method: 'PATCH', headers: JSON_HDR, body: JSON.stringify(body) });
check('a fresh account is moderatable', Number.isInteger(targetId) && Number.isInteger(targetPhoto?.id), `${targetId}/${targetPhoto?.id}`);
const anonModerate = await moderate(actor(), targetId, { blocked: true });
check('moderating needs a session', anonModerate.status === 401, `got ${anonModerate.status}`);
const memberModerate = await moderate(member, targetId, { blocked: true });
check('a member cannot moderate', memberModerate.status === 403, `got ${memberModerate.status}`);
const selfModerate = await moderate(admin, adminRow?.id, { blocked: true });
check('an allowlisted account cannot be moderated', selfModerate.status === 403, `got ${selfModerate.status}`);
const ghostModerate = await moderate(admin, 999999, { blocked: true });
check('an unknown account is a 404', ghostModerate.status === 404, `got ${ghostModerate.status}`);
const badModerate = await moderate(admin, targetId, { blocked: 'yes' });
check('a moderation body is type-checked', badModerate.status === 400, `got ${badModerate.status}`);
const blocked = await moderate(admin, targetId, { blocked: true });
check('an admin blocks an account', blocked.status === 200 && blocked.body?.user?.blocked === true, JSON.stringify(blocked.body));
const blockedLogin = await login(`moderated${stamp}@test.local`, 'supersecret1');
check('a blocked account cannot sign in', blockedLogin.status === 403 && blockedLogin.body?.error === 'account blocked', JSON.stringify(blockedLogin.body));
check('a blocked session stops being a user', (await target.req('/auth/me')).body?.user === null, JSON.stringify((await target.req('/auth/me')).body));
const blockedRow = ((await admin.req('/admin/users')).body?.users ?? []).find((u) => u.id === targetId);
check('the list flags a blocked account', blockedRow?.blocked === true, JSON.stringify(blockedRow));
const unblocked = await moderate(admin, targetId, { blocked: false });
check('an admin unblocks an account', unblocked.status === 200 && unblocked.body?.user?.blocked === false, JSON.stringify(unblocked.body));
check('an unblocked account signs in again', (await login(`moderated${stamp}@test.local`, 'supersecret1')).status === 200);
check('its photo is back on the strip', ((await actor().req('/photos')).body?.photos ?? []).some((p) => p.id === targetPhoto?.id));
const removed = await moderate(admin, targetId, { removed: true });
check('an admin removes an account', removed.status === 200 && removed.body?.user?.removed === true, JSON.stringify(removed.body));
const removedLogin = await login(`moderated${stamp}@test.local`, 'supersecret1');
check('a removed account cannot sign in', removedLogin.status === 403 && removedLogin.body?.error === 'account removed', JSON.stringify(removedLogin.body));
check('a removed account’s photos leave the strip', !((await actor().req('/photos')).body?.photos ?? []).some((p) => p.id === targetPhoto?.id));
const removedRow = ((await admin.req('/admin/users')).body?.users ?? []).find((u) => u.id === targetId);
check('the list flags a removed account', removedRow?.removed === true, JSON.stringify(removedRow));
const restored = await moderate(admin, targetId, { removed: false });
check('an admin restores an account', restored.status === 200 && restored.body?.user?.removed === false, JSON.stringify(restored.body));
check('a restored account signs in again', (await login(`moderated${stamp}@test.local`, 'supersecret1')).status === 200);
const hardDelete = await admin.req(`/admin/users/${targetId}`, { method: 'DELETE' });
check('an admin deletes an account outright', hardDelete.status === 204, `got ${hardDelete.status}`);
check('a deleted account leaves the list', !((await admin.req('/admin/users')).body?.users ?? []).some((u) => u.id === targetId));
check('a deleted account cannot sign in', (await login(`moderated${stamp}@test.local`, 'supersecret1')).status === 401);
check('a deleted account’s photo file is unlinked', (await fetch(targetPhotoUrl)).status === 404);
// ---- film-strip ratings -------------------------------------------------
// Public and one-per-visitor: two addresses are two voters, and a second vote
// from the same address replaces the first instead of adding to it.
const vote = (ip, key, stars) =>
fetch(`${BASE}/ratings`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-forwarded-for': ip },
body: JSON.stringify({ key, stars }),
});
const readRatings = async (ip) =>
((await (await fetch(`${BASE}/ratings`, { headers: { 'x-forwarded-for': ip } })).json()).ratings ?? {});
check('a guest may rate a frame', (await vote('10.9.9.1', 'look:TEST_LOOK', 5)).status === 200);
await vote('10.9.9.2', 'look:TEST_LOOK', 3);
const tally = (await readRatings('10.9.9.1'))['look:TEST_LOOK'];
check('the tally averages every vote', tally?.avg === 4 && tally?.n === 2, JSON.stringify(tally));
check('a visitor reads back their own vote', tally?.mine === 5);
check('a stranger has no vote of their own', (await readRatings('10.9.9.3'))['look:TEST_LOOK']?.mine === 0);
await vote('10.9.9.1', 'look:TEST_LOOK', 1);
const changed = (await readRatings('10.9.9.1'))['look:TEST_LOOK'];
check('a second vote replaces the first', changed?.avg === 2 && changed?.n === 2 && changed?.mine === 1, JSON.stringify(changed));
check('a six-star score is refused', (await vote('10.9.9.1', 'look:TEST_LOOK', 6)).status === 400);
check('a zero-star score is refused', (await vote('10.9.9.1', 'look:TEST_LOOK', 0)).status === 400);
check('a malformed key is refused', (await vote('10.9.9.1', 'bad key!', 3)).status === 400);
check('an empty key is refused', (await vote('10.9.9.1', '', 3)).status === 400);
const throwaway = await user.upload(PNG, 'image/png');
const throwawayId = throwaway.body?.photo?.id;
check('the throwaway upload lands', Number.isInteger(throwawayId), JSON.stringify(throwaway.body));
await vote('10.9.9.4', `photo:${throwawayId}`, 5);
// ---- the hero's award windows -------------------------------------------
// The landing's award column reads the same votes over a window: what was
// cast today, and what was cast this week. A vote cast a moment ago is in
// both. A subject that is not a photo — a built-in look — is never a frame.
const highlights = (await (await fetch(`${BASE}/highlights`)).json()).highlights ?? {};
const day = (highlights.day ?? []).find((r) => r.id === throwawayId);
const week = (highlights.week ?? []).find((r) => r.id === throwawayId);
check('a vote lands in today’s window', day?.avg === 5 && day?.n === 1, JSON.stringify(day));
check('and in this week’s window too', week?.avg === 5 && week?.n === 1, JSON.stringify(week));
check(
'a look that is not a photo is never an award',
[...(highlights.day ?? []), ...(highlights.week ?? [])].every((r) => Number.isInteger(r.id)),
JSON.stringify(highlights),
);
await user.req(`/photos/${throwawayId}`, { method: 'DELETE' });
check(
'a deleted photo takes its votes with it',
!Object.hasOwn(await readRatings('10.9.9.4'), `photo:${throwawayId}`),
);
const afterDelete = (await (await fetch(`${BASE}/highlights`)).json()).highlights ?? {};
check(
'and off the award column',
![...(afterDelete.day ?? []), ...(afterDelete.week ?? [])].some((r) => r.id === throwawayId),
JSON.stringify(afterDelete),
);
// ---- pre-existing guarantees still hold ---------------------------------
const foreignRecipe = await user.req('/recipes/1', { method: 'DELETE' });
check("another account's recipe is not deletable", foreignRecipe.status === 404, `got ${foreignRecipe.status}`);
const missing = await actor().req('/nope');
check('unknown routes keep the single error shape', missing.status === 404 && missing.body?.error === 'not_found');
} catch (err) {
fail++;
console.log(`FAIL harness :: ${err && err.stack ? err.stack : err}`);
console.log(serverLog.slice(-2000));
} finally {
server.kill('SIGTERM');
rmSync(DATA_DIR, { recursive: true, force: true });
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail === 0 ? 0 : 1);