File
Blob: tests/worker/protocol/carddav/addressbook-reports.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 { propfindBody, reportBody, uid, vcard } from "./helpers"; |
| 6 | |
| 7 | describe("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 | }); |