File
Blob: tests/worker/protocol/caldav/calendar-reports.workers.test.ts
| 1 | import { describe, expect, it } from "vitest"; |
| 2 | |
| 3 | import { createDavFixture, davRequest } from "@tests/worker/helpers/dav"; |
| 4 | import { fetchWorker } from "@tests/worker/helpers/http"; |
| 5 | import { event, propfindBody, reportBody, uid } from "./helpers"; |
| 6 | |
| 7 | describe("CalDAV reports", () => { |
| 8 | it("supports calendar-query and calendar-multiget", async () => { |
| 9 | const fixture = await createDavFixture(["caldav.full"]); |
| 10 | const aliceUid = uid(); |
| 11 | const bobUid = uid(); |
| 12 | expect( |
| 13 | await fetchWorker( |
| 14 | davRequest(fixture, "/calendars/default/alice.ics", { |
| 15 | method: "PUT", |
| 16 | body: event({ uid: aliceUid, summary: "Alice Query", start: "20260521T120000Z", end: "20260521T130000Z" }), |
| 17 | }), |
| 18 | ), |
| 19 | ).toMatchObject({ status: 201 }); |
| 20 | expect( |
| 21 | await fetchWorker( |
| 22 | davRequest(fixture, "/calendars/default/bob.ics", { |
| 23 | method: "PUT", |
| 24 | body: event({ uid: bobUid, summary: "Bob Query", start: "20270621T120000Z", end: "20270621T130000Z" }), |
| 25 | }), |
| 26 | ), |
| 27 | ).toMatchObject({ status: 201 }); |
| 28 | |
| 29 | const query = await fetchWorker( |
| 30 | davRequest(fixture, "/calendars/default/", { |
| 31 | method: "REPORT", |
| 32 | headers: { "content-type": "application/xml", depth: "1" }, |
| 33 | body: reportBody( |
| 34 | "calendar-query", |
| 35 | '<D:prop><D:getetag/><C:calendar-data/></D:prop><C:filter><C:comp-filter name="VCALENDAR"><C:comp-filter name="VEVENT"><C:time-range start="20260501T000000Z" end="20260601T000000Z"/></C:comp-filter></C:comp-filter></C:filter>', |
| 36 | ), |
| 37 | }), |
| 38 | ); |
| 39 | expect(query.status).toBe(207); |
| 40 | const queryXml = await query.text(); |
| 41 | expect(queryXml).toContain("/calendars/default/alice.ics"); |
| 42 | expect(queryXml).not.toContain("/calendars/default/bob.ics"); |
| 43 | expect(queryXml).toContain(`UID:${aliceUid}`); |
| 44 | |
| 45 | const multiget = await fetchWorker( |
| 46 | davRequest(fixture, "/calendars/default/", { |
| 47 | method: "REPORT", |
| 48 | headers: { "content-type": "application/xml", depth: "1" }, |
| 49 | body: reportBody( |
| 50 | "calendar-multiget", |
| 51 | "<D:prop><D:getetag/><C:calendar-data/></D:prop><D:href>/calendars/default/bob.ics</D:href><D:href>/calendars/default/missing.ics</D:href>", |
| 52 | ), |
| 53 | }), |
| 54 | ); |
| 55 | expect(multiget.status).toBe(207); |
| 56 | const multigetXml = await multiget.text(); |
| 57 | expect((multigetXml.match(/<D:response>/g) ?? []).length).toBe(2); |
| 58 | expect(multigetXml).toContain("/calendars/default/bob.ics"); |
| 59 | expect(multigetXml).toContain(`UID:${bobUid}`); |
| 60 | expect(multigetXml).toContain("/calendars/default/missing.ics"); |
| 61 | expect(multigetXml).toContain("HTTP/1.1 404 Not Found"); |
| 62 | }); |
| 63 | |
| 64 | it("does not return calendar-data from PROPFIND but still returns it from REPORT", async () => { |
| 65 | const fixture = await createDavFixture(["caldav.full"]); |
| 66 | const eventUid = uid(); |
| 67 | expect( |
| 68 | await fetchWorker( |
| 69 | davRequest(fixture, "/calendars/default/no-propfind-data.ics", { |
| 70 | method: "PUT", |
| 71 | body: event({ uid: eventUid, summary: "No PROPFIND Data" }), |
| 72 | }), |
| 73 | ), |
| 74 | ).toMatchObject({ status: 201 }); |
| 75 | |
| 76 | const propfind = await fetchWorker( |
| 77 | davRequest(fixture, "/calendars/default/no-propfind-data.ics", { |
| 78 | method: "PROPFIND", |
| 79 | headers: { depth: "0", "content-type": "application/xml" }, |
| 80 | body: propfindBody("<C:calendar-data/>"), |
| 81 | }), |
| 82 | ); |
| 83 | expect(propfind.status).toBe(207); |
| 84 | expect(await propfind.text()).toContain("HTTP/1.1 404 Not Found"); |
| 85 | |
| 86 | const report = await fetchWorker( |
| 87 | davRequest(fixture, "/calendars/default/", { |
| 88 | method: "REPORT", |
| 89 | headers: { "content-type": "application/xml", depth: "1" }, |
| 90 | body: reportBody( |
| 91 | "calendar-query", |
| 92 | '<D:prop><C:calendar-data/></D:prop><C:filter><C:comp-filter name="VCALENDAR"/></C:filter>', |
| 93 | ), |
| 94 | }), |
| 95 | ); |
| 96 | expect(report.status).toBe(207); |
| 97 | expect(await report.text()).toContain(`UID:${eventUid}`); |
| 98 | }); |
| 99 | |
| 100 | it("uses CALDAV:timezone for floating time-range matching", async () => { |
| 101 | const fixture = await createDavFixture(["caldav.full"]); |
| 102 | const floatingUid = uid(); |
| 103 | expect( |
| 104 | await fetchWorker( |
| 105 | davRequest(fixture, "/calendars/default/floating.ics", { |
| 106 | method: "PUT", |
| 107 | body: event({ |
| 108 | uid: floatingUid, |
| 109 | summary: "Floating", |
| 110 | start: "20260521T090000", |
| 111 | end: "20260521T100000", |
| 112 | }), |
| 113 | }), |
| 114 | ), |
| 115 | ).toMatchObject({ status: 201 }); |
| 116 | |
| 117 | const filter = |
| 118 | '<D:prop><D:getetag/></D:prop><C:filter><C:comp-filter name="VCALENDAR"><C:comp-filter name="VEVENT"><C:time-range start="20260521T163000Z" end="20260521T164500Z"/></C:comp-filter></C:comp-filter></C:filter>'; |
| 119 | const withoutTimezone = await fetchWorker( |
| 120 | davRequest(fixture, "/calendars/default/", { |
| 121 | method: "REPORT", |
| 122 | headers: { "content-type": "application/xml", depth: "1" }, |
| 123 | body: reportBody("calendar-query", filter), |
| 124 | }), |
| 125 | ); |
| 126 | expect(withoutTimezone.status).toBe(207); |
| 127 | expect(await withoutTimezone.text()).not.toContain("/calendars/default/floating.ics"); |
| 128 | |
| 129 | const timezone = ["BEGIN:VTIMEZONE", "TZID:America/Los_Angeles", "END:VTIMEZONE"].join("\r\n"); |
| 130 | const withTimezone = await fetchWorker( |
| 131 | davRequest(fixture, "/calendars/default/", { |
| 132 | method: "REPORT", |
| 133 | headers: { "content-type": "application/xml", depth: "1" }, |
| 134 | body: reportBody("calendar-query", `<C:timezone>${timezone}</C:timezone>${filter}`), |
| 135 | }), |
| 136 | ); |
| 137 | expect(withTimezone.status).toBe(207); |
| 138 | expect(await withTimezone.text()).toContain("/calendars/default/floating.ics"); |
| 139 | }); |
| 140 | |
| 141 | it("rejects REPORT elements from the wrong namespace", async () => { |
| 142 | const fixture = await createDavFixture(["caldav.full"]); |
| 143 | const response = await fetchWorker( |
| 144 | davRequest(fixture, "/calendars/default/", { |
| 145 | method: "REPORT", |
| 146 | headers: { "content-type": "application/xml", depth: "1" }, |
| 147 | body: '<?xml version="1.0"?><X:calendar-query xmlns:X="urn:wrong"><D:prop xmlns:D="DAV:"><D:getetag/></D:prop></X:calendar-query>', |
| 148 | }), |
| 149 | ); |
| 150 | expect(response.status).toBe(400); |
| 151 | }); |
| 152 | |
| 153 | it("treats missing calendar-query Depth as 0 and Depth 1 as child scope", async () => { |
| 154 | const fixture = await createDavFixture(["caldav.full"]); |
| 155 | expect( |
| 156 | await fetchWorker( |
| 157 | davRequest(fixture, "/calendars/default/depth.ics", { |
| 158 | method: "PUT", |
| 159 | body: event({ uid: uid(), summary: "Depth Query" }), |
| 160 | }), |
| 161 | ), |
| 162 | ).toMatchObject({ status: 201 }); |
| 163 | const body = reportBody( |
| 164 | "calendar-query", |
| 165 | '<D:prop><D:getetag/></D:prop><C:filter><C:comp-filter name="VCALENDAR"/></C:filter>', |
| 166 | ); |
| 167 | |
| 168 | const depth0 = await fetchWorker( |
| 169 | davRequest(fixture, "/calendars/default/", { |
| 170 | method: "REPORT", |
| 171 | headers: { "content-type": "application/xml" }, |
| 172 | body, |
| 173 | }), |
| 174 | ); |
| 175 | expect(depth0.status).toBe(207); |
| 176 | expect(await depth0.text()).not.toContain("/calendars/default/depth.ics"); |
| 177 | |
| 178 | const depth1 = await fetchWorker( |
| 179 | davRequest(fixture, "/calendars/default/", { |
| 180 | method: "REPORT", |
| 181 | headers: { "content-type": "application/xml", depth: "1" }, |
| 182 | body, |
| 183 | }), |
| 184 | ); |
| 185 | expect(depth1.status).toBe(207); |
| 186 | expect(await depth1.text()).toContain("/calendars/default/depth.ics"); |
| 187 | }); |
| 188 | |
| 189 | it("rejects unsupported CalDAV filters and invalid time ranges", async () => { |
| 190 | const fixture = await createDavFixture(["caldav.full"]); |
| 191 | |
| 192 | const unsupportedParamFilter = await fetchWorker( |
| 193 | davRequest(fixture, "/calendars/default/", { |
| 194 | method: "REPORT", |
| 195 | headers: { "content-type": "application/xml" }, |
| 196 | body: reportBody( |
| 197 | "calendar-query", |
| 198 | '<D:prop><D:getetag/></D:prop><C:filter><C:comp-filter name="VCALENDAR"><C:comp-filter name="VEVENT"><C:prop-filter name="ATTENDEE"><C:param-filter name="PARTSTAT"/></C:prop-filter></C:comp-filter></C:comp-filter></C:filter>', |
| 199 | ), |
| 200 | }), |
| 201 | ); |
| 202 | expect(unsupportedParamFilter.status).toBe(400); |
| 203 | await expect(unsupportedParamFilter.text()).resolves.toContain("Unsupported CalDAV param-filter"); |
| 204 | |
| 205 | const unsupportedComponent = await fetchWorker( |
| 206 | davRequest(fixture, "/calendars/default/", { |
| 207 | method: "REPORT", |
| 208 | headers: { "content-type": "application/xml" }, |
| 209 | body: reportBody( |
| 210 | "calendar-query", |
| 211 | '<D:prop><D:getetag/></D:prop><C:filter><C:comp-filter name="VCALENDAR"><C:comp-filter name="VFREEBUSY"/></C:comp-filter></C:filter>', |
| 212 | ), |
| 213 | }), |
| 214 | ); |
| 215 | expect(unsupportedComponent.status).toBe(400); |
| 216 | await expect(unsupportedComponent.text()).resolves.toContain("Unsupported CalDAV comp-filter"); |
| 217 | |
| 218 | const invalidTimeRange = await fetchWorker( |
| 219 | davRequest(fixture, "/calendars/default/", { |
| 220 | method: "REPORT", |
| 221 | headers: { "content-type": "application/xml" }, |
| 222 | body: reportBody( |
| 223 | "calendar-query", |
| 224 | '<D:prop><D:getetag/></D:prop><C:filter><C:comp-filter name="VCALENDAR"><C:comp-filter name="VEVENT"><C:time-range start="20260522T000000Z" end="20260521T000000Z"/></C:comp-filter></C:comp-filter></C:filter>', |
| 225 | ), |
| 226 | }), |
| 227 | ); |
| 228 | expect(invalidTimeRange.status).toBe(400); |
| 229 | await expect(invalidTimeRange.text()).resolves.toContain("end must be after start"); |
| 230 | }); |
| 231 | }); |