feat(ldap): individual user search + selective import with dedup
Tessera CI/CD / Lint & Type Check (push) Successful in 49s
Tessera CI/CD / Tests (push) Successful in 47s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m45s

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:
2026-07-22 13:52:50 +02:00
parent 0dd104054b
commit 38face43b4
7 changed files with 744 additions and 59 deletions
+152
View File
@@ -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();
});
});