feat(16-01): tracer — select and import AD groups end-to-end

Task 1 checkpoint resolved: approve-both, granted 2026-08-06 by the
project owner (D-04 one-way schema extension: Group.internalName +
Group.ldapObjectGuid, both nullable, one versioned migration).

Adds the Phase 16 tracer slice through every layer:
- Prisma schema: Group.internalName, Group.ldapObjectGuid,
  @@unique([tenantId, ldapObjectGuid]) (Prisma client regenerated;
  the versioned migration itself is Task 3, separately blocking).
- LdapService: listGroups() now reads objectGUID via
  explicitBufferAttributes and flags alreadyImported per tenant;
  new importGroupsByDn() creates a Group per checked DN with
  name/ldapDn/ldapObjectGuid, reject-with-report on name collision
  (P2002 on name -> nameCollisions, P2002 on ldapObjectGuid ->
  skipped), never aborts the batch on one DN's error; new static
  escapeLdapFilterBuffer() for Plan 16-03's later existence sweep.
- DTO/controller: ImportGroupsDto, POST /ldap/groups/import
  (ADMIN/SUPER_ADMIN), listGroups route now tenant-scoped.
- Frontend: new "AD-Gruppen importieren" section in /admin/ldap,
  own discovery/import handlers with a visible error state
  (Owner decision 2026-08-06 — no silent catch{} for these two
  handlers), i18n keys in de.json/en.json.
- Tests: 8 new cases covering the full <behavior> list plus
  listGroups sort order and alreadyImported.

Flagged assumption (RESEARCH.md A1/A2): objectGUID rename-stability
and the binary filter syntax are unverified against a real AD —
this plan only WRITES the GUID, Plan 16-03 reads it back live.
This commit is contained in:
2026-08-06 15:09:35 +02:00
parent c4a25511f3
commit 3523e43a13
8 changed files with 767 additions and 31 deletions
+227
View File
@@ -874,3 +874,230 @@ describe('LdapService.syncUsersForTenant — AD-bound group membership sync (D-1
);
});
});
describe('LdapService — AD group import (SC-1/SC-2, D-01/D-02)', () => {
let service: LdapService;
let prisma: any;
let userService: any;
const cfg = {
id: 'cfg1',
tenantId: 't1',
serverUrl: 'ldap://example',
baseDn: 'dc=example,dc=com',
searchFilter: '(objectClass=person)',
groupFilterDns: [] as string[],
userExcludeList: [] as string[],
fieldMappings: [{ ldapField: 'sAMAccountName', tesseraField: 'username' }],
};
// 16 raw bytes, hex-decodable to a stable 32-char lowercase string —
// stands in for a real AD objectGUID.
const guidBuffer = Buffer.from('0123456789abcdef0123456789abcde', 'hex');
const guidHex = guidBuffer.toString('hex');
beforeEach(() => {
vi.clearAllMocks();
mockBind.mockResolvedValue(undefined);
mockUnbind.mockResolvedValue(undefined);
prisma = {
group: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue({}),
},
};
userService = { create: vi.fn().mockResolvedValue({}) };
service = new LdapService(prisma, userService);
});
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',
]);
});
});