770 lines
28 KiB
JavaScript
770 lines
28 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import { createServer } from 'node:http';
|
|
import { rm } from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
import { createServerApp } from '../src/server.js';
|
|
import {
|
|
createDuplicatiFixture,
|
|
createDuplicatiServerDbFixture
|
|
} from './support/duplicatiFixture.js';
|
|
|
|
async function startTestServer() {
|
|
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'duplicati-server-'));
|
|
const appDbPath = path.join(tempDirectory, 'app.sqlite');
|
|
const uploadDir = path.join(tempDirectory, 'sources');
|
|
const runtime = await createServerApp({
|
|
appDbPath,
|
|
uploadDir,
|
|
previewMaxBytes: 1024
|
|
});
|
|
|
|
const server = await new Promise((resolve) => {
|
|
const instance = runtime.app.listen(0, () => resolve(instance));
|
|
});
|
|
const address = server.address();
|
|
const baseUrl = `http://127.0.0.1:${address.port}`;
|
|
|
|
return {
|
|
baseUrl,
|
|
appDbPath,
|
|
uploadDir,
|
|
runtime,
|
|
server,
|
|
async close() {
|
|
await new Promise((resolve, reject) => {
|
|
server.close((error) => {
|
|
if (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
|
|
resolve();
|
|
});
|
|
});
|
|
await runtime.close();
|
|
await rm(tempDirectory, { recursive: true, force: true });
|
|
}
|
|
};
|
|
}
|
|
|
|
async function uploadFixture(baseUrl, fixture, client = null) {
|
|
return uploadDatabase(baseUrl, '/api/sources/upload', fixture, client);
|
|
}
|
|
|
|
async function uploadServerDbFixture(baseUrl, fixture, client = null) {
|
|
return uploadDatabase(baseUrl, '/api/server-db/upload', fixture, client);
|
|
}
|
|
|
|
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 = 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')}`;
|
|
}
|
|
|
|
async function startRemoteVolumeServer({ remoteDir, username, password }) {
|
|
const expectedAuth = createBasicHeader(username, password);
|
|
const server = createServer(async (request, response) => {
|
|
if ((request.headers.authorization ?? '') !== expectedAuth) {
|
|
response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="test"' });
|
|
response.end('unauthorized');
|
|
return;
|
|
}
|
|
|
|
const filename = decodeURIComponent(new URL(request.url, 'http://127.0.0.1').pathname.slice(1));
|
|
const targetPath = path.join(remoteDir, filename);
|
|
|
|
try {
|
|
const stat = await fs.promises.stat(targetPath);
|
|
response.writeHead(200, {
|
|
'Content-Length': stat.size,
|
|
'Content-Type': 'application/octet-stream'
|
|
});
|
|
fs.createReadStream(targetPath).pipe(response);
|
|
} catch (error) {
|
|
response.writeHead(404);
|
|
response.end('missing');
|
|
}
|
|
});
|
|
|
|
await new Promise((resolve) => server.listen(0, resolve));
|
|
const address = server.address();
|
|
const baseUrl = `http://127.0.0.1:${address.port}/`;
|
|
|
|
return {
|
|
baseUrl,
|
|
server,
|
|
async close() {
|
|
await new Promise((resolve, reject) => {
|
|
server.close((error) => {
|
|
if (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
async function waitForEnhancementStatus(baseUrl, sourceId, allowedStatuses, client = null) {
|
|
for (let attempt = 0; attempt < 120; attempt += 1) {
|
|
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;
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
|
|
throw new Error(`Timed out waiting for enhancement status ${allowedStatuses.join(', ')}`);
|
|
}
|
|
|
|
test('multi-source uploads coexist, duplicate uploads reuse the existing source, and sourceId becomes required', async () => {
|
|
const server = await startTestServer();
|
|
const firstFixture = createDuplicatiFixture({
|
|
filename: 'first.sqlite',
|
|
layout: 'file_lookup',
|
|
includeArchiveEntryIndex: false,
|
|
includeVolumeCryptoCache: false
|
|
});
|
|
const secondFixture = createDuplicatiFixture({
|
|
filename: 'second.sqlite',
|
|
layout: 'file_lookup',
|
|
includeArchiveEntryIndex: false,
|
|
includeVolumeCryptoCache: false,
|
|
demoFileName: 'demo-v2.mp4',
|
|
demoFileHash: 'filehash-demo-v2=='
|
|
});
|
|
|
|
try {
|
|
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, client);
|
|
assert.equal(firstUpload.response.status, 201);
|
|
assert.equal(firstUpload.payload.reused, false);
|
|
|
|
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, client);
|
|
assert.equal(secondUpload.response.status, 201);
|
|
assert.notEqual(secondUpload.payload.source.id, firstUpload.payload.source.id);
|
|
|
|
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 client.json('/api/ls?path=/source/media/movies');
|
|
assert.equal(missingSourceId.error.code, 'SOURCE_ID_REQUIRED');
|
|
|
|
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 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 });
|
|
const sourceDirectories = filesInSourceRoot.filter(
|
|
(entry) => entry.isDirectory() && entry.name !== '_staging' && entry.name !== '_server'
|
|
);
|
|
assert.equal(sourceDirectories.length, 2);
|
|
} finally {
|
|
firstFixture.cleanup();
|
|
secondFixture.cleanup();
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
test('deleting a single source removes only that source and keeps the server database intact', async () => {
|
|
const server = await startTestServer();
|
|
const sourceA = createDuplicatiFixture({
|
|
filename: 'delete-a.sqlite',
|
|
layout: 'file_lookup',
|
|
includeArchiveEntryIndex: false,
|
|
includeVolumeCryptoCache: false
|
|
});
|
|
const sourceB = createDuplicatiFixture({
|
|
filename: 'delete-b.sqlite',
|
|
layout: 'file_lookup',
|
|
includeArchiveEntryIndex: false,
|
|
includeVolumeCryptoCache: false,
|
|
demoFileName: 'delete-b.mp4',
|
|
demoFileHash: 'delete-b-hash=='
|
|
});
|
|
const serverDb = createDuplicatiServerDbFixture({
|
|
backups: [
|
|
{ id: 21, name: 'Delete A', dbPath: '/data/Duplicati/delete-a.sqlite' },
|
|
{ id: 22, name: 'Delete B', dbPath: '/data/Duplicati/delete-b.sqlite' }
|
|
]
|
|
});
|
|
|
|
try {
|
|
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, client);
|
|
assert.equal(serverDbUpload.response.status, 201);
|
|
|
|
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 client.json('/api/sources');
|
|
assert.equal(remainingSources.sources.length, 1);
|
|
assert.equal(remainingSources.sources[0].id, uploadB.payload.source.id);
|
|
|
|
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 client.json('/api/server-db');
|
|
assert.equal(serverDbSummary.serverDb.available, true);
|
|
assert.equal(serverDbSummary.serverDb.backupCount, 2);
|
|
} finally {
|
|
sourceA.cleanup();
|
|
sourceB.cleanup();
|
|
serverDb.cleanup();
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
test('server db upload maps task names onto existing sources and replacement remaps them', async () => {
|
|
const server = await startTestServer();
|
|
const sourceA = createDuplicatiFixture({
|
|
filename: 'TMQRJYNADS.sqlite',
|
|
layout: 'file_lookup',
|
|
includeArchiveEntryIndex: false,
|
|
includeVolumeCryptoCache: false
|
|
});
|
|
const sourceB = createDuplicatiFixture({
|
|
filename: 'ENVPLWAIWJ.sqlite',
|
|
layout: 'file_lookup',
|
|
includeArchiveEntryIndex: false,
|
|
includeVolumeCryptoCache: false,
|
|
demoFileName: 'env-demo.mp4',
|
|
demoFileHash: 'env-demo-hash=='
|
|
});
|
|
const firstServerDb = createDuplicatiServerDbFixture({
|
|
backups: [
|
|
{ id: 2, name: '四川农场主', dbPath: '/data/Duplicati/TMQRJYNADS.sqlite' },
|
|
{ id: 3, name: '小白', dbPath: '/data/Duplicati/ENVPLWAIWJ.sqlite' }
|
|
]
|
|
});
|
|
const replacementServerDb = createDuplicatiServerDbFixture({
|
|
backups: [
|
|
{ id: 2, name: '四川农场主-新', dbPath: '/data/Duplicati/TMQRJYNADS.sqlite' },
|
|
{ id: 3, name: '小白-新', dbPath: '/data/Duplicati/ENVPLWAIWJ.sqlite' }
|
|
]
|
|
});
|
|
|
|
try {
|
|
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, 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, client);
|
|
assert.equal(firstServerDbUpload.response.status, 201);
|
|
assert.equal(firstServerDbUpload.payload.serverDb.available, true);
|
|
assert.equal(firstServerDbUpload.payload.serverDb.backupCount, 2);
|
|
|
|
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, '四川农场主');
|
|
assert.equal(mappedA.displayNameSource, 'server-db');
|
|
assert.equal(mappedA.matchedBackupName, '四川农场主');
|
|
assert.equal(mappedB.displayName, '小白');
|
|
|
|
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, client);
|
|
assert.equal(replacementUpload.response.status, 201);
|
|
|
|
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, '四川农场主-新');
|
|
assert.equal(remappedB.displayName, '小白-新');
|
|
} finally {
|
|
sourceA.cleanup();
|
|
sourceB.cleanup();
|
|
firstServerDb.cleanup();
|
|
replacementServerDb.cleanup();
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
test('global WebDAV defaults plus server-db target URL allow enhancement without per-source credential re-entry', async () => {
|
|
const server = await startTestServer();
|
|
const fixture = createDuplicatiFixture({
|
|
filename: 'global-defaults.sqlite',
|
|
layout: 'file_lookup',
|
|
includeArchiveEntryIndex: false,
|
|
includeVolumeCryptoCache: false,
|
|
withRemoteVolumes: true
|
|
});
|
|
const remote = await startRemoteVolumeServer({
|
|
remoteDir: fixture.remoteDir,
|
|
username: 'demo',
|
|
password: 'secret'
|
|
});
|
|
const serverDb = createDuplicatiServerDbFixture({
|
|
backups: [
|
|
{
|
|
id: 7,
|
|
name: 'Global Defaults Demo',
|
|
dbPath: '/data/Duplicati/global-defaults.sqlite',
|
|
targetUrl: remote.baseUrl.replace('http://', 'webdav://')
|
|
}
|
|
]
|
|
});
|
|
|
|
try {
|
|
const client = await setupAuthenticatedClient(server.baseUrl);
|
|
const upload = await uploadFixture(server.baseUrl, fixture, client);
|
|
const sourceId = upload.payload.source.id;
|
|
|
|
const defaultsResponse = await client.fetch('/api/webdav-defaults', {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
authMode: 'basic',
|
|
username: 'demo',
|
|
password: 'secret',
|
|
passphrase: fixture.passphrase
|
|
})
|
|
});
|
|
const defaultsPayload = await defaultsResponse.json();
|
|
assert.equal(defaultsResponse.status, 200);
|
|
assert.equal(defaultsPayload.defaults.configured, true);
|
|
assert.equal(defaultsPayload.defaults.hasPassphrase, true);
|
|
|
|
const serverDbUpload = await uploadServerDbFixture(server.baseUrl, serverDb, client);
|
|
assert.equal(serverDbUpload.response.status, 201);
|
|
|
|
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 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 client.fetch(`/api/sources/${sourceId}/enhance`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({})
|
|
});
|
|
assert.equal(enhanceResponse.status, 202);
|
|
|
|
const enhancement = await waitForEnhancementStatus(server.baseUrl, sourceId, ['ready', 'failed'], client);
|
|
assert.equal(enhancement.status, 'ready');
|
|
} finally {
|
|
await remote.close();
|
|
fixture.cleanup();
|
|
serverDb.cleanup();
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
test('raw source keeps browsing available, gates file-info, and does not leak saved secrets', async () => {
|
|
const server = await startTestServer();
|
|
const rawFixture = createDuplicatiFixture({
|
|
filename: 'raw-job.sqlite',
|
|
layout: 'file_lookup',
|
|
includeArchiveEntryIndex: false,
|
|
includeVolumeCryptoCache: false
|
|
});
|
|
|
|
try {
|
|
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 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 client.json(
|
|
`/api/file-info?sourceId=${sourceId}&id=${encodeURIComponent(fileId)}`
|
|
);
|
|
assert.equal(fileInfoBeforeEnhance.error.code, 'ENHANCEMENT_NOT_READY');
|
|
|
|
const saveSecretsResponse = await client.fetch(`/api/sources/${sourceId}/secrets`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
webdavBaseUrl: 'http://127.0.0.1:9999/remote/',
|
|
username: 'demo',
|
|
password: 'secret',
|
|
passphrase: 'passphrase',
|
|
authMode: 'basic'
|
|
})
|
|
});
|
|
const saveSecretsPayload = await saveSecretsResponse.json();
|
|
assert.equal(saveSecretsResponse.status, 200);
|
|
assert.equal(saveSecretsPayload.source.credentialsSaved, true);
|
|
assert.equal(saveSecretsPayload.source.password, undefined);
|
|
assert.equal(saveSecretsPayload.source.passphrase, undefined);
|
|
|
|
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);
|
|
} finally {
|
|
rawFixture.cleanup();
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
test('enhancement job builds archive indexes from Basic-auth WebDAV volumes and old file ids stay valid', async () => {
|
|
const server = await startTestServer();
|
|
const fixture = createDuplicatiFixture({
|
|
filename: 'enhance.sqlite',
|
|
layout: 'file_lookup',
|
|
includeArchiveEntryIndex: false,
|
|
includeVolumeCryptoCache: false,
|
|
withRemoteVolumes: true,
|
|
includeAesExtensions: true
|
|
});
|
|
const remote = await startRemoteVolumeServer({
|
|
remoteDir: fixture.remoteDir,
|
|
username: 'demo',
|
|
password: 'secret'
|
|
});
|
|
|
|
try {
|
|
const client = await setupAuthenticatedClient(server.baseUrl);
|
|
const upload = await uploadFixture(server.baseUrl, fixture, client);
|
|
const sourceId = upload.payload.source.id;
|
|
|
|
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 client.fetch(`/api/sources/${sourceId}/enhance`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
webdavBaseUrl: remote.baseUrl,
|
|
username: 'demo',
|
|
password: 'secret',
|
|
passphrase: fixture.passphrase,
|
|
authMode: 'basic'
|
|
})
|
|
});
|
|
assert.equal(enhanceResponse.status, 202);
|
|
|
|
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 client.json(`/api/sources/${sourceId}`);
|
|
assert.equal(sourceDetail.source.capabilities.archiveEntryIndex, true);
|
|
assert.equal(sourceDetail.source.capabilities.volumeCryptoCache, true);
|
|
|
|
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');
|
|
assert.equal(fileInfo.volumes[0].encryption.streamFormat, 'v2');
|
|
} finally {
|
|
await remote.close();
|
|
fixture.cleanup();
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
test('video thumbnails can be uploaded, listed, fetched, deleted, and cleared per source', async () => {
|
|
const server = await startTestServer();
|
|
const fixture = createDuplicatiFixture({
|
|
filename: 'thumbs.sqlite',
|
|
layout: 'file_lookup',
|
|
includeArchiveEntryIndex: false,
|
|
includeVolumeCryptoCache: false,
|
|
withRemoteVolumes: true
|
|
});
|
|
const remote = await startRemoteVolumeServer({
|
|
remoteDir: fixture.remoteDir,
|
|
username: 'demo',
|
|
password: 'secret'
|
|
});
|
|
|
|
try {
|
|
const client = await setupAuthenticatedClient(server.baseUrl);
|
|
const upload = await uploadFixture(server.baseUrl, fixture, client);
|
|
const sourceId = upload.payload.source.id;
|
|
|
|
const enhanceResponse = await client.fetch(`/api/sources/${sourceId}/enhance`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
webdavBaseUrl: remote.baseUrl,
|
|
username: 'demo',
|
|
password: 'secret',
|
|
passphrase: fixture.passphrase,
|
|
authMode: 'basic'
|
|
})
|
|
});
|
|
assert.equal(enhanceResponse.status, 202);
|
|
|
|
const enhancement = await waitForEnhancementStatus(server.baseUrl, sourceId, ['ready', 'failed'], client);
|
|
assert.equal(enhancement.status, 'ready');
|
|
|
|
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 client.json(
|
|
`/api/file-info?sourceId=${sourceId}&id=${encodeURIComponent(fileEntry.id)}`
|
|
);
|
|
assert.equal(fileInfoBefore.file.thumbnail.available, false);
|
|
|
|
const thumbnailForm = new FormData();
|
|
thumbnailForm.set('fileId', fileEntry.id);
|
|
thumbnailForm.set(
|
|
'image',
|
|
new File([Uint8Array.from([0x52, 0x49, 0x46, 0x46, 0x10, 0x00, 0x00, 0x00])], 'preview.webp', {
|
|
type: 'image/webp'
|
|
})
|
|
);
|
|
|
|
const thumbnailUploadResponse = await client.fetch(`/api/sources/${sourceId}/thumbnails`, {
|
|
method: 'POST',
|
|
body: thumbnailForm
|
|
});
|
|
const thumbnailUploadPayload = await thumbnailUploadResponse.json();
|
|
assert.equal(thumbnailUploadResponse.status, 201);
|
|
assert.equal(thumbnailUploadPayload.thumbnail.available, true);
|
|
assert.ok(thumbnailUploadPayload.thumbnail.thumbnailId);
|
|
|
|
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 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 client.json(
|
|
`/api/file-info?sourceId=${sourceId}&id=${encodeURIComponent(fileEntry.id)}`
|
|
);
|
|
assert.equal(fileInfoAfterUpload.file.thumbnail.available, true);
|
|
|
|
const sourceDetailAfterUpload = await client.json(`/api/sources/${sourceId}`);
|
|
assert.equal(sourceDetailAfterUpload.source.thumbnailCache.count, 1);
|
|
|
|
const singleDeleteResponse = await client.fetch(
|
|
`/api/sources/${sourceId}/thumbnails/${encodeURIComponent(thumbnailUploadPayload.thumbnail.thumbnailId)}`,
|
|
{
|
|
method: 'DELETE'
|
|
}
|
|
);
|
|
const singleDeletePayload = await singleDeleteResponse.json();
|
|
assert.equal(singleDeleteResponse.status, 200);
|
|
assert.equal(singleDeletePayload.deleted.fileId, fileEntry.id);
|
|
|
|
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);
|
|
|
|
const secondThumbnailForm = new FormData();
|
|
secondThumbnailForm.set('fileId', fileEntry.id);
|
|
secondThumbnailForm.set(
|
|
'image',
|
|
new File([Uint8Array.from([0x57, 0x45, 0x42, 0x50])], 'preview-2.webp', {
|
|
type: 'image/webp'
|
|
})
|
|
);
|
|
const secondUploadResponse = await client.fetch(`/api/sources/${sourceId}/thumbnails`, {
|
|
method: 'POST',
|
|
body: secondThumbnailForm
|
|
});
|
|
assert.equal(secondUploadResponse.status, 201);
|
|
|
|
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 client.json(`/api/sources/${sourceId}`);
|
|
assert.equal(sourceDetailAfterClear.source.thumbnailCache.count, 0);
|
|
} finally {
|
|
await remote.close();
|
|
fixture.cleanup();
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
test('failed enhancement keeps directory browsing available and surfaces ENHANCEMENT_FAILED', async () => {
|
|
const server = await startTestServer();
|
|
const fixture = createDuplicatiFixture({
|
|
filename: 'enhance-fail.sqlite',
|
|
layout: 'file_lookup',
|
|
includeArchiveEntryIndex: false,
|
|
includeVolumeCryptoCache: false,
|
|
withRemoteVolumes: true
|
|
});
|
|
const remote = await startRemoteVolumeServer({
|
|
remoteDir: fixture.remoteDir,
|
|
username: 'demo',
|
|
password: 'secret'
|
|
});
|
|
|
|
try {
|
|
const client = await setupAuthenticatedClient(server.baseUrl);
|
|
const upload = await uploadFixture(server.baseUrl, fixture, client);
|
|
const sourceId = upload.payload.source.id;
|
|
|
|
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 client.fetch(`/api/sources/${sourceId}/enhance`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
webdavBaseUrl: remote.baseUrl,
|
|
username: 'demo',
|
|
password: 'secret',
|
|
passphrase: 'wrong-passphrase',
|
|
authMode: 'basic'
|
|
})
|
|
});
|
|
assert.equal(enhanceResponse.status, 202);
|
|
|
|
const enhancement = await waitForEnhancementStatus(server.baseUrl, sourceId, ['ready', 'failed'], client);
|
|
assert.equal(enhancement.status, 'failed');
|
|
|
|
const listingAfter = await client.json(`/api/ls?sourceId=${sourceId}&path=/source/media/movies`);
|
|
assert.equal(listingAfter.entries.length, listingBefore.entries.length);
|
|
|
|
const fileInfo = await client.json(`/api/file-info?sourceId=${sourceId}&id=${encodeURIComponent(fileId)}`);
|
|
assert.equal(fileInfo.error.code, 'ENHANCEMENT_FAILED');
|
|
} finally {
|
|
await remote.close();
|
|
fixture.cleanup();
|
|
await server.close();
|
|
}
|
|
});
|