feat(17-02): delete protection and 20-feed cap for personal RSS feeds

- 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
This commit is contained in:
2026-08-12 11:41:55 +02:00
parent adb72f611f
commit 96161556db
4 changed files with 324 additions and 34 deletions
@@ -1,4 +1,4 @@
import { BadRequestException } from '@nestjs/common';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { describe, expect, it } from 'vitest';
import { TenderRssFeedSourceService } from './tender-rss-feed.service';
@@ -16,18 +16,23 @@ import { TenderRssFeedSourceService } from './tender-rss-feed.service';
* 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 (OR/AND/equality) so `listForUser`'s
* ownership scoping is actually proven, not just assumed.
* 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;
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);
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() {
@@ -43,6 +48,8 @@ function makeFakePrisma() {
}
return all;
},
count: async ({ where }: any = {}) =>
[...rows.values()].filter((row) => matchesWhere(row, where)).length,
create: async ({ data }: any) => {
seq += 1;
const row = {
@@ -61,6 +68,17 @@ function makeFakePrisma() {
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,
};
@@ -271,19 +289,161 @@ describe('TenderRssFeedSourceService', () => {
expect(prisma.__rows.size).toBe(2);
});
it('remove() deletes the feed by id', async () => {
});
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.createPlatform({
url: 'https://c.example-tenders.invalid/rss.xml',
label: 'c',
});
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);
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);
});
});
});