feat(14-03): add email-config admin routes + D-13 read-side visibility filter + EmailAlertConfigForm
buildTenderWhere gains an optional ownerTenantId param: a resolved
requesting tenant sees global tenders (null) plus its own private ones
(OR[global, mine]); an unresolved requester fails CLOSED to global-only —
never an accidental cross-tenant leak.
TendersController: listTenders/getTender resolve the requesting tenant
leniently from the auth context (resolveRequestingTenantId, never throws)
and apply the D-13 filter; getTender 404s (not a distinct "forbidden") when
a tender's non-null ownerTenantId doesn't match the requester, so no
cross-tenant detail leak. New GET/PUT /modules/tender-radar/email-config
routes (Roles ADMIN/SUPER_ADMIN, tenantId from auth context, never the
body) delegate to TenderEmailConfigService — declared before @Get(':id')
per the project's NestJS route-order convention.
Web: EmailAlertConfig type + fetchEmailConfig/saveEmailConfig client
functions; EmailAlertConfigForm mirrors the DKV InboxConfigForm (password
blank on load, only sent when typed — T-07-12), added as a new
"E-Mail-Alerts" section on the existing tender-radar settings page.
Hardcoded German strings — i18n is Plan 14-05.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -256,6 +256,26 @@ describe('buildTenderWhere — favOnly (UI-04, D-10, T-11-10/11)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTenderWhere — D-13 ownerTenantId visibility (Phase 14, Plan 03)', () => {
|
||||
it('a resolved ownerTenantId adds an OR[global, mine] clause', () => {
|
||||
const where = buildTenderWhere(dto(), undefined, 'tenant-a');
|
||||
|
||||
expect(where.AND).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ OR: [{ ownerTenantId: null }, { ownerTenantId: 'tenant-a' }] },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('an unresolved ownerTenantId (no auth context) fails closed to global-only tenders', () => {
|
||||
const where = buildTenderWhere(dto());
|
||||
|
||||
expect(where.AND).toEqual(
|
||||
expect.arrayContaining([{ ownerTenantId: null }]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildOrderBy', () => {
|
||||
it('sort=deadline maps to { deadlineAt: asc }', () => {
|
||||
expect(buildOrderBy('deadline')).toEqual({ deadlineAt: 'asc' });
|
||||
|
||||
@@ -31,10 +31,20 @@ const MAX_FAV_IDS = 500;
|
||||
* `TenderTriageService.favoriteIds(userId)`) from the auth context —
|
||||
* never accepted here as user input. Only consulted when `dto.favOnly` is
|
||||
* true.
|
||||
*
|
||||
* `ownerTenantId` (Phase 14, Plan 03, D-13): the requesting tenant's id,
|
||||
* resolved by the CALLER (TendersController) from the auth context — never
|
||||
* from `dto`. Applies the OR[global, mine] visibility rule for privately-
|
||||
* sourced (email-alert) tenders: `{ OR: [{ownerTenantId:null},
|
||||
* {ownerTenantId}] }`. When the caller cannot resolve a requesting tenant
|
||||
* (no auth context), this fails CLOSED — only globally-visible
|
||||
* (`ownerTenantId: null`) tenders are returned, never a private tenant's
|
||||
* rows leaked to an unidentified requester.
|
||||
*/
|
||||
export function buildTenderWhere(
|
||||
dto: TenderQueryDto,
|
||||
favIds?: string[],
|
||||
ownerTenantId?: string,
|
||||
): Prisma.TenderWhereInput {
|
||||
const where: Prisma.TenderWhereInput = {};
|
||||
const AND: Prisma.TenderWhereInput[] = [];
|
||||
@@ -126,6 +136,16 @@ export function buildTenderWhere(
|
||||
AND.push({ id: { in: ids.length ? ids : ['__none__'] } });
|
||||
}
|
||||
|
||||
// D-13 (Phase 14, Plan 03): private (email-alert) tender visibility.
|
||||
// A resolved requesting tenant sees global tenders (null) PLUS its own;
|
||||
// an unidentified requester (ownerTenantId undefined) sees ONLY global
|
||||
// tenders — fail-closed, never an accidental cross-tenant leak.
|
||||
AND.push(
|
||||
ownerTenantId
|
||||
? { OR: [{ ownerTenantId: null }, { ownerTenantId }] }
|
||||
: { ownerTenantId: null },
|
||||
);
|
||||
|
||||
if (AND.length) where.AND = AND;
|
||||
return where;
|
||||
}
|
||||
|
||||
@@ -100,6 +100,18 @@ function makeFakeRssFeedService() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake TenderEmailConfigService for controller-level wiring tests (Plan
|
||||
* 14-03, D-06/D-07/CONFIG-02). Default stubs echo/return null; individual
|
||||
* tests override via `.mockResolvedValueOnce`/reassigning the mock.
|
||||
*/
|
||||
function makeFakeEmailConfigService() {
|
||||
return {
|
||||
getConfigForApi: vi.fn(async (_tenantId: string) => null as any),
|
||||
saveConfig: vi.fn(async (_tenantId: string, dto: any) => ({ id: 'ec-1', ...dto })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake TenderSavedSearchService for controller-level wiring tests (Plan
|
||||
* 11-06, Task 2). Default stubs return empty/echo results — individual
|
||||
@@ -147,6 +159,7 @@ describe('TendersController — global read (not tenant-scoped)', () => {
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
await controller.listTenders({});
|
||||
@@ -167,6 +180,7 @@ describe('TendersController — global read (not tenant-scoped)', () => {
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
const result = await controller.getTender('t1');
|
||||
@@ -187,6 +201,7 @@ describe('TendersController — global read (not tenant-scoped)', () => {
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
const result = await controller.getTender('t1');
|
||||
@@ -222,6 +237,7 @@ describe('TendersController — global read (not tenant-scoped)', () => {
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
await expect(controller.getTender('missing')).rejects.toBeInstanceOf(NotFoundException);
|
||||
@@ -240,6 +256,7 @@ describe('TendersController — admin source-config applies live to the schedule
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
await controller.saveSourceConfig({ isActive: true, pollIntervalMin: 30 });
|
||||
@@ -260,6 +277,7 @@ describe('TendersController — admin source-config applies live to the schedule
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
await controller.saveSourceConfig({ isActive: false });
|
||||
@@ -313,6 +331,19 @@ describe('TendersController — route declaration order (static route before :id
|
||||
expect(removeIdx).toBeLessThan(idIdx);
|
||||
});
|
||||
|
||||
it('declares getEmailConfig/saveEmailConfig before getTender so GET /:id cannot shadow "email-config" (Plan 14-03, Pitfall 5)', () => {
|
||||
const methods = Object.getOwnPropertyNames(TendersController.prototype);
|
||||
const getIdx = methods.indexOf('getEmailConfig');
|
||||
const saveIdx = methods.indexOf('saveEmailConfig');
|
||||
const idIdx = methods.indexOf('getTender');
|
||||
|
||||
expect(getIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(saveIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(idIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(getIdx).toBeLessThan(idIdx);
|
||||
expect(saveIdx).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');
|
||||
@@ -363,6 +394,7 @@ describe('TendersController — GET/PUT notification-pref (NOTIFY-01, per-user,
|
||||
makeFakeSavedSearchService() as any,
|
||||
prefService as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
await controller.getNotificationPref(makeFakeRequest('u-real'));
|
||||
@@ -382,6 +414,7 @@ describe('TendersController — GET/PUT notification-pref (NOTIFY-01, per-user,
|
||||
makeFakeSavedSearchService() as any,
|
||||
prefService as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
await controller.setNotificationPref(
|
||||
@@ -406,6 +439,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1
|
||||
savedSearchService as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
await controller.listSavedSearches(makeFakeRequest('u-real'));
|
||||
@@ -425,6 +459,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1
|
||||
savedSearchService as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
await controller.createSavedSearch(
|
||||
@@ -450,6 +485,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1
|
||||
savedSearchService as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
await controller.updateSavedSearch(
|
||||
@@ -475,6 +511,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1
|
||||
savedSearchService as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
const result = await controller.removeSavedSearch('ss-1', makeFakeRequest('u1'));
|
||||
@@ -496,6 +533,7 @@ describe('TendersController — listTenders uses the query builder (sort whiteli
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
await controller.listTenders({ sort: 'deadline', q: 'Bau' } as any);
|
||||
@@ -525,6 +563,7 @@ describe('TendersController — listTenders uses the query builder (sort whiteli
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
await controller.listTenders({ sort: 'not-whitelisted' } as any);
|
||||
@@ -544,6 +583,7 @@ describe('TendersController — listTenders uses the query builder (sort whiteli
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
await controller.listTenders({ page: 3, limit: 10 } as any);
|
||||
@@ -566,6 +606,7 @@ describe('TendersController — GET /coverage', () => {
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
const result = await controller.getCoverage();
|
||||
@@ -595,6 +636,7 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () =
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
await controller.listTriage('t1, t2 ,t3', makeFakeRequest('u1'));
|
||||
@@ -613,6 +655,7 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () =
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
await controller.listTriage('t1', makeFakeRequest('u-real'));
|
||||
@@ -631,6 +674,7 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () =
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
const manyIds = Array.from({ length: 300 }, (_, i) => `t${i}`).join(',');
|
||||
@@ -653,6 +697,7 @@ describe('TendersController — PUT /triage (upsert, per-user, T-11-10)', () =>
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
await controller.setTriage(
|
||||
@@ -680,6 +725,7 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)',
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1'));
|
||||
@@ -703,6 +749,7 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)',
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1'));
|
||||
@@ -724,6 +771,7 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)',
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
await controller.listTenders({} as any, makeFakeRequest('u1'));
|
||||
@@ -747,6 +795,7 @@ describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', (
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
rssFeedService as any,
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
const result = await controller.listRssFeeds();
|
||||
@@ -768,6 +817,7 @@ describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', (
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
rssFeedService as any,
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
const dto = { url: 'https://service.bund.de/rss.xml', label: 'service-bund' } as any;
|
||||
@@ -790,6 +840,7 @@ describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', (
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
rssFeedService as any,
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
await expect(
|
||||
@@ -811,6 +862,7 @@ describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', (
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
rssFeedService as any,
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
const result = await controller.removeRssFeed('feed-1');
|
||||
@@ -819,3 +871,227 @@ describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', (
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('TendersController — email-config (Plan 14-03, per-tenant, D-06/D-07/T-14-03-05)', () => {
|
||||
it('GET /email-config resolves tenantId from the auth context and delegates to tenderEmailConfig.getConfigForApi(tenantId)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||
const emailConfigService = makeFakeEmailConfigService();
|
||||
emailConfigService.getConfigForApi.mockResolvedValueOnce({
|
||||
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('tenant1');
|
||||
expect(result).toEqual({ tenantId: 'tenant1', protocol: 'imap', hasPassword: true });
|
||||
});
|
||||
|
||||
it('PUT /email-config delegates to tenderEmailConfig.saveConfig with 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('tenant1', 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,9 +20,11 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { UpdateNotificationPrefDto } from './dto/notification-pref.dto';
|
||||
import { CreateSavedSearchDto, UpdateSavedSearchDto } from './dto/saved-search.dto';
|
||||
import { SourceConfigDto } from './dto/source-config.dto';
|
||||
import { TenderEmailConfigDto } from './dto/tender-email-config.dto';
|
||||
import { TenderQueryDto } from './dto/tender-query.dto';
|
||||
import { TenderRssFeedDto } from './dto/tender-rss-feed.dto';
|
||||
import { TenderTriageDto } from './dto/tender-triage.dto';
|
||||
import { TenderEmailConfigService } from './tender-email-config.service';
|
||||
import { TenderNotificationPrefService } from './tender-notification-pref.service';
|
||||
import { TenderRssFeedSourceService } from './tender-rss-feed.service';
|
||||
import { TenderSavedSearchService } from './tender-saved-search.service';
|
||||
@@ -54,6 +56,16 @@ const DOE_SOURCE_TYPE = 'doe-opendata';
|
||||
* (T-10-13) and are NOT gated by @UseModule — an admin configuring the
|
||||
* shared platform-wide poll schedule is a platform-admin action, not a
|
||||
* per-tenant module feature.
|
||||
*
|
||||
* Phase 14, Plan 03 (INGEST-05, D-13): `GET`/`PUT /email-config` are, like
|
||||
* `source-config`/`rss-feeds`, per-handler `@Roles(ADMIN, SUPER_ADMIN)`-
|
||||
* guarded — but UNLIKE those (platform-wide singletons/lists), the email
|
||||
* mailbox config is per-TENANT: tenantId is resolved from the auth context,
|
||||
* never the body (T-14-03-05/IDOR). This is also the plan that breaks the
|
||||
* "GET/GET :id are never row-scoped" invariant above, narrowly: `listTenders`/
|
||||
* `getTender` now resolve the requesting tenant to apply the D-13 OR[global,
|
||||
* mine] visibility filter for PRIVATE (email-alert) tenders only — public
|
||||
* tenders (D-03) remain visible to every tenant exactly as before.
|
||||
*/
|
||||
@Controller('modules/tender-radar')
|
||||
export class TendersController {
|
||||
@@ -64,6 +76,7 @@ export class TendersController {
|
||||
private readonly tenderSavedSearch: TenderSavedSearchService,
|
||||
private readonly tenderNotificationPref: TenderNotificationPrefService,
|
||||
private readonly tenderRssFeedSource: TenderRssFeedSourceService,
|
||||
private readonly tenderEmailConfig: TenderEmailConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -86,6 +99,18 @@ export class TendersController {
|
||||
return { userId, tenantId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Leniently resolves the requesting tenant's id for the D-13 visibility
|
||||
* filter — unlike extractTriageContext, this NEVER throws when the
|
||||
* context is missing: `listTenders`/`getTender` are gated only by
|
||||
* `@UseModule`, not per-user auth, and must degrade to "global tenders
|
||||
* only" (fail-closed, tender-query.builder.ts) rather than 403 when no
|
||||
* tenant context is present.
|
||||
*/
|
||||
private resolveRequestingTenantId(req?: Request): string | undefined {
|
||||
return (req as any)?.user?.tenantId ?? (req as any)?.tenantId;
|
||||
}
|
||||
|
||||
// ─── Global read (ModuleGuard-gated, NOT tenant-scoped) ────────────────────
|
||||
|
||||
/**
|
||||
@@ -123,7 +148,11 @@ export class TendersController {
|
||||
favIds = await this.tenderTriage.favoriteIds(userId);
|
||||
}
|
||||
|
||||
const where = buildTenderWhere(query, favIds);
|
||||
// D-13: resolve the requesting tenant for the private-tender visibility
|
||||
// filter — leniently (never throws); see resolveRequestingTenantId doc.
|
||||
const ownerTenantId = this.resolveRequestingTenantId(req);
|
||||
|
||||
const where = buildTenderWhere(query, favIds, ownerTenantId);
|
||||
const orderBy = buildOrderBy(query.sort);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
@@ -202,6 +231,39 @@ export class TendersController {
|
||||
return this.tenderRssFeedSource.remove(feedId);
|
||||
}
|
||||
|
||||
// ─── E-Mail-Alerts config (Roles-guarded, PER-TENANT, D-06/D-07/D-13) ──────
|
||||
|
||||
/**
|
||||
* GET /modules/tender-radar/email-config — this tenant's portal-alert
|
||||
* mailbox config (safe-select — never the password, T-07-12). Unlike
|
||||
* `source-config`/`rss-feeds` (platform-wide), this is PER-TENANT:
|
||||
* tenantId is resolved from the auth context, never a query/body field
|
||||
* (T-14-03-05 / V4 — IDOR).
|
||||
*
|
||||
* MUST be declared before `@Get(':id')` below — same route-order pitfall
|
||||
* as `source-config`/`coverage`/`triage`/`rss-feeds`/... above (Pitfall 5).
|
||||
*/
|
||||
@Get('email-config')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async getEmailConfig(@Req() req: Request) {
|
||||
const { tenantId } = this.extractTriageContext(req);
|
||||
return this.tenderEmailConfig.getConfigForApi(tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /modules/tender-radar/email-config — upsert this tenant's mailbox
|
||||
* config. tenantId comes exclusively from the auth context — `dto` never
|
||||
* carries a tenantId field (T-14-03-05 / V4 — IDOR). Credential
|
||||
* encrypt-preserve-empty semantics live in TenderEmailConfigService
|
||||
* (mirrors DkvService.saveConfig / T-07-12).
|
||||
*/
|
||||
@Put('email-config')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async saveEmailConfig(@Body() dto: TenderEmailConfigDto, @Req() req: Request) {
|
||||
const { tenantId } = this.extractTriageContext(req);
|
||||
return this.tenderEmailConfig.saveConfig(tenantId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /modules/tender-radar/coverage — distribution of active tenders
|
||||
* by sourcePortal (D-12, UI-05). Feeds the frontend CoverageBanner so a
|
||||
@@ -399,7 +461,7 @@ export class TendersController {
|
||||
*/
|
||||
@Get(':id')
|
||||
@UseModule('tender-radar')
|
||||
async getTender(@Param('id') id: string) {
|
||||
async getTender(@Param('id') id: string, @Req() req?: Request) {
|
||||
const tender = await this.prisma.tender.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
@@ -411,6 +473,18 @@ export class TendersController {
|
||||
if (!tender) {
|
||||
throw new NotFoundException('Tender not found');
|
||||
}
|
||||
|
||||
// D-13: a privately-owned (email-alert) tender is invisible to every
|
||||
// OTHER tenant — surfaced as the SAME NotFoundException as a genuinely
|
||||
// missing id, never a distinct "forbidden" response (no cross-tenant
|
||||
// detail leak, e.g. confirming the id exists at all).
|
||||
if ((tender as { ownerTenantId?: string | null }).ownerTenantId) {
|
||||
const requestingTenantId = this.resolveRequestingTenantId(req);
|
||||
if ((tender as { ownerTenantId?: string | null }).ownerTenantId !== requestingTenantId) {
|
||||
throw new NotFoundException('Tender not found');
|
||||
}
|
||||
}
|
||||
|
||||
return tender;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user