File
Blob: tests/worker/protocol/dav/discovery.workers.test.ts
| 1 | import { describe, expect, it } from "vitest"; |
| 2 | |
| 3 | import { createDavFixture, davRequest } from "@tests/worker/helpers/dav"; |
| 4 | import { controlRequest, fetchWorker } from "@tests/worker/helpers/http"; |
| 5 | |
| 6 | function propfindBody(props: string): string { |
| 7 | return `<?xml version="1.0"?><D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CARD="urn:ietf:params:xml:ns:carddav"><D:prop>${props}</D:prop></D:propfind>`; |
| 8 | } |
| 9 | |
| 10 | function allpropIncludeBody(props: string): string { |
| 11 | return `<?xml version="1.0"?><D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CARD="urn:ietf:params:xml:ns:carddav"><D:allprop/><D:include>${props}</D:include></D:propfind>`; |
| 12 | } |
| 13 | |
| 14 | describe("DAV discovery", () => { |
| 15 | it("serves files-only principal discovery without exposing CalDAV or CardDAV homes", async () => { |
| 16 | const fixture = await createDavFixture(["files.readonly"]); |
| 17 | const response = await fetchWorker( |
| 18 | davRequest(fixture, "/", { |
| 19 | method: "PROPFIND", |
| 20 | headers: { depth: "0", "content-type": "application/xml" }, |
| 21 | body: propfindBody( |
| 22 | "<D:current-user-principal/><D:principal-URL/><D:current-user-privilege-set/><C:calendar-home-set/><CARD:addressbook-home-set/>", |
| 23 | ), |
| 24 | }), |
| 25 | ); |
| 26 | |
| 27 | expect(response.status).toBe(207); |
| 28 | const xml = await response.text(); |
| 29 | expect(xml).toContain("<D:href>/principals/me/</D:href>"); |
| 30 | expect(xml).toContain(`<D:href>/principals/${fixture.hostLabel}/</D:href>`); |
| 31 | expect(xml).toContain("<D:read/>"); |
| 32 | expect(xml).not.toContain("/calendars/"); |
| 33 | expect(xml).not.toContain("/addressbooks/"); |
| 34 | expect(xml).not.toContain(fixture.subjectId); |
| 35 | }); |
| 36 | |
| 37 | it("advertises CalDAV homes and read-only privileges to a read-only CalDAV PAT", async () => { |
| 38 | const fixture = await createDavFixture(["caldav.readonly"]); |
| 39 | const root = await fetchWorker( |
| 40 | davRequest(fixture, "/principals/me/", { |
| 41 | method: "PROPFIND", |
| 42 | headers: { depth: "0", "content-type": "application/xml" }, |
| 43 | body: propfindBody( |
| 44 | "<D:current-user-principal/><D:supported-privilege-set/><C:calendar-home-set/><C:calendar-user-address-set/><CARD:addressbook-home-set/>", |
| 45 | ), |
| 46 | }), |
| 47 | ); |
| 48 | |
| 49 | expect(root.status).toBe(207); |
| 50 | const rootXml = await root.text(); |
| 51 | expect(rootXml).toContain("/calendars/"); |
| 52 | expect(rootXml).toContain("calendar-user-address-set"); |
| 53 | expect(rootXml).toContain("read-current-user-privilege-set"); |
| 54 | expect(rootXml).not.toContain("/addressbooks/"); |
| 55 | |
| 56 | const calendarHome = await fetchWorker( |
| 57 | davRequest(fixture, "/calendars/", { |
| 58 | method: "PROPFIND", |
| 59 | headers: { depth: "1", "content-type": "application/xml" }, |
| 60 | body: propfindBody("<D:resourcetype/><D:current-user-privilege-set/><D:supported-privilege-set/>"), |
| 61 | }), |
| 62 | ); |
| 63 | expect(calendarHome.status).toBe(207); |
| 64 | const calendarXml = await calendarHome.text(); |
| 65 | expect(calendarXml).toContain("/calendars/default/"); |
| 66 | expect(calendarXml).toContain("<D:read/>"); |
| 67 | expect(calendarXml).toContain("read-current-user-privilege-set"); |
| 68 | expect(calendarXml).toContain( |
| 69 | "<D:current-user-privilege-set><D:privilege><D:read/></D:privilege><D:privilege><D:read-current-user-privilege-set/></D:privilege></D:current-user-privilege-set>", |
| 70 | ); |
| 71 | }); |
| 72 | |
| 73 | it("advertises CardDAV homes and write privileges to a full CardDAV PAT", async () => { |
| 74 | const fixture = await createDavFixture(["carddav.full"]); |
| 75 | const principal = await fetchWorker( |
| 76 | davRequest(fixture, `/principals/${fixture.hostLabel}/`, { |
| 77 | method: "PROPFIND", |
| 78 | headers: { depth: "0", "content-type": "application/xml" }, |
| 79 | body: propfindBody("<D:owner/><CARD:addressbook-home-set/>"), |
| 80 | }), |
| 81 | ); |
| 82 | |
| 83 | expect(principal.status).toBe(207); |
| 84 | const principalXml = await principal.text(); |
| 85 | expect(principalXml).toContain(`<D:href>/principals/${fixture.hostLabel}/</D:href>`); |
| 86 | expect(principalXml).toContain("/addressbooks/"); |
| 87 | |
| 88 | const addressbook = await fetchWorker( |
| 89 | davRequest(fixture, "/addressbooks/default/", { |
| 90 | method: "PROPFIND", |
| 91 | headers: { depth: "0", "content-type": "application/xml" }, |
| 92 | body: propfindBody("<D:resourcetype/><D:current-user-privilege-set/>"), |
| 93 | }), |
| 94 | ); |
| 95 | expect(addressbook.status).toBe(207); |
| 96 | const addressbookXml = await addressbook.text(); |
| 97 | expect(addressbookXml).toContain("<CARD:addressbook"); |
| 98 | expect(addressbookXml).toContain("<D:read/>"); |
| 99 | expect(addressbookXml).toContain("<D:write/>"); |
| 100 | }); |
| 101 | |
| 102 | it("honors allprop include properties on discovery resources", async () => { |
| 103 | const fixture = await createDavFixture(["files.readonly"]); |
| 104 | const response = await fetchWorker( |
| 105 | davRequest(fixture, "/", { |
| 106 | method: "PROPFIND", |
| 107 | headers: { depth: "0", "content-type": "application/xml" }, |
| 108 | body: allpropIncludeBody("<CARD:addressbook-home-set/>"), |
| 109 | }), |
| 110 | ); |
| 111 | |
| 112 | expect(response.status).toBe(207); |
| 113 | const xml = await response.text(); |
| 114 | expect(xml).toContain("addressbook-home-set"); |
| 115 | expect(xml).not.toContain("/addressbooks/"); |
| 116 | }); |
| 117 | |
| 118 | it("allows write-only scopes to discover the matching protocol home", async () => { |
| 119 | const fixture = await createDavFixture(["dav:caldav:write"]); |
| 120 | const principal = await fetchWorker( |
| 121 | davRequest(fixture, "/principals/me/", { |
| 122 | method: "PROPFIND", |
| 123 | headers: { depth: "0", "content-type": "application/xml" }, |
| 124 | body: propfindBody("<C:calendar-home-set/>"), |
| 125 | }), |
| 126 | ); |
| 127 | |
| 128 | expect(principal.status).toBe(207); |
| 129 | expect(await principal.text()).toContain("/calendars/"); |
| 130 | |
| 131 | const calendarHome = await fetchWorker( |
| 132 | davRequest(fixture, "/calendars/", { |
| 133 | method: "PROPFIND", |
| 134 | headers: { depth: "0", "content-type": "application/xml" }, |
| 135 | body: propfindBody("<D:current-user-privilege-set/>"), |
| 136 | }), |
| 137 | ); |
| 138 | |
| 139 | expect(calendarHome.status).toBe(207); |
| 140 | const calendarXml = await calendarHome.text(); |
| 141 | expect(calendarXml).toContain("<D:read/>"); |
| 142 | expect(calendarXml).toContain("<D:write/>"); |
| 143 | }); |
| 144 | |
| 145 | it("fails closed for unknown principal labels and control-plane DAV paths", async () => { |
| 146 | const fixture = await createDavFixture(["dav.full"]); |
| 147 | const unknownPrincipal = await fetchWorker( |
| 148 | davRequest(fixture, "/principals/not-the-label/", { |
| 149 | method: "PROPFIND", |
| 150 | headers: { "content-type": "application/xml" }, |
| 151 | body: propfindBody("<D:current-user-principal/>"), |
| 152 | }), |
| 153 | ); |
| 154 | expect(unknownPrincipal.status).toBe(404); |
| 155 | |
| 156 | const controlPlaneDav = await fetchWorker( |
| 157 | controlRequest("/principals/me/", { |
| 158 | method: "PROPFIND", |
| 159 | headers: { authorization: fixture.authHeader, "content-type": "application/xml" }, |
| 160 | body: propfindBody("<D:current-user-principal/>"), |
| 161 | }), |
| 162 | ); |
| 163 | expect(controlPlaneDav.status).toBe(404); |
| 164 | }); |
| 165 | |
| 166 | it("resolves subject hosts before well-known redirects", async () => { |
| 167 | const fixture = await createDavFixture(["dav.full"]); |
| 168 | const known = await fetchWorker(davRequest(fixture, "/.well-known/caldav", { redirect: "manual" })); |
| 169 | expect(known.status).toBe(302); |
| 170 | |
| 171 | const unknown = await fetchWorker( |
| 172 | new Request("https://river-copper-lantern-velvet-maple.dav.example.com/.well-known/caldav"), |
| 173 | ); |
| 174 | expect(unknown.status).toBe(404); |
| 175 | }); |
| 176 | |
| 177 | it("authenticates subject-host OPTIONS and enforces subject ownership", async () => { |
| 178 | const fixture = await createDavFixture(["dav.full"]); |
| 179 | |
| 180 | const unauthenticated = await fetchWorker( |
| 181 | new Request(`https://${fixture.hostLabel}.dav.example.com/files/`, { method: "OPTIONS" }), |
| 182 | ); |
| 183 | expect(unauthenticated.status).toBe(401); |
| 184 | |
| 185 | const unknownSubject = await fetchWorker( |
| 186 | new Request("https://river-copper-lantern-velvet-maple.dav.example.com/files/", { |
| 187 | method: "OPTIONS", |
| 188 | headers: { authorization: fixture.authHeader }, |
| 189 | }), |
| 190 | ); |
| 191 | expect(unknownSubject.status).toBe(404); |
| 192 | |
| 193 | const authenticated = await fetchWorker(davRequest(fixture, "/files/", { method: "OPTIONS" })); |
| 194 | expect(authenticated.status).toBe(204); |
| 195 | expect(authenticated.headers.get("allow")).toContain("PROPFIND"); |
| 196 | }); |
| 197 | |
| 198 | it("rejects insecure production DAV requests before Basic auth challenge", async () => { |
| 199 | const fixture = await createDavFixture(["dav.full"]); |
| 200 | const response = await fetchWorker(new Request(`http://${fixture.hostLabel}.dav.example.com/files/`)); |
| 201 | |
| 202 | expect(response.status).toBe(403); |
| 203 | expect(response.headers.has("www-authenticate")).toBe(false); |
| 204 | }); |
| 205 | }); |