import { readdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { INestApplication } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import { zipSync } from 'fflate'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { AuthTokensService } from '../auth/auth-tokens.service'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; import { PluginStorageService } from './plugin-storage.service'; import { PluginWatcherService } from './plugin-watcher.service'; const enc = (text: string) => new TextEncoder().encode(text); function codeManifest(overrides: Record = {}): Record { return { id: 'toc', name: 'Table of Contents', version: '1.0.0', apiVersion: '1', kind: 'code', extensionPoints: [{ type: 'pageTool', id: 'toc', title: { de: 'Inhalt', en: 'Contents' } }], permissions: ['readCurrentPage'], license: 'MIT', ...overrides, }; } function pluginZip(manifest: Record, bundle = 'export default {}'): Buffer { return Buffer.from( zipSync({ 'manifest.json': enc(JSON.stringify(manifest)), 'plugin.js': enc(bundle) }), ); } /** * Plugin storage, install API, and directory watcher (issue #71). Exercises the * GUI upload endpoint, the dropzone watcher, atomic updates, and the uninstall * guards against a real database and filesystem. */ describe.skipIf(!hasTestDb)('plugins install (e2e, issue #71)', () => { let app: INestApplication; let prisma: PrismaClient; let storage: PluginStorageService; let watcher: PluginWatcherService; const suffix = uniqueSuffix(); const password = 'plugins installieren macht spass 1'; const admin = { username: `pam-plugins-${suffix}`, displayName: `Pam Plugins ${suffix}` }; const outsider = { username: `orin-plugins-${suffix}`, displayName: `Orin Outside ${suffix}` }; let adminCookie: string; let outsiderCookie: string; const api = () => request(app.getHttpServer()); async function loginOf(username: string): Promise { const res = await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: username, password }) .expect(200); return sessionCookieOf(res); } beforeAll(async () => { prisma = createTestPrisma(); // A local dev DB is shared with the e2e stack, which installs the real // reference plugins (`toc`, …) — clear the registry up front so the // fixture installs below never collide with a leftover active version. await prisma.pondPlugin.deleteMany({}); await prisma.plugin.deleteMany({}); app = await createTestApp(); storage = app.get(PluginStorageService); watcher = app.get(PluginWatcherService); const users = app.get(UsersService); const tokens = app.get(AuthTokensService); const adminUser = await users.createUser({ username: admin.username, email: `${admin.username}@example.org`, displayName: admin.displayName, password, locale: 'en', }); const verifyToken = await tokens.issue(adminUser.id, 'EMAIL_VERIFICATION', 600); await api().post('/api/v1/auth/verify-email').send({ token: verifyToken }).expect(204); await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } }); adminCookie = await loginOf(admin.username); const outsiderUser = await users.createUser({ username: outsider.username, email: `${outsider.username}@example.org`, displayName: outsider.displayName, password, locale: 'en', }); await users.markEmailVerified(outsiderUser.id); outsiderCookie = await loginOf(outsider.username); }); afterAll(async () => { await prisma.pondPlugin.deleteMany({}); await prisma.plugin.deleteMany({}); await app.close(); }); it('installs via the GUI upload and serves the bundle immutably', async () => { const res = await api() .post('/api/v1/admin/plugins') .set('Cookie', adminCookie) .attach('file', pluginZip(codeManifest()), 'toc.zip') .expect(201); expect(res.body).toMatchObject({ id: 'toc', version: '1.0.0', kind: 'code', mode: 'disabled' }); const asset = await api().get('/api/v1/plugins/toc/1.0.0/plugin.js').expect(200); expect(asset.headers['cache-control']).toContain('immutable'); expect(asset.headers['content-type']).toContain('text/javascript'); expect(asset.text).toContain('export default'); const list = await api().get('/api/v1/admin/plugins').set('Cookie', adminCookie).expect(200); expect(list.body.map((p: { id: string }) => p.id)).toContain('toc'); }); it('serves the sandbox frame document with a network-denying CSP (issue #73)', async () => { await api() .post('/api/v1/admin/plugins') .set('Cookie', adminCookie) .attach('file', pluginZip(codeManifest({ id: 'framer', name: 'Framer' })), 'framer.zip') .expect(201); const frame = await api().get('/api/v1/plugins/framer/1.0.0/frame').expect(200); expect(frame.headers['content-type']).toContain('text/html'); expect(frame.text).toContain('