Skip to content
File

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

typescript186 lines
1import { describe, expect, it } from "vitest";
2 
3import { createDavFixture, davRequest } from "@tests/worker/helpers/dav";
4import { fetchWorker } from "@tests/worker/helpers/http";
5import { mkcolCalendarBody, propfindBody } from "./helpers";
6 
7describe("CalDAV calendar collections", () => {
8 it("creates calendars with MKCOL calendar resource type", async () => {
9 const fixture = await createDavFixture(["caldav.full"]);
10 const calendarName = `work-${crypto.randomUUID()}`;
11 const created = await fetchWorker(
12 davRequest(fixture, `/calendars/${calendarName}/`, {
13 method: "MKCOL",
14 headers: { "content-type": "application/xml", depth: "1" },
15 body: mkcolCalendarBody("Work Calendar"),
16 }),
17 );
18 expect(created.status).toBe(201);
19 
20 const propfind = await fetchWorker(
21 davRequest(fixture, `/calendars/${calendarName}/`, {
22 method: "PROPFIND",
23 headers: { depth: "0", "content-type": "application/xml" },
24 body: propfindBody("<D:displayname/><D:resourcetype/>"),
25 }),
26 );
27 expect(propfind.status).toBe(207);
28 const xml = await propfind.text();
29 expect(xml).toContain("<C:calendar");
30 expect(xml).toContain("Work Calendar");
31 
32 const deleted = await fetchWorker(davRequest(fixture, `/calendars/${calendarName}/`, { method: "DELETE" }));
33 expect(deleted.status).toBe(204);
34 expect(
35 await fetchWorker(
36 davRequest(fixture, `/calendars/${calendarName}/`, {
37 method: "PROPFIND",
38 headers: { depth: "0", "content-type": "application/xml" },
39 body: propfindBody("<D:displayname/>"),
40 }),
41 ),
42 ).toMatchObject({ status: 404 });
43 
44 const recreated = await fetchWorker(
45 davRequest(fixture, `/calendars/${calendarName}/`, {
46 method: "MKCOL",
47 headers: { "content-type": "application/xml", depth: "1" },
48 body: mkcolCalendarBody("Recreated Work Calendar"),
49 }),
50 );
51 expect(recreated.status).toBe(201);
52 
53 const reprobe = await fetchWorker(
54 davRequest(fixture, `/calendars/${calendarName}/`, {
55 method: "PROPFIND",
56 headers: { depth: "0", "content-type": "application/xml" },
57 body: propfindBody("<D:displayname/><D:resourcetype/>"),
58 }),
59 );
60 expect(reprobe.status).toBe(207);
61 const reprobeXml = await reprobe.text();
62 expect(reprobeXml).toContain("<C:calendar");
63 expect(reprobeXml).toContain("Recreated Work Calendar");
64 });
65 
66 it("advertises sync:0 getctag for newly created calendars", async () => {
67 const fixture = await createDavFixture(["caldav.full"]);
68 const calendarName = `fresh-ctag-${crypto.randomUUID()}`;
69 expect(
70 await fetchWorker(
71 davRequest(fixture, `/calendars/${calendarName}/`, {
72 method: "MKCOL",
73 headers: { "content-type": "application/xml" },
74 body: mkcolCalendarBody("Fresh Ctag"),
75 }),
76 ),
77 ).toMatchObject({ status: 201 });
78 
79 const propfind = await fetchWorker(
80 davRequest(fixture, `/calendars/${calendarName}/`, {
81 method: "PROPFIND",
82 headers: { depth: "0", "content-type": "application/xml" },
83 body: propfindBody("<CS:getctag/>"),
84 }),
85 );
86 expect(propfind.status).toBe(207);
87 expect(await propfind.text()).toContain('<CS:getctag xmlns:CS="http://calendarserver.org/ns/">sync:0</CS:getctag>');
88 });
89 
90 it("returns per-property PROPPATCH failures atomically for protected CalDAV properties", async () => {
91 const fixture = await createDavFixture(["caldav.full"]);
92 const patch = await fetchWorker(
93 davRequest(fixture, "/calendars/default/", {
94 method: "PROPPATCH",
95 headers: { "content-type": "application/xml", depth: "1" },
96 body: [
97 '<?xml version="1.0"?>',
98 '<D:propertyupdate xmlns:D="DAV:" xmlns:X="urn:test">',
99 "<D:set><D:prop><X:label>Family</X:label></D:prop></D:set>",
100 "<D:set><D:prop><D:getetag>bad</D:getetag></D:prop></D:set>",
101 "</D:propertyupdate>",
102 ].join(""),
103 }),
104 );
105 expect(patch.status).toBe(207);
106 const patchXml = await patch.text();
107 expect(patchXml).toContain("HTTP/1.1 403 Forbidden");
108 expect(patchXml).toContain("HTTP/1.1 424 Failed Dependency");
109 
110 const propfind = await fetchWorker(
111 davRequest(fixture, "/calendars/default/", {
112 method: "PROPFIND",
113 headers: { depth: "0", "content-type": "application/xml" },
114 body: propfindBody('<X:label xmlns:X="urn:test"/>'),
115 }),
116 );
117 expect(propfind.status).toBe(207);
118 expect(await propfind.text()).toContain("HTTP/1.1 404 Not Found");
119 });
120 
121 it("stores MKCOL calendar creation timezone atomically and returns no-cache", async () => {
122 const fixture = await createDavFixture(["caldav.full"]);
123 const calendarName = `timezone-${crypto.randomUUID()}`;
124 const timezone = ["BEGIN:VTIMEZONE", "TZID:America/New_York", "END:VTIMEZONE"].join("\r\n");
125 const created = await fetchWorker(
126 davRequest(fixture, `/calendars/${calendarName}/`, {
127 method: "MKCOL",
128 headers: { "content-type": "application/xml", depth: "1" },
129 body: mkcolCalendarBody("Timezone Calendar", timezone),
130 }),
131 );
132 expect(created.status).toBe(201);
133 expect(created.headers.get("cache-control")).toBe("no-cache");
134 
135 const propfind = await fetchWorker(
136 davRequest(fixture, `/calendars/${calendarName}/`, {
137 method: "PROPFIND",
138 headers: { depth: "0", "content-type": "application/xml" },
139 body: propfindBody("<D:displayname/><C:calendar-timezone/>"),
140 }),
141 );
142 expect(propfind.status).toBe(207);
143 const xml = await propfind.text();
144 expect(xml).toContain("Timezone Calendar");
145 expect(xml).toContain("TZID:America/New_York");
146 
147 const badName = `bad-timezone-${crypto.randomUUID()}`;
148 const invalid = await fetchWorker(
149 davRequest(fixture, `/calendars/${badName}/`, {
150 method: "MKCOL",
151 headers: { "content-type": "application/xml", depth: "1" },
152 body: mkcolCalendarBody("Bad Timezone", "BEGIN:VTIMEZONE\r\nTZID:Not/A_Zone\r\nEND:VTIMEZONE"),
153 }),
154 );
155 expect(invalid.status).toBe(400);
156 expect(
157 await fetchWorker(
158 davRequest(fixture, `/calendars/${badName}/`, {
159 method: "PROPFIND",
160 headers: { depth: "0", "content-type": "application/xml" },
161 body: propfindBody("<D:displayname/>"),
162 }),
163 ),
164 ).toMatchObject({ status: 404 });
165 });
166 
167 it("rejects MKCOL calendar bodies from the wrong namespace", async () => {
168 const fixture = await createDavFixture(["caldav.full"]);
169 const calendarName = `bad-mkcol-${crypto.randomUUID()}`;
170 const created = await fetchWorker(
171 davRequest(fixture, `/calendars/${calendarName}/`, {
172 method: "MKCOL",
173 headers: { "content-type": "application/xml", depth: "1" },
174 body: [
175 '<?xml version="1.0"?>',
176 '<X:mkcol xmlns:X="urn:wrong" xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">',
177 "<D:set><D:prop><D:resourcetype><D:collection/><C:calendar/></D:resourcetype></D:prop></D:set>",
178 "</X:mkcol>",
179 ].join(""),
180 }),
181 );
182 
183 expect(created.status).toBe(400);
184 });
185});