Skip to content
File

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

typescript219 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, propfindSyncToken, reportBody, uid } from "./helpers";
6 
7describe("CalDAV calendar objects", () => {
8 it("puts, gets, and deletes an iCalendar object", async () => {
9 const fixture = await createDavFixture(["caldav.full"]);
10 const eventUid = uid();
11 const put = await fetchWorker(
12 davRequest(fixture, "/calendars/default/meeting.ics", {
13 method: "PUT",
14 headers: { "content-type": "text/calendar" },
15 body: event({ uid: eventUid, summary: "Planning" }),
16 }),
17 );
18 expect(put.status).toBe(201);
19 expect(put.headers.get("etag")).toMatch(/^"[0-9a-f]+"$/);
20 
21 const get = await fetchWorker(davRequest(fixture, "/calendars/default/meeting.ics"));
22 expect(get.status).toBe(200);
23 expect(get.headers.get("content-type")).toBe("text/calendar; charset=utf-8");
24 const body = await get.text();
25 expect(body).toContain(`UID:${eventUid}`);
26 expect(body).toContain("SUMMARY:Planning");
27 
28 const deleted = await fetchWorker(davRequest(fixture, "/calendars/default/meeting.ics", { method: "DELETE" }));
29 expect(deleted.status).toBe(204);
30 expect(await fetchWorker(davRequest(fixture, "/calendars/default/meeting.ics"))).toMatchObject({ status: 404 });
31 });
32 
33 it("stores and time-range queries IANA TZID calendar objects", async () => {
34 const fixture = await createDavFixture(["caldav.full"]);
35 const eventUid = uid();
36 const body = [
37 "BEGIN:VCALENDAR",
38 "VERSION:2.0",
39 "PRODID:-//dab//tests//EN",
40 "BEGIN:VEVENT",
41 `UID:${eventUid}`,
42 "DTSTAMP:20260101T000000Z",
43 "SUMMARY:TZID Query",
44 "DTSTART;TZID=America/Los_Angeles:20260521T090000",
45 "DTEND;TZID=America/Los_Angeles:20260521T100000",
46 "END:VEVENT",
47 "END:VCALENDAR",
48 "",
49 ].join("\r\n");
50 
51 const put = await fetchWorker(
52 davRequest(fixture, "/calendars/default/tzid.ics", {
53 method: "PUT",
54 headers: { "content-type": "text/calendar" },
55 body,
56 }),
57 );
58 expect(put.status).toBe(201);
59 
60 const get = await fetchWorker(davRequest(fixture, "/calendars/default/tzid.ics"));
61 expect(get.status).toBe(200);
62 await expect(get.text()).resolves.toContain("DTSTART;TZID=America/Los_Angeles:20260521T090000");
63 
64 const query = await fetchWorker(
65 davRequest(fixture, "/calendars/default/", {
66 method: "REPORT",
67 headers: { "content-type": "application/xml", depth: "1" },
68 body: reportBody(
69 "calendar-query",
70 '<D:prop><D:getetag/></D:prop><C:filter><C:comp-filter name="VCALENDAR"><C:comp-filter name="VEVENT"><C:time-range start="20260521T153000Z" end="20260521T163000Z"/></C:comp-filter></C:comp-filter></C:filter>',
71 ),
72 }),
73 );
74 expect(query.status).toBe(207);
75 expect(await query.text()).toContain("/calendars/default/tzid.ics");
76 });
77 
78 it("rejects malformed iCalendar objects, duplicate UIDs, and excessive recurrence", async () => {
79 const fixture = await createDavFixture(["caldav.full"]);
80 const malformed = await fetchWorker(
81 davRequest(fixture, "/calendars/default/bad.ics", {
82 method: "PUT",
83 body: "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n",
84 }),
85 );
86 expect(malformed.status).toBe(400);
87 
88 const eventUid = uid();
89 expect(
90 await fetchWorker(
91 davRequest(fixture, "/calendars/default/one.ics", {
92 method: "PUT",
93 body: event({ uid: eventUid, summary: "One" }),
94 }),
95 ),
96 ).toMatchObject({ status: 201 });
97 expect(
98 await fetchWorker(
99 davRequest(fixture, "/calendars/default/two.ics", {
100 method: "PUT",
101 body: event({ uid: eventUid, summary: "Two" }),
102 }),
103 ),
104 ).toMatchObject({ status: 409 });
105 
106 const recurrence = await fetchWorker(
107 davRequest(fixture, "/calendars/default/recurs.ics", {
108 method: "PUT",
109 body: event({ uid: uid(), summary: "Too many", rrule: "FREQ=DAILY;COUNT=10001" }),
110 }),
111 );
112 expect(recurrence.status).toBe(400);
113 await expect(recurrence.text()).resolves.toContain("maximum instances");
114 
115 expect(
116 await fetchWorker(
117 davRequest(fixture, "/calendars/default/unbounded.ics", {
118 method: "PUT",
119 body: event({ uid: uid(), summary: "Open ended", rrule: "FREQ=MONTHLY" }),
120 }),
121 ),
122 ).toMatchObject({ status: 201 });
123 });
124 
125 it("enforces tagged collection sync-token If preconditions on object writes", async () => {
126 const fixture = await createDavFixture(["caldav.full"]);
127 const eventUid = uid();
128 const initialToken = await propfindSyncToken(fixture);
129 const created = await fetchWorker(
130 davRequest(fixture, "/calendars/default/if-sync.ics", {
131 method: "PUT",
132 headers: { if: `</calendars/default/> (<${initialToken}>)` },
133 body: event({ uid: eventUid, summary: "If Sync" }),
134 }),
135 );
136 expect(created.status).toBe(201);
137 
138 const currentToken = await propfindSyncToken(fixture);
139 const stalePut = await fetchWorker(
140 davRequest(fixture, "/calendars/default/if-sync.ics", {
141 method: "PUT",
142 headers: { if: `</calendars/default/> (<${initialToken}>)` },
143 body: event({ uid: eventUid, summary: "If Sync Stale" }),
144 }),
145 );
146 expect(stalePut.status).toBe(412);
147 
148 const wrongTag = await fetchWorker(
149 davRequest(fixture, "/calendars/default/if-sync.ics", {
150 method: "PUT",
151 headers: { if: `</calendars/not-default/> (<${currentToken}>)` },
152 body: event({ uid: eventUid, summary: "If Sync Wrong Tag" }),
153 }),
154 );
155 expect(wrongTag.status).toBe(412);
156 
157 const updated = await fetchWorker(
158 davRequest(fixture, "/calendars/default/if-sync.ics", {
159 method: "PUT",
160 headers: { if: `</calendars/default/> (<${currentToken}>)` },
161 body: event({ uid: eventUid, summary: "If Sync Updated" }),
162 }),
163 );
164 expect(updated.status).toBe(204);
165 
166 const staleDelete = await fetchWorker(
167 davRequest(fixture, "/calendars/default/if-sync.ics", {
168 method: "DELETE",
169 headers: { if: `</calendars/default/> (<${currentToken}>)` },
170 }),
171 );
172 expect(staleDelete.status).toBe(412);
173 });
174 
175 it("honors HTTP If-Match on CalDAV object DELETE", async () => {
176 const fixture = await createDavFixture(["caldav.full"]);
177 expect(
178 await fetchWorker(
179 davRequest(fixture, "/calendars/default/delete-if-match.ics", {
180 method: "PUT",
181 body: event({ uid: uid(), summary: "Delete If-Match" }),
182 }),
183 ),
184 ).toMatchObject({ status: 201 });
185 const deleted = await fetchWorker(
186 davRequest(fixture, "/calendars/default/delete-if-match.ics", {
187 method: "DELETE",
188 headers: { "if-match": '"definitely-stale"' },
189 }),
190 );
191 expect(deleted.status).toBe(412);
192 expect(await fetchWorker(davRequest(fixture, "/calendars/default/delete-if-match.ics"))).toHaveProperty(
193 "status",
194 200,
195 );
196 });
197 
198 it("rejects encoded slashes and control characters in calendar object paths", async () => {
199 const fixture = await createDavFixture(["caldav.full"]);
200 
201 const encodedSlash = await fetchWorker(davRequest(fixture, "/calendars/default%2Fevil/item.ics"));
202 expect(encodedSlash.status).toBe(404);
203 
204 const controlCharacter = await fetchWorker(davRequest(fixture, "/calendars/default/%00.ics"));
205 expect(controlCharacter.status).toBe(404);
206 });
207 
208 it("prevents read-only CalDAV PATs from writing objects", async () => {
209 const fixture = await createDavFixture(["caldav.readonly"]);
210 const put = await fetchWorker(
211 davRequest(fixture, "/calendars/default/readonly.ics", {
212 method: "PUT",
213 body: event({ uid: uid(), summary: "No Write" }),
214 }),
215 );
216 expect(put.status).toBe(403);
217 });
218});