feat(14-02): add TenderRssFeedSource CRUD, tick poll gate, and wire RssAdapter
Global admin-managed RSS feed list (TenderRssFeedSource, D-08/D-14) with
a save-time hostname/SSRF guard (TenderRssFeedSourceService) — RSS feed
URLs are runtime admin input, so the code-level SourceRegistry denylist
gate does not cover them; a separate check rejects DENYLISTED_PORTALS
hostnames, non-http(s) schemes, and private/loopback hosts.
Adds TenderSourcePollConfig.pollGranularity ('day' | 'tick', D-15):
pollDueSources() branches per source — 'day' sources keep the existing
lastIngestedDay gate byte-unchanged, 'tick' sources (rss) fetch on every
active scheduler tick regardless of lastIngestedDay, since the day-cursor
gate was built for a genuine daily batch-export API and would otherwise
silently cap RSS to one fetch per calendar day.
Wires RssAdapter.fetchTenders() to fan out over active feed rows (native
fetch + AbortController 15s + response-size ceiling, catch-per-feed),
registers it in tenders.module.ts, and seeds the 'rss' poll config
active with pollGranularity='tick' plus a default-active service.bund.de
feed row (subreport-elvis has no single canonical URL — zero rows seeded,
admin adds relevant municipality feeds).
Migration applied locally per project convention (host -> container IP).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
import { BadRequestException } 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-admin-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.
|
||||
*
|
||||
* 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).
|
||||
*/
|
||||
function makeFakePrisma() {
|
||||
const rows = new Map<string, any>();
|
||||
let seq = 0;
|
||||
|
||||
return {
|
||||
tenderRssFeedSource: {
|
||||
findMany: async ({ orderBy }: any) => {
|
||||
const all = [...rows.values()];
|
||||
if (orderBy?.createdAt === 'asc') {
|
||||
all.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||
}
|
||||
return all;
|
||||
},
|
||||
create: async ({ data }: any) => {
|
||||
seq += 1;
|
||||
const row = {
|
||||
id: `feed-${seq}`,
|
||||
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;
|
||||
},
|
||||
},
|
||||
__rows: rows,
|
||||
};
|
||||
}
|
||||
|
||||
describe('TenderRssFeedSourceService', () => {
|
||||
describe('create() — 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({
|
||||
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.create({ 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.create({
|
||||
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.create({
|
||||
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.create({ 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.create({ 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.create({
|
||||
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.create({ 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.create({ 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.create({ 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.create({ 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.create({
|
||||
url: 'https://example-tenders.invalid/rss.xml',
|
||||
label: 'inactive-feed',
|
||||
isActive: false,
|
||||
});
|
||||
|
||||
expect(created.isActive).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list()/remove()', () => {
|
||||
it('list() returns created feeds ordered by createdAt asc', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
await service.create({
|
||||
url: 'https://a.example-tenders.invalid/rss.xml',
|
||||
label: 'a',
|
||||
});
|
||||
await service.create({
|
||||
url: 'https://b.example-tenders.invalid/rss.xml',
|
||||
label: 'b',
|
||||
});
|
||||
|
||||
const list = await service.list();
|
||||
expect(list.map((f: any) => f.label)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('remove() deletes the feed by id', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
const created = await service.create({
|
||||
url: 'https://c.example-tenders.invalid/rss.xml',
|
||||
label: 'c',
|
||||
});
|
||||
|
||||
const result = await service.remove(created.id);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(prisma.__rows.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user