Skip to content
File

Blob: tests/worker/protocol/caldav/calendar-reports.workers.test.ts

typescript232 lines
1import { describe, expect, it } from "vitest";
2 
3import { createDavFixture, davRequest } from "@tests/worker/helpers/dav";
4import { fetchWorker } from "@tests/worker/helpers/http";
5import { event, propfindBody, reportBody, uid } from "./helpers";
6 
7describe("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});