Skip to content
File

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

typescript197 lines
1import { describe, expect, it } from "vitest";
2 
3import { createDavFixture, davRequest } from "@tests/worker/helpers/dav";
4import { fetchWorker } from "@tests/worker/helpers/http";
5import { withEnvBinding } from "@tests/worker/helpers/env";
6import { event, mkcolCalendarBody, propfindSyncToken, syncCollectionBody, syncToken, uid } from "./helpers";
7 
8describe("CalDAV sync collection", () => {
9 it("accepts sync-collection REPORT with Depth 1 as Depth 0 compatibility", async () => {
10 const fixture = await createDavFixture(["caldav.full"]);
11 const response = await fetchWorker(
12 davRequest(fixture, "/calendars/default/", {
13 method: "REPORT",
14 headers: { "content-type": "application/xml", depth: "1" },
15 body: syncCollectionBody(""),
16 }),
17 );
18 expect(response.status).toBe(207);
19 expect(await response.text()).toContain("<D:sync-token>");
20 });
21 
22 it("rejects sync-collection REPORT with malformed Depth", async () => {
23 const fixture = await createDavFixture(["caldav.full"]);
24 const response = await fetchWorker(
25 davRequest(fixture, "/calendars/default/", {
26 method: "REPORT",
27 headers: { "content-type": "application/xml", depth: "garbage" },
28 body: syncCollectionBody(""),
29 }),
30 );
31 expect(response.status).toBe(400);
32 await expect(response.text()).resolves.toContain("REPORT Depth must be 0, 1, or infinity");
33 });
34 
35 it("returns requested properties for sync-collection current and changed members without stale replay", async () => {
36 const fixture = await createDavFixture(["caldav.full"]);
37 const eventUid = uid();
38 expect(
39 await fetchWorker(
40 davRequest(fixture, "/calendars/default/sync-props.ics", {
41 method: "PUT",
42 body: event({ uid: eventUid, summary: "Sync Props" }),
43 }),
44 ),
45 ).toMatchObject({ status: 201 });
46 
47 const initial = await fetchWorker(
48 davRequest(fixture, "/calendars/default/", {
49 method: "REPORT",
50 headers: { "content-type": "application/xml" },
51 body: syncCollectionBody("", "<D:getetag/><C:calendar-data/>"),
52 }),
53 );
54 expect(initial.status).toBe(207);
55 const initialXml = await initial.text();
56 expect(initialXml).toContain("/calendars/default/sync-props.ics");
57 expect(initialXml).toContain(`UID:${eventUid}`);
58 const token = /<D:sync-token>([^<]+)<\/D:sync-token>/.exec(initialXml)?.[1];
59 expect(token).toBeTruthy();
60 
61 const repeated = await fetchWorker(
62 davRequest(fixture, "/calendars/default/", {
63 method: "REPORT",
64 headers: { "content-type": "application/xml" },
65 body: syncCollectionBody(token!),
66 }),
67 );
68 expect(repeated.status).toBe(207);
69 expect((await repeated.text()).match(/<D:response>/g)?.length ?? 0).toBe(0);
70 
71 expect(
72 await fetchWorker(
73 davRequest(fixture, "/calendars/default/sync-props.ics", {
74 method: "PUT",
75 body: event({ uid: eventUid, summary: "Sync Props Updated" }),
76 }),
77 ),
78 ).toMatchObject({ status: 204 });
79 const changed = await fetchWorker(
80 davRequest(fixture, "/calendars/default/", {
81 method: "REPORT",
82 headers: { "content-type": "application/xml" },
83 body: syncCollectionBody(token!, "<D:getetag/><C:calendar-data/>"),
84 }),
85 );
86 expect(changed.status).toBe(207);
87 const changedXml = await changed.text();
88 expect(changedXml).toContain("/calendars/default/sync-props.ics");
89 expect(changedXml).toContain("Sync Props Updated");
90 const changedToken = /<D:sync-token>([^<]+)<\/D:sync-token>/.exec(changedXml)?.[1];
91 expect(changedToken).toBeTruthy();
92 
93 const otherCalendar = `other-${crypto.randomUUID()}`;
94 expect(
95 await fetchWorker(
96 davRequest(fixture, `/calendars/${otherCalendar}/`, {
97 method: "MKCOL",
98 headers: { "content-type": "application/xml" },
99 body: mkcolCalendarBody("Other"),
100 }),
101 ),
102 ).toMatchObject({ status: 201 });
103 expect(
104 await fetchWorker(
105 davRequest(fixture, `/calendars/${otherCalendar}/other.ics`, {
106 method: "PUT",
107 body: event({ uid: uid(), summary: "Other Calendar" }),
108 }),
109 ),
110 ).toMatchObject({ status: 201 });
111 
112 const afterOtherCalendarChange = await fetchWorker(
113 davRequest(fixture, "/calendars/default/", {
114 method: "REPORT",
115 headers: { "content-type": "application/xml" },
116 body: syncCollectionBody(changedToken!),
117 }),
118 );
119 expect(afterOtherCalendarChange.status).toBe(207);
120 expect((await afterOtherCalendarChange.text()).match(/<D:response>/g)?.length ?? 0).toBe(0);
121 
122 const propfindToken = await propfindSyncToken(fixture);
123 const syncFromPropfindToken = await fetchWorker(
124 davRequest(fixture, "/calendars/default/", {
125 method: "REPORT",
126 headers: { "content-type": "application/xml" },
127 body: syncCollectionBody(propfindToken),
128 }),
129 );
130 expect(syncFromPropfindToken.status).toBe(207);
131 expect((await syncFromPropfindToken.text()).match(/<D:response>/g)?.length ?? 0).toBe(0);
132 });
133 
134 it("reports sync-collection tombstones", async () => {
135 const fixture = await createDavFixture(["caldav.full"]);
136 const baselineToken = await syncToken(fixture);
137 expect(
138 await fetchWorker(
139 davRequest(fixture, "/calendars/default/sync.ics", {
140 method: "PUT",
141 body: event({ uid: uid(), summary: "Sync Me" }),
142 }),
143 ),
144 ).toMatchObject({ status: 201 });
145 expect(await fetchWorker(davRequest(fixture, "/calendars/default/sync.ics", { method: "DELETE" }))).toMatchObject({
146 status: 204,
147 });
148 
149 const sync = await fetchWorker(
150 davRequest(fixture, "/calendars/default/", {
151 method: "REPORT",
152 headers: { "content-type": "application/xml" },
153 body: syncCollectionBody(baselineToken),
154 }),
155 );
156 expect(sync.status).toBe(207);
157 const xml = await sync.text();
158 expect(xml).not.toContain(`<D:sync-token>${baselineToken}</D:sync-token>`);
159 expect(xml).toContain("/calendars/default/sync.ics");
160 expect(xml).toContain("HTTP/1.1 404 Not Found");
161 });
162 
163 it("marks truncated sync-collection responses with a 507 response", async () => {
164 const fixture = await createDavFixture(["caldav.full"]);
165 expect(
166 await fetchWorker(
167 davRequest(fixture, `/calendars/default/truncated-a-${crypto.randomUUID()}.ics`, {
168 method: "PUT",
169 body: event({ uid: uid(), summary: "Truncated A" }),
170 }),
171 ),
172 ).toMatchObject({ status: 201 });
173 expect(
174 await fetchWorker(
175 davRequest(fixture, `/calendars/default/truncated-b-${crypto.randomUUID()}.ics`, {
176 method: "PUT",
177 body: event({ uid: uid(), summary: "Truncated B" }),
178 }),
179 ),
180 ).toMatchObject({ status: 201 });
181 
182 await withEnvBinding("MAX_REPORT_RESULTS", "1", async () => {
183 const sync = await fetchWorker(
184 davRequest(fixture, "/calendars/default/", {
185 method: "REPORT",
186 headers: { "content-type": "application/xml" },
187 body: syncCollectionBody(""),
188 }),
189 );
190 expect(sync.status).toBe(207);
191 const xml = await sync.text();
192 expect(xml).toContain("HTTP/1.1 507 Insufficient Storage");
193 expect(xml).toContain("<D:sync-token>sync:0</D:sync-token>");
194 });
195 });
196});