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(); } });