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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user