BREAKING CHANGE(SmartAcme): Refactor challenge handling by removing legacy setChallenge/removeChallenge in favor of pluggable challengeHandlers and update documentation and tests accordingly

This commit is contained in:
2025-04-27 14:28:05 +00:00
parent 48018b8955
commit 58015f0b58
16 changed files with 411 additions and 143 deletions

21
test/test.certmatcher.ts Normal file
View File

@ -0,0 +1,21 @@
import { tap, expect } from '@push.rocks/tapbundle';
import { SmartacmeCertMatcher } from '../ts/smartacme.classes.certmatcher.js';
tap.test('should match 2-level domain', async () => {
const matcher = new SmartacmeCertMatcher();
expect(matcher.getCertificateDomainNameByDomainName('example.com')).toEqual('example.com');
});
tap.test('should match 3-level domain', async () => {
const matcher = new SmartacmeCertMatcher();
expect(matcher.getCertificateDomainNameByDomainName('subdomain.example.com')).toEqual('example.com');
});
tap.test('should return undefined for deeper domain', async () => {
const matcher = new SmartacmeCertMatcher();
// domain with 4 or more levels
const result = matcher.getCertificateDomainNameByDomainName('a.b.example.com');
expect(result).toEqual(undefined);
});
export default tap.start();

View File

@ -0,0 +1,38 @@
import { tap, expect } from '@push.rocks/tapbundle';
import { Dns01Handler } from '../ts/handlers/Dns01Handler.js';
tap.test('Dns01Handler prepare and cleanup calls Cloudflare and DNS functions', async () => {
let setCalled = false;
let removeCalled = false;
// fake Cloudflare API
const fakeCF: any = {
convenience: {
acmeSetDnsChallenge: async (ch: any) => {
setCalled = true;
expect(ch).toHaveProperty('hostName');
expect(ch).toHaveProperty('challenge');
},
acmeRemoveDnsChallenge: async (ch: any) => {
removeCalled = true;
expect(ch).toHaveProperty('hostName');
expect(ch).toHaveProperty('challenge');
},
},
};
// fake DNS checker
const fakeDNS: any = {
checkUntilAvailable: async (host: string, rr: string, val: string, count: number, interval: number) => {
expect(host).toEqual('test.host');
expect(rr).toEqual('TXT');
expect(val).toEqual('token');
},
};
const handler = new Dns01Handler(fakeCF, fakeDNS);
const input = { hostName: 'test.host', challenge: 'token' };
await handler.prepare(input);
expect(setCalled).toEqual(true);
await handler.cleanup(input);
expect(removeCalled).toEqual(true);
});
export default tap.start();

View File

@ -0,0 +1,26 @@
import { tap, expect } from '@push.rocks/tapbundle';
import { Http01Handler } from '../ts/handlers/Http01Handler.js';
import { promises as fs } from 'fs';
import * as path from 'path';
import os from 'os';
tap.test('Http01Handler writes challenge file and removes it on cleanup', async () => {
// create temporary webroot directory
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'http01-'));
const handler = new Http01Handler({ webroot: tmpDir });
const token = 'testtoken';
const keyAuth = 'keyAuthValue';
const webPath = `/.well-known/acme-challenge/${token}`;
const input = { type: 'http-01', token, keyAuthorization: keyAuth, webPath };
// prepare should write the file
await handler.prepare(input);
const filePath = path.join(tmpDir, webPath);
const content = await fs.readFile(filePath, 'utf8');
expect(content).toEqual(keyAuth);
// cleanup should remove the file
await handler.cleanup(input);
const exists = await fs.stat(filePath).then(() => true).catch(() => false);
expect(exists).toEqual(false);
});
export default tap.start();

View File

@ -0,0 +1,47 @@
import { tap, expect } from '@push.rocks/tapbundle';
import { Qenv } from '@push.rocks/qenv';
import * as cloudflare from '@apiclient.xyz/cloudflare';
import { SmartAcme } from '../ts/index.js';
import { Dns01Handler } from '../ts/handlers/Dns01Handler.js';
// Load environment variables for credentials (stored under .nogit/)
const testQenv = new Qenv('./', './.nogit/');
// Cloudflare API token for DNS-01 challenge (must be set in .nogit/ or env)
const cfToken = (await testQenv.getEnvVarOnDemand('CF_TOKEN'))!;
const cfAccount = new cloudflare.CloudflareAccount(cfToken);
// MongoDB connection settings for certificate storage (must be set in .nogit/ or env)
const mongoDbName = (await testQenv.getEnvVarOnDemand('MONGODB_DATABASE'))!;
const mongoDbPass = (await testQenv.getEnvVarOnDemand('MONGODB_PASSWORD'))!;
const mongoDbUrl = (await testQenv.getEnvVarOnDemand('MONGODB_URL'))!;
let smartAcmeInstance: SmartAcme;
tap.test('create SmartAcme instance with DNS-01 handler and start', async () => {
smartAcmeInstance = new SmartAcme({
accountEmail: 'domains@lossless.org',
mongoDescriptor: { mongoDbName, mongoDbPass, mongoDbUrl },
environment: 'integration',
retryOptions: {},
challengeHandlers: [new Dns01Handler(cfAccount)],
challengePriority: ['dns-01'],
});
await smartAcmeInstance.start();
expect(smartAcmeInstance).toBeInstanceOf(SmartAcme);
});
tap.test('get a domain certificate via DNS-01 challenge', async () => {
// Replace 'bleu.de' with your test domain if different
const domain = 'bleu.de';
const cert = await smartAcmeInstance.getCertificateForDomain(domain);
expect(cert).toHaveProperty('domainName');
expect(cert.domainName).toEqual(domain);
expect(cert).toHaveProperty('publicKey');
expect(typeof cert.publicKey).toEqual('string');
expect(cert.publicKey.length).toBeGreaterThan(0);
});
tap.test('stop SmartAcme instance', async () => {
await smartAcmeInstance.stop();
});
export default tap.start();

32
test/test.smartacme.ts Normal file
View File

@ -0,0 +1,32 @@
import { tap, expect } from '@push.rocks/tapbundle';
import { SmartAcme } from '../ts/index.js';
import type { IChallengeHandler } from '../ts/handlers/IChallengeHandler.js';
// Dummy handler for testing
class DummyHandler implements IChallengeHandler<any> {
getSupportedTypes(): string[] { return ['dns-01']; }
async prepare(_: any): Promise<void> { /* no-op */ }
async cleanup(_: any): Promise<void> { /* no-op */ }
}
tap.test('constructor throws without challengeHandlers', async () => {
expect(() => new SmartAcme({
accountEmail: 'test@example.com',
mongoDescriptor: { mongoDbName: 'db', mongoDbPass: 'pass', mongoDbUrl: 'url' },
environment: 'integration',
retryOptions: {},
} as any)).toThrow();
});
tap.test('constructor accepts valid challengeHandlers', async () => {
const sa = new SmartAcme({
accountEmail: 'test@example.com',
mongoDescriptor: { mongoDbName: 'db', mongoDbPass: 'pass', mongoDbUrl: 'url' },
environment: 'integration',
retryOptions: {},
challengeHandlers: [new DummyHandler()],
});
expect(sa).toBeInstanceOf(SmartAcme);
});
export default tap.start();

View File

@ -1,48 +0,0 @@
import { tap, expect } from '@push.rocks/tapbundle';
import { Qenv } from '@push.rocks/qenv';
import * as cloudflare from '@apiclient.xyz/cloudflare';
const testQenv = new Qenv('./', './.nogit/');
const testCloudflare = new cloudflare.CloudflareAccount(await testQenv.getEnvVarOnDemand('CF_TOKEN'));
import * as smartacme from '../ts/index.js';
let smartAcmeInstance: smartacme.SmartAcme;
tap.test('should create a valid instance of SmartAcme', async () => {
smartAcmeInstance = new smartacme.SmartAcme({
accountEmail: 'domains@lossless.org',
accountPrivateKey: null,
mongoDescriptor: {
mongoDbName: await testQenv.getEnvVarOnDemand('MONGODB_DATABASE'),
mongoDbPass: await testQenv.getEnvVarOnDemand('MONGODB_PASSWORD'),
mongoDbUrl: await testQenv.getEnvVarOnDemand('MONGODB_URL'),
},
removeChallenge: async (dnsChallenge) => {
testCloudflare.convenience.acmeRemoveDnsChallenge(dnsChallenge);
},
setChallenge: async (dnsChallenge) => {
testCloudflare.convenience.acmeSetDnsChallenge(dnsChallenge);
},
environment: 'integration',
});
await smartAcmeInstance.start();
});
tap.test('should get a domain certificate', async () => {
const certificate = await smartAcmeInstance.getCertificateForDomain('bleu.de');
console.log(certificate);
});
tap.test('certmatcher should correctly match domains', async () => {
const certMatcherMod = await import('../ts/smartacme.classes.certmatcher.js');
const certMatcher = new certMatcherMod.SmartacmeCertMatcher();
const matchedCert = certMatcher.getCertificateDomainNameByDomainName('level3.level2.level1');
expect(matchedCert).toEqual('level2.level1');
});
tap.test('should stop correctly', async () => {
await smartAcmeInstance.stop();
});
tap.start();