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:
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user