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:
@@ -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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user