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, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; /** Smallest bytes that pass the magic check — the api never parses further. */ const woff2 = (): Buffer => Buffer.concat([Buffer.from('wOF2'), Buffer.alloc(64)]); const woff = (): Buffer => Buffer.concat([Buffer.from('wOFF'), Buffer.alloc(64)]); describe.skipIf(!hasTestDb)('custom fonts (e2e, issue #303)', () => { let app: INestApplication; let prisma: PrismaClient; let fontsDir: string; const suffix = uniqueSuffix(); const password = 'schriftverwaltung mit stil 1'; const admin = { username: `fa-${suffix}`, displayName: `Font Admin ${suffix}` }; const plain = { username: `fp-${suffix}`, displayName: `Font Plain ${suffix}` }; let adminCookie: string; let plainCookie: string; const api = () => request(app.getHttpServer()); beforeAll(async () => { prisma = createTestPrisma(); await prisma.rateLimit.deleteMany({}); // A real directory so the storage layer is exercised, not mocked — the // point of this suite is that bytes actually land somewhere retrievable. fontsDir = await mkdtemp(join(tmpdir(), 'dorfteich-fonts-')); process.env.CUSTOM_FONTS_DIR = fontsDir; app = await createTestApp(); const users = app.get(UsersService); const adminUser = await users.createUser({ username: admin.username, email: `${admin.username}@example.org`, displayName: admin.displayName, password, locale: 'en', }); await users.markEmailVerified(adminUser.id); await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } }); // additional_ponds defaults to 0 (ADR 0011) and the instance default is // never raised — the usage test needs a pond, so grant an override. await prisma.quotaOverride.create({ data: { subjectType: 'USER', subjectId: adminUser.id, quotaKey: 'additional_ponds', value: 10, }, }); const plainUser = await users.createUser({ username: plain.username, email: `${plain.username}@example.org`, displayName: plain.displayName, 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.customFont.deleteMany({}); const ids = ( await prisma.user.findMany({ where: { username: { contains: suffix } }, select: { id: true }, }) ).map((row) => row.id); await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } }); await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } }); await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); await prisma.$disconnect(); await app.close(); await rm(fontsDir, { recursive: true, force: true }); delete process.env.CUSTOM_FONTS_DIR; }); it('uploads a family, writes the bytes, and serves them back', async () => { const created = await api() .post('/api/v1/admin/fonts') .set('Cookie', adminCookie) .field('family', `Hausschrift ${suffix}`) .field('category', 'serif') .field('licence', 'Commercial — Foundry XY') .attach('woff2-400', woff2(), 'x.woff2') .attach('woff-400', woff(), 'x.woff') .expect(201); expect(created.body.weights).toEqual([400]); expect(created.body.licence).toBe('Commercial — Foundry XY'); const slug = created.body.slug as string; // The bytes are really on disk, in the catalog's layout. const onDisk = await readFile(join(fontsDir, slug, `${slug}-400.woff2`)); expect(onDisk.subarray(0, 4).toString()).toBe('wOF2'); // …and reachable without a session: a font is fetched from CSS. const served = await api().get(`/api/v1/fonts/custom/${slug}/${slug}-400.woff2`).expect(200); expect(served.headers['content-type']).toContain('font/woff2'); }); it('rejects a file that is not a font, whatever it is called', async () => { const res = await api() .post('/api/v1/admin/fonts') .set('Cookie', adminCookie) .field('family', `Fake ${suffix}`) .field('category', 'sans-serif') .field('licence', 'X') .attach('woff2-400', Buffer.from('\x89PNG\r\n\x1a\n and more'), 'evil.woff2') .expect(400); expect(res.body.code).toBe('font_file_not_a_font'); }); it('refuses a family name that a catalog font already owns', async () => { const res = await api() .post('/api/v1/admin/fonts') .set('Cookie', adminCookie) .field('family', 'Roboto') .field('category', 'sans-serif') .field('licence', 'X') .attach('woff2-400', woff2(), 'x.woff2') .expect(409); expect(res.body.code).toBe('font_family_reserved'); }); it('refuses a weight whose WOFF2 is missing', async () => { const res = await api() .post('/api/v1/admin/fonts') .set('Cookie', adminCookie) .field('family', `NurWoff ${suffix}`) .field('category', 'sans-serif') .field('licence', 'X') .attach('woff-400', woff(), 'x.woff') .expect(400); expect(res.body.code).toBe('font_woff2_missing'); }); /** * Issue #304: an ordinary member picks fonts in their pond's Appearance * settings and reads the licence page, so the family list cannot be * Site-Admin-only — only the management routes are. */ it('lets any signed-in user read the family list, but nobody anonymous', async () => { await api() .post('/api/v1/admin/fonts') .set('Cookie', adminCookie) .field('family', `Leseschrift ${suffix}`) .field('category', 'monospace') .field('licence', 'Read me') .attach('woff2-500', woff2(), 'x.woff2') .expect(201); const listed = await api().get('/api/v1/fonts/custom').set('Cookie', plainCookie).expect(200); const seen = (listed.body as { family: string; weights: number[] }[]).find( (font) => font.family === `Leseschrift ${suffix}`, ); expect(seen?.weights).toEqual([500]); await api().get('/api/v1/fonts/custom').expect(401); }); it('keeps every management route away from a non-admin', async () => { await api().get('/api/v1/admin/fonts').set('Cookie', plainCookie).expect(403); await api() .post('/api/v1/admin/fonts') .set('Cookie', plainCookie) .field('family', `Nope ${suffix}`) .field('category', 'serif') .field('licence', 'X') .attach('woff2-400', woff2(), 'x.woff2') .expect(403); }); it('counts the ponds a family is used by, and deletion leaves them working', async () => { const created = await api() .post('/api/v1/admin/fonts') .set('Cookie', adminCookie) .field('family', `Zählschrift ${suffix}`) .field('category', 'sans-serif') .field('licence', 'X') .attach('woff2-400', woff2(), 'x.woff2') .expect(201); const pond = await api() .post('/api/v1/ponds') .set('Cookie', adminCookie) .send({ name: `Schriftteich ${suffix}` }) .expect(201); await api() .patch(`/api/v1/ponds/${pond.body.id}`) .set('Cookie', adminCookie) .send({ fonts: { body: { family: `Zählschrift ${suffix}`, weight: 400 } } }) .expect(200); const usage = await api() .get(`/api/v1/admin/fonts/${created.body.id}/usage`) .set('Cookie', adminCookie) .expect(200); expect(usage.body.pondsAffected).toBe(1); // Deletion is never blocked by usage. await api() .delete(`/api/v1/admin/fonts/${created.body.id}`) .set('Cookie', adminCookie) .expect(204); // The pond still resolves — it keeps the stored family name and falls // back to the system stack, rather than breaking. const after = await api() .get(`/api/v1/ponds/${pond.body.slug}`) .set('Cookie', adminCookie) .expect(200); expect(after.body.settings.fonts.body.family).toBe(`Zählschrift ${suffix}`); expect( await api().get('/api/v1/admin/fonts').set('Cookie', adminCookie).expect(200), ).toBeTruthy(); const audit = await prisma.auditEntry.findFirst({ where: { action: 'font.deleted', targetId: created.body.id }, }); expect(audit).not.toBeNull(); expect(audit!.details).toMatchObject({ pondsAffected: 1 }); }); });