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
@@ -230,4 +230,92 @@ describe('TenderEmailConfigService', () => {
expect(configG2!.host).toBe('imap.user-g2.test'); expect(configG2!.host).toBe('imap.user-g2.test');
expect(prisma.__store.size).toBe(2); 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();
});
});
}); });
@@ -1,5 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable, Logger, Optional } from '@nestjs/common';
import { CryptoService } from '../crypto/crypto.service'; 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 { PrismaService } from '../prisma/prisma.service';
import type { TenderEmailConfigDto } from './dto/tender-email-config.dto'; import type { TenderEmailConfigDto } from './dto/tender-email-config.dto';
@@ -59,9 +62,21 @@ const EMAIL_CONFIG_SAFE_SELECT = {
*/ */
@Injectable() @Injectable()
export class TenderEmailConfigService { export class TenderEmailConfigService {
private readonly logger = new Logger(TenderEmailConfigService.name);
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly crypto: CryptoService, 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, 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);
}
} }
@@ -136,6 +136,7 @@ function makeFakeEmailConfigService() {
id: 'ec-1', id: 'ec-1',
...dto, ...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); 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 methods = Object.getOwnPropertyNames(TendersController.prototype);
const getIdx = methods.indexOf('getEmailConfig'); const getIdx = methods.indexOf('getEmailConfig');
const saveIdx = methods.indexOf('saveEmailConfig'); const saveIdx = methods.indexOf('saveEmailConfig');
const testIdx = methods.indexOf('testEmailConnection');
const idIdx = methods.indexOf('getTender'); const idIdx = methods.indexOf('getTender');
expect(getIdx).toBeGreaterThanOrEqual(0); expect(getIdx).toBeGreaterThanOrEqual(0);
expect(saveIdx).toBeGreaterThanOrEqual(0); expect(saveIdx).toBeGreaterThanOrEqual(0);
expect(testIdx).toBeGreaterThanOrEqual(0);
expect(idIdx).toBeGreaterThanOrEqual(0); expect(idIdx).toBeGreaterThanOrEqual(0);
expect(getIdx).toBeLessThan(idIdx); expect(getIdx).toBeLessThan(idIdx);
expect(saveIdx).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)', () => { 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(1, 'user-a');
expect(emailConfigService.getConfigForApi).toHaveBeenNthCalledWith(2, 'user-b'); 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)', () => { describe('TendersController — D-13 private (email-alert) tender visibility (Phase 14, Plan 03)', () => {
@@ -361,6 +361,31 @@ export class TendersController {
return this.tenderEmailConfig.saveConfig({ userId, tenantId }, dto); 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 * GET /modules/tender-radar/coverage — distribution of active tenders
* by sourcePortal (D-12, UI-05). Feeds the frontend CoverageBanner so a * by sourcePortal (D-12, UI-05). Feeds the frontend CoverageBanner so a