smartproxy/test/test.challenge-route-lifecycle.node.ts

346 lines
9.2 KiB
TypeScript

import * as plugins from '../ts/plugins.js';
import { SmartProxy } from '../ts/index.js';
import { tap, expect } from '@push.rocks/tapbundle';
let testProxy: SmartProxy;
// Helper to check if a port is being listened on
async function isPortListening(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = plugins.net.createServer();
server.once('error', (err: any) => {
if (err.code === 'EADDRINUSE') {
// Port is already in use (being listened on)
resolve(true);
} else {
resolve(false);
}
});
server.once('listening', () => {
// Port is available (not being listened on)
server.close();
resolve(false);
});
server.listen(port);
});
}
// Helper to create test route
const createRoute = (id: number, port: number = 8443) => ({
name: `test-route-${id}`,
match: {
ports: [port],
domains: [`test${id}.example.com`]
},
action: {
type: 'forward' as const,
target: {
host: 'localhost',
port: 3000 + id
},
tls: {
mode: 'terminate' as const,
certificate: 'auto' as const
}
}
});
tap.test('should add challenge route once during initialization', async () => {
testProxy = new SmartProxy({
routes: [createRoute(1, 8443)],
acme: {
email: 'test@example.com',
useProduction: false,
port: 8080 // Use high port for testing
}
});
// Mock certificate manager initialization
let challengeRouteAddCount = 0;
const originalInitCertManager = (testProxy as any).initializeCertificateManager;
(testProxy as any).initializeCertificateManager = async function() {
// Track challenge route additions
const mockCertManager = {
addChallengeRoute: async function() {
challengeRouteAddCount++;
},
removeChallengeRoute: async function() {
challengeRouteAddCount--;
},
setUpdateRoutesCallback: function() {},
setNetworkProxy: function() {},
setGlobalAcmeDefaults: function() {},
initialize: async function() {
// Simulate adding challenge route during init
await this.addChallengeRoute();
},
stop: async function() {
// Simulate removing challenge route during stop
await this.removeChallengeRoute();
},
getAcmeOptions: function() {
return { email: 'test@example.com' };
}
};
(this as any).certManager = mockCertManager;
};
await testProxy.start();
// Challenge route should be added exactly once
expect(challengeRouteAddCount).toEqual(1);
await testProxy.stop();
// Challenge route should be removed on stop
expect(challengeRouteAddCount).toEqual(0);
});
tap.test('should persist challenge route during multiple certificate provisioning', async () => {
testProxy = new SmartProxy({
routes: [
createRoute(1, 8443),
createRoute(2, 8444),
createRoute(3, 8445)
],
acme: {
email: 'test@example.com',
useProduction: false,
port: 8080
}
});
// Mock to track route operations
let challengeRouteActive = false;
let addAttempts = 0;
let removeAttempts = 0;
(testProxy as any).initializeCertificateManager = async function() {
const mockCertManager = {
challengeRouteActive: false,
isProvisioning: false,
addChallengeRoute: async function() {
addAttempts++;
if (this.challengeRouteActive) {
console.log('Challenge route already active, skipping');
return;
}
this.challengeRouteActive = true;
challengeRouteActive = true;
},
removeChallengeRoute: async function() {
removeAttempts++;
if (!this.challengeRouteActive) {
console.log('Challenge route not active, skipping removal');
return;
}
this.challengeRouteActive = false;
challengeRouteActive = false;
},
provisionAllCertificates: async function() {
this.isProvisioning = true;
// Simulate provisioning multiple certificates
for (let i = 0; i < 3; i++) {
// Would normally call provisionCertificate for each route
// Challenge route should remain active throughout
expect(this.challengeRouteActive).toEqual(true);
}
this.isProvisioning = false;
},
setUpdateRoutesCallback: function() {},
setNetworkProxy: function() {},
setGlobalAcmeDefaults: function() {},
initialize: async function() {
await this.addChallengeRoute();
await this.provisionAllCertificates();
},
stop: async function() {
await this.removeChallengeRoute();
},
getAcmeOptions: function() {
return { email: 'test@example.com' };
}
};
(this as any).certManager = mockCertManager;
};
await testProxy.start();
// Challenge route should be added once and remain active
expect(addAttempts).toEqual(1);
expect(challengeRouteActive).toEqual(true);
await testProxy.stop();
// Challenge route should be removed once
expect(removeAttempts).toEqual(1);
expect(challengeRouteActive).toEqual(false);
});
tap.test('should handle port conflicts gracefully', async () => {
// Create a server that listens on port 8080 to create a conflict
const conflictServer = plugins.net.createServer();
await new Promise<void>((resolve) => {
conflictServer.listen(8080, () => {
resolve();
});
});
try {
testProxy = new SmartProxy({
routes: [createRoute(1, 8443)],
acme: {
email: 'test@example.com',
useProduction: false,
port: 8080 // This port is already in use
}
});
let error: Error | null = null;
(testProxy as any).initializeCertificateManager = async function() {
const mockCertManager = {
challengeRouteActive: false,
addChallengeRoute: async function() {
if (this.challengeRouteActive) {
return;
}
// Simulate EADDRINUSE error
const err = new Error('listen EADDRINUSE: address already in use :::8080');
(err as any).code = 'EADDRINUSE';
throw err;
},
setUpdateRoutesCallback: function() {},
setNetworkProxy: function() {},
setGlobalAcmeDefaults: function() {},
initialize: async function() {
try {
await this.addChallengeRoute();
} catch (e) {
error = e as Error;
throw e;
}
},
stop: async function() {},
getAcmeOptions: function() {
return { email: 'test@example.com' };
}
};
(this as any).certManager = mockCertManager;
};
try {
await testProxy.start();
} catch (e) {
error = e as Error;
}
// Should have caught the port conflict
expect(error).toBeTruthy();
expect(error?.message).toContain('Port 8080 is already in use');
} finally {
// Clean up conflict server
conflictServer.close();
}
});
tap.test('should prevent concurrent provisioning', async () => {
// Mock the certificate manager with tracking
let concurrentAttempts = 0;
let maxConcurrent = 0;
let currentlyProvisioning = 0;
const mockProxy = {
provisionCertificate: async function(route: any, allowConcurrent = false) {
if (!allowConcurrent && currentlyProvisioning > 0) {
console.log('Provisioning already in progress, skipping');
return;
}
concurrentAttempts++;
currentlyProvisioning++;
maxConcurrent = Math.max(maxConcurrent, currentlyProvisioning);
// Simulate provisioning delay
await new Promise(resolve => setTimeout(resolve, 10));
currentlyProvisioning--;
}
};
// Try to provision multiple certificates concurrently
const promises = [];
for (let i = 0; i < 5; i++) {
promises.push(mockProxy.provisionCertificate({ name: `route-${i}` }));
}
await Promise.all(promises);
// Should have rejected concurrent attempts
expect(concurrentAttempts).toEqual(1);
expect(maxConcurrent).toEqual(1);
});
tap.test('should clean up properly even on errors', async () => {
let challengeRouteActive = false;
const mockCertManager = {
challengeRouteActive: false,
addChallengeRoute: async function() {
this.challengeRouteActive = true;
challengeRouteActive = true;
throw new Error('Test error during add');
},
removeChallengeRoute: async function() {
if (!this.challengeRouteActive) {
return;
}
this.challengeRouteActive = false;
challengeRouteActive = false;
},
initialize: async function() {
try {
await this.addChallengeRoute();
} catch (error) {
// Should still clean up
await this.removeChallengeRoute();
throw error;
}
}
};
try {
await mockCertManager.initialize();
} catch (error) {
// Expected error
}
// State should be cleaned up
expect(challengeRouteActive).toEqual(false);
expect(mockCertManager.challengeRouteActive).toEqual(false);
});
tap.start();