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(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
|
||||||
|
|||||||
Reference in New Issue
Block a user