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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
This commit is contained in:
2026-09-07 15:40:28 +02:00
parent 5da58ad182
commit 3bf550bc65
4 changed files with 219 additions and 2 deletions
@@ -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);
}
}