import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { INestApplication } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; /** * A real PNG of `size`×`size`, built the same way the shipped default is — * the api reads the IHDR, so the header has to be genuine. */ async function png(size: number): Promise { const { deflateSync } = await import('node:zlib'); const crcTable = Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; }); const crc32 = (buf: Buffer): number => { let c = 0xffffffff; for (const byte of buf) c = crcTable[(c ^ byte) & 0xff]! ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; }; const chunk = (type: string, data: Buffer): Buffer => { const length = Buffer.alloc(4); length.writeUInt32BE(data.length); const body = Buffer.concat([Buffer.from(type, 'ascii'), data]); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body)); return Buffer.concat([length, body, crc]); }; const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(size, 0); ihdr.writeUInt32BE(size, 4); ihdr[8] = 8; ihdr[9] = 6; const raw = Buffer.alloc(size * (size * 4 + 1)); return Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0)), ]); } describe.skipIf(!hasTestDb)('instance branding (e2e, issue #306)', () => { let app: INestApplication; let prisma: PrismaClient; let brandingDir: string; const suffix = uniqueSuffix(); const password = 'markenzeichen mit teich 1'; const admin = { username: `ba-${suffix}` }; const plain = { username: `bp-${suffix}` }; let adminCookie: string; let plainCookie: string; const api = () => request(app.getHttpServer()); beforeAll(async () => { prisma = createTestPrisma(); await prisma.rateLimit.deleteMany({}); // A real directory: the point is that bytes land somewhere and come back. brandingDir = await mkdtemp(join(tmpdir(), 'dorfteich-branding-')); process.env.BRANDING_DIR = brandingDir; app = await createTestApp(); const users = app.get(UsersService); const adminUser = await users.createUser({ username: admin.username, email: `${admin.username}@example.org`, displayName: `Branding Admin ${suffix}`, password, locale: 'en', }); await users.markEmailVerified(adminUser.id); await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } }); const plainUser = await users.createUser({ username: plain.username, email: `${plain.username}@example.org`, displayName: `Branding Plain ${suffix}`, password, locale: 'en', }); await users.markEmailVerified(plainUser.id); const login = async (username: string): Promise => sessionCookieOf( await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: username, password }) .expect(200), ); adminCookie = await login(admin.username); plainCookie = await login(plain.username); }); afterAll(async () => { await prisma.instanceSetting.deleteMany({ where: { key: { in: ['instance.logo', 'instance.logoDark', 'instance.favicon'] } }, }); await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); await prisma.$disconnect(); await app.close(); await rm(brandingDir, { recursive: true, force: true }); delete process.env.BRANDING_DIR; }); it('serves the shipped default favicon before anything is uploaded', async () => { // The `` in index.html is a constant — this route must // never 404, or the browser keeps its generic icon for good. const res = await api().get('/api/v1/branding/favicon').expect(200); expect(res.headers['content-type']).toContain('image/png'); expect(res.body.subarray(0, 8).toString('latin1')).toContain('PNG'); }); it('stores a logo, reports it, and serves the bytes without a session', async () => { const bytes = await png(64); const view = await api() .post('/api/v1/admin/branding/logo?variant=light') .set('Cookie', adminCookie) .attach('file', bytes, 'logo.png') .expect(201); expect(view.body.logo).toMatchObject({ width: 64, height: 64 }); expect(view.body.logoDark).toBeNull(); // On disk, under the key the pond override (#307) will extend. const onDisk = await readFile(join(brandingDir, 'instance-logo-light.png')); expect(onDisk.length).toBe(bytes.length); // Anonymous: the login screen carries the branding. const served = await api().get('/api/v1/branding/logo?variant=light').expect(200); expect(served.headers['content-type']).toContain('image/png'); const anon = await api().get('/api/v1/branding').expect(200); expect(anon.body.logo.hash).toBe(view.body.logo.hash); expect(anon.body.instanceName).toBeTruthy(); }); it('answers 404 for a logo variant that was never uploaded', async () => { // No shipped default for the logo: without one the app renders the // instance NAME, so an empty answer is the honest one. await api().get('/api/v1/branding/logo?variant=dark').expect(404); }); it('rejects an SVG with its own message, not a generic one', async () => { const res = await api() .post('/api/v1/admin/branding/logo?variant=light') .set('Cookie', adminCookie) .attach('file', Buffer.from(''), 'x.png') .expect(400); expect(res.body.code).toBe('branding_svg_rejected'); }); it('rejects bytes that are not a PNG at all', async () => { const res = await api() .post('/api/v1/admin/branding/logo?variant=light') .set('Cookie', adminCookie) .attach('file', Buffer.from('GIF89a and then some'), 'x.png') .expect(400); expect(res.body.code).toBe('branding_not_a_png'); }); it('rejects a logo larger than the maximum edge', async () => { const res = await api() .post('/api/v1/admin/branding/logo?variant=light') .set('Cookie', adminCookie) .attach('file', await png(600), 'x.png') .expect(400); expect(res.body.code).toBe('branding_image_too_large'); }); it('takes both favicon sizes together and serves each back', async () => { await api() .post('/api/v1/admin/branding/favicon') .set('Cookie', adminCookie) .attach('png-32', await png(32), 'f32.png') .attach('png-180', await png(180), 'f180.png') .expect(201); for (const size of [32, 180]) { const res = await api().get(`/api/v1/branding/favicon?size=${size}`).expect(200); expect(res.body.length).toBe((await png(size)).length); } }); it('refuses a favicon whose bytes do not match the size they claim', async () => { const res = await api() .post('/api/v1/admin/branding/favicon') .set('Cookie', adminCookie) .attach('png-32', await png(64), 'f32.png') .attach('png-180', await png(180), 'f180.png') .expect(400); expect(res.body.code).toBe('branding_favicon_not_square'); }); it('clears an asset and falls back again', async () => { await api().delete('/api/v1/admin/branding/favicon').set('Cookie', adminCookie).expect(200); const view = await api().get('/api/v1/branding').expect(200); expect(view.body.favicon).toBeNull(); // Back to the shipped default rather than a 404. await api().get('/api/v1/branding/favicon').expect(200); await api() .delete('/api/v1/admin/branding/logo?variant=light') .set('Cookie', adminCookie) .expect(200); await api().get('/api/v1/branding/logo?variant=light').expect(404); }); it('keeps management away from a non-admin, but not reading', async () => { await api() .post('/api/v1/admin/branding/logo?variant=light') .set('Cookie', plainCookie) .attach('file', await png(32), 'x.png') .expect(403); await api().delete('/api/v1/admin/branding/favicon').set('Cookie', plainCookie).expect(403); await api().get('/api/v1/branding').set('Cookie', plainCookie).expect(200); }); it('audits every branding change with scope, asset and direction', async () => { await api() .post('/api/v1/admin/branding/logo?variant=dark') .set('Cookie', adminCookie) .attach('file', await png(48), 'logo.png') .expect(201); const entry = await prisma.auditEntry.findFirst({ where: { action: 'branding.changed', targetId: 'instance.logoDark' }, orderBy: { at: 'desc' }, }); expect(entry).not.toBeNull(); expect(entry!.details).toMatchObject({ scope: 'instance', asset: 'logoDark', change: 'set' }); }); it('refuses to write branding metadata through the settings endpoint', async () => { // The metadata describes bytes on disk; hand-writing it would claim an // asset that is not there, so the settings PATCH does not accept it. const res = await api() .patch('/api/v1/admin/settings') .set('Cookie', adminCookie) .send({ 'instance.logo': { hash: 'deadbeefdeadbeef', width: 10, height: 10 } }) .expect(400); expect(res.body.code).toBe('bad_request'); }); });