Files
smartproxy/test/test.certificate-provision.ts

424 lines
12 KiB
TypeScript
Raw Permalink Normal View History

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');
// 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');
}
}
};
}
2025-07-13 00:05:32 +00:00
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
};
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);
2025-07-13 00:05:32 +00:00
// Create proxy with custom certificate provision
testProxy = new SmartProxy({
certProvisionFunction: async (domain: string): Promise<TSmartProxyCertProvisionObject> => {
console.log(`Custom cert provision called for domain: ${domain}`);
2025-07-13 00:05:32 +00:00
// 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
}
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',
targets: [{
2025-07-13 00:05:32 +00:00
host: 'localhost',
port: 8080
}],
2025-07-13 00:05:32 +00:00
tls: {
mode: 'terminate',
certificate: 'auto'
}
}
}
]
});
2025-07-13 00:05:32 +00:00
expect(testProxy).toBeInstanceOf(SmartProxy);
});
tap.test('Custom certificate provision function should be called', async () => {
let provisionCalled = false;
const provisionedDomains: string[] = [];
2025-07-13 00:05:32 +00:00
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',
targets: [{
2025-07-13 00:05:32 +00:00
host: 'localhost',
port: 8080
}],
2025-07-13 00:05:32 +00:00
tls: {
mode: 'terminate',
certificate: 'auto'
}
}
}
]
});
// Fully mock the certificate manager to avoid ACME server contact
2025-07-13 00:05:32 +00:00
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;
2025-07-13 00:05:32 +00:00
};
2025-07-13 00:05:32 +00:00
// Start the proxy (this will trigger certificate provisioning)
await testProxy2.start();
2025-07-13 00:05:32 +00:00
expect(certManagerCalled).toBeTrue();
expect(provisionCalled).toBeTrue();
expect(provisionedDomains).toContain('custom.example.com');
2025-07-13 00:05:32 +00:00
await testProxy2.stop();
});
tap.test('Should fallback to ACME when custom provision fails', async () => {
const failedDomains: string[] = [];
let acmeAttempted = false;
2025-07-13 00:05:32 +00:00
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: [{
2025-07-13 00:05:32 +00:00
host: 'localhost',
port: 8080
}],
2025-07-13 00:05:32 +00:00
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;
}
2025-07-13 00:05:32 +00:00
}
});
// Set callback as in real implementation
mockCertManager.setUpdateRoutesCallback(async (routes: any) => {
await this.updateRoutes(routes);
});
return mockCertManager;
2025-07-13 00:05:32 +00:00
};
2025-07-13 00:05:32 +00:00
// Start the proxy
await testProxy3.start();
2025-07-13 00:05:32 +00:00
// Custom provision should have failed
expect(failedDomains).toContain('fallback.example.com');
2025-07-13 00:05:32 +00:00
// ACME should have been attempted as fallback
expect(acmeAttempted).toBeTrue();
2025-07-13 00:05:32 +00:00
await testProxy3.stop();
});
tap.test('Should not fallback when certProvisionFallbackToAcme is false', async () => {
let errorThrown = false;
let errorMessage = '';
2025-07-13 00:05:32 +00:00
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
},
2025-07-13 00:05:32 +00:00
routes: [
{
name: 'no-fallback-route',
match: {
ports: [9449],
2025-07-13 00:05:32 +00:00
domains: ['no-fallback.example.com']
},
action: {
type: 'forward',
targets: [{
2025-07-13 00:05:32 +00:00
host: 'localhost',
port: 8080
}],
2025-07-13 00:05:32 +00:00
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;
}
}
2025-07-13 00:05:32 +00:00
}
});
// Set callback as in real implementation
mockCertManager.setUpdateRoutesCallback(async (routes: any) => {
await this.updateRoutes(routes);
});
return mockCertManager;
2025-07-13 00:05:32 +00:00
};
2025-07-13 00:05:32 +00:00
try {
await testProxy4.start();
} catch (e) {
// Expected to fail
}
2025-07-13 00:05:32 +00:00
expect(errorThrown).toBeTrue();
expect(errorMessage).toInclude('Custom provision failed for testing');
2025-07-13 00:05:32 +00:00
await testProxy4.stop();
});
tap.test('Should return http01 for unknown domains', async () => {
let returnedHttp01 = false;
let acmeAttempted = false;
2025-07-13 00:05:32 +00:00
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',
targets: [{
2025-07-13 00:05:32 +00:00
host: 'localhost',
port: 8080
}],
2025-07-13 00:05:32 +00:00
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;
}
2025-07-13 00:05:32 +00:00
}
});
// Set callback as in real implementation
mockCertManager.setUpdateRoutesCallback(async (routes: any) => {
await this.updateRoutes(routes);
});
return mockCertManager;
2025-07-13 00:05:32 +00:00
};
2025-07-13 00:05:32 +00:00
await testProxy5.start();
2025-07-13 00:05:32 +00:00
// Should have returned http01 for unknown domain
expect(returnedHttp01).toBeTrue();
2025-07-13 00:05:32 +00:00
// ACME should have been attempted
expect(acmeAttempted).toBeTrue();
2025-07-13 00:05:32 +00:00
await testProxy5.stop();
});
tap.test('cleanup', async () => {
// Clean up any test proxies
if (testProxy) {
await testProxy.stop();
}
});
export default tap.start();