Skip to content
File

Blob: tests/worker/control/api/addressbooks.workers.test.ts

typescript148 lines
1import { describe, expect, it } from "vitest";
2 
3import { AddressbookResponseSchema, MeResponseSchema, PatCreateResponseSchema } from "@/worker/openapi/schemas";
4import { completeOidcLogin } from "@tests/worker/helpers/auth";
5import { basicPat } from "@tests/worker/helpers/dav";
6import { controlRequest, fetchWorker, subjectRequest } from "@tests/worker/helpers/http";
7 
8function jsonHeaders(sessionCookie: string): HeadersInit {
9 return {
10 cookie: sessionCookie,
11 origin: "https://dav.example.com",
12 "content-type": "application/json",
13 };
14}
15 
16function propfindBody(props: string): string {
17 return `<?xml version="1.0"?><D:propfind xmlns:D="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav"><D:prop>${props}</D:prop></D:propfind>`;
18}
19 
20describe("address book JSON API", () => {
21 it("manages address books and exposes API-created books over CardDAV", async () => {
22 const login = await completeOidcLogin();
23 const meResponse = await fetchWorker(controlRequest("/api/v1/me", { headers: { cookie: login.sessionCookie } }));
24 const me = MeResponseSchema.parse(await meResponse.json());
25 
26 const listDefaultResponse = await fetchWorker(
27 controlRequest("/api/v1/addressbooks", { headers: { cookie: login.sessionCookie } }),
28 );
29 expect(listDefaultResponse.status).toBe(200);
30 const initialBooks = AddressbookResponseSchema.array().parse(await listDefaultResponse.json());
31 const defaultBook = initialBooks.find((book) => book.name === "default");
32 expect(defaultBook).toBeDefined();
33 
34 const bookName = `api-${crypto.randomUUID()}`;
35 const createResponse = await fetchWorker(
36 controlRequest("/api/v1/addressbooks", {
37 method: "POST",
38 headers: jsonHeaders(login.sessionCookie),
39 body: JSON.stringify({
40 name: bookName,
41 display_name: "Team Contacts",
42 description: "Shared team address book",
43 }),
44 }),
45 );
46 expect(createResponse.status).toBe(201);
47 const created = AddressbookResponseSchema.parse(await createResponse.json());
48 expect(created).toMatchObject({
49 name: bookName,
50 display_name: "Team Contacts",
51 description: "Shared team address book",
52 href: `/addressbooks/${bookName}/`,
53 });
54 
55 const duplicateResponse = await fetchWorker(
56 controlRequest("/api/v1/addressbooks", {
57 method: "POST",
58 headers: jsonHeaders(login.sessionCookie),
59 body: JSON.stringify({ name: bookName }),
60 }),
61 );
62 expect(duplicateResponse.status).toBe(409);
63 
64 const patchResponse = await fetchWorker(
65 controlRequest(`/api/v1/addressbooks/${created.id}`, {
66 method: "PATCH",
67 headers: jsonHeaders(login.sessionCookie),
68 body: JSON.stringify({ display_name: "Renamed Contacts", description: null }),
69 }),
70 );
71 expect(patchResponse.status).toBe(200);
72 const patched = AddressbookResponseSchema.parse(await patchResponse.json());
73 expect(patched).toMatchObject({
74 id: created.id,
75 name: bookName,
76 display_name: "Renamed Contacts",
77 description: null,
78 });
79 expect(patched.sync_token).not.toBe(created.sync_token);
80 
81 const patResponse = await fetchWorker(
82 controlRequest("/api/v1/pats", {
83 method: "POST",
84 headers: jsonHeaders(login.sessionCookie),
85 body: JSON.stringify({ name: "carddav api", scopes: ["carddav.full"] }),
86 }),
87 );
88 expect(patResponse.status).toBe(201);
89 const pat = PatCreateResponseSchema.parse(await patResponse.json());
90 
91 const propfind = await fetchWorker(
92 subjectRequest(me.host_label, "/addressbooks/", {
93 method: "PROPFIND",
94 headers: {
95 authorization: basicPat(pat.token),
96 depth: "1",
97 "content-type": "application/xml",
98 },
99 body: propfindBody("<D:displayname/><D:resourcetype/>"),
100 }),
101 );
102 expect(propfind.status).toBe(207);
103 const xml = await propfind.text();
104 expect(xml).toContain(`/addressbooks/${bookName}/`);
105 expect(xml).toContain("Renamed Contacts");
106 
107 const deleteDefaultResponse = await fetchWorker(
108 controlRequest(`/api/v1/addressbooks/${defaultBook!.id}`, {
109 method: "DELETE",
110 headers: {
111 cookie: login.sessionCookie,
112 origin: "https://dav.example.com",
113 },
114 }),
115 );
116 expect(deleteDefaultResponse.status).toBe(403);
117 
118 const deleteResponse = await fetchWorker(
119 controlRequest(`/api/v1/addressbooks/${created.id}`, {
120 method: "DELETE",
121 headers: {
122 cookie: login.sessionCookie,
123 origin: "https://dav.example.com",
124 },
125 }),
126 );
127 expect(deleteResponse.status).toBe(204);
128 
129 const detailAfterDelete = await fetchWorker(
130 controlRequest(`/api/v1/addressbooks/${created.id}`, { headers: { cookie: login.sessionCookie } }),
131 );
132 expect(detailAfterDelete.status).toBe(404);
133 });
134 
135 it("rejects address book names that cannot be DAV path segments", async () => {
136 const login = await completeOidcLogin();
137 const response = await fetchWorker(
138 controlRequest("/api/v1/addressbooks", {
139 method: "POST",
140 headers: jsonHeaders(login.sessionCookie),
141 body: JSON.stringify({ name: "bad/name" }),
142 }),
143 );
144 
145 expect(response.status).toBe(400);
146 });
147});