Files
tessera-ctl/apps/api/src/ldap/ldap.service.spec.ts
T
schalli e1586a41dd 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
2026-09-09 14:10:51 +02:00

2484 lines
85 KiB
TypeScript

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.
const mockBind = vi.fn().mockResolvedValue(undefined);
const mockSearch = vi.fn();
const mockUnbind = vi.fn().mockResolvedValue(undefined);
// EqualityFilter is mocked as a plain carrier of { attribute, value } — that is
// exactly the contract the existence sweep depends on: the raw GUID Buffer is
// handed to the filter object instead of being escaped into a filter string
// (quick task 260811-f9i). The class is declared INSIDE the factory because
// vi.mock is hoisted above every top-level binding in this file.
vi.mock('ldapts', () => ({
Client: vi.fn().mockImplementation(() => ({
bind: mockBind,
search: mockSearch,
unbind: mockUnbind,
})),
EqualityFilter: class {
attribute: string;
value: Buffer | string;
constructor({
attribute,
value,
}: {
attribute: string;
value: Buffer | string;
}) {
this.attribute = attribute;
this.value = value;
}
},
}));
/**
* The existence sweep passes a FILTER OBJECT carrying the raw GUID bytes, never
* an escaped filter string. Returns the Buffer when a search call is that sweep,
* null otherwise — a string filter of the form "(objectGUID=...)" deliberately
* returns null, so the old broken shape can never satisfy a mock again.
*/
const sweptGuid = (filter: unknown): Buffer | null => {
if (
filter &&
typeof filter === 'object' &&
(filter as { attribute?: unknown }).attribute === 'objectGUID'
) {
const value = (filter as { value?: unknown }).value;
return Buffer.isBuffer(value) ? value : null;
}
return null;
};
// forTenant just returns the same client in these tests (tenant scoping is not
// under test here).
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
}));
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;
let prisma: any;
let userService: any;
const baseConfig = {
id: 'cfg1',
tenantId: 't1',
serverUrl: 'ldap://example',
baseDn: 'dc=example,dc=com',
searchFilter: '(objectClass=person)',
// The Base-DN is the sync scope (an empty parsed base-DN list is the
// sole no-op path — see the dedicated "empty base DN no-op" describe
// block below). groupFilterDns here is an optional extra restriction;
// set to exercise the memberOf-restricted search path.
groupFilterDns: ['ou=people,dc=example,dc=com'] as string[],
userExcludeList: [] as string[],
fieldMappings: [
{ ldapField: 'sAMAccountName', tesseraField: 'username' },
],
};
beforeEach(() => {
vi.clearAllMocks();
mockBind.mockResolvedValue(undefined);
mockUnbind.mockResolvedValue(undefined);
prisma = {
user: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
update: vi.fn().mockResolvedValue({}),
},
// No AD-bound groups in this describe block — the group-membership
// reconciliation (D-21) has its own dedicated describe block below.
group: { findMany: vi.fn().mockResolvedValue([]) },
groupMembership: {
createMany: vi.fn().mockResolvedValue({ count: 0 }),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
};
userService = { create: vi.fn().mockResolvedValue({}) };
service = new LdapService(prisma, userService, {} as any);
});
it('imports every user when the exclude list is empty', async () => {
mockSearch.mockResolvedValue({
searchEntries: [
{ dn: 'cn=admin', sAMAccountName: 'Administrator' },
{ dn: 'cn=alice', sAMAccountName: 'alice' },
],
});
const result = await service.syncUsersForTenant(baseConfig as any, 't1');
expect(result.created).toBe(2);
expect(userService.create).toHaveBeenCalledTimes(2);
});
it('skips excluded usernames (case-insensitive match)', async () => {
mockSearch.mockResolvedValue({
searchEntries: [
{ dn: 'cn=admin', sAMAccountName: 'Administrator' },
{ dn: 'cn=krbtgt', sAMAccountName: 'krbtgt' },
{ dn: 'cn=alice', sAMAccountName: 'alice' },
],
});
const result = await service.syncUsersForTenant(
{ ...baseConfig, userExcludeList: ['administrator', 'KRBTGT'] } as any,
't1',
);
expect(result.created).toBe(1);
expect(userService.create).toHaveBeenCalledTimes(1);
expect(userService.create).toHaveBeenCalledWith(
expect.objectContaining({ username: 'alice' }),
);
});
it('deactivates a previously-imported user once they are excluded', async () => {
// AD still returns "guest", but it is now on the exclude list, so it must
// not stay in syncedDns and therefore gets deactivated.
mockSearch.mockResolvedValue({
searchEntries: [{ dn: 'cn=guest', sAMAccountName: 'guest' }],
});
prisma.user.findMany.mockResolvedValue([{ id: 'u-guest', ldapDn: 'cn=guest' }]);
const result = await service.syncUsersForTenant(
{ ...baseConfig, userExcludeList: ['guest'] } as any,
't1',
);
expect(result.created).toBe(0);
expect(userService.create).not.toHaveBeenCalled();
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 'u-guest' },
data: { isActive: false },
});
expect(result.deactivated).toBe(1);
});
});
describe('LdapService.syncUsersForTenant — empty base DN no-op', () => {
let service: LdapService;
let prisma: any;
let userService: any;
const emptyBaseDnConfig = {
id: 'cfg1',
tenantId: 't1',
serverUrl: 'ldap://example',
baseDn: '',
searchFilter: '(objectClass=person)',
groupFilterDns: [] as string[],
userExcludeList: [] as string[],
fieldMappings: [
{ ldapField: 'sAMAccountName', tesseraField: 'username' },
],
};
beforeEach(() => {
vi.clearAllMocks();
mockBind.mockResolvedValue(undefined);
mockUnbind.mockResolvedValue(undefined);
prisma = {
user: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([{ id: 'u-existing', ldapDn: 'cn=existing' }]),
update: vi.fn().mockResolvedValue({}),
},
group: { findMany: vi.fn().mockResolvedValue([]) },
groupMembership: {
createMany: vi.fn().mockResolvedValue({ count: 0 }),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
};
userService = { create: vi.fn().mockResolvedValue({}) };
service = new LdapService(prisma, userService, {} as any);
});
it('creates nobody and deactivates nobody when the base DN is empty (whitespace-only)', async () => {
const result = await service.syncUsersForTenant(
{ ...emptyBaseDnConfig, baseDn: ' \n \n' } as any,
't1',
);
expect(result).toEqual({
created: 0,
updated: 0,
deactivated: 0,
groupMembershipsAdded: 0,
groupMembershipsRemoved: 0,
groupsAdopted: 0,
groupsRenamed: 0,
groupsDeleted: 0,
defaultMarkerMoved: 0,
emailConflicts: [],
skippedNoLogin: [],
entryFailures: [],
errors: [],
});
expect(mockSearch).not.toHaveBeenCalled();
expect(mockBind).not.toHaveBeenCalled();
expect(userService.create).not.toHaveBeenCalled();
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.ldapConfig.update).not.toHaveBeenCalled();
});
it('is NOT a no-op when baseDn is set but groupFilterDns is empty (normal multi-base search)', async () => {
mockSearch.mockResolvedValue({
searchEntries: [{ dn: 'cn=alice', sAMAccountName: 'alice' }],
});
const result = await service.syncUsersForTenant(
{ ...emptyBaseDnConfig, baseDn: 'dc=example,dc=com' } as any,
't1',
);
expect(mockBind).toHaveBeenCalled();
expect(mockSearch).toHaveBeenCalled();
expect(result.created).toBe(1);
expect(userService.create).toHaveBeenCalledTimes(1);
});
});
describe('LdapService.syncUsersForTenant — multi base DN scope', () => {
let service: LdapService;
let prisma: any;
let userService: any;
const multiBaseConfig = {
id: 'cfg1',
tenantId: 't1',
serverUrl: 'ldap://example',
baseDn: 'dc=a,dc=com\ndc=b,dc=com',
searchFilter: '(objectClass=person)',
groupFilterDns: [] as string[],
userExcludeList: [] as string[],
fieldMappings: [
{ ldapField: 'sAMAccountName', tesseraField: 'username' },
],
};
beforeEach(() => {
vi.clearAllMocks();
mockBind.mockResolvedValue(undefined);
mockUnbind.mockResolvedValue(undefined);
prisma = {
user: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
update: vi.fn().mockResolvedValue({}),
},
group: { findMany: vi.fn().mockResolvedValue([]) },
groupMembership: {
createMany: vi.fn().mockResolvedValue({ count: 0 }),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
};
userService = { create: vi.fn().mockResolvedValue({}) };
service = new LdapService(prisma, userService, {} as any);
});
it('searches every configured base DN and merges/dedupes results by dn', async () => {
mockSearch
.mockResolvedValueOnce({
searchEntries: [
{ dn: 'cn=shared,dc=a,dc=com', sAMAccountName: 'shared' },
{ dn: 'cn=alice,dc=a,dc=com', sAMAccountName: 'alice' },
],
})
.mockResolvedValueOnce({
searchEntries: [
{ dn: 'cn=shared,dc=a,dc=com', sAMAccountName: 'shared' },
{ dn: 'cn=bob,dc=b,dc=com', sAMAccountName: 'bob' },
],
});
const result = await service.syncUsersForTenant(
multiBaseConfig as any,
't1',
);
expect(mockSearch).toHaveBeenCalledTimes(2);
expect(mockSearch).toHaveBeenNthCalledWith(
1,
'dc=a,dc=com',
expect.objectContaining({ filter: '(objectClass=person)' }),
);
expect(mockSearch).toHaveBeenNthCalledWith(
2,
'dc=b,dc=com',
expect.objectContaining({ filter: '(objectClass=person)' }),
);
// 3 distinct dns (shared, alice, bob) — the duplicate "shared" dn from
// the second base is deduped, not double-created.
expect(result.created).toBe(3);
expect(userService.create).toHaveBeenCalledTimes(3);
});
});
describe('LdapService — individual user search & import (dedup)', () => {
let service: LdapService;
let prisma: 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: 'displayName', tesseraField: 'displayName' },
{ ldapField: 'mail', tesseraField: 'email' },
],
};
beforeEach(() => {
vi.clearAllMocks();
mockBind.mockResolvedValue(undefined);
mockUnbind.mockResolvedValue(undefined);
prisma = {
user: {
findFirst: vi.fn().mockResolvedValue(null),
// Consulted by importUsersByDn's WINDOWS #15 email-collision decider
// whenever an entry carries a mapped `mail` attribute.
findUnique: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
update: vi.fn().mockResolvedValue({}),
},
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
};
userService = { create: vi.fn().mockResolvedValue({}) };
service = new LdapService(prisma, userService, {} as any);
});
it('searchUsers flags results already present by username or ldapDn', async () => {
mockSearch.mockResolvedValue({
searchEntries: [
{
dn: 'cn=alice,dc=example,dc=com',
sAMAccountName: 'alice',
displayName: 'Alice A',
mail: 'alice@x',
},
{
dn: 'cn=bob,dc=example,dc=com',
sAMAccountName: 'bob',
displayName: 'Bob B',
mail: 'bob@x',
},
],
});
prisma.user.findMany.mockResolvedValue([{ ldapDn: null, username: 'alice' }]);
const res = await service.searchUsers(cfg as any, 't1', 'a');
expect(res).toHaveLength(2);
expect(res.find((r) => r.username === 'alice')?.alreadyImported).toBe(true);
expect(res.find((r) => r.username === 'bob')?.alreadyImported).toBe(false);
});
it('searchUsers returns [] for an empty query without binding', async () => {
const res = await service.searchUsers(cfg as any, 't1', ' ');
expect(res).toEqual([]);
expect(mockSearch).not.toHaveBeenCalled();
});
it('importUsersByDn creates a new user with ldapDn set', async () => {
mockSearch.mockResolvedValue({
searchEntries: [
{ dn: 'cn=carol,dc=example,dc=com', sAMAccountName: 'carol', mail: 'carol@x' },
],
});
prisma.user.findFirst.mockResolvedValue(null);
const res = await service.importUsersByDn(cfg as any, 't1', [
'cn=carol,dc=example,dc=com',
]);
expect(res.created).toBe(1);
expect(res.skipped).toBe(0);
expect(userService.create).toHaveBeenCalledWith(
expect.objectContaining({
username: 'carol',
ldapDn: 'cn=carol,dc=example,dc=com',
tenantId: 't1',
}),
);
});
it('importUsersByDn skips an already-imported user (no duplicate)', async () => {
mockSearch.mockResolvedValue({
searchEntries: [{ dn: 'cn=dave,dc=example,dc=com', sAMAccountName: 'dave' }],
});
prisma.user.findFirst.mockResolvedValue({
id: 'u9',
username: 'dave',
ldapDn: 'cn=dave,dc=example,dc=com',
});
const res = await service.importUsersByDn(cfg as any, 't1', [
'cn=dave,dc=example,dc=com',
]);
expect(res.skipped).toBe(1);
expect(res.created).toBe(0);
expect(userService.create).not.toHaveBeenCalled();
});
it('importUsersByDn links ldapDn on a user previously matched only by username', async () => {
mockSearch.mockResolvedValue({
searchEntries: [{ dn: 'cn=erin,dc=example,dc=com', sAMAccountName: 'erin' }],
});
prisma.user.findFirst.mockResolvedValue({
id: 'u10',
username: 'erin',
ldapDn: null,
});
const res = await service.importUsersByDn(cfg as any, 't1', [
'cn=erin,dc=example,dc=com',
]);
expect(res.skipped).toBe(1);
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'u10' },
data: { ldapDn: 'cn=erin,dc=example,dc=com' },
}),
);
expect(userService.create).not.toHaveBeenCalled();
});
it('importUsersByDn respects the userExcludeList denylist', async () => {
mockSearch.mockResolvedValue({
searchEntries: [
{ dn: 'cn=svc,dc=example,dc=com', sAMAccountName: 'Administrator' },
],
});
const res = await service.importUsersByDn(
{ ...cfg, userExcludeList: ['administrator'] } as any,
't1',
['cn=svc,dc=example,dc=com'],
);
expect(res.skipped).toBe(1);
expect(userService.create).not.toHaveBeenCalled();
});
});
describe('LdapService.syncUsersForTenant — E-Mail-Kollision bei der Kontoanlage (WINDOWS #15)', () => {
let service: LdapService;
let prisma: 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);
prisma = {
user: {
findFirst: vi.fn().mockResolvedValue(null),
// The collision decider queries this — NOT mocked in the older
// describe blocks above, added here because this is the first
// block that exercises it (see PLAN.md Task 2 behavior note).
findUnique: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
update: vi.fn().mockResolvedValue({}),
},
group: { findMany: vi.fn().mockResolvedValue([]) },
groupMembership: {
createMany: vi.fn().mockResolvedValue({ count: 0 }),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
};
userService = { create: vi.fn().mockResolvedValue({ id: 'created-user' }) };
service = new LdapService(prisma, userService, {} as any);
});
it('legt ein Konto mit belegter Adresse trotzdem an, nur ohne Adresse', async () => {
mockSearch.mockResolvedValue({
searchEntries: [
{
dn: 'cn=konto1,dc=example,dc=com',
sAMAccountName: 'konto1',
mail: 'geteilt@example.com',
},
{
dn: 'cn=konto2,dc=example,dc=com',
sAMAccountName: 'konto2',
mail: 'geteilt@example.com',
},
],
});
// The first entry's address is free (findUnique -> null). By the time
// the second entry is processed, the address already belongs to the
// first-created account.
prisma.user.findUnique
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 'konto1-id', email: 'geteilt@example.com' });
const result = await service.syncUsersForTenant(cfg as any, 't1');
expect(userService.create).toHaveBeenCalledTimes(2);
expect(userService.create).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ username: 'konto1', email: 'geteilt@example.com' }),
);
const secondCallArgs = userService.create.mock.calls[1][0];
expect(secondCallArgs.email).toBeUndefined();
expect(result.created).toBe(2);
});
it('meldet die Kollision strukturiert und nicht als Fehler', async () => {
mockSearch.mockResolvedValue({
searchEntries: [
{
dn: 'cn=konto1,dc=example,dc=com',
sAMAccountName: 'konto1',
mail: 'geteilt@example.com',
},
{
dn: 'cn=konto2,dc=example,dc=com',
sAMAccountName: 'konto2',
mail: 'geteilt@example.com',
},
],
});
prisma.user.findUnique
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 'konto1-id', email: 'geteilt@example.com' });
const result = await service.syncUsersForTenant(cfg as any, 't1');
expect(result.emailConflicts).toEqual([
{ account: 'konto2', email: 'geteilt@example.com' },
]);
expect(result.errors).toEqual([]);
});
it('nimmt einem bestehenden Konto seine Adresse nicht weg', async () => {
mockSearch.mockResolvedValue({
searchEntries: [
{
dn: 'cn=konto3,dc=example,dc=com',
sAMAccountName: 'konto3',
mail: 'fremd@example.com',
},
],
});
// konto3 already exists locally (matched by ldapDn); the address AD now
// reports for it belongs to a THIRD, unrelated user.
prisma.user.findFirst.mockResolvedValue({ id: 'konto3-id', email: 'alt@example.com' });
prisma.user.findUnique.mockResolvedValue({ id: 'dritter-user-id', email: 'fremd@example.com' });
const result = await service.syncUsersForTenant(cfg as any, 't1');
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'konto3-id' },
data: expect.not.objectContaining({ email: expect.anything() }),
}),
);
expect(result.emailConflicts).toEqual([
{ account: 'konto3', email: 'fremd@example.com' },
]);
});
it('meldet Eintraege ohne Anmeldenamen getrennt und nicht als Fehler', async () => {
mockSearch.mockResolvedValue({
searchEntries: [{ dn: 'cn=kontakt1,dc=example,dc=com' }],
});
const result = await service.syncUsersForTenant(cfg as any, 't1');
expect(result.skippedNoLogin).toEqual(['cn=kontakt1,dc=example,dc=com']);
expect(result.errors).toEqual([]);
});
it('reicht keinen rohen Datenbanktext an den Bericht durch', async () => {
mockSearch.mockResolvedValue({
searchEntries: [
{
dn: 'cn=konto5,dc=example,dc=com',
sAMAccountName: 'konto5',
mail: 'konto5@example.com',
},
],
});
const ormMessage =
'Invalid `prisma.user.create()` invocation: Unique constraint failed on the fields: (`email`)';
userService.create.mockRejectedValueOnce(new Error(ormMessage));
const result = await service.syncUsersForTenant(cfg as any, 't1');
expect(result.entryFailures).toEqual(['cn=konto5,dc=example,dc=com']);
expect(result.errors).not.toContain(ormMessage);
expect(JSON.stringify(result)).not.toContain(ormMessage);
});
});
describe('LdapService.testConnection — TLS verification opt-out (ldaps)', () => {
let service: LdapService;
beforeEach(() => {
vi.clearAllMocks();
mockBind.mockResolvedValue(undefined);
mockUnbind.mockResolvedValue(undefined);
service = new LdapService({} as any, {} as any, {} as any);
});
it('passes tlsOptions.rejectUnauthorized=false for ldaps when opted out', async () => {
await service.testConnection({
serverUrl: 'ldaps://ad:636',
tlsRejectUnauthorized: false,
});
expect(Client).toHaveBeenCalledWith(
expect.objectContaining({
url: 'ldaps://ad:636',
tlsOptions: { rejectUnauthorized: false },
}),
);
});
it('keeps verification on for ldaps when tlsRejectUnauthorized is true', async () => {
await service.testConnection({
serverUrl: 'ldaps://ad:636',
tlsRejectUnauthorized: true,
});
const opts = (Client as any).mock.calls.at(-1)[0];
expect(opts.tlsOptions).toBeUndefined();
});
it('ignores the flag for plain ldap:// (no TLS)', async () => {
await service.testConnection({
serverUrl: 'ldap://ad:389',
tlsRejectUnauthorized: false,
});
const opts = (Client as any).mock.calls.at(-1)[0];
expect(opts.tlsOptions).toBeUndefined();
});
});
describe('LdapService.verifyUserCredentials — LDAP login bind', () => {
let service: LdapService;
beforeEach(() => {
vi.clearAllMocks();
mockUnbind.mockResolvedValue(undefined);
service = new LdapService({} as any, {} as any, {} as any);
});
it('returns true when the user bind succeeds', async () => {
mockBind.mockResolvedValue(undefined);
const ok = await service.verifyUserCredentials(
{ serverUrl: 'ldaps://ad:636', tlsRejectUnauthorized: false },
'CN=alice,DC=x',
'correct-pw',
);
expect(ok).toBe(true);
expect(mockBind).toHaveBeenCalledWith('CN=alice,DC=x', 'correct-pw');
});
it('returns false when the user bind fails (wrong password)', async () => {
mockBind.mockRejectedValue(new Error('invalid credentials'));
const ok = await service.verifyUserCredentials(
{ serverUrl: 'ldaps://ad:636' },
'CN=alice,DC=x',
'wrong-pw',
);
expect(ok).toBe(false);
});
it('rejects an empty password WITHOUT binding (no anonymous-bind bypass)', async () => {
const ok = await service.verifyUserCredentials(
{ serverUrl: 'ldaps://ad:636' },
'CN=alice,DC=x',
'',
);
expect(ok).toBe(false);
expect(mockBind).not.toHaveBeenCalled();
});
});
describe('LdapService.syncUsersForTenant — AD-bound group membership sync (D-19/D-20/D-21, PERM-02)', () => {
let service: LdapService;
let prisma: any;
let userService: any;
// Hand-rolled in-memory fake for Group/GroupMembership/User (project pattern
// — see groups.service.spec.ts), so createMany/skipDuplicates and deleteMany
// behave like the real @@unique([groupId, userId]) constraint from 15-01:
// a pre-existing MANUAL row is left untouched by an LDAP createMany, never
// upgraded/duplicated (D-19/D-20).
let groups: { id: string; tenantId: string; name: string; ldapDn: string | null }[];
let memberships: { id: string; groupId: string; userId: string; source: 'MANUAL' | 'LDAP' }[];
let users: { id: string; tenantId: string; username: string }[];
let seq: number;
// The plain user-sync search (no memberOf clause) returns nothing in this
// block by default — every test here focuses purely on the group-membership
// reconciliation step, not on user creation/deactivation (covered above).
let groupSearchEntries: Record<string, unknown>[];
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' }],
};
// Plan 16-03 step 5a now runs BEFORE this block's own step-5b search in
// every real syncUsersForTenant() call. This describe block only
// exercises step 5b (membership sync), so 5a must resolve as a
// deterministic no-op by default: every fixture group's own DN-derived
// GUID resolves to an identity hit (same cn/dn as already stored) — no
// rename, no delete, nothing added to result.errors. Exposed at
// describe-scope (not just inside beforeEach) so a test that needs to
// ALSO fail one group's step-5b search (see the "records a search
// failure" test below) can still delegate the 5a shapes to this helper
// instead of re-deriving them.
const guidForDn = (dn: string): Buffer => {
const hex = Buffer.from(dn).toString('hex').padEnd(32, '0').slice(0, 32);
return Buffer.from(hex, 'hex');
};
const resolve5aNoOp = (baseDn: string, filter: unknown) => {
if (filter === '(objectClass=group)') {
// 5a legacy-binding backfill probe: the probed base DN IS the
// group's own stored ldapDn (scope 'base').
const g = groups.find((x) => x.ldapDn === baseDn);
return {
searchEntries: g
? [{ dn: g.ldapDn, cn: g.name, objectGUID: guidForDn(g.ldapDn as string) }]
: [],
};
}
const guid = sweptGuid(filter);
if (guid) {
// 5a existence sweep: match whichever fixture group's DN-derived GUID
// the filter object carries.
const g = groups.find(
(x) => x.ldapDn && guidForDn(x.ldapDn as string).equals(guid),
);
return { searchEntries: g ? [{ dn: g.ldapDn, cn: g.name }] : [] };
}
return null;
};
beforeEach(() => {
vi.clearAllMocks();
mockBind.mockResolvedValue(undefined);
mockUnbind.mockResolvedValue(undefined);
seq = 0;
groups = [];
memberships = [];
users = [
{ id: 'u-alice', tenantId: 't1', username: 'alice' },
{ id: 'u-bob', tenantId: 't1', username: 'bob' },
];
groupSearchEntries = [];
mockSearch.mockImplementation((baseDn: string, opts: any) => {
const filter = typeof opts.filter === 'string' ? opts.filter : '';
if (filter.includes('memberOf=')) {
return Promise.resolve({ searchEntries: groupSearchEntries });
}
const fivea = resolve5aNoOp(baseDn, opts.filter);
if (fivea) {
return Promise.resolve(fivea);
}
// Plain user-sync search: no entries in this describe block.
return Promise.resolve({ searchEntries: [] });
});
prisma = {
user: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn((args: any) => {
if (args?.where?.username?.in) {
const wanted = new Set<string>(args.where.username.in);
return Promise.resolve(
users
.filter(
(u) => u.tenantId === args.where.tenantId && wanted.has(u.username),
)
.map((u) => ({ id: u.id })),
);
}
// Deactivation-loop query (ldapDn: { not: null }) — not under test here.
return Promise.resolve([]);
}),
update: vi.fn().mockResolvedValue({}),
},
group: {
findMany: vi.fn((args: any) =>
Promise.resolve(
groups.filter(
(g) => g.tenantId === args.where.tenantId && g.ldapDn !== null,
),
),
),
// 5a's legacy-binding backfill write (ldapObjectGuid only) — never
// a rename/delete write in this block since the mockSearch above
// always resolves an identity hit.
update: vi.fn((args: any) => {
const g = groups.find((x) => x.id === args.where.id);
if (g) {
Object.assign(g, args.data);
}
return Promise.resolve(g);
}),
},
groupMembership: {
createMany: vi.fn((args: any) => {
let count = 0;
for (const row of args.data as {
groupId: string;
userId: string;
source: string;
}[]) {
const exists = memberships.some(
(m) => m.groupId === row.groupId && m.userId === row.userId,
);
if (exists) {
// skipDuplicates: pre-existing row (e.g. MANUAL) stays untouched,
// never upgraded/counted — exactly the @@unique([groupId, userId])
// constraint from 15-01.
continue;
}
memberships.push({
id: `auto${seq++}`,
groupId: row.groupId,
userId: row.userId,
source: row.source as 'MANUAL' | 'LDAP',
});
count++;
}
return Promise.resolve({ count });
}),
deleteMany: vi.fn((args: any) => {
const notIn: string[] = args.where.userId?.notIn ?? [];
const before = memberships.length;
memberships = memberships.filter((m) => {
const matchesDeleteTarget =
m.groupId === args.where.groupId &&
m.source === args.where.source &&
!notIn.includes(m.userId);
return !matchesDeleteTarget;
});
return Promise.resolve({ count: before - memberships.length });
}),
},
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
};
userService = { create: vi.fn().mockResolvedValue({}) };
service = new LdapService(prisma, userService, {} as any);
});
it('runs no additional LDAP search for a tenant without AD-bound groups', async () => {
// groups stays [] — group.findMany() has nothing to return.
const result = await service.syncUsersForTenant(cfg as any, 't1');
expect(prisma.group.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { tenantId: 't1', ldapDn: { not: null } },
}),
);
// Only the plain user-sync search ran (one call, one base DN) — no
// memberOf-filtered search was issued.
expect(mockSearch).toHaveBeenCalledTimes(1);
expect(mockSearch.mock.calls[0][1].filter).not.toContain('memberOf=');
expect(result.groupMembershipsAdded).toBe(0);
expect(result.groupMembershipsRemoved).toBe(0);
});
it('ignores a Tessera group with no ldapDn set (never queried)', async () => {
groups = [{ id: 'g-unbound', tenantId: 't1', name: 'Unbound', ldapDn: null }];
await service.syncUsersForTenant(cfg as any, 't1');
// The in-memory fake's group.findMany already filters ldapDn !== null,
// mirroring the real Prisma where-clause — so no group search happens.
expect(mockSearch).toHaveBeenCalledTimes(1);
expect(mockSearch.mock.calls[0][1].filter).not.toContain('memberOf=');
});
it('searches every configured base DN once per bound group, filter = sanitized filter AND escaped memberOf', async () => {
groups = [
{ id: 'g1', tenantId: 't1', name: 'Sales', ldapDn: 'CN=Sales,DC=ctl,DC=local' },
];
groupSearchEntries = [];
const result = await service.syncUsersForTenant(
{ ...cfg, baseDn: 'dc=a,dc=com\ndc=b,dc=com' } as any,
't1',
);
const memberOfCalls = mockSearch.mock.calls.filter(([, opts]) =>
typeof opts.filter === 'string' && opts.filter.includes('memberOf='),
);
expect(memberOfCalls).toHaveLength(2);
expect(memberOfCalls[0][0]).toBe('dc=a,dc=com');
expect(memberOfCalls[1][0]).toBe('dc=b,dc=com');
for (const [, opts] of memberOfCalls) {
expect(opts.filter).toBe(
'(&(objectClass=person)(memberOf=CN=Sales,DC=ctl,DC=local))',
);
expect(opts.scope).toBe('sub');
}
expect(result.errors).toEqual([]);
});
it('passes the IDENTICAL attribute list to the group search as to the user sync (memberOf is a filter, never a return attribute)', async () => {
groups = [
{ id: 'g1', tenantId: 't1', name: 'Sales', ldapDn: 'CN=Sales,DC=ctl,DC=local' },
];
groupSearchEntries = [];
await service.syncUsersForTenant(cfg as any, 't1');
const userSyncCall = mockSearch.mock.calls.find(
([, opts]) =>
typeof opts.filter === 'string' && !opts.filter.includes('memberOf='),
);
const groupSyncCall = mockSearch.mock.calls.find(([, opts]) =>
typeof opts.filter === 'string' && opts.filter.includes('memberOf='),
);
expect(userSyncCall).toBeDefined();
expect(groupSyncCall).toBeDefined();
expect(groupSyncCall![1].attributes).toEqual(userSyncCall![1].attributes);
// Never a return attribute: memberOf itself is not in the requested list.
expect(groupSyncCall![1].attributes).not.toContain('memberOf');
});
it('escapes special characters in the group DN before interpolating into the filter (RFC 4515)', async () => {
groups = [
{
id: 'g1',
tenantId: 't1',
name: 'Sales EMEA',
ldapDn: 'CN=Sales (EMEA)*\\,DC=ctl,DC=local',
},
];
groupSearchEntries = [];
await service.syncUsersForTenant(cfg as any, 't1');
const groupSyncCall = mockSearch.mock.calls.find(([, opts]) =>
typeof opts.filter === 'string' && opts.filter.includes('memberOf='),
);
expect(groupSyncCall![1].filter).toBe(
'(&(objectClass=person)(memberOf=CN=Sales \\28EMEA\\29\\2a\\5c,DC=ctl,DC=local))',
);
});
it('creates a GroupMembership(source: LDAP) for an AD hit whose username exists locally', async () => {
groups = [{ id: 'g1', tenantId: 't1', name: 'Sales', ldapDn: 'CN=Sales,DC=ctl,DC=local' }];
groupSearchEntries = [{ dn: 'cn=alice,dc=example,dc=com', sAMAccountName: 'alice' }];
const result = await service.syncUsersForTenant(cfg as any, 't1');
expect(memberships).toEqual([
{ id: 'auto0', groupId: 'g1', userId: 'u-alice', source: 'LDAP' },
]);
expect(result.groupMembershipsAdded).toBe(1);
expect(result.groupMembershipsRemoved).toBe(0);
expect(result.errors).toEqual([]);
});
it('creates no membership and no error for an AD hit with no matching local user', async () => {
groups = [{ id: 'g1', tenantId: 't1', name: 'Sales', ldapDn: 'CN=Sales,DC=ctl,DC=local' }];
groupSearchEntries = [{ dn: 'cn=ghost,dc=example,dc=com', sAMAccountName: 'ghost' }];
const result = await service.syncUsersForTenant(cfg as any, 't1');
expect(memberships).toEqual([]);
expect(result.groupMembershipsAdded).toBe(0);
expect(result.errors).toEqual([]);
});
it('empty AD result removes every LDAP membership of the group but keeps every MANUAL one (D-19/D-20)', async () => {
groups = [{ id: 'g1', tenantId: 't1', name: 'Sales', ldapDn: 'CN=Sales,DC=ctl,DC=local' }];
memberships = [
{ id: 'm0', groupId: 'g1', userId: 'u-alice', source: 'LDAP' },
{ id: 'm1', groupId: 'g1', userId: 'u-bob', source: 'MANUAL' },
];
groupSearchEntries = []; // zero AD hits
const result = await service.syncUsersForTenant(cfg as any, 't1');
expect(memberships).toEqual([
{ id: 'm1', groupId: 'g1', userId: 'u-bob', source: 'MANUAL' },
]);
expect(result.groupMembershipsRemoved).toBe(1);
});
it('never removes a MANUAL membership even when its user is absent from the AD result, and never upgrades it to LDAP when re-found (D-19/D-20 mixed membership)', async () => {
groups = [{ id: 'g1', tenantId: 't1', name: 'Sales', ldapDn: 'CN=Sales,DC=ctl,DC=local' }];
memberships = [
{ id: 'm0', groupId: 'g1', userId: 'u-alice', source: 'MANUAL' },
];
// alice IS present in the AD result too — mixed membership (D-20).
groupSearchEntries = [{ dn: 'cn=alice,dc=example,dc=com', sAMAccountName: 'alice' }];
const result = await service.syncUsersForTenant(cfg as any, 't1');
// Exactly one row, still MANUAL — createMany's skipDuplicates left it
// untouched, it was not upgraded to LDAP nor duplicated.
expect(memberships).toEqual([
{ id: 'm0', groupId: 'g1', userId: 'u-alice', source: 'MANUAL' },
]);
expect(result.groupMembershipsAdded).toBe(0);
expect(result.groupMembershipsRemoved).toBe(0);
});
it('is unaffected by AD result ORDER — the outcome is a pure set operation over usernames', async () => {
groups = [{ id: 'g1', tenantId: 't1', name: 'Sales', ldapDn: 'CN=Sales,DC=ctl,DC=local' }];
groupSearchEntries = [
{ dn: 'cn=alice,dc=example,dc=com', sAMAccountName: 'alice' },
{ dn: 'cn=bob,dc=example,dc=com', sAMAccountName: 'bob' },
];
await service.syncUsersForTenant(cfg as any, 't1');
const forward = memberships.map((m) => m.userId).sort();
// Reset and re-run with the reversed hit order.
seq = 0;
memberships = [];
groupSearchEntries = [
{ dn: 'cn=bob,dc=example,dc=com', sAMAccountName: 'bob' },
{ dn: 'cn=alice,dc=example,dc=com', sAMAccountName: 'alice' },
];
await service.syncUsersForTenant(cfg as any, 't1');
const reversed = memberships.map((m) => m.userId).sort();
expect(reversed).toEqual(forward);
expect(forward).toEqual(['u-alice', 'u-bob']);
});
it('is idempotent: a second run with an unchanged AD result adds and removes nothing', async () => {
groups = [{ id: 'g1', tenantId: 't1', name: 'Sales', ldapDn: 'CN=Sales,DC=ctl,DC=local' }];
groupSearchEntries = [{ dn: 'cn=alice,dc=example,dc=com', sAMAccountName: 'alice' }];
const first = await service.syncUsersForTenant(cfg as any, 't1');
expect(first.groupMembershipsAdded).toBe(1);
expect(first.groupMembershipsRemoved).toBe(0);
const second = await service.syncUsersForTenant(cfg as any, 't1');
expect(second.groupMembershipsAdded).toBe(0);
expect(second.groupMembershipsRemoved).toBe(0);
expect(memberships).toHaveLength(1);
});
it('records a search failure for one group in result.errors (with the group name) and keeps processing the remaining groups without losing MANUAL rows (concurrency/backstop)', async () => {
groups = [
{ id: 'g-broken', tenantId: 't1', name: 'Broken Group', ldapDn: 'CN=Broken,DC=ctl,DC=local' },
{ id: 'g-ok', tenantId: 't1', name: 'OK Group', ldapDn: 'CN=OK,DC=ctl,DC=local' },
];
memberships = [
{ id: 'm0', groupId: 'g-broken', userId: 'u-bob', source: 'MANUAL' },
];
mockSearch.mockImplementation((baseDn: string, opts: any) => {
const filter = typeof opts.filter === 'string' ? opts.filter : '';
// Only step 5b's memberOf-filtered search for THIS group fails — 5a
// (reconciliation) resolves as a no-op for both groups via the
// shared helper, so this test isolates the 5b failure it targets.
if (filter.includes('memberOf=') && filter.includes('CN=Broken')) {
return Promise.reject(new Error('directory unavailable'));
}
if (filter.includes('memberOf=')) {
return Promise.resolve({
searchEntries: [{ dn: 'cn=alice,dc=example,dc=com', sAMAccountName: 'alice' }],
});
}
const fivea = resolve5aNoOp(baseDn, opts.filter);
if (fivea) {
return Promise.resolve(fivea);
}
return Promise.resolve({ searchEntries: [] });
});
const result = await service.syncUsersForTenant(cfg as any, 't1');
expect(result.errors).toEqual([
'Gruppe Broken Group: directory unavailable',
]);
// The broken group's pre-existing MANUAL row survives untouched.
expect(memberships).toContainEqual({
id: 'm0',
groupId: 'g-broken',
userId: 'u-bob',
source: 'MANUAL',
});
// The second, healthy group was still processed.
expect(memberships).toContainEqual(
expect.objectContaining({ groupId: 'g-ok', userId: 'u-alice', source: 'LDAP' }),
);
});
});
describe('LdapService.syncUsersForTenant — Schrittreihenfolge Gruppen vor Mitgliedschaften', () => {
let service: LdapService;
let prisma: any;
let userService: any;
let groupsService: 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' }],
};
const guidBuffer = Buffer.from('0123456789abcdef'.repeat(2), 'hex');
const guidHex = guidBuffer.toString('hex');
beforeEach(() => {
vi.clearAllMocks();
mockBind.mockResolvedValue(undefined);
mockUnbind.mockResolvedValue(undefined);
mockSearch.mockResolvedValue({ searchEntries: [] });
prisma = {
user: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
update: vi.fn().mockResolvedValue({}),
},
group: {
findMany: vi.fn().mockResolvedValue([]),
update: vi.fn().mockResolvedValue({}),
},
groupMembership: {
createMany: vi.fn().mockResolvedValue({ count: 0 }),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
};
userService = { create: vi.fn().mockResolvedValue({}) };
groupsService = {
reassignDefaultBeforeDelete: vi.fn().mockResolvedValue(false),
ensureDefaultGroup: vi.fn().mockResolvedValue(null),
};
service = new LdapService(prisma, userService, groupsService);
});
it('calls syncBoundGroupsForTenant (5a) before syncGroupMembershipsForTenant (5b) — observed via call-order spies, not just asserted', async () => {
const callOrder: string[] = [];
const originalGroups = (service as any).syncBoundGroupsForTenant.bind(service);
const originalMemberships = (service as any).syncGroupMembershipsForTenant.bind(service);
vi.spyOn(service as any, 'syncBoundGroupsForTenant').mockImplementation(
async (...args: any[]) => {
callOrder.push('syncBoundGroupsForTenant');
return originalGroups(...args);
},
);
vi.spyOn(service as any, 'syncGroupMembershipsForTenant').mockImplementation(
async (...args: any[]) => {
callOrder.push('syncGroupMembershipsForTenant');
return originalMemberships(...args);
},
);
await service.syncUsersForTenant(cfg as any, 't1');
expect(callOrder).toEqual([
'syncBoundGroupsForTenant',
'syncGroupMembershipsForTenant',
]);
});
it('sits behind the same Base-DN no-op guard as the rest of the sync — an empty base DN triggers neither step', async () => {
const callOrder: string[] = [];
vi.spyOn(service as any, 'syncBoundGroupsForTenant').mockImplementation(
async () => {
callOrder.push('syncBoundGroupsForTenant');
},
);
vi.spyOn(service as any, 'syncGroupMembershipsForTenant').mockImplementation(
async () => {
callOrder.push('syncGroupMembershipsForTenant');
},
);
await service.syncUsersForTenant({ ...cfg, baseDn: ' \n ' } as any, 't1');
expect(callOrder).toEqual([]);
expect(mockSearch).not.toHaveBeenCalled();
});
it('regression: a rename detected in 5a is written back before 5b builds its memberOf filter — no memberOf search ever uses the stale pre-rename DN', async () => {
const groups = [
{
id: 'g1',
tenantId: 't1',
name: 'Sales',
ldapDn: 'cn=Sales,dc=example,dc=com',
ldapObjectGuid: guidHex,
isDefault: false,
},
];
prisma.group.findMany = vi.fn(() => Promise.resolve(groups));
prisma.group.update = vi.fn((args: any) => {
const g = groups.find((x) => x.id === args.where.id);
if (g) {
Object.assign(g, args.data);
}
return Promise.resolve(g);
});
mockSearch.mockImplementation((_baseDn: string, opts: any) => {
if (sweptGuid(opts.filter)) {
// 5a's existence sweep: AD reports the group renamed — new cn/dn.
return Promise.resolve({
searchEntries: [
{ dn: 'cn=Vertrieb,dc=example,dc=com', cn: 'Vertrieb' },
],
});
}
// 5b's memberOf search (and the plain user-sync search) — no hits,
// only the filter STRING passed matters for this test.
return Promise.resolve({ searchEntries: [] });
});
await service.syncUsersForTenant(cfg as any, 't1');
// The rename was written back (5a ran and updated the in-memory row).
expect(groups[0].ldapDn).toBe('cn=Vertrieb,dc=example,dc=com');
const memberOfCall = mockSearch.mock.calls.find(([, opts]: any) =>
typeof opts.filter === 'string' && opts.filter.includes('memberOf='),
);
expect(memberOfCall).toBeDefined();
expect(memberOfCall![1].filter).toContain('cn=Vertrieb,dc=example,dc=com');
expect(memberOfCall![1].filter).not.toContain('cn=Sales,dc=example,dc=com');
});
});
describe('LdapService.syncBoundGroupsForTenant — Rekonziliation gegen das Verzeichnis (SC-3/SC-4/SC-5, D-05/D-06)', () => {
let service: LdapService;
let prisma: any;
let userService: any;
let groupsService: any;
let client: any;
// Hand-rolled in-memory fake for Group (project pattern — see the D-21
// block above), so update()/delete() and the OR-candidate query behave
// exactly like the real forTenant()-scoped Prisma calls this method
// issues, across multiple sequential runs (idempotency test below).
let groups: {
id: string;
tenantId: string;
name: string;
ldapDn: string | null;
ldapObjectGuid: string | null;
isDefault: boolean;
}[];
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' }],
};
// 16 raw bytes, hex-decodable to a stable 32-char lowercase string —
// stands in for a real AD objectGUID. NOTE: deliberately its own literal,
// not shared with the group-import block below — that block's fixture
// string is actually 31 hex characters (an existing off-by-one from Plan
// 16-01 that never mattered there because importGroupsByDn() never
// length-validates), which would fail this method's 32-char guard.
const guidBuffer = Buffer.from('0123456789abcdef'.repeat(2), 'hex');
const guidHex = guidBuffer.toString('hex');
const makeResult = (): any => ({
created: 0,
updated: 0,
deactivated: 0,
groupMembershipsAdded: 0,
groupMembershipsRemoved: 0,
groupsAdopted: 0,
groupsRenamed: 0,
groupsDeleted: 0,
defaultMarkerMoved: 0,
errors: [] as string[],
});
// Direct invocation of the private method (not yet wired into
// syncUsersForTenant — that wiring is Plan 16-03 Task 2, tested
// separately below via a dedicated ordering test).
const run = (result: any) =>
(service as any).syncBoundGroupsForTenant(client, cfg, 't1', result);
beforeEach(() => {
vi.clearAllMocks();
groups = [];
client = new Client({} as any);
prisma = {
group: {
findMany: vi.fn((args: any) =>
Promise.resolve(
groups.filter(
(g) =>
g.tenantId === args.where.tenantId &&
(g.ldapObjectGuid !== null || g.ldapDn !== null),
),
),
),
update: vi.fn((args: any) => {
const g = groups.find((x) => x.id === args.where.id);
if (g) {
Object.assign(g, args.data);
}
return Promise.resolve(g);
}),
delete: vi.fn((args: any) => {
const idx = groups.findIndex((x) => x.id === args.where.id);
if (idx === -1) {
const err: any = new Error('Record to delete does not exist.');
err.code = 'P2025';
return Promise.reject(err);
}
const [removed] = groups.splice(idx, 1);
return Promise.resolve(removed);
}),
},
};
userService = { create: vi.fn().mockResolvedValue({}) };
groupsService = {
reassignDefaultBeforeDelete: vi.fn().mockResolvedValue(false),
ensureDefaultGroup: vi.fn().mockResolvedValue(null),
};
service = new LdapService(prisma, userService, groupsService);
});
it('returns immediately with no client.search call when the tenant has no candidate group (SC-5)', async () => {
const result = makeResult();
await run(result);
expect(mockSearch).not.toHaveBeenCalled();
expect(result.errors).toEqual([]);
});
it('a purely local group (ldapObjectGuid: null, ldapDn: null) is excluded by the candidate query and never touched', async () => {
groups = [
{ id: 'g-local', tenantId: 't1', name: 'Local', ldapDn: null, ldapObjectGuid: null, isDefault: false },
];
const result = makeResult();
await run(result);
expect(mockSearch).not.toHaveBeenCalled();
expect(prisma.group.update).not.toHaveBeenCalled();
expect(prisma.group.delete).not.toHaveBeenCalled();
expect(groups).toEqual([
{ id: 'g-local', tenantId: 't1', name: 'Local', ldapDn: null, ldapObjectGuid: null, isDefault: false },
]);
});
it('a hit with unchanged cn/dn makes no write and increments no counter', async () => {
groups = [
{
id: 'g1',
tenantId: 't1',
name: 'Sales',
ldapDn: 'cn=Sales,dc=example,dc=com',
ldapObjectGuid: guidHex,
isDefault: false,
},
];
mockSearch.mockResolvedValue({
searchEntries: [{ dn: 'cn=Sales,dc=example,dc=com', cn: 'Sales' }],
});
const result = makeResult();
await run(result);
expect(prisma.group.update).not.toHaveBeenCalled();
expect(result.groupsRenamed).toBe(0);
expect(result.groupsDeleted).toBe(0);
expect(result.errors).toEqual([]);
});
it('a hit with a changed cn/dn updates name and ldapDn and increments groupsRenamed, never writing internalName (SC-3, D-04)', async () => {
groups = [
{
id: 'g1',
tenantId: 't1',
name: 'Sales',
ldapDn: 'cn=Sales,dc=example,dc=com',
ldapObjectGuid: guidHex,
isDefault: false,
},
];
mockSearch.mockResolvedValue({
searchEntries: [{ dn: 'cn=Vertrieb,dc=example,dc=com', cn: 'Vertrieb' }],
});
const result = makeResult();
await run(result);
expect(prisma.group.update).toHaveBeenCalledWith({
where: { id: 'g1' },
data: { name: 'Vertrieb', ldapDn: 'cn=Vertrieb,dc=example,dc=com' },
});
expect(result.groupsRenamed).toBe(1);
expect(groups[0].name).toBe('Vertrieb');
expect(groups[0].ldapDn).toBe('cn=Vertrieb,dc=example,dc=com');
});
it('a rename colliding with an existing local name (P2002 on the name unique index) is reported and the group is left unchanged, run continues', async () => {
groups = [
{
id: 'g1',
tenantId: 't1',
name: 'Sales',
ldapDn: 'cn=Sales,dc=example,dc=com',
ldapObjectGuid: guidHex,
isDefault: false,
},
{
id: 'g2',
tenantId: 't1',
name: 'IT',
ldapDn: 'cn=IT,dc=example,dc=com',
ldapObjectGuid: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
isDefault: false,
},
];
prisma.group.update = vi.fn((args: any) => {
if (args.where.id === 'g1') {
const err: any = new Error('Unique constraint');
err.code = 'P2002';
// WR-02 (16-REVIEW.md): a realistic @@unique([tenantId, name])
// violation carries this target — the fix must inspect it rather
// than assuming every P2002 on this update() is a name collision.
err.meta = { target: ['tenantId', 'name'] };
return Promise.reject(err);
}
const g = groups.find((x) => x.id === args.where.id);
if (g) {
Object.assign(g, args.data);
}
return Promise.resolve(g);
});
// g1's AD search hit renames it to "IT" (collides with g2's stored
// name); g2's own AD search hit renames it away to "IT-Extern" — both
// candidates genuinely change, so both reach the update() call and the
// "run continues" claim is observable (2 update attempts, not 1).
mockSearch
.mockImplementationOnce(() =>
Promise.resolve({
searchEntries: [{ dn: 'cn=IT,dc=example,dc=com', cn: 'IT' }],
}),
)
.mockImplementationOnce(() =>
Promise.resolve({
searchEntries: [
{ dn: 'cn=IT-Extern,dc=example,dc=com', cn: 'IT-Extern' },
],
}),
);
const result = makeResult();
await run(result);
expect(result.errors).toEqual([
"Gruppe Sales: Umbenennung nach 'IT' kollidiert mit einer bestehenden Gruppe",
]);
expect(result.groupsRenamed).toBe(1);
expect(groups[0].name).toBe('Sales');
expect(groups[1].name).toBe('IT-Extern');
// The second candidate was still processed (run continues).
expect(prisma.group.update).toHaveBeenCalledTimes(2);
});
it('a rename P2002 whose meta.target does NOT include name (e.g. the ldapDn unique index) is reported as a binding collision, not mislabelled as a name collision (WR-02, 16-REVIEW.md)', async () => {
groups = [
{
id: 'g1',
tenantId: 't1',
name: 'Sales',
ldapDn: 'cn=Sales,dc=example,dc=com',
ldapObjectGuid: guidHex,
isDefault: false,
},
];
prisma.group.update = vi.fn((args: any) => {
const err: any = new Error('Unique constraint');
err.code = 'P2002';
// A P2002 on the OTHER unique index this update() can hit —
// @@unique([tenantId, ldapDn]) — must not be reported as "kollidiert
// mit einer bestehenden Gruppe" (that message is specifically for a
// name collision).
err.meta = { target: ['tenantId', 'ldapDn'] };
return Promise.reject(err);
});
mockSearch.mockResolvedValue({
searchEntries: [{ dn: 'cn=Vertrieb,dc=example,dc=com', cn: 'Vertrieb' }],
});
const result = makeResult();
await run(result);
expect(result.errors).toEqual([
'Gruppe Sales: Aktualisierung kollidiert mit einer bestehenden Bindung (["tenantId","ldapDn"])',
]);
expect(result.errors[0]).not.toContain('Umbenennung nach');
expect(result.groupsRenamed).toBe(0);
expect(groups[0].name).toBe('Sales');
});
it('no hit, not the default group, deletes it and increments groupsDeleted without moving the marker', async () => {
groups = [
{
id: 'g1',
tenantId: 't1',
name: 'Sales',
ldapDn: 'cn=Sales,dc=example,dc=com',
ldapObjectGuid: guidHex,
isDefault: false,
},
{
id: 'g-other',
tenantId: 't1',
name: 'Alle Benutzer',
ldapDn: null,
ldapObjectGuid: null,
isDefault: true,
},
];
mockSearch.mockResolvedValue({ searchEntries: [] });
const result = makeResult();
await run(result);
expect(groupsService.reassignDefaultBeforeDelete).toHaveBeenCalledWith('t1', 'g1');
expect(prisma.group.delete).toHaveBeenCalledWith({ where: { id: 'g1' } });
expect(result.groupsDeleted).toBe(1);
expect(result.defaultMarkerMoved).toBe(0);
expect(groups.find((g) => g.id === 'g1')).toBeUndefined();
// A deletion happened this run — ensureDefaultGroup runs once as the
// Pitfall-5 fallback, even though a target already existed.
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith('t1');
});
it('no hit, IS the default group, another group exists — handoff runs BEFORE the delete and increments defaultMarkerMoved (D-06)', async () => {
groups = [
{
id: 'g1',
tenantId: 't1',
name: 'Sales',
ldapDn: 'cn=Sales,dc=example,dc=com',
ldapObjectGuid: guidHex,
isDefault: true,
},
];
groupsService.reassignDefaultBeforeDelete.mockResolvedValue(true);
mockSearch.mockResolvedValue({ searchEntries: [] });
const callOrder: string[] = [];
groupsService.reassignDefaultBeforeDelete.mockImplementation(async () => {
callOrder.push('handoff');
return true;
});
prisma.group.delete = vi.fn((args: any) => {
callOrder.push('delete');
const idx = groups.findIndex((x) => x.id === args.where.id);
const [removed] = groups.splice(idx, 1);
return Promise.resolve(removed);
});
const result = makeResult();
await run(result);
expect(callOrder).toEqual(['handoff', 'delete']);
expect(result.defaultMarkerMoved).toBe(1);
expect(result.groupsDeleted).toBe(1);
});
it('no hit, is the ONLY group of the tenant — after the delete, ensureDefaultGroup rebuilds the default group', async () => {
groups = [
{
id: 'g1',
tenantId: 't1',
name: 'Sales',
ldapDn: 'cn=Sales,dc=example,dc=com',
ldapObjectGuid: guidHex,
isDefault: true,
},
];
groupsService.reassignDefaultBeforeDelete.mockResolvedValue(false);
mockSearch.mockResolvedValue({ searchEntries: [] });
const result = makeResult();
await run(result);
expect(result.groupsDeleted).toBe(1);
expect(groups).toEqual([]);
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith('t1');
});
it('a P2025 on the delete (already gone, concurrent manual delete) is swallowed and not double-counted', async () => {
groups = [
{
id: 'g1',
tenantId: 't1',
name: 'Sales',
ldapDn: 'cn=Sales,dc=example,dc=com',
ldapObjectGuid: guidHex,
isDefault: false,
},
];
mockSearch.mockResolvedValue({ searchEntries: [] });
prisma.group.delete = vi.fn(() => {
const err: any = new Error('Record to delete does not exist.');
err.code = 'P2025';
return Promise.reject(err);
});
const result = makeResult();
await run(result);
expect(result.groupsDeleted).toBe(0);
expect(result.errors).toEqual([]);
});
it('a legacy binding (ldapDn set, no ldapObjectGuid) whose DN still resolves is backfilled, groupsAdopted increments, and it is reconciled in the same pass (D-07)', async () => {
groups = [
{
id: 'g1',
tenantId: 't1',
name: 'Sales',
ldapDn: 'cn=Sales,dc=example,dc=com',
ldapObjectGuid: null,
isDefault: false,
},
];
mockSearch.mockImplementation((_dn: string, opts: any) => {
if (opts.scope === 'base') {
return Promise.resolve({
searchEntries: [
{ dn: 'cn=Sales,dc=example,dc=com', cn: 'Sales', objectGUID: guidBuffer },
],
});
}
// Existence sweep: same, unchanged group — no rename.
return Promise.resolve({
searchEntries: [{ dn: 'cn=Sales,dc=example,dc=com', cn: 'Sales' }],
});
});
const result = makeResult();
await run(result);
expect(result.groupsAdopted).toBe(1);
expect(groups[0].ldapObjectGuid).toBe(guidHex);
// Only the adoption write happened — cn/dn were already current, no
// rename update on top of it.
expect(prisma.group.update).toHaveBeenCalledTimes(1);
expect(prisma.group.update).toHaveBeenCalledWith({
where: { id: 'g1' },
data: { ldapObjectGuid: guidHex },
});
expect(result.errors).toEqual([]);
});
it('a legacy binding whose DN no longer resolves is NOT deleted — error line only (D-07/T-16-11)', async () => {
groups = [
{
id: 'g1',
tenantId: 't1',
name: 'Sales',
ldapDn: 'cn=Sales,dc=example,dc=com',
ldapObjectGuid: null,
isDefault: false,
},
];
mockSearch.mockResolvedValue({ searchEntries: [] });
const result = makeResult();
await run(result);
expect(prisma.group.delete).not.toHaveBeenCalled();
expect(prisma.group.update).not.toHaveBeenCalled();
expect(result.groupsDeleted).toBe(0);
expect(result.groupsAdopted).toBe(0);
expect(result.errors).toEqual([
'Gruppe Sales: Alt-Bindung cn=Sales,dc=example,dc=com laesst sich nicht mehr aufloesen',
]);
expect(groups).toHaveLength(1);
});
it('an invalid stored ldapObjectGuid (not 32 [0-9a-f] chars) never reaches a filter — error line only', async () => {
groups = [
{
id: 'g1',
tenantId: 't1',
name: 'Sales',
ldapDn: 'cn=Sales,dc=example,dc=com',
ldapObjectGuid: 'not-a-valid-hex-guid',
isDefault: false,
},
];
const result = makeResult();
await run(result);
expect(mockSearch).not.toHaveBeenCalled();
expect(result.errors).toEqual([
'Gruppe Sales: ungültiger ldapObjectGuid-Wert',
]);
expect(prisma.group.delete).not.toHaveBeenCalled();
});
it('a client.search exception for one group is recorded with the group name in result.errors, remaining groups still processed', async () => {
groups = [
{
id: 'g-broken',
tenantId: 't1',
name: 'Broken',
ldapDn: 'cn=Broken,dc=example,dc=com',
ldapObjectGuid: guidHex,
isDefault: false,
},
{
id: 'g-ok',
tenantId: 't1',
name: 'OK',
ldapDn: 'cn=OK,dc=example,dc=com',
ldapObjectGuid: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
isDefault: false,
},
];
mockSearch.mockImplementation((_baseDn: string, opts: any) => {
const swept = sweptGuid(opts.filter);
if (swept && swept.equals(guidBuffer)) {
return Promise.reject(new Error('directory unavailable'));
}
return Promise.resolve({
searchEntries: [{ dn: 'cn=OK,dc=example,dc=com', cn: 'OK' }],
});
});
const result = makeResult();
await run(result);
expect(result.errors).toEqual(['Gruppe Broken: directory unavailable']);
// The healthy group was still processed (no write needed — unchanged).
expect(groups.find((g) => g.id === 'g-ok')).toBeDefined();
});
// Quick task 260811-f9i. The sweep used to interpolate a byte-wise `\xx`
// escape into a filter STRING. Measured read-only against a real AD on
// 2026-08-11, that filter returned zero hits for an object whose GUID had
// just been read from that same directory — so every bound group looked
// deleted and would have been removed with its memberships and module grants
// on the first real sync. These two tests pin the shape that actually works.
it('passes the GUID as raw Buffer bytes on a filter object, never as an escaped filter string', async () => {
groups = [
{
id: 'g1',
tenantId: 't1',
name: 'Sales',
ldapDn: 'cn=Sales,dc=example,dc=com',
ldapObjectGuid: guidHex,
isDefault: false,
},
];
mockSearch.mockResolvedValue({
searchEntries: [{ dn: 'cn=Sales,dc=example,dc=com', cn: 'Sales' }],
});
await run(makeResult());
const sweepCalls = mockSearch.mock.calls.filter(([, opts]: any) =>
sweptGuid(opts.filter),
);
expect(sweepCalls.length).toBeGreaterThan(0);
for (const [, opts] of sweepCalls as any[]) {
const value = opts.filter.value;
expect(Buffer.isBuffer(value)).toBe(true);
// Byte-for-byte the stored hex, not a re-encoding of it.
expect((value as Buffer).equals(guidBuffer)).toBe(true);
}
// No search in this run may carry a stringly-typed objectGUID filter —
// that is the exact shape the directory refused to match.
const stringGuidFilters = mockSearch.mock.calls.filter(
([, opts]: any) =>
typeof opts.filter === 'string' && opts.filter.includes('objectGUID='),
);
expect(stringGuidFilters).toEqual([]);
});
it('reuses the same raw-Buffer filter for the wider WR-03 move sweep', async () => {
groups = [
{
id: 'g1',
tenantId: 't1',
name: 'Sales',
ldapDn: 'cn=Sales,ou=Vertrieb,dc=example,dc=com',
ldapObjectGuid: guidHex,
isDefault: false,
},
];
// A COPY — `cfg` is shared across this describe block and never reset in
// beforeEach, so mutating it here would leak into later tests.
const narrowCfg = { ...cfg, baseDn: 'ou=Vertrieb,dc=example,dc=com' };
// Narrow sweep misses, wide sweep over the domain root finds it —
// the move case WR-03 protects. Both must carry the same Buffer, or the
// protection inherits a broken filter and deletes the group anyway.
mockSearch.mockImplementation((baseDn: string) =>
Promise.resolve(
baseDn === 'dc=example,dc=com'
? {
searchEntries: [
{ dn: 'cn=Sales,ou=Anders,dc=example,dc=com', cn: 'Sales' },
],
}
: { searchEntries: [] },
),
);
const result = makeResult();
await (service as any).syncBoundGroupsForTenant(
client,
narrowCfg,
't1',
result,
);
const sweepCalls = mockSearch.mock.calls.filter(([, opts]: any) =>
sweptGuid(opts.filter),
);
// Narrow (configured base DN) plus wide (domain root).
expect(sweepCalls.length).toBe(2);
for (const [, opts] of sweepCalls as any[]) {
expect((opts.filter.value as Buffer).equals(guidBuffer)).toBe(true);
}
expect(prisma.group.delete).not.toHaveBeenCalled();
expect(result.errors.length).toBe(1);
expect(result.errors[0]).toContain('Nicht gelöscht');
});
it('is idempotent: a second run over an unchanged AD state issues no group.update or group.delete call', async () => {
groups = [
{
id: 'g1',
tenantId: 't1',
name: 'Sales',
ldapDn: 'cn=Sales,dc=example,dc=com',
ldapObjectGuid: guidHex,
isDefault: false,
},
];
mockSearch.mockResolvedValue({
searchEntries: [{ dn: 'cn=Sales,dc=example,dc=com', cn: 'Sales' }],
});
await run(makeResult());
prisma.group.update.mockClear();
prisma.group.delete.mockClear();
const second = makeResult();
await run(second);
expect(prisma.group.update).not.toHaveBeenCalled();
expect(prisma.group.delete).not.toHaveBeenCalled();
expect(second.groupsRenamed).toBe(0);
expect(second.groupsDeleted).toBe(0);
});
it('a hit whose cn/dn differ only in casing from the stored values is NOT a rename — no write, no groupsRenamed increment (WR-04, 16-REVIEW.md)', async () => {
// Simulates a domain-controller switch returning different casing for
// the same, unchanged AD group between two syncs — Priority Check 7
// ("sync twice over an unchanged AD state = no-op") must hold even
// then.
groups = [
{
id: 'g1',
tenantId: 't1',
name: 'Sales',
ldapDn: 'CN=Sales,DC=example,DC=com',
ldapObjectGuid: guidHex,
isDefault: false,
},
];
mockSearch.mockResolvedValue({
searchEntries: [{ dn: 'cn=sales,dc=example,dc=com', cn: 'sales' }],
});
const result = makeResult();
await run(result);
expect(prisma.group.update).not.toHaveBeenCalled();
expect(result.groupsRenamed).toBe(0);
// The stored value is untouched — it is NOT rewritten to the
// differently-cased AD response either.
expect(groups[0].name).toBe('Sales');
expect(groups[0].ldapDn).toBe('CN=Sales,DC=example,DC=com');
});
it('a bound group not found under the configured (narrower) base DN, but found under the wider domain root, is NOT deleted — reported instead (WR-03, 16-REVIEW.md)', async () => {
// The configured base DN is an OU beneath the domain root — an AD
// group moved OUT of that OU (into ou=Archive, still under the same
// domain) is a directory-internal move, not a deletion.
const narrowCfg = { ...cfg, baseDn: 'ou=Sales,dc=example,dc=com' };
groups = [
{
id: 'g1',
tenantId: 't1',
name: 'Sales',
ldapDn: 'cn=Sales,ou=Sales,dc=example,dc=com',
ldapObjectGuid: guidHex,
isDefault: false,
},
];
mockSearch.mockImplementation((baseDn: string) => {
if (baseDn === 'ou=Sales,dc=example,dc=com') {
// Nothing found under the configured (narrow) base DN.
return Promise.resolve({ searchEntries: [] });
}
if (baseDn === 'dc=example,dc=com') {
// But the group is still there, just moved to a different OU under
// the same domain — the WR-03 wide sweep must catch this.
return Promise.resolve({
searchEntries: [
{ dn: 'cn=Sales,ou=Archive,dc=example,dc=com', cn: 'Sales' },
],
});
}
throw new Error(`unexpected base DN in test: ${baseDn}`);
});
const result = makeResult();
await (service as any).syncBoundGroupsForTenant(
client,
narrowCfg,
't1',
result,
);
expect(prisma.group.delete).not.toHaveBeenCalled();
expect(prisma.group.update).not.toHaveBeenCalled();
expect(result.groupsDeleted).toBe(0);
expect(result.errors).toEqual([
"Gruppe Sales: nicht mehr unter den konfigurierten Base-DNs gefunden, existiert aber weiterhin unter 'cn=Sales,ou=Archive,dc=example,dc=com' — vermutlich im Verzeichnis verschoben, Base-DN-Konfiguration prüfen. Nicht gelöscht.",
]);
expect(groups).toHaveLength(1);
});
it('a bound group not found under the configured base DN AND not found under the wider domain root either is genuinely deleted (WR-03, 16-REVIEW.md)', async () => {
const narrowCfg = { ...cfg, baseDn: 'ou=Sales,dc=example,dc=com' };
groups = [
{
id: 'g1',
tenantId: 't1',
name: 'Sales',
ldapDn: 'cn=Sales,ou=Sales,dc=example,dc=com',
ldapObjectGuid: guidHex,
isDefault: false,
},
{
id: 'g-other',
tenantId: 't1',
name: 'Alle Benutzer',
ldapDn: null,
ldapObjectGuid: null,
isDefault: true,
},
];
// Empty everywhere: under the configured OU AND under the domain root.
mockSearch.mockResolvedValue({ searchEntries: [] });
const result = makeResult();
await (service as any).syncBoundGroupsForTenant(
client,
narrowCfg,
't1',
result,
);
expect(prisma.group.delete).toHaveBeenCalledWith({ where: { id: 'g1' } });
expect(result.groupsDeleted).toBe(1);
expect(result.errors).toEqual([]);
});
it('skips the wide fallback sweep entirely when the configured base DN already IS the domain root — no redundant client.search call (WR-03, 16-REVIEW.md)', async () => {
groups = [
{
id: 'g1',
tenantId: 't1',
name: 'Sales',
ldapDn: 'cn=Sales,dc=example,dc=com',
ldapObjectGuid: guidHex,
isDefault: false,
},
{
id: 'g-other',
tenantId: 't1',
name: 'Alle Benutzer',
ldapDn: null,
ldapObjectGuid: null,
isDefault: true,
},
];
mockSearch.mockResolvedValue({ searchEntries: [] });
const result = makeResult();
await run(result); // cfg.baseDn === 'dc=example,dc=com', already the domain root
expect(mockSearch).toHaveBeenCalledTimes(1);
expect(result.groupsDeleted).toBe(1);
});
});
describe('LdapService — AD group import (SC-1/SC-2, D-01/D-02)', () => {
let service: LdapService;
let prisma: 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' }],
};
// 16 raw bytes, hex-decodable to a stable 32-char lowercase string —
// stands in for a real AD objectGUID.
const guidBuffer = Buffer.from('0123456789abcdef0123456789abcde', 'hex');
const guidHex = guidBuffer.toString('hex');
beforeEach(() => {
vi.clearAllMocks();
mockBind.mockResolvedValue(undefined);
mockUnbind.mockResolvedValue(undefined);
prisma = {
group: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue({}),
},
};
userService = { create: vi.fn().mockResolvedValue({}) };
service = new LdapService(prisma, userService, {} as any);
});
it('importGroupsByDn creates a Group with name/ldapDn/ldapObjectGuid and counts imported', async () => {
mockSearch.mockResolvedValue({
searchEntries: [
{
dn: 'cn=Sales,ou=groups,dc=example,dc=com',
cn: 'Sales',
objectGUID: guidBuffer,
},
],
});
const res = await service.importGroupsByDn(cfg as any, 't1', [
'cn=Sales,ou=groups,dc=example,dc=com',
]);
expect(res.imported).toBe(1);
expect(res.skipped).toBe(0);
expect(res.errors).toEqual([]);
expect(res.nameCollisions).toEqual([]);
expect(prisma.group.create).toHaveBeenCalledWith({
data: {
tenantId: 't1',
name: 'Sales',
ldapDn: 'cn=Sales,ou=groups,dc=example,dc=com',
ldapObjectGuid: guidHex,
},
});
});
it('importGroupsByDn skips a DN whose ldapObjectGuid already exists for this tenant (no duplicate row)', async () => {
mockSearch.mockResolvedValue({
searchEntries: [
{
dn: 'cn=Sales,ou=groups,dc=example,dc=com',
cn: 'Sales',
objectGUID: guidBuffer,
},
],
});
prisma.group.findFirst.mockResolvedValue({ id: 'g1', ldapObjectGuid: guidHex });
const res = await service.importGroupsByDn(cfg as any, 't1', [
'cn=Sales,ou=groups,dc=example,dc=com',
]);
expect(res.imported).toBe(0);
expect(res.skipped).toBe(1);
expect(prisma.group.create).not.toHaveBeenCalled();
});
it('importGroupsByDn records a DN with no AD hit as an error line, not a Group row', async () => {
mockSearch.mockResolvedValue({ searchEntries: [] });
const res = await service.importGroupsByDn(cfg as any, 't1', [
'cn=Ghost,ou=groups,dc=example,dc=com',
]);
expect(res.imported).toBe(0);
expect(res.errors).toEqual([
'cn=Ghost,ou=groups,dc=example,dc=com: not found',
]);
expect(prisma.group.create).not.toHaveBeenCalled();
});
it('importGroupsByDn reports a name collision (P2002 on tenantId,name) without aborting the remaining DNs', async () => {
mockSearch.mockImplementation((dn: string) => {
if (dn === 'cn=Collide,ou=groups,dc=example,dc=com') {
return Promise.resolve({
searchEntries: [
{ dn, cn: 'Collide', objectGUID: guidBuffer },
],
});
}
return Promise.resolve({
searchEntries: [
{
dn,
cn: 'Second',
objectGUID: Buffer.from(
'ffffffffffffffffffffffffffffffff',
'hex',
),
},
],
});
});
const nameCollisionError = Object.assign(new Error('Unique constraint'), {
code: 'P2002',
meta: { target: ['tenantId', 'name'] },
});
prisma.group.create
.mockRejectedValueOnce(nameCollisionError)
.mockResolvedValueOnce({});
const res = await service.importGroupsByDn(cfg as any, 't1', [
'cn=Collide,ou=groups,dc=example,dc=com',
'cn=Second,ou=groups,dc=example,dc=com',
]);
expect(res.nameCollisions).toEqual(['Collide']);
expect(res.imported).toBe(1);
expect(res.errors).toEqual([]);
expect(prisma.group.create).toHaveBeenCalledTimes(2);
});
it('importGroupsByDn treats a P2002 on (tenantId, ldapObjectGuid) like skipped, not an error', async () => {
mockSearch.mockResolvedValue({
searchEntries: [
{
dn: 'cn=Sales,ou=groups,dc=example,dc=com',
cn: 'Sales',
objectGUID: guidBuffer,
},
],
});
const raceLossError = Object.assign(new Error('Unique constraint'), {
code: 'P2002',
meta: { target: ['tenantId', 'ldapObjectGuid'] },
});
prisma.group.create.mockRejectedValue(raceLossError);
const res = await service.importGroupsByDn(cfg as any, 't1', [
'cn=Sales,ou=groups,dc=example,dc=com',
]);
expect(res.skipped).toBe(1);
expect(res.imported).toBe(0);
expect(res.nameCollisions).toEqual([]);
expect(res.errors).toEqual([]);
});
it('importGroupsByDn records an entry with no readable objectGUID buffer as an error, never a mis-stringified value', async () => {
mockSearch.mockResolvedValue({
searchEntries: [
{
dn: 'cn=NoBuffer,ou=groups,dc=example,dc=com',
cn: 'NoBuffer',
// Missing/absent objectGUID, exactly as ldapts represents it.
objectGUID: [],
},
],
});
const res = await service.importGroupsByDn(cfg as any, 't1', [
'cn=NoBuffer,ou=groups,dc=example,dc=com',
]);
expect(res.imported).toBe(0);
expect(res.errors).toEqual([
'cn=NoBuffer,ou=groups,dc=example,dc=com: objectGUID not readable',
]);
expect(prisma.group.create).not.toHaveBeenCalled();
});
it('listGroups marks entries as alreadyImported by matching hex ldapObjectGuid for this tenant', async () => {
const otherGuid = Buffer.from('11'.repeat(16), 'hex');
mockSearch.mockResolvedValue({
searchEntries: [
{ dn: 'cn=Sales,ou=groups,dc=example,dc=com', cn: 'Sales', objectGUID: guidBuffer },
{ dn: 'cn=IT,ou=groups,dc=example,dc=com', cn: 'IT', objectGUID: otherGuid },
{ dn: 'ou=groups,dc=example,dc=com', ou: 'groups' },
],
});
prisma.group.findMany.mockResolvedValue([{ ldapObjectGuid: guidHex }]);
const res = await service.listGroups(cfg as any, 't1');
expect(res.find((e) => e.name === 'Sales')?.alreadyImported).toBe(true);
expect(res.find((e) => e.name === 'IT')?.alreadyImported).toBe(false);
expect(res.find((e) => e.type === 'ou')?.alreadyImported).toBe(false);
});
it('listGroups sorts results by name (localeCompare), then dn on a tie', async () => {
mockSearch.mockResolvedValue({
searchEntries: [
{ dn: 'cn=Zebra,ou=groups,dc=example,dc=com', cn: 'Zebra', objectGUID: [] },
{ dn: 'cn=Apple,ou=b,dc=example,dc=com', cn: 'Apple', objectGUID: [] },
{ dn: 'cn=Apple,ou=a,dc=example,dc=com', cn: 'Apple', objectGUID: [] },
],
});
const res = await service.listGroups(cfg as any, 't1');
expect(res.map((e) => e.dn)).toEqual([
'cn=Apple,ou=a,dc=example,dc=com',
'cn=Apple,ou=b,dc=example,dc=com',
'cn=Zebra,ou=groups,dc=example,dc=com',
]);
});
});
/**
* 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();
});
});