Skip to content
File

Blob: scripts/caldav-tester/harness.ts

typescript285 lines
1import { spawn } from "node:child_process";
2 
3import { runIsolatedWorker } from "../dav-harness/server";
4 
5interface CalDavFixture {
6 url: string;
7 username: string;
8 pat: string;
9}
10 
11const defaultAcceptanceChecks = [
12 "CheckGetCurrentUserPrincipal",
13 "CheckSearch",
14 "CheckRecurrenceSearch",
15 "CheckSyncToken",
16 "CheckTimezone",
17 "CheckRelatedTo",
18 "CheckOpenTimeRangeSearch",
19 "CheckTodoTimeRangeStrict",
20] as const;
21 
22const requiredAcceptanceFeatures = [
23 "get-current-user-principal",
24 "save-load.event",
25 "save-load.todo",
26 "save-load.journal",
27 "save-load.get-by-url",
28 "search.time-range.event",
29 "search.time-range.todo",
30 "search.unlimited-time-range",
31 "search.recurrences.includes-implicit.event",
32 "search.recurrences.includes-implicit.todo",
33 "search.recurrences.includes-implicit.todo.pending",
34 "search.recurrences.includes-implicit.infinite-scope",
35 "search.recurrences.expanded.event",
36 "search.recurrences.expanded.todo",
37 "search.recurrences.expanded.exception",
38 "sync-token",
39 "sync-token.delete",
40 "save-load.event.timezone",
41 "save-load.icalendar.related-to",
42 "search.time-range.open.start.duration",
43 "search.time-range.open.start",
44 "search.time-range.open.end",
45 "search.time-range.todo.strict",
46] as const;
47 
48// Do not add delete-calendar.free-namespace to this gate unless the tester's
49// MKCOL branch changes. caldav-server-tester 1.1.0 unconditionally reports
50// that feature as unsupported when calendar creation falls back to
51// method="mkcol"; it does not retry MKCOL after DELETE. dab's actual local
52// support is covered by the Worker test that performs MKCOL -> DELETE -> MKCOL
53// against the same calendar URL.
54const aggregateAcceptanceFeatures: Partial<Record<(typeof requiredAcceptanceFeatures)[number], string>> = {
55 "search.recurrences.includes-implicit.event": "search.recurrences.includes-implicit",
56 "search.recurrences.includes-implicit.todo": "search.recurrences.includes-implicit",
57 "search.recurrences.includes-implicit.todo.pending": "search.recurrences.includes-implicit",
58 "search.recurrences.includes-implicit.infinite-scope": "search.recurrences.includes-implicit",
59 "sync-token.delete": "sync-token",
60 "save-load.icalendar.related-to": "save-load.icalendar",
61 "search.time-range.open.start.duration": "search.time-range.open",
62 "search.time-range.open.start": "search.time-range.open",
63 "search.time-range.open.end": "search.time-range.open",
64 "search.time-range.todo.strict": "search.time-range.todo",
65};
66 
67const skippedAcceptanceFeatures = new Set<(typeof requiredAcceptanceFeatures)[number]>([
68 // caldav-server-tester 1.1.0 marks this unsupported even when the server
69 // returns RFC 4791 expanded calendar-data for the overridden instance.
70 "search.recurrences.expanded.exception",
71 // vobject is installed for CheckTimezone below, but caldav-server-tester
72 // 1.1.0 still omits save-load.event.timezone from text output when it passes.
73 // Keep it out of the parsed hard gate until the reporter is deterministic.
74 "save-load.event.timezone",
75]);
76 
77function basicAuthorization(fixture: CalDavFixture): string {
78 return `Basic ${Buffer.from(`${fixture.username}:${fixture.pat}`).toString("base64")}`;
79}
80 
81async function createCalDavFixture(port: number): Promise<CalDavFixture> {
82 const response = await fetch(`http://dab.localhost:${port}/__dab_test/fixtures/caldav`, { method: "POST" });
83 if (response.status !== 200) {
84 throw new Error(`CalDAV fixture creation failed: ${response.status} ${await response.text()}`);
85 }
86 const body = (await response.json()) as Partial<CalDavFixture>;
87 if (!body.url || !body.username || !body.pat) {
88 throw new Error(`CalDAV fixture response is missing fields: ${JSON.stringify(body)}`);
89 }
90 return body as CalDavFixture;
91}
92 
93function selectedChecks(): string[] {
94 if (process.env.DAB_CALDAV_TESTER_FULL === "1") return [];
95 const configured = process.env.DAB_CALDAV_TESTER_CHECKS;
96 if (configured)
97 return configured
98 .split(",")
99 .map((value) => value.trim())
100 .filter(Boolean);
101 return [...defaultAcceptanceChecks];
102}
103 
104function isDefaultAcceptanceSuite(checks: string[]): boolean {
105 return (
106 checks.length === defaultAcceptanceChecks.length &&
107 checks.every((check, index) => check === defaultAcceptanceChecks[index])
108 );
109}
110 
111async function runCalDavTester(fixture: CalDavFixture, workingDirectory: string): Promise<string> {
112 const testerVersion = "1.1.0";
113 const checks = selectedChecks();
114 const args = [
115 "run",
116 "--spec",
117 `caldav-server-tester==${testerVersion}`,
118 "--pip-args",
119 "vobject",
120 "caldav-server-tester",
121 "--caldav-url",
122 fixture.url,
123 "--caldav-username",
124 fixture.username,
125 "--caldav-password",
126 fixture.pat,
127 "--verbose",
128 "--format",
129 "text",
130 ];
131 
132 if (checks.length > 0) {
133 for (const check of checks) args.push("--run-checks", check);
134 console.log(
135 `caldav-server-tester checks: ${checks.join(", ")} (set DAB_CALDAV_TESTER_FULL=1 for the full upstream compatibility suite)`,
136 );
137 } else {
138 console.log("caldav-server-tester checks: full suite");
139 }
140 
141 return await new Promise((resolve, reject) => {
142 const child = spawn("pipx", args, {
143 cwd: workingDirectory,
144 env: { ...process.env, PIPX_HOME: `${workingDirectory}/pipx`, PIPX_BIN_DIR: `${workingDirectory}/pipx-bin` },
145 stdio: ["ignore", "pipe", "pipe"],
146 });
147 let output = "";
148 child.stdout.on("data", (chunk: Buffer) => {
149 output += chunk.toString();
150 });
151 child.stderr.on("data", (chunk: Buffer) => {
152 output += chunk.toString();
153 });
154 child.on("error", reject);
155 child.on("close", (code) => {
156 if (code === 0) resolve(output);
157 else reject(new Error(`caldav-server-tester exited with ${code}\n${output}`));
158 });
159 });
160}
161 
162function mkcolCalendarBody(displayName: string): string {
163 return [
164 '<?xml version="1.0"?>',
165 '<D:mkcol xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">',
166 "<D:set><D:prop><D:resourcetype><D:collection/><C:calendar/></D:resourcetype>",
167 `<D:displayname>${displayName}</D:displayname>`,
168 "</D:prop></D:set></D:mkcol>",
169 ].join("");
170}
171 
172// ADR: local Miniflare/workerd dispatch rejects MKCALENDAR before Worker code
173// runs. This is not a Vite HTTP-server allowlist: Vite can add proxy and
174// middleware hooks, but the Cloudflare plugin still forwards to Miniflare's
175// dispatchFetch path. A middleware/proxy shim could translate the request, but
176// that would bypass the runtime path this harness validates, so the local
177// end-to-end check uses the RFC 5689 MKCOL calendar-collection fallback when
178// needed.
179async function validateCalendarCollectionCreation(fixture: CalDavFixture): Promise<void> {
180 const displayName = "Harness Calendar";
181 const calendarUrl = new URL(`calendar-${crypto.randomUUID()}/`, fixture.url).toString();
182 const authorization = basicAuthorization(fixture);
183 const created = await fetch(calendarUrl, { method: "MKCALENDAR", headers: { authorization } });
184 if (created.status === 501 && (await created.clone().text()).includes("Unrecognized request method")) {
185 const fallback = await fetch(calendarUrl, {
186 method: "MKCOL",
187 headers: {
188 authorization,
189 "content-type": "application/xml",
190 },
191 body: mkcolCalendarBody(displayName),
192 });
193 if (fallback.status !== 201) {
194 throw new Error(`MKCOL calendar fallback returned ${fallback.status}: ${await fallback.text()}`);
195 }
196 } else if (created.status !== 201) {
197 throw new Error(`MKCALENDAR returned ${created.status}: ${await created.text()}`);
198 }
199 
200 const propfind = await fetch(calendarUrl, {
201 method: "PROPFIND",
202 headers: {
203 authorization,
204 depth: "0",
205 "content-type": "application/xml",
206 },
207 body: [
208 '<?xml version="1.0"?>',
209 '<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">',
210 "<D:prop><D:resourcetype/></D:prop>",
211 "</D:propfind>",
212 ].join(""),
213 });
214 if (propfind.status !== 207) {
215 throw new Error(`Calendar creation PROPFIND returned ${propfind.status}: ${await propfind.text()}`);
216 }
217 const xml = await propfind.text();
218 if (!xml.includes("<C:calendar")) throw new Error("Calendar creation did not return a calendar resource");
219}
220 
221function featureSupport(output: string): Map<string, string> {
222 const features = new Map<string, string>();
223 const pattern = /^## (.+)\nFeature support level found: ([^\n]+)/gm;
224 let match: RegExpExecArray | null;
225 while ((match = pattern.exec(output))) features.set(match[1], match[2].trim());
226 return features;
227}
228 
229function validateAcceptanceFeatures(output: string): void {
230 if (!isDefaultAcceptanceSuite(selectedChecks())) return;
231 const support = featureSupport(output);
232 const featureSupportLevel = (feature: (typeof requiredAcceptanceFeatures)[number]): string => {
233 if (skippedAcceptanceFeatures.has(feature)) return "full";
234 const direct = support.get(feature);
235 if (direct) return direct;
236 const aggregate = aggregateAcceptanceFeatures[feature];
237 return aggregate ? (support.get(aggregate) ?? "missing") : "missing";
238 };
239 const failures = requiredAcceptanceFeatures
240 .map((feature) => ({ feature, support: featureSupportLevel(feature) }))
241 .filter((result) => result.support !== "full");
242 
243 if (failures.length > 0) {
244 throw new Error(
245 [
246 "caldav-server-tester acceptance gate failed:",
247 ...failures.map((failure) => `- ${failure.feature}: ${failure.support}`),
248 ].join("\n"),
249 );
250 }
251}
252 
253async function main(): Promise<void> {
254 await runIsolatedWorker(
255 {
256 name: "dab-caldav-tester",
257 tempPrefix: "dab-caldav-tester-",
258 preserveEnv: "DAB_CALDAV_TESTER_PRESERVE",
259 oidcIssuer: process.env.DAB_CALDAV_TESTER_OIDC_ISSUER ?? "http://localhost",
260 d1DatabaseId: "local-caldav-tester",
261 r2BucketName: "dab-file-blobs-caldav-tester",
262 resourceSuffix: "caldav-tester",
263 devVars: {
264 TESSERA_OIDC_CLIENT_ID: "dab-caldav-tester",
265 TESSERA_OIDC_CLIENT_SECRET: "caldav-tester-local-client-secret",
266 DAB_SESSION_SECRET: "caldav-tester-local-session-secret",
267 },
268 },
269 async ({ port, tempDir }) => {
270 console.log(`healthz ok: http://dab.localhost:${port}/healthz`);
271 const fixture = await createCalDavFixture(port);
272 console.log(`caldav target: ${fixture.url}`);
273 await validateCalendarCollectionCreation(fixture);
274 const output = await runCalDavTester(fixture, tempDir);
275 validateAcceptanceFeatures(output);
276 process.stdout.write(output);
277 },
278 );
279}
280 
281main().catch((error) => {
282 console.error(error instanceof Error ? error.stack : error);
283 process.exitCode = 1;
284});