Files

66 lines
5.1 KiB
TypeScript

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<string, string>[], 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<import('node:net').Socket>();
const server = createServer(socket => { sockets.add(socket); socket.on('close', () => sockets.delete(socket)); });
await new Promise<void>(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<void>(resolve => server.close(() => resolve())); }
}, 7000);
});