From 3bf550bc65dd2a2a0c114f21e4e898e8bb0f176d Mon Sep 17 00:00:00 2001 From: Schalli Date: Mon, 7 Sep 2026 15:40:28 +0200 Subject: [PATCH] feat(quick-260907-let): Verbindungstest fuer Postfach-Endpunkt im API - 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) Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU --- .../tender-email-config.service.spec.ts | 88 +++++++++++++++++++ .../tenders/tender-email-config.service.ts | 78 +++++++++++++++- .../src/tenders/tenders.controller.spec.ts | 30 ++++++- apps/api/src/tenders/tenders.controller.ts | 25 ++++++ 4 files changed, 219 insertions(+), 2 deletions(-) diff --git a/apps/api/src/tenders/tender-email-config.service.spec.ts b/apps/api/src/tenders/tender-email-config.service.spec.ts index a37cf70..1e62c94 100644 --- a/apps/api/src/tenders/tender-email-config.service.spec.ts +++ b/apps/api/src/tenders/tender-email-config.service.spec.ts @@ -230,4 +230,92 @@ describe('TenderEmailConfigService', () => { expect(configG2!.host).toBe('imap.user-g2.test'); expect(prisma.__store.size).toBe(2); }); + + describe('testConnection (Quick 260907-let, WINDOWS #16)', () => { + function makeFakeProviders() { + return { + imapProvider: { testConnection: vi.fn(async () => ({ success: true })) }, + exchangeProvider: { testConnection: vi.fn(async () => ({ success: true })) }, + }; + } + + it('a typed-in password is passed through to the provider unchanged, without reading the database', async () => { + const prisma = makeFakePrisma(); + const crypto = makeFakeCrypto(); + const { imapProvider, exchangeProvider } = makeFakeProviders(); + const service = new TenderEmailConfigService( + prisma as any, + crypto as any, + imapProvider as any, + exchangeProvider as any, + ); + + const result = await service.testConnection('user-h', { + protocol: 'imap', + encryption: 'ssl-tls', + host: 'imap.example.test', + username: 'typed-user', + password: 'typed-secret', + } as any); + + expect(prisma.tenderEmailConfig.findUnique).not.toHaveBeenCalled(); + expect(imapProvider.testConnection).toHaveBeenCalledWith( + expect.objectContaining({ username: 'typed-user', password: 'typed-secret' }), + ); + expect(result).toEqual({ success: true }); + }); + + it('an empty password falls back to this same user\'s stored, decrypted credentials', async () => { + const prisma = makeFakePrisma(); + const crypto = makeFakeCrypto(); + const { imapProvider, exchangeProvider } = makeFakeProviders(); + const service = new TenderEmailConfigService( + prisma as any, + crypto as any, + imapProvider as any, + exchangeProvider as any, + ); + + await service.saveConfig( + { userId: 'user-i', tenantId: 'tenant-i' }, + { + protocol: 'imap', + encryption: 'ssl-tls', + username: 'stored-user', + password: 'stored-secret', + } as any, + ); + + await service.testConnection('user-i', { + protocol: 'imap', + encryption: 'ssl-tls', + } as any); + + expect(imapProvider.testConnection).toHaveBeenCalledWith( + expect.objectContaining({ username: 'stored-user', password: 'stored-secret' }), + ); + }); + + it("dto.protocol 'exchange' selects the Exchange provider, not IMAP", async () => { + const prisma = makeFakePrisma(); + const crypto = makeFakeCrypto(); + const { imapProvider, exchangeProvider } = makeFakeProviders(); + const service = new TenderEmailConfigService( + prisma as any, + crypto as any, + imapProvider as any, + exchangeProvider as any, + ); + + await service.testConnection('user-j', { + protocol: 'exchange', + encryption: 'ssl-tls', + username: 'ews-user', + password: 'ews-secret', + } as any); + + expect(exchangeProvider.testConnection).toHaveBeenCalledTimes(1); + expect(imapProvider.testConnection).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/api/src/tenders/tender-email-config.service.ts b/apps/api/src/tenders/tender-email-config.service.ts index bc17c24..7738034 100644 --- a/apps/api/src/tenders/tender-email-config.service.ts +++ b/apps/api/src/tenders/tender-email-config.service.ts @@ -1,5 +1,8 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger, Optional } from '@nestjs/common'; import { CryptoService } from '../crypto/crypto.service'; +import { ExchangeInboxProvider } from '../inbox/exchange-inbox.provider'; +import { ImapProvider } from '../inbox/imap.provider'; +import type { InboxConfig, InboxProvider } from '../inbox/inbox-provider.interface'; import { PrismaService } from '../prisma/prisma.service'; import type { TenderEmailConfigDto } from './dto/tender-email-config.dto'; @@ -59,9 +62,21 @@ const EMAIL_CONFIG_SAFE_SELECT = { */ @Injectable() export class TenderEmailConfigService { + private readonly logger = new Logger(TenderEmailConfigService.name); + constructor( private readonly prisma: PrismaService, private readonly crypto: CryptoService, + /** + * Appended as OPTIONAL trailing constructor params (Quick 260907-let) — + * preserves every existing `new TenderEmailConfigService(prisma, + * crypto)` 2-arg call site in the spec unchanged. NestJS DI always + * resolves and injects both in production (InboxModule is imported in + * tenders.module.ts and exports both providers) — same pattern as + * TendersController.tenderIngestionService (tenders.controller.ts). + */ + @Optional() private readonly imapProvider?: ImapProvider, + @Optional() private readonly exchangeProvider?: ExchangeInboxProvider, ) {} /** @@ -164,4 +179,65 @@ export class TenderEmailConfigService { select: EMAIL_CONFIG_SAFE_SELECT, }); } + + /** + * Tests the inbox connection using credentials from the form DTO, + * WITHOUT persisting anything (structural clone of + * DkvService.testConnection, dkv.service.ts:201). + * + * Credential fallback (T-QT16-03): a DTO field left empty falls back to + * this SAME user's stored, decrypted credentials — never another user's + * row, since `where: { userId }` is the only lookup key. Unlike + * saveConfig's credChanged branching, BOTH username and password are + * independently backfilled here (saveConfig only ever needs to backfill + * the one field the caller didn't touch on a partial re-save; a + * connection test with a fully blank form needs both). + * + * T-05-13: decrypted credentials exist only within this method's local + * scope — never returned, never logged beyond the userId itself. + */ + async testConnection( + userId: string, + dto: TenderEmailConfigDto, + ): Promise<{ success: boolean; message?: string }> { + let username: string | undefined = dto.username; + let password: string | undefined = dto.password; + + if (!username || !password) { + try { + const existing = await this.prisma.tenderEmailConfig.findUnique({ where: { userId } }); + if (existing?.encryptedInboxCreds) { + const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as { + username?: string; + password?: string; + }; + if (!username) username = stored.username; + if (!password) password = stored.password; + } + } catch { + // T-05-13: generic — no credential details, only the userId + this.logger.error(`testConnection: failed to load stored credentials for user ${userId}`); + } + } + + const inboxConfig: InboxConfig = { + protocol: dto.protocol, + host: dto.host ?? '', + port: dto.port ?? 993, + username, + password, + encryption: dto.encryption, + folder: dto.folder ?? 'INBOX', + senderFilter: dto.senderFilter, + domain: dto.domain, + }; + + const provider: InboxProvider | undefined = + dto.protocol === 'exchange' ? this.exchangeProvider : this.imapProvider; + if (!provider) { + return { success: false, message: 'Inbox-Anbieter nicht verfuegbar' }; + } + + return provider.testConnection(inboxConfig); + } } diff --git a/apps/api/src/tenders/tenders.controller.spec.ts b/apps/api/src/tenders/tenders.controller.spec.ts index 1f75c9f..59d0db5 100644 --- a/apps/api/src/tenders/tenders.controller.spec.ts +++ b/apps/api/src/tenders/tenders.controller.spec.ts @@ -136,6 +136,7 @@ function makeFakeEmailConfigService() { id: 'ec-1', ...dto, })), + testConnection: vi.fn(async (_userId: string, _dto: any) => ({ success: true }) as any), }; } @@ -408,17 +409,20 @@ 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)', () => { + 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)', () => { @@ -1129,6 +1133,30 @@ describe('TendersController — email-config (Plan 14-03, per-user since Phase 1 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)', () => { diff --git a/apps/api/src/tenders/tenders.controller.ts b/apps/api/src/tenders/tenders.controller.ts index dae3f5e..07a0190 100644 --- a/apps/api/src/tenders/tenders.controller.ts +++ b/apps/api/src/tenders/tenders.controller.ts @@ -361,6 +361,31 @@ export class TendersController { return this.tenderEmailConfig.saveConfig({ userId, tenantId }, dto); } + /** + * POST /modules/tender-radar/email-config/test — tests the connection to + * the requesting USER's own portal-alert mailbox WITHOUT saving anything + * (Quick 260907-let, WINDOWS #16). `userId` comes exclusively from the + * auth context, exactly like `getEmailConfig`/`saveEmailConfig` above — + * `dto` carries no ownership field at all, so a body value under any key + * can never redirect the test at a different user's mailbox + * (T-14-03-05 / T-17-01 / T-QT16-01 — IDOR). + * + * Declared directly after `saveEmailConfig`, keeping the whole + * email-config block together, and — like every other handler in this + * block — placed before `@Get(':id')` below. NestJS actually resolves + * routes per HTTP verb, so a `GET :id` placeholder could never shadow + * this `POST` route today; the ordering here is defensive consistency + * with the surrounding email-config handlers (and the guard test below), + * not a live 404 risk, in case a `POST :id`-shaped placeholder is ever + * added to this controller in the future. + */ + @Post('email-config/test') + @UseModule('tender-radar') + async testEmailConnection(@Body() dto: TenderEmailConfigDto, @Req() req: Request) { + const { userId } = this.extractTriageContext(req); + return this.tenderEmailConfig.testConnection(userId, dto); + } + /** * GET /modules/tender-radar/coverage — distribution of active tenders * by sourcePortal (D-12, UI-05). Feeds the frontend CoverageBanner so a