248 lines
8.0 KiB
JavaScript
248 lines
8.0 KiB
JavaScript
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();
|
|
}
|
|
});
|