import { describe, expect, it } from 'vitest';
import {
basicAuthHeader,
folderSegments,
parsePropfind,
webdavCheck,
webdavFileUrl,
webdavFolderUrl,
webdavList,
} from './webdav';
/** A trimmed real-world Nextcloud PROPFIND (depth 1) multistatus body. */
const NEXTCLOUD_PROPFIND = `
/remote.php/dav/files/backupuser/dorfteich-backups/
Sat, 11 Jul 2026 03:00:22 GMT
HTTP/1.1 200 OK
/remote.php/dav/files/backupuser/dorfteich-backups/dorfteich-backup-20260711-030001.tar.gz
Sat, 11 Jul 2026 03:00:22 GMT
1048576
HTTP/1.1 200 OK
/remote.php/dav/files/backupuser/dorfteich-backups/notes%20%26%20misc
HTTP/1.1 200 OK
`;
const target = {
baseUrl: 'https://cloud.example.com',
username: 'backupuser',
password: 'app-password',
folder: 'dorfteich-backups',
};
describe('webdav url derivation', () => {
it('derives the Nextcloud DAV path from the base url', () => {
expect(webdavFolderUrl(target)).toBe(
'https://cloud.example.com/remote.php/dav/files/backupuser/dorfteich-backups',
);
});
it('keeps an explicit DAV base verbatim (generic WebDAV servers)', () => {
expect(webdavFolderUrl({ ...target, baseUrl: 'https://dav.example.com/dav/home/' })).toBe(
'https://dav.example.com/dav/home/dorfteich-backups',
);
});
it('encodes folder segments and file names', () => {
expect(webdavFileUrl({ ...target, folder: 'backups/my instance' }, 'a b.tar.gz')).toBe(
'https://cloud.example.com/remote.php/dav/files/backupuser/backups/my%20instance/a%20b.tar.gz',
);
});
it('rejects traversal segments and slashes in file names', () => {
expect(() => folderSegments('../etc')).toThrow(/segments/);
expect(() => webdavFileUrl(target, 'x/y')).toThrow(/file name/);
});
});
describe('parsePropfind', () => {
it('parses a Nextcloud multistatus and skips the folder itself', () => {
const entries = parsePropfind(
NEXTCLOUD_PROPFIND,
'/remote.php/dav/files/backupuser/dorfteich-backups/',
);
expect(entries).toEqual([
{
name: 'dorfteich-backup-20260711-030001.tar.gz',
isCollection: false,
sizeBytes: 1_048_576,
lastModified: 'Sat, 11 Jul 2026 03:00:22 GMT',
},
{
name: 'notes & misc',
isCollection: true,
sizeBytes: null,
lastModified: null,
},
]);
});
it('tolerates uppercase and missing namespace prefixes', () => {
const xml = `
/dav/f/file.bin
7
`;
expect(parsePropfind(xml, '/dav/f/')).toEqual([
{ name: 'file.bin', isCollection: false, sizeBytes: 7, lastModified: null },
]);
});
});
describe('webdavCheck', () => {
it('creates missing folder segments via MKCOL', async () => {
const calls: { url: string; method: string }[] = [];
const fetchLike = async (url: string, init?: RequestInit): Promise => {
const method = init?.method ?? 'GET';
calls.push({ url, method });
if (method === 'PROPFIND' && url.endsWith('/dorfteich-backups')) {
return new Response('', { status: 404 });
}
return new Response('', { status: 207 });
};
const result = await webdavCheck(target, fetchLike);
expect(result.ok).toBe(true);
expect(calls.map((c) => c.method)).toEqual(['PROPFIND', 'PROPFIND', 'MKCOL']);
});
it('reports authentication failures readably', async () => {
const fetchLike = async (): Promise =>
new Response('', { status: 401, statusText: 'Unauthorized' });
const result = await webdavCheck(target, fetchLike);
expect(result).toEqual({
ok: false,
error: expect.stringContaining('authentication failed') as unknown as string,
});
});
it('turns thrown network errors into readable results', async () => {
const fetchLike = async (): Promise => {
throw new Error('getaddrinfo ENOTFOUND cloud.example.com');
};
const result = await webdavCheck(target, fetchLike);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain('ENOTFOUND');
});
});
describe('webdavList', () => {
it('sends PROPFIND depth 1 with basic auth and parses the body', async () => {
let seen: RequestInit | undefined;
const fetchLike = async (_url: string, init?: RequestInit): Promise => {
seen = init;
return new Response(NEXTCLOUD_PROPFIND, { status: 207 });
};
const result = await webdavList(target, fetchLike);
expect(result.ok).toBe(true);
if (result.ok) expect(result.value).toHaveLength(2);
expect((seen?.headers as Record).Depth).toBe('1');
expect((seen?.headers as Record).Authorization).toBe(basicAuthHeader(target));
});
});