Skip to content
File

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

typescript216 lines
1import { describe, expect, it } from "vitest";
2 
3import { createDavFixture, davRequest } from "@tests/worker/helpers/dav";
4import { fetchWorker } from "@tests/worker/helpers/http";
5import { propfindBody, reportBody, uid, vcard } from "./helpers";
6 
7describe("CardDAV reports", () => {
8 it("supports addressbook-query and addressbook-multiget", async () => {
9 const fixture = await createDavFixture(["carddav.full"]);
10 const aliceUid = uid();
11 const bobUid = uid();
12 expect(
13 await fetchWorker(
14 davRequest(fixture, "/addressbooks/default/alice.vcf", {
15 method: "PUT",
16 body: vcard({ uid: aliceUid, fn: "Alice Query", email: "alice-query@example.test" }),
17 }),
18 ),
19 ).toMatchObject({ status: 201 });
20 expect(
21 await fetchWorker(
22 davRequest(fixture, "/addressbooks/default/bob.vcf", {
23 method: "PUT",
24 body: vcard({ uid: bobUid, fn: "Bob Query", email: "bob-query@example.test" }),
25 }),
26 ),
27 ).toMatchObject({ status: 201 });
28 
29 const query = await fetchWorker(
30 davRequest(fixture, "/addressbooks/default/", {
31 method: "REPORT",
32 headers: { "content-type": "application/xml" },
33 body: reportBody(
34 "addressbook-query",
35 '<D:prop><D:getetag/><CARD:address-data/></D:prop><CARD:filter><CARD:prop-filter name="EMAIL"><CARD:text-match>alice-query</CARD:text-match></CARD:prop-filter></CARD:filter>',
36 ),
37 }),
38 );
39 expect(query.status).toBe(207);
40 const queryXml = await query.text();
41 expect(queryXml).toContain("/addressbooks/default/alice.vcf");
42 expect(queryXml).not.toContain("/addressbooks/default/bob.vcf");
43 expect(queryXml).toContain(`UID:${aliceUid}`);
44 
45 const multiget = await fetchWorker(
46 davRequest(fixture, "/addressbooks/default/", {
47 method: "REPORT",
48 headers: { "content-type": "application/xml" },
49 body: reportBody(
50 "addressbook-multiget",
51 "<D:prop><D:getetag/><CARD:address-data/></D:prop><D:href>/addressbooks/default/bob.vcf</D:href><D:href>/addressbooks/default/missing.vcf</D:href>",
52 ),
53 }),
54 );
55 expect(multiget.status).toBe(207);
56 const multigetXml = await multiget.text();
57 expect((multigetXml.match(/<D:response>/g) ?? []).length).toBe(2);
58 expect(multigetXml).toContain("/addressbooks/default/bob.vcf");
59 expect(multigetXml).toContain(`UID:${bobUid}`);
60 expect(multigetXml).toContain("/addressbooks/default/missing.vcf");
61 expect(multigetXml).toContain("HTTP/1.1 404 Not Found");
62 });
63 
64 it("matches grouped vCard EMAIL and TEL properties", async () => {
65 const fixture = await createDavFixture(["carddav.full"]);
66 const contactUid = uid();
67 const grouped = [
68 "BEGIN:VCARD",
69 "VERSION:4.0",
70 `UID:${contactUid}`,
71 "FN:Grouped Contact",
72 "N:Contact;Grouped;;;",
73 "item1.EMAIL:grouped@example.test",
74 "item2.TEL:+15555550100",
75 "END:VCARD",
76 "",
77 ].join("\r\n");
78 expect(
79 await fetchWorker(
80 davRequest(fixture, "/addressbooks/default/grouped.vcf", {
81 method: "PUT",
82 body: grouped,
83 }),
84 ),
85 ).toMatchObject({ status: 201 });
86 
87 const emailQuery = await fetchWorker(
88 davRequest(fixture, "/addressbooks/default/", {
89 method: "REPORT",
90 headers: { "content-type": "application/xml" },
91 body: reportBody(
92 "addressbook-query",
93 '<D:prop><D:getetag/></D:prop><CARD:filter><CARD:prop-filter name="EMAIL"><CARD:text-match>grouped@example</CARD:text-match></CARD:prop-filter></CARD:filter>',
94 ),
95 }),
96 );
97 expect(emailQuery.status).toBe(207);
98 expect(await emailQuery.text()).toContain("/addressbooks/default/grouped.vcf");
99 
100 const telQuery = await fetchWorker(
101 davRequest(fixture, "/addressbooks/default/", {
102 method: "REPORT",
103 headers: { "content-type": "application/xml" },
104 body: reportBody(
105 "addressbook-query",
106 '<D:prop><D:getetag/></D:prop><CARD:filter><CARD:prop-filter name="TEL"><CARD:text-match>5550100</CARD:text-match></CARD:prop-filter></CARD:filter>',
107 ),
108 }),
109 );
110 expect(telQuery.status).toBe(207);
111 expect(await telQuery.text()).toContain("/addressbooks/default/grouped.vcf");
112 });
113 
114 it("does not return address-data from PROPFIND but still returns it from REPORT", async () => {
115 const fixture = await createDavFixture(["carddav.full"]);
116 const contactUid = uid();
117 expect(
118 await fetchWorker(
119 davRequest(fixture, "/addressbooks/default/no-propfind-data.vcf", {
120 method: "PUT",
121 body: vcard({ uid: contactUid, fn: "No PROPFIND Data" }),
122 }),
123 ),
124 ).toMatchObject({ status: 201 });
125 
126 const propfind = await fetchWorker(
127 davRequest(fixture, "/addressbooks/default/no-propfind-data.vcf", {
128 method: "PROPFIND",
129 headers: { depth: "0", "content-type": "application/xml" },
130 body: propfindBody("<CARD:address-data/>"),
131 }),
132 );
133 expect(propfind.status).toBe(207);
134 expect(await propfind.text()).toContain("HTTP/1.1 404 Not Found");
135 
136 const report = await fetchWorker(
137 davRequest(fixture, "/addressbooks/default/", {
138 method: "REPORT",
139 headers: { "content-type": "application/xml" },
140 body: reportBody("addressbook-query", "<D:prop><CARD:address-data/></D:prop><CARD:filter/>"),
141 }),
142 );
143 expect(report.status).toBe(207);
144 expect(await report.text()).toContain(`UID:${contactUid}`);
145 });
146 
147 it("keeps addressbook-query Depth scoped and treats missing Depth as compatibility Depth 1", async () => {
148 const fixture = await createDavFixture(["carddav.full"]);
149 expect(
150 await fetchWorker(
151 davRequest(fixture, "/addressbooks/default/depth.vcf", {
152 method: "PUT",
153 body: vcard({ uid: uid(), fn: "Depth Query" }),
154 }),
155 ),
156 ).toMatchObject({ status: 201 });
157 const body = reportBody("addressbook-query", "<D:prop><D:getetag/></D:prop><CARD:filter/>");
158 
159 const missingDepth = await fetchWorker(
160 davRequest(fixture, "/addressbooks/default/", {
161 method: "REPORT",
162 headers: { "content-type": "application/xml" },
163 body,
164 }),
165 );
166 expect(missingDepth.status).toBe(207);
167 expect(await missingDepth.text()).toContain("/addressbooks/default/depth.vcf");
168 
169 const depth0 = await fetchWorker(
170 davRequest(fixture, "/addressbooks/default/", {
171 method: "REPORT",
172 headers: { "content-type": "application/xml", depth: "0" },
173 body,
174 }),
175 );
176 expect(depth0.status).toBe(207);
177 expect(await depth0.text()).not.toContain("/addressbooks/default/depth.vcf");
178 });
179 
180 it("rejects REPORT elements from the wrong namespace and returns per-property statuses", async () => {
181 const fixture = await createDavFixture(["carddav.full"]);
182 const wrongNamespace = await fetchWorker(
183 davRequest(fixture, "/addressbooks/default/", {
184 method: "REPORT",
185 headers: { "content-type": "application/xml" },
186 body: '<?xml version="1.0"?><X:addressbook-query xmlns:X="urn:wrong"><D:prop xmlns:D="DAV:"><D:getetag/></D:prop></X:addressbook-query>',
187 }),
188 );
189 expect(wrongNamespace.status).toBe(400);
190 
191 expect(
192 await fetchWorker(
193 davRequest(fixture, "/addressbooks/default/unknown-prop.vcf", {
194 method: "PUT",
195 body: vcard({ uid: uid(), fn: "Unknown Prop" }),
196 }),
197 ),
198 ).toMatchObject({ status: 201 });
199 const unknownProp = await fetchWorker(
200 davRequest(fixture, "/addressbooks/default/", {
201 method: "REPORT",
202 headers: { "content-type": "application/xml" },
203 body: reportBody(
204 "addressbook-query",
205 '<D:prop><D:getetag/><X:unknown xmlns:X="urn:example"/></D:prop><CARD:filter/>',
206 ),
207 }),
208 );
209 expect(unknownProp.status).toBe(207);
210 const xml = await unknownProp.text();
211 expect(xml).toContain("<D:getetag>");
212 expect(xml).toContain("HTTP/1.1 404 Not Found");
213 expect(xml).toContain("unknown");
214 });
215});