feat(17-01): move TenderEmailConfig ownership from tenant to user
Alert-Postfach gehoert jetzt dem einzelnen Nutzer (userId @unique) statt
dem Mandanten (D-01) — ein zweiter Kollege desselben Mandanten kann sein
eigenes Postfach anbinden. tenantId bleibt denormalisiert (SMTP-Aufloesung,
Herkunftsmarkierung), wird auf create UND update mitgeschrieben.
- Handgeschriebene Migration (prisma migrate dev verweigert die
nicht-interaktive Shell): befuellt Bestandszeilen mit dem aeltesten
aktiven Administrator ihres Mandanten, entfernt verwaiste Zeilen ohne
Administrator, ersetzt die tenantId-Eindeutigkeit durch userId.
Lokal getestet (0 Bestandszeilen lokal und auf alpha — Zaehlung im
Task-1-Checkpoint), Index-Ergebnis verifiziert.
- TenderEmailConfigService.getConfigForApi/saveConfig auf userId als
Schluessel umgestellt; saveConfig nimmt {userId, tenantId}.
- TendersController: email-config-Routen von @Roles(ADMIN,SUPER_ADMIN)
auf @UseModule('tender-radar') umgestellt (Postfach ist jetzt
Nutzereinstellung); Route-Reihenfolge vor @Get(':id') unveraendert.
- Neue Seite /modules/tender-radar/my-sources ("Meine Quellen") mit dem
unveraenderten EmailAlertConfigForm; Hinweistext benennt D-05 (Tender
bleibt plattform-global — nur wer Quellen einspeist aendert sich).
- tenders.controller.spec.ts an neue Service-Signatur angepasst (Rule 3,
nicht im Plan gelistet, aber zum Kompilieren/Bestehen erforderlich).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,12 @@ import { TenderEmailConfigService } from './tender-email-config.service';
|
||||
* (deterministic reversible encode, NOT real AES) — same convention as
|
||||
* tender-dedup.service.spec.ts: no live DB/crypto dependency, just proving
|
||||
* this service's own encrypt-preserve-empty / safe-select contract.
|
||||
*
|
||||
* Phase 17, Plan 01 (D-01): ownership moved from tenantId to userId — the
|
||||
* fake prisma below is now keyed by userId (matches the real
|
||||
* `where: { userId }` upsert target), and two new cases prove the actual
|
||||
* new capability: two users of the SAME tenant get two independent rows,
|
||||
* and tenantId is written on create (denormalized, D-01).
|
||||
*/
|
||||
|
||||
function makeFakeCrypto() {
|
||||
@@ -24,7 +30,7 @@ function makeFakePrisma() {
|
||||
return {
|
||||
tenderEmailConfig: {
|
||||
findUnique: vi.fn(async ({ where, select }: any) => {
|
||||
const row = configs.get(where.tenantId);
|
||||
const row = configs.get(where.userId);
|
||||
if (!row) return null;
|
||||
if (!select) return row;
|
||||
const out: any = {};
|
||||
@@ -32,9 +38,9 @@ function makeFakePrisma() {
|
||||
return out;
|
||||
}),
|
||||
upsert: vi.fn(async ({ where, update, create, select }: any) => {
|
||||
const existing = configs.get(where.tenantId);
|
||||
const row = existing ? { ...existing, ...update } : { id: 'cfg-1', ...create };
|
||||
configs.set(where.tenantId, row);
|
||||
const existing = configs.get(where.userId);
|
||||
const row = existing ? { ...existing, ...update } : { id: `cfg-${configs.size + 1}`, ...create };
|
||||
configs.set(where.userId, row);
|
||||
if (!select) return row;
|
||||
const out: any = {};
|
||||
for (const k of Object.keys(select)) out[k] = row[k];
|
||||
@@ -46,12 +52,12 @@ function makeFakePrisma() {
|
||||
}
|
||||
|
||||
describe('TenderEmailConfigService', () => {
|
||||
it('getConfigForApi returns null when no config exists for the tenant', async () => {
|
||||
it('getConfigForApi returns null when no config exists for the user', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const crypto = makeFakeCrypto();
|
||||
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||
|
||||
const result = await service.getConfigForApi('tenant-missing');
|
||||
const result = await service.getConfigForApi('user-missing');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@@ -61,18 +67,21 @@ describe('TenderEmailConfigService', () => {
|
||||
const crypto = makeFakeCrypto();
|
||||
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||
|
||||
await service.saveConfig('tenant-a', {
|
||||
protocol: 'imap',
|
||||
encryption: 'ssl-tls',
|
||||
host: 'imap.example.test',
|
||||
port: 993,
|
||||
folder: 'INBOX',
|
||||
username: 'alerts@example.test',
|
||||
password: 'super-secret',
|
||||
isActive: true,
|
||||
} as any);
|
||||
await service.saveConfig(
|
||||
{ userId: 'user-a', tenantId: 'tenant-a' },
|
||||
{
|
||||
protocol: 'imap',
|
||||
encryption: 'ssl-tls',
|
||||
host: 'imap.example.test',
|
||||
port: 993,
|
||||
folder: 'INBOX',
|
||||
username: 'alerts@example.test',
|
||||
password: 'super-secret',
|
||||
isActive: true,
|
||||
} as any,
|
||||
);
|
||||
|
||||
const apiResult = await service.getConfigForApi('tenant-a');
|
||||
const apiResult = await service.getConfigForApi('user-a');
|
||||
|
||||
expect(apiResult).not.toBeNull();
|
||||
expect(apiResult).not.toHaveProperty('password');
|
||||
@@ -86,16 +95,19 @@ describe('TenderEmailConfigService', () => {
|
||||
const crypto = makeFakeCrypto();
|
||||
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||
|
||||
await service.saveConfig('tenant-b', {
|
||||
protocol: 'imap',
|
||||
encryption: 'ssl-tls',
|
||||
host: 'imap.example.test',
|
||||
port: 993,
|
||||
folder: 'INBOX',
|
||||
isActive: false,
|
||||
} as any);
|
||||
await service.saveConfig(
|
||||
{ userId: 'user-b', tenantId: 'tenant-b' },
|
||||
{
|
||||
protocol: 'imap',
|
||||
encryption: 'ssl-tls',
|
||||
host: 'imap.example.test',
|
||||
port: 993,
|
||||
folder: 'INBOX',
|
||||
isActive: false,
|
||||
} as any,
|
||||
);
|
||||
|
||||
const apiResult = await service.getConfigForApi('tenant-b');
|
||||
const apiResult = await service.getConfigForApi('user-b');
|
||||
|
||||
expect(apiResult!.hasPassword).toBe(false);
|
||||
expect(apiResult!.username).toBeNull();
|
||||
@@ -107,21 +119,27 @@ describe('TenderEmailConfigService', () => {
|
||||
const crypto = makeFakeCrypto();
|
||||
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||
|
||||
await service.saveConfig('tenant-c', {
|
||||
protocol: 'imap',
|
||||
encryption: 'ssl-tls',
|
||||
username: 'old@example.test',
|
||||
password: 'original-secret',
|
||||
} as any);
|
||||
await service.saveConfig(
|
||||
{ userId: 'user-c', tenantId: 'tenant-c' },
|
||||
{
|
||||
protocol: 'imap',
|
||||
encryption: 'ssl-tls',
|
||||
username: 'old@example.test',
|
||||
password: 'original-secret',
|
||||
} as any,
|
||||
);
|
||||
|
||||
// Re-save with a new username, password left blank (T-07-12 UI convention)
|
||||
await service.saveConfig('tenant-c', {
|
||||
protocol: 'imap',
|
||||
encryption: 'ssl-tls',
|
||||
username: 'new@example.test',
|
||||
} as any);
|
||||
await service.saveConfig(
|
||||
{ userId: 'user-c', tenantId: 'tenant-c' },
|
||||
{
|
||||
protocol: 'imap',
|
||||
encryption: 'ssl-tls',
|
||||
username: 'new@example.test',
|
||||
} as any,
|
||||
);
|
||||
|
||||
const raw = prisma.__store.get('tenant-c');
|
||||
const raw = prisma.__store.get('user-c');
|
||||
const decrypted = JSON.parse(crypto.decrypt(raw.encryptedInboxCreds));
|
||||
expect(decrypted.username).toBe('new@example.test');
|
||||
expect(decrypted.password).toBe('original-secret');
|
||||
@@ -132,20 +150,26 @@ describe('TenderEmailConfigService', () => {
|
||||
const crypto = makeFakeCrypto();
|
||||
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||
|
||||
await service.saveConfig('tenant-d', {
|
||||
protocol: 'imap',
|
||||
encryption: 'ssl-tls',
|
||||
username: 'stable@example.test',
|
||||
password: 'first-secret',
|
||||
} as any);
|
||||
await service.saveConfig(
|
||||
{ userId: 'user-d', tenantId: 'tenant-d' },
|
||||
{
|
||||
protocol: 'imap',
|
||||
encryption: 'ssl-tls',
|
||||
username: 'stable@example.test',
|
||||
password: 'first-secret',
|
||||
} as any,
|
||||
);
|
||||
|
||||
await service.saveConfig('tenant-d', {
|
||||
protocol: 'imap',
|
||||
encryption: 'ssl-tls',
|
||||
password: 'rotated-secret',
|
||||
} as any);
|
||||
await service.saveConfig(
|
||||
{ userId: 'user-d', tenantId: 'tenant-d' },
|
||||
{
|
||||
protocol: 'imap',
|
||||
encryption: 'ssl-tls',
|
||||
password: 'rotated-secret',
|
||||
} as any,
|
||||
);
|
||||
|
||||
const raw = prisma.__store.get('tenant-d');
|
||||
const raw = prisma.__store.get('user-d');
|
||||
const decrypted = JSON.parse(crypto.decrypt(raw.encryptedInboxCreds));
|
||||
expect(decrypted.username).toBe('stable@example.test');
|
||||
expect(decrypted.password).toBe('rotated-secret');
|
||||
@@ -156,14 +180,54 @@ describe('TenderEmailConfigService', () => {
|
||||
const crypto = makeFakeCrypto();
|
||||
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||
|
||||
const result = await service.saveConfig('tenant-e', {
|
||||
protocol: 'imap',
|
||||
encryption: 'ssl-tls',
|
||||
username: 'x@example.test',
|
||||
password: 'y',
|
||||
} as any);
|
||||
const result = await service.saveConfig(
|
||||
{ userId: 'user-e', tenantId: 'tenant-e' },
|
||||
{
|
||||
protocol: 'imap',
|
||||
encryption: 'ssl-tls',
|
||||
username: 'x@example.test',
|
||||
password: 'y',
|
||||
} as any,
|
||||
);
|
||||
|
||||
expect(result).not.toHaveProperty('encryptedInboxCreds');
|
||||
expect(result).not.toHaveProperty('password');
|
||||
});
|
||||
|
||||
it('saveConfig writes BOTH userId and tenantId on create (Phase 17, D-01: tenantId stays denormalized)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const crypto = makeFakeCrypto();
|
||||
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||
|
||||
await service.saveConfig(
|
||||
{ userId: 'user-f', tenantId: 'tenant-f' },
|
||||
{ protocol: 'imap', encryption: 'ssl-tls' } as any,
|
||||
);
|
||||
|
||||
const raw = prisma.__store.get('user-f');
|
||||
expect(raw.userId).toBe('user-f');
|
||||
expect(raw.tenantId).toBe('tenant-f');
|
||||
});
|
||||
|
||||
it('two users of the SAME tenant each get their own row — the second save never overwrites the first (Phase 17, D-01)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const crypto = makeFakeCrypto();
|
||||
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||
|
||||
await service.saveConfig(
|
||||
{ userId: 'user-g1', tenantId: 'tenant-shared' },
|
||||
{ protocol: 'imap', encryption: 'ssl-tls', host: 'imap.user-g1.test' } as any,
|
||||
);
|
||||
await service.saveConfig(
|
||||
{ userId: 'user-g2', tenantId: 'tenant-shared' },
|
||||
{ protocol: 'imap', encryption: 'ssl-tls', host: 'imap.user-g2.test' } as any,
|
||||
);
|
||||
|
||||
const configG1 = await service.getConfigForApi('user-g1');
|
||||
const configG2 = await service.getConfigForApi('user-g2');
|
||||
|
||||
expect(configG1!.host).toBe('imap.user-g1.test');
|
||||
expect(configG2!.host).toBe('imap.user-g2.test');
|
||||
expect(prisma.__store.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { TenderEmailConfigDto } from './dto/tender-email-config.dto';
|
||||
*/
|
||||
const EMAIL_CONFIG_SAFE_SELECT = {
|
||||
id: true,
|
||||
userId: true,
|
||||
tenantId: true,
|
||||
protocol: true,
|
||||
host: true,
|
||||
@@ -25,25 +26,36 @@ const EMAIL_CONFIG_SAFE_SELECT = {
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* TenderEmailConfigService — per-tenant admin CRUD for the portal-alert
|
||||
* mailbox config (Phase 14, Plan 03, INGEST-05/CONFIG-02, D-06/D-07).
|
||||
* Structural clone of DkvService's config half (safe-select + encrypt-
|
||||
* preserve-empty semantics), mirroring the exact same pattern already
|
||||
* proven for DKV's own (separate, D-03) mailbox config.
|
||||
* TenderEmailConfigService — per-USER CRUD for the portal-alert mailbox
|
||||
* config (Phase 14, Plan 03, INGEST-05/CONFIG-02, D-06/D-07; ownership
|
||||
* moved from tenant to user in Phase 17, Plan 01, D-01). Structural clone
|
||||
* of DkvService's config half (safe-select + encrypt-preserve-empty
|
||||
* semantics), mirroring the exact same pattern already proven for DKV's
|
||||
* own (separate, D-03) mailbox config.
|
||||
*
|
||||
* Ownership (Phase 17, D-01): a mailbox belongs to exactly one user
|
||||
* (`userId @unique`) — two users at the same tenant each connect their own
|
||||
* inbox independently, neither can read or overwrite the other's config.
|
||||
* `tenantId` stays denormalized on every row (SMTP resolution, same role as
|
||||
* `tenantId` on TenderMatch) and is written on both create and update,
|
||||
* since a user's tenant can in principle change.
|
||||
*
|
||||
* Security:
|
||||
* - T-07-12: encryptedInboxCreds is excluded from every read-path select;
|
||||
* getConfigForApi returns `hasPassword: boolean` instead of the password.
|
||||
* - T-05-13: decrypted credentials only ever exist within a method's local
|
||||
* scope — never logged.
|
||||
* - T-17-01: userId/tenantId are supplied by the caller from the auth
|
||||
* context (TendersController.extractTriageContext) — this service never
|
||||
* derives ownership from anything the DTO carries.
|
||||
*
|
||||
* This service is used ONLY by the admin GET/PUT /email-config routes
|
||||
* (TendersController). EmailAlertAdapter's own per-tenant poll-time fan-out
|
||||
* decrypts credentials independently via a direct CryptoService
|
||||
* injection (RESEARCH.md Pattern 1) — it does NOT go through this service,
|
||||
* since the adapter's cross-tenant `findMany({where:{isActive:true}})` read
|
||||
* is a deliberate platform-scheduler exception (see EmailAlertAdapter's
|
||||
* docstring), structurally different from this service's tenant-scoped CRUD.
|
||||
* This service is used ONLY by the GET/PUT /email-config routes
|
||||
* (TendersController). EmailAlertAdapter's own poll-time fan-out decrypts
|
||||
* credentials independently via a direct CryptoService injection
|
||||
* (RESEARCH.md Pattern 1) — it does NOT go through this service, since the
|
||||
* adapter's cross-tenant `findMany({where:{isActive:true}})` read is a
|
||||
* deliberate platform-scheduler exception (see EmailAlertAdapter's
|
||||
* docstring), structurally different from this service's per-user CRUD.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TenderEmailConfigService {
|
||||
@@ -54,11 +66,12 @@ export class TenderEmailConfigService {
|
||||
|
||||
/**
|
||||
* Load config for API response: safe fields + decrypted username +
|
||||
* hasPassword flag. T-07-12: password is NEVER returned.
|
||||
* hasPassword flag. T-07-12: password is NEVER returned. Scoped strictly
|
||||
* by userId (T-17-01) — a user only ever reads their own mailbox.
|
||||
*/
|
||||
async getConfigForApi(tenantId: string) {
|
||||
async getConfigForApi(userId: string) {
|
||||
const safe = await this.prisma.tenderEmailConfig.findUnique({
|
||||
where: { tenantId },
|
||||
where: { userId },
|
||||
select: EMAIL_CONFIG_SAFE_SELECT,
|
||||
});
|
||||
if (!safe) return null;
|
||||
@@ -66,7 +79,7 @@ export class TenderEmailConfigService {
|
||||
let username: string | null = null;
|
||||
let hasPassword = false;
|
||||
try {
|
||||
const raw = await this.prisma.tenderEmailConfig.findUnique({ where: { tenantId } });
|
||||
const raw = await this.prisma.tenderEmailConfig.findUnique({ where: { userId } });
|
||||
if (raw?.encryptedInboxCreds) {
|
||||
const creds = JSON.parse(this.crypto.decrypt(raw.encryptedInboxCreds)) as {
|
||||
username?: string;
|
||||
@@ -83,7 +96,7 @@ export class TenderEmailConfigService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert TenderEmailConfig for a tenant.
|
||||
* Upsert TenderEmailConfig for a user.
|
||||
*
|
||||
* Credential handling (identical semantics to DkvService.saveConfig):
|
||||
* - dto.password non-empty: re-encrypt {username, password} together.
|
||||
@@ -91,10 +104,15 @@ export class TenderEmailConfigService {
|
||||
* password, re-encrypt with the new username.
|
||||
* - both empty/undefined: preserve existing encryptedInboxCreds entirely.
|
||||
*
|
||||
* tenantId is written on both create AND update (Phase 17, D-01): it is
|
||||
* denormalized, so if the resolved tenant for this user ever changes the
|
||||
* stored value must follow.
|
||||
*
|
||||
* T-07-12: Returns safe select (no encryptedInboxCreds).
|
||||
* T-05-13: Never logs decrypted credentials.
|
||||
*/
|
||||
async saveConfig(tenantId: string, dto: TenderEmailConfigDto) {
|
||||
async saveConfig(ctx: { userId: string; tenantId: string }, dto: TenderEmailConfigDto) {
|
||||
const { userId, tenantId } = ctx;
|
||||
let encryptedInboxCreds: string | undefined;
|
||||
|
||||
const credChanged =
|
||||
@@ -107,7 +125,7 @@ export class TenderEmailConfigService {
|
||||
|
||||
if (!dto.password || !dto.username) {
|
||||
try {
|
||||
const existing = await this.prisma.tenderEmailConfig.findUnique({ where: { tenantId } });
|
||||
const existing = await this.prisma.tenderEmailConfig.findUnique({ where: { userId } });
|
||||
if (existing?.encryptedInboxCreds) {
|
||||
const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as {
|
||||
username?: string;
|
||||
@@ -136,10 +154,13 @@ export class TenderEmailConfigService {
|
||||
...(encryptedInboxCreds !== undefined && { encryptedInboxCreds }),
|
||||
};
|
||||
|
||||
// tenantId is written on BOTH create and update — it is denormalized
|
||||
// (Phase 17, D-01), so a user's resolved tenant must always overwrite
|
||||
// whatever was stored previously, not just be set once on create.
|
||||
return this.prisma.tenderEmailConfig.upsert({
|
||||
where: { tenantId },
|
||||
create: { tenantId, ...data },
|
||||
update: data,
|
||||
where: { userId },
|
||||
create: { userId, tenantId, ...data },
|
||||
update: { tenantId, ...data },
|
||||
select: EMAIL_CONFIG_SAFE_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -104,13 +104,17 @@ 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.
|
||||
* 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 (_tenantId: string) => null as any),
|
||||
saveConfig: vi.fn(async (_tenantId: string, dto: any) => ({ id: 'ec-1', ...dto })),
|
||||
getConfigForApi: vi.fn(async (_userId: string) => null as any),
|
||||
saveConfig: vi.fn(async (_ctx: { userId: string; tenantId: string }, dto: any) => ({
|
||||
id: 'ec-1',
|
||||
...dto,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -959,12 +963,13 @@ describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', (
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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,
|
||||
@@ -981,11 +986,16 @@ describe('TendersController — email-config (Plan 14-03, per-tenant, D-06/D-07/
|
||||
|
||||
const result = await controller.getEmailConfig(makeFakeRequest('u1', 'tenant1'));
|
||||
|
||||
expect(emailConfigService.getConfigForApi).toHaveBeenCalledWith('tenant1');
|
||||
expect(result).toEqual({ tenantId: 'tenant1', protocol: 'imap', hasPassword: true });
|
||||
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 tenantId from the auth context, never the body', async () => {
|
||||
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();
|
||||
@@ -1002,7 +1012,31 @@ describe('TendersController — email-config (Plan 14-03, per-tenant, D-06/D-07/
|
||||
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);
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -59,15 +59,17 @@ const DOE_SOURCE_TYPE = 'doe-opendata';
|
||||
* 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.
|
||||
* Phase 14, Plan 03 (INGEST-05, D-13) originally made `GET`/`PUT
|
||||
* /email-config` per-handler `@Roles(ADMIN, SUPER_ADMIN)`-guarded and
|
||||
* per-TENANT. Phase 17, Plan 01 (D-01) changed this: the mailbox is now
|
||||
* per-USER — every user with module access connects their own inbox, so
|
||||
* these two routes are `@UseModule('tender-radar')`-gated like the other
|
||||
* per-user routes below, and `userId` (not `tenantId`) is the ownership
|
||||
* key, resolved from the auth context, never the body (T-14-03-05/T-17-01/
|
||||
* IDOR). `listTenders`/`getTender` still resolve the requesting tenant to
|
||||
* apply the D-13 OR[global, mine] visibility filter for PRIVATE
|
||||
* (email-alert) tenders — public tenders (D-03) remain visible to every
|
||||
* tenant exactly as before; that part of D-13 is unaffected by D-01.
|
||||
*/
|
||||
@Controller('modules/tender-radar')
|
||||
export class TendersController {
|
||||
@@ -268,37 +270,40 @@ export class TendersController {
|
||||
return this.tenderRssFeedSource.remove(feedId);
|
||||
}
|
||||
|
||||
// ─── E-Mail-Alerts config (Roles-guarded, PER-TENANT, D-06/D-07/D-13) ──────
|
||||
// ─── E-Mail-Alerts config (ModuleGuard-gated, PER-USER, Phase 17 D-01) ─────
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* GET /modules/tender-radar/email-config — the requesting USER's own
|
||||
* portal-alert mailbox config (safe-select — never the password,
|
||||
* T-07-12). Phase 17 (D-01): ownership moved from tenant to user — every
|
||||
* user with module access manages their own mailbox, so the Roles guard
|
||||
* (previously ADMIN/SUPER_ADMIN only) is replaced with `@UseModule`, the
|
||||
* same access gate as every other per-user route below. `userId` comes
|
||||
* exclusively from the auth context, never a query/body field
|
||||
* (T-14-03-05 / T-17-01 / 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)
|
||||
@UseModule('tender-radar')
|
||||
async getEmailConfig(@Req() req: Request) {
|
||||
const { tenantId } = this.extractTriageContext(req);
|
||||
return this.tenderEmailConfig.getConfigForApi(tenantId);
|
||||
const { userId } = this.extractTriageContext(req);
|
||||
return this.tenderEmailConfig.getConfigForApi(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 /modules/tender-radar/email-config — upsert the requesting USER's
|
||||
* own mailbox config. userId/tenantId come exclusively from the auth
|
||||
* context — `dto` never carries either field (T-14-03-05 / T-17-01 / 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)
|
||||
@UseModule('tender-radar')
|
||||
async saveEmailConfig(@Body() dto: TenderEmailConfigDto, @Req() req: Request) {
|
||||
const { tenantId } = this.extractTriageContext(req);
|
||||
return this.tenderEmailConfig.saveConfig(tenantId, dto);
|
||||
const { userId, tenantId } = this.extractTriageContext(req);
|
||||
return this.tenderEmailConfig.saveConfig({ userId, tenantId }, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user