web: PRO needs a proven address — email verification gates the studio

A signed-in account is served exactly like a guest until it opens the
verification link: watermarked 2048px export, no saving, no PRO frames,
GPS stamp or HDF. SMTP is declared in .env; with SMTP_HOST unset the link
goes to the container log. Allowlisted admins count as verified.
This commit is contained in:
2026-09-20 07:39:03 +07:00
parent efe578f61c
commit 52b672deec
16 changed files with 633 additions and 74 deletions
+80 -6
View File
@@ -77,6 +77,30 @@ function actor() {
};
}
// 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;
}
// 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');
@@ -121,9 +145,9 @@ try {
const adminSignup = await admin.signup(ADMIN_EMAIL);
check('admin account signs up', adminSignup.status === 201, `got ${adminSignup.status}`);
const userSignup = await user.signup(`contributor${stamp}@test.local`);
const userSignup = await activeSignup(user, `contributor${stamp}@test.local`);
check('contributor account signs up', userSignup.status === 201, `got ${userSignup.status}`);
await other.signup(`other${stamp}@test.local`);
await activeSignup(other, `other${stamp}@test.local`);
const cookie = userSignup.setCookie;
check('session cookie is HttpOnly', /HttpOnly/i.test(cookie), cookie);
@@ -157,6 +181,50 @@ try {
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);
// ---- rate limiting ------------------------------------------------------
const brute = actor();
const bruteEmail = `brute${stamp}@test.local`;
@@ -228,7 +296,7 @@ try {
);
const stranger = actor();
await stranger.signup(`stranger${stamp}@test.local`);
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.
@@ -389,7 +457,7 @@ try {
// ---- quota --------------------------------------------------------------
const quota = actor();
await quota.signup(`quota${stamp}@test.local`);
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}`);
@@ -409,7 +477,7 @@ try {
const edit = (a, body) => a.req('/auth/me', { method: 'PATCH', headers: JSON_HDR, body: JSON.stringify(body) });
const member = actor();
await member.signup(`profile${stamp}@test.local`);
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' });
@@ -422,6 +490,12 @@ try {
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);
@@ -479,7 +553,7 @@ try {
// ---- moderation: block, remove, delete an account -----------------------
const target = actor();
await target.signup(`moderated${stamp}@test.local`);
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`;