Files
tessera-ctl/apps/api/src/tenders/tender-email-config.service.spec.ts
T
schalli 05b1d293d8 feat(17-01): move TenderEmailConfig ownership from tenant to user
Alert-Postfach gehoert jetzt dem einzelnen Nutzer (userId @unique) statt
dem Mandanten (D-01) — ein zweiter Kollege desselben Mandanten kann sein
eigenes Postfach anbinden. tenantId bleibt denormalisiert (SMTP-Aufloesung,
Herkunftsmarkierung), wird auf create UND update mitgeschrieben.

- Handgeschriebene Migration (prisma migrate dev verweigert die
  nicht-interaktive Shell): befuellt Bestandszeilen mit dem aeltesten
  aktiven Administrator ihres Mandanten, entfernt verwaiste Zeilen ohne
  Administrator, ersetzt die tenantId-Eindeutigkeit durch userId.
  Lokal getestet (0 Bestandszeilen lokal und auf alpha — Zaehlung im
  Task-1-Checkpoint), Index-Ergebnis verifiziert.
- TenderEmailConfigService.getConfigForApi/saveConfig auf userId als
  Schluessel umgestellt; saveConfig nimmt {userId, tenantId}.
- TendersController: email-config-Routen von @Roles(ADMIN,SUPER_ADMIN)
  auf @UseModule('tender-radar') umgestellt (Postfach ist jetzt
  Nutzereinstellung); Route-Reihenfolge vor @Get(':id') unveraendert.
- Neue Seite /modules/tender-radar/my-sources ("Meine Quellen") mit dem
  unveraenderten EmailAlertConfigForm; Hinweistext benennt D-05 (Tender
  bleibt plattform-global — nur wer Quellen einspeist aendert sich).
- tenders.controller.spec.ts an neue Service-Signatur angepasst (Rule 3,
  nicht im Plan gelistet, aber zum Kompilieren/Bestehen erforderlich).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 11:19:55 +02:00

234 lines
8.1 KiB
TypeScript

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 CryptoService
* (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.
*
* Phase 17, Plan 01 (D-01): ownership moved from tenantId to userId — the
* fake prisma below is now keyed by userId (matches the real
* `where: { userId }` upsert target), and two new cases prove the actual
* new capability: two users of the SAME tenant get two independent rows,
* and tenantId is written on create (denormalized, D-01).
*/
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.userId);
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.userId);
const row = existing ? { ...existing, ...update } : { id: `cfg-${configs.size + 1}`, ...create };
configs.set(where.userId, 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 user', async () => {
const prisma = makeFakePrisma();
const crypto = makeFakeCrypto();
const service = new TenderEmailConfigService(prisma as any, crypto as any);
const result = await service.getConfigForApi('user-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(
{ userId: 'user-a', tenantId: '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('user-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(
{ userId: 'user-b', tenantId: 'tenant-b' },
{
protocol: 'imap',
encryption: 'ssl-tls',
host: 'imap.example.test',
port: 993,
folder: 'INBOX',
isActive: false,
} as any,
);
const apiResult = await service.getConfigForApi('user-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(
{ userId: 'user-c', tenantId: '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(
{ userId: 'user-c', tenantId: 'tenant-c' },
{
protocol: 'imap',
encryption: 'ssl-tls',
username: 'new@example.test',
} as any,
);
const raw = prisma.__store.get('user-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(
{ userId: 'user-d', tenantId: 'tenant-d' },
{
protocol: 'imap',
encryption: 'ssl-tls',
username: 'stable@example.test',
password: 'first-secret',
} as any,
);
await service.saveConfig(
{ userId: 'user-d', tenantId: 'tenant-d' },
{
protocol: 'imap',
encryption: 'ssl-tls',
password: 'rotated-secret',
} as any,
);
const raw = prisma.__store.get('user-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(
{ userId: 'user-e', tenantId: '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');
});
it('saveConfig writes BOTH userId and tenantId on create (Phase 17, D-01: tenantId stays denormalized)', async () => {
const prisma = makeFakePrisma();
const crypto = makeFakeCrypto();
const service = new TenderEmailConfigService(prisma as any, crypto as any);
await service.saveConfig(
{ userId: 'user-f', tenantId: 'tenant-f' },
{ protocol: 'imap', encryption: 'ssl-tls' } as any,
);
const raw = prisma.__store.get('user-f');
expect(raw.userId).toBe('user-f');
expect(raw.tenantId).toBe('tenant-f');
});
it('two users of the SAME tenant each get their own row — the second save never overwrites the first (Phase 17, D-01)', async () => {
const prisma = makeFakePrisma();
const crypto = makeFakeCrypto();
const service = new TenderEmailConfigService(prisma as any, crypto as any);
await service.saveConfig(
{ userId: 'user-g1', tenantId: 'tenant-shared' },
{ protocol: 'imap', encryption: 'ssl-tls', host: 'imap.user-g1.test' } as any,
);
await service.saveConfig(
{ userId: 'user-g2', tenantId: 'tenant-shared' },
{ protocol: 'imap', encryption: 'ssl-tls', host: 'imap.user-g2.test' } as any,
);
const configG1 = await service.getConfigForApi('user-g1');
const configG2 = await service.getConfigForApi('user-g2');
expect(configG1!.host).toBe('imap.user-g1.test');
expect(configG2!.host).toBe('imap.user-g2.test');
expect(prisma.__store.size).toBe(2);
});
});