3336a6e419
tender-saved-search.service.ts, tender-triage.service.ts, tender-notification-pref.service.ts und tender-email-config.service.ts laufen jetzt vollstaendig ueber forTenant() — vier neue Parameter (list, update, remove, listForUser, favoriteIds, getForUser, getConfigForApi, testConnection bekommen tenantId), die anwendungsseitige userId-Filterung bleibt unveraendert (Befund E: die Policies haben keine Benutzerdimension). tender-rss-feed.service.ts bindet nur createForUser (Zaehler + Anlage, beide ausschliesslich auf persoenlichen Zeilen); listForUser, createPlatform und remove bleiben mit Codekommentar bewusst ungebunden (WINDOWS #19 — eine gebundene plattformweite Zeile waere unter jedem Mandanten unsichtbar, ein gebundenes Einfuegen ohne Mandant wuerde abgewiesen). tender-notification-pref.service.ts und tender-email-config.service.ts uebersetzen eine P2002-Verletzung auf dem tenantlosen upsert-Schluessel (Befund F) in eine verstaendliche deutsche Meldung statt eines rohen Fehlers. tenders.controller.ts reicht tenantId an den acht betroffenen Aufrufstellen durch extractTriageContext() durch (kein neuer Aufloesungsweg); die drei RSS-Aufrufstellen bleiben unveraendert, da ihre Dienstmethoden nicht binden. Alle sieben angefassten Testdateien bekommen den Zwei-Client-Nachweis (__makeBoundClient ueber demselben Speicher) und Bindungstests je umgestellter Methode; tender-rss-feed.service.spec.ts zusaetzlich den Gegentest, dass die drei unveraendert bleibenden Pfade forTenant() NICHT aufrufen. Falsifiziert: ein probeweiser Rueckbau der list()-Bindung in tender-saved-search.service.ts machte genau den erwarteten Bindungstest rot, danach zurueckgenommen. docs/mandantentrennung-zugriffsklassifikation.md: Stand der fuenf Paare auf gebunden bzw. gemischt nachgezogen; tenderRssFeedSource von muss-mandantengebunden auf beides umklassifiziert (derselbe Praezedenzfall wie ldapConfig in 260909-ipc). 761 Tests gruen (743 + 18 neue Bindungsnachweise), Typpruefung sauber, Wegwerf-Werkzeug 32/32, kein Schema-/Migrations-/Compose-/ Umgebungsdatei-Diff. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
427 lines
15 KiB
TypeScript
427 lines
15 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).
|
|
*
|
|
* Bindung an forTenant() (260909-laa, Befund C/H): `__makeBoundClient()`
|
|
* wraps the SAME in-memory Map with a per-call logging layer — a pure
|
|
* identity mock would leave a forgotten `forTenant()` call invisible to
|
|
* every test. Muster aus `groups.service.spec.ts` (260909-jts).
|
|
*/
|
|
|
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
|
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
|
|
}));
|
|
|
|
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>();
|
|
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
|
|
|
|
const 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;
|
|
}),
|
|
};
|
|
|
|
const fake: any = {
|
|
tenderEmailConfig,
|
|
__store: configs,
|
|
__boundCallLog: boundCallLog,
|
|
__makeBoundClient(tenantId: string) {
|
|
const wrapped: any = {};
|
|
for (const method of Object.keys(tenderEmailConfig)) {
|
|
wrapped[method] = async (...args: any[]) => {
|
|
boundCallLog.push({ tenantId, model: 'tenderEmailConfig', method });
|
|
return (tenderEmailConfig as any)[method](...args);
|
|
};
|
|
}
|
|
return { tenderEmailConfig: wrapped };
|
|
},
|
|
};
|
|
|
|
return fake;
|
|
}
|
|
|
|
function expectBoundCall(prisma: any, tenantId: string, method: string) {
|
|
const found = prisma.__boundCallLog.some(
|
|
(c: any) => c.tenantId === tenantId && c.model === 'tenderEmailConfig' && c.method === method,
|
|
);
|
|
expect(
|
|
found,
|
|
`erwarteter gebundener Aufruf tenderEmailConfig.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`,
|
|
).toBe(true);
|
|
}
|
|
|
|
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', 'tenant-x');
|
|
|
|
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', '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(
|
|
{ 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', '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(
|
|
{ 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', 'tenant-shared');
|
|
const configG2 = await service.getConfigForApi('user-g2', 'tenant-shared');
|
|
|
|
expect(configG1!.host).toBe('imap.user-g1.test');
|
|
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', 'tenant-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', 'tenant-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', 'tenant-j', {
|
|
protocol: 'exchange',
|
|
encryption: 'ssl-tls',
|
|
username: 'ews-user',
|
|
password: 'ews-secret',
|
|
} as any);
|
|
|
|
expect(exchangeProvider.testConnection).toHaveBeenCalledTimes(1);
|
|
expect(imapProvider.testConnection).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// --- Bindung an forTenant() (260909-laa, Aufgabe 2) -----------------------
|
|
|
|
describe('Bindung an forTenant() (260909-laa)', () => {
|
|
it('getConfigForApi() bindet beide tenderEmailConfig.findUnique-Zugriffe an den uebergebenen Mandanten', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const crypto = makeFakeCrypto();
|
|
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
|
|
|
await service.saveConfig(
|
|
{ userId: 'user-k', tenantId: 't1' },
|
|
{ protocol: 'imap', encryption: 'ssl-tls', username: 'k', password: 'p' } as any,
|
|
);
|
|
prisma.__boundCallLog.length = 0;
|
|
|
|
await service.getConfigForApi('user-k', 't1');
|
|
|
|
const findUniqueCalls = prisma.__boundCallLog.filter(
|
|
(c: any) => c.tenantId === 't1' && c.model === 'tenderEmailConfig' && c.method === 'findUnique',
|
|
);
|
|
expect(findUniqueCalls.length).toBe(2);
|
|
});
|
|
|
|
it('saveConfig() bindet den credChanged-Lesezugriff UND das upsert an den uebergebenen Mandanten', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const crypto = makeFakeCrypto();
|
|
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
|
|
|
await service.saveConfig(
|
|
{ userId: 'user-l', tenantId: 't1' },
|
|
{ protocol: 'imap', encryption: 'ssl-tls', username: 'only-username' } as any,
|
|
);
|
|
|
|
expectBoundCall(prisma, 't1', 'findUnique');
|
|
expectBoundCall(prisma, 't1', 'upsert');
|
|
});
|
|
|
|
it('testConnection() bindet den Zugangsdaten-Rueckgriff an den uebergebenen Mandanten', 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-m', tenantId: 't1' },
|
|
{ protocol: 'imap', encryption: 'ssl-tls', username: 'm', password: 'p' } as any,
|
|
);
|
|
prisma.__boundCallLog.length = 0;
|
|
|
|
await service.testConnection('user-m', 't1', {
|
|
protocol: 'imap',
|
|
encryption: 'ssl-tls',
|
|
} as any);
|
|
|
|
expectBoundCall(prisma, 't1', 'findUnique');
|
|
});
|
|
|
|
function makeFakeProviders() {
|
|
return {
|
|
imapProvider: { testConnection: vi.fn(async () => ({ success: true })) },
|
|
exchangeProvider: { testConnection: vi.fn(async () => ({ success: true })) },
|
|
};
|
|
}
|
|
});
|
|
});
|