CI / check (push) Canceled after 0s
- Upload a profile picture on web and mobile. The API square-crops it to 512px WebP, strips metadata (phone photos carry GPS), stores it in Postgres and serves it with immutable caching. Avatar URLs are stored as paths, so they survive moving the API. Arbitrary avatar URLs are no longer accepted (they'd let a profile track its viewers). - New accounts get the username as typed as their display name; existing accounts are backfilled, and clearing it falls back to the username. - Service names in Settings get a brand-coloured badge and larger type. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
82 lines
3.8 KiB
TypeScript
82 lines
3.8 KiB
TypeScript
import sharp from 'sharp';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { bearer, createUser, useTestApp } from './helpers';
|
|
|
|
const app = useTestApp();
|
|
|
|
/** Builds a multipart body by hand, the way a browser or phone would send it. */
|
|
function multipart(file: Buffer, type = 'image/jpeg', name = 'photo.jpg') {
|
|
const boundary = '----musicshare-test';
|
|
const payload = Buffer.concat([
|
|
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: ${type}\r\n\r\n`),
|
|
file,
|
|
Buffer.from(`\r\n--${boundary}--\r\n`),
|
|
]);
|
|
return { payload, headers: { 'content-type': `multipart/form-data; boundary=${boundary}` } };
|
|
}
|
|
|
|
const photo = () =>
|
|
sharp({ create: { width: 1200, height: 800, channels: 3, background: '#7b2ff7' } })
|
|
.jpeg()
|
|
.withExif({ IFD0: { Make: 'TestPhone', Model: 'GPS-Leaker' } })
|
|
.toBuffer();
|
|
|
|
describe('avatars', () => {
|
|
it('resizes, re-encodes and serves an uploaded picture', async () => {
|
|
const { token, user } = await createUser(app(), 'alice');
|
|
const { payload, headers } = multipart(await photo());
|
|
const res = await app().inject({ method: 'POST', url: '/me/avatar', payload, headers: { ...headers, ...bearer(token) } });
|
|
expect(res.statusCode).toBe(200);
|
|
const url: string = res.json().avatarUrl;
|
|
expect(url).toMatch(new RegExp(`^https://api\\.test/avatars/${user.id}\\?v=\\d+$`));
|
|
|
|
const img = await app().inject({ url: url.replace('https://api.test', '') });
|
|
expect(img.statusCode).toBe(200);
|
|
expect(img.headers['content-type']).toBe('image/webp');
|
|
expect(img.headers['cache-control']).toContain('immutable');
|
|
const meta = await sharp(img.rawPayload).metadata();
|
|
expect(meta).toMatchObject({ format: 'webp', width: 512, height: 512 });
|
|
expect(meta.exif).toBeUndefined(); // camera/GPS metadata stripped
|
|
|
|
// Shows up wherever the user does, e.g. their profile.
|
|
const profile = await app().inject({ url: '/users/alice', headers: bearer(token) });
|
|
expect(profile.json().user.avatarUrl).toBe(url);
|
|
});
|
|
|
|
it('rejects files that are not images', async () => {
|
|
const { token } = await createUser(app(), 'alice');
|
|
const { payload, headers } = multipart(Buffer.from('<script>alert(1)</script>'), 'image/png', 'x.png');
|
|
const res = await app().inject({ method: 'POST', url: '/me/avatar', payload, headers: { ...headers, ...bearer(token) } });
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.json().error).toMatch(/isn't an image/);
|
|
});
|
|
|
|
it('removes the picture', async () => {
|
|
const { token, user } = await createUser(app(), 'alice');
|
|
const { payload, headers } = multipart(await photo());
|
|
await app().inject({ method: 'POST', url: '/me/avatar', payload, headers: { ...headers, ...bearer(token) } });
|
|
const res = await app().inject({ method: 'DELETE', url: '/me/avatar', headers: bearer(token) });
|
|
expect(res.json().avatarUrl).toBeNull();
|
|
expect((await app().inject({ url: `/avatars/${user.id}` })).statusCode).toBe(404);
|
|
});
|
|
|
|
it('requires sign-in to upload', async () => {
|
|
const { payload, headers } = multipart(await photo());
|
|
expect((await app().inject({ method: 'POST', url: '/me/avatar', payload, headers })).statusCode).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe('display name', () => {
|
|
it('defaults to the username as typed, and falls back to it when cleared', async () => {
|
|
const res = await app().inject({
|
|
method: 'POST',
|
|
url: '/auth/register',
|
|
payload: { username: 'BinaryGnome', email: 'g@example.com', password: 'password123' },
|
|
});
|
|
expect(res.json().user).toMatchObject({ username: 'binarygnome', displayName: 'BinaryGnome' });
|
|
|
|
const cleared = await app().inject({ method: 'PATCH', url: '/me', headers: bearer(res.json().token), payload: { displayName: '' } });
|
|
expect(cleared.json().displayName).toBe('binarygnome');
|
|
});
|
|
});
|