Files
tessera-ctl/apps/api/src/ldap/ldap-config.service.spec.ts
T
schalli 636fe0df8f refactor(quick-260921-bi2): maschinelle Lint-Fixe und toten Code abbauen
- Aufgabe 2: vier sichere Biome-Regeln (useImportType pfadgebunden auf
  apps/web+packages, noUselessEscapeInRegex, useConst,
  useExponentiationOperator) sowie fuenf ungesicherte Regeln
  (useNodejsImportProtocol, useLiteralKeys, useOptionalChain, useTemplate,
  useParseIntRadix) angewendet und den gesamten Diff von Hand gelesen
  (ldap.service.ts zeichenweise gegen Gross-/Kleinschreibung der
  AD-Merkmale, auth.service.ts/jwt.strategy.ts gegen Durchwinken bei
  fehlender Sitzung geprueft)
- noUselessSwitchCase bleibt bewusst stehen (tender-normalizer.service.ts:60,
  die Fallmarke dokumentiert Absicht)
- Toter Code (D-03): fuenf folgenlose Auffangvariablen entfernt, eine
  nicht benutzte Funktion (forSystemQuery, Pruefskript) entfernt, ein
  positionsgebundener Dekoratorparameter umbenannt (current-user.decorator.ts),
  fuenf Symptomfunde entfernt und als Folgeaufgaben zu melden (siehe unten)
- Sechs weitere, im Plan nicht namentlich gelistete aber
  gleich-kategorische Dead-Code-Fundstellen in Testdateien zusaetzlich
  bereinigt (groups.service.spec.ts, cert-manager.test.tsx,
  ldap.service.spec.ts, prisma-tenant.extension.spec.ts x3) — noetig, um
  die vom Plan selbst verlangten Nullstaende bei noUnusedVariables/
  noUnusedImports/noUnusedFunctionParameters zu erreichen

Dekoratordaten aus apps/api unveraendert (593 Zeilen, sha256 6e1583f1...).
Endstand 620 Befunde (541 echt, 79 Test) statt der im Plan geschaetzten
621/542 — eine Differenz von 1, weil das Streichen des Namens aus
`catch (e: any)` in calendar.service.ts (Symptom-Fix) den dort ebenfalls
gemeldeten noExplicitAny-Befund miteliminiert; das ist eine erwuenschte
Nebenwirkung, keine Regression. Fehlerstufe 0, beide Testlaeufe
punktgleich gruen (69/1124, 66/459), pnpm type-check 4/4, pnpm lint
--force 5/5.

Folgeaufgaben aus D-03 (nicht in diesem Vorgang behoben):
- force-password-change.interceptor.ts: Freigabeliste prueft nur den Pfad,
  nicht die HTTP-Methode
- change-password/page.tsx: nach erzwungenem Wechsel bleibt die Person auf
  der Seite stehen (keine Weiterleitung, keine Aktualisierung der
  Benutzerablage)
- VehicleTable.tsx: Loeschschaltflaeche hat keinen Besetztzustand, laesst
  sich doppelt ausloesen
- SplitTab.tsx: downloadAllAsZip erhielt eine ungenutzte
  Uebersetzungsfunktion, Hinweis auf fest verdrahtete Texte im Zip-Pfad

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 08:58:32 +02:00

366 lines
14 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { LdapConfigService } from './ldap-config.service';
// forTenant gibt in den Bestandstests denselben Client zurueck (tenant
// scoping ist dort nicht unter Test) — ohne diesen Mock bricht die Datei am
// blanken Prisma-Ersatz beim ersten `$extends`-Aufruf (Befund F,
// 260909-ipc-PLAN.md). Der eigene Bindungs-Testblock unten biegt die
// Implementierung auf ein zweites, unterscheidbares Client-Objekt um.
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
// Systemkontext (260914-eym): liefert den in `__systemClient` hinterlegten
// Klienten, sonst denselben Client (Bestandstests).
forSystem: vi.fn((p: any) => p.__systemClient ?? p),
}));
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
/**
* Das Bind-Passwort ist das einzige Zugangsdatum, das nicht gehasht werden
* kann — Tessera muss sich damit am Domain Controller anmelden, braucht es
* also im Original. Deshalb Verschluesselung, und deshalb diese Tests: sie
* halten fest, dass der Klartext nie in die Datenbank geschrieben wird, dass
* Aufrufer trotzdem weiterhin ein entschluesseltes `bindPassword` sehen, und
* dass ein Wert aus der Zeit vor der Verschluesselung den Sync nicht bricht.
*/
// Ein durchschaubarer Ersatz fuer AES-256-GCM, der die gespeicherte Form
// iv:authTag:ciphertext nachbildet — die Tests pruefen das Zusammenspiel,
// nicht die Krypto-Bibliothek.
const crypto = {
encrypt: vi.fn((plaintext: string) =>
['aa11', 'bb22', Buffer.from(plaintext, 'utf8').toString('hex')].join(':'),
),
decrypt: vi.fn((stored: string) => {
const [, , ciphertext] = stored.split(':');
return Buffer.from(ciphertext, 'hex').toString('utf8');
}),
};
const CONFIG_ROW = {
id: 'cfg1',
tenantId: 't1',
serverUrl: 'ldap://example',
baseDn: 'dc=example,dc=com',
bindDn: 'svc@example',
encryptedBindPassword: null as string | null,
searchFilter: '(objectClass=person)',
syncIntervalMin: 0,
isActive: true,
tlsRejectUnauthorized: true,
groupFilterDns: [] as string[],
userExcludeList: [] as string[],
fieldMappings: [],
};
describe('LdapConfigService — Bind-Passwort verschluesselt at rest', () => {
let prisma: any;
let service: LdapConfigService;
beforeEach(() => {
vi.clearAllMocks();
prisma = {
ldapConfig: {
findUnique: vi.fn(),
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn((args: any) =>
Promise.resolve({ ...CONFIG_ROW, ...args.data }),
),
update: vi.fn((args: any) =>
Promise.resolve({ ...CONFIG_ROW, ...args.data }),
),
},
};
service = new LdapConfigService(prisma, crypto as any);
});
it('schreibt beim Anlegen den Chiffretext, nie den Klartext', async () => {
await service.createConfig('t1', {
serverUrl: 'ldap://example',
baseDn: 'dc=example,dc=com',
bindDn: 'svc@example',
bindPassword: 'geheim',
} as any);
const written = prisma.ldapConfig.create.mock.calls[0][0].data;
expect(written.encryptedBindPassword).toBe(
`aa11:bb22:${Buffer.from('geheim').toString('hex')}`,
);
expect(JSON.stringify(written)).not.toContain('geheim');
// Die alte Klartext-Spalte darf nicht wieder auftauchen.
expect(written).not.toHaveProperty('bindPassword');
});
it('verschluesselt auch beim Aktualisieren und laesst das Feld sonst unberuehrt', async () => {
await service.updateConfig('t1', { bindPassword: 'neu' } as any);
const first = prisma.ldapConfig.update.mock.calls[0][0].data;
expect(first.encryptedBindPassword).toBe(
`aa11:bb22:${Buffer.from('neu').toString('hex')}`,
);
await service.updateConfig('t1', { serverUrl: 'ldap://anders' } as any);
const second = prisma.ldapConfig.update.mock.calls[1][0].data;
expect(second).not.toHaveProperty('encryptedBindPassword');
});
it('leeres Passwort loescht den Wert, statt Leerstring zu verschluesseln', async () => {
await service.updateConfig('t1', { bindPassword: '' } as any);
const written = prisma.ldapConfig.update.mock.calls[0][0].data;
expect(written.encryptedBindPassword).toBeNull();
expect(crypto.encrypt).not.toHaveBeenCalled();
});
it('gibt Aufrufern weiterhin ein entschluesseltes bindPassword', async () => {
prisma.ldapConfig.findUnique.mockResolvedValue({
...CONFIG_ROW,
encryptedBindPassword: `aa11:bb22:${Buffer.from('geheim').toString('hex')}`,
});
const config: any = await service.getConfig('t1');
expect(config.bindPassword).toBe('geheim');
expect(config).not.toHaveProperty('encryptedBindPassword');
});
it('reicht einen Altbestand-Klartext unveraendert durch, statt den Sync zu brechen', async () => {
// Zeitfenster zwischen Spalten-Umbenennung und Bootstrap-Backfill.
prisma.ldapConfig.findUnique.mockResolvedValue({
...CONFIG_ROW,
encryptedBindPassword: 'nochKlartext',
});
const config: any = await service.getConfig('t1');
expect(config.bindPassword).toBe('nochKlartext');
expect(crypto.decrypt).not.toHaveBeenCalled();
});
it('meldet einen fehlgeschlagenen Entschluesselungsversuch, statt still kein Passwort zu liefern', async () => {
// Ein falscher Schluessel darf nicht wie "kein Passwort konfiguriert"
// aussehen — das wuerde einen authentifizierten Bind unbemerkt in einen
// anonymen verwandeln.
crypto.decrypt.mockImplementationOnce(() => {
throw new Error('Unsupported state or unable to authenticate data');
});
prisma.ldapConfig.findUnique.mockResolvedValue({
...CONFIG_ROW,
encryptedBindPassword: 'aa11:bb22:ffff',
});
await expect(service.getConfig('t1')).rejects.toThrow();
});
it('verschluesselt beim Start genau die Zeilen, die noch Klartext tragen', async () => {
prisma.ldapConfig.findMany.mockResolvedValue([
{ id: 'a', tenantId: 't1', encryptedBindPassword: 'klartext' },
{
id: 'b',
tenantId: 't2',
encryptedBindPassword: `aa11:bb22:${Buffer.from('schon').toString('hex')}`,
},
{ id: 'c', tenantId: 't3', encryptedBindPassword: null },
]);
await service.onApplicationBootstrap();
expect(prisma.ldapConfig.update).toHaveBeenCalledTimes(1);
const call = prisma.ldapConfig.update.mock.calls[0][0];
expect(call.where).toEqual({ id: 'a' });
expect(call.data.encryptedBindPassword).toBe(
`aa11:bb22:${Buffer.from('klartext').toString('hex')}`,
);
});
it('ist beim zweiten Start ein No-Op', async () => {
prisma.ldapConfig.findMany.mockResolvedValue([
{
id: 'a',
tenantId: 't1',
encryptedBindPassword: `aa11:bb22:${Buffer.from('x').toString('hex')}`,
},
]);
await service.onApplicationBootstrap();
expect(prisma.ldapConfig.update).not.toHaveBeenCalled();
});
it('laesst einen fehlgeschlagenen Backfill den Start nicht verhindern', async () => {
prisma.ldapConfig.findMany.mockRejectedValue(new Error('db weg'));
await expect(service.onApplicationBootstrap()).resolves.toBeUndefined();
});
});
/**
* Aufgabe 2 (260909-ipc, WINDOWS #20 Etappe 2, T-IPC-01/T-IPC-02): belegt,
* dass die drei pro-Mandant-Methoden gebunden laufen, die beiden
* uebergreifenden Methoden es NICHT tun, und dass das Loeschen einer
* Feldzuordnung ohne Mandant nicht mehr moeglich ist.
*/
describe('LdapConfigService — Bindung an forTenant() (260909-ipc)', () => {
let prisma: any;
let service: LdapConfigService;
beforeEach(() => {
vi.clearAllMocks();
prisma = {
ldapConfig: {
findUnique: vi.fn().mockResolvedValue(CONFIG_ROW),
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn((args: any) => Promise.resolve({ ...CONFIG_ROW, ...args.data })),
update: vi.fn((args: any) => Promise.resolve({ ...CONFIG_ROW, ...args.data })),
},
ldapFieldMapping: {
create: vi.fn((args: any) => Promise.resolve({ id: 'map1', isDefault: false, ...args.data })),
findUnique: vi.fn(),
delete: vi.fn((args: any) => Promise.resolve({ id: args.where.id })),
},
};
service = new LdapConfigService(prisma, crypto as any);
});
it('getConfig() bindet ueber forTenant() an den uebergebenen Mandanten', async () => {
await service.getConfig('t1');
expect(forTenant).toHaveBeenCalledWith(prisma, 't1');
expect(prisma.ldapConfig.findUnique).toHaveBeenCalledWith(
expect.objectContaining({ where: { tenantId: 't1' } }),
);
});
it('createConfig() bindet ueber forTenant() an den uebergebenen Mandanten', async () => {
await service.createConfig('t1', {
serverUrl: 'ldap://example',
baseDn: 'dc=example,dc=com',
} as any);
expect(forTenant).toHaveBeenCalledWith(prisma, 't1');
expect(prisma.ldapConfig.create).toHaveBeenCalled();
});
it('updateConfig() bindet ueber forTenant() an den uebergebenen Mandanten', async () => {
await service.updateConfig('t1', { serverUrl: 'ldap://anders' } as any);
expect(forTenant).toHaveBeenCalledWith(prisma, 't1');
expect(prisma.ldapConfig.update).toHaveBeenCalledWith(
expect.objectContaining({ where: { tenantId: 't1' } }),
);
});
it('addFieldMapping() nimmt den Mandanten entgegen und schreibt gebunden', async () => {
await service.addFieldMapping('t1', 'cfg1', {
ldapField: 'department',
tesseraField: 'department',
} as any);
expect(forTenant).toHaveBeenCalledWith(prisma, 't1');
expect(prisma.ldapFieldMapping.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ ldapConfigId: 'cfg1' }),
}),
);
});
it('removeFieldMapping() nimmt den Mandanten entgegen, liest gebunden und loescht gebunden', async () => {
prisma.ldapFieldMapping.findUnique.mockResolvedValue({
id: 'map1',
ldapConfigId: 'cfg1',
isDefault: false,
});
const result = await service.removeFieldMapping('t1', 'map1');
expect(forTenant).toHaveBeenCalledWith(prisma, 't1');
expect(prisma.ldapFieldMapping.findUnique).toHaveBeenCalledWith({
where: { id: 'map1' },
});
expect(prisma.ldapFieldMapping.delete).toHaveBeenCalledWith({
where: { id: 'map1' },
});
expect(result).toEqual({ id: 'map1' });
});
it('removeFieldMapping() liefert null, wenn die Zuordnung unter diesem Mandanten nicht sichtbar ist (T-IPC-01)', async () => {
// Simuliert die RLS-Wirkung: unter dem Mandantenkontext von t1 ist eine
// fremde Feldzuordnung (Mandant t2) unsichtbar — findUnique liefert null,
// genau wie es die echte Policy nach dem Scharfschalten taete.
prisma.ldapFieldMapping.findUnique.mockResolvedValue(null);
const result = await service.removeFieldMapping('t1', 'map-fremd');
expect(result).toBeNull();
expect(prisma.ldapFieldMapping.delete).not.toHaveBeenCalled();
});
it('removeFieldMapping() schuetzt Vorgabe-Zuordnungen weiterhin, auch gebunden', async () => {
prisma.ldapFieldMapping.findUnique.mockResolvedValue({
id: 'map1',
ldapConfigId: 'cfg1',
isDefault: true,
});
await expect(service.removeFieldMapping('t1', 'map1')).rejects.toThrow(
'Cannot delete default field mappings',
);
expect(prisma.ldapFieldMapping.delete).not.toHaveBeenCalled();
});
it('getAllActiveConfigs() liest ueber den Systemkontext: forSystem genau einmal, forTenant nie (260914-eym)', async () => {
const systemClient = {
ldapConfig: { findMany: vi.fn().mockResolvedValue([{ ...CONFIG_ROW }]) },
};
prisma.__systemClient = systemClient;
const result = await service.getAllActiveConfigs();
expect(forSystem).toHaveBeenCalledTimes(1);
expect(forSystem).toHaveBeenCalledWith(prisma);
expect(forTenant).not.toHaveBeenCalled();
expect(systemClient.ldapConfig.findMany).toHaveBeenCalledWith({
where: { isActive: true },
include: { tenant: true, fieldMappings: true },
});
expect(prisma.ldapConfig.findMany).not.toHaveBeenCalled();
expect(result).toHaveLength(1);
});
it('onApplicationBootstrap() mit leerer Liste: forSystem einmal, forTenant nie, kein Update (Leere ist Nichtstun, 260914-eym)', async () => {
const systemClient = { ldapConfig: { findMany: vi.fn().mockResolvedValue([]) } };
prisma.__systemClient = systemClient;
await service.onApplicationBootstrap();
expect(forSystem).toHaveBeenCalledTimes(1);
expect(forTenant).not.toHaveBeenCalled();
expect(prisma.ldapConfig.update).not.toHaveBeenCalled();
});
it('onApplicationBootstrap() mit einer Altzeile (Klartext, t1): liest system, schreibt GEBUNDEN — forTenant genau einmal mit t1, update traegt das verschluesselte Kennwort (260914-eym)', async () => {
const systemClient = {
ldapConfig: {
findMany: vi.fn().mockResolvedValue([
{ id: 'alt', tenantId: 't1', encryptedBindPassword: 'klartext' },
]),
},
};
prisma.__systemClient = systemClient;
const boundClient = {
ldapConfig: { update: vi.fn((args: any) => Promise.resolve({ ...CONFIG_ROW, ...args.data })) },
};
vi.mocked(forTenant).mockImplementation(() => boundClient as any);
await service.onApplicationBootstrap();
expect(forSystem).toHaveBeenCalledTimes(1);
expect(forTenant).toHaveBeenCalledTimes(1);
expect(forTenant).toHaveBeenCalledWith(prisma, 't1');
expect(boundClient.ldapConfig.update).toHaveBeenCalledTimes(1);
const call = boundClient.ldapConfig.update.mock.calls[0][0];
expect(call.where).toEqual({ id: 'alt' });
expect(call.data.encryptedBindPassword).toBe(
`aa11:bb22:${Buffer.from('klartext').toString('hex')}`,
);
// Der rohe Client schreibt NICHT.
expect(prisma.ldapConfig.update).not.toHaveBeenCalled();
// Implementierung zuruecksetzen (vi.clearAllMocks loescht nur Aufrufe).
vi.mocked(forTenant).mockImplementation((p: unknown) => p as any);
});
});