3bf550bc65
- TenderEmailConfigService.testConnection(userId, dto) mit Rueckfall auf
gespeicherte, entschluesselte Zugangsdaten bei leeren Feldern
- TendersController: POST email-config/test, userId aus Auth-Kontext,
deklariert vor @Get(':id')
- Beide Provider (ImapProvider/ExchangeInboxProvider) optional angehaengt,
bestehende 2-Arg-Konstruktoraufrufe bleiben typkorrekt
- Reihenfolge-Waechter und IDOR-Testfall ergaenzt
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
1338 lines
51 KiB
TypeScript
1338 lines
51 KiB
TypeScript
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';
|
|
import { TendersController } from './tenders.controller';
|
|
|
|
/**
|
|
* TendersController.spec — automated proof for INGEST-06 and the
|
|
* tenant-gated-not-scoped invariant (RESEARCH.md V4, T-10-14):
|
|
*
|
|
* - The global read path (`GET /`) never adds the tenant's id to the
|
|
* prisma `where` clause — the catalog is platform-wide, not row-scoped.
|
|
* - Saving the admin source-config drives the scheduler's setInterval()
|
|
* (single argument, no tenant id) / stopJob() exactly as the DKV analog
|
|
* does, minus the tenant argument.
|
|
*
|
|
* Uses the same hand-rolled prisma-shaped fake convention as
|
|
* tender-ingestion.service.spec.ts / tender-scheduler.service.spec.ts
|
|
* (in-memory fakes, no live DB connection).
|
|
*/
|
|
|
|
function makeFakePrisma() {
|
|
const tenders = new Map<string, any>();
|
|
tenders.set('t1', {
|
|
id: 't1',
|
|
title: 'Beispielausschreibung',
|
|
status: 'active',
|
|
sourcePortal: 'doe-opendata',
|
|
sources: [
|
|
{
|
|
sourcePortal: 'doe-opendata',
|
|
sourceUrl: 'https://doe.example/t1',
|
|
sourceNoticeId: 'doe-1',
|
|
},
|
|
{
|
|
sourcePortal: 'cosinex-dtvp',
|
|
sourceUrl: 'https://dtvp.example/t1',
|
|
sourceNoticeId: 'dtvp-1',
|
|
},
|
|
],
|
|
});
|
|
const configs = new Map<string, any>();
|
|
configs.set('doe-opendata', {
|
|
id: 'cfg1',
|
|
sourceType: 'doe-opendata',
|
|
pollIntervalMin: 60,
|
|
isActive: true,
|
|
lastIngestedDay: null,
|
|
});
|
|
|
|
return {
|
|
tender: {
|
|
findMany: vi.fn(async (_args?: any) => Array.from(tenders.values())),
|
|
count: vi.fn(async (_args?: any) => tenders.size),
|
|
findUnique: vi.fn(async ({ where }: any) => tenders.get(where.id) ?? null),
|
|
groupBy: vi.fn(async (_args?: any) => [
|
|
{ sourcePortal: 'doe-opendata', _count: tenders.size },
|
|
]),
|
|
},
|
|
tenderSourcePollConfig: {
|
|
findUnique: vi.fn(async ({ where }: any) => configs.get(where.sourceType) ?? null),
|
|
upsert: vi.fn(async ({ where, update, create }: any) => {
|
|
const existing = configs.get(where.sourceType);
|
|
const record = existing ? { ...existing, ...update } : { ...create };
|
|
configs.set(where.sourceType, record);
|
|
return record;
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fake TenderTriageService for controller-level wiring tests (Plan 11-05,
|
|
* Task 2). Default stubs return empty results — individual tests override
|
|
* via `.mockResolvedValueOnce`/reassigning the mock as needed.
|
|
*/
|
|
function makeFakeTriageService() {
|
|
return {
|
|
favoriteIds: vi.fn(async (_userId: string) => [] as string[]),
|
|
listForUser: vi.fn(async (_userId: string, _tenderIds: string[]) => [] as any[]),
|
|
setTriage: vi.fn(async (_userId: string, _tenantId: string, _tenderId: string, _dto: 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;
|
|
}
|
|
|
|
/**
|
|
* Fake TenderRssFeedSourceService for controller-level wiring tests (Plan
|
|
* 14-02, Task 3; ownership split since Phase 17, Plan 02, D-02). Default
|
|
* stubs echo/list-nothing; individual tests override via
|
|
* `.mockResolvedValueOnce`/reassigning the mock — including `createForUser`/
|
|
* `createPlatform` rejecting with BadRequestException to prove the
|
|
* denylist error surfaces through the controller unchanged.
|
|
*/
|
|
function makeFakeRssFeedService() {
|
|
return {
|
|
listForUser: vi.fn(async (_userId: string) => [] as any[]),
|
|
createForUser: vi.fn(
|
|
async (ctx: { userId: string; tenantId: string }, dto: any) => ({
|
|
id: 'feed-1',
|
|
isActive: true,
|
|
userId: ctx.userId,
|
|
tenantId: ctx.tenantId,
|
|
...dto,
|
|
}),
|
|
),
|
|
createPlatform: vi.fn(async (dto: any) => ({
|
|
id: 'feed-1',
|
|
isActive: true,
|
|
userId: null,
|
|
tenantId: null,
|
|
...dto,
|
|
})),
|
|
remove: vi.fn(async (_id: string) => ({ success: true })),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fake TenderEmailConfigService for controller-level wiring tests (Plan
|
|
* 14-03, D-06/D-07/CONFIG-02; per-user ownership since Phase 17, Plan 01,
|
|
* D-01). Default stubs echo/return null; individual tests override via
|
|
* `.mockResolvedValueOnce`/reassigning the mock.
|
|
*/
|
|
function makeFakeEmailConfigService() {
|
|
return {
|
|
getConfigForApi: vi.fn(async (_userId: string) => null as any),
|
|
saveConfig: vi.fn(async (_ctx: { userId: string; tenantId: string }, dto: any) => ({
|
|
id: 'ec-1',
|
|
...dto,
|
|
})),
|
|
testConnection: vi.fn(async (_userId: string, _dto: any) => ({ success: true }) as any),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fake TenderSavedSearchService for controller-level wiring tests (Plan
|
|
* 11-06, Task 2). Default stubs return empty/echo results — individual
|
|
* tests override via `.mockResolvedValueOnce`/reassigning the mock.
|
|
*/
|
|
function makeFakeSavedSearchService() {
|
|
return {
|
|
list: vi.fn(async (_userId: string) => [] as any[]),
|
|
create: vi.fn(async (_userId: string, _tenantId: string, dto: any) => ({
|
|
id: 'ss-1',
|
|
...dto,
|
|
})),
|
|
update: vi.fn(async (_id: string, _userId: string, dto: any) => ({
|
|
id: 'ss-1',
|
|
...dto,
|
|
})),
|
|
remove: vi.fn(async (_id: string, _userId: string) => undefined),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fake TenderNotificationPrefService for controller-level wiring tests
|
|
* (Plan 12-04, NOTIFY-01/T-12-14). Default stubs return a daily default /
|
|
* echo the upserted value; individual tests override via
|
|
* `.mockResolvedValueOnce`/reassigning the mock.
|
|
*/
|
|
function makeFakeNotificationPrefService() {
|
|
return {
|
|
getForUser: vi.fn(async (_userId: string) => ({ digestInterval: 'daily' })),
|
|
setForUser: vi.fn(async (_userId: string, _tenantId: string, digestInterval: string) => ({
|
|
digestInterval,
|
|
})),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fake TenderIngestionService for the poll-now controller wiring test
|
|
* (Quick 260723-lvg). Only `pollDueSources` is exercised by the controller.
|
|
*/
|
|
function makeFakeIngestionService() {
|
|
return {
|
|
pollDueSources: vi.fn(async () => undefined),
|
|
};
|
|
}
|
|
|
|
describe('TendersController — POST /poll-now (admin manual trigger)', () => {
|
|
it('pollNow() calls pollDueSources() exactly once and returns { ok: true }', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const ingestionService = makeFakeIngestionService();
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
makeFakeTriageService() as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
ingestionService as any,
|
|
);
|
|
|
|
const result = await controller.pollNow();
|
|
|
|
expect(ingestionService.pollDueSources).toHaveBeenCalledTimes(1);
|
|
expect(result).toEqual({ ok: true });
|
|
});
|
|
|
|
it('pollNow carries @Roles(ADMIN, SUPER_ADMIN) metadata', () => {
|
|
const roles = Reflect.getMetadata(ROLES_KEY, TendersController.prototype.pollNow);
|
|
|
|
expect(roles).toContain(Role.ADMIN);
|
|
expect(roles).toContain(Role.SUPER_ADMIN);
|
|
});
|
|
});
|
|
|
|
describe('TendersController — global read (not tenant-scoped)', () => {
|
|
it('GET / calls prisma.tender.findMany with a where clause that has no tenantId key', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const triageService = makeFakeTriageService();
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
triageService as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
await controller.listTenders({});
|
|
|
|
expect(prisma.tender.findMany).toHaveBeenCalledTimes(1);
|
|
const callArgs = prisma.tender.findMany.mock.calls[0][0];
|
|
expect(callArgs.where).not.toHaveProperty('tenantId');
|
|
});
|
|
|
|
it('GET /:id returns the tender when found and never scopes by tenant', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const triageService = makeFakeTriageService();
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
triageService as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
const result = await controller.getTender('t1');
|
|
|
|
expect(result.id).toBe('t1');
|
|
const callArgs = prisma.tender.findUnique.mock.calls[0][0];
|
|
expect(callArgs.where).toEqual({ id: 't1' });
|
|
});
|
|
|
|
it('GET /:id includes the sources relation so the response carries all TenderSource links (13-06, SCHEMA-03)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const triageService = makeFakeTriageService();
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
triageService as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
const result = await controller.getTender('t1');
|
|
|
|
const callArgs = prisma.tender.findUnique.mock.calls[0][0];
|
|
expect(callArgs.include).toEqual({
|
|
sources: {
|
|
select: { sourcePortal: true, sourceUrl: true, sourceNoticeId: true },
|
|
},
|
|
});
|
|
expect(result.sources).toEqual([
|
|
{
|
|
sourcePortal: 'doe-opendata',
|
|
sourceUrl: 'https://doe.example/t1',
|
|
sourceNoticeId: 'doe-1',
|
|
},
|
|
{
|
|
sourcePortal: 'cosinex-dtvp',
|
|
sourceUrl: 'https://dtvp.example/t1',
|
|
sourceNoticeId: 'dtvp-1',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('GET /:id throws NotFoundException for a missing id', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const triageService = makeFakeTriageService();
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
triageService as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
await expect(controller.getTender('missing')).rejects.toBeInstanceOf(NotFoundException);
|
|
});
|
|
});
|
|
|
|
describe('TendersController — admin source-config applies live to the scheduler (INGEST-06)', () => {
|
|
it('PUT /source-config with isActive=true + pollIntervalMin=30 calls scheduler.setInterval(30) with a single argument', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const triageService = makeFakeTriageService();
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
triageService as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
await controller.saveSourceConfig({ isActive: true, pollIntervalMin: 30 });
|
|
|
|
expect(scheduler.setInterval).toHaveBeenCalledTimes(1);
|
|
expect(scheduler.setInterval).toHaveBeenCalledWith(30);
|
|
expect(scheduler.stopJob).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('PUT /source-config with isActive=false calls scheduler.stopJob()', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const triageService = makeFakeTriageService();
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
triageService as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
await controller.saveSourceConfig({ isActive: false });
|
|
|
|
expect(scheduler.stopJob).toHaveBeenCalledTimes(1);
|
|
expect(scheduler.setInterval).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('TendersController — route declaration order (static route before :id)', () => {
|
|
// Regression guard for the GET /source-config → 404 bug: NestJS RouterExplorer
|
|
// maps routes in method-declaration order. When `@Get(':id')` is declared
|
|
// before `@Get('source-config')`, the param route captures "source-config" as
|
|
// an id and shadows the static handler — 401 unauthenticated, 404 once past the
|
|
// guard (Tender "source-config" not found). Unit-calling the methods directly
|
|
// (the tests above) bypasses routing and cannot catch this, so we assert the
|
|
// declaration order explicitly.
|
|
it('declares getSourceConfig before getTender so GET /:id cannot shadow it', () => {
|
|
const methods = Object.getOwnPropertyNames(TendersController.prototype);
|
|
const sourceConfigIdx = methods.indexOf('getSourceConfig');
|
|
const idIdx = methods.indexOf('getTender');
|
|
|
|
expect(sourceConfigIdx).toBeGreaterThanOrEqual(0);
|
|
expect(idIdx).toBeGreaterThanOrEqual(0);
|
|
expect(sourceConfigIdx).toBeLessThan(idIdx);
|
|
});
|
|
|
|
it('declares getCoverage before getTender so GET /:id cannot shadow it (Pitfall 5)', () => {
|
|
const methods = Object.getOwnPropertyNames(TendersController.prototype);
|
|
const coverageIdx = methods.indexOf('getCoverage');
|
|
const idIdx = methods.indexOf('getTender');
|
|
|
|
expect(coverageIdx).toBeGreaterThanOrEqual(0);
|
|
expect(idIdx).toBeGreaterThanOrEqual(0);
|
|
expect(coverageIdx).toBeLessThan(idIdx);
|
|
});
|
|
|
|
it('declares getDenylistedPortals before getTender so GET /:id cannot shadow "denylisted-portals" (Plan 14-04, Pitfall 5)', () => {
|
|
const methods = Object.getOwnPropertyNames(TendersController.prototype);
|
|
const denylistIdx = methods.indexOf('getDenylistedPortals');
|
|
const idIdx = methods.indexOf('getTender');
|
|
|
|
expect(denylistIdx).toBeGreaterThanOrEqual(0);
|
|
expect(idIdx).toBeGreaterThanOrEqual(0);
|
|
expect(denylistIdx).toBeLessThan(idIdx);
|
|
});
|
|
|
|
it('declares listRssFeeds/createRssFeed/removeRssFeed before getTender so GET /:id cannot shadow "rss-feeds" (Plan 14-02, Pitfall 5)', () => {
|
|
const methods = Object.getOwnPropertyNames(TendersController.prototype);
|
|
const listIdx = methods.indexOf('listRssFeeds');
|
|
const createIdx = methods.indexOf('createRssFeed');
|
|
const removeIdx = methods.indexOf('removeRssFeed');
|
|
const idIdx = methods.indexOf('getTender');
|
|
|
|
expect(listIdx).toBeGreaterThanOrEqual(0);
|
|
expect(createIdx).toBeGreaterThanOrEqual(0);
|
|
expect(removeIdx).toBeGreaterThanOrEqual(0);
|
|
expect(idIdx).toBeGreaterThanOrEqual(0);
|
|
expect(listIdx).toBeLessThan(idIdx);
|
|
expect(createIdx).toBeLessThan(idIdx);
|
|
expect(removeIdx).toBeLessThan(idIdx);
|
|
});
|
|
|
|
it('declares getEmailConfig/saveEmailConfig/testEmailConnection before getTender so GET /:id cannot shadow "email-config" (Plan 14-03, Pitfall 5; testEmailConnection added Quick 260907-let)', () => {
|
|
const methods = Object.getOwnPropertyNames(TendersController.prototype);
|
|
const getIdx = methods.indexOf('getEmailConfig');
|
|
const saveIdx = methods.indexOf('saveEmailConfig');
|
|
const testIdx = methods.indexOf('testEmailConnection');
|
|
const idIdx = methods.indexOf('getTender');
|
|
|
|
expect(getIdx).toBeGreaterThanOrEqual(0);
|
|
expect(saveIdx).toBeGreaterThanOrEqual(0);
|
|
expect(testIdx).toBeGreaterThanOrEqual(0);
|
|
expect(idIdx).toBeGreaterThanOrEqual(0);
|
|
expect(getIdx).toBeLessThan(idIdx);
|
|
expect(saveIdx).toBeLessThan(idIdx);
|
|
expect(testIdx).toBeLessThan(idIdx);
|
|
});
|
|
|
|
it('declares listTriage and setTriage before getTender so GET /:id cannot shadow "triage" (Plan 11-05, Pitfall 5)', () => {
|
|
const methods = Object.getOwnPropertyNames(TendersController.prototype);
|
|
const listTriageIdx = methods.indexOf('listTriage');
|
|
const setTriageIdx = methods.indexOf('setTriage');
|
|
const idIdx = methods.indexOf('getTender');
|
|
|
|
expect(listTriageIdx).toBeGreaterThanOrEqual(0);
|
|
expect(setTriageIdx).toBeGreaterThanOrEqual(0);
|
|
expect(idIdx).toBeGreaterThanOrEqual(0);
|
|
expect(listTriageIdx).toBeLessThan(idIdx);
|
|
expect(setTriageIdx).toBeLessThan(idIdx);
|
|
});
|
|
|
|
it('declares listSavedSearches before getTender so GET /:id cannot shadow "saved-searches" (Plan 11-06, Pitfall 5/T-11-16)', () => {
|
|
const methods = Object.getOwnPropertyNames(TendersController.prototype);
|
|
const listSavedSearchesIdx = methods.indexOf('listSavedSearches');
|
|
const idIdx = methods.indexOf('getTender');
|
|
|
|
expect(listSavedSearchesIdx).toBeGreaterThanOrEqual(0);
|
|
expect(idIdx).toBeGreaterThanOrEqual(0);
|
|
expect(listSavedSearchesIdx).toBeLessThan(idIdx);
|
|
});
|
|
|
|
it('declares getNotificationPref/setNotificationPref before getTender so GET /:id cannot shadow "notification-pref" (Plan 12-04, Pitfall 5)', () => {
|
|
const methods = Object.getOwnPropertyNames(TendersController.prototype);
|
|
const getPrefIdx = methods.indexOf('getNotificationPref');
|
|
const setPrefIdx = methods.indexOf('setNotificationPref');
|
|
const idIdx = methods.indexOf('getTender');
|
|
|
|
expect(getPrefIdx).toBeGreaterThanOrEqual(0);
|
|
expect(setPrefIdx).toBeGreaterThanOrEqual(0);
|
|
expect(idIdx).toBeGreaterThanOrEqual(0);
|
|
expect(getPrefIdx).toBeLessThan(idIdx);
|
|
expect(setPrefIdx).toBeLessThan(idIdx);
|
|
});
|
|
|
|
it('declares pollNow before getTender so GET /:id cannot shadow "poll-now" (Quick 260723-lvg, Pitfall 5)', () => {
|
|
const methods = Object.getOwnPropertyNames(TendersController.prototype);
|
|
const pollNowIdx = methods.indexOf('pollNow');
|
|
const idIdx = methods.indexOf('getTender');
|
|
|
|
expect(pollNowIdx).toBeGreaterThanOrEqual(0);
|
|
expect(idIdx).toBeGreaterThanOrEqual(0);
|
|
expect(pollNowIdx).toBeLessThan(idIdx);
|
|
});
|
|
});
|
|
|
|
describe('TendersController — GET/PUT notification-pref (NOTIFY-01, per-user, T-12-14)', () => {
|
|
it('GET /notification-pref derives userId from req.user and delegates to tenderNotificationPref.getForUser(userId) — never from a query param (V4 / IDOR)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const triageService = makeFakeTriageService();
|
|
const prefService = makeFakeNotificationPrefService();
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
triageService as any,
|
|
makeFakeSavedSearchService() as any,
|
|
prefService as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
await controller.getNotificationPref(makeFakeRequest('u-real'));
|
|
|
|
expect(prefService.getForUser).toHaveBeenCalledWith('u-real');
|
|
});
|
|
|
|
it('PUT /notification-pref delegates to tenderNotificationPref.setForUser with userId/tenantId from the auth context, not the body', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const triageService = makeFakeTriageService();
|
|
const prefService = makeFakeNotificationPrefService();
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
triageService as any,
|
|
makeFakeSavedSearchService() as any,
|
|
prefService as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
await controller.setNotificationPref(
|
|
{ digestInterval: 'weekly' } as any,
|
|
makeFakeRequest('u1', 'tenant1'),
|
|
);
|
|
|
|
expect(prefService.setForUser).toHaveBeenCalledWith('u1', 'tenant1', 'weekly');
|
|
});
|
|
});
|
|
|
|
describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-14/15/16)', () => {
|
|
it('GET /saved-searches derives userId from req.user and delegates to tenderSavedSearch.list(userId) — never from a query param (V4 / IDOR)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const triageService = makeFakeTriageService();
|
|
const savedSearchService = makeFakeSavedSearchService();
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
triageService as any,
|
|
savedSearchService as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
await controller.listSavedSearches(makeFakeRequest('u-real'));
|
|
|
|
expect(savedSearchService.list).toHaveBeenCalledWith('u-real');
|
|
});
|
|
|
|
it('POST /saved-searches delegates to tenderSavedSearch.create with userId/tenantId from the auth context, not the body', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const triageService = makeFakeTriageService();
|
|
const savedSearchService = makeFakeSavedSearchService();
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
triageService as any,
|
|
savedSearchService as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
await controller.createSavedSearch(
|
|
{ name: 'Bau NRW', filters: { q: 'Bau' } } as any,
|
|
makeFakeRequest('u1', 'tenant1'),
|
|
);
|
|
|
|
expect(savedSearchService.create).toHaveBeenCalledWith('u1', 'tenant1', {
|
|
name: 'Bau NRW',
|
|
filters: { q: 'Bau' },
|
|
});
|
|
});
|
|
|
|
it('PATCH /saved-searches/:searchId delegates to tenderSavedSearch.update(searchId, userId, dto) — userId from the auth context', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const triageService = makeFakeTriageService();
|
|
const savedSearchService = makeFakeSavedSearchService();
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
triageService as any,
|
|
savedSearchService as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
await controller.updateSavedSearch(
|
|
'ss-1',
|
|
{ name: 'Neuer Name' } as any,
|
|
makeFakeRequest('u1'),
|
|
);
|
|
|
|
expect(savedSearchService.update).toHaveBeenCalledWith('ss-1', 'u1', {
|
|
name: 'Neuer Name',
|
|
});
|
|
});
|
|
|
|
it('DELETE /saved-searches/:searchId delegates to tenderSavedSearch.remove(searchId, userId) — userId from the auth context', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const triageService = makeFakeTriageService();
|
|
const savedSearchService = makeFakeSavedSearchService();
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
triageService as any,
|
|
savedSearchService as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
const result = await controller.removeSavedSearch('ss-1', makeFakeRequest('u1'));
|
|
|
|
expect(savedSearchService.remove).toHaveBeenCalledWith('ss-1', 'u1');
|
|
expect(result).toEqual({ success: true });
|
|
});
|
|
});
|
|
|
|
describe('TendersController — listTenders uses the query builder (sort whitelist + pagination bounds)', () => {
|
|
it('passes buildTenderWhere/buildOrderBy output through to prisma.tender.findMany', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const triageService = makeFakeTriageService();
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
triageService as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
await controller.listTenders({ sort: 'deadline', q: 'Bau' } as any);
|
|
|
|
const callArgs = prisma.tender.findMany.mock.calls[0][0];
|
|
expect(callArgs.orderBy).toEqual({ deadlineAt: 'asc' });
|
|
expect(callArgs.where.AND).toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
OR: [
|
|
{ title: { contains: 'Bau', mode: 'insensitive' } },
|
|
{ buyerName: { contains: 'Bau', mode: 'insensitive' } },
|
|
],
|
|
},
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('an unknown sort key falls back to the publishedAt-desc default via buildOrderBy', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const triageService = makeFakeTriageService();
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
triageService as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
await controller.listTenders({ sort: 'not-whitelisted' } as any);
|
|
|
|
const callArgs = prisma.tender.findMany.mock.calls[0][0];
|
|
expect(callArgs.orderBy).toEqual({ publishedAt: 'desc' });
|
|
});
|
|
|
|
it('respects page/limit for skip/take (pagination bounds unchanged, T-10-15)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const triageService = makeFakeTriageService();
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
triageService as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
await controller.listTenders({ page: 3, limit: 10 } as any);
|
|
|
|
const callArgs = prisma.tender.findMany.mock.calls[0][0];
|
|
expect(callArgs.skip).toBe(20);
|
|
expect(callArgs.take).toBe(10);
|
|
});
|
|
});
|
|
|
|
describe('TendersController — GET /coverage', () => {
|
|
it('returns distinct sourcePortal distribution and total for active tenders', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const triageService = makeFakeTriageService();
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
triageService as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
const result = await controller.getCoverage();
|
|
|
|
expect(prisma.tender.groupBy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
by: ['sourcePortal'],
|
|
where: { status: 'active' },
|
|
}),
|
|
);
|
|
expect(result).toEqual({
|
|
total: 1,
|
|
sources: [{ sourcePortal: 'doe-opendata', count: 1 }],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('TendersController — GET /denylisted-portals (Plan 14-04, UI-06/D-12)', () => {
|
|
it('returns vergabe24 and aumass each with their canonical URL, derived from DENYLISTED_PORTALS', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
makeFakeTriageService() as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
const result = await controller.getDenylistedPortals();
|
|
|
|
expect(result).toEqual({
|
|
portals: [
|
|
{ portal: 'vergabe24', url: 'https://www.vergabe24.de' },
|
|
{ portal: 'aumass', url: 'https://www.aumass.de' },
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () => {
|
|
it('parses the comma-separated ids param and delegates to tenderTriage.listForUser(userId, ids)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const triageService = makeFakeTriageService();
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
triageService as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
await controller.listTriage('t1, t2 ,t3', makeFakeRequest('u1'));
|
|
|
|
expect(triageService.listForUser).toHaveBeenCalledWith('u1', ['t1', 't2', 't3']);
|
|
});
|
|
|
|
it('derives userId from req.user, never from the query string (V4 / IDOR)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const triageService = makeFakeTriageService();
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
triageService as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
await controller.listTriage('t1', makeFakeRequest('u-real'));
|
|
|
|
expect(triageService.listForUser).toHaveBeenCalledWith('u-real', ['t1']);
|
|
});
|
|
|
|
it('caps the ids batch at MAX_TRIAGE_BATCH_IDS (T-11-11 DoS)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const triageService = makeFakeTriageService();
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
triageService as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
const manyIds = Array.from({ length: 300 }, (_, i) => `t${i}`).join(',');
|
|
await controller.listTriage(manyIds, makeFakeRequest('u1'));
|
|
|
|
const calledIds = triageService.listForUser.mock.calls[0][1];
|
|
expect(calledIds.length).toBeLessThanOrEqual(200);
|
|
});
|
|
});
|
|
|
|
describe('TendersController — PUT /triage (upsert, per-user, T-11-10)', () => {
|
|
it('delegates to tenderTriage.setTriage with userId/tenantId from the request context, not the body', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const triageService = makeFakeTriageService();
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
triageService as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
await controller.setTriage(
|
|
{ tenderId: 't1', isRead: true } as any,
|
|
makeFakeRequest('u1', 'tenant1'),
|
|
);
|
|
|
|
expect(triageService.setTriage).toHaveBeenCalledWith('u1', 'tenant1', 't1', {
|
|
isRead: true,
|
|
isFavorite: undefined,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)', () => {
|
|
it('favOnly=true resolves favoriteIds(userId) from the auth context and passes them into the where-builder', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const triageService = makeFakeTriageService();
|
|
triageService.favoriteIds.mockResolvedValueOnce(['t1']);
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
triageService as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1'));
|
|
|
|
expect(triageService.favoriteIds).toHaveBeenCalledWith('u1');
|
|
const callArgs = prisma.tender.findMany.mock.calls[0][0];
|
|
expect(callArgs.where.AND).toEqual(
|
|
expect.arrayContaining([{ id: { in: ['t1'] } }]),
|
|
);
|
|
});
|
|
|
|
it('favOnly=true with no favorites yields a zero-match query, never the unfiltered catalog', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const triageService = makeFakeTriageService();
|
|
triageService.favoriteIds.mockResolvedValueOnce([]);
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
triageService as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1'));
|
|
|
|
const callArgs = prisma.tender.findMany.mock.calls[0][0];
|
|
expect(callArgs.where.AND).toEqual(
|
|
expect.arrayContaining([{ id: { in: ['__none__'] } }]),
|
|
);
|
|
});
|
|
|
|
it('favOnly unset never calls tenderTriage.favoriteIds', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const triageService = makeFakeTriageService();
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
triageService as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
await controller.listTenders({} as any, makeFakeRequest('u1'));
|
|
|
|
expect(triageService.favoriteIds).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('TendersController — RSS-feeds personal + platform-wide (Plan 14-02 D-14/D-08, ownership split Phase 17 Plan 02 D-02)', () => {
|
|
it('GET /rss-feeds delegates to tenderRssFeedSource.listForUser(userId) and maps isPlatformWide, stripping userId', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const rssFeedService = makeFakeRssFeedService();
|
|
rssFeedService.listForUser.mockResolvedValueOnce([
|
|
{ id: 'f1', url: 'https://service.bund.de/rss.xml', label: 'service-bund', userId: null },
|
|
{ id: 'f2', url: 'https://mine.invalid/rss.xml', label: 'mine', userId: 'u1' },
|
|
]);
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
makeFakeTriageService() as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
rssFeedService as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
const result = await controller.listRssFeeds(makeFakeRequest('u1', 'tenant1'));
|
|
|
|
expect(rssFeedService.listForUser).toHaveBeenCalledWith('u1');
|
|
expect(result).toEqual([
|
|
{ id: 'f1', url: 'https://service.bund.de/rss.xml', label: 'service-bund', isPlatformWide: true },
|
|
{ id: 'f2', url: 'https://mine.invalid/rss.xml', label: 'mine', isPlatformWide: false },
|
|
]);
|
|
expect(result.every((f: any) => !('userId' in f))).toBe(true);
|
|
});
|
|
|
|
it('POST /rss-feeds with scope omitted creates a personal feed via createForUser({userId,tenantId}, dto)', 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://mine.invalid/rss.xml', label: 'mine' } as any;
|
|
await controller.createRssFeed(dto, makeFakeRequest('u1', 'tenant1'));
|
|
|
|
expect(rssFeedService.createForUser).toHaveBeenCalledWith({ userId: 'u1', tenantId: 'tenant1' }, dto);
|
|
expect(rssFeedService.createPlatform).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('POST /rss-feeds surfaces a BadRequestException from the service when the URL is denylisted (D-14)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const rssFeedService = makeFakeRssFeedService();
|
|
rssFeedService.createForUser.mockRejectedValueOnce(
|
|
new BadRequestException("Der Host 'www.vergabe24.de' ist AGB-seitig für automatisierten Zugriff gesperrt"),
|
|
);
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
makeFakeTriageService() as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
rssFeedService as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
await expect(
|
|
controller.createRssFeed(
|
|
{ url: 'https://www.vergabe24.de/rss.xml', label: 'vergabe24' } as any,
|
|
makeFakeRequest('u1', 'tenant1'),
|
|
),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
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();
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
makeFakeTriageService() as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
rssFeedService as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
const result = await controller.removeRssFeed(
|
|
'feed-1',
|
|
makeFakeRequest('u1', 'tenant1', Role.USER),
|
|
);
|
|
|
|
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)', () => {
|
|
it('GET /email-config resolves userId from the auth context and delegates to tenderEmailConfig.getConfigForApi(userId)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const emailConfigService = makeFakeEmailConfigService();
|
|
emailConfigService.getConfigForApi.mockResolvedValueOnce({
|
|
userId: 'u1',
|
|
tenantId: 'tenant1',
|
|
protocol: 'imap',
|
|
hasPassword: true,
|
|
} as any);
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
makeFakeTriageService() as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
emailConfigService as any,
|
|
);
|
|
|
|
const result = await controller.getEmailConfig(makeFakeRequest('u1', 'tenant1'));
|
|
|
|
expect(emailConfigService.getConfigForApi).toHaveBeenCalledWith('u1');
|
|
expect(result).toEqual({
|
|
userId: 'u1',
|
|
tenantId: 'tenant1',
|
|
protocol: 'imap',
|
|
hasPassword: true,
|
|
});
|
|
});
|
|
|
|
it('PUT /email-config delegates to tenderEmailConfig.saveConfig with {userId, tenantId} from the auth context, never the body', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const emailConfigService = makeFakeEmailConfigService();
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
makeFakeTriageService() as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
emailConfigService as any,
|
|
);
|
|
|
|
const dto = { protocol: 'imap', encryption: 'ssl-tls', host: 'imap.example.test' } as any;
|
|
await controller.saveEmailConfig(dto, makeFakeRequest('u1', 'tenant1'));
|
|
|
|
expect(emailConfigService.saveConfig).toHaveBeenCalledWith(
|
|
{ userId: 'u1', tenantId: 'tenant1' },
|
|
dto,
|
|
);
|
|
});
|
|
|
|
it('two different users of the same tenant each resolve their own userId — never the other user\'s (T-17-01, IDOR)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const emailConfigService = makeFakeEmailConfigService();
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
makeFakeTriageService() as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
emailConfigService as any,
|
|
);
|
|
|
|
await controller.getEmailConfig(makeFakeRequest('user-a', 'tenant1'));
|
|
await controller.getEmailConfig(makeFakeRequest('user-b', 'tenant1'));
|
|
|
|
expect(emailConfigService.getConfigForApi).toHaveBeenNthCalledWith(1, 'user-a');
|
|
expect(emailConfigService.getConfigForApi).toHaveBeenNthCalledWith(2, 'user-b');
|
|
});
|
|
|
|
it('POST /email-config/test resolves userId from the auth context, even when the body carries a different identity field (T-QT16-01, IDOR)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const emailConfigService = makeFakeEmailConfigService();
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
makeFakeTriageService() as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
emailConfigService as any,
|
|
);
|
|
|
|
const dto = {
|
|
protocol: 'imap',
|
|
encryption: 'ssl-tls',
|
|
userId: 'attacker-supplied-id',
|
|
} as any;
|
|
await controller.testEmailConnection(dto, makeFakeRequest('u1', 'tenant1'));
|
|
|
|
expect(emailConfigService.testConnection).toHaveBeenCalledWith('u1', dto);
|
|
});
|
|
});
|
|
|
|
describe('TendersController — D-13 private (email-alert) tender visibility (Phase 14, Plan 03)', () => {
|
|
/**
|
|
* Bespoke fake prisma for these tests: unlike makeFakePrisma() (which
|
|
* ignores `where` entirely), this one evaluates the ownerTenantId-related
|
|
* AND clauses buildTenderWhere produces — proving the visibility filter
|
|
* actually excludes/includes rows, not just that the where clause "looks
|
|
* right" in isolation (tender-query.builder.spec.ts already covers that).
|
|
*/
|
|
function makeFakeVisibilityPrisma() {
|
|
const tenders = new Map<string, any>([
|
|
['global-1', { id: 'global-1', title: 'Global Tender', ownerTenantId: null, sources: [] }],
|
|
['private-a', { id: 'private-a', title: 'Private Tender (Tenant A)', ownerTenantId: 'tenant-a', sources: [] }],
|
|
]);
|
|
|
|
function matchesOwnerTenantClause(tender: any, where: any): boolean {
|
|
const and = (where?.AND ?? []) as any[];
|
|
for (const clause of and) {
|
|
if (clause && typeof clause === 'object' && 'ownerTenantId' in clause) {
|
|
if (clause.ownerTenantId !== tender.ownerTenantId) return false;
|
|
} else if (clause && typeof clause === 'object' && 'OR' in clause) {
|
|
const or = clause.OR as any[];
|
|
const isOwnerOrClause = or.every(
|
|
(c) => c && typeof c === 'object' && 'ownerTenantId' in c,
|
|
);
|
|
if (isOwnerOrClause) {
|
|
const matches = or.some((c) => c.ownerTenantId === tender.ownerTenantId);
|
|
if (!matches) return false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
return {
|
|
tender: {
|
|
findMany: vi.fn(async ({ where }: any) =>
|
|
Array.from(tenders.values()).filter((t) => matchesOwnerTenantClause(t, where)),
|
|
),
|
|
count: vi.fn(async ({ where }: any) =>
|
|
Array.from(tenders.values()).filter((t) => matchesOwnerTenantClause(t, where)).length,
|
|
),
|
|
findUnique: vi.fn(async ({ where }: any) => tenders.get(where.id) ?? null),
|
|
},
|
|
tenderSourcePollConfig: { findUnique: vi.fn(), upsert: vi.fn() },
|
|
};
|
|
}
|
|
|
|
it('GET / for tenant-a includes both the global tender and its own private tender', async () => {
|
|
const prisma = makeFakeVisibilityPrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
makeFakeTriageService() as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
const result = await controller.listTenders({} as any, makeFakeRequest('u-a', 'tenant-a'));
|
|
|
|
const ids = result.items.map((t: any) => t.id).sort();
|
|
expect(ids).toEqual(['global-1', 'private-a']);
|
|
});
|
|
|
|
it('GET / for tenant-b (a different tenant) excludes tenant-a\'s private tender, includes the global one', async () => {
|
|
const prisma = makeFakeVisibilityPrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
makeFakeTriageService() as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
const result = await controller.listTenders({} as any, makeFakeRequest('u-b', 'tenant-b'));
|
|
|
|
const ids = result.items.map((t: any) => t.id).sort();
|
|
expect(ids).toEqual(['global-1']);
|
|
});
|
|
|
|
it('GET / with no resolvable tenant context (no req) fails closed to only the global tender', async () => {
|
|
const prisma = makeFakeVisibilityPrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
makeFakeTriageService() as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
const result = await controller.listTenders({} as any);
|
|
|
|
const ids = result.items.map((t: any) => t.id).sort();
|
|
expect(ids).toEqual(['global-1']);
|
|
});
|
|
|
|
it('GET /:id returns the null-owner tender to both tenant-a and tenant-b', async () => {
|
|
const prisma = makeFakeVisibilityPrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
makeFakeTriageService() as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
const forA = await controller.getTender('global-1', makeFakeRequest('u-a', 'tenant-a'));
|
|
const forB = await controller.getTender('global-1', makeFakeRequest('u-b', 'tenant-b'));
|
|
|
|
expect(forA.id).toBe('global-1');
|
|
expect(forB.id).toBe('global-1');
|
|
});
|
|
|
|
it('GET /:id returns tenant-a\'s private tender to tenant-a', async () => {
|
|
const prisma = makeFakeVisibilityPrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
makeFakeTriageService() as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
const result = await controller.getTender('private-a', makeFakeRequest('u-a', 'tenant-a'));
|
|
|
|
expect(result.id).toBe('private-a');
|
|
});
|
|
|
|
it('GET /:id 404s for tenant-b requesting tenant-a\'s private tender (no cross-tenant detail leak)', async () => {
|
|
const prisma = makeFakeVisibilityPrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
makeFakeTriageService() as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
await expect(
|
|
controller.getTender('private-a', makeFakeRequest('u-b', 'tenant-b')),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
});
|
|
|
|
it('GET /:id 404s for an unauthenticated request (no req) to a private tender', async () => {
|
|
const prisma = makeFakeVisibilityPrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const controller = new TendersController(
|
|
prisma as any,
|
|
scheduler,
|
|
makeFakeTriageService() as any,
|
|
makeFakeSavedSearchService() as any,
|
|
makeFakeNotificationPrefService() as any,
|
|
makeFakeRssFeedService() as any,
|
|
makeFakeEmailConfigService() as any,
|
|
);
|
|
|
|
await expect(controller.getTender('private-a')).rejects.toBeInstanceOf(NotFoundException);
|
|
});
|
|
});
|