feat(ldap): individual user search + selective import with dedup
Add an AD single-user search (by cn/sAMAccountName/displayName/mail) and a selective import to the LDAP admin page, alongside the existing group/OU filter. Imported users are deduped against existing ones by (ldapDn, then username): a manually-imported user carries its ldapDn, so a later department/group sync matches and updates it in place instead of creating a duplicate. Search results flag alreadyImported; import skips existing users and links a missing ldapDn. Extracted shared mapEntry/upsertMappedUser helpers so sync and manual import resolve identity identically. Backend: GET /ldap/users/search, POST /ldap/users/import (RFC-4515 escaped query, ADMIN-guarded). 6 new service specs (search flags, create, skip, ldapDn-link, denylist). Full API suite 215 green, both apps tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -113,3 +113,155 @@ describe('LdapService.syncUsersForTenant — per-user exclude list', () => {
|
||||
expect(result.deactivated).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user