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
This commit is contained in:
2026-08-06 16:15:09 +02:00
parent da0361df5f
commit 522293417a
3 changed files with 751 additions and 9 deletions
+517 -8
View File
@@ -65,7 +65,7 @@ describe('LdapService.syncUsersForTenant — per-user exclude list', () => {
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
};
userService = { create: vi.fn().mockResolvedValue({}) };
service = new LdapService(prisma, userService);
service = new LdapService(prisma, userService, {} as any);
});
it('imports every user when the exclude list is empty', async () => {
@@ -162,7 +162,7 @@ describe('LdapService.syncUsersForTenant — empty base DN no-op', () => {
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
};
userService = { create: vi.fn().mockResolvedValue({}) };
service = new LdapService(prisma, userService);
service = new LdapService(prisma, userService, {} as any);
});
it('creates nobody and deactivates nobody when the base DN is empty (whitespace-only)', async () => {
@@ -177,6 +177,10 @@ describe('LdapService.syncUsersForTenant — empty base DN no-op', () => {
deactivated: 0,
groupMembershipsAdded: 0,
groupMembershipsRemoved: 0,
groupsAdopted: 0,
groupsRenamed: 0,
groupsDeleted: 0,
defaultMarkerMoved: 0,
errors: [],
});
expect(mockSearch).not.toHaveBeenCalled();
@@ -239,7 +243,7 @@ describe('LdapService.syncUsersForTenant — multi base DN scope', () => {
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
};
userService = { create: vi.fn().mockResolvedValue({}) };
service = new LdapService(prisma, userService);
service = new LdapService(prisma, userService, {} as any);
});
it('searches every configured base DN and merges/dedupes results by dn', async () => {
@@ -313,7 +317,7 @@ describe('LdapService — individual user search & import (dedup)', () => {
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
};
userService = { create: vi.fn().mockResolvedValue({}) };
service = new LdapService(prisma, userService);
service = new LdapService(prisma, userService, {} as any);
});
it('searchUsers flags results already present by username or ldapDn', async () => {
@@ -439,7 +443,7 @@ describe('LdapService.testConnection — TLS verification opt-out (ldaps)', () =
vi.clearAllMocks();
mockBind.mockResolvedValue(undefined);
mockUnbind.mockResolvedValue(undefined);
service = new LdapService({} as any, {} as any);
service = new LdapService({} as any, {} as any, {} as any);
});
it('passes tlsOptions.rejectUnauthorized=false for ldaps when opted out', async () => {
@@ -480,7 +484,7 @@ describe('LdapService.verifyUserCredentials — LDAP login bind', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUnbind.mockResolvedValue(undefined);
service = new LdapService({} as any, {} as any);
service = new LdapService({} as any, {} as any, {} as any);
});
it('returns true when the user bind succeeds', async () => {
@@ -639,7 +643,7 @@ describe('LdapService.syncUsersForTenant — AD-bound group membership sync (D-1
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
};
userService = { create: vi.fn().mockResolvedValue({}) };
service = new LdapService(prisma, userService);
service = new LdapService(prisma, userService, {} as any);
});
it('runs no additional LDAP search for a tenant without AD-bound groups', async () => {
@@ -875,6 +879,511 @@ describe('LdapService.syncUsersForTenant — AD-bound group membership sync (D-1
});
});
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;
@@ -908,7 +1417,7 @@ describe('LdapService — AD group import (SC-1/SC-2, D-01/D-02)', () => {
},
};
userService = { create: vi.fn().mockResolvedValue({}) };
service = new LdapService(prisma, userService);
service = new LdapService(prisma, userService, {} as any);
});
it('importGroupsByDn creates a Group with name/ldapDn/ldapObjectGuid and counts imported', async () => {