feat: add auth and dual-stack tls management
This commit is contained in:
@@ -51,27 +51,70 @@ async function startTestServer() {
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadFixture(baseUrl, fixture) {
|
||||
return uploadDatabase(baseUrl, '/api/sources/upload', fixture);
|
||||
async function uploadFixture(baseUrl, fixture, client = null) {
|
||||
return uploadDatabase(baseUrl, '/api/sources/upload', fixture, client);
|
||||
}
|
||||
|
||||
async function uploadServerDbFixture(baseUrl, fixture) {
|
||||
return uploadDatabase(baseUrl, '/api/server-db/upload', fixture);
|
||||
async function uploadServerDbFixture(baseUrl, fixture, client = null) {
|
||||
return uploadDatabase(baseUrl, '/api/server-db/upload', fixture, client);
|
||||
}
|
||||
|
||||
async function uploadDatabase(baseUrl, endpoint, fixture) {
|
||||
async function uploadDatabase(baseUrl, endpoint, fixture, client = null) {
|
||||
const form = new FormData();
|
||||
const bytes = await fs.promises.readFile(fixture.databasePath);
|
||||
form.set('database', new File([bytes], path.basename(fixture.databasePath), { type: 'application/octet-stream' }));
|
||||
|
||||
const response = await fetch(`${baseUrl}${endpoint}`, {
|
||||
method: 'POST',
|
||||
body: form
|
||||
});
|
||||
const response = client
|
||||
? await client.fetch(endpoint, {
|
||||
method: 'POST',
|
||||
body: form
|
||||
})
|
||||
: await fetch(`${baseUrl}${endpoint}`, {
|
||||
method: 'POST',
|
||||
body: form
|
||||
});
|
||||
const payload = await response.json();
|
||||
return { response, payload };
|
||||
}
|
||||
|
||||
function attachCookie(headers, cookie) {
|
||||
const normalizedHeaders = new Headers(headers ?? {});
|
||||
normalizedHeaders.set('Cookie', cookie);
|
||||
return normalizedHeaders;
|
||||
}
|
||||
|
||||
async function setupAuthenticatedClient(baseUrl) {
|
||||
const setupResponse = await fetch(`${baseUrl}/api/auth/setup`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: 'admin',
|
||||
password: 'Password123!'
|
||||
})
|
||||
});
|
||||
const setupPayload = await setupResponse.json();
|
||||
const sessionCookie = setupResponse.headers.get('set-cookie')?.split(';', 1)?.[0] ?? '';
|
||||
assert.equal(setupResponse.status, 201);
|
||||
assert.equal(setupPayload.auth.authenticated, true);
|
||||
assert.ok(sessionCookie);
|
||||
|
||||
return {
|
||||
cookie: sessionCookie,
|
||||
async fetch(pathname, options = {}) {
|
||||
return fetch(`${baseUrl}${pathname}`, {
|
||||
...options,
|
||||
headers: attachCookie(options.headers, sessionCookie)
|
||||
});
|
||||
},
|
||||
async json(pathname, options = {}) {
|
||||
const response = await this.fetch(pathname, options);
|
||||
return response.json();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createBasicHeader(username, password) {
|
||||
return `Basic ${Buffer.from(`${username}:${password}`, 'utf8').toString('base64')}`;
|
||||
}
|
||||
@@ -123,11 +166,11 @@ async function startRemoteVolumeServer({ remoteDir, username, password }) {
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForEnhancementStatus(baseUrl, sourceId, allowedStatuses) {
|
||||
async function waitForEnhancementStatus(baseUrl, sourceId, allowedStatuses, client = null) {
|
||||
for (let attempt = 0; attempt < 120; attempt += 1) {
|
||||
const payload = await fetch(`${baseUrl}/api/sources/${sourceId}/enhance/status`).then((response) =>
|
||||
response.json()
|
||||
);
|
||||
const payload = client
|
||||
? await client.json(`/api/sources/${sourceId}/enhance/status`)
|
||||
: await fetch(`${baseUrl}/api/sources/${sourceId}/enhance/status`).then((response) => response.json());
|
||||
if (allowedStatuses.includes(payload.enhancement.status)) {
|
||||
return payload.enhancement;
|
||||
}
|
||||
@@ -156,40 +199,46 @@ test('multi-source uploads coexist, duplicate uploads reuse the existing source,
|
||||
});
|
||||
|
||||
try {
|
||||
const emptySources = await fetch(`${server.baseUrl}/api/sources`).then((response) => response.json());
|
||||
const authStateBeforeSetup = await fetch(`${server.baseUrl}/api/auth/state`).then((response) => response.json());
|
||||
assert.equal(authStateBeforeSetup.auth.setupRequired, true);
|
||||
assert.equal(authStateBeforeSetup.auth.authenticated, false);
|
||||
|
||||
const unauthenticatedSources = await fetch(`${server.baseUrl}/api/sources`).then((response) => response.json());
|
||||
assert.equal(unauthenticatedSources.error.code, 'AUTH_SETUP_REQUIRED');
|
||||
|
||||
const client = await setupAuthenticatedClient(server.baseUrl);
|
||||
const emptySources = await client.json('/api/sources');
|
||||
assert.equal(emptySources.sources.length, 0);
|
||||
|
||||
const firstUpload = await uploadFixture(server.baseUrl, firstFixture);
|
||||
const firstUpload = await uploadFixture(server.baseUrl, firstFixture, client);
|
||||
assert.equal(firstUpload.response.status, 201);
|
||||
assert.equal(firstUpload.payload.reused, false);
|
||||
|
||||
const duplicateUpload = await uploadFixture(server.baseUrl, firstFixture);
|
||||
const duplicateUpload = await uploadFixture(server.baseUrl, firstFixture, client);
|
||||
assert.equal(duplicateUpload.response.status, 200);
|
||||
assert.equal(duplicateUpload.payload.reused, true);
|
||||
assert.equal(duplicateUpload.payload.source.id, firstUpload.payload.source.id);
|
||||
|
||||
const secondUpload = await uploadFixture(server.baseUrl, secondFixture);
|
||||
const secondUpload = await uploadFixture(server.baseUrl, secondFixture, client);
|
||||
assert.equal(secondUpload.response.status, 201);
|
||||
assert.notEqual(secondUpload.payload.source.id, firstUpload.payload.source.id);
|
||||
|
||||
const compatCurrent = await fetch(`${server.baseUrl}/api/source/current`);
|
||||
const compatCurrent = await client.fetch('/api/source/current');
|
||||
const compatPayload = await compatCurrent.json();
|
||||
assert.equal(compatCurrent.status, 409);
|
||||
assert.equal(compatPayload.error.code, 'SOURCE_ID_REQUIRED');
|
||||
|
||||
const missingSourceId = await fetch(`${server.baseUrl}/api/ls?path=/source/media/movies`).then((response) =>
|
||||
response.json()
|
||||
);
|
||||
const missingSourceId = await client.json('/api/ls?path=/source/media/movies');
|
||||
assert.equal(missingSourceId.error.code, 'SOURCE_ID_REQUIRED');
|
||||
|
||||
const firstListing = await fetch(
|
||||
`${server.baseUrl}/api/ls?sourceId=${firstUpload.payload.source.id}&path=/source/media/movies`
|
||||
).then((response) => response.json());
|
||||
const firstListing = await client.json(
|
||||
`/api/ls?sourceId=${firstUpload.payload.source.id}&path=/source/media/movies`
|
||||
);
|
||||
assert.equal(firstListing.sourceId, firstUpload.payload.source.id);
|
||||
|
||||
const secondListing = await fetch(
|
||||
`${server.baseUrl}/api/ls?sourceId=${secondUpload.payload.source.id}&path=/source/media/movies`
|
||||
).then((response) => response.json());
|
||||
const secondListing = await client.json(
|
||||
`/api/ls?sourceId=${secondUpload.payload.source.id}&path=/source/media/movies`
|
||||
);
|
||||
assert.equal(secondListing.sourceId, secondUpload.payload.source.id);
|
||||
|
||||
const filesInSourceRoot = await fs.promises.readdir(server.uploadDir, { withFileTypes: true });
|
||||
@@ -228,35 +277,34 @@ test('deleting a single source removes only that source and keeps the server dat
|
||||
});
|
||||
|
||||
try {
|
||||
const uploadA = await uploadFixture(server.baseUrl, sourceA);
|
||||
const uploadB = await uploadFixture(server.baseUrl, sourceB);
|
||||
const client = await setupAuthenticatedClient(server.baseUrl);
|
||||
const uploadA = await uploadFixture(server.baseUrl, sourceA, client);
|
||||
const uploadB = await uploadFixture(server.baseUrl, sourceB, client);
|
||||
const deleteSourceDir = path.join(server.uploadDir, uploadA.payload.source.id);
|
||||
const keepSourceDir = path.join(server.uploadDir, uploadB.payload.source.id);
|
||||
|
||||
const serverDbUpload = await uploadServerDbFixture(server.baseUrl, serverDb);
|
||||
const serverDbUpload = await uploadServerDbFixture(server.baseUrl, serverDb, client);
|
||||
assert.equal(serverDbUpload.response.status, 201);
|
||||
|
||||
const deleteResponse = await fetch(`${server.baseUrl}/api/sources/${uploadA.payload.source.id}`, {
|
||||
const deleteResponse = await client.fetch(`/api/sources/${uploadA.payload.source.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const deletePayload = await deleteResponse.json();
|
||||
assert.equal(deleteResponse.status, 200);
|
||||
assert.equal(deletePayload.deleted.id, uploadA.payload.source.id);
|
||||
|
||||
const remainingSources = await fetch(`${server.baseUrl}/api/sources`).then((response) => response.json());
|
||||
const remainingSources = await client.json('/api/sources');
|
||||
assert.equal(remainingSources.sources.length, 1);
|
||||
assert.equal(remainingSources.sources[0].id, uploadB.payload.source.id);
|
||||
|
||||
const deletedLookup = await fetch(`${server.baseUrl}/api/sources/${uploadA.payload.source.id}`).then((response) =>
|
||||
response.json()
|
||||
);
|
||||
const deletedLookup = await client.json(`/api/sources/${uploadA.payload.source.id}`);
|
||||
assert.equal(deletedLookup.error.code, 'SOURCE_NOT_FOUND');
|
||||
|
||||
await assert.rejects(() => fs.promises.stat(deleteSourceDir));
|
||||
const keptStat = await fs.promises.stat(keepSourceDir);
|
||||
assert.equal(keptStat.isDirectory(), true);
|
||||
|
||||
const serverDbSummary = await fetch(`${server.baseUrl}/api/server-db`).then((response) => response.json());
|
||||
const serverDbSummary = await client.json('/api/server-db');
|
||||
assert.equal(serverDbSummary.serverDb.available, true);
|
||||
assert.equal(serverDbSummary.serverDb.backupCount, 2);
|
||||
} finally {
|
||||
@@ -297,21 +345,22 @@ test('server db upload maps task names onto existing sources and replacement rem
|
||||
});
|
||||
|
||||
try {
|
||||
const invalidServerDbUpload = await uploadDatabase(server.baseUrl, '/api/sources/upload', firstServerDb);
|
||||
const client = await setupAuthenticatedClient(server.baseUrl);
|
||||
const invalidServerDbUpload = await uploadDatabase(server.baseUrl, '/api/sources/upload', firstServerDb, client);
|
||||
assert.equal(invalidServerDbUpload.response.status, 422);
|
||||
assert.equal(invalidServerDbUpload.payload.error.code, 'INVALID_SOURCE_SCHEMA');
|
||||
|
||||
const uploadA = await uploadFixture(server.baseUrl, sourceA);
|
||||
const uploadB = await uploadFixture(server.baseUrl, sourceB);
|
||||
const uploadA = await uploadFixture(server.baseUrl, sourceA, client);
|
||||
const uploadB = await uploadFixture(server.baseUrl, sourceB, client);
|
||||
assert.equal(uploadA.payload.source.displayName, 'TMQRJYNADS.sqlite');
|
||||
assert.equal(uploadA.payload.source.displayNameSource, 'filename');
|
||||
|
||||
const firstServerDbUpload = await uploadServerDbFixture(server.baseUrl, firstServerDb);
|
||||
const firstServerDbUpload = await uploadServerDbFixture(server.baseUrl, firstServerDb, client);
|
||||
assert.equal(firstServerDbUpload.response.status, 201);
|
||||
assert.equal(firstServerDbUpload.payload.serverDb.available, true);
|
||||
assert.equal(firstServerDbUpload.payload.serverDb.backupCount, 2);
|
||||
|
||||
const sourcesAfterMapping = await fetch(`${server.baseUrl}/api/sources`).then((response) => response.json());
|
||||
const sourcesAfterMapping = await client.json('/api/sources');
|
||||
const mappedA = sourcesAfterMapping.sources.find((source) => source.id === uploadA.payload.source.id);
|
||||
const mappedB = sourcesAfterMapping.sources.find((source) => source.id === uploadB.payload.source.id);
|
||||
assert.equal(mappedA.displayName, '四川农场主');
|
||||
@@ -319,14 +368,14 @@ test('server db upload maps task names onto existing sources and replacement rem
|
||||
assert.equal(mappedA.matchedBackupName, '四川农场主');
|
||||
assert.equal(mappedB.displayName, '小白');
|
||||
|
||||
const serverDbSummary = await fetch(`${server.baseUrl}/api/server-db`).then((response) => response.json());
|
||||
const serverDbSummary = await client.json('/api/server-db');
|
||||
assert.equal(serverDbSummary.serverDb.available, true);
|
||||
assert.equal(serverDbSummary.serverDb.backupCount, 2);
|
||||
|
||||
const replacementUpload = await uploadServerDbFixture(server.baseUrl, replacementServerDb);
|
||||
const replacementUpload = await uploadServerDbFixture(server.baseUrl, replacementServerDb, client);
|
||||
assert.equal(replacementUpload.response.status, 201);
|
||||
|
||||
const sourcesAfterReplacement = await fetch(`${server.baseUrl}/api/sources`).then((response) => response.json());
|
||||
const sourcesAfterReplacement = await client.json('/api/sources');
|
||||
const remappedA = sourcesAfterReplacement.sources.find((source) => source.id === uploadA.payload.source.id);
|
||||
const remappedB = sourcesAfterReplacement.sources.find((source) => source.id === uploadB.payload.source.id);
|
||||
assert.equal(remappedA.displayName, '四川农场主-新');
|
||||
@@ -366,10 +415,11 @@ test('global WebDAV defaults plus server-db target URL allow enhancement without
|
||||
});
|
||||
|
||||
try {
|
||||
const upload = await uploadFixture(server.baseUrl, fixture);
|
||||
const client = await setupAuthenticatedClient(server.baseUrl);
|
||||
const upload = await uploadFixture(server.baseUrl, fixture, client);
|
||||
const sourceId = upload.payload.source.id;
|
||||
|
||||
const defaultsResponse = await fetch(`${server.baseUrl}/api/webdav-defaults`, {
|
||||
const defaultsResponse = await client.fetch('/api/webdav-defaults', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -386,26 +436,22 @@ test('global WebDAV defaults plus server-db target URL allow enhancement without
|
||||
assert.equal(defaultsPayload.defaults.configured, true);
|
||||
assert.equal(defaultsPayload.defaults.hasPassphrase, true);
|
||||
|
||||
const serverDbUpload = await uploadServerDbFixture(server.baseUrl, serverDb);
|
||||
const serverDbUpload = await uploadServerDbFixture(server.baseUrl, serverDb, client);
|
||||
assert.equal(serverDbUpload.response.status, 201);
|
||||
|
||||
const sourceDetail = await fetch(`${server.baseUrl}/api/sources/${sourceId}`).then((response) =>
|
||||
response.json()
|
||||
);
|
||||
const sourceDetail = await client.json(`/api/sources/${sourceId}`);
|
||||
assert.equal(sourceDetail.source.webdav.derivedWebdavBaseUrl, remote.baseUrl);
|
||||
assert.equal(sourceDetail.source.webdav.effectiveWebdavBaseUrl, remote.baseUrl);
|
||||
assert.equal(sourceDetail.source.webdav.effectiveWebdavBaseUrlSource, 'server-db');
|
||||
|
||||
const browserSecrets = await fetch(`${server.baseUrl}/api/sources/${sourceId}/browser-secrets`).then((response) =>
|
||||
response.json()
|
||||
);
|
||||
const browserSecrets = await client.json(`/api/sources/${sourceId}/browser-secrets`);
|
||||
assert.equal(browserSecrets.secrets.webdavBaseUrl, remote.baseUrl);
|
||||
assert.equal(browserSecrets.secrets.username, 'demo');
|
||||
assert.equal(browserSecrets.secrets.password, 'secret');
|
||||
assert.equal(browserSecrets.secrets.passphrase, fixture.passphrase);
|
||||
assert.equal(browserSecrets.secrets.authMode, 'basic');
|
||||
|
||||
const enhanceResponse = await fetch(`${server.baseUrl}/api/sources/${sourceId}/enhance`, {
|
||||
const enhanceResponse = await client.fetch(`/api/sources/${sourceId}/enhance`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -414,7 +460,7 @@ test('global WebDAV defaults plus server-db target URL allow enhancement without
|
||||
});
|
||||
assert.equal(enhanceResponse.status, 202);
|
||||
|
||||
const enhancement = await waitForEnhancementStatus(server.baseUrl, sourceId, ['ready', 'failed']);
|
||||
const enhancement = await waitForEnhancementStatus(server.baseUrl, sourceId, ['ready', 'failed'], client);
|
||||
assert.equal(enhancement.status, 'ready');
|
||||
} finally {
|
||||
await remote.close();
|
||||
@@ -434,22 +480,21 @@ test('raw source keeps browsing available, gates file-info, and does not leak sa
|
||||
});
|
||||
|
||||
try {
|
||||
const upload = await uploadFixture(server.baseUrl, rawFixture);
|
||||
const client = await setupAuthenticatedClient(server.baseUrl);
|
||||
const upload = await uploadFixture(server.baseUrl, rawFixture, client);
|
||||
assert.equal(upload.response.status, 201);
|
||||
|
||||
const sourceId = upload.payload.source.id;
|
||||
const listing = await fetch(
|
||||
`${server.baseUrl}/api/ls?sourceId=${sourceId}&path=/source/media/movies`
|
||||
).then((response) => response.json());
|
||||
const listing = await client.json(`/api/ls?sourceId=${sourceId}&path=/source/media/movies`);
|
||||
const fileId = listing.entries.find((entry) => entry.type === 'file')?.id;
|
||||
assert.ok(fileId);
|
||||
|
||||
const fileInfoBeforeEnhance = await fetch(
|
||||
`${server.baseUrl}/api/file-info?sourceId=${sourceId}&id=${encodeURIComponent(fileId)}`
|
||||
).then((response) => response.json());
|
||||
const fileInfoBeforeEnhance = await client.json(
|
||||
`/api/file-info?sourceId=${sourceId}&id=${encodeURIComponent(fileId)}`
|
||||
);
|
||||
assert.equal(fileInfoBeforeEnhance.error.code, 'ENHANCEMENT_NOT_READY');
|
||||
|
||||
const saveSecretsResponse = await fetch(`${server.baseUrl}/api/sources/${sourceId}/secrets`, {
|
||||
const saveSecretsResponse = await client.fetch(`/api/sources/${sourceId}/secrets`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -468,9 +513,7 @@ test('raw source keeps browsing available, gates file-info, and does not leak sa
|
||||
assert.equal(saveSecretsPayload.source.password, undefined);
|
||||
assert.equal(saveSecretsPayload.source.passphrase, undefined);
|
||||
|
||||
const sourceDetail = await fetch(`${server.baseUrl}/api/sources/${sourceId}`).then((response) =>
|
||||
response.json()
|
||||
);
|
||||
const sourceDetail = await client.json(`/api/sources/${sourceId}`);
|
||||
assert.equal(sourceDetail.source.credentialsSaved, true);
|
||||
assert.equal(sourceDetail.source.username, undefined);
|
||||
assert.equal(sourceDetail.source.password, undefined);
|
||||
@@ -497,16 +540,15 @@ test('enhancement job builds archive indexes from Basic-auth WebDAV volumes and
|
||||
});
|
||||
|
||||
try {
|
||||
const upload = await uploadFixture(server.baseUrl, fixture);
|
||||
const client = await setupAuthenticatedClient(server.baseUrl);
|
||||
const upload = await uploadFixture(server.baseUrl, fixture, client);
|
||||
const sourceId = upload.payload.source.id;
|
||||
|
||||
const listing = await fetch(
|
||||
`${server.baseUrl}/api/ls?sourceId=${sourceId}&path=/source/media/movies`
|
||||
).then((response) => response.json());
|
||||
const listing = await client.json(`/api/ls?sourceId=${sourceId}&path=/source/media/movies`);
|
||||
const fileId = listing.entries.find((entry) => entry.type === 'file')?.id;
|
||||
assert.ok(fileId);
|
||||
|
||||
const enhanceResponse = await fetch(`${server.baseUrl}/api/sources/${sourceId}/enhance`, {
|
||||
const enhanceResponse = await client.fetch(`/api/sources/${sourceId}/enhance`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -521,20 +563,18 @@ test('enhancement job builds archive indexes from Basic-auth WebDAV volumes and
|
||||
});
|
||||
assert.equal(enhanceResponse.status, 202);
|
||||
|
||||
const enhancement = await waitForEnhancementStatus(server.baseUrl, sourceId, ['ready', 'failed']);
|
||||
const enhancement = await waitForEnhancementStatus(server.baseUrl, sourceId, ['ready', 'failed'], client);
|
||||
assert.equal(enhancement.status, 'ready');
|
||||
assert.equal(enhancement.processedVolumes, 2);
|
||||
assert.equal(enhancement.totalVolumes, 2);
|
||||
|
||||
const sourceDetail = await fetch(`${server.baseUrl}/api/sources/${sourceId}`).then((response) =>
|
||||
response.json()
|
||||
);
|
||||
const sourceDetail = await client.json(`/api/sources/${sourceId}`);
|
||||
assert.equal(sourceDetail.source.capabilities.archiveEntryIndex, true);
|
||||
assert.equal(sourceDetail.source.capabilities.volumeCryptoCache, true);
|
||||
|
||||
const fileInfo = await fetch(
|
||||
`${server.baseUrl}/api/file-info?sourceId=${sourceId}&id=${encodeURIComponent(fileId)}`
|
||||
).then((response) => response.json());
|
||||
const fileInfo = await client.json(
|
||||
`/api/file-info?sourceId=${sourceId}&id=${encodeURIComponent(fileId)}`
|
||||
);
|
||||
assert.equal(fileInfo.file.path, '/source/media/movies/demo.mp4');
|
||||
assert.equal(fileInfo.restorePlan.segmentCount, 3);
|
||||
assert.equal(fileInfo.requiredDblocks[0], 'duplicati-demo.dblock.zip.aes');
|
||||
@@ -562,10 +602,11 @@ test('video thumbnails can be uploaded, listed, fetched, deleted, and cleared pe
|
||||
});
|
||||
|
||||
try {
|
||||
const upload = await uploadFixture(server.baseUrl, fixture);
|
||||
const client = await setupAuthenticatedClient(server.baseUrl);
|
||||
const upload = await uploadFixture(server.baseUrl, fixture, client);
|
||||
const sourceId = upload.payload.source.id;
|
||||
|
||||
const enhanceResponse = await fetch(`${server.baseUrl}/api/sources/${sourceId}/enhance`, {
|
||||
const enhanceResponse = await client.fetch(`/api/sources/${sourceId}/enhance`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -580,19 +621,17 @@ test('video thumbnails can be uploaded, listed, fetched, deleted, and cleared pe
|
||||
});
|
||||
assert.equal(enhanceResponse.status, 202);
|
||||
|
||||
const enhancement = await waitForEnhancementStatus(server.baseUrl, sourceId, ['ready', 'failed']);
|
||||
const enhancement = await waitForEnhancementStatus(server.baseUrl, sourceId, ['ready', 'failed'], client);
|
||||
assert.equal(enhancement.status, 'ready');
|
||||
|
||||
const listingBefore = await fetch(
|
||||
`${server.baseUrl}/api/ls?sourceId=${sourceId}&path=/source/media/movies`
|
||||
).then((response) => response.json());
|
||||
const listingBefore = await client.json(`/api/ls?sourceId=${sourceId}&path=/source/media/movies`);
|
||||
const fileEntry = listingBefore.entries.find((entry) => entry.type === 'file');
|
||||
assert.ok(fileEntry);
|
||||
assert.equal(fileEntry.thumbnail.available, false);
|
||||
|
||||
const fileInfoBefore = await fetch(
|
||||
`${server.baseUrl}/api/file-info?sourceId=${sourceId}&id=${encodeURIComponent(fileEntry.id)}`
|
||||
).then((response) => response.json());
|
||||
const fileInfoBefore = await client.json(
|
||||
`/api/file-info?sourceId=${sourceId}&id=${encodeURIComponent(fileEntry.id)}`
|
||||
);
|
||||
assert.equal(fileInfoBefore.file.thumbnail.available, false);
|
||||
|
||||
const thumbnailForm = new FormData();
|
||||
@@ -604,7 +643,7 @@ test('video thumbnails can be uploaded, listed, fetched, deleted, and cleared pe
|
||||
})
|
||||
);
|
||||
|
||||
const thumbnailUploadResponse = await fetch(`${server.baseUrl}/api/sources/${sourceId}/thumbnails`, {
|
||||
const thumbnailUploadResponse = await client.fetch(`/api/sources/${sourceId}/thumbnails`, {
|
||||
method: 'POST',
|
||||
body: thumbnailForm
|
||||
});
|
||||
@@ -613,30 +652,26 @@ test('video thumbnails can be uploaded, listed, fetched, deleted, and cleared pe
|
||||
assert.equal(thumbnailUploadPayload.thumbnail.available, true);
|
||||
assert.ok(thumbnailUploadPayload.thumbnail.thumbnailId);
|
||||
|
||||
const thumbnailFetchResponse = await fetch(`${server.baseUrl}${thumbnailUploadPayload.thumbnail.thumbnailUrl}`);
|
||||
const thumbnailFetchResponse = await client.fetch(thumbnailUploadPayload.thumbnail.thumbnailUrl);
|
||||
assert.equal(thumbnailFetchResponse.status, 200);
|
||||
assert.equal(thumbnailFetchResponse.headers.get('content-type'), 'image/webp');
|
||||
assert.deepEqual(new Uint8Array(await thumbnailFetchResponse.arrayBuffer()), Uint8Array.from([0x52, 0x49, 0x46, 0x46, 0x10, 0x00, 0x00, 0x00]));
|
||||
|
||||
const listingAfterUpload = await fetch(
|
||||
`${server.baseUrl}/api/ls?sourceId=${sourceId}&path=/source/media/movies`
|
||||
).then((response) => response.json());
|
||||
const listingAfterUpload = await client.json(`/api/ls?sourceId=${sourceId}&path=/source/media/movies`);
|
||||
const listedFileAfterUpload = listingAfterUpload.entries.find((entry) => entry.id === fileEntry.id);
|
||||
assert.equal(listedFileAfterUpload.thumbnail.available, true);
|
||||
assert.equal(listedFileAfterUpload.thumbnail.thumbnailId, thumbnailUploadPayload.thumbnail.thumbnailId);
|
||||
|
||||
const fileInfoAfterUpload = await fetch(
|
||||
`${server.baseUrl}/api/file-info?sourceId=${sourceId}&id=${encodeURIComponent(fileEntry.id)}`
|
||||
).then((response) => response.json());
|
||||
const fileInfoAfterUpload = await client.json(
|
||||
`/api/file-info?sourceId=${sourceId}&id=${encodeURIComponent(fileEntry.id)}`
|
||||
);
|
||||
assert.equal(fileInfoAfterUpload.file.thumbnail.available, true);
|
||||
|
||||
const sourceDetailAfterUpload = await fetch(`${server.baseUrl}/api/sources/${sourceId}`).then((response) =>
|
||||
response.json()
|
||||
);
|
||||
const sourceDetailAfterUpload = await client.json(`/api/sources/${sourceId}`);
|
||||
assert.equal(sourceDetailAfterUpload.source.thumbnailCache.count, 1);
|
||||
|
||||
const singleDeleteResponse = await fetch(
|
||||
`${server.baseUrl}/api/sources/${sourceId}/thumbnails/${encodeURIComponent(thumbnailUploadPayload.thumbnail.thumbnailId)}`,
|
||||
const singleDeleteResponse = await client.fetch(
|
||||
`/api/sources/${sourceId}/thumbnails/${encodeURIComponent(thumbnailUploadPayload.thumbnail.thumbnailId)}`,
|
||||
{
|
||||
method: 'DELETE'
|
||||
}
|
||||
@@ -645,9 +680,7 @@ test('video thumbnails can be uploaded, listed, fetched, deleted, and cleared pe
|
||||
assert.equal(singleDeleteResponse.status, 200);
|
||||
assert.equal(singleDeletePayload.deleted.fileId, fileEntry.id);
|
||||
|
||||
const listingAfterSingleDelete = await fetch(
|
||||
`${server.baseUrl}/api/ls?sourceId=${sourceId}&path=/source/media/movies`
|
||||
).then((response) => response.json());
|
||||
const listingAfterSingleDelete = await client.json(`/api/ls?sourceId=${sourceId}&path=/source/media/movies`);
|
||||
const listedFileAfterDelete = listingAfterSingleDelete.entries.find((entry) => entry.id === fileEntry.id);
|
||||
assert.equal(listedFileAfterDelete.thumbnail.available, false);
|
||||
|
||||
@@ -659,22 +692,20 @@ test('video thumbnails can be uploaded, listed, fetched, deleted, and cleared pe
|
||||
type: 'image/webp'
|
||||
})
|
||||
);
|
||||
const secondUploadResponse = await fetch(`${server.baseUrl}/api/sources/${sourceId}/thumbnails`, {
|
||||
const secondUploadResponse = await client.fetch(`/api/sources/${sourceId}/thumbnails`, {
|
||||
method: 'POST',
|
||||
body: secondThumbnailForm
|
||||
});
|
||||
assert.equal(secondUploadResponse.status, 201);
|
||||
|
||||
const clearResponse = await fetch(`${server.baseUrl}/api/sources/${sourceId}/thumbnails`, {
|
||||
const clearResponse = await client.fetch(`/api/sources/${sourceId}/thumbnails`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const clearPayload = await clearResponse.json();
|
||||
assert.equal(clearResponse.status, 200);
|
||||
assert.equal(clearPayload.cleared.removedCount, 1);
|
||||
|
||||
const sourceDetailAfterClear = await fetch(`${server.baseUrl}/api/sources/${sourceId}`).then((response) =>
|
||||
response.json()
|
||||
);
|
||||
const sourceDetailAfterClear = await client.json(`/api/sources/${sourceId}`);
|
||||
assert.equal(sourceDetailAfterClear.source.thumbnailCache.count, 0);
|
||||
} finally {
|
||||
await remote.close();
|
||||
@@ -699,16 +730,15 @@ test('failed enhancement keeps directory browsing available and surfaces ENHANCE
|
||||
});
|
||||
|
||||
try {
|
||||
const upload = await uploadFixture(server.baseUrl, fixture);
|
||||
const client = await setupAuthenticatedClient(server.baseUrl);
|
||||
const upload = await uploadFixture(server.baseUrl, fixture, client);
|
||||
const sourceId = upload.payload.source.id;
|
||||
|
||||
const listingBefore = await fetch(
|
||||
`${server.baseUrl}/api/ls?sourceId=${sourceId}&path=/source/media/movies`
|
||||
).then((response) => response.json());
|
||||
const listingBefore = await client.json(`/api/ls?sourceId=${sourceId}&path=/source/media/movies`);
|
||||
const fileId = listingBefore.entries.find((entry) => entry.type === 'file')?.id;
|
||||
assert.ok(fileId);
|
||||
|
||||
const enhanceResponse = await fetch(`${server.baseUrl}/api/sources/${sourceId}/enhance`, {
|
||||
const enhanceResponse = await client.fetch(`/api/sources/${sourceId}/enhance`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -723,17 +753,13 @@ test('failed enhancement keeps directory browsing available and surfaces ENHANCE
|
||||
});
|
||||
assert.equal(enhanceResponse.status, 202);
|
||||
|
||||
const enhancement = await waitForEnhancementStatus(server.baseUrl, sourceId, ['ready', 'failed']);
|
||||
const enhancement = await waitForEnhancementStatus(server.baseUrl, sourceId, ['ready', 'failed'], client);
|
||||
assert.equal(enhancement.status, 'failed');
|
||||
|
||||
const listingAfter = await fetch(
|
||||
`${server.baseUrl}/api/ls?sourceId=${sourceId}&path=/source/media/movies`
|
||||
).then((response) => response.json());
|
||||
const listingAfter = await client.json(`/api/ls?sourceId=${sourceId}&path=/source/media/movies`);
|
||||
assert.equal(listingAfter.entries.length, listingBefore.entries.length);
|
||||
|
||||
const fileInfo = await fetch(
|
||||
`${server.baseUrl}/api/file-info?sourceId=${sourceId}&id=${encodeURIComponent(fileId)}`
|
||||
).then((response) => response.json());
|
||||
const fileInfo = await client.json(`/api/file-info?sourceId=${sourceId}&id=${encodeURIComponent(fileId)}`);
|
||||
assert.equal(fileInfo.error.code, 'ENHANCEMENT_FAILED');
|
||||
} finally {
|
||||
await remote.close();
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user