522293417a
- 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
1613 lines
54 KiB
TypeScript
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',
|
|
]);
|
|
});
|
|
});
|