import { 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); vi.mock('ldapts', () => ({ Client: vi.fn().mockImplementation(() => ({ bind: mockBind, search: mockSearch, unbind: mockUnbind, })), })); // 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'; 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, 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), 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.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[]; 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' }], }; 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) => { if (typeof opts.filter === 'string' && opts.filter.includes('memberOf=')) { return Promise.resolve({ searchEntries: groupSearchEntries }); } // 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(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, ), ), ), }, 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]) => (opts.filter as string).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]) => !(opts.filter as string).includes('memberOf='), ); const groupSyncCall = mockSearch.mock.calls.find(([, opts]) => (opts.filter as string).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]) => (opts.filter as string).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) => { if (typeof opts.filter === 'string' && opts.filter.includes('CN=Broken')) { return Promise.reject(new Error('directory unavailable')); } if (typeof opts.filter === 'string' && opts.filter.includes('memberOf=')) { return Promise.resolve({ searchEntries: [{ dn: 'cn=alice,dc=example,dc=com', sAMAccountName: 'alice' }], }); } 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.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) 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'; 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('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: ungueltiger 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) => { if (opts.filter.includes(LdapService.escapeLdapFilterBuffer(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(); }); 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); }); }); 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', ]); }); });