The backend from #303 could store an operator's font but nothing could choose one: no list endpoint outside the Site-Admin routes, no @font-face rules for a family that only exists at runtime, and no management UI. Found while wiring it up — a real defect in #303, invisible to its tests: `fontStack` cannot tell an uploaded family from a deleted one, so the PDF exporter embedded the face and then never named it. Every export of a pond using an operator font rendered in the system font while the job reported success. Both `fontStack` call sites now take the uploaded families (`buildPdfHtml`, `pondFontVariables`); `pdf-html.test.ts` pins the regression from both sides. Verified against a real Gotenberg: with the families the PDF embeds PlayfairDisplay-Bold, without them NotoSans-Bold — that was the whole bug, in one diff of two PDFs. - `GET /fonts/custom` is readable by any signed-in user, not Site Admins only: the pickers, the licence page and the injected `@font-face` rules all need it, and gating it would have forced a second, admin-only UI. - Bundled and uploaded families are told apart by their `<optgroup>`, not by a badge — the grouping is then part of the control's semantics, so a screen reader announces it and the native mobile select keeps it. Within each source the catalog's category grouping is preserved. - The delete confirmation names how many ponds use the family and what happens to them; focus moves to it and back on cancel. Deletion stays unblocked (the api's decision, #303) — the ponds degrade, they do not break. - The licence page grew a second table. That is what makes an attribution obligation satisfiable: a commercial licence that requires naming the foundry needs a page to name it on. Verified in the browser end to end (upload two weights → listed and rendered in its own font → chosen in a pond → page renders in it → deleted → pond falls back): api suite for fonts/export 77 passed, a11y pack 11/11 locally in both schemes, lint/typecheck/i18n:check green.
245 lines
8.9 KiB
TypeScript
245 lines
8.9 KiB
TypeScript
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<string> =>
|
|
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 });
|
|
});
|
|
});
|