web: moderate accounts, accept 12MB uploads, put SAVE under CREATE

- /admin User account rows gain BLOCK/UNBLOCK, REMOVE/RESTORE and DELETE.
  Blocked = cannot sign in (sessions swept), removed = hidden from the strip
  and cannot sign in, both reversible; DELETE drops the account with its
  photos and recipes and unlinks the files. An allowlisted account is never
  a target, so an admin cannot moderate or delete itself.
- Photo uploads move from a 3MB API cap / 4m nginx cap to 12MB / 16m, and
  the browser shrinks an oversized still before sending it (2048px JPEG,
  avatars 512px) so the declared type still matches the sniffed bytes.
- The studio SAVE leaves the top bar and sits under the CREATE RECIPES tab,
  labelled SAVE RECIPES.
This commit is contained in:
2026-09-18 10:33:35 +07:00
parent 8e6c1493e8
commit 43d86b4b6f
12 changed files with 386 additions and 45 deletions
+52 -1
View File
@@ -187,7 +187,7 @@ try {
);
check('empty body is refused', (await user.upload(Buffer.alloc(0), 'image/png')).status === 400);
const tooBig = Buffer.concat([PNG, Buffer.alloc(4 * 1024 * 1024)]);
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');
@@ -345,6 +345,57 @@ try {
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 target.signup(`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);
// ---- 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}`);