dorfteich/apps/api/src/plugins/plugins.e2e.db.test.ts
Claude Opus 5 45f1925917
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m28s
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Auth e2e pack (pull_request) Failing after 4m1s
CI / Build container images (pull_request) Successful in 4m3s
#302: configurable pond start page, created with every new pond
Opening a pond landed on whatever sorted first in the sidebar — stable,
but a rule nobody could see, and one whose target moved as soon as
someone added a page ahead of it. New ponds landed on the empty-pond hint
instead of anything useful.

- `startPageId` joins the pond settings. No migration: `Pond.settings` is
  already jsonb. It stores an id, not a slug, so renaming or moving the
  page keeps it working.
- `PondHomePage` prefers it, but only when the page is in this user's
  page list. That list already holds just what they may see, so a start
  page hidden by a page-scoped grant — or trashed — falls back silently
  instead of landing them on a 404, and it costs no extra request.
- Both creation paths give the pond a start page, titled from the
  creator's stored locale. It happens after the creating transaction
  commits: the owner's grant is written inside it and permissions cache
  per pond, so creating the page any earlier would ask about rights the
  grant has not published yet. A failure is logged, not fatal — a pond
  without a start page still works.

`PagesModule` imported `PondsModule` without using it. Removing that
vestigial edge let PondsModule depend on PagesModule in the honest
direction instead of tying the two together with forwardRef.

Every pond created through the api now owns a page, which broke eight
suites whose teardown deleted ponds directly — `Page.pond` deliberately
has no cascade, because a real purge removes contents explicitly and
audits it. A shared `deletePondsWhere` helper deletes pages first. Two
tests that counted pages now account for the start page rather than
pretending the pond began empty.
2026-08-01 08:06:35 +02:00

517 lines
21 KiB
TypeScript

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, deletePondsWhere, 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<string, unknown> = {}): Record<string, unknown> {
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<string, unknown>, 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<string> {
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('<script type="module" src="./plugin.js">');
// The CSP pins every load to the plugin's own asset path on the configured
// public origin (not the request Host) and forbids network access — the
// sandbox security core.
const csp = frame.headers['content-security-policy'];
expect(csp).toContain(`default-src 'none'`);
expect(csp).toMatch(/script-src https?:\/\/[^ ]+\/api\/v1\/plugins\/framer\/1\.0\.0\//);
// Network + frames are pinned to the plugin's OWN asset path (bundled
// apps like drawio) — still zero external network, zero api access.
expect(csp).toMatch(/connect-src https?:\/\/[^ ]+\/api\/v1\/plugins\/framer\/1\.0\.0\//);
expect(csp).toMatch(/frame-src https?:\/\/[^ ]+\/api\/v1\/plugins\/framer\/1\.0\.0\//);
// Framing stays possible under the global security headers (issue #197):
// the host app embeds this document same-origin, X-Frame-Options is
// SAMEORIGIN (never DENY), and the frame's CSP carries no
// frame-ancestors that could override it.
expect(frame.headers['x-frame-options']).toBe('SAMEORIGIN');
expect(csp).not.toContain('frame-ancestors');
// Only the installed current version has a frame; anything else 404s.
await api().get('/api/v1/plugins/framer/9.9.9/frame').expect(404);
await api().get('/api/v1/plugins/ghost/1.0.0/frame').expect(404);
});
it('forbids install for non-Site-Admins', async () => {
await api()
.post('/api/v1/admin/plugins')
.set('Cookie', outsiderCookie)
.attach('file', pluginZip(codeManifest({ id: 'nope' })), 'nope.zip')
.expect(403);
});
it('rejects a schema-invalid manifest with field details', async () => {
const res = await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', pluginZip(codeManifest({ id: 'Bad Id' })), 'bad.zip')
.expect(400);
expect(res.body.code).toBe('plugin_invalid_manifest');
expect(res.body.details.id).toBeTruthy();
});
it('installs a plugin dropped into the dropzone and quarantines an invalid one', async () => {
await storage.ensureServiceDirs();
const goodPath = join(storage.dropzoneDir, 'dropped-index.zip');
await writeFile(goodPath, pluginZip(codeManifest({ id: 'page-index' })));
const good = await watcher.processDropped(goodPath);
expect(good).toEqual({ installed: true, id: 'page-index' });
expect(
await storage.assetExists(join(storage.versionDir('page-index', '1.0.0'), 'plugin.js')),
).toBe(true);
const badPath = join(storage.dropzoneDir, 'dropped-broken.zip');
await writeFile(badPath, Buffer.from('not a zip'));
const bad = await watcher.processDropped(badPath);
expect(bad.installed).toBe(false);
const quarantined = await readdir(storage.quarantineDir);
expect(quarantined.some((name) => name.endsWith('dropped-broken.zip'))).toBe(true);
});
it('updates only to a higher version, atomically', async () => {
// Install a fresh plugin, then update it.
await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', pluginZip(codeManifest({ id: 'updatable', version: '1.0.0' })), 'v1.zip')
.expect(201);
// A same/lower version is refused.
await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', pluginZip(codeManifest({ id: 'updatable', version: '1.0.0' })), 'v1.zip')
.expect(409)
.expect((r) => expect(r.body.code).toBe('plugin_version_not_higher'));
// A higher version installs; its assets exist and the old version is gone.
await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', pluginZip(codeManifest({ id: 'updatable', version: '1.1.0' })), 'v2.zip')
.expect(201);
await api().get('/api/v1/plugins/updatable/1.1.0/plugin.js').expect(200);
// The pointer moved, so the old version no longer resolves.
await api().get('/api/v1/plugins/updatable/1.0.0/plugin.js').expect(404);
expect(
await storage.assetExists(join(storage.versionDir('updatable', '1.0.0'), 'plugin.js')),
).toBe(false);
});
it('switches instance mode and activates optional plugins per pond (#72)', async () => {
await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', pluginZip(codeManifest({ id: 'modeable', name: 'Modeable' })), 'm.zip')
.expect(201);
// The admin owns a personal pond (created at verify-email) → pond_admin on it.
const ponds = await api().get('/api/v1/ponds').set('Cookie', adminCookie).expect(200);
const pondId = ponds.body.find((p: { type: string }) => p.type === 'personal').id;
// Optional but not activated → absent from the pond's effective list.
await api()
.patch('/api/v1/admin/plugins/modeable/mode')
.set('Cookie', adminCookie)
.send({ mode: 'optional' })
.expect(200)
.expect((r) => expect(r.body.mode).toBe('optional'));
let active = await api()
.get(`/api/v1/ponds/${pondId}/plugins`)
.set('Cookie', adminCookie)
.expect(200);
expect(active.body.map((p: { id: string }) => p.id)).not.toContain('modeable');
// Settings list shows it as an off toggle; turning it on makes it active.
const settings = await api()
.get(`/api/v1/ponds/${pondId}/plugins/settings`)
.set('Cookie', adminCookie)
.expect(200);
expect(settings.body).toEqual([
expect.objectContaining({
enabled: false,
plugin: expect.objectContaining({ id: 'modeable' }),
}),
]);
await api()
.put(`/api/v1/ponds/${pondId}/plugins/modeable`)
.set('Cookie', adminCookie)
.send({ enabled: true })
.expect(204);
active = await api()
.get(`/api/v1/ponds/${pondId}/plugins`)
.set('Cookie', adminCookie)
.expect(200);
expect(active.body.map((p: { id: string }) => p.id)).toContain('modeable');
// Required → present everywhere, even without a per-pond activation row, and
// toggling it per pond is refused.
await api()
.patch('/api/v1/admin/plugins/modeable/mode')
.set('Cookie', adminCookie)
.send({ mode: 'required' })
.expect(200);
await prisma.pondPlugin.deleteMany({ where: { pluginId: 'modeable' } });
active = await api()
.get(`/api/v1/ponds/${pondId}/plugins`)
.set('Cookie', adminCookie)
.expect(200);
expect(active.body.map((p: { id: string }) => p.id)).toContain('modeable');
await api()
.put(`/api/v1/ponds/${pondId}/plugins/modeable`)
.set('Cookie', adminCookie)
.send({ enabled: true })
.expect(409)
.expect((r) => expect(r.body.code).toBe('plugin_not_optional'));
// Disabled → gone from the effective list.
await api()
.patch('/api/v1/admin/plugins/modeable/mode')
.set('Cookie', adminCookie)
.send({ mode: 'disabled' })
.expect(200);
active = await api()
.get(`/api/v1/ponds/${pondId}/plugins`)
.set('Cookie', adminCookie)
.expect(200);
expect(active.body.map((p: { id: string }) => p.id)).not.toContain('modeable');
// The mode switch is Site-Admin only.
await api()
.patch('/api/v1/admin/plugins/modeable/mode')
.set('Cookie', outsiderCookie)
.send({ mode: 'optional' })
.expect(403);
});
it('serves the manifest fallback for blocks, surviving uninstall as text (#76)', async () => {
await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach(
'file',
pluginZip(
codeManifest({
id: 'fally',
extensionPoints: [{ type: 'block', id: 'diagram', title: { de: 'D', en: 'D' } }],
fallback: { type: 'text', value: '[Diagramm]' },
}),
),
'f.zip',
)
.expect(201);
// Any signed-in user may resolve a fallback; anonymous requests may not.
const view = await api()
.get('/api/v1/plugins/fally/fallback')
.set('Cookie', outsiderCookie)
.expect(200);
expect(view.body).toMatchObject({
pluginId: 'fally',
fallback: { type: 'text', value: '[Diagramm]' },
});
await api().get('/api/v1/plugins/fally/fallback').expect(401);
// The tombstone keeps answering after uninstall (existing plugin_block
// nodes still render something meaningful); never-installed ids 404.
await api().delete('/api/v1/admin/plugins/fally').set('Cookie', adminCookie).expect(204);
const gone = await api()
.get('/api/v1/plugins/fally/fallback')
.set('Cookie', outsiderCookie)
.expect(200);
expect(gone.body.fallback).toEqual({ type: 'text', value: '[Diagramm]' });
await api()
.get('/api/v1/plugins/never-there/fallback')
.set('Cookie', outsiderCookie)
.expect(404);
});
it('refuses uninstall while required, then removes files and marks it removed', async () => {
await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', pluginZip(codeManifest({ id: 'removable' })), 'r.zip')
.expect(201);
// Required plugins cannot be uninstalled.
await prisma.plugin.update({ where: { id: 'removable' }, data: { mode: 'REQUIRED' } });
await api()
.delete('/api/v1/admin/plugins/removable')
.set('Cookie', adminCookie)
.expect(409)
.expect((r) => expect(r.body.code).toBe('plugin_required_cannot_uninstall'));
// Made optional, it uninstalls: assets vanish and metadata is tombstoned.
await prisma.plugin.update({ where: { id: 'removable' }, data: { mode: 'OPTIONAL' } });
await api().delete('/api/v1/admin/plugins/removable').set('Cookie', adminCookie).expect(204);
await api().get('/api/v1/plugins/removable/1.0.0/plugin.js').expect(404);
const record = await prisma.plugin.findUnique({ where: { id: 'removable' } });
expect(record?.removedAt).not.toBeNull();
const list = await api().get('/api/v1/admin/plugins').set('Cookie', adminCookie).expect(200);
expect(list.body.map((p: { id: string }) => p.id)).not.toContain('removable');
});
});
/**
* Instance-wide plugin kill switch (issue #200, ADR 0025): with
* `plugins.enabled = false` every plugin surface answers 404 — even for a
* Site Admin — while existing blocks keep their declared fallback readable;
* the dropzone quarantines instead of installing; and the switch itself is
* flipped through the admin settings surface. Lives in this file because the
* install suite above wipes the plugin registry in its setup — a separate
* parallel file would race that wipe.
*/
describe.skipIf(!hasTestDb)('plugins kill switch (e2e, issue #200)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let watcher: PluginWatcherService;
let storage: PluginStorageService;
const suffix = uniqueSuffix();
const password = 'schalter aus heisst wirklich aus 1';
const admin = `kira-killswitch-${suffix}`;
const pluginId = `switched-${suffix}`;
let adminCookie: string;
let pondId: string;
const api = () => request(app.getHttpServer());
beforeAll(async () => {
prisma = createTestPrisma();
// Written BEFORE the app boots — the settings cache is in-process and
// fills on first read. Cleared afterAll.
await prisma.instanceSetting.upsert({
where: { key: 'plugins.enabled' },
create: { key: 'plugins.enabled', value: false },
update: { value: false },
});
// Registry row for the fallback assertion, created directly — the
// install route is exactly what the switch turns off.
await prisma.plugin.create({
data: {
id: pluginId,
name: 'Switched Off',
version: '1.0.0',
apiVersion: '1',
kind: 'code',
mode: 'OPTIONAL',
manifest: {
...codeManifest({ id: pluginId, name: 'Switched Off' }),
fallback: { type: 'text', value: 'Der Block schlummert.' },
},
},
});
app = await createTestApp();
watcher = app.get(PluginWatcherService);
storage = app.get(PluginStorageService);
const users = app.get(UsersService);
const tokens = app.get(AuthTokensService);
const adminUser = await users.createUser({
username: admin,
email: `${admin}@example.org`,
displayName: `Kira Killswitch ${suffix}`,
password,
locale: 'en',
});
const verify = await tokens.issue(adminUser.id, 'EMAIL_VERIFICATION', 600);
await api().post('/api/v1/auth/verify-email').send({ token: verify }).expect(204);
await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } });
const login = await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: admin, password })
.expect(200);
adminCookie = sessionCookieOf(login);
pondId = (await prisma.pond.findFirstOrThrow({ where: { ownerId: adminUser.id } })).id;
});
afterAll(async () => {
await prisma.instanceSetting.deleteMany({ where: { key: 'plugins.enabled' } });
await prisma.plugin.deleteMany({ where: { id: pluginId } });
const where = { pond: { owner: { username: { contains: suffix } } } };
await prisma.roleGrant.deleteMany({ where });
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await app.close();
});
it('404s every plugin surface, even for a Site Admin', async () => {
await api().get('/api/v1/admin/plugins').set('Cookie', adminCookie).expect(404);
await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', pluginZip(codeManifest({ id: 'nope', name: 'Nope' })), 'nope.zip')
.expect(404);
await api().get(`/api/v1/ponds/${pondId}/plugins`).set('Cookie', adminCookie).expect(404);
await api()
.put(`/api/v1/ponds/${pondId}/plugins/${pluginId}`)
.set('Cookie', adminCookie)
.send({ enabled: true })
.expect(404);
await api().get(`/api/v1/plugins/${pluginId}/1.0.0/frame`).expect(404);
await api().get(`/api/v1/plugins/${pluginId}/1.0.0/plugin.js`).expect(404);
});
it('keeps the declared fallback readable so existing blocks render it', async () => {
const res = await api()
.get(`/api/v1/plugins/${pluginId}/fallback`)
.set('Cookie', adminCookie)
.expect(200);
expect(res.body).toMatchObject({
name: 'Switched Off',
fallback: { type: 'text', value: 'Der Block schlummert.' },
});
});
it('quarantines a dropzone drop instead of installing it', async () => {
await storage.ensureServiceDirs();
const dropPath = join(storage.dropzoneDir, `switched-drop-${suffix}.zip`);
await writeFile(dropPath, pluginZip(codeManifest({ id: 'dropped', name: 'Dropped' })));
const outcome = await watcher.processDropped(dropPath);
expect(outcome).toEqual({ installed: false, code: 'plugins_disabled' });
const quarantined = await readdir(storage.quarantineDir);
expect(quarantined.some((name) => name.endsWith(`switched-drop-${suffix}.zip`))).toBe(true);
});
it('is flipped through the admin settings surface', async () => {
await api()
.patch('/api/v1/admin/settings')
.set('Cookie', adminCookie)
.send({ 'plugins.enabled': true })
.expect(200);
await api().get('/api/v1/admin/plugins').set('Cookie', adminCookie).expect(200);
await api()
.patch('/api/v1/admin/settings')
.set('Cookie', adminCookie)
.send({ 'plugins.enabled': false })
.expect(200);
await api().get('/api/v1/admin/plugins').set('Cookie', adminCookie).expect(404);
});
});