import { INestApplication } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { InstanceSettingsService } from '../settings/instance-settings.service'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; /** * Atom feeds end to end (issue #149): the pond feed lists recently updated * pages, the page feed lists versions; a public pond serves anonymously, a * private pond 404s without a feed token and opens with one; the feed-token * lifecycle runs through the settings endpoints. */ describe.skipIf(!hasTestDb)('atom feeds (e2e, issue #149)', () => { let app: INestApplication; let prisma: PrismaClient; const suffix = uniqueSuffix(); const password = 'feeds sind bequem 1'; let ownerId: string; let ownerCookie: string; let pondSlug: string; let pondId: string; let privatePondSlug: string; let privatePondId: string; let feedToken: string; const api = () => request(app.getHttpServer()); async function makePage(pondIdV: string, slug: string, title: string): Promise { const page = await prisma.page.create({ data: { pondId: pondIdV, slug, title, createdBy: ownerId, sortKey: 'a0', ydocState: new Uint8Array(), contentCache: { create: { plainText: title, markdown: title, html: `

${title}

`, outline: [] }, }, }, }); return page.id; } beforeAll(async () => { prisma = createTestPrisma(); app = await createTestApp(); const users = app.get(UsersService); const username = `feed-owner-${suffix}`; const owner = await users.createUser({ username, email: `${username}@example.test`, displayName: 'Feed Owner', password, locale: 'en', }); ownerId = owner.id; await users.markEmailVerified(ownerId); ownerCookie = sessionCookieOf( await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: username, password }) .expect(200), ); pondSlug = `feed-pond-${suffix}`; const pond = await prisma.pond.create({ data: { slug: pondSlug, name: 'Feed Pond', type: 'SHARED', ownerId }, }); pondId = pond.id; await makePage(pondId, `older-${suffix}`, 'Older Page'); const newerId = await makePage(pondId, `newer-${suffix}`, 'Newer Page'); await prisma.pageVersion.create({ data: { pageId: newerId, ydocSnapshot: new Uint8Array(), trigger: 'MANUAL', label: 'First draft', createdBy: ownerId, }, }); await prisma.roleGrant.create({ data: { pondId, subjectType: 'PUBLIC', subjectId: null, role: 'READER', scopeType: 'POND', scopeId: null, effect: 'ALLOW', createdBy: ownerId, }, }); privatePondSlug = `feed-priv-${suffix}`; const priv = await prisma.pond.create({ data: { slug: privatePondSlug, name: 'Private Feed Pond', type: 'SHARED', ownerId }, }); privatePondId = priv.id; await makePage(privatePondId, `hidden-${suffix}`, 'Hidden Page'); // Raw ponds carry no owner grant row — give the owner explicit read // access so the feed token (resolving to the owner) may see the pond. await prisma.roleGrant.create({ data: { pondId: privatePondId, subjectType: 'USER', subjectId: ownerId, role: 'READER', scopeType: 'POND', scopeId: null, effect: 'ALLOW', createdBy: ownerId, }, }); }); afterAll(async () => { await prisma.instanceSetting.deleteMany({ where: { key: 'feeds.enabled' } }); await prisma.feedToken.deleteMany({ where: { userId: ownerId } }); await prisma.roleGrant.deleteMany({ where: { pond: { ownerId } } }); await prisma.pageVersion.deleteMany({ where: { page: { pond: { ownerId } } } }); await prisma.pageContentCache.deleteMany({ where: { page: { pond: { ownerId } } } }); await prisma.page.deleteMany({ where: { pond: { ownerId } } }); await prisma.pond.deleteMany({ where: { ownerId } }); await prisma.session.deleteMany({ where: { userId: ownerId } }); await prisma.user.deleteMany({ where: { id: ownerId } }); await prisma.$disconnect(); await app.close(); }); it('serves a public pond feed anonymously as Atom', async () => { const res = await api().get(`/api/v1/public/${pondSlug}/feed.xml`).expect(200); expect(res.headers['content-type']).toContain('application/atom+xml'); expect(res.text).toContain(''); expect(res.text).toContain('Feed Pond'); expect(res.text).toContain('Newer Page'); expect(res.text).toContain('Older Page'); expect(res.text).toContain(''); // Anonymous entries link into the public view. expect(res.text).toContain(`/api/v1/public/${pondSlug}/newer-${suffix}`); }); it('marks classified entries and states the highest level at feed level (issue #211)', async () => { // Unclassified feed: no category element at all (ADR 0022 — no noise). const open = await api().get(`/api/v1/public/${pondSlug}/feed.xml`).expect(200); expect(open.text).not.toContain('urn:dorfteich:classification'); await prisma.page.updateMany({ where: { pondId, slug: `newer-${suffix}` }, data: { classification: 'VS_NFD' }, }); try { const res = await api().get(`/api/v1/public/${pondSlug}/feed.xml`).expect(200); // The classified entry carries the documented category element… expect(res.text).toContain( '', ); // …and the feed document states the highest contained level once: // 1 feed-level + 1 entry-level = exactly two categories (the open // entry carries none). expect(res.text.split('urn:dorfteich:classification').length - 1).toBe(2); // The page feed of a classified page marks its entries and itself too. const pageFeed = await api() .get(`/api/v1/public/${pondSlug}/newer-${suffix}/feed.xml`) .expect(200); expect(pageFeed.text).toContain('urn:dorfteich:classification'); } finally { await prisma.page.updateMany({ where: { pondId, slug: `newer-${suffix}` }, data: { classification: 'UNCLASSIFIED' }, }); } }); it('serves a page feed built from the version history', async () => { const res = await api().get(`/api/v1/public/${pondSlug}/newer-${suffix}/feed.xml`).expect(200); expect(res.text).toContain('Newer Page — Feed Pond'); expect(res.text).toContain('First draft'); }); it('runs the feed-token lifecycle and opens a private pond with it', async () => { // Without any auth the private pond hides (404, #60). await api().get(`/api/v1/public/${privatePondSlug}/feed.xml`).expect(404); const created = await api() .post('/api/v1/users/me/feed-tokens') .set('Cookie', ownerCookie) .send({ name: 'Reader im Wohnzimmer' }) .expect(201); feedToken = created.body.token as string; expect(feedToken).toMatch(/^dt_feed_/); // The token authenticates the feed; entries link into the app. const res = await api() .get(`/api/v1/public/${privatePondSlug}/feed.xml?token=${feedToken}`) .expect(200); expect(res.text).toContain('Hidden Page'); expect(res.text).toContain(`/p/${privatePondSlug}/hidden-${suffix}`); // Garbage tokens fall back to anonymous → 404 for the private pond. await api().get(`/api/v1/public/${privatePondSlug}/feed.xml?token=dt_feed_junk`).expect(404); // List shows it (without the secret); delete kills the access. const list = await api() .get('/api/v1/users/me/feed-tokens') .set('Cookie', ownerCookie) .expect(200); expect(list.body).toHaveLength(1); expect(list.body[0].name).toBe('Reader im Wohnzimmer'); expect(list.body[0].token).toBeUndefined(); await api() .delete(`/api/v1/users/me/feed-tokens/${list.body[0].id}`) .set('Cookie', ownerCookie) .expect(204); await api().get(`/api/v1/public/${privatePondSlug}/feed.xml?token=${feedToken}`).expect(404); }); it('keeps the page feed permission-checked', async () => { await api().get(`/api/v1/public/${privatePondSlug}/hidden-${suffix}/feed.xml`).expect(404); }); it('advertises the pond feed in the public HTML shell', async () => { const res = await api().get(`/api/v1/public/${pondSlug}/newer-${suffix}`).expect(200); expect(res.text).toContain('rel="alternate" type="application/atom+xml"'); expect(res.text).toContain(`/api/v1/public/${pondSlug}/feed.xml`); }); it('answers 404 on the whole feed surface while feeds.enabled is off (issue #191)', async () => { const settings = app.get(InstanceSettingsService); await settings.set('feeds.enabled', false, ownerId); try { // Feed routes hide — even for a pond that serves anonymously above. await api().get(`/api/v1/public/${pondSlug}/feed.xml`).expect(404); await api().get(`/api/v1/public/${pondSlug}/newer-${suffix}/feed.xml`).expect(404); // The token management surface hides with them. await api().get('/api/v1/users/me/feed-tokens').set('Cookie', ownerCookie).expect(404); await api() .post('/api/v1/users/me/feed-tokens') .set('Cookie', ownerCookie) .send({ name: 'nope' }) .expect(404); // The rest of the public surface is untouched. await api().get(`/api/v1/public/${pondSlug}/newer-${suffix}`).expect(200); } finally { await settings.set('feeds.enabled', true, ownerId); } }); });