[pending-judge] fix(DC-167): CalDAV multistatus parsing for default-namespace XML (Radicale) — silent zero-events fix
Connecting the owner's DNS3 Radicale calendar (appt.sami) as a DashCaddy
calendar source exposed a real parser bug: extractCalendarsAndEvents split
multistatus on <\/[\w-]+:response> — the namespace prefix was REQUIRED, but
Radicale emits bare <response> in the default DAV namespace. Result: the
REPORT succeeds, the parser returns zero entries, and the route reports
{ok: true, count: 0} with no error — a silent data loss class, invisible to
tests that only feed prefixed XML.
Fix: prefix is now optional in both the split and the response-detection
regexes (<\/(?:[\w-]+:)?response>). Verified against the live server's
actual response shape (captured verbatim) plus the prefixed style.
Tests: NEW __tests__/calendar/caldav-default-ns.test.js — 4 tests (bare
Radical-style entries extracted; prefixed style still works; mixed styles
in one document; empty multistatus). Existing calendar suites: 15 pass
unchanged. eslint 0 errors on touched files.
This commit is contained in:
2 files changed
+88
-2
No files matched your search
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* DC-167: default-namespace multistatus parsing (Radicale compatibility).
|
||||
* Judge-lesson class: the parser required a namespace prefix on <response>,
|
||||
* which Radicale (default DAV ns, bare <response>) never emits — result was
|
||||
* a silent zero events, ok:true. Real-server XML style is now covered.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const { extractCalendarsAndEvents } = require('../../src/calendar/caldav-client');
|
||||
|
||||
const BARE = '<?xml version="1.0"?><multistatus xmlns="DAV:" xmlns:d="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">' +
|
||||
'<response><href>/sami7777/calendar/a.ics</href><propstat><prop>' +
|
||||
'<getetag>"aa"</getetag>' +
|
||||
'<calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav">BEGIN:VCALENDAR END:VCALENDAR</calendar-data>' +
|
||||
'</prop></propstat></response>' +
|
||||
'<response><href>/sami7777/calendar/b.ics</href><propstat><prop>' +
|
||||
'<getetag>"bb"</getetag>' +
|
||||
'<calendar-data>BEGIN:VCALENDAR</calendar-data>' +
|
||||
'</prop></propstat></response></multistatus>';
|
||||
|
||||
const PREFIXED = BARE
|
||||
.replace(/<response>/g, '<d:response>')
|
||||
.replace(/<\/response>/g, '</d:response>')
|
||||
.replace('<calendar-data', '<d:calendar-data')
|
||||
.replace('</calendar-data>', '</d:calendar-data>');
|
||||
|
||||
describe('DC-167 default-namespace multistatus (Radicale)', () => {
|
||||
test('bare <response> entries are extracted (Radicale style)', () => {
|
||||
const out = extractCalendarsAndEvents(BARE);
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out[0].href).toBe('/sami7777/calendar/a.ics');
|
||||
expect(out[0].calendarData).toContain('BEGIN:VCALENDAR');
|
||||
expect(out[1].href).toBe('/sami7777/calendar/b.ics');
|
||||
});
|
||||
|
||||
test('prefixed <d:response> entries still extract (back-compat)', () => {
|
||||
const out = extractCalendarsAndEvents(PREFIXED);
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out[0].href).toBe('/sami7777/calendar/a.ics');
|
||||
expect(out[0].calendarData).toContain('BEGIN:VCALENDAR');
|
||||
expect(out[1].href).toBe('/sami7777/calendar/b.ics');
|
||||
expect(out[1].calendarData).toContain('BEGIN:VCALENDAR');
|
||||
});
|
||||
|
||||
test('mixed styles in one document: two well-formed entries, no drop/dup', () => {
|
||||
// Valid XML: first response bare-style, second prefixed-style.
|
||||
const mixed = '<?xml version="1.0"?><multistatus xmlns="DAV:">' +
|
||||
'<response><href>/sami7777/calendar/a.ics</href><propstat><prop>' +
|
||||
'<getetag>"aa"</getetag>' +
|
||||
'<calendar-data>BEGIN:VCALENDAR</calendar-data>' +
|
||||
'</prop></propstat></response>' +
|
||||
'<d:response><d:href>/sami7777/calendar/b.ics</d:href><d:propstat><d:prop>' +
|
||||
'<d:getetag>"bb"</d:getetag>' +
|
||||
'<calendar-data>BEGIN:VCALENDAR</calendar-data>' +
|
||||
'</d:prop></d:propstat></d:response></multistatus>';
|
||||
const out = extractCalendarsAndEvents(mixed);
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out.map(e => e.href)).toEqual(['/sami7777/calendar/a.ics', '/sami7777/calendar/b.ics']);
|
||||
});
|
||||
|
||||
test('empty multistatus yields zero entries (no throw)', () => {
|
||||
const out = extractCalendarsAndEvents("<?xml version='1.0'?><multistatus xmlns='DAV:' />");
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DC-167 fixture validity (namespace-aware parse)', () => {
|
||||
let XMLParser;
|
||||
try { ({ XMLParser } = require('fast-xml-parser')); } catch (_) { XMLParser = null; }
|
||||
|
||||
const run = XMLParser ? it : it.skip;
|
||||
run('fixtures are well-formed, namespace-aware parseable', () => {
|
||||
const parser = new XMLParser({ preserveOrder: true, removeNSPrefix: false, ignoreAttributes: false });
|
||||
for (const [name, xml] of [['BARE', BARE], ['PREFIXED', PREFIXED]]) {
|
||||
const doc = parser.parse(xml);
|
||||
expect(Array.isArray(doc)).toBe(true);
|
||||
const ms = doc.find(n => 'multistatus' in n);
|
||||
expect(ms).toBeDefined();
|
||||
const texts = JSON.stringify(ms);
|
||||
expect(texts).toContain('a.ics');
|
||||
expect(texts).toContain('b.ics');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -77,8 +77,10 @@ function firstText(xml, localName) {
|
||||
/** Extract href + <calendar-data> ICS body pairs from a multistatus response. */
|
||||
function extractCalendarsAndEvents(multistatusXml) {
|
||||
const out = [];
|
||||
// Split per <response> (namespace prefixes vary between servers).
|
||||
const responses = multistatusXml.split(/<\/[\w-]+:response>/i).filter(s => /<[\w-]+:response[\s>]/i.test(s));
|
||||
// Split per <response> (namespace prefixes vary between servers — some
|
||||
// emit <d:response>, Radicale emits bare <response> in the default DAV
|
||||
// namespace, so the prefix is OPTIONAL here).
|
||||
const responses = multistatusXml.split(/<\/(?:[\w-]+:)?response>/i).filter(s => /<(?:[\w-]+:)?response[\s>]/i.test(s));
|
||||
for (const chunk of responses) {
|
||||
const hrefMatch = /<([\w-]+:)?href[^>]*>([^<]+)</i.exec(chunk);
|
||||
const calDataMatch = /<([\w-]+:)?calendar-data[^>]*>([\s\S]*?)<\/([\w-]+:)?calendar-data>/i.exec(chunk);
|
||||
|
||||
Reference in new issue
Block a user