import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import { rm } from 'node:fs/promises'; import https from 'node:https'; import os from 'node:os'; import path from 'node:path'; import selfsigned from 'selfsigned'; import { startServer } from '../src/server.js'; async function startTlsRuntime() { const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'duplicati-tls-')); const runtime = await startServer({ port: 0, httpsPort: 0, appDbPath: path.join(tempDirectory, 'app.sqlite'), uploadDir: path.join(tempDirectory, 'sources'), tlsDir: path.join(tempDirectory, 'tls'), previewMaxBytes: 1024 }); const httpAddress = runtime.httpServer.address(); const httpsAddress = runtime.httpsServer.address(); const httpBaseUrl = `http://127.0.0.1:${httpAddress.port}`; const httpsBaseUrl = `https://127.0.0.1:${httpsAddress.port}`; return { runtime, httpBaseUrl, httpsBaseUrl, async close() { await runtime.close(); await rm(tempDirectory, { recursive: true, force: true }); } }; } async function httpsRequestJson(baseUrl, pathname, { method = 'GET', headers = {}, body = null } = {}) { const target = new URL(pathname, baseUrl); return new Promise((resolve, reject) => { const request = https.request( target, { method, headers, rejectUnauthorized: false }, (response) => { const chunks = []; response.on('data', (chunk) => chunks.push(chunk)); response.on('end', () => { const payloadText = Buffer.concat(chunks).toString('utf8'); let payload = {}; try { payload = payloadText ? JSON.parse(payloadText) : {}; } catch (error) { payload = { raw: payloadText }; } resolve({ status: response.statusCode ?? 0, headers: response.headers, payload }); }); } ); request.on('error', reject); if (body) { request.write(body); } request.end(); }); } async function setupAuthenticatedClient(baseUrl) { const response = await fetch(`${baseUrl}/api/auth/setup`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'admin', password: 'Password123!' }) }); const payload = await response.json(); const cookie = response.headers.get('set-cookie')?.split(';', 1)?.[0] ?? ''; assert.equal(response.status, 201); assert.equal(payload.auth.authenticated, true); assert.ok(cookie); return { cookie, async json(pathname, options = {}) { const responseValue = await fetch(`${baseUrl}${pathname}`, { ...options, headers: { ...(options.headers ?? {}), Cookie: cookie } }); const payloadValue = await responseValue.json(); return { response: responseValue, payload: payloadValue }; } }; } test('dual-stack startup auto-generates a self-signed certificate and serves health checks over HTTP and HTTPS', async () => { const runtime = await startTlsRuntime(); try { const httpHealth = await fetch(`${runtime.httpBaseUrl}/healthz`).then((response) => response.json()); assert.equal(httpHealth.ok, true); const httpsHealth = await httpsRequestJson(runtime.httpsBaseUrl, '/healthz'); assert.equal(httpsHealth.status, 200); assert.equal(httpsHealth.payload.ok, true); const authState = await fetch(`${runtime.httpBaseUrl}/api/auth/state`).then((response) => response.json()); assert.equal(authState.auth.setupRequired, true); const client = await setupAuthenticatedClient(runtime.httpBaseUrl); const tlsSummary = await httpsRequestJson(runtime.httpsBaseUrl, '/api/system/tls', { headers: { Cookie: client.cookie } }); assert.equal(tlsSummary.status, 200); assert.equal(tlsSummary.payload.tls.mode, 'self-signed'); assert.equal(tlsSummary.payload.tls.activeSource, 'self-signed'); assert.ok(tlsSummary.payload.tls.certificate.fingerprint256); } finally { await runtime.close(); } }); test('saving self-signed TLS settings regenerates certificate metadata and keeps HTTP/HTTPS both available', async () => { const runtime = await startTlsRuntime(); try { const client = await setupAuthenticatedClient(runtime.httpBaseUrl); const before = await client.json('/api/system/tls'); const previousFingerprint = before.payload.tls.certificate.fingerprint256; const updated = await client.json('/api/system/tls', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'self-signed', primaryDomain: 'preview.example.com', subjectAltNames: ['preview.internal', '192.168.5.10'] }) }); assert.equal(updated.response.status, 200); assert.equal(updated.payload.tls.primaryDomain, 'preview.example.com'); assert.equal(updated.payload.tls.activeSource, 'self-signed'); assert.notEqual(updated.payload.tls.certificate.fingerprint256, previousFingerprint); const httpsState = await httpsRequestJson(runtime.httpsBaseUrl, '/api/system/tls', { headers: { Cookie: client.cookie } }); assert.equal(httpsState.status, 200); assert.equal(httpsState.payload.tls.primaryDomain, 'preview.example.com'); assert.deepEqual(httpsState.payload.tls.subjectAltNames, ['preview.internal', '192.168.5.10']); const httpHealth = await fetch(`${runtime.httpBaseUrl}/healthz`).then((response) => response.json()); assert.equal(httpHealth.ok, true); const httpsHealth = await httpsRequestJson(runtime.httpsBaseUrl, '/healthz'); assert.equal(httpsHealth.status, 200); } finally { await runtime.close(); } }); test('custom PEM upload activates custom certificate and delete falls back to self-signed', async () => { const runtime = await startTlsRuntime(); try { const client = await setupAuthenticatedClient(runtime.httpBaseUrl); const generated = await selfsigned.generate( [{ name: 'commonName', value: 'custom.example.com' }], { algorithm: 'sha256', keySize: 2048, days: 365, extensions: [ { name: 'subjectAltName', altNames: [ { type: 2, value: 'custom.example.com' }, { type: 2, value: 'cdn.custom.example.com' } ] } ] } ); const uploadForm = new FormData(); uploadForm.set('primaryDomain', 'custom.example.com'); uploadForm.set('subjectAltNames', 'cdn.custom.example.com'); uploadForm.set('certificate', new File([generated.cert], 'fullchain.pem', { type: 'text/plain' })); uploadForm.set('privateKey', new File([generated.private], 'privkey.pem', { type: 'text/plain' })); const uploadResponse = await fetch(`${runtime.httpBaseUrl}/api/system/tls/upload`, { method: 'POST', headers: { Cookie: client.cookie }, body: uploadForm }); const uploadPayload = await uploadResponse.json(); assert.equal(uploadResponse.status, 200); assert.equal(uploadPayload.tls.mode, 'custom-pem'); assert.equal(uploadPayload.tls.activeSource, 'custom-pem'); const customFingerprint = uploadPayload.tls.certificate.fingerprint256; const httpsState = await httpsRequestJson(runtime.httpsBaseUrl, '/api/system/tls', { headers: { Cookie: client.cookie } }); assert.equal(httpsState.status, 200); assert.equal(httpsState.payload.tls.activeSource, 'custom-pem'); const deleteResult = await client.json('/api/system/tls/custom-certificate', { method: 'DELETE' }); assert.equal(deleteResult.response.status, 200); assert.equal(deleteResult.payload.tls.mode, 'self-signed'); assert.equal(deleteResult.payload.tls.activeSource, 'self-signed'); assert.notEqual(deleteResult.payload.tls.certificate.fingerprint256, customFingerprint); } finally { await runtime.close(); } });