web: account avatars, member /profile, framed admin panel
- an account can carry a picture: POST /api/auth/avatar (raw bytes, sniffed, replaces and unlinks the old file) and the public GET /api/users/:id/avatar. It rides wherever the account is named — the landing chip, the studio TopBar, the profile form. - new /profile page for members, sharing one Profile form (picture, email, password) with the admin drawer. - /admin is now one bordered frame whose left column is Profile / User account / Pictures / Close. Pictures lists every photo in the system with the slot that shows it; User account lists each account's name, email, picture and contribution count. - account control opens a menu: Admin page + Log out for an admin, Profile + Log out for a member.
This commit is contained in:
@@ -71,6 +71,9 @@ function actor() {
|
||||
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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -269,6 +272,79 @@ try {
|
||||
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 member.signup(`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));
|
||||
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));
|
||||
|
||||
// ---- 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}`);
|
||||
|
||||
Reference in New Issue
Block a user