Files
tessera-ctl/apps/api/src/ldap/ldap.service.spec.ts
T
schalli 522293417a feat(16-03): add syncBoundGroupsForTenant reconciliation method
- New private LdapService.syncBoundGroupsForTenant(): rename detection
  (SC-3), disappearance deletion with default-marker handoff before delete
  (SC-4/D-05/D-06), legacy ldapDn-only binding GUID backfill (D-07), and a
  32-hex-char guard before any objectGUID filter interpolation (T-16-01)
- LdapSyncResult grows additively: groupsAdopted, groupsRenamed,
  groupsDeleted, defaultMarkerMoved
- LdapService constructor takes GroupsService; LdapModule imports
  GroupsModule (no cycle)
- 14 new test cases covering the full behavior matrix plus idempotency
2026-08-06 16:15:09 +02:00

1613 lines
54 KiB
TypeScript

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<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' }],
};
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<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,
),
),
),
},
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',
]);
});
});