dorfteich/apps/api/src/public/feed.e2e.db.test.ts
Claude Fable 5 521ea514b4
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m38s
CI / Build container images (pull_request) Successful in 4m14s
CI / Auth e2e pack (pull_request) Successful in 9m7s
CI / Import/export fidelity gate (pull_request) Successful in 1m6s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
#211: classification through feeds, public API, search and the no-JS shell
Feeds: classified entries carry a standard Atom <category>
(term=level, scheme=urn:dorfteich:classification, label=the fixed
wording); the feed document states the highest contained level once;
all-open feeds carry none. Public API: page representations (list+get)
gain the classification field, OpenAPI + public-api.md documented.
Search: every hit carries the level and the palette renders the marking
with the snippet (compact form of the banner, text token only). No-JS
shell: banner above and below the content, own markup for the separate
render path; unclassified pages unchanged everywhere. One test per
channel (feed categories + count, public API list/get with the switch
on, search hit levels, shell top+bottom).

Also: fidelity CI sidecars get per-job container names — the fixed
names collided across parallel runs on the shared host (run 547's red
fidelity job; a fixed-name cleanup could even kill a sibling's live
sidecars).

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:23:53 +02:00

256 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string> {
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: `<p>${title}</p>`, 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('<feed xmlns="http://www.w3.org/2005/Atom">');
expect(res.text).toContain('<title>Feed Pond</title>');
expect(res.text).toContain('Newer Page');
expect(res.text).toContain('Older Page');
expect(res.text).toContain('<updated>');
// 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(
'<category term="vs_nfd" scheme="urn:dorfteich:classification" ' +
'label="VS NUR FÜR DEN DIENSTGEBRAUCH"/>',
);
// …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('<title>Newer Page — Feed Pond</title>');
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);
}
});
});