[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:
Hermes committed 2026-10-11 22:17:54 -07:00
1 parent a1b8fa36d9
commit 208bbf0679
2 files changed
+99 -2

No files matched your search

@@ -0,0 +1,95 @@
/**
* 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&#13;&#10;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:" 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>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, mandatory)', () => {
// fail-closed: if the parser is unavailable the suite FAILS (no skip),
// because fixture validity is part of this change's acceptance criteria.
let XMLParser;
beforeAll(() => {
({ XMLParser } = require('fast-xml-parser'));
expect(typeof XMLParser).toBe('function');
});
test('all fixtures are well-formed, namespace-aware parseable', () => {
const parser = new XMLParser({ preserveOrder: true, removeNSPrefix: false, ignoreAttributes: false });
const mixed = '<?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>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>';
for (const [name, xml] of [['BARE', BARE], ['PREFIXED', PREFIXED], ['MIXED', mixed]]) {
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');
}
});
});