[pending-judge] fix(DC-167): CalDAV multistatus default-ns parsing (Radicale silent zero-events) + scope-aware fixture ns validation
extractCalendarsAndEvents required a namespace prefix on <response>; Radicale emits bare <response> (default DAV ns) so every entry was silently dropped — REPORT 200, ok:true, 0 events, no error. Prefix is now optional in the split and detection regexes. Tests: 8 in the ns suite incl. SCOPE-AWARE namespace validation (element stack of xmlns bindings, runs on all fixtures; undeclared prefix rejected with reason) + XMLValidator well-formedness rejection. Calendar-related suites: 51. eslint clean.
This commit is contained in:
2 files changed
+144
-2
No files matched your search
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 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 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 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', () => {
|
||||
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, fail-closed)', () => {
|
||||
let XMLParser;
|
||||
beforeAll(() => {
|
||||
({ XMLParser } = require('fast-xml-parser'));
|
||||
// fail-closed: fixture validation is an acceptance criterion — a missing
|
||||
// parser must FAIL this suite, never skip it.
|
||||
expect(typeof XMLParser).toBe('function');
|
||||
});
|
||||
|
||||
const parser = () => new XMLParser({ preserveOrder: true, removeNSPrefix: false, ignoreAttributes: false, processEntities: true });
|
||||
|
||||
test('all three fixtures are well-formed and namespace-parseable', () => {
|
||||
for (const xml of [BARE, PREFIXED, MIXED]) {
|
||||
const doc = parser().parse(xml);
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
test('undeclared d: prefix is REJECTED (scope-aware ns validation)', () => {
|
||||
// Scope-aware namespace validation per XML-NS: walk elements with a
|
||||
// stack of prefix bindings; each prefix in scope must be declared on
|
||||
// that element or an ancestor. Runs against ALL fixtures.
|
||||
const { XMLValidator } = require('fast-xml-parser');
|
||||
const validateNs = (xml) => {
|
||||
const wf = XMLValidator.validate(xml);
|
||||
if (wf !== true) return { ok: false, reason: 'not well-formed: ' + JSON.stringify(wf.err || wf) };
|
||||
const stack = [new Map()]; // scope 0: no bindings
|
||||
const tagRe = /<\/(?:([\w.-]+):)?([\w.-]+)>|<(?:([\w.-]+):)?([\w.-]+)([^>]*)>/g;
|
||||
for (const m of xml.matchAll(tagRe)) {
|
||||
const [full, closePrefix, closeLocal, openPrefix, openLocal, attrs] = m;
|
||||
if (closePrefix !== undefined || (openPrefix === undefined && full.startsWith('</'))) {
|
||||
// closing tag: pop scope
|
||||
const closeName = (closePrefix || '') + (closeLocal || '');
|
||||
if (closeName) stack.pop();
|
||||
continue;
|
||||
}
|
||||
if (openLocal === undefined) continue;
|
||||
// opening tag: register new bindings, then verify
|
||||
const bindings = new Map(stack[stack.length - 1]);
|
||||
for (const am of (attrs || '').matchAll(/xmlns:([\w.-]+)="([^"]*)"/g)) bindings.set(am[1], am[2]);
|
||||
stack.push(bindings);
|
||||
const pfx = openPrefix || '';
|
||||
if (pfx && !bindings.has(pfx)) return { ok: false, reason: 'undeclared prefix <' + pfx + ':' + openLocal + '>' };
|
||||
}
|
||||
return { ok: true };
|
||||
};
|
||||
for (const xml of [BARE, PREFIXED, MIXED]) {
|
||||
expect(validateNs(xml)).toEqual({ ok: true });
|
||||
}
|
||||
const bad = MIXED.replace(' xmlns:d="DAV:"', '');
|
||||
const res = validateNs(bad);
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toMatch(/undeclared prefix/);
|
||||
});
|
||||
|
||||
test('mismatched close tag is REJECTED (well-formedness via XMLValidator)', () => {
|
||||
const { XMLValidator } = require('fast-xml-parser');
|
||||
const bad = BARE.replace('</response>', '</other>');
|
||||
const v = XMLValidator.validate(bad);
|
||||
expect(v).not.toBe(true);
|
||||
expect(v.err || v).toBeDefined();
|
||||
});
|
||||
|
||||
test('extraction tests consume the exact validated fixtures', () => {
|
||||
// guard against fixture drift: MIXED used above is the same object the
|
||||
// extraction test parses
|
||||
expect(MIXED).toContain('<response>');
|
||||
expect(MIXED).toContain('<d:response>');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user