// 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(''), 'image/svg+xml')).status === 415,
);
check(
'svg bytes under an image content-type is refused',
(await user.upload(Buffer.from(''), '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\n {2}[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(''), '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),
);
// ---- the award column on a quiet week ------------------------------------
// Both windows are the clock's, so a vote the API cannot backdate is aged here
// instead. Nothing has been voted on today or this week — the state the column
// used to answer with nothing at all — and the all-time bests have to stand in.
const aged = await user.upload(PNG, 'image/png');
const agedId = aged.body?.photo?.id;
await vote('10.9.9.5', `photo:${agedId}`, 4);
{
const db = new Database(join(DATA_DIR, 'recipescam.db'));
db.prepare('UPDATE ratings SET at = ? WHERE key = ?').run(
new Date(Date.now() - 30 * 86_400_000).toISOString(),
`photo:${agedId}`,
);
db.close();
}
const quiet = (await (await fetch(`${BASE}/highlights`)).json()).highlights ?? {};
check(
'a vote older than both windows leaves them empty',
(quiet.day ?? []).length === 0 && (quiet.week ?? []).length === 0,
JSON.stringify(quiet),
);
const ever = (quiet.ever ?? []).find((r) => r.id === agedId);
check('and the all-time bests stand in for them', ever?.avg === 4 && ever?.n === 1, JSON.stringify(ever));
await user.req(`/photos/${agedId}`, { method: 'DELETE' });
const nothing = (await (await fetch(`${BASE}/highlights`)).json()).highlights ?? {};
check(
'with nothing ever rated the column has no frame to show',
(nothing.ever ?? []).length === 0,
JSON.stringify(nothing),
);
// ---- 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);