feat(14-03): add per-tenant encrypted TenderEmailConfig + ownerTenantId write-side (D-13)

Prisma: new TenderEmailConfig model (per-tenant, tenantId @unique, mirrors
DkvModuleConfig) + Tender.ownerTenantId nullable column + index (D-13:
null = global/platform-wide, unchanged for all existing rows and every
public source; set = visible only to that tenant). Migration
20260723113917_tender_email_config_owner_tenant_id applied locally.

TenderEmailConfigService: safe-select admin CRUD (GET never returns the
password, only hasPassword — T-07-12) with DkvService's encrypt-preserve-
empty semantics, via CalendarCryptoService (AES-256-GCM).

RawTenderRecord/NormalizedTenderFields gain optional ownerTenantId,
threaded through TenderNormalizerService.assemble() unchanged.
TenderDedupService's CREATE branch writes ownerTenantId (defaulting to
null); the UPDATE branch deliberately never references it, so a tender
later also seen on a public source is never retroactively hidden.

EmailAlertAdapter.fetchTenders() now does the real per-tenant fan-out:
findMany({isActive:true}) across ALL tenants (deliberate, documented
cross-tenant platform-scheduler read, never forTenant()/RLS), decrypts
each tenant's credentials, picks imap/exchange provider, and tags every
extracted candidate with ownerTenantId — catch-per-tenant so one broken
mailbox never blocks the others.

tenders.module.ts: imports CalendarModule/InboxModule, registers
EmailAlertAdapter + TenderEmailConfigService, seeds an 'email-alert'
TenderSourcePollConfig row (pollGranularity='tick', isActive=false —
no default mailbox to activate yet, D-02 framework-ready stance).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 13:45:11 +02:00
parent 8983231196
commit 1be6b15249
12 changed files with 994 additions and 21 deletions
@@ -0,0 +1,169 @@
import { describe, expect, it, vi } from 'vitest';
import { TenderEmailConfigService } from './tender-email-config.service';
/**
* TenderEmailConfigService.spec — Phase 14, Plan 03 (CONFIG-02, D-06/D-07).
* Hand-rolled fake PrismaService (Map) + a fake CalendarCryptoService
* (deterministic reversible encode, NOT real AES) — same convention as
* tender-dedup.service.spec.ts: no live DB/crypto dependency, just proving
* this service's own encrypt-preserve-empty / safe-select contract.
*/
function makeFakeCrypto() {
return {
encrypt: vi.fn((plaintext: string) => `enc:${Buffer.from(plaintext).toString('base64')}`),
decrypt: vi.fn((stored: string) => {
if (!stored.startsWith('enc:')) throw new Error('Invalid encrypted value format');
return Buffer.from(stored.slice(4), 'base64').toString('utf8');
}),
};
}
function makeFakePrisma() {
const configs = new Map<string, any>();
return {
tenderEmailConfig: {
findUnique: vi.fn(async ({ where, select }: any) => {
const row = configs.get(where.tenantId);
if (!row) return null;
if (!select) return row;
const out: any = {};
for (const k of Object.keys(select)) out[k] = row[k];
return out;
}),
upsert: vi.fn(async ({ where, update, create, select }: any) => {
const existing = configs.get(where.tenantId);
const row = existing ? { ...existing, ...update } : { id: 'cfg-1', ...create };
configs.set(where.tenantId, row);
if (!select) return row;
const out: any = {};
for (const k of Object.keys(select)) out[k] = row[k];
return out;
}),
},
__store: configs,
};
}
describe('TenderEmailConfigService', () => {
it('getConfigForApi returns null when no config exists for the tenant', async () => {
const prisma = makeFakePrisma();
const crypto = makeFakeCrypto();
const service = new TenderEmailConfigService(prisma as any, crypto as any);
const result = await service.getConfigForApi('tenant-missing');
expect(result).toBeNull();
});
it('saveConfig encrypts {username,password} and getConfigForApi round-trips username, NEVER returns the password field (T-07-12)', async () => {
const prisma = makeFakePrisma();
const crypto = makeFakeCrypto();
const service = new TenderEmailConfigService(prisma as any, crypto as any);
await service.saveConfig('tenant-a', {
protocol: 'imap',
encryption: 'ssl-tls',
host: 'imap.example.test',
port: 993,
folder: 'INBOX',
username: 'alerts@example.test',
password: 'super-secret',
isActive: true,
} as any);
const apiResult = await service.getConfigForApi('tenant-a');
expect(apiResult).not.toBeNull();
expect(apiResult).not.toHaveProperty('password');
expect(apiResult).not.toHaveProperty('encryptedInboxCreds');
expect(apiResult!.username).toBe('alerts@example.test');
expect(apiResult!.hasPassword).toBe(true);
});
it('saveConfig with no username/password leaves hasPassword false and username null (fresh config)', async () => {
const prisma = makeFakePrisma();
const crypto = makeFakeCrypto();
const service = new TenderEmailConfigService(prisma as any, crypto as any);
await service.saveConfig('tenant-b', {
protocol: 'imap',
encryption: 'ssl-tls',
host: 'imap.example.test',
port: 993,
folder: 'INBOX',
isActive: false,
} as any);
const apiResult = await service.getConfigForApi('tenant-b');
expect(apiResult!.hasPassword).toBe(false);
expect(apiResult!.username).toBeNull();
expect(crypto.encrypt).not.toHaveBeenCalled();
});
it('saveConfig preserves the existing password when only username changes on a re-save', async () => {
const prisma = makeFakePrisma();
const crypto = makeFakeCrypto();
const service = new TenderEmailConfigService(prisma as any, crypto as any);
await service.saveConfig('tenant-c', {
protocol: 'imap',
encryption: 'ssl-tls',
username: 'old@example.test',
password: 'original-secret',
} as any);
// Re-save with a new username, password left blank (T-07-12 UI convention)
await service.saveConfig('tenant-c', {
protocol: 'imap',
encryption: 'ssl-tls',
username: 'new@example.test',
} as any);
const raw = prisma.__store.get('tenant-c');
const decrypted = JSON.parse(crypto.decrypt(raw.encryptedInboxCreds));
expect(decrypted.username).toBe('new@example.test');
expect(decrypted.password).toBe('original-secret');
});
it('saveConfig preserves the existing username when only password changes on a re-save', async () => {
const prisma = makeFakePrisma();
const crypto = makeFakeCrypto();
const service = new TenderEmailConfigService(prisma as any, crypto as any);
await service.saveConfig('tenant-d', {
protocol: 'imap',
encryption: 'ssl-tls',
username: 'stable@example.test',
password: 'first-secret',
} as any);
await service.saveConfig('tenant-d', {
protocol: 'imap',
encryption: 'ssl-tls',
password: 'rotated-secret',
} as any);
const raw = prisma.__store.get('tenant-d');
const decrypted = JSON.parse(crypto.decrypt(raw.encryptedInboxCreds));
expect(decrypted.username).toBe('stable@example.test');
expect(decrypted.password).toBe('rotated-secret');
});
it('the safe select never includes encryptedInboxCreds in the upsert return value (T-07-12)', async () => {
const prisma = makeFakePrisma();
const crypto = makeFakeCrypto();
const service = new TenderEmailConfigService(prisma as any, crypto as any);
const result = await service.saveConfig('tenant-e', {
protocol: 'imap',
encryption: 'ssl-tls',
username: 'x@example.test',
password: 'y',
} as any);
expect(result).not.toHaveProperty('encryptedInboxCreds');
expect(result).not.toHaveProperty('password');
});
});