import { expect, tap } from '@push.rocks/tapbundle'; import { SmartProxy } from '../ts/index.js'; /** * Test that verifies mutex prevents race conditions during concurrent route updates */ tap.test('should handle concurrent route updates without race conditions', async (tools) => { tools.timeout(10000); const settings = { port: 6001, routes: [ { name: 'initial-route', match: { ports: 80 }, action: { type: 'forward' as const, targetUrl: 'http://localhost:3000' } } ], acme: { email: 'test@test.com', port: 80 } }; const proxy = new SmartProxy(settings); await proxy.start(); // Simulate concurrent route updates const updates = []; for (let i = 0; i < 5; i++) { updates.push(proxy.updateRoutes([ ...settings.routes, { name: `route-${i}`, match: { ports: [443] }, action: { type: 'forward' as const, targetUrl: `https://localhost:${3001 + i}`, tls: { mode: 'terminate' as const, certificate: 'auto' } } } ])); } // All updates should complete without errors await Promise.all(updates); // Verify final state const currentRoutes = proxy['settings'].routes; tools.expect(currentRoutes.length).toEqual(2); // Initial route + last update await proxy.stop(); }); /** * Test that verifies mutex serializes route updates */ tap.test('should serialize route updates with mutex', async (tools) => { tools.timeout(10000); const settings = { port: 6002, routes: [{ name: 'test-route', match: { ports: [80] }, action: { type: 'forward' as const, targetUrl: 'http://localhost:3000' } }] }; const proxy = new SmartProxy(settings); await proxy.start(); let updateStartCount = 0; let updateEndCount = 0; let maxConcurrent = 0; // Wrap updateRoutes to track concurrent execution const originalUpdateRoutes = proxy['updateRoutes'].bind(proxy); proxy['updateRoutes'] = async (routes: any[]) => { updateStartCount++; const concurrent = updateStartCount - updateEndCount; maxConcurrent = Math.max(maxConcurrent, concurrent); // If mutex is working, only one update should run at a time tools.expect(concurrent).toEqual(1); const result = await originalUpdateRoutes(routes); updateEndCount++; return result; }; // Trigger multiple concurrent updates const updates = []; for (let i = 0; i < 5; i++) { updates.push(proxy.updateRoutes([ ...settings.routes, { name: `concurrent-route-${i}`, match: { ports: [2000 + i] }, action: { type: 'forward' as const, targetUrl: `http://localhost:${3000 + i}` } } ])); } await Promise.all(updates); // All updates should have completed tools.expect(updateStartCount).toEqual(5); tools.expect(updateEndCount).toEqual(5); tools.expect(maxConcurrent).toEqual(1); // Mutex ensures only one at a time await proxy.stop(); }); /** * Test that challenge route state is preserved across certificate manager recreations */ tap.test('should preserve challenge route state during cert manager recreation', async (tools) => { tools.timeout(10000); const settings = { port: 6003, routes: [{ name: 'acme-route', match: { ports: [443] }, action: { type: 'forward' as const, targetUrl: 'https://localhost:3001', tls: { mode: 'terminate' as const, certificate: 'auto' } } }], acme: { email: 'test@test.com', port: 80 } }; const proxy = new SmartProxy(settings); // Track certificate manager recreations let certManagerCreationCount = 0; const originalCreateCertManager = proxy['createCertificateManager'].bind(proxy); proxy['createCertificateManager'] = async (...args: any[]) => { certManagerCreationCount++; return originalCreateCertManager(...args); }; await proxy.start(); // Initial creation tools.expect(certManagerCreationCount).toEqual(1); // Multiple route updates for (let i = 0; i < 3; i++) { await proxy.updateRoutes([ ...settings.routes, { name: `dynamic-route-${i}`, match: { ports: [9000 + i] }, action: { type: 'forward' as const, targetUrl: `http://localhost:${5000 + i}` } } ]); } // Certificate manager should be recreated for each update tools.expect(certManagerCreationCount).toEqual(4); // 1 initial + 3 updates // State should be preserved (challenge route active) const globalState = proxy['globalChallengeRouteActive']; tools.expect(globalState).toBeDefined(); await proxy.stop(); }); export default tap;