Skip to content
File

Blob: tests/worker/protocol/dav/discovery.workers.test.ts

typescript206 lines
1import { describe, expect, it } from "vitest";
2 
3import { createDavFixture, davRequest } from "@tests/worker/helpers/dav";
4import { controlRequest, fetchWorker } from "@tests/worker/helpers/http";
5 
6function 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 
10function 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 
14describe("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});