feat(quick-260909-ipc): ldap.service.ts an forTenant() binden, Adress-Kollisionspruefung bewusst uebergreifend belassen

Aufgabe 3 der Etappe 2: elf Abfragen in sechs Methoden (listGroups,
upsertMappedUser, searchUsers, importUsersByDn, importGroupsByDn,
syncUsersForTenant) laufen jetzt ueber forTenant(), teils mit einem neu
erzeugten, teils mit dem in derselben Methode bereits vorhandenen gebundenen
Client. resolveEmailForWrite bleibt ausdruecklich ungebunden (Befund A,
T-IPC-04): email/username sind plattformweit eindeutig, eine Bindung wuerde
einen fremden Halter uebersehen und eine saubere Kollisionsmeldung in einen
P2002-Abbruch verwandeln.

Ein neuer Testblock biegt forTenant() auf ein zweites, unterscheidbares
Client-Objekt um (der bisherige Identitaets-Mock haette die Umstellung nicht
bemerkt, Befund F) und belegt damit, dass die Adressabfrage weiterhin am
ungebundenen und der Rest am gebundenen Client landet. Alle 67 Bestandstests
bleiben unveraendert gruen.

docs/mandantentrennung-zugriffsklassifikation.md ist fuer den Bereich ldap
geschlossen: gemessener Stand je Fundstelle, neu gerechnete Bereichsuebersicht
(gebunden getrennt von ungebunden gezaehlt) und die Uebergabe des
Standardgruppen-Punkts an den Bereich groups vor Etappe 4 dokumentiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-09 14:10:51 +02:00
parent 9a57fa79f5
commit e1586a41dd
3 changed files with 289 additions and 45 deletions
+182 -1
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock ldapts so no real directory connection is attempted. The single shared
// search mock is re-programmed per test.
@@ -59,6 +59,7 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
import { Client } from 'ldapts';
import { LdapService } from './ldap.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
describe('LdapService.syncUsersForTenant — per-user exclude list', () => {
let service: LdapService;
@@ -2300,3 +2301,183 @@ describe('LdapService — AD group import (SC-1/SC-2, D-01/D-02)', () => {
]);
});
});
/**
* Aufgabe 3 (260909-ipc, WINDOWS #20 Etappe 2, Befund F): der Identitaets-
* Mock von forTenant() weiter oben in dieser Datei bemerkt eine Umstellung
* von `this.prisma.X` auf `tenantPrisma.X` nicht, weil beide Seiten auf
* denselben Objekt zeigen. Dieser Block biegt forTenant() ausschliesslich
* fuer sich selbst auf ein ZWEITES, unterscheidbares Client-Objekt um: der
* ungebundene Ersatz (`unboundPrisma`, das an den Konstruktor uebergebene
* `this.prisma`) und der gebundene Ersatz (`boundPrisma`, das Ergebnis von
* forTenant()) sind zwei verschiedene Spione. Damit ist nachweisbar, WELCHER
* Client welchen Aufruf bekommt — mit dem Identitaets-Mock waere das nicht
* unterscheidbar.
*/
describe('LdapService — Bindungsnachweis mit unterscheidbaren Clients (260909-ipc, Befund F)', () => {
let service: LdapService;
let unboundPrisma: any;
let boundPrisma: any;
let userService: any;
const cfg = {
id: 'cfg1',
tenantId: 't1',
serverUrl: 'ldap://example',
baseDn: 'dc=example,dc=com',
searchFilter: '(objectClass=person)',
groupFilterDns: [] as string[],
userExcludeList: [] as string[],
fieldMappings: [
{ ldapField: 'sAMAccountName', tesseraField: 'username' },
{ ldapField: 'mail', tesseraField: 'email' },
],
};
beforeEach(() => {
vi.clearAllMocks();
mockBind.mockResolvedValue(undefined);
mockUnbind.mockResolvedValue(undefined);
// Der ungebundene Ersatz: genau das, was resolveEmailForWrite() ueber
// this.prisma.user.findUnique erreicht (Befund A, bleibt bewusst
// ungebunden).
unboundPrisma = {
user: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
// Der gebundene Ersatz: das Ergebnis von forTenant(this.prisma, tenantId)
// in jeder umgestellten Methode.
boundPrisma = {
user: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
update: vi.fn().mockResolvedValue({}),
},
group: {
findMany: vi.fn().mockResolvedValue([]),
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({}),
},
groupMembership: {
createMany: vi.fn().mockResolvedValue({ count: 0 }),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
};
(forTenant as any).mockImplementation(() => boundPrisma);
userService = { create: vi.fn().mockResolvedValue({}) };
service = new LdapService(unboundPrisma, userService, {} as any);
});
afterEach(() => {
// Andere describe-Bloecke dieser Datei verlassen sich auf die
// Identitaets-Grundform (forTenant gibt denselben Client zurueck) — die
// Umbiegung bleibt auf diesen Block beschraenkt.
(forTenant as any).mockImplementation((p: unknown) => p);
});
it('syncUsersForTenant: resolveEmailForWrite fragt den UNGEBUNDENEN Client, upsertMappedUser den GEBUNDENEN', async () => {
mockSearch.mockResolvedValue({
searchEntries: [
{ dn: 'cn=alice,dc=example,dc=com', sAMAccountName: 'alice', mail: 'alice@x' },
],
});
const result = await service.syncUsersForTenant(cfg as any, 't1');
expect(result.created).toBe(1);
expect(forTenant).toHaveBeenCalledWith(unboundPrisma, 't1');
// Die Adressabfrage aus resolveEmailForWrite() landet auf dem
// UNGEBUNDENEN Client — niemals auf dem gebundenen (Befund A, T-IPC-04).
expect(unboundPrisma.user.findUnique).toHaveBeenCalledWith({
where: { email: 'alice@x' },
});
// Die Identitaetssuche aus upsertMappedUser() landet auf dem GEBUNDENEN
// Client.
expect(boundPrisma.user.findFirst).toHaveBeenCalled();
});
it('syncUsersForTenant bindet die Deaktivierungs-Kandidatenliste und die lastSyncAt-Fortschreibung', async () => {
mockSearch.mockResolvedValue({ searchEntries: [] });
await service.syncUsersForTenant(cfg as any, 't1');
expect(boundPrisma.user.findMany).toHaveBeenCalled();
expect(boundPrisma.ldapConfig.update).toHaveBeenCalledWith({
where: { id: 'cfg1' },
data: { lastSyncAt: expect.any(Date) },
});
});
it('listGroups bindet ueber forTenant() an den uebergebenen Mandanten', async () => {
mockSearch.mockResolvedValue({
searchEntries: [
{
dn: 'cn=Sales,dc=example,dc=com',
cn: 'Sales',
objectGUID: Buffer.from('0123456789abcdef0123456789abcdef', 'hex'),
},
],
});
await service.listGroups(cfg as any, 't1');
expect(forTenant).toHaveBeenCalledWith(unboundPrisma, 't1');
expect(boundPrisma.group.findMany).toHaveBeenCalled();
});
it('searchUsers bindet ueber forTenant() an den uebergebenen Mandanten', async () => {
mockSearch.mockResolvedValue({
searchEntries: [
{ dn: 'cn=alice,dc=example,dc=com', sAMAccountName: 'alice', mail: 'alice@x' },
],
});
await service.searchUsers(cfg as any, 't1', 'a');
expect(forTenant).toHaveBeenCalledWith(unboundPrisma, 't1');
expect(boundPrisma.user.findMany).toHaveBeenCalled();
});
it('importUsersByDn bindet Dedup und Update, die Adress-Kollisionspruefung bleibt ungebunden', async () => {
mockSearch.mockResolvedValue({
searchEntries: [
{ dn: 'cn=carol,dc=example,dc=com', sAMAccountName: 'carol', mail: 'carol@x' },
],
});
const result = await service.importUsersByDn(cfg as any, 't1', [
'cn=carol,dc=example,dc=com',
]);
expect(result.created).toBe(1);
expect(boundPrisma.user.findFirst).toHaveBeenCalled();
expect(unboundPrisma.user.findUnique).toHaveBeenCalledWith({
where: { email: 'carol@x' },
});
});
it('importGroupsByDn bindet die Idempotenzpruefung und die Anlage auf DEMSELBEN gebundenen Client', async () => {
mockSearch.mockResolvedValue({
searchEntries: [
{
dn: 'cn=Sales,dc=example,dc=com',
cn: 'Sales',
objectGUID: Buffer.from('0123456789abcdef0123456789abcdef', 'hex'),
},
],
});
const result = await service.importGroupsByDn(cfg as any, 't1', [
'cn=Sales,dc=example,dc=com',
]);
expect(result.imported).toBe(1);
expect(boundPrisma.group.findFirst).toHaveBeenCalled();
expect(boundPrisma.group.create).toHaveBeenCalled();
});
});