96161556db
- remove(id, {userId, isAdmin}) replaces remove(id): single conditional
deleteMany (id AND (owned-by-caller OR admin-on-platform-feed)) — no
TOCTOU window, ownership check lives in the DB condition. Deletes
nothing -> NotFoundException (never Forbidden, no existence leak)
- createForUser rejects a caller's 21st personal feed with a clear
German message (T-17-10); platform-wide feeds are not counted
- DELETE /rss-feeds/:feedId moves from @Roles(ADMIN,SUPER_ADMIN) to
@UseModule('tender-radar') — ownership check does the gating now
- Tests use a Prisma double that actually evaluates the where condition
(not a double that always "succeeds") for both deleteMany and count
- Files modified: apps/api/src/tenders/tender-rss-feed.service.ts, apps/api/src/tenders/tenders.controller.ts, apps/api/src/tenders/tender-rss-feed.service.spec.ts, apps/api/src/tenders/tenders.controller.spec.ts
450 lines
17 KiB
TypeScript
450 lines
17 KiB
TypeScript
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
|
import { describe, expect, it } from 'vitest';
|
|
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-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. 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). Unlike the pre-Phase-17 fake, this
|
|
* one EVALUATES the `where` clause (top-level keys AND'd together, `OR`/
|
|
* `AND` sub-clauses handled recursively) so `listForUser`'s ownership
|
|
* scoping AND `remove()`'s delete-protection condition (T-17-07) are
|
|
* actually proven, not just assumed — a double that ignored `where` and
|
|
* always "succeeded" would only fake the protection, not test it.
|
|
*/
|
|
function matchesWhere(row: any, where: any): boolean {
|
|
if (!where) return true;
|
|
return Object.entries(where).every(([key, value]) => {
|
|
if (key === 'OR') {
|
|
return (value as any[]).some((clause) => matchesWhere(row, clause));
|
|
}
|
|
if (key === 'AND') {
|
|
return (value as any[]).every((clause) => matchesWhere(row, clause));
|
|
}
|
|
return row[key] === value;
|
|
});
|
|
}
|
|
|
|
function makeFakePrisma() {
|
|
const rows = new Map<string, any>();
|
|
let seq = 0;
|
|
|
|
return {
|
|
tenderRssFeedSource: {
|
|
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());
|
|
}
|
|
return all;
|
|
},
|
|
count: async ({ where }: any = {}) =>
|
|
[...rows.values()].filter((row) => matchesWhere(row, where)).length,
|
|
create: async ({ data }: any) => {
|
|
seq += 1;
|
|
const row = {
|
|
id: `feed-${seq}`,
|
|
userId: null,
|
|
tenantId: null,
|
|
createdAt: new Date(Date.now() + seq),
|
|
updatedAt: new Date(Date.now() + seq),
|
|
...data,
|
|
};
|
|
rows.set(row.id, row);
|
|
return row;
|
|
},
|
|
delete: async ({ where }: any) => {
|
|
const existing = rows.get(where.id);
|
|
rows.delete(where.id);
|
|
return existing;
|
|
},
|
|
/**
|
|
* REAL condition evaluation (T-17-07 precedent, tenders.controller.spec.ts
|
|
* ~line 1057): only rows matching `matchesWhere` are removed — a double
|
|
* that deleted unconditionally on `id` alone would defeat the entire
|
|
* point of this test file's ownership-protection tests.
|
|
*/
|
|
deleteMany: async ({ where }: any) => {
|
|
const toDelete = [...rows.values()].filter((row) => matchesWhere(row, where));
|
|
for (const row of toDelete) rows.delete(row.id);
|
|
return { count: toDelete.length };
|
|
},
|
|
},
|
|
__rows: rows,
|
|
};
|
|
}
|
|
|
|
describe('TenderRssFeedSourceService', () => {
|
|
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.createPlatform({
|
|
url: 'https://www.vergabe24.de/rss.xml',
|
|
label: 'vergabe24',
|
|
}),
|
|
).rejects.toThrow(BadRequestException);
|
|
expect(prisma.__rows.size).toBe(0);
|
|
});
|
|
|
|
it('rejects an aumass.de feed URL (denylisted portal, D-14)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderRssFeedSourceService(prisma as any);
|
|
|
|
await expect(
|
|
service.createPlatform({ url: 'https://aumass.de/feed', label: 'aumass' }),
|
|
).rejects.toThrow(BadRequestException);
|
|
expect(prisma.__rows.size).toBe(0);
|
|
});
|
|
|
|
it('rejects a subdomain of a denylisted host (substring match on hostname)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderRssFeedSourceService(prisma as any);
|
|
|
|
await expect(
|
|
service.createPlatform({
|
|
url: 'https://feeds.vergabe24.de/rss.xml',
|
|
label: 'vergabe24-sub',
|
|
}),
|
|
).rejects.toThrow(BadRequestException);
|
|
});
|
|
|
|
it('accepts a valid service.bund.de feed URL', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderRssFeedSourceService(prisma as any);
|
|
|
|
const created = await service.createPlatform({
|
|
url: 'https://www.service.bund.de/Content/Globals/Functions/RSSFeed/RSSGenerator_Ausschreibungen.xml',
|
|
label: 'service-bund',
|
|
});
|
|
|
|
expect(created.url).toBe(
|
|
'https://www.service.bund.de/Content/Globals/Functions/RSSFeed/RSSGenerator_Ausschreibungen.xml',
|
|
);
|
|
expect(created.isActive).toBe(true); // default when omitted
|
|
expect(prisma.__rows.size).toBe(1);
|
|
});
|
|
|
|
it('rejects a non-http(s) scheme (e.g. file://)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderRssFeedSourceService(prisma as any);
|
|
|
|
await expect(
|
|
service.createPlatform({ url: 'file:///etc/passwd', label: 'local-file' }),
|
|
).rejects.toThrow(BadRequestException);
|
|
});
|
|
|
|
it('rejects a malformed URL', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderRssFeedSourceService(prisma as any);
|
|
|
|
await expect(
|
|
service.createPlatform({ url: 'not-a-url', label: 'broken' }),
|
|
).rejects.toThrow(BadRequestException);
|
|
});
|
|
|
|
it('rejects a loopback host (127.0.0.1, SSRF)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderRssFeedSourceService(prisma as any);
|
|
|
|
await expect(
|
|
service.createPlatform({
|
|
url: 'http://127.0.0.1:8080/internal-feed.xml',
|
|
label: 'internal',
|
|
}),
|
|
).rejects.toThrow(BadRequestException);
|
|
});
|
|
|
|
it('rejects "localhost" (SSRF)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderRssFeedSourceService(prisma as any);
|
|
|
|
await expect(
|
|
service.createPlatform({ url: 'http://localhost:3000/feed', label: 'x' }),
|
|
).rejects.toThrow(BadRequestException);
|
|
});
|
|
|
|
it('rejects a private 10.x.x.x host (SSRF)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderRssFeedSourceService(prisma as any);
|
|
|
|
await expect(
|
|
service.createPlatform({ url: 'http://10.0.0.5/feed', label: 'x' }),
|
|
).rejects.toThrow(BadRequestException);
|
|
});
|
|
|
|
it('rejects a private 192.168.x.x host (SSRF)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderRssFeedSourceService(prisma as any);
|
|
|
|
await expect(
|
|
service.createPlatform({ url: 'http://192.168.1.1/feed', label: 'x' }),
|
|
).rejects.toThrow(BadRequestException);
|
|
});
|
|
|
|
it('rejects an IPv6 loopback host ([::1], SSRF)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderRssFeedSourceService(prisma as any);
|
|
|
|
await expect(
|
|
service.createPlatform({ url: 'http://[::1]/feed', label: 'x' }),
|
|
).rejects.toThrow(BadRequestException);
|
|
});
|
|
|
|
it('creates isActive=false when explicitly supplied', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderRssFeedSourceService(prisma as any);
|
|
|
|
const created = await service.createPlatform({
|
|
url: 'https://example-tenders.invalid/rss.xml',
|
|
label: 'inactive-feed',
|
|
isActive: false,
|
|
});
|
|
|
|
expect(created.isActive).toBe(false);
|
|
});
|
|
});
|
|
|
|
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.createPlatform({
|
|
url: 'https://a.example-tenders.invalid/rss.xml',
|
|
label: 'a',
|
|
});
|
|
await service.createPlatform({
|
|
url: 'https://b.example-tenders.invalid/rss.xml',
|
|
label: 'b',
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
});
|
|
|
|
describe('remove() — delete protection (T-17-07, Phase 17 Plan 02 Task 2)', () => {
|
|
it('User A deletes their own feed: succeeds', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderRssFeedSourceService(prisma as any);
|
|
|
|
const created = await service.createForUser(
|
|
{ userId: 'user-a', tenantId: 'tenant-a' },
|
|
{ url: 'https://a.example-tenders.invalid/rss.xml', label: 'a' },
|
|
);
|
|
|
|
const result = await service.remove(created.id, { userId: 'user-a', isAdmin: false });
|
|
|
|
expect(result).toEqual({ success: true });
|
|
expect(prisma.__rows.size).toBe(0);
|
|
});
|
|
|
|
it('User A tries to delete User B\'s feed: the feed stays, "not found"', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderRssFeedSourceService(prisma as any);
|
|
|
|
const created = await service.createForUser(
|
|
{ userId: 'user-b', tenantId: 'tenant-b' },
|
|
{ url: 'https://b.example-tenders.invalid/rss.xml', label: 'b' },
|
|
);
|
|
|
|
await expect(
|
|
service.remove(created.id, { userId: 'user-a', isAdmin: false }),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
expect(prisma.__rows.size).toBe(1);
|
|
});
|
|
|
|
it('User A (non-admin) tries to delete a platform-wide feed: the feed stays, "not found"', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderRssFeedSourceService(prisma as any);
|
|
|
|
const created = await service.createPlatform({
|
|
url: 'https://platform.example-tenders.invalid/rss.xml',
|
|
label: 'platform',
|
|
});
|
|
|
|
await expect(
|
|
service.remove(created.id, { userId: 'user-a', isAdmin: false }),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
expect(prisma.__rows.size).toBe(1);
|
|
});
|
|
|
|
it('An administrator deletes a platform-wide feed: succeeds', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderRssFeedSourceService(prisma as any);
|
|
|
|
const created = await service.createPlatform({
|
|
url: 'https://platform.example-tenders.invalid/rss.xml',
|
|
label: 'platform',
|
|
});
|
|
|
|
const result = await service.remove(created.id, { userId: 'admin-1', isAdmin: true });
|
|
|
|
expect(result).toEqual({ success: true });
|
|
expect(prisma.__rows.size).toBe(0);
|
|
});
|
|
|
|
it('An administrator does NOT delete another user\'s personal feed unasked over this path: stays, "not found"', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderRssFeedSourceService(prisma as any);
|
|
|
|
const created = await service.createForUser(
|
|
{ userId: 'user-a', tenantId: 'tenant-a' },
|
|
{ url: 'https://a.example-tenders.invalid/rss.xml', label: 'a' },
|
|
);
|
|
|
|
await expect(
|
|
service.remove(created.id, { userId: 'admin-1', isAdmin: true }),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
expect(prisma.__rows.size).toBe(1);
|
|
});
|
|
|
|
it('removing a genuinely missing id: "not found", nothing to delete', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderRssFeedSourceService(prisma as any);
|
|
|
|
await expect(
|
|
service.remove('does-not-exist', { userId: 'user-a', isAdmin: false }),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
});
|
|
});
|
|
|
|
describe('createForUser() — personal feed cap (T-17-10)', () => {
|
|
it('rejects the 21st personal feed for the same user with a clear message', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderRssFeedSourceService(prisma as any);
|
|
const ctx = { userId: 'user-a', tenantId: 'tenant-a' };
|
|
|
|
for (let i = 0; i < 20; i += 1) {
|
|
await service.createForUser(ctx, {
|
|
url: `https://feed-${i}.example-tenders.invalid/rss.xml`,
|
|
label: `feed-${i}`,
|
|
});
|
|
}
|
|
|
|
await expect(
|
|
service.createForUser(ctx, {
|
|
url: 'https://feed-21.example-tenders.invalid/rss.xml',
|
|
label: 'feed-21',
|
|
}),
|
|
).rejects.toThrow(BadRequestException);
|
|
expect(prisma.__rows.size).toBe(20);
|
|
});
|
|
|
|
it('platform-wide feeds do not count against a user\'s personal cap', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderRssFeedSourceService(prisma as any);
|
|
|
|
for (let i = 0; i < 25; i += 1) {
|
|
await service.createPlatform({
|
|
url: `https://platform-${i}.example-tenders.invalid/rss.xml`,
|
|
label: `platform-${i}`,
|
|
});
|
|
}
|
|
|
|
const created = await service.createForUser(
|
|
{ userId: 'user-a', tenantId: 'tenant-a' },
|
|
{ url: 'https://mine.example-tenders.invalid/rss.xml', label: 'mine' },
|
|
);
|
|
|
|
expect(created.userId).toBe('user-a');
|
|
});
|
|
});
|
|
|
|
describe('createForUser() — the save-time hostname/SSRF guard also runs on the personal path (T-17-09)', () => {
|
|
it('rejects a denylisted URL via createForUser exactly like createPlatform', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderRssFeedSourceService(prisma as any);
|
|
|
|
await expect(
|
|
service.createForUser(
|
|
{ userId: 'user-a', tenantId: 'tenant-a' },
|
|
{ url: 'https://www.vergabe24.de/rss.xml', label: 'vergabe24' },
|
|
),
|
|
).rejects.toThrow(BadRequestException);
|
|
expect(prisma.__rows.size).toBe(0);
|
|
});
|
|
|
|
it('rejects a private-host URL via createForUser (SSRF)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new TenderRssFeedSourceService(prisma as any);
|
|
|
|
await expect(
|
|
service.createForUser(
|
|
{ userId: 'user-a', tenantId: 'tenant-a' },
|
|
{ url: 'http://192.168.1.1/feed', label: 'internal' },
|
|
),
|
|
).rejects.toThrow(BadRequestException);
|
|
expect(prisma.__rows.size).toBe(0);
|
|
});
|
|
});
|
|
});
|