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