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
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>
256 lines
9.8 KiB
TypeScript
256 lines
9.8 KiB
TypeScript
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);
|
||
}
|
||
});
|
||
});
|