Extract API, migrations and portable NAS deployment
This commit is contained in:
commit
673255fa4f
57 files changed
+3799
No files matched your search
@@ -0,0 +1,2 @@
|
||||
// Only used against model_delivery_test. This is not a default application account.
|
||||
export const testAccount = { username: 'test.admin', password: 'Test-only-password-932!', displayName: 'משתמש בדיקה' };
|
||||
@@ -0,0 +1,55 @@
|
||||
// Test-only identity provider. Never imported or started by application code.
|
||||
import { createServer } from 'node:http';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { generateKeyPair, exportJWK, SignJWT } from 'jose';
|
||||
|
||||
export async function startMockOidc(port = 0) {
|
||||
const { privateKey, publicKey } = await generateKeyPair('RS256');
|
||||
const jwk = { ...await exportJWK(publicKey), kid: 'test-key', use: 'sig', alg: 'RS256' };
|
||||
const codes = new Map(), refreshTokens = new Set(), accessTokens = new Set();
|
||||
const controls = { badNonce: false, denyRefresh: false, refreshCount: 0, omitName: false, omitRefreshToken: false, subject: 'synology-test-user', groups: ['model-delivery-users'] };
|
||||
let issuer = '';
|
||||
const server = createServer(async (req, res) => {
|
||||
const profile = { sub: controls.subject, username: 'test.sso', ...(controls.omitName ? {} : { name: 'טום בדיקה' }), email: 'test@example.invalid', groups: controls.groups };
|
||||
const json = (data, status = 200) => { res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(data)); };
|
||||
const url = new URL(req.url, issuer);
|
||||
try {
|
||||
if (url.pathname === '/.well-known/openid-configuration') return json({ issuer, authorization_endpoint: `${issuer}/authorize`, token_endpoint: `${issuer}/token`, userinfo_endpoint: `${issuer}/userinfo`, jwks_uri: `${issuer}/jwks`, response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'], subject_types_supported: ['public'], id_token_signing_alg_values_supported: ['RS256'], token_endpoint_auth_methods_supported: ['client_secret_post'], scopes_supported: ['openid', 'profile', 'email', 'groups', 'offline_access'], claims_supported: ['sub', 'username', 'name', 'email', 'groups'], code_challenge_methods_supported: ['S256'] });
|
||||
if (url.pathname === '/jwks') return json({ keys: [jwk] });
|
||||
if (url.pathname === '/authorize') {
|
||||
const values = Object.fromEntries(url.searchParams);
|
||||
if (values.client_id !== 'model-test' || values.redirect_uri !== 'http://localhost:4174/api/auth/callback' || values.code_challenge_method !== 'S256' || !values.code_challenge || !values.state || !values.nonce) return json({ error: 'invalid_request' }, 400);
|
||||
const code = randomBytes(24).toString('hex'); codes.set(code, values);
|
||||
const redirect = new URL(values.redirect_uri); redirect.searchParams.set('code', code); redirect.searchParams.set('state', values.state);
|
||||
res.writeHead(302, { location: redirect.href }); return res.end();
|
||||
}
|
||||
if (url.pathname === '/token' && req.method === 'POST') {
|
||||
let raw = ''; for await (const chunk of req) raw += chunk;
|
||||
const fields = new URLSearchParams(raw);
|
||||
if (fields.get('client_id') !== 'model-test' || fields.get('client_secret') !== 'mock-test-secret') return json({ error: 'invalid_client' }, 401);
|
||||
let values;
|
||||
if (fields.get('grant_type') === 'authorization_code') {
|
||||
values = codes.get(fields.get('code')); codes.delete(fields.get('code'));
|
||||
if (!values || values.redirect_uri !== fields.get('redirect_uri') || createHash('sha256').update(fields.get('code_verifier') ?? '').digest('base64url') !== values.code_challenge) return json({ error: 'invalid_grant' }, 400);
|
||||
} else if (fields.get('grant_type') === 'refresh_token') {
|
||||
if (controls.denyRefresh || !refreshTokens.delete(fields.get('refresh_token'))) return json({ error: 'invalid_grant' }, 400);
|
||||
controls.refreshCount++;
|
||||
} else return json({ error: 'unsupported_grant_type' }, 400);
|
||||
const access_token = randomBytes(32).toString('hex'), refresh_token = randomBytes(32).toString('hex');
|
||||
accessTokens.add(access_token); refreshTokens.add(refresh_token);
|
||||
const id_token = values ? await new SignJWT({ ...profile, nonce: controls.badNonce ? 'wrong-nonce' : values.nonce }).setProtectedHeader({ alg: 'RS256', kid: jwk.kid }).setIssuer(issuer).setAudience('model-test').setIssuedAt().setExpirationTime('1h').sign(privateKey) : undefined;
|
||||
return json({ access_token, ...(!controls.omitRefreshToken ? { refresh_token } : {}), id_token, token_type: 'Bearer', expires_in: 3600, scope: 'openid profile email groups offline_access' });
|
||||
}
|
||||
if (url.pathname === '/userinfo' && accessTokens.has(req.headers.authorization?.replace(/^Bearer /, ''))) return json(profile);
|
||||
return json({ error: 'not_found' }, 404);
|
||||
} catch { return json({ error: 'server_error' }, 500); }
|
||||
});
|
||||
await new Promise(resolve => server.listen(port, '127.0.0.1', resolve));
|
||||
issuer = `http://localhost:${server.address().port}`;
|
||||
return { issuer, controls, close: () => new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) };
|
||||
}
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const provider = await startMockOidc(4005);
|
||||
console.log(`Test OIDC provider ready at ${provider.issuer}`);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { operationSchema, type Operation } from '@model/shared';
|
||||
import { createPool } from '../../src/db';
|
||||
import { buildApp } from '../../src/app';
|
||||
import { testAccount } from '../helpers/account';
|
||||
|
||||
const pool = createPool();
|
||||
const app = buildApp(pool, false);
|
||||
const deviceId = randomUUID();
|
||||
const customerId = randomUUID();
|
||||
const addressId = randomUUID();
|
||||
function operation(kind: Operation['kind'], data: unknown, entityId = randomUUID()) {
|
||||
return operationSchema.parse({ operationId: randomUUID(), deviceId, entityId, kind, baseVersion: 0, data });
|
||||
}
|
||||
let headers: Record<string, string> = { 'x-model-request': '1' };
|
||||
const push = (payload: Operation) => app.inject({ method: 'POST', url: '/sync/push', payload, headers });
|
||||
const get = (url: string) => app.inject({ url, headers });
|
||||
const deliveryData = { customerId, addressId, scheduledDate: '2026-09-06', deliverySlot: 'morning' as const, specificTime: null, deliveryWindow: { start: '08:00', end: '11:00' }, deliveryTypeId: '10000000-0000-4000-8000-000000000003', deliveryTypeVersion: 1, shipmentUnitHundredths: 150, unitPriceAgorot: 1001, notes: 'Integration test' };
|
||||
beforeAll(async () => {
|
||||
expect(process.env.DB_NAME).toBe('model_delivery_test');
|
||||
const state = (await app.inject('/local-admin/status')).json();
|
||||
const login = await app.inject({ method: 'POST', url: state.setupRequired ? '/local-admin/setup' : '/local-admin/login', headers, payload: state.setupRequired ? { ...testAccount, setupToken: process.env.LOCAL_ADMIN_SETUP_TOKEN } : { username: testAccount.username, password: testAccount.password } });
|
||||
expect(login.statusCode).toBe(200);
|
||||
headers = { ...headers, cookie: login.cookies.map(c => `${c.name}=${c.value}`).join('; '), 'x-model-user': login.json().user.id };
|
||||
expect((await push(operation('customer.create', { name: 'Integration test customer' }, customerId))).statusCode).toBe(200);
|
||||
expect((await push(operation('address.create', { customerId, label: 'Test', address: 'Test address' }, addressId))).statusCode).toBe(200);
|
||||
});
|
||||
afterAll(async () => { await app.close(); await pool.end(); });
|
||||
describe('MariaDB-backed API', () => {
|
||||
it('requires authentication and rejects a different account or unsafe request origin', async () => {
|
||||
expect((await app.inject('/sync/pull')).statusCode).toBe(401);
|
||||
expect((await app.inject({ method: 'POST', url: '/sync/push', headers: { 'x-model-request': '1' }, payload: operation('delivery.create', deliveryData) })).statusCode).toBe(401);
|
||||
expect((await app.inject({ url: '/sync/pull', headers: { ...headers, 'x-model-user': randomUUID() } })).statusCode).toBe(401);
|
||||
expect((await app.inject({ method: 'POST', url: '/auth/logout', headers: { ...headers, origin: 'https://untrusted.example' } })).statusCode).toBe(403);
|
||||
expect((await app.inject({ method: 'POST', url: '/sync/push', headers: { cookie: headers.cookie! }, payload: operation('delivery.create', deliveryData) })).statusCode).toBe(403);
|
||||
});
|
||||
it('provides a personal session, closes initial setup, and invalidates logout sessions', async () => {
|
||||
const current = (await get('/auth/session')).json();
|
||||
expect(current.user.displayName).toBe(testAccount.displayName);
|
||||
const setup = await app.inject({ method: 'POST', url: '/local-admin/setup', headers, payload: { ...testAccount, username: 'another.admin', setupToken: process.env.LOCAL_ADMIN_SETUP_TOKEN } });
|
||||
expect(setup.statusCode).toBe(409);
|
||||
const login = await app.inject({ method: 'POST', url: '/local-admin/login', headers: { 'x-model-request': '1' }, payload: { username: testAccount.username, password: testAccount.password } });
|
||||
expect(login.statusCode).toBe(200);
|
||||
expect(login.headers['set-cookie']).toContain('HttpOnly');
|
||||
expect(login.headers['set-cookie']).toContain('SameSite=Strict');
|
||||
const otherHeaders = { ...headers, cookie: login.cookies.map(c => `${c.name}=${c.value}`).join('; ') };
|
||||
expect((await app.inject({ method: 'POST', url: '/auth/logout', headers: otherHeaders })).statusCode).toBe(200);
|
||||
expect((await app.inject({ url: '/sync/pull', headers: otherHeaders })).statusCode).toBe(401);
|
||||
const bad = await app.inject({ method: 'POST', url: '/local-admin/login', headers: { 'x-model-request': '1' }, payload: { username: testAccount.username, password: 'Incorrect-test-password' } });
|
||||
expect(bad.statusCode).toBe(401);
|
||||
});
|
||||
it('detects concurrent edits, applies explicit new-version retries, and keeps immutable values', async () => {
|
||||
const created = (await push(operation('delivery.create', deliveryData))).json().change.record;
|
||||
const edit = (notes: string, baseVersion: number, scheduledDate = '2026-09-07') => operationSchema.parse({ operationId: randomUUID(), deviceId, entityId: created.id, baseVersion, kind: 'delivery.update', data: { customerId, addressId, scheduledDate, deliverySlot: 'custom', specificTime: '14:35', deliveryWindow: null, notes } });
|
||||
const a = edit('Device A', 1), b = edit('Device B', 1);
|
||||
expect((await push(a)).statusCode).toBe(200);
|
||||
const conflict = await push(b);
|
||||
expect(conflict.statusCode).toBe(409);
|
||||
expect(conflict.json().current.record).toMatchObject({ version: 2, notes: 'Device A' });
|
||||
const retried = await push(edit('Device B', 2));
|
||||
expect(retried.json().change.record).toMatchObject({ version: 3, notes: 'Device B', scheduledDate: '2026-09-07', deliverySlot: 'custom', specificTime: '14:35', deliveryWindow: null, deliveryNumber: created.deliveryNumber, shipmentUnitHundredths: 150, unitPriceAgorot: 1001, totalPriceAgorot: 1502, createdAt: created.createdAt });
|
||||
expect((await pool.query("SELECT DATE_FORMAT(scheduled_date, '%Y-%m-%d') AS scheduled_date, delivery_slot, specific_time FROM deliveries WHERE id = ?", [created.id]))[0]).toMatchObject({ scheduled_date: '2026-09-07', delivery_slot: 'custom', specific_time: '14:35' });
|
||||
const tampered = await app.inject({ method: 'POST', url: '/sync/push', headers, payload: { ...edit('Reprice', 3), data: { customerId, addressId, notes: 'Reprice', unitPriceAgorot: 1 } } });
|
||||
expect(tampered.statusCode).toBe(400);
|
||||
const audit = await pool.query('SELECT actor_user_id FROM sync_operations WHERE operation_id = ?', [a.operationId]);
|
||||
expect(audit[0].actor_user_id).toBe(headers['x-model-user']);
|
||||
});
|
||||
it('cancels idempotently, retains its number and prevents further edits', async () => {
|
||||
const created = (await push(operation('delivery.create', deliveryData))).json().change.record;
|
||||
const op = operationSchema.parse({ operationId: randomUUID(), deviceId, entityId: created.id, baseVersion: 1, kind: 'delivery.cancel', data: { reason: 'Test cancellation' } });
|
||||
const first = await push(op), replay = await push(op);
|
||||
expect(first.json().change.record).toMatchObject({ status: 'cancelled', version: 2, deliveryNumber: created.deliveryNumber, totalPriceAgorot: 1502 });
|
||||
expect(replay.json().replayed).toBe(true);
|
||||
const edit = operationSchema.parse({ ...op, operationId: randomUUID(), baseVersion: 2, kind: 'delivery.update', data: { customerId, addressId, notes: 'Cannot reopen' } });
|
||||
expect((await push(edit)).json().code).toBe('DELIVERY_CANCELLED');
|
||||
const next = (await push(operation('delivery.create', deliveryData))).json().change.record;
|
||||
expect(next.deliveryNumber).toBeGreaterThan(created.deliveryNumber);
|
||||
});
|
||||
it('updates and archives full customer profiles while preserving associated records', async () => {
|
||||
const profileId = randomUUID(), profileAddressId = randomUUID();
|
||||
const details = { name: 'Profile test clinic', contactName: 'תום רוף', phone: '+972 50-123-4567', comments: 'Use the side entrance', deliveryWindows: { morning: { start: '08:00', end: '11:30' }, afternoon: { start: '13:00', end: '16:30' }, lateNight: null } };
|
||||
const created = await push(operation('customer.create', details, profileId));
|
||||
expect(created.json().change.record).toMatchObject({ ...details, version: 1, updatedAt: null });
|
||||
const address = await push(operation('address.create', { customerId: profileId, label: 'Main', address: 'Main 7, Haifa', city: 'Haifa', street: 'Main', buildingNumber: '7' }, profileAddressId));
|
||||
expect(address.json().change.record).toMatchObject({ city: 'Haifa', street: 'Main', buildingNumber: '7' });
|
||||
const profileDelivery = (await push(operation('delivery.create', { ...deliveryData, customerId: profileId, addressId: profileAddressId }))).json().change.record;
|
||||
const edit = (name: string, baseVersion: number) => operationSchema.parse({ operationId: randomUUID(), deviceId, entityId: profileId, baseVersion, kind: 'customer.update', data: { ...details, name } });
|
||||
expect((await push(edit('Updated profile clinic', 1))).json().change.record).toMatchObject({ version: 2, name: 'Updated profile clinic', contactName: details.contactName });
|
||||
const stale = await push(edit('Stale profile clinic', 1));
|
||||
expect(stale.statusCode).toBe(409); expect(stale.json().current.record).toMatchObject({ version: 2, name: 'Updated profile clinic' });
|
||||
const removed = operationSchema.parse({ operationId: randomUUID(), deviceId, entityId: profileId, baseVersion: 2, kind: 'customer.remove', data: {} });
|
||||
expect((await push(removed)).json().change.record).toMatchObject({ version: 3, name: 'Updated profile clinic' });
|
||||
expect((await push(operation('delivery.create', { ...deliveryData, customerId: profileId, addressId: profileAddressId }))).json().code).toBe('ADDRESS_MISMATCH');
|
||||
expect(Number((await pool.query('SELECT COUNT(*) AS count FROM deliveries WHERE id = ?', [profileDelivery.id]))[0].count)).toBe(1);
|
||||
expect(Number((await pool.query('SELECT COUNT(*) AS count FROM customer_addresses WHERE id = ?', [profileAddressId]))[0].count)).toBe(1);
|
||||
});
|
||||
it('provides health, readiness, version and request IDs', async () => {
|
||||
expect((await app.inject('/health')).json()).toEqual({ status: 'ok' });
|
||||
expect((await app.inject('/ready')).statusCode).toBe(200);
|
||||
const version = await app.inject('/version');
|
||||
expect(version.json().syncProtocol).toBe(5);
|
||||
expect(version.headers['x-request-id']).toBeTruthy();
|
||||
});
|
||||
it('replays concurrent identical operations exactly once and rejects changed reuse', async () => {
|
||||
const op = operation('delivery.create', deliveryData);
|
||||
const responses = await Promise.all([push(op), push(op), push(op)]);
|
||||
expect(responses.map(r => r.statusCode)).toEqual([200, 200, 200]);
|
||||
const numbers = responses.map(r => r.json().change.record.deliveryNumber);
|
||||
expect(new Set(numbers).size).toBe(1);
|
||||
expect(responses.filter(r => r.json().replayed === false)).toHaveLength(1);
|
||||
const records = await pool.query('SELECT COUNT(*) AS count FROM deliveries WHERE id = ?', [op.entityId]);
|
||||
expect(Number(records[0].count)).toBe(1);
|
||||
const changes = await pool.query('SELECT COUNT(*) AS count FROM sync_changes WHERE entity_id = ?', [op.entityId]);
|
||||
expect(Number(changes[0].count)).toBe(1);
|
||||
const changed = { ...op, data: { ...deliveryData, notes: 'Changed request' } } as Operation;
|
||||
expect((await push(changed)).json().code).toBe('OPERATION_REUSED');
|
||||
const versionConflict = { ...op, operationId: randomUUID() };
|
||||
const conflict = await push(versionConflict);
|
||||
expect(conflict.statusCode).toBe(409);
|
||||
expect(conflict.json().current.record.id).toBe(op.entityId);
|
||||
});
|
||||
it('allocates unique sequential numbers under concurrent creates', async () => {
|
||||
const replies = await Promise.all(Array.from({ length: 5 }, () => push(operation('delivery.create', deliveryData))));
|
||||
expect(replies.every(r => r.statusCode === 200)).toBe(true);
|
||||
const numbers: number[] = replies.map(r => r.json().change.record.deliveryNumber).sort((a: number, b: number) => a - b);
|
||||
expect(new Set(numbers).size).toBe(5);
|
||||
expect(numbers.at(-1)! - numbers[0]!).toBe(4);
|
||||
expect(replies[0]!.json().change.record.totalPriceAgorot).toBe(1502);
|
||||
});
|
||||
it('rejects another customer address and stale catalog without consuming a number', async () => {
|
||||
const before = await pool.query('SELECT next_delivery_number FROM sync_state');
|
||||
const badAddress = await push(operation('delivery.create', { ...deliveryData, customerId: randomUUID() }));
|
||||
expect(badAddress.statusCode).toBe(422);
|
||||
expect(badAddress.json().code).toBe('ADDRESS_MISMATCH');
|
||||
const stale = await push(operation('delivery.create', { ...deliveryData, deliveryTypeVersion: 99 }));
|
||||
expect(stale.json().code).toBe('CATALOG_CONFLICT');
|
||||
const after = await pool.query('SELECT next_delivery_number FROM sync_state');
|
||||
expect(after[0].next_delivery_number).toBe(before[0].next_delivery_number);
|
||||
});
|
||||
it('paginates committed changes in ascending order and rejects invalid cursors', async () => {
|
||||
let cursor = '0';
|
||||
const seen = new Set<string>();
|
||||
let pages = 0;
|
||||
while (true) {
|
||||
const response = await get(`/sync/pull?cursor=${cursor}&limit=2`);
|
||||
expect(response.statusCode).toBe(200);
|
||||
const page = response.json();
|
||||
for (const entry of page.changes) {
|
||||
expect(BigInt(entry.sequence)).toBeGreaterThan(BigInt(cursor));
|
||||
expect(seen.has(entry.sequence)).toBe(false);
|
||||
seen.add(entry.sequence);
|
||||
}
|
||||
cursor = page.nextCursor;
|
||||
pages++;
|
||||
if (!page.hasMore) break;
|
||||
if (pages > 1000) throw new Error('Pagination did not terminate');
|
||||
}
|
||||
expect(pages).toBeGreaterThan(1);
|
||||
expect((await get('/sync/pull?cursor=-1')).statusCode).toBe(400);
|
||||
expect((await get('/sync/pull?cursor=18446744073709551615')).json().code).toBe('CURSOR_AHEAD');
|
||||
});
|
||||
it('validates create-only versions and refuses arbitrary request fields', async () => {
|
||||
const invalid = await app.inject({ method: 'POST', url: '/sync/push', headers, payload: { ...operation('delivery.create', deliveryData), baseVersion: 1 } });
|
||||
expect(invalid.statusCode).toBe(400);
|
||||
const arbitrary = await app.inject({ method: 'POST', url: '/sync/push', headers, payload: { ...operation('delivery.create', deliveryData), admin: true } });
|
||||
expect(arbitrary.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { Client } from 'ldapts';
|
||||
import { createPool } from '../../src/db';
|
||||
import { buildApp } from '../../src/app';
|
||||
import { defaultDirectorySettings, readDirectorySettings, saveDirectorySettings } from '../../src/directory';
|
||||
import { readSettings, saveSettings } from '../../src/oidc';
|
||||
import { startMockOidc } from '../helpers/mock-oidc.mjs';
|
||||
import { testAccount } from '../helpers/account';
|
||||
|
||||
const pool = createPool();
|
||||
process.env.OIDC_ALLOW_HTTP = 'true';
|
||||
process.env.APP_ORIGINS = 'http://localhost:4174';
|
||||
const app = buildApp(pool, false);
|
||||
const headers = { 'x-model-request': '1' };
|
||||
let admin: Record<string, string>;
|
||||
let adminId: string;
|
||||
let originalSso: Awaited<ReturnType<typeof readSettings>>;
|
||||
let originalDirectory: Awaited<ReturnType<typeof readDirectorySettings>>;
|
||||
let provider: Awaited<ReturnType<typeof startMockOidc>>;
|
||||
const directory = { ...defaultDirectorySettings(), enabled: true, url: 'ldaps://directory.example.invalid:636', baseDn: 'DC=example,DC=invalid', bindDn: 'reader@example.invalid', bindPassword: 'directory-test-secret' };
|
||||
const cookies = (response: { cookies: Array<{ name: string; value: string }> }) => response.cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||||
async function login() {
|
||||
const start = await app.inject({ method: 'POST', url: '/auth/login', headers });
|
||||
expect(start.statusCode).toBe(200);
|
||||
const response = await fetch(start.json().url, { redirect: 'manual' });
|
||||
const callback = new URL(response.headers.get('location')!);
|
||||
const complete = await app.inject({ url: `/auth/callback${callback.search}`, headers: { cookie: cookies(start) } });
|
||||
expect(complete.headers.location).toBe('/');
|
||||
const sessionHeaders = { ...headers, cookie: cookies(complete) };
|
||||
const session = (await app.inject({ url: '/auth/session', headers: sessionHeaders })).json();
|
||||
return { user: session.user, headers: { ...sessionHeaders, 'x-model-user': session.user.id } };
|
||||
}
|
||||
beforeAll(async () => {
|
||||
expect(process.env.DB_NAME).toBe('model_delivery_test');
|
||||
originalSso = await readSettings(pool); originalDirectory = await readDirectorySettings(pool);
|
||||
const status = (await app.inject('/local-admin/status')).json();
|
||||
const result = await app.inject({ method: 'POST', url: `/local-admin/${status.setupRequired ? 'setup' : 'login'}`, headers, payload: status.setupRequired ? { ...testAccount, setupToken: process.env.LOCAL_ADMIN_SETUP_TOKEN } : { username: testAccount.username, password: testAccount.password } });
|
||||
expect(result.statusCode).toBe(200);
|
||||
admin = { ...headers, cookie: cookies(result) }; adminId = result.json().user.id;
|
||||
provider = await startMockOidc(); provider.controls.subject = 'REZNIK\\tom.ruff'; provider.controls.omitName = true;
|
||||
await saveSettings(pool, { enabled: true, issuer: provider.issuer, discoveryUrl: `${provider.issuer}/.well-known/openid-configuration`, clientId: 'model-test', clientSecret: 'mock-test-secret', redirectUri: 'http://localhost:4174/api/auth/callback', scopes: 'openid email', authMethod: 'client_secret_post', offlineAccess: false, promptConsent: false, displayNameClaim: 'name' }, adminId);
|
||||
});
|
||||
afterAll(async () => {
|
||||
vi.restoreAllMocks();
|
||||
if (originalDirectory) await saveDirectorySettings(pool, originalDirectory.settings, originalDirectory.issuer, adminId);
|
||||
if (originalSso) await saveSettings(pool, originalSso, adminId);
|
||||
await app.close(); await pool.end(); await provider?.close();
|
||||
});
|
||||
describe('directory settings and greeting', () => {
|
||||
it('guards settings and diagnostic routes, encrypts passwords and preserves them only for the same server/account', async () => {
|
||||
for (const url of ['/local-admin/directory', '/local-admin/directory/test']) {
|
||||
expect((await app.inject({ method: 'POST', url, headers, payload: {} })).statusCode).toBe(401);
|
||||
expect((await app.inject({ method: 'POST', url, headers: admin, remoteAddress: '192.0.2.1', payload: {} })).statusCode).toBe(403);
|
||||
expect((await app.inject({ method: 'POST', url, headers: { ...admin, origin: 'https://other.invalid' }, payload: {} })).statusCode).toBe(403);
|
||||
}
|
||||
const saved = await app.inject({ method: 'POST', url: '/local-admin/directory', headers: admin, payload: directory });
|
||||
expect(saved.statusCode).toBe(200);
|
||||
expect(saved.json()).toMatchObject({ bindPassword: '', passwordConfigured: true, issuer: provider.issuer });
|
||||
expect((await app.inject({ url: '/local-admin/directory', headers: admin })).body).not.toContain(directory.bindPassword);
|
||||
expect((await pool.query('SELECT encrypted_config FROM directory_settings WHERE id = 1'))[0].encrypted_config).not.toContain(directory.bindPassword);
|
||||
expect((await app.inject({ method: 'POST', url: '/local-admin/directory', headers: admin, payload: { ...directory, bindPassword: '' } })).statusCode).toBe(200);
|
||||
expect((await app.inject({ method: 'POST', url: '/local-admin/directory', headers: admin, payload: { ...directory, url: 'ldaps://other.invalid', bindPassword: '' } })).statusCode).toBe(400);
|
||||
});
|
||||
it('reads a name after OIDC, keeps it during outages, supports personal overrides and invalidates stale cache when disabled', async () => {
|
||||
const bind = vi.spyOn(Client.prototype, 'bind').mockResolvedValue();
|
||||
vi.spyOn(Client.prototype, 'search').mockResolvedValue({ searchEntries: [{ dn: 'CN=test', sAMAccountName: 'tom.ruff', displayName: 'תום רוף' }], searchReferences: [] });
|
||||
vi.spyOn(Client.prototype, 'unbind').mockResolvedValue();
|
||||
const test = await app.inject({ method: 'POST', url: '/local-admin/directory/test', headers: admin, payload: { subject: 'REZNIK\\tom.ruff' } });
|
||||
expect(test.json()).toEqual({ code: 'FOUND', displayName: 'תום רוף' });
|
||||
const first = await login();
|
||||
expect(first.user).toMatchObject({ displayName: 'תום רוף', role: 'staff', username: 'test.sso' });
|
||||
expect((await app.inject({ url: '/local-admin/directory', headers: first.headers })).statusCode).toBe(403);
|
||||
expect((await app.inject({ method: 'POST', url: '/local-admin/directory/test', headers: first.headers, payload: { subject: 'REZNIK\\other' } })).statusCode).toBe(403);
|
||||
const stored = (await pool.query('SELECT display_name, directory_display_name FROM app_users WHERE id = ?', [first.user.id]))[0];
|
||||
expect(stored).toMatchObject({ display_name: 'test.sso', directory_display_name: 'תום רוף' });
|
||||
bind.mockRejectedValue(new Error('directory-test-secret: temporary outage'));
|
||||
expect((await login()).user).toMatchObject({ id: first.user.id, displayName: 'תום רוף' });
|
||||
const custom = await app.inject({ method: 'POST', url: '/auth/profile', headers: first.headers, payload: { displayName: 'שם אישי' } });
|
||||
expect(custom.json().user.displayName).toBe('שם אישי');
|
||||
expect((await login()).user.displayName).toBe('שם אישי');
|
||||
const reset = await app.inject({ method: 'POST', url: '/auth/profile', headers: first.headers, payload: { displayName: null } });
|
||||
expect(reset.json().user.displayName).toBe('תום רוף');
|
||||
const failedTest = await app.inject({ method: 'POST', url: '/local-admin/directory/test', headers: admin, payload: { subject: 'REZNIK\\tom.ruff' } });
|
||||
expect(failedTest.statusCode).toBe(422); expect(failedTest.json()).toEqual({ code: 'UNAVAILABLE' });
|
||||
await saveDirectorySettings(pool, { ...directory, enabled: false }, provider.issuer, adminId);
|
||||
expect((await app.inject({ url: '/auth/session', headers: first.headers })).json().user.displayName).toBe('test.sso');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createPool } from '../../src/db';
|
||||
import { buildApp } from '../../src/app';
|
||||
import { readSettings, saveSettings } from '../../src/oidc';
|
||||
import { seal, unseal } from '../../src/token-vault';
|
||||
import { startMockOidc } from '../helpers/mock-oidc.mjs';
|
||||
import { testAccount } from '../helpers/account';
|
||||
|
||||
const pool = createPool();
|
||||
process.env.OIDC_ALLOW_HTTP = 'true';
|
||||
process.env.APP_ORIGINS = 'http://localhost:4174';
|
||||
const app = buildApp(pool, false);
|
||||
let provider: Awaited<ReturnType<typeof startMockOidc>>;
|
||||
let original: Awaited<ReturnType<typeof readSettings>>;
|
||||
let admin: Record<string, string>;
|
||||
const cookie = (response: { cookies: Array<{ name: string; value: string }> }) => response.cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||||
async function begin() {
|
||||
const started = await app.inject({ method: 'POST', url: '/auth/login', headers: { 'x-model-request': '1' } });
|
||||
expect(started.statusCode).toBe(200);
|
||||
const authorizationUrl = new URL(started.json().url);
|
||||
expect(authorizationUrl.searchParams.get('code_challenge_method')).toBe('S256');
|
||||
expect(authorizationUrl.searchParams.get('scope')).toContain('offline_access');
|
||||
expect(authorizationUrl.searchParams.get('scope')).toContain('groups');
|
||||
expect(started.headers['set-cookie']).toContain('SameSite=Lax');
|
||||
const redirect = await fetch(authorizationUrl, { redirect: 'manual' });
|
||||
const callback = new URL(redirect.headers.get('location')!);
|
||||
return { url: `/auth/callback${callback.search}`, headers: { cookie: cookie(started) } };
|
||||
}
|
||||
async function login() {
|
||||
const callback = await app.inject(await begin());
|
||||
expect(callback.headers.location).toBe('/');
|
||||
return { cookie: cookie(callback), 'x-model-request': '1' };
|
||||
}
|
||||
beforeAll(async () => {
|
||||
expect(process.env.DB_NAME).toBe('model_delivery_test');
|
||||
provider = await startMockOidc();
|
||||
original = await readSettings(pool);
|
||||
const status = (await app.inject('/local-admin/status')).json();
|
||||
const result = await app.inject({ method: 'POST', url: `/local-admin/${status.setupRequired ? 'setup' : 'login'}`, headers: { 'x-model-request': '1' }, payload: status.setupRequired ? { ...testAccount, setupToken: process.env.LOCAL_ADMIN_SETUP_TOKEN } : { username: testAccount.username, password: testAccount.password } });
|
||||
expect(result.statusCode).toBe(200);
|
||||
admin = { cookie: cookie(result), 'x-model-request': '1' };
|
||||
const saved = await app.inject({ method: 'POST', url: '/local-admin/settings', headers: admin, payload: { enabled: true, issuer: provider.issuer, discoveryUrl: `${provider.issuer}/.well-known/openid-configuration`, clientId: 'model-test', clientSecret: 'mock-test-secret', redirectUri: 'http://localhost:4174/api/auth/callback', scopes: 'openid profile email groups', authMethod: 'client_secret_post', offlineAccess: true, promptConsent: true, displayNameClaim: 'name', groupAccessEnabled: true, groupClaim: 'groups', allowedGroups: ['model-delivery-users'] } });
|
||||
expect(saved.statusCode).toBe(200);
|
||||
expect(saved.json()).toMatchObject({ clientSecret: '', secretConfigured: true });
|
||||
});
|
||||
afterAll(async () => {
|
||||
if (original) await saveSettings(pool, original, (await app.inject({ url: '/auth/session', headers: admin })).json().user.id);
|
||||
await app.close(); await pool.end(); await provider?.close();
|
||||
});
|
||||
describe('OIDC and local recovery', () => {
|
||||
it('keeps a personal display name across SSO logins when the provider returns only a username', async () => {
|
||||
provider.controls.omitName = true;
|
||||
const headers = await login();
|
||||
const before = (await app.inject({ url: '/auth/session', headers })).json();
|
||||
expect(before.user.displayName).toBe('test.sso');
|
||||
const payload = { displayName: 'תום רוף' };
|
||||
expect((await app.inject({ method: 'POST', url: '/auth/profile', headers: { 'x-model-request': '1' }, payload })).statusCode).toBe(401);
|
||||
expect((await app.inject({ method: 'POST', url: '/auth/profile', headers: { ...headers, 'x-model-user': 'another-account' }, payload })).statusCode).toBe(401);
|
||||
const ownHeaders = { ...headers, 'x-model-user': before.user.id };
|
||||
expect((await app.inject({ method: 'POST', url: '/auth/profile', headers: ownHeaders, payload: { ...payload, role: 'admin' } })).statusCode).toBe(400);
|
||||
expect((await app.inject({ method: 'POST', url: '/auth/profile', headers: ownHeaders, payload })).json().user).toMatchObject({ displayName: 'תום רוף', username: 'test.sso', role: 'staff' });
|
||||
const again = (await app.inject({ url: '/auth/session', headers: await login() })).json();
|
||||
expect(again.user).toMatchObject({ id: before.user.id, displayName: 'תום רוף', username: 'test.sso' });
|
||||
const stored = await pool.query('SELECT display_name, display_name_override FROM app_users WHERE id = ?', [before.user.id]);
|
||||
expect(stored[0]).toMatchObject({ display_name: 'test.sso', display_name_override: 'תום רוף' });
|
||||
await pool.query('UPDATE app_users SET display_name_override = NULL WHERE id = ?', [before.user.id]);
|
||||
provider.controls.omitName = false;
|
||||
});
|
||||
it('guards recovery setup/settings and masks/encrypts the client secret', async () => {
|
||||
expect((await app.inject('/local-admin/settings')).statusCode).toBe(401);
|
||||
expect((await app.inject({ url: '/local-admin/status', remoteAddress: '192.0.2.1' })).statusCode).toBe(403);
|
||||
expect((await app.inject({ method: 'POST', url: '/local-admin/setup', headers: { 'x-model-request': '1' }, payload: { ...testAccount, setupToken: 'wrong' } })).statusCode).toBe(403);
|
||||
const settings = await app.inject({ url: '/local-admin/settings', headers: admin });
|
||||
expect(settings.body).not.toContain('mock-test-secret');
|
||||
const stored = await pool.query('SELECT encrypted_config FROM oidc_settings');
|
||||
expect(stored[0].encrypted_config).not.toContain('mock-test-secret');
|
||||
expect((await app.inject({ method: 'POST', url: '/local-admin/discover', headers: admin })).json().pkceAdvertised).toBe(true);
|
||||
});
|
||||
it('binds sign-in to its browser, checks state/nonce and rejects callback replay', async () => {
|
||||
const pending = await begin();
|
||||
expect((await app.inject(pending.url)).headers.location).toBe('/?authError=signin_failed');
|
||||
const tampered = new URL(pending.url, 'http://test'); tampered.searchParams.set('state', 'wrong-state');
|
||||
expect((await app.inject({ ...pending, url: `${tampered.pathname}${tampered.search}` })).headers.location).toBe('/?authError=signin_failed');
|
||||
provider.controls.badNonce = true;
|
||||
expect((await app.inject(await begin())).headers.location).toBe('/?authError=signin_failed');
|
||||
provider.controls.badNonce = false;
|
||||
const valid = await begin();
|
||||
expect((await app.inject(valid)).headers.location).toBe('/');
|
||||
expect((await app.inject(valid)).headers.location).toBe('/?authError=signin_failed');
|
||||
});
|
||||
it('denies SSO users outside the configured Synology group allowlist', async () => {
|
||||
provider.controls.groups = ['unrelated-team'];
|
||||
const denied = await app.inject(await begin());
|
||||
expect(denied.headers.location).toBe('/?authError=group_denied');
|
||||
provider.controls.groups = ['MODEL-DELIVERY-USERS'];
|
||||
const allowedHeaders = await login();
|
||||
expect((await app.inject({ url: '/auth/session', headers: allowedHeaders })).json().user.groups).toEqual(['MODEL-DELIVERY-USERS']);
|
||||
provider.controls.groups = ['model-delivery-users'];
|
||||
});
|
||||
it('keeps a bounded app session after provider expiry when no refresh token was issued', async () => {
|
||||
provider.controls.omitRefreshToken = true;
|
||||
try {
|
||||
const headers = await login();
|
||||
const profile = (await app.inject({ url: '/auth/session', headers })).json();
|
||||
const hash = createHash('sha256').update(/(?:^|; )model_session=([^;]+)/.exec(headers.cookie)![1]!).digest('hex');
|
||||
const rows = await pool.query('SELECT encrypted_tokens FROM auth_sessions WHERE token_hash = ?', [hash]);
|
||||
const tokens = unseal<Record<string, unknown>>(rows[0].encrypted_tokens);
|
||||
expect(tokens.refreshToken).toBeUndefined();
|
||||
await pool.query('UPDATE auth_sessions SET encrypted_tokens = ? WHERE token_hash = ?', [seal({ ...tokens, expiresAt: Date.now() - 60 * 60 * 1000 }), hash]);
|
||||
const refreshCount = provider.controls.refreshCount;
|
||||
const resumed = await Promise.all(Array.from({ length: 3 }, () => app.inject({ url: '/auth/session', headers })));
|
||||
expect(resumed.every(r => r.json().user?.id === profile.user.id && r.json().expiresAt === profile.expiresAt)).toBe(true);
|
||||
expect(provider.controls.refreshCount).toBe(refreshCount);
|
||||
const syncHeaders = { ...headers, 'x-model-user': profile.user.id };
|
||||
expect((await app.inject({ url: '/sync/pull', headers: syncHeaders })).statusCode).toBe(200);
|
||||
|
||||
// Losing app access still blocks synchronization even without token refresh.
|
||||
await pool.query('UPDATE app_users SET active = FALSE WHERE id = ?', [profile.user.id]);
|
||||
try { expect((await app.inject({ url: '/sync/pull', headers: syncHeaders })).statusCode).toBe(401); }
|
||||
finally { await pool.query('UPDATE app_users SET active = TRUE WHERE id = ?', [profile.user.id]); }
|
||||
const settings = await readSettings(pool);
|
||||
const adminId = (await app.inject({ url: '/auth/session', headers: admin })).json().user.id;
|
||||
try {
|
||||
await saveSettings(pool, { ...settings, allowedGroups: ['another-team'] }, adminId);
|
||||
expect((await app.inject({ url: '/sync/pull', headers: syncHeaders })).statusCode).toBe(401);
|
||||
} finally { await saveSettings(pool, settings, adminId); }
|
||||
expect((await app.inject({ url: '/auth/session', headers })).json().user).toBeNull();
|
||||
|
||||
const expiredHeaders = await login();
|
||||
const expiredHash = createHash('sha256').update(/(?:^|; )model_session=([^;]+)/.exec(expiredHeaders.cookie)![1]!).digest('hex');
|
||||
await pool.query('UPDATE auth_sessions SET expires_at = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 SECOND) WHERE token_hash = ?', [expiredHash]);
|
||||
expect((await app.inject({ url: '/sync/pull', headers: { ...expiredHeaders, 'x-model-user': profile.user.id } })).statusCode).toBe(401);
|
||||
expect((await app.inject({ url: '/auth/session', headers: expiredHeaders })).json().user).toBeNull();
|
||||
|
||||
const logoutHeaders = await login();
|
||||
expect((await app.inject({ method: 'POST', url: '/auth/logout', headers: logoutHeaders })).statusCode).toBe(200);
|
||||
expect((await app.inject({ url: '/auth/session', headers: logoutHeaders })).json().user).toBeNull();
|
||||
} finally { provider.controls.omitRefreshToken = false; }
|
||||
});
|
||||
it('returns the greeting profile without tokens, refreshes once concurrently, and reauthenticates after revoked refresh', async () => {
|
||||
const headers = await login();
|
||||
const profile = (await app.inject({ url: '/auth/session', headers })).json();
|
||||
expect(profile.user).toMatchObject({ displayName: 'טום בדיקה', username: 'test.sso', authProvider: 'oidc', role: 'staff', groups: ['model-delivery-users'] });
|
||||
expect(Object.keys(profile).sort()).toEqual(['expiresAt', 'user']);
|
||||
expect((await app.inject({ url: '/local-admin/settings', headers })).statusCode).toBe(403);
|
||||
const token = /(?:^|; )model_session=([^;]+)/.exec(headers.cookie)![1]!;
|
||||
const hash = createHash('sha256').update(token).digest('hex');
|
||||
async function expire() {
|
||||
const rows = await pool.query('SELECT encrypted_tokens FROM auth_sessions WHERE token_hash = ?', [hash]);
|
||||
const tokens = unseal<Record<string, unknown>>(rows[0].encrypted_tokens);
|
||||
expect(rows[0].encrypted_tokens).not.toContain(tokens.refreshToken);
|
||||
await pool.query('UPDATE auth_sessions SET encrypted_tokens = ? WHERE token_hash = ?', [seal({ ...tokens, expiresAt: Date.now() - 1000 }), hash]);
|
||||
}
|
||||
await expire();
|
||||
const replies = await Promise.all(Array.from({ length: 3 }, () => app.inject({ url: '/auth/session', headers })));
|
||||
expect(replies.every(r => r.json().user?.id === profile.user.id)).toBe(true);
|
||||
expect(provider.controls.refreshCount).toBe(1);
|
||||
await expire(); provider.controls.denyRefresh = true;
|
||||
expect((await app.inject({ url: '/sync/pull', headers: { ...headers, 'x-model-user': profile.user.id } })).statusCode).toBe(401);
|
||||
expect((await app.inject({ url: '/auth/session', headers })).json().user).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
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);
|
||||
});
|
||||
Reference in new issue
Block a user