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, NotFoundException } from '@nestjs/common';
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { Role } from '@prisma/client';
import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
@@ -82,9 +82,14 @@ function makeFakeTriageService() {
};
}
/** Minimal authenticated Express Request fake (userId/tenantId only). */
function makeFakeRequest(userId = 'u1', tenantId = 'tenant1') {
return { user: { id: userId, tenantId }, tenantId } as any;
/**
* Minimal authenticated Express Request fake (userId/tenantId/role).
* `role` defaults to `Role.USER` — Phase 17, Plan 02 (T-17-08) added it so
* `extractTriageContext` can resolve the caller's role from the SAME place
* `RolesGuard` reads it, for the `POST /rss-feeds` scope:'platform' check.
*/
function makeFakeRequest(userId = 'u1', tenantId = 'tenant1', role: Role = Role.USER) {
return { user: { id: userId, tenantId, role }, tenantId } as any;
}
/**
@@ -962,7 +967,7 @@ describe('TendersController — RSS-feeds personal + platform-wide (Plan 14-02 D
).rejects.toBeInstanceOf(BadRequestException);
});
it('DELETE /rss-feeds/:feedId delegates to tenderRssFeedSource.remove(feedId)', async () => {
it('DELETE /rss-feeds/:feedId delegates to tenderRssFeedSource.remove(feedId, {userId, isAdmin}) — isAdmin false for a USER', async () => {
const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const rssFeedService = makeFakeRssFeedService();
@@ -976,11 +981,77 @@ describe('TendersController — RSS-feeds personal + platform-wide (Plan 14-02 D
makeFakeEmailConfigService() as any,
);
const result = await controller.removeRssFeed('feed-1');
const result = await controller.removeRssFeed(
'feed-1',
makeFakeRequest('u1', 'tenant1', Role.USER),
);
expect(rssFeedService.remove).toHaveBeenCalledWith('feed-1');
expect(rssFeedService.remove).toHaveBeenCalledWith('feed-1', { userId: 'u1', isAdmin: false });
expect(result).toEqual({ success: true });
});
it('DELETE /rss-feeds/:feedId passes isAdmin true for an ADMIN account', async () => {
const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const rssFeedService = makeFakeRssFeedService();
const controller = new TendersController(
prisma as any,
scheduler,
makeFakeTriageService() as any,
makeFakeSavedSearchService() as any,
makeFakeNotificationPrefService() as any,
rssFeedService as any,
makeFakeEmailConfigService() as any,
);
await controller.removeRssFeed('feed-1', makeFakeRequest('admin-1', 'tenant1', Role.ADMIN));
expect(rssFeedService.remove).toHaveBeenCalledWith('feed-1', { userId: 'admin-1', isAdmin: true });
});
it('POST /rss-feeds with scope:"platform" and role USER is rejected with ForbiddenException, service never called (T-17-08)', async () => {
const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const rssFeedService = makeFakeRssFeedService();
const controller = new TendersController(
prisma as any,
scheduler,
makeFakeTriageService() as any,
makeFakeSavedSearchService() as any,
makeFakeNotificationPrefService() as any,
rssFeedService as any,
makeFakeEmailConfigService() as any,
);
const dto = { url: 'https://service.bund.de/rss.xml', label: 'service-bund', scope: 'platform' } as any;
await expect(
controller.createRssFeed(dto, makeFakeRequest('u1', 'tenant1', Role.USER)),
).rejects.toBeInstanceOf(ForbiddenException);
expect(rssFeedService.createPlatform).not.toHaveBeenCalled();
expect(rssFeedService.createForUser).not.toHaveBeenCalled();
});
it('POST /rss-feeds with scope:"platform" and role ADMIN delegates to tenderRssFeedSource.createPlatform(dto) (T-17-08)', async () => {
const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const rssFeedService = makeFakeRssFeedService();
const controller = new TendersController(
prisma as any,
scheduler,
makeFakeTriageService() as any,
makeFakeSavedSearchService() as any,
makeFakeNotificationPrefService() as any,
rssFeedService as any,
makeFakeEmailConfigService() as any,
);
const dto = { url: 'https://service.bund.de/rss.xml', label: 'service-bund', scope: 'platform' } as any;
await controller.createRssFeed(dto, makeFakeRequest('admin-1', 'tenant1', Role.ADMIN));
expect(rssFeedService.createPlatform).toHaveBeenCalledWith(dto);
expect(rssFeedService.createForUser).not.toHaveBeenCalled();
});
});
describe('TendersController — email-config (Plan 14-03, per-user since Phase 17 Plan 01 D-01, T-14-03-05/T-17-01)', () => {