636fe0df8f
- 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
366 lines
14 KiB
TypeScript
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);
|
|
});
|
|
});
|