424 lines
12 KiB
TypeScript
424 lines
12 KiB
TypeScript
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
|
import { SmartProxy } from '../ts/index.js';
|
|
import type { TSmartProxyCertProvisionObject } from '../ts/index.js';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
let testProxy: SmartProxy;
|
|
|
|
// Load test certificates from helpers
|
|
const testCert = fs.readFileSync(path.join(__dirname, 'helpers/test-cert.pem'), 'utf8');
|
|
const testKey = fs.readFileSync(path.join(__dirname, 'helpers/test-key.pem'), 'utf8');
|
|
|
|
// Helper to create a fully mocked certificate manager that doesn't contact ACME servers
|
|
function createMockCertManager(options: {
|
|
onProvisionAll?: () => void;
|
|
onGetCertForDomain?: (domain: string) => void;
|
|
} = {}) {
|
|
return {
|
|
setUpdateRoutesCallback: function(callback: any) {
|
|
this.updateRoutesCallback = callback;
|
|
},
|
|
updateRoutesCallback: null as any,
|
|
setHttpProxy: function() {},
|
|
setGlobalAcmeDefaults: function() {},
|
|
setAcmeStateManager: function() {},
|
|
setRoutes: function(routes: any) {},
|
|
initialize: async function() {},
|
|
provisionAllCertificates: async function() {
|
|
if (options.onProvisionAll) {
|
|
options.onProvisionAll();
|
|
}
|
|
},
|
|
stop: async function() {},
|
|
getAcmeOptions: function() {
|
|
return { email: 'test@example.com', useProduction: false };
|
|
},
|
|
getState: function() {
|
|
return { challengeRouteActive: false };
|
|
},
|
|
smartAcme: {
|
|
getCertificateForDomain: async (domain: string) => {
|
|
if (options.onGetCertForDomain) {
|
|
options.onGetCertForDomain(domain);
|
|
}
|
|
throw new Error('Mocked ACME - not calling real servers');
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
tap.test('SmartProxy should support custom certificate provision function', async () => {
|
|
// Create test certificate object matching ICert interface
|
|
const testCertObject = {
|
|
id: 'test-cert-1',
|
|
domainName: 'test.example.com',
|
|
created: Date.now(),
|
|
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000, // 90 days
|
|
privateKey: testKey,
|
|
publicKey: testCert,
|
|
csr: ''
|
|
};
|
|
|
|
// Custom certificate store for testing
|
|
const customCerts = new Map<string, typeof testCertObject>();
|
|
customCerts.set('test.example.com', testCertObject);
|
|
|
|
// Create proxy with custom certificate provision
|
|
testProxy = new SmartProxy({
|
|
certProvisionFunction: async (domain: string): Promise<TSmartProxyCertProvisionObject> => {
|
|
console.log(`Custom cert provision called for domain: ${domain}`);
|
|
|
|
// Return custom cert for known domains
|
|
if (customCerts.has(domain)) {
|
|
console.log(`Returning custom certificate for ${domain}`);
|
|
return customCerts.get(domain)!;
|
|
}
|
|
|
|
// Fallback to Let's Encrypt for other domains
|
|
console.log(`Falling back to Let's Encrypt for ${domain}`);
|
|
return 'http01';
|
|
},
|
|
certProvisionFallbackToAcme: true,
|
|
acme: {
|
|
email: 'test@example.com',
|
|
useProduction: false
|
|
},
|
|
routes: [
|
|
{
|
|
name: 'test-route',
|
|
match: {
|
|
ports: [443],
|
|
domains: ['test.example.com']
|
|
},
|
|
action: {
|
|
type: 'forward',
|
|
targets: [{
|
|
host: 'localhost',
|
|
port: 8080
|
|
}],
|
|
tls: {
|
|
mode: 'terminate',
|
|
certificate: 'auto'
|
|
}
|
|
}
|
|
}
|
|
]
|
|
});
|
|
|
|
expect(testProxy).toBeInstanceOf(SmartProxy);
|
|
});
|
|
|
|
tap.test('Custom certificate provision function should be called', async () => {
|
|
let provisionCalled = false;
|
|
const provisionedDomains: string[] = [];
|
|
|
|
const testProxy2 = new SmartProxy({
|
|
certProvisionFunction: async (domain: string): Promise<TSmartProxyCertProvisionObject> => {
|
|
provisionCalled = true;
|
|
provisionedDomains.push(domain);
|
|
|
|
// Return a test certificate matching ICert interface
|
|
return {
|
|
id: `test-cert-${domain}`,
|
|
domainName: domain,
|
|
created: Date.now(),
|
|
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000,
|
|
privateKey: testKey,
|
|
publicKey: testCert,
|
|
csr: ''
|
|
};
|
|
},
|
|
acme: {
|
|
email: 'test@example.com',
|
|
useProduction: false,
|
|
port: 9080
|
|
},
|
|
routes: [
|
|
{
|
|
name: 'custom-cert-route',
|
|
match: {
|
|
ports: [9443],
|
|
domains: ['custom.example.com']
|
|
},
|
|
action: {
|
|
type: 'forward',
|
|
targets: [{
|
|
host: 'localhost',
|
|
port: 8080
|
|
}],
|
|
tls: {
|
|
mode: 'terminate',
|
|
certificate: 'auto'
|
|
}
|
|
}
|
|
}
|
|
]
|
|
});
|
|
|
|
// Fully mock the certificate manager to avoid ACME server contact
|
|
let certManagerCalled = false;
|
|
(testProxy2 as any).createCertificateManager = async function() {
|
|
const mockCertManager = createMockCertManager({
|
|
onProvisionAll: () => {
|
|
certManagerCalled = true;
|
|
// Simulate calling the provision function
|
|
testProxy2.settings.certProvisionFunction?.('custom.example.com');
|
|
}
|
|
});
|
|
|
|
// Set callback as in real implementation
|
|
mockCertManager.setUpdateRoutesCallback(async (routes: any) => {
|
|
await this.updateRoutes(routes);
|
|
});
|
|
|
|
return mockCertManager;
|
|
};
|
|
|
|
// Start the proxy (this will trigger certificate provisioning)
|
|
await testProxy2.start();
|
|
|
|
expect(certManagerCalled).toBeTrue();
|
|
expect(provisionCalled).toBeTrue();
|
|
expect(provisionedDomains).toContain('custom.example.com');
|
|
|
|
await testProxy2.stop();
|
|
});
|
|
|
|
tap.test('Should fallback to ACME when custom provision fails', async () => {
|
|
const failedDomains: string[] = [];
|
|
let acmeAttempted = false;
|
|
|
|
const testProxy3 = new SmartProxy({
|
|
certProvisionFunction: async (domain: string): Promise<TSmartProxyCertProvisionObject> => {
|
|
failedDomains.push(domain);
|
|
throw new Error('Custom provision failed for testing');
|
|
},
|
|
certProvisionFallbackToAcme: true,
|
|
acme: {
|
|
email: 'test@example.com',
|
|
useProduction: false,
|
|
port: 9080
|
|
},
|
|
routes: [
|
|
{
|
|
name: 'fallback-route',
|
|
match: {
|
|
ports: [9444],
|
|
domains: ['fallback.example.com']
|
|
},
|
|
action: {
|
|
type: 'forward',
|
|
targets: [{
|
|
host: 'localhost',
|
|
port: 8080
|
|
}],
|
|
tls: {
|
|
mode: 'terminate',
|
|
certificate: 'auto'
|
|
}
|
|
}
|
|
}
|
|
]
|
|
});
|
|
|
|
// Fully mock the certificate manager to avoid ACME server contact
|
|
(testProxy3 as any).createCertificateManager = async function() {
|
|
const mockCertManager = createMockCertManager({
|
|
onProvisionAll: async () => {
|
|
// Simulate the provision logic: first try custom function, then ACME
|
|
try {
|
|
await testProxy3.settings.certProvisionFunction?.('fallback.example.com');
|
|
} catch (e) {
|
|
// Custom provision failed, try ACME
|
|
acmeAttempted = true;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Set callback as in real implementation
|
|
mockCertManager.setUpdateRoutesCallback(async (routes: any) => {
|
|
await this.updateRoutes(routes);
|
|
});
|
|
|
|
return mockCertManager;
|
|
};
|
|
|
|
// Start the proxy
|
|
await testProxy3.start();
|
|
|
|
// Custom provision should have failed
|
|
expect(failedDomains).toContain('fallback.example.com');
|
|
|
|
// ACME should have been attempted as fallback
|
|
expect(acmeAttempted).toBeTrue();
|
|
|
|
await testProxy3.stop();
|
|
});
|
|
|
|
tap.test('Should not fallback when certProvisionFallbackToAcme is false', async () => {
|
|
let errorThrown = false;
|
|
let errorMessage = '';
|
|
|
|
const testProxy4 = new SmartProxy({
|
|
certProvisionFunction: async (_domain: string): Promise<TSmartProxyCertProvisionObject> => {
|
|
throw new Error('Custom provision failed for testing');
|
|
},
|
|
certProvisionFallbackToAcme: false,
|
|
acme: {
|
|
email: 'test@example.com',
|
|
useProduction: false,
|
|
port: 9082
|
|
},
|
|
routes: [
|
|
{
|
|
name: 'no-fallback-route',
|
|
match: {
|
|
ports: [9449],
|
|
domains: ['no-fallback.example.com']
|
|
},
|
|
action: {
|
|
type: 'forward',
|
|
targets: [{
|
|
host: 'localhost',
|
|
port: 8080
|
|
}],
|
|
tls: {
|
|
mode: 'terminate',
|
|
certificate: 'auto'
|
|
}
|
|
}
|
|
}
|
|
]
|
|
});
|
|
|
|
// Fully mock the certificate manager to avoid ACME server contact
|
|
(testProxy4 as any).createCertificateManager = async function() {
|
|
const mockCertManager = createMockCertManager({
|
|
onProvisionAll: async () => {
|
|
// Simulate the provision logic with no fallback
|
|
try {
|
|
await testProxy4.settings.certProvisionFunction?.('no-fallback.example.com');
|
|
} catch (e: any) {
|
|
errorThrown = true;
|
|
errorMessage = e.message;
|
|
// With certProvisionFallbackToAcme=false, the error should propagate
|
|
if (!testProxy4.settings.certProvisionFallbackToAcme) {
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Set callback as in real implementation
|
|
mockCertManager.setUpdateRoutesCallback(async (routes: any) => {
|
|
await this.updateRoutes(routes);
|
|
});
|
|
|
|
return mockCertManager;
|
|
};
|
|
|
|
try {
|
|
await testProxy4.start();
|
|
} catch (e) {
|
|
// Expected to fail
|
|
}
|
|
|
|
expect(errorThrown).toBeTrue();
|
|
expect(errorMessage).toInclude('Custom provision failed for testing');
|
|
|
|
await testProxy4.stop();
|
|
});
|
|
|
|
tap.test('Should return http01 for unknown domains', async () => {
|
|
let returnedHttp01 = false;
|
|
let acmeAttempted = false;
|
|
|
|
const testProxy5 = new SmartProxy({
|
|
certProvisionFunction: async (domain: string): Promise<TSmartProxyCertProvisionObject> => {
|
|
if (domain === 'known.example.com') {
|
|
return {
|
|
id: `test-cert-${domain}`,
|
|
domainName: domain,
|
|
created: Date.now(),
|
|
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000,
|
|
privateKey: testKey,
|
|
publicKey: testCert,
|
|
csr: ''
|
|
};
|
|
}
|
|
returnedHttp01 = true;
|
|
return 'http01';
|
|
},
|
|
acme: {
|
|
email: 'test@example.com',
|
|
useProduction: false,
|
|
port: 9081
|
|
},
|
|
routes: [
|
|
{
|
|
name: 'unknown-domain-route',
|
|
match: {
|
|
ports: [9446],
|
|
domains: ['unknown.example.com']
|
|
},
|
|
action: {
|
|
type: 'forward',
|
|
targets: [{
|
|
host: 'localhost',
|
|
port: 8080
|
|
}],
|
|
tls: {
|
|
mode: 'terminate',
|
|
certificate: 'auto'
|
|
}
|
|
}
|
|
}
|
|
]
|
|
});
|
|
|
|
// Fully mock the certificate manager to avoid ACME server contact
|
|
(testProxy5 as any).createCertificateManager = async function() {
|
|
const mockCertManager = createMockCertManager({
|
|
onProvisionAll: async () => {
|
|
// Simulate the provision logic: call provision function first
|
|
const result = await testProxy5.settings.certProvisionFunction?.('unknown.example.com');
|
|
if (result === 'http01') {
|
|
// http01 means use ACME
|
|
acmeAttempted = true;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Set callback as in real implementation
|
|
mockCertManager.setUpdateRoutesCallback(async (routes: any) => {
|
|
await this.updateRoutes(routes);
|
|
});
|
|
|
|
return mockCertManager;
|
|
};
|
|
|
|
await testProxy5.start();
|
|
|
|
// Should have returned http01 for unknown domain
|
|
expect(returnedHttp01).toBeTrue();
|
|
|
|
// ACME should have been attempted
|
|
expect(acmeAttempted).toBeTrue();
|
|
|
|
await testProxy5.stop();
|
|
});
|
|
|
|
tap.test('cleanup', async () => {
|
|
// Clean up any test proxies
|
|
if (testProxy) {
|
|
await testProxy.stop();
|
|
}
|
|
});
|
|
|
|
export default tap.start();
|