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