File
Blob: tests/worker/protocol/caldav/calendar-objects.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, propfindSyncToken, reportBody, uid } from "./helpers"; |
| 6 | |
| 7 | describe("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 | }); |