import { INestApplication } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { PondsService } from '../ponds/ponds.service'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; /** * Viewer-scoped plugin API (issue #74). The `/plugin/*` endpoints reuse the * same page/pond guards as the rest of the app, so a plugin sees exactly what * its viewer could: a label-restricted reader gets a filtered `listPages` and * a 404 on the page they cannot read; a non-member is refused outright. */ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => { let app: INestApplication; let prisma: PrismaClient; const suffix = uniqueSuffix(); const password = 'plugins lesen nur was der nutzer darf 1'; const userIds: Record = {}; const cookies: Record = {}; let pondId: string; let openPageId: string; let secretPageId: string; const api = () => request(app.getHttpServer()); async function makeUser(handle: string): Promise { const users = app.get(UsersService); const username = `plugapi-${handle}-${suffix}`; const user = await users.createUser({ username, email: `${username}@example.org`, displayName: `PlugApi ${handle}`, password, locale: 'en', }); userIds[handle] = user.id; await users.markEmailVerified(user.id); const res = await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: username, password }) .expect(200); cookies[handle] = sessionCookieOf(res); } beforeAll(async () => { prisma = createTestPrisma(); await prisma.rateLimit.deleteMany({}); app = await createTestApp(); for (const handle of ['owner', 'reader', 'foreign']) await makeUser(handle); await app .get(PondsService) .ensurePersonalPond(await prisma.user.findUniqueOrThrow({ where: { id: userIds.owner! } })); await prisma.quotaOverride.create({ data: { subjectType: 'USER', subjectId: userIds.owner!, quotaKey: 'additional_ponds', value: 10, }, }); const pond = await api() .post('/api/v1/ponds') .set('Cookie', cookies.owner!) .send({ name: `PlugApi Pond ${suffix}` }) .expect(201); pondId = pond.body.id; const open = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', cookies.owner!) .send({ title: 'Open Page' }) .expect(201); openPageId = open.body.id; const secret = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', cookies.owner!) .send({ title: 'Secret Page' }) .expect(201); secretPageId = secret.body.id; // The reader may see the pond, but a label-deny hides the secret page. await api() .post(`/api/v1/ponds/${pondId}/grants`) .set('Cookie', cookies.owner!) .send({ subjectType: 'user', subjectId: userIds.reader!, role: 'reader', scopeType: 'pond', effect: 'allow', }) .expect(201); const label = await api() .post(`/api/v1/ponds/${pondId}/labels`) .set('Cookie', cookies.owner!) .send({ name: `secret-${suffix}` }) .expect(201); await api() .post(`/api/v1/pages/${secretPageId}/labels`) .set('Cookie', cookies.owner!) .send({ labelId: label.body.id }) .expect(201); await api() .post(`/api/v1/ponds/${pondId}/grants`) .set('Cookie', cookies.owner!) .send({ subjectType: 'user', subjectId: userIds.reader!, role: 'reader', scopeType: 'label', scopeId: label.body.id, effect: 'deny', }) .expect(201); }); afterAll(async () => { await prisma.roleGrant.deleteMany({ where: { pondId } }); await prisma.pageLabel.deleteMany({ where: { page: { pondId } } }); await prisma.label.deleteMany({ where: { pondId } }); await prisma.pageContentCache.deleteMany({ where: { page: { pondId } } }); await prisma.pageUpdate.deleteMany({ where: { page: { pondId } } }); await prisma.page.deleteMany({ where: { pondId } }); await prisma.pond.deleteMany({ where: { id: pondId } }); const ids = Object.values(userIds); await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } }); await prisma.pond.deleteMany({ where: { ownerId: { in: ids } } }); await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } }); await prisma.user.deleteMany({ where: { id: { in: ids } } }); await prisma.$disconnect(); await app.close(); }); it('lists all pages for a full reader (the owner)', async () => { const res = await api() .get(`/api/v1/plugin/ponds/${pondId}/pages`) .set('Cookie', cookies.owner!) .expect(200); expect(res.body.map((p: { id: string }) => p.id).sort()).toEqual( [openPageId, secretPageId].sort(), ); expect(res.body[0]).toMatchObject({ title: expect.any(String), slug: expect.any(String) }); // Label *names* travel with each summary (issue #77, page-index filter). const byId = new Map(res.body.map((p: { id: string; labels: string[] }) => [p.id, p.labels])); expect(byId.get(secretPageId)).toEqual([`secret-${suffix}`]); expect(byId.get(openPageId)).toEqual([]); }); it('filters listPages for a label-restricted reader and 404s the hidden page', async () => { const list = await api() .get(`/api/v1/plugin/ponds/${pondId}/pages`) .set('Cookie', cookies.reader!) .expect(200); const ids = list.body.map((p: { id: string }) => p.id); expect(ids).toContain(openPageId); expect(ids).not.toContain(secretPageId); // The readable page's content/outline/meta are served… await api() .get(`/api/v1/plugin/pages/${openPageId}/content`) .set('Cookie', cookies.reader!) .expect(200); await api() .get(`/api/v1/plugin/pages/${openPageId}/outline`) .set('Cookie', cookies.reader!) .expect(200); const meta = await api() .get(`/api/v1/plugin/pages/${openPageId}/meta`) .set('Cookie', cookies.reader!) .expect(200); expect(meta.body).toMatchObject({ id: openPageId, pondId }); // …but the denied page reads as nonexistent through the plugin API too. await api() .get(`/api/v1/plugin/pages/${secretPageId}/content`) .set('Cookie', cookies.reader!) .expect(404); await api() .get(`/api/v1/plugin/pages/${secretPageId}/outline`) .set('Cookie', cookies.reader!) .expect(404); }); it('hides everything from a non-member (404) and rejects anonymous callers (401)', async () => { // An authenticated non-member sees 404 (existence hidden) — the same read // policy the rest of the app enforces, inherited via the shared guards. await api() .get(`/api/v1/plugin/ponds/${pondId}/pages`) .set('Cookie', cookies.foreign!) .expect(404); await api() .get(`/api/v1/plugin/pages/${openPageId}/content`) .set('Cookie', cookies.foreign!) .expect(404); // The plugin API runs with the viewer's session; there is no anonymous // viewer (public read is the separate #56 path), so auth rejects first. await api().get(`/api/v1/plugin/pages/${openPageId}/meta`).expect(401); }); });