2025-07-13 00:05:32 +00:00
|
|
|
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');
|
|
|
|
|
|
|
|
tap.test('SmartProxy should support custom certificate provision function', async () => {
|
2025-07-13 00:41:44 +00:00
|
|
|
// Create test certificate object matching ICert interface
|
2025-07-13 00:05:32 +00:00
|
|
|
const testCertObject = {
|
2025-07-13 00:41:44 +00:00
|
|
|
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: ''
|
2025-07-13 00:05:32 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
// 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}`);
|
2025-07-13 00:41:44 +00:00
|
|
|
return customCerts.get(domain)!;
|
2025-07-13 00:05:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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',
|
|
|
|
target: {
|
|
|
|
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);
|
|
|
|
|
2025-07-13 00:41:44 +00:00
|
|
|
// Return a test certificate matching ICert interface
|
2025-07-13 00:05:32 +00:00
|
|
|
return {
|
2025-07-13 00:41:44 +00:00
|
|
|
id: `test-cert-${domain}`,
|
|
|
|
domainName: domain,
|
|
|
|
created: Date.now(),
|
|
|
|
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000,
|
|
|
|
privateKey: testKey,
|
|
|
|
publicKey: testCert,
|
|
|
|
csr: ''
|
|
|
|
};
|
2025-07-13 00:05:32 +00:00
|
|
|
},
|
|
|
|
acme: {
|
|
|
|
email: 'test@example.com',
|
|
|
|
useProduction: false,
|
|
|
|
port: 9080
|
|
|
|
},
|
|
|
|
routes: [
|
|
|
|
{
|
|
|
|
name: 'custom-cert-route',
|
|
|
|
match: {
|
|
|
|
ports: [9443],
|
|
|
|
domains: ['custom.example.com']
|
|
|
|
},
|
|
|
|
action: {
|
|
|
|
type: 'forward',
|
|
|
|
target: {
|
|
|
|
host: 'localhost',
|
|
|
|
port: 8080
|
|
|
|
},
|
|
|
|
tls: {
|
|
|
|
mode: 'terminate',
|
|
|
|
certificate: 'auto'
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
]
|
|
|
|
});
|
|
|
|
|
|
|
|
// Mock the certificate manager to test our custom provision function
|
|
|
|
let certManagerCalled = false;
|
|
|
|
const origCreateCertManager = (testProxy2 as any).createCertificateManager;
|
|
|
|
(testProxy2 as any).createCertificateManager = async function(...args: any[]) {
|
|
|
|
const certManager = await origCreateCertManager.apply(testProxy2, args);
|
|
|
|
|
|
|
|
// Override provisionAllCertificates to track calls
|
|
|
|
const origProvisionAll = certManager.provisionAllCertificates;
|
|
|
|
certManager.provisionAllCertificates = async function() {
|
|
|
|
certManagerCalled = true;
|
|
|
|
await origProvisionAll.call(certManager);
|
|
|
|
};
|
|
|
|
|
|
|
|
return certManager;
|
|
|
|
};
|
|
|
|
|
|
|
|
// 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',
|
|
|
|
target: {
|
|
|
|
host: 'localhost',
|
|
|
|
port: 8080
|
|
|
|
},
|
|
|
|
tls: {
|
|
|
|
mode: 'terminate',
|
|
|
|
certificate: 'auto'
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
]
|
|
|
|
});
|
|
|
|
|
|
|
|
// Mock to track ACME attempts
|
|
|
|
const origCreateCertManager = (testProxy3 as any).createCertificateManager;
|
|
|
|
(testProxy3 as any).createCertificateManager = async function(...args: any[]) {
|
|
|
|
const certManager = await origCreateCertManager.apply(testProxy3, args);
|
|
|
|
|
|
|
|
// Mock SmartAcme to avoid real ACME calls
|
|
|
|
(certManager as any).smartAcme = {
|
|
|
|
getCertificateForDomain: async () => {
|
|
|
|
acmeAttempted = true;
|
|
|
|
throw new Error('Mocked ACME failure');
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
return certManager;
|
|
|
|
};
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
routes: [
|
|
|
|
{
|
|
|
|
name: 'no-fallback-route',
|
|
|
|
match: {
|
|
|
|
ports: [9445],
|
|
|
|
domains: ['no-fallback.example.com']
|
|
|
|
},
|
|
|
|
action: {
|
|
|
|
type: 'forward',
|
|
|
|
target: {
|
|
|
|
host: 'localhost',
|
|
|
|
port: 8080
|
|
|
|
},
|
|
|
|
tls: {
|
|
|
|
mode: 'terminate',
|
|
|
|
certificate: 'auto'
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
]
|
|
|
|
});
|
|
|
|
|
|
|
|
// Mock certificate manager to capture errors
|
|
|
|
const origCreateCertManager = (testProxy4 as any).createCertificateManager;
|
|
|
|
(testProxy4 as any).createCertificateManager = async function(...args: any[]) {
|
|
|
|
const certManager = await origCreateCertManager.apply(testProxy4, args);
|
|
|
|
|
|
|
|
// Override provisionAllCertificates to capture errors
|
|
|
|
const origProvisionAll = certManager.provisionAllCertificates;
|
|
|
|
certManager.provisionAllCertificates = async function() {
|
|
|
|
try {
|
|
|
|
await origProvisionAll.call(certManager);
|
|
|
|
} catch (e) {
|
|
|
|
errorThrown = true;
|
|
|
|
errorMessage = e.message;
|
|
|
|
throw e;
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
return certManager;
|
|
|
|
};
|
|
|
|
|
|
|
|
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 {
|
2025-07-13 00:41:44 +00:00
|
|
|
id: `test-cert-${domain}`,
|
|
|
|
domainName: domain,
|
|
|
|
created: Date.now(),
|
|
|
|
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000,
|
|
|
|
privateKey: testKey,
|
|
|
|
publicKey: testCert,
|
|
|
|
csr: ''
|
|
|
|
};
|
2025-07-13 00:05:32 +00:00
|
|
|
}
|
|
|
|
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',
|
|
|
|
target: {
|
|
|
|
host: 'localhost',
|
|
|
|
port: 8080
|
|
|
|
},
|
|
|
|
tls: {
|
|
|
|
mode: 'terminate',
|
|
|
|
certificate: 'auto'
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
]
|
|
|
|
});
|
|
|
|
|
|
|
|
// Mock to track ACME attempts
|
|
|
|
const origCreateCertManager = (testProxy5 as any).createCertificateManager;
|
|
|
|
(testProxy5 as any).createCertificateManager = async function(...args: any[]) {
|
|
|
|
const certManager = await origCreateCertManager.apply(testProxy5, args);
|
|
|
|
|
|
|
|
// Mock SmartAcme to track attempts
|
|
|
|
(certManager as any).smartAcme = {
|
|
|
|
getCertificateForDomain: async () => {
|
|
|
|
acmeAttempted = true;
|
|
|
|
throw new Error('Mocked ACME failure');
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
return certManager;
|
|
|
|
};
|
|
|
|
|
|
|
|
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();
|