import { INestApplication } from '@nestjs/common'; import { PondArchiveManifest } from '@dorfteich/shared'; import { PrismaClient } from '@prisma/client'; import { unzipSync } from 'fflate'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { AuthTokensService } from '../auth/auth-tokens.service'; import { FilesService } from '../files/files.service'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; function entries(buffer: Buffer): Record { return unzipSync(new Uint8Array(buffer)); } /** supertest parses text by default — a ZIP has to be collected as bytes. */ function asBinary(req: request.Test): request.Test { return req.parse((res, cb) => { const chunks: Buffer[] = []; res.on('data', (chunk: Buffer) => chunks.push(chunk)); res.on('end', () => cb(null, Buffer.concat(chunks))); }); } function manifestOf(buffer: Buffer): PondArchiveManifest { const raw = entries(buffer)['manifest.json']; return JSON.parse(Buffer.from(raw!).toString('utf8')) as PondArchiveManifest; } /** * The full pond archive (issue #305). What separates it from the Markdown * export is exactly what is asserted here: EVERY attachment travels, not only * the embedded ones, and the manifest carries what Markdown cannot — settings, * labels, comments and the hierarchy. */ describe.skipIf(!hasTestDb)('pond archive (e2e, issue #305)', () => { let app: INestApplication; let prisma: PrismaClient; let files: FilesService; const suffix = uniqueSuffix(); const password = 'archiviere den ganzen teich 1'; const owner = { username: `arch-${suffix}` }; const admin = { username: `archadm-${suffix}` }; let ownerId: string; let ownerCookie: string; let adminCookie: string; let pondId: string; let parentPageId: string; const api = () => request(app.getHttpServer()); beforeAll(async () => { prisma = createTestPrisma(); await prisma.rateLimit.deleteMany({}); app = await createTestApp(); files = app.get(FilesService); const users = app.get(UsersService); const tokens = app.get(AuthTokensService); const ownerUser = await users.createUser({ username: owner.username, email: `${owner.username}@example.org`, displayName: `Archive Owner ${suffix}`, password, locale: 'en', }); ownerId = ownerUser.id; await api() .post('/api/v1/auth/verify-email') .send({ token: await tokens.issue(ownerUser.id, 'EMAIL_VERIFICATION', 600) }) .expect(204); ownerCookie = sessionCookieOf( await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: owner.username, password }) .expect(200), ); const adminUser = await users.createUser({ username: admin.username, email: `${admin.username}@example.org`, displayName: `Archive Admin ${suffix}`, password, locale: 'en', }); await users.markEmailVerified(adminUser.id); await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } }); adminCookie = sessionCookieOf( await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: admin.username, password }) .expect(200), ); pondId = (await prisma.pond.findFirstOrThrow({ where: { ownerId, type: 'PERSONAL' } })).id; // A parent and a child page, so the hierarchy has something to state. const parent = await prisma.page.create({ data: { pondId, title: 'Archive Parent', slug: 'archive-parent', ydocState: new Uint8Array(), sortKey: 'a', createdBy: ownerId, contentCache: { create: { plainText: 'Parent body', markdown: 'Parent body', html: '', outline: [] }, }, }, }); parentPageId = parent.id; await prisma.page.create({ data: { pondId, parentId: parent.id, title: 'Archive Child', slug: 'archive-child', ydocState: new Uint8Array(), sortKey: 'b', createdBy: ownerId, contentCache: { create: { plainText: 'Child body', markdown: 'Child body', html: '', outline: [] }, }, }, }); const label = await prisma.label.create({ data: { pondId, name: `Archive Label ${suffix}`, color: '#2f6f4f' }, }); await prisma.pageLabel.create({ data: { pageId: parent.id, labelId: label.id } }); await prisma.comment.create({ data: { pageId: parent.id, authorId: ownerId, body: 'A remark worth keeping.' }, }); }); afterAll(async () => { const where = { pond: { owner: { username: { contains: suffix } } } }; await prisma.comment.deleteMany({ where: { page: where } }); await prisma.attachment.deleteMany({ where }); await prisma.pageLabel.deleteMany({ where: { page: where } }); await prisma.label.deleteMany({ where }); await prisma.page.deleteMany({ where }); await prisma.roleGrant.deleteMany({ where }); await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } }); await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); await prisma.$disconnect(); await app.close(); }); it('contains EVERY attachment, not only the embedded ones', async () => { // The gap this whole issue exists for: an attachment nobody embedded // would vanish unnoticed with the Markdown export. const orphan = await files.upload({ id: ownerId } as never, pondId, { buffer: Buffer.from(PNG_BASE64, 'base64'), size: 70, originalname: 'never-embedded.png', } as never); const res = await asBinary(api().get(`/api/v1/ponds/${pondId}/archive`)) .set('Cookie', ownerCookie) .expect(200); expect(res.headers['content-type']).toContain('application/zip'); const names = Object.keys(entries(res.body as Buffer)); expect(names).toContain('manifest.json'); expect(names).toContain('README.txt'); expect(names).toContain('pages/archive-parent.md'); expect(names).toContain('pages/archive-child.md'); expect(names.some((name) => name.startsWith(`media/${orphan.id}.`))).toBe(true); const manifest = manifestOf(res.body as Buffer); expect(manifest.attachments.map((a) => a.id)).toContain(orphan.id); // The Markdown export would have shipped no media at all here. expect(manifest.attachments.length).toBeGreaterThan(0); }); it('states the hierarchy, labels, comments and settings in the manifest', async () => { const res = await asBinary(api().get(`/api/v1/ponds/${pondId}/archive`)) .set('Cookie', ownerCookie) .expect(200); const manifest = manifestOf(res.body as Buffer); expect(manifest.kind).toBe('dorfteich-pond-archive'); expect(manifest.formatVersion).toBe(1); expect(manifest.complete).toBe(true); expect(manifest.omittedPages).toBe(0); const child = manifest.pages.find((page) => page.slug === 'archive-child'); // The hierarchy is exactly what a folder of Markdown cannot express. expect(child?.parentId).toBe(parentPageId); expect(manifest.labels.some((label) => label.name.includes(suffix))).toBe(true); expect(manifest.comments.map((comment) => comment.body)).toContain('A remark worth keeping.'); // A display name, not an account id — the archive outlives the account. expect(manifest.comments[0]?.author).toContain('Archive Owner'); // Pond settings ride along; fonts are always present through the schema // defaults, so their presence proves the settings object is real. expect(manifest.pond.settings).toHaveProperty('fonts'); }); it('tells a requester before the download how much they would get', async () => { const preview = await api() .get(`/api/v1/ponds/${pondId}/archive/preview`) .set('Cookie', ownerCookie) .expect(200); expect(preview.body.omittedPages).toBe(0); expect(preview.body.includedPages).toBe(preview.body.totalPages); expect(preview.body.complete).toBe(true); }); it('audits the download with counts and completeness', async () => { await asBinary(api().get(`/api/v1/ponds/${pondId}/archive`)) .set('Cookie', ownerCookie) .expect(200); const entry = await prisma.auditEntry.findFirst({ where: { action: 'pond.archived', targetId: pondId }, orderBy: { at: 'desc' }, }); expect(entry).not.toBeNull(); expect(entry!.details).toMatchObject({ complete: true, omittedPages: 0 }); }); it('gives the Site Admin a complete archive without pond membership', async () => { // The purge dialog's archive must not depend on which ponds the operator // happens to be a member of — this admin is a member of none. const preview = await api() .get(`/api/v1/admin/trash/ponds/${pondId}/archive/preview`) .set('Cookie', adminCookie) .expect(200); expect(preview.body.complete).toBe(true); expect(preview.body.omittedPages).toBe(0); const res = await asBinary(api().get(`/api/v1/admin/trash/ponds/${pondId}/archive`)) .set('Cookie', adminCookie) .expect(200); expect(manifestOf(res.body as Buffer).pages.length).toBe(preview.body.totalPages); }); it('keeps the admin archive away from an ordinary pond admin', async () => { await api() .get(`/api/v1/admin/trash/ponds/${pondId}/archive`) .set('Cookie', ownerCookie) .expect(403); }); });