feat(17-02): RSS feeds get an owner — platform-wide vs personal (D-02)
- TenderRssFeedSource.userId/tenantId (nullable): null = platform-wide
(admin-managed, includes the existing service.bund.de default),
set = personal feed owned by exactly one user
- Migration replaces url @unique with @@unique([userId, url]) — two
users can now follow the same address independently; existing rows
keep an empty owner (platform-wide, unchanged behavior)
- Service: listForUser/createForUser/createPlatform replace list/create
- Controller: GET/POST /rss-feeds move from @Roles(ADMIN,SUPER_ADMIN) to
@UseModule('tender-radar'); POST with scope:'platform' still requires
ADMIN/SUPER_ADMIN, checked inline (T-17-08)
- tenders.module.ts seed switched from upsert-on-url to find-then-create
(Rule 3, pulled forward from Task 3): the new compound unique index
requires a non-null userId in Prisma's generated type, so a
platform-wide row can no longer be addressed via upsert
- Files modified: apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260812110000_tender_rss_feed_owner/migration.sql, apps/api/src/tenders/tender-rss-feed.service.ts, apps/api/src/tenders/dto/tender-rss-feed.dto.ts, apps/api/src/tenders/tenders.controller.ts, apps/api/src/tenders/tenders.module.ts, apps/api/src/tenders/tender-rss-feed.service.spec.ts, apps/api/src/tenders/tenders.controller.spec.ts
This commit is contained in:
@@ -4,24 +4,40 @@ import { TenderRssFeedSourceService } from './tender-rss-feed.service';
|
||||
|
||||
/**
|
||||
* TenderRssFeedSourceService.spec — proves the save-time hostname/SSRF
|
||||
* guard (T-14-02-01, D-14/RESEARCH.md Pitfall 3): a runtime-admin-supplied
|
||||
* guard (T-14-02-01, D-14/RESEARCH.md Pitfall 3): a runtime-user-supplied
|
||||
* RSS feed URL is NOT covered by the code-level SourceRegistry denylist
|
||||
* gate (that only checks an adapter's statically-declared `portals` array
|
||||
* at DI-boot time) — this service is the separate, independent
|
||||
* enforcement point.
|
||||
* enforcement point. Also proves the ownership split introduced in Phase
|
||||
* 17, Plan 02 (D-02): `listForUser` returns platform-wide feeds plus the
|
||||
* caller's own, `createForUser` stamps an owner, `createPlatform` leaves
|
||||
* ownership empty.
|
||||
*
|
||||
* Uses the same hand-rolled prisma-shaped fake convention as
|
||||
* tender-notification-pref.service.spec.ts / tender-saved-search.service.spec.ts
|
||||
* (in-memory Map, no live DB connection).
|
||||
* (in-memory Map, no live DB connection). Unlike the pre-Phase-17 fake, this
|
||||
* one EVALUATES the `where` clause (OR/AND/equality) so `listForUser`'s
|
||||
* ownership scoping is actually proven, not just assumed.
|
||||
*/
|
||||
function matchesWhere(row: any, where: any): boolean {
|
||||
if (!where) return true;
|
||||
if ('OR' in where) {
|
||||
return (where.OR as any[]).some((clause) => matchesWhere(row, clause));
|
||||
}
|
||||
if ('AND' in where) {
|
||||
return (where.AND as any[]).every((clause) => matchesWhere(row, clause));
|
||||
}
|
||||
return Object.entries(where).every(([key, value]) => row[key] === value);
|
||||
}
|
||||
|
||||
function makeFakePrisma() {
|
||||
const rows = new Map<string, any>();
|
||||
let seq = 0;
|
||||
|
||||
return {
|
||||
tenderRssFeedSource: {
|
||||
findMany: async ({ orderBy }: any) => {
|
||||
const all = [...rows.values()];
|
||||
findMany: async ({ where, orderBy }: any = {}) => {
|
||||
let all = [...rows.values()].filter((row) => matchesWhere(row, where));
|
||||
if (orderBy?.createdAt === 'asc') {
|
||||
all.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||
}
|
||||
@@ -31,6 +47,8 @@ function makeFakePrisma() {
|
||||
seq += 1;
|
||||
const row = {
|
||||
id: `feed-${seq}`,
|
||||
userId: null,
|
||||
tenantId: null,
|
||||
createdAt: new Date(Date.now() + seq),
|
||||
updatedAt: new Date(Date.now() + seq),
|
||||
...data,
|
||||
@@ -49,13 +67,13 @@ function makeFakePrisma() {
|
||||
}
|
||||
|
||||
describe('TenderRssFeedSourceService', () => {
|
||||
describe('create() — save-time hostname/SSRF guard (T-14-02-01)', () => {
|
||||
describe('createPlatform() — save-time hostname/SSRF guard (T-14-02-01)', () => {
|
||||
it('rejects a vergabe24.de feed URL (denylisted portal, D-14)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
await expect(
|
||||
service.create({
|
||||
service.createPlatform({
|
||||
url: 'https://www.vergabe24.de/rss.xml',
|
||||
label: 'vergabe24',
|
||||
}),
|
||||
@@ -68,7 +86,7 @@ describe('TenderRssFeedSourceService', () => {
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
await expect(
|
||||
service.create({ url: 'https://aumass.de/feed', label: 'aumass' }),
|
||||
service.createPlatform({ url: 'https://aumass.de/feed', label: 'aumass' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(prisma.__rows.size).toBe(0);
|
||||
});
|
||||
@@ -78,7 +96,7 @@ describe('TenderRssFeedSourceService', () => {
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
await expect(
|
||||
service.create({
|
||||
service.createPlatform({
|
||||
url: 'https://feeds.vergabe24.de/rss.xml',
|
||||
label: 'vergabe24-sub',
|
||||
}),
|
||||
@@ -89,7 +107,7 @@ describe('TenderRssFeedSourceService', () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
const created = await service.create({
|
||||
const created = await service.createPlatform({
|
||||
url: 'https://www.service.bund.de/Content/Globals/Functions/RSSFeed/RSSGenerator_Ausschreibungen.xml',
|
||||
label: 'service-bund',
|
||||
});
|
||||
@@ -106,7 +124,7 @@ describe('TenderRssFeedSourceService', () => {
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
await expect(
|
||||
service.create({ url: 'file:///etc/passwd', label: 'local-file' }),
|
||||
service.createPlatform({ url: 'file:///etc/passwd', label: 'local-file' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
@@ -115,7 +133,7 @@ describe('TenderRssFeedSourceService', () => {
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
await expect(
|
||||
service.create({ url: 'not-a-url', label: 'broken' }),
|
||||
service.createPlatform({ url: 'not-a-url', label: 'broken' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
@@ -124,7 +142,7 @@ describe('TenderRssFeedSourceService', () => {
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
await expect(
|
||||
service.create({
|
||||
service.createPlatform({
|
||||
url: 'http://127.0.0.1:8080/internal-feed.xml',
|
||||
label: 'internal',
|
||||
}),
|
||||
@@ -136,7 +154,7 @@ describe('TenderRssFeedSourceService', () => {
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
await expect(
|
||||
service.create({ url: 'http://localhost:3000/feed', label: 'x' }),
|
||||
service.createPlatform({ url: 'http://localhost:3000/feed', label: 'x' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
@@ -145,7 +163,7 @@ describe('TenderRssFeedSourceService', () => {
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
await expect(
|
||||
service.create({ url: 'http://10.0.0.5/feed', label: 'x' }),
|
||||
service.createPlatform({ url: 'http://10.0.0.5/feed', label: 'x' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
@@ -154,7 +172,7 @@ describe('TenderRssFeedSourceService', () => {
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
await expect(
|
||||
service.create({ url: 'http://192.168.1.1/feed', label: 'x' }),
|
||||
service.createPlatform({ url: 'http://192.168.1.1/feed', label: 'x' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
@@ -163,7 +181,7 @@ describe('TenderRssFeedSourceService', () => {
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
await expect(
|
||||
service.create({ url: 'http://[::1]/feed', label: 'x' }),
|
||||
service.createPlatform({ url: 'http://[::1]/feed', label: 'x' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
@@ -171,7 +189,7 @@ describe('TenderRssFeedSourceService', () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
const created = await service.create({
|
||||
const created = await service.createPlatform({
|
||||
url: 'https://example-tenders.invalid/rss.xml',
|
||||
label: 'inactive-feed',
|
||||
isActive: false,
|
||||
@@ -181,29 +199,83 @@ describe('TenderRssFeedSourceService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('list()/remove()', () => {
|
||||
it('list() returns created feeds ordered by createdAt asc', async () => {
|
||||
describe('listForUser()/createForUser()/remove() — ownership split (Phase 17, Plan 02, D-02)', () => {
|
||||
it('listForUser() returns platform-wide feeds ordered by createdAt asc when the caller has none of their own', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
await service.create({
|
||||
await service.createPlatform({
|
||||
url: 'https://a.example-tenders.invalid/rss.xml',
|
||||
label: 'a',
|
||||
});
|
||||
await service.create({
|
||||
await service.createPlatform({
|
||||
url: 'https://b.example-tenders.invalid/rss.xml',
|
||||
label: 'b',
|
||||
});
|
||||
|
||||
const list = await service.list();
|
||||
const list = await service.listForUser('u-anyone');
|
||||
expect(list.map((f: any) => f.label)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('listForUser(userId) includes platform-wide feeds plus this user\'s own personal feeds, never another user\'s', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
await service.createPlatform({
|
||||
url: 'https://platform.example-tenders.invalid/rss.xml',
|
||||
label: 'platform-feed',
|
||||
});
|
||||
await service.createForUser(
|
||||
{ userId: 'user-a', tenantId: 'tenant-a' },
|
||||
{ url: 'https://a-only.example-tenders.invalid/rss.xml', label: 'a-only' },
|
||||
);
|
||||
await service.createForUser(
|
||||
{ userId: 'user-b', tenantId: 'tenant-b' },
|
||||
{ url: 'https://b-only.example-tenders.invalid/rss.xml', label: 'b-only' },
|
||||
);
|
||||
|
||||
const listForA = await service.listForUser('user-a');
|
||||
expect(listForA.map((f: any) => f.label).sort()).toEqual(['a-only', 'platform-feed']);
|
||||
|
||||
const listForB = await service.listForUser('user-b');
|
||||
expect(listForB.map((f: any) => f.label).sort()).toEqual(['b-only', 'platform-feed']);
|
||||
});
|
||||
|
||||
it('createForUser() stamps the owner\'s userId/tenantId; createPlatform() leaves both null', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
const personal = await service.createForUser(
|
||||
{ userId: 'user-a', tenantId: 'tenant-a' },
|
||||
{ url: 'https://mine.example-tenders.invalid/rss.xml', label: 'mine' },
|
||||
);
|
||||
const platform = await service.createPlatform({
|
||||
url: 'https://platform-only.example-tenders.invalid/rss.xml',
|
||||
label: 'platform-only',
|
||||
});
|
||||
|
||||
expect(personal.userId).toBe('user-a');
|
||||
expect(personal.tenantId).toBe('tenant-a');
|
||||
expect(platform.userId).toBeNull();
|
||||
expect(platform.tenantId).toBeNull();
|
||||
});
|
||||
|
||||
it('two different users may each register the same URL independently (D-02)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
const sameUrl = 'https://shared.example-tenders.invalid/rss.xml';
|
||||
|
||||
await service.createForUser({ userId: 'user-a', tenantId: 'tenant-a' }, { url: sameUrl, label: 'a-copy' });
|
||||
await service.createForUser({ userId: 'user-b', tenantId: 'tenant-b' }, { url: sameUrl, label: 'b-copy' });
|
||||
|
||||
expect(prisma.__rows.size).toBe(2);
|
||||
});
|
||||
|
||||
it('remove() deletes the feed by id', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
const created = await service.create({
|
||||
const created = await service.createPlatform({
|
||||
url: 'https://c.example-tenders.invalid/rss.xml',
|
||||
label: 'c',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user