164 lines
5.8 KiB
JavaScript
164 lines
5.8 KiB
JavaScript
import test from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { parsePropfindXml } from '#tofu/tools/webdav/parsePropfindXml.js'
|
|
|
|
// A real Nextcloud answer to PROPFIND Depth: 1 on /Tofu — the folder itself,
|
|
// a sub-folder and two files. Note the second propstat of each entry: Nextcloud
|
|
// reports the properties it could not produce with a 404 status.
|
|
const NEXTCLOUD_PROPFIND = `<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
|
<d:response>
|
|
<d:href>/remote.php/dav/files/tofu/Tofu/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:displayname>Tofu</d:displayname>
|
|
<d:getlastmodified>Wed, 16 Sep 2026 20:12:41 GMT</d:getlastmodified>
|
|
<d:resourcetype><d:collection/></d:resourcetype>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
<d:propstat>
|
|
<d:prop><d:getcontentlength/></d:prop>
|
|
<d:status>HTTP/1.1 404 Not Found</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>/remote.php/dav/files/tofu/Tofu/plannings/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:displayname>plannings</d:displayname>
|
|
<d:getlastmodified>Thu, 17 Sep 2026 07:03:12 GMT</d:getlastmodified>
|
|
<d:resourcetype><d:collection/></d:resourcetype>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>/remote.php/dav/files/tofu/Tofu/Gnocchis%20%C3%A0%20la%20sauge.json</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:displayname>Gnocchis à la sauge.json</d:displayname>
|
|
<d:getcontentlength>512</d:getcontentlength>
|
|
<d:getlastmodified>Thu, 17 Sep 2026 07:04:55 GMT</d:getlastmodified>
|
|
<d:resourcetype/>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>/remote.php/dav/files/tofu/Tofu/index.json</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:displayname>index.json</d:displayname>
|
|
<d:getcontentlength>1843</d:getcontentlength>
|
|
<d:getlastmodified>Thu, 17 Sep 2026 07:05:01 GMT</d:getlastmodified>
|
|
<d:resourcetype/>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>
|
|
`
|
|
|
|
// Minimal stand-in for DOMParser, which Node does not provide. Nextcloud always
|
|
// uses the 'd:' prefix for the DAV namespace, so matching on the prefix is enough
|
|
// here; the production default parser does read the namespace.
|
|
function parseXmlWithRegExp(xmlText) {
|
|
if (!xmlText.includes('<d:multistatus')) return null
|
|
return createElement(xmlText)
|
|
}
|
|
|
|
function createElement(xml) {
|
|
return {
|
|
textContent: xml.replace(/<[^>]*>/g, ''),
|
|
getElementsByTagNameNS(namespace, localName) {
|
|
return matchTags(xml, localName).map(createElement)
|
|
},
|
|
}
|
|
}
|
|
|
|
function matchTags(xml, localName) {
|
|
const pattern = new RegExp(`<d:${localName}(?:\\s[^>]*)?(?:/>|>([\\s\\S]*?)</d:${localName}>)`, 'g')
|
|
return Array.from(xml.matchAll(pattern)).map((match) => match[1] ?? '')
|
|
}
|
|
|
|
test('lists the children of the requested folder', () => {
|
|
const entries = parsePropfindXml(NEXTCLOUD_PROPFIND, 'Tofu', parseXmlWithRegExp)
|
|
assert.deepEqual(entries.map((entry) => entry.name), [
|
|
'plannings',
|
|
'Gnocchis à la sauge.json',
|
|
'index.json',
|
|
])
|
|
})
|
|
|
|
test('excludes the requested folder itself', () => {
|
|
const entries = parsePropfindXml(NEXTCLOUD_PROPFIND, 'Tofu', parseXmlWithRegExp)
|
|
assert.equal(entries.some((entry) => entry.name === 'Tofu'), false)
|
|
})
|
|
|
|
test('describes a sub-folder', () => {
|
|
const [folder] = parsePropfindXml(NEXTCLOUD_PROPFIND, 'Tofu', parseXmlWithRegExp)
|
|
assert.deepEqual(folder, {
|
|
name: 'plannings',
|
|
path: 'Tofu/plannings',
|
|
isFolder: true,
|
|
size: 0,
|
|
lastModified: 'Thu, 17 Sep 2026 07:03:12 GMT',
|
|
})
|
|
})
|
|
|
|
test('describes a file and decodes its href', () => {
|
|
const entries = parsePropfindXml(NEXTCLOUD_PROPFIND, 'Tofu', parseXmlWithRegExp)
|
|
assert.deepEqual(entries[1], {
|
|
name: 'Gnocchis à la sauge.json',
|
|
path: 'Tofu/Gnocchis à la sauge.json',
|
|
isFolder: false,
|
|
size: 512,
|
|
lastModified: 'Thu, 17 Sep 2026 07:04:55 GMT',
|
|
})
|
|
})
|
|
|
|
test('builds paths from the base path, whatever its slashes', () => {
|
|
const entries = parsePropfindXml(NEXTCLOUD_PROPFIND, '/Tofu/', parseXmlWithRegExp)
|
|
assert.equal(entries[2].path, 'Tofu/index.json')
|
|
})
|
|
|
|
test('an empty base path gives plain names as paths', () => {
|
|
const entries = parsePropfindXml(NEXTCLOUD_PROPFIND, '', parseXmlWithRegExp)
|
|
assert.equal(entries[2].path, 'index.json')
|
|
})
|
|
|
|
test('returns an empty list on invalid XML', () => {
|
|
assert.deepEqual(parsePropfindXml('<d:multi', 'Tofu', parseXmlWithRegExp), [])
|
|
assert.deepEqual(parsePropfindXml('not xml at all', 'Tofu', parseXmlWithRegExp), [])
|
|
assert.deepEqual(parsePropfindXml('', 'Tofu', parseXmlWithRegExp), [])
|
|
})
|
|
|
|
test('returns an empty list when the folder has no child', () => {
|
|
const emptyFolder = `<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:">
|
|
<d:response>
|
|
<d:href>/remote.php/dav/files/tofu/Tofu/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:displayname>Tofu</d:displayname>
|
|
<d:resourcetype><d:collection/></d:resourcetype>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>
|
|
`
|
|
assert.deepEqual(parsePropfindXml(emptyFolder, 'Tofu', parseXmlWithRegExp), [])
|
|
})
|
|
|
|
test('returns an empty list rather than failing when a parser throws', () => {
|
|
const entries = parsePropfindXml(NEXTCLOUD_PROPFIND, 'Tofu', () => {
|
|
throw new Error('broken parser')
|
|
})
|
|
assert.deepEqual(entries, [])
|
|
})
|
|
|
|
test('returns an empty list when no XML parser is available', { skip: typeof DOMParser !== 'undefined' }, () => {
|
|
assert.deepEqual(parsePropfindXml(NEXTCLOUD_PROPFIND, 'Tofu'), [])
|
|
})
|