Tofu/tests/webdav/parsePropfindXml.test.js

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'), [])
})