1import test from 'node:test';
2import assert from 'node:assert/strict';
3import {
4 toIsoDate,
5 parseSanDnsNames,
6 hostnameMatchesCert,
7 analyzeExpiry,
8 isWeakProtocol,
9 analyzeKeyStrength,
10 buildCertificateChain,
11 isSelfSigned,
12 scoreCertificateAudit,
13 normalizeAndValidateUrl,
14 auditTlsCertificate,
15} from '../src/main.js';
16
17
18
19test('toIsoDate parses Node certificate date strings', () => {
20 const iso = toIsoDate('Feb 28 12:00:00 2027 GMT');
21 assert.equal(iso, '2027-02-28T12:00:00.000Z');
22});
23
24test('toIsoDate tolerates null and garbage', () => {
25 assert.equal(toIsoDate(null), null);
26 assert.equal(toIsoDate('not a date'), null);
27 assert.equal(toIsoDate(undefined), null);
28});
29
30
31
32test('parseSanDnsNames extracts DNS entries only, lowercased', () => {
33 const names = parseSanDnsNames('DNS:Example.COM, DNS:*.Wikipedia.ORG, IP Address:1.2.3.4, email:a@b.c');
34 assert.deepEqual(names, ['example.com', '*.wikipedia.org']);
35});
36
37test('parseSanDnsNames handles null and empty input', () => {
38 assert.deepEqual(parseSanDnsNames(null), []);
39 assert.deepEqual(parseSanDnsNames(''), []);
40 assert.deepEqual(parseSanDnsNames('IP Address:1.2.3.4'), []);
41});
42
43
44
45test('hostnameMatchesCert matches exact SAN entries', () => {
46 const r = hostnameMatchesCert('github.com', ['github.com', 'www.github.com'], null);
47 assert.equal(r.matches, true);
48 assert.equal(r.matchedName, 'github.com');
49});
50
51test('hostnameMatchesCert matches wildcard SAN limited to the leftmost label', () => {
52 const r = hostnameMatchesCert('docs.github.com', ['*.github.com'], null);
53 assert.equal(r.matches, true);
54 assert.equal(r.matchedName, '*.github.com');
55});
56
57test('hostnameMatchesCert rejects wildcard matching a bare domain', () => {
58 const r = hostnameMatchesCert('github.com', ['*.github.com'], null);
59 assert.equal(r.matches, false);
60});
61
62test('hostnameMatchesCert rejects wildcard crossing label boundaries', () => {
63 const r = hostnameMatchesCert('a.b.github.com', ['*.github.com'], null);
64 assert.equal(r.matches, false);
65});
66
67test('hostnameMatchesCert falls back to the subject CN', () => {
68 const r = hostnameMatchesCert('example.com', [], 'example.com');
69 assert.equal(r.matches, true);
70});
71
72test('hostnameMatchesCert rejects a mismatch entirely', () => {
73 const r = hostnameMatchesCert('wrong.example', ['example.com', '*.example.com'], 'example.com');
74 assert.equal(r.matches, false);
75 assert.equal(r.matchedName, null);
76});
77
78test('hostnameMatchesCert ignores trailing dot on the hostname', () => {
79 const r = hostnameMatchesCert('example.com.', ['example.com'], null);
80 assert.equal(r.matches, true);
81});
82
83
84
85test('analyzeExpiry reports valid with a healthy lifetime', () => {
86 const now = new Date('2026-09-29T00:00:00Z');
87 const r = analyzeExpiry('2026-01-01T00:00:00Z', '2027-01-01T00:00:00Z', now, 30);
88 assert.equal(r.status, 'valid');
89 assert.equal(r.daysUntilExpiry, 94);
90});
91
92test('analyzeExpiry flags expiring-soon inside the warning window', () => {
93 const now = new Date('2026-09-29T00:00:00Z');
94 const r = analyzeExpiry('2026-01-01T00:00:00Z', '2026-10-10T00:00:00Z', now, 30);
95 assert.equal(r.status, 'expiring-soon');
96 assert.equal(r.daysUntilExpiry, 11);
97});
98
99test('analyzeExpiry flags expired certificates with negative days', () => {
100 const now = new Date('2026-09-29T00:00:00Z');
101 const r = analyzeExpiry('2025-01-01T00:00:00Z', '2026-09-01T00:00:00Z', now, 30);
102 assert.equal(r.status, 'expired');
103 assert.equal(r.daysUntilExpiry, -28);
104});
105
106test('analyzeExpiry flags not-yet-valid certificates', () => {
107 const now = new Date('2026-09-29T00:00:00Z');
108 const r = analyzeExpiry('2027-01-01T00:00:00Z', '2028-01-01T00:00:00Z', now, 30);
109 assert.equal(r.status, 'not-yet-valid');
110});
111
112test('analyzeExpiry reports unknown when dates are missing', () => {
113 const r = analyzeExpiry(null, null, new Date(), 30);
114 assert.equal(r.status, 'unknown');
115 assert.equal(r.daysUntilExpiry, null);
116});
117
118
119
120test('isWeakProtocol flags TLS 1.0/1.1/SSLv3 and accepts TLS 1.2/1.3', () => {
121 assert.equal(isWeakProtocol('TLSv1'), true);
122 assert.equal(isWeakProtocol('TLSv1.1'), true);
123 assert.equal(isWeakProtocol('SSLv3'), true);
124 assert.equal(isWeakProtocol('TLSv1.2'), false);
125 assert.equal(isWeakProtocol('TLSv1.3'), false);
126 assert.equal(isWeakProtocol(null), false);
127});
128
129test('analyzeKeyStrength flags RSA below 2048 bits', () => {
130 const r = analyzeKeyStrength({ bits: 1024, asn1Algorithm: 'rsaEncryption' });
131 assert.equal(r.weak, true);
132 assert.equal(r.keyType, 'RSA');
133});
134
135test('analyzeKeyStrength accepts RSA 2048 and above', () => {
136 const r = analyzeKeyStrength({ bits: 2048, asn1Algorithm: 'rsaEncryption' });
137 assert.equal(r.weak, false);
138 assert.equal(r.keyType, 'RSA');
139 const strong = analyzeKeyStrength({ bits: 4096, asn1Algorithm: 'rsaEncryption' });
140 assert.equal(strong.weak, false);
141});
142
143test('analyzeKeyStrength flags EC below 256 bits', () => {
144 const r = analyzeKeyStrength({ bits: 192, asn1Algorithm: 'id-ecPublicKey' });
145 assert.equal(r.weak, true);
146 assert.equal(r.keyType, 'EC');
147});
148
149test('analyzeKeyStrength accepts EC P-256 and above', () => {
150 const r = analyzeKeyStrength({ bits: 256, asn1Algorithm: 'id-ecPublicKey' });
151 assert.equal(r.weak, false);
152 assert.equal(r.keyType, 'EC');
153});
154
155test('analyzeKeyStrength tolerates missing bits', () => {
156 const r = analyzeKeyStrength({ asn1Algorithm: 'rsaEncryption' });
157 assert.equal(r.bits, null);
158 assert.equal(r.weak, false);
159});
160
161
162
163test('isSelfSigned detects subject=issuer certificates', () => {
164 const subject = { CN: 'root' };
165 assert.equal(isSelfSigned({ subject, issuer: { CN: 'root' } }), true);
166 assert.equal(isSelfSigned({ subject, issuer: { CN: 'other' } }), false);
167});
168
169test('isSelfSigned detects matching fingerprints', () => {
170 const cert = {
171 subject: { CN: 'a' },
172 issuer: { CN: 'b' },
173 fingerprint256: 'AA',
174 issuerCertificate: { fingerprint256: 'AA' },
175 };
176 assert.equal(isSelfSigned(cert), true);
177});
178
179test('buildCertificateChain follows issuers without loops', () => {
180 const leaf = {
181 subject: { CN: 'leaf' },
182 issuer: { CN: 'inter' },
183 fingerprint256: 'L1',
184 issuerCertificate: {
185 subject: { CN: 'inter' },
186 issuer: { CN: 'root' },
187 fingerprint256: 'I1',
188 issuerCertificate: {
189 subject: { CN: 'root' },
190 issuer: { CN: 'root' },
191 fingerprint256: 'R1',
192 },
193 },
194 };
195 const chain = buildCertificateChain(leaf);
196 assert.equal(chain.length, 3);
197 assert.equal(isSelfSigned(chain[2]), true);
198});
199
200test('buildCertificateChain stops on circular issuer references', () => {
201 const leaf = {
202 subject: { CN: 'a' },
203 issuer: { CN: 'b' },
204 fingerprint256: 'X1',
205 };
206 leaf.issuerCertificate = leaf;
207 const chain = buildCertificateChain(leaf);
208 assert.equal(chain.length, 1);
209});
210
211test('buildCertificateChain stops on repeated fingerprints', () => {
212 const inter = {
213 subject: { CN: 'inter' },
214 issuer: { CN: 'leaf' },
215 fingerprint256: 'L1',
216 };
217 inter.issuerCertificate = {
218 subject: { CN: 'leaf' },
219 issuer: { CN: 'inter' },
220 fingerprint256: 'L1',
221 };
222 const chain = buildCertificateChain(inter);
223 assert.equal(chain.length, 2);
224});
225
226
227
228test('scoreCertificateAudit gives A+ for a fully healthy certificate', () => {
229 const scored = scoreCertificateAudit({
230 authorized: true,
231 authorizationError: null,
232 expiry: { status: 'valid', daysUntilExpiry: 200, validToIso: '2027-01-01T00:00:00Z' },
233 hostnameMatch: true,
234 weakProtocol: false,
235 protocol: 'TLSv1.3',
236 weakCipher: false,
237 key: { bits: 2048, weak: false, keyType: 'RSA' },
238 incompleteChain: false,
239 selfSignedLeaf: false,
240 });
241 assert.equal(scored.score, 100);
242 assert.equal(scored.grade, 'A+');
243 assert.equal(scored.issues.length, 0);
244});
245
246test('scoreCertificateAudit fails an expired certificate hard', () => {
247 const scored = scoreCertificateAudit({
248 authorized: true,
249 authorizationError: null,
250 expiry: { status: 'expired', daysUntilExpiry: -5, validToIso: '2026-09-24T00:00:00Z' },
251 hostnameMatch: true,
252 weakProtocol: false,
253 protocol: 'TLSv1.3',
254 weakCipher: false,
255 key: { bits: 256, weak: false, keyType: 'EC' },
256 incompleteChain: false,
257 selfSignedLeaf: false,
258 });
259 assert.equal(scored.score, 40);
260 assert.equal(scored.grade, 'E');
261 assert.ok(scored.issues.some((i) => i.includes('expired')));
262});
263
264test('scoreCertificateAudit penalizes expiring-soon', () => {
265 const scored = scoreCertificateAudit({
266 authorized: true,
267 authorizationError: null,
268 expiry: { status: 'expiring-soon', daysUntilExpiry: 10, validToIso: '2026-10-10T00:00:00Z' },
269 hostnameMatch: true,
270 weakProtocol: false,
271 protocol: 'TLSv1.3',
272 weakCipher: false,
273 key: { bits: 256, weak: false, keyType: 'EC' },
274 incompleteChain: false,
275 selfSignedLeaf: false,
276 });
277 assert.equal(scored.score, 85);
278 assert.ok(scored.issues.some((i) => i.includes('10 day(s)')));
279});
280
281test('scoreCertificateAudit penalizes hostname mismatch harder than expiring-soon', () => {
282 const base = {
283 authorized: true,
284 authorizationError: null,
285 expiry: { status: 'valid', daysUntilExpiry: 200 },
286 weakProtocol: false,
287 protocol: 'TLSv1.3',
288 weakCipher: false,
289 key: { bits: 256, weak: false, keyType: 'EC' },
290 incompleteChain: false,
291 selfSignedLeaf: false,
292 };
293 const mismatch = scoreCertificateAudit({ ...base, hostnameMatch: false });
294 const soon = scoreCertificateAudit({
295 ...base,
296 hostnameMatch: true,
297 expiry: { status: 'expiring-soon', daysUntilExpiry: 5 },
298 });
299 assert.equal(mismatch.score, 60);
300 assert.ok(mismatch.score < soon.score);
301});
302
303test('scoreCertificateAudit penalizes untrusted chain and missing intermediate', () => {
304 const scored = scoreCertificateAudit({
305 authorized: false,
306 authorizationError: 'unable to verify the first certificate',
307 expiry: { status: 'valid', daysUntilExpiry: 200 },
308 hostnameMatch: true,
309 weakProtocol: false,
310 protocol: 'TLSv1.2',
311 weakCipher: false,
312 key: { bits: 2048, weak: false, keyType: 'RSA' },
313 incompleteChain: true,
314 selfSignedLeaf: false,
315 });
316 assert.equal(scored.score, 55);
317 assert.ok(scored.issues.some((i) => i.includes('not trusted')));
318 assert.ok(scored.issues.some((i) => i.includes('intermediate')));
319});
320
321test('scoreCertificateAudit penalizes self-signed leaf', () => {
322 const scored = scoreCertificateAudit({
323 authorized: false,
324 authorizationError: 'self signed certificate',
325 expiry: { status: 'valid', daysUntilExpiry: 200 },
326 hostnameMatch: true,
327 weakProtocol: false,
328 protocol: 'TLSv1.3',
329 weakCipher: false,
330 key: { bits: 256, weak: false, keyType: 'EC' },
331 incompleteChain: false,
332 selfSignedLeaf: true,
333 });
334 assert.equal(scored.score, 65);
335 assert.ok(scored.recommendations.some((r) => r.includes('publicly trusted CA')));
336});
337
338test('scoreCertificateAudit penalizes weak protocol and cipher and key', () => {
339 const scored = scoreCertificateAudit({
340 authorized: true,
341 authorizationError: null,
342 expiry: { status: 'valid', daysUntilExpiry: 200 },
343 hostnameMatch: true,
344 weakProtocol: true,
345 protocol: 'TLSv1',
346 weakCipher: true,
347 key: { bits: 1024, weak: true, keyType: 'RSA' },
348 incompleteChain: false,
349 selfSignedLeaf: false,
350 });
351 assert.equal(scored.score, 40);
352 assert.ok(scored.issues.some((i) => i.includes('TLSv1')));
353 assert.ok(scored.issues.some((i) => i.includes('weak cipher')));
354 assert.ok(scored.issues.some((i) => i.includes('weak for its type')));
355});
356
357test('scoreCertificateAudit never returns a score out of bounds', () => {
358 const scored = scoreCertificateAudit({
359 authorized: false,
360 authorizationError: 'bad',
361 expiry: { status: 'expired', daysUntilExpiry: -400 },
362 hostnameMatch: false,
363 weakProtocol: true,
364 protocol: 'TLSv1',
365 weakCipher: true,
366 key: { bits: 512, weak: true, keyType: 'RSA' },
367 incompleteChain: true,
368 selfSignedLeaf: false,
369 });
370 assert.ok(scored.score >= 0 && scored.score <= 100);
371});
372
373
374
375test('normalizeAndValidateUrl rejects private IPv4 literals', async () => {
376 await assert.rejects(() => normalizeAndValidateUrl('https://192.168.1.1/'), /Private IPv4/);
377});
378
379test('normalizeAndValidateUrl rejects localhost', async () => {
380 await assert.rejects(() => normalizeAndValidateUrl('https://localhost/'), /private/i);
381});
382
383test('normalizeAndValidateUrl rejects loopback IP', async () => {
384 await assert.rejects(() => normalizeAndValidateUrl('https://127.0.0.1/'), /Private IPv4/);
385});
386
387test('normalizeAndValidateUrl rejects URL credentials', async () => {
388 await assert.rejects(() => normalizeAndValidateUrl('https://user:pass@example.com/'), /credentials/i);
389});
390
391test('normalizeAndValidateUrl rejects non-HTTP schemes', async () => {
392 await assert.rejects(() => normalizeAndValidateUrl('ftp://example.com/'), /Only HTTP and HTTPS/);
393 await assert.rejects(() => normalizeAndValidateUrl('file:///etc/passwd'), /Only HTTP and HTTPS/);
394});
395
396test('normalizeAndValidateUrl rejects private IPv6 literals', async () => {
397 await assert.rejects(() => normalizeAndValidateUrl('https://[::1]/'), /Private IPv6/);
398 await assert.rejects(() => normalizeAndValidateUrl('https://[fd00::1]/'), /Private IPv6/);
399});
400
401test('normalizeAndValidateUrl accepts a public https URL', async () => {
402 const url = await normalizeAndValidateUrl('https://example.com/');
403 assert.equal(url.hostname, 'example.com');
404});
405
406test('normalizeAndValidateUrl prepends https scheme to bare hosts', async () => {
407 const url = await normalizeAndValidateUrl('example.com');
408 assert.equal(url.protocol, 'https:');
409});
410
411test('normalizeAndValidateUrl rejects missing input', async () => {
412 await assert.rejects(() => normalizeAndValidateUrl(''), /required/i);
413 await assert.rejects(() => normalizeAndValidateUrl(null), /required/i);
414});
415
416
417
418test('auditTlsCertificate returns a structured error for private IP input', async () => {
419 const result = await auditTlsCertificate({ startUrl: 'https://192.168.0.1/' });
420 assert.equal(result.ok, false);
421 assert.equal(result.error !== null, true);
422 assert.equal(result.grade, 'F');
423 assert.equal(result.connected, false);
424});
425
426test('auditTlsCertificate returns a structured error for nonexistent host', async () => {
427 const result = await auditTlsCertificate({
428 startUrl: 'https://tls-certificate-auditor-nonexistent-invalid.example/',
429 timeoutSeconds: 3,
430 });
431 assert.equal(result.ok, false);
432 assert.equal(result.error !== null, true);
433 assert.equal(result.grade, 'F');
434});
435
436test('auditTlsCertificate honors a custom URL port', async () => {
437
438
439 const result = await auditTlsCertificate({ startUrl: 'https://example.com:8443/', timeoutSeconds: 5 });
440 if (result.ok) {
441 assert.equal(result.port, 8443);
442 } else {
443 assert.equal(result.port, 8443);
444 assert.ok(result.error.includes('8443'));
445 }
446});
447
448
449
450test('auditTlsCertificate smoke: example.com serves a trusted, valid certificate', async () => {
451 const result = await auditTlsCertificate({ startUrl: 'https://example.com/', timeoutSeconds: 15 });
452
453 if (result.ok) {
454 assert.equal(result.host, 'example.com');
455 assert.equal(result.port, 443);
456 assert.equal(result.authorized, true);
457 assert.equal(result.hostnameMatch, true);
458 assert.equal(result.expiryStatus, 'valid');
459 assert.ok(result.daysUntilExpiry > 0);
460 assert.ok(['TLSv1.2', 'TLSv1.3'].includes(result.protocol));
461 assert.equal(result.weakCipher, false);
462 assert.ok(result.grade === 'A+' || result.grade === 'A');
463 } else {
464 assert.ok(result.error, 'failed smoke must carry an error message');
465 }
466});
467
468test('auditTlsCertificate smoke: github.com serves a trusted certificate with a SAN match', async () => {
469 const result = await auditTlsCertificate({ startUrl: 'https://github.com/', timeoutSeconds: 15 });
470 if (result.ok) {
471 assert.equal(result.authorized, true);
472 assert.equal(result.hostnameMatch, true);
473 assert.ok(result.subjectAltNames.length > 0 || result.subjectCommonName !== null);
474 assert.ok(typeof result.score === 'number');
475 assert.ok(['A+', 'A', 'B'].includes(result.grade));
476 } else {
477 assert.ok(result.error, 'failed smoke must carry an error message');
478 }
479});