File
Blob: scripts/caldav-tester/harness.ts
| 1 | import { spawn } from "node:child_process"; |
| 2 | |
| 3 | import { runIsolatedWorker } from "../dav-harness/server"; |
| 4 | |
| 5 | interface CalDavFixture { |
| 6 | url: string; |
| 7 | username: string; |
| 8 | pat: string; |
| 9 | } |
| 10 | |
| 11 | const defaultAcceptanceChecks = [ |
| 12 | "CheckGetCurrentUserPrincipal", |
| 13 | "CheckSearch", |
| 14 | "CheckRecurrenceSearch", |
| 15 | "CheckSyncToken", |
| 16 | "CheckTimezone", |
| 17 | "CheckRelatedTo", |
| 18 | "CheckOpenTimeRangeSearch", |
| 19 | "CheckTodoTimeRangeStrict", |
| 20 | ] as const; |
| 21 | |
| 22 | const 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. |
| 54 | const 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 | |
| 67 | const 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 | |
| 77 | function basicAuthorization(fixture: CalDavFixture): string { |
| 78 | return `Basic ${Buffer.from(`${fixture.username}:${fixture.pat}`).toString("base64")}`; |
| 79 | } |
| 80 | |
| 81 | async 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 | |
| 93 | function 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 | |
| 104 | function isDefaultAcceptanceSuite(checks: string[]): boolean { |
| 105 | return ( |
| 106 | checks.length === defaultAcceptanceChecks.length && |
| 107 | checks.every((check, index) => check === defaultAcceptanceChecks[index]) |
| 108 | ); |
| 109 | } |
| 110 | |
| 111 | async 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 | |
| 162 | function 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. |
| 179 | async 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 | |
| 221 | function 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 | |
| 229 | function 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 | |
| 253 | async 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 | |
| 281 | main().catch((error) => { |
| 282 | console.error(error instanceof Error ? error.stack : error); |
| 283 | process.exitCode = 1; |
| 284 | }); |