Skip to content
File

Blob: tests/worker/protocol/carddav/addressbook-sync.workers.test.ts

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