diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5b67244..0c579ff 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -232,6 +232,16 @@ jobs: E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/comments.spec.ts + - name: Reset login rate limit before social pack + run: | + echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ + pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" + + - name: Run social pack + run: | + E2E_BASE_URL=http://localhost:5173 \ + pnpm --filter @dorfteich/web exec playwright test e2e/social.spec.ts + - name: Reset login rate limit before admin-quotas pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ diff --git a/apps/api/src/notifications/__snapshots__/digest.snapshot.db.test.ts.snap b/apps/api/src/notifications/__snapshots__/digest.snapshot.db.test.ts.snap new file mode 100644 index 0000000..3fa04db --- /dev/null +++ b/apps/api/src/notifications/__snapshots__/digest.snapshot.db.test.ts.snap @@ -0,0 +1,15 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`digest mail structure snapshot (issue #96) > renders the grouped digest exactly as pinned 1`] = ` +"Dorfteich: 4 Neuigkeiten für dich +--- +Das ist auf von dir beobachteten Seiten passiert (4 Neuigkeiten): + +Snapshot Pond: + - Notizen: 2 Änderungen, 1 Kommentar (Anna Autorin) + - Plan: 1 Änderung (Anna Autorin) + +Dorfteich öffnen: http://localhost:5173 + +Diese Digest-Mails abbestellen: http://localhost:5173/api/v1/notifications/unsubscribe?token=" +`; diff --git a/apps/api/src/notifications/digest.snapshot.db.test.ts b/apps/api/src/notifications/digest.snapshot.db.test.ts new file mode 100644 index 0000000..f53d6a7 --- /dev/null +++ b/apps/api/src/notifications/digest.snapshot.db.test.ts @@ -0,0 +1,136 @@ +import { INestApplication } from '@nestjs/common'; +import { PrismaClient, User } from '@prisma/client'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { DigestService } from './digest.service'; +import { NotificationsService } from './notifications.service'; +import { createTestApp } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { UsersService } from '../users/users.service'; + +const HOUR = 60 * 60 * 1000; + +/** + * Pins the digest mail's structure (issue #96): grouped per pond → per + * page with counts and actors, intro/open/unsubscribe framing. Changing + * the mail requires an explicit snapshot update — that is the point. + * Dynamic parts (signed token) are normalized before snapshotting. + */ +describe.skipIf(!hasTestDb)('digest mail structure snapshot (issue #96)', () => { + let app: INestApplication; + let prisma: PrismaClient; + const suffix = uniqueSuffix(); + const users: Record = {}; + let pondId: string; + const pageIds: string[] = []; + + async function makeUser(handle: string, displayName: string): Promise { + const service = app.get(UsersService); + const user = await service.createUser({ + username: `ds-${handle}-${suffix}`, + email: `ds-${handle}-${suffix}@example.org`, + displayName, + password: 'schnappschuss bleibt stabil 1', + locale: 'de', + }); + await service.markEmailVerified(user.id); + users[handle] = user; + } + + async function makePage(title: string, slug: string): Promise { + const page = await prisma.page.create({ + data: { + pondId, + title, + slug, + ydocState: new Uint8Array(), + sortKey: `a${slug}`, + createdBy: users.owner!.id, + }, + }); + pageIds.push(page.id); + return page.id; + } + + beforeAll(async () => { + prisma = createTestPrisma(); + app = await createTestApp(); + await makeUser('owner', 'Anna Autorin'); + await makeUser('watcher', 'Willi Watcher'); + + const pond = await prisma.pond.create({ + data: { + slug: `ds-pond-${suffix}`, + name: 'Snapshot Pond', + type: 'SHARED', + ownerId: users.owner!.id, + }, + }); + pondId = pond.id; + for (const [handle, role] of [ + ['owner', 'POND_ADMIN'], + ['watcher', 'READER'], + ] as const) { + await prisma.roleGrant.create({ + data: { + pondId, + subjectType: 'USER', + subjectId: users[handle]!.id, + role, + scopeType: 'POND', + effect: 'ALLOW', + createdBy: users.owner!.id, + }, + }); + } + }); + + afterAll(async () => { + const ids = Object.values(users).map((u) => u.id); + await prisma.mailOutbox.deleteMany({ + where: { toAddress: { in: Object.values(users).map((u) => u.email) } }, + }); + await prisma.notification.deleteMany({ where: { userId: { in: ids } } }); + await prisma.watch.deleteMany({ where: { userId: { in: ids } } }); + await prisma.roleGrant.deleteMany({ where: { pondId } }); + await prisma.page.deleteMany({ where: { pondId } }); + await prisma.pond.deleteMany({ where: { id: pondId } }); + await prisma.userIdentity.deleteMany({ where: { userId: { in: ids } } }); + await prisma.user.deleteMany({ where: { id: { in: ids } } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('renders the grouped digest exactly as pinned', async () => { + const notes = await makePage('Notizen', 'notizen'); + const plan = await makePage('Plan', 'plan'); + await prisma.watch.createMany({ + data: [ + { userId: users.watcher!.id, targetType: 'PAGE', targetId: notes }, + { userId: users.watcher!.id, targetType: 'PAGE', targetId: plan }, + ], + }); + const notifications = app.get(NotificationsService); + await notifications.fanoutPageEvent('page_changed', notes, [users.owner!.id]); + await notifications.fanoutPageEvent('page_changed', notes, [users.owner!.id]); + await notifications.fanoutPageEvent('comment_added', notes, [users.owner!.id]); + await notifications.fanoutPageEvent('page_changed', plan, [users.owner!.id]); + await prisma.notification.updateMany({ + where: { userId: users.watcher!.id }, + data: { createdAt: new Date(Date.now() - 2 * HOUR) }, + }); + + expect(await app.get(DigestService).runOnce()).toBe(1); + const mail = await prisma.mailOutbox.findFirstOrThrow({ + where: { toAddress: users.watcher!.email }, + orderBy: { createdAt: 'desc' }, + }); + + // Normalize the signed token — everything else must be stable. + const normalized = `${mail.subject}\n---\n${mail.textBody}`.replace( + /token=[A-Za-z0-9_.-]+/g, + 'token=', + ); + expect(normalized).toMatchSnapshot(); + }); +}); diff --git a/apps/web/e2e/permission-matrix.spec.ts b/apps/web/e2e/permission-matrix.spec.ts index 813923b..dea7c1e 100644 --- a/apps/web/e2e/permission-matrix.spec.ts +++ b/apps/web/e2e/permission-matrix.spec.ts @@ -16,7 +16,9 @@ const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; * Subjects: site admin, pond admin (owner), editor, label-restricted editor * (same account, blocked on a "secret" label), reader, public (anonymous), * foreign (signed-in non-member). Surfaces: page read, edit (collab token - * mode), sidebar list, search, versions, media, public HTML. + * mode), sidebar list, search, versions, media, public HTML — and, since + * issue #96, comments: reading follows page read, writing follows the pond's + * `commentPolicy` (readers | editors), both under the same 404-vs-403 policy. */ interface Fixture { @@ -206,3 +208,33 @@ test('versions require write; media follows page read; public HTML honours grant expect(await status(f.anon, `/api/v1/public/${f.pondSlug}/${f.publicPage.slug}`)).toBe(200); expect(await status(f.anon, `/api/v1/public/${f.pondSlug}/${f.normalPage.slug}`)).toBe(404); }); + +test('comments follow page read plus the pond comment policy (issue #96)', async () => { + const post = (c: APIRequestContext, pageId: string): Promise => + c + .post(`/api/v1/pages/${pageId}/comments`, { data: { body: 'matrix probe' } }) + .then((r) => r.status()); + const list = (c: APIRequestContext, pageId: string): Promise => + status(c, `/api/v1/pages/${pageId}/comments`); + + // Default policy (readers): every reader writes, hidden pages stay hidden. + expect(await post(f.owner.request, f.normalPage.id)).toBe(201); + expect(await post(f.editor.request, f.normalPage.id)).toBe(201); + expect(await post(f.reader.request, f.normalPage.id)).toBe(201); + expect(await post(f.outsider.request, f.normalPage.id)).toBe(404); + expect(await post(f.anon, f.normalPage.id)).toBe(401); + // The label-restricted editor cannot even see the secret page's thread. + expect(await post(f.editor.request, f.secretPage.id)).toBe(404); + expect(await list(f.editor.request, f.secretPage.id)).toBe(404); + expect(await list(f.reader.request, f.normalPage.id)).toBe(200); + + // Editors-only policy: readable-but-barred is an explicit 403. + const patched = await f.owner.request.patch(`/api/v1/ponds/${f.pondId}`, { + data: { commentPolicy: 'editors' }, + }); + expect(patched.ok()).toBe(true); + expect(await post(f.reader.request, f.normalPage.id)).toBe(403); + expect(await post(f.editor.request, f.normalPage.id)).toBe(201); + expect(await post(f.outsider.request, f.normalPage.id)).toBe(404); + expect(await list(f.reader.request, f.normalPage.id)).toBe(200); // reading stays open +}); diff --git a/apps/web/e2e/social.spec.ts b/apps/web/e2e/social.spec.ts new file mode 100644 index 0000000..7d4fdac --- /dev/null +++ b/apps/web/e2e/social.spec.ts @@ -0,0 +1,110 @@ +import { expect, test } from '@playwright/test'; +import type { APIRequestContext, BrowserContext } from '@playwright/test'; + +import { contextForUser } from './helpers'; + +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; + +/** + * Watch → notify regression flows with two users (issue #96). API-level on + * purpose — the UI adds nothing over the delivered notification rows (the + * bell is plain rendering of this API; the comments UI has its own pack). + * Fixtures: fixture-user owns a throwaway pond (the seed grants fixture + * users additional-pond headroom), fixture-viewer is its reader/watcher. + * Digest *semantics* are pinned by the DB suite + the mail-structure + * snapshot (issue #95); here the job only proves it runs end to end. + */ + +let owner: BrowserContext; +let watcher: BrowserContext; +let pondId: string; +let pageId: string; +let watcherId: string; + +async function notificationsFor( + ctx: BrowserContext, +): Promise<{ + unreadCount: number; + notifications: { type: string; payload: { pageId: string } }[]; +}> { + const res = await ctx.request.get('/api/v1/notifications'); + expect(res.ok()).toBe(true); + return (await res.json()) as { + unreadCount: number; + notifications: { type: string; payload: { pageId: string } }[]; + }; +} + +const forPage = (list: { notifications: { type: string; payload: { pageId: string } }[] }) => + list.notifications.filter((n) => n.payload.pageId === pageId); + +test.beforeAll(async ({ browser }) => { + owner = await contextForUser(browser, BASE_URL, 'fixture-user'); + watcher = await contextForUser(browser, BASE_URL, 'fixture-viewer'); + + const pond = (await ( + await owner.request.post('/api/v1/ponds', { data: { name: `Social ${Date.now()}` } }) + ).json()) as { id: string }; + pondId = pond.id; + const page = (await ( + await owner.request.post(`/api/v1/ponds/${pondId}/pages`, { data: { title: 'Social target' } }) + ).json()) as { id: string }; + pageId = page.id; + + const member = await owner.request.post(`/api/v1/ponds/${pondId}/members`, { + data: { usernameOrEmail: 'fixture-viewer', role: 'reader' }, + }); + expect(member.ok()).toBe(true); + watcherId = ((await (await watcher.request.get('/api/v1/auth/me')).json()) as { id: string }).id; + const watch = await watcher.request.put(`/api/v1/watches/page/${pageId}`); + expect(watch.ok()).toBe(true); +}); + +test.afterAll(async () => { + await watcher.request.post('/api/v1/notifications/read-all'); + await owner.request.delete(`/api/v1/ponds/${pondId}`); + await owner.close(); + await watcher.close(); +}); + +test('a comment notifies the watcher, never the actor, and read state sticks', async ({}, testInfo) => { + testInfo.setTimeout(20_000); + const commented = await owner.request.post(`/api/v1/pages/${pageId}/comments`, { + data: { body: 'ping the watchers' }, + }); + expect(commented.ok()).toBe(true); + + const watcherList = await notificationsFor(watcher); + const mine = forPage(watcherList); + expect(mine.length).toBe(1); + expect(mine[0]!.type).toBe('comment_added'); + expect(watcherList.unreadCount).toBeGreaterThan(0); + + // The actor got nothing for their own comment. + expect(forPage(await notificationsFor(owner)).length).toBe(0); + + // Read-all clears the badge; a fresh request still sees it (server state). + await watcher.request.post('/api/v1/notifications/read-all'); + expect((await notificationsFor(watcher)).unreadCount).toBe(0); +}); + +test('a revoked watcher receives nothing new (delivery-time re-check)', async () => { + const before = forPage(await notificationsFor(watcher)).length; + + const removed = await owner.request.delete(`/api/v1/ponds/${pondId}/members/${watcherId}`); + expect(removed.ok()).toBe(true); + const commented = await owner.request.post(`/api/v1/pages/${pageId}/comments`, { + data: { body: 'the revoked watcher must not hear this' }, + }); + expect(commented.ok()).toBe(true); + + expect(forPage(await notificationsFor(watcher)).length).toBe(before); +}); + +test('the digest job runs end to end via the manual trigger', async ({ browser }) => { + const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); + const res = await admin.request.post('/api/v1/admin/system/jobs/notification-digest/run'); + expect(res.ok()).toBe(true); + expect(((await res.json()) as { outcome: string }).outcome).toBe('succeeded'); + await admin.close(); +});