import { afterEach, describe, expect, it, vi } from 'vitest'; import { Client } from 'ldapts'; import { createServer } from 'node:net'; import { defaultDirectorySettings, directoryUsername, lookupDirectoryName, validateDirectorySettings, type DirectoryConfig } from '../../src/directory'; const config: DirectoryConfig = { id: 'test-config', issuer: 'https://sso.example.invalid', settings: { ...defaultDirectorySettings(), enabled: true, url: 'ldaps://nas.example.invalid:636', baseDn: 'DC=example,DC=invalid', bindDn: 'reader@example.invalid', bindPassword: 'test-only-secret' } }; const identity = { issuer: config.issuer, subject: 'REZNIK\\tom.ruff' }; afterEach(() => vi.restoreAllMocks()); function mockDirectory(entries: Record[], references: string[] = []) { const bind = vi.spyOn(Client.prototype, 'bind').mockResolvedValue(); const search = vi.spyOn(Client.prototype, 'search').mockResolvedValue({ searchEntries: entries.map(entry => ({ dn: 'CN=test', ...entry })), searchReferences: references }); const unbind = vi.spyOn(Client.prototype, 'unbind').mockResolvedValue(); return { bind, search, unbind }; } describe('AD display-name enrichment', () => { it('requires the exact configured issuer and domain-qualified subject before connecting', async () => { const ldap = mockDirectory([]); expect(directoryUsername(config, { ...identity, subject: 'reznik\\tom.ruff' })).toBe('tom.ruff'); for (const subject of ['tom.ruff', 'RUFF\\tom.ruff', 'REZNIK\\', 'REZNIK\\tom\\other']) expect(await lookupDirectoryName(config, { ...identity, subject })).toEqual({ code: 'IDENTITY_MISMATCH' }); expect(await lookupDirectoryName(config, { ...identity, issuer: 'https://other.invalid' })).toEqual({ code: 'IDENTITY_MISMATCH' }); expect(await lookupDirectoryName({ ...config, settings: defaultDirectorySettings() }, identity)).toEqual({ code: 'DISABLED' }); expect(ldap.bind).not.toHaveBeenCalled(); }); it('rejects plaintext, embedded credentials, URL paths and missing bind credentials', () => { for (const url of ['ldap://nas:389', 'https://nas', 'ldaps://user:password@nas', 'ldaps://nas/directory', 'ldaps://nas?query=1']) expect(() => validateDirectorySettings({ ...config.settings, url })).toThrow(); expect(() => validateDirectorySettings({ ...config.settings, bindPassword: '' })).toThrow(); expect(() => validateDirectorySettings(config.settings)).not.toThrow(); }); it('reads Hebrew displayName and encodes special characters as a literal equality value', async () => { const username = 'tom*)(sAMAccountName=*)'; const ldap = mockDirectory([{ sAMAccountName: username, displayName: 'תום רוף' }]); expect(await lookupDirectoryName(config, { ...identity, subject: `REZNIK\\${username}` })).toEqual({ code: 'FOUND', displayName: 'תום רוף' }); const options = ldap.search.mock.calls[0]![1]!; expect(String(options.filter)).toContain('tom\\2a\\29\\28sAMAccountName=\\2a\\29'); expect(options).toMatchObject({ scope: 'sub', sizeLimit: 2, timeLimit: 2 }); expect(ldap.unbind).toHaveBeenCalledOnce(); }); it('joins first/surname and refuses ambiguous or mismatched results', async () => { const ldap = mockDirectory([{ samaccountname: 'TOM.RUFF', givenName: 'תום', sn: 'רוף' }]); expect(await lookupDirectoryName(config, identity)).toEqual({ code: 'FOUND', displayName: 'תום רוף' }); ldap.search.mockResolvedValue({ searchEntries: [{ dn: 'a' }, { dn: 'b' }], searchReferences: [] }); expect(await lookupDirectoryName(config, identity)).toEqual({ code: 'AMBIGUOUS' }); ldap.search.mockResolvedValue({ searchEntries: [{ dn: 'a', sAMAccountName: 'other', displayName: 'Someone else' }], searchReferences: [] }); expect(await lookupDirectoryName(config, identity)).toEqual({ code: 'NOT_FOUND' }); ldap.search.mockResolvedValue({ searchEntries: [], searchReferences: ['ldaps://other.invalid'] }); expect(await lookupDirectoryName(config, identity)).toEqual({ code: 'AMBIGUOUS' }); }); it('sanitizes failures and always releases the connection', async () => { const ldap = mockDirectory([]); ldap.bind.mockRejectedValue(new Error('test-only-secret: bind rejected')); expect(await lookupDirectoryName(config, identity)).toEqual({ code: 'UNAVAILABLE' }); expect(ldap.unbind).toHaveBeenCalledOnce(); }); it('times out when a reachable host never completes TLS', async () => { const sockets = new Set(); const server = createServer(socket => { sockets.add(socket); socket.on('close', () => sockets.delete(socket)); }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); try { const port = (server.address() as import('node:net').AddressInfo).port; const started = Date.now(); expect(await lookupDirectoryName({ ...config, settings: { ...config.settings, url: `ldaps://127.0.0.1:${port}` } }, identity)).toEqual({ code: 'UNAVAILABLE' }); expect(Date.now() - started).toBeLessThan(5000); } finally { for (const socket of sockets) socket.destroy(); await new Promise(resolve => server.close(() => resolve())); } }, 7000); });