Skip to content
File

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

typescript153 lines
1import { describe, expect, it } from "vitest";
2 
3import { CalendarResponseSchema, 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:C="urn:ietf:params:xml:ns:caldav"><D:prop>${props}</D:prop></D:propfind>`;
18}
19 
20describe("calendar JSON API", () => {
21 it("manages calendars and exposes API-created calendars over CalDAV", 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/calendars", { headers: { cookie: login.sessionCookie } }),
28 );
29 expect(listDefaultResponse.status).toBe(200);
30 const initialCalendars = CalendarResponseSchema.array().parse(await listDefaultResponse.json());
31 const defaultCalendar = initialCalendars.find((calendar) => calendar.name === "default");
32 expect(defaultCalendar).toBeDefined();
33 
34 const calendarName = `api-${crypto.randomUUID()}`;
35 const createResponse = await fetchWorker(
36 controlRequest("/api/v1/calendars", {
37 method: "POST",
38 headers: jsonHeaders(login.sessionCookie),
39 body: JSON.stringify({
40 name: calendarName,
41 display_name: "Team Calendar",
42 description: "Shared team calendar",
43 color: "#2277cc",
44 order_index: 20,
45 }),
46 }),
47 );
48 expect(createResponse.status).toBe(201);
49 const created = CalendarResponseSchema.parse(await createResponse.json());
50 expect(created).toMatchObject({
51 name: calendarName,
52 display_name: "Team Calendar",
53 description: "Shared team calendar",
54 href: `/calendars/${calendarName}/`,
55 color: "#2277cc",
56 order_index: 20,
57 });
58 
59 const duplicateResponse = await fetchWorker(
60 controlRequest("/api/v1/calendars", {
61 method: "POST",
62 headers: jsonHeaders(login.sessionCookie),
63 body: JSON.stringify({ name: calendarName }),
64 }),
65 );
66 expect(duplicateResponse.status).toBe(409);
67 
68 const patchResponse = await fetchWorker(
69 controlRequest(`/api/v1/calendars/${created.id}`, {
70 method: "PATCH",
71 headers: jsonHeaders(login.sessionCookie),
72 body: JSON.stringify({ display_name: "Renamed Calendar", description: null, order_index: 5 }),
73 }),
74 );
75 expect(patchResponse.status).toBe(200);
76 const patched = CalendarResponseSchema.parse(await patchResponse.json());
77 expect(patched).toMatchObject({
78 id: created.id,
79 name: calendarName,
80 display_name: "Renamed Calendar",
81 description: null,
82 order_index: 5,
83 });
84 expect(patched.sync_token).not.toBe(created.sync_token);
85 
86 const patResponse = await fetchWorker(
87 controlRequest("/api/v1/pats", {
88 method: "POST",
89 headers: jsonHeaders(login.sessionCookie),
90 body: JSON.stringify({ name: "caldav api", scopes: ["caldav.full"] }),
91 }),
92 );
93 expect(patResponse.status).toBe(201);
94 const pat = PatCreateResponseSchema.parse(await patResponse.json());
95 
96 const propfind = await fetchWorker(
97 subjectRequest(me.host_label, "/calendars/", {
98 method: "PROPFIND",
99 headers: {
100 authorization: basicPat(pat.token),
101 depth: "1",
102 "content-type": "application/xml",
103 },
104 body: propfindBody("<D:displayname/><D:resourcetype/>"),
105 }),
106 );
107 expect(propfind.status).toBe(207);
108 const xml = await propfind.text();
109 expect(xml).toContain(`/calendars/${calendarName}/`);
110 expect(xml).toContain("Renamed Calendar");
111 
112 const deleteDefaultResponse = await fetchWorker(
113 controlRequest(`/api/v1/calendars/${defaultCalendar!.id}`, {
114 method: "DELETE",
115 headers: {
116 cookie: login.sessionCookie,
117 origin: "https://dav.example.com",
118 },
119 }),
120 );
121 expect(deleteDefaultResponse.status).toBe(403);
122 
123 const deleteResponse = await fetchWorker(
124 controlRequest(`/api/v1/calendars/${created.id}`, {
125 method: "DELETE",
126 headers: {
127 cookie: login.sessionCookie,
128 origin: "https://dav.example.com",
129 },
130 }),
131 );
132 expect(deleteResponse.status).toBe(204);
133 
134 const detailAfterDelete = await fetchWorker(
135 controlRequest(`/api/v1/calendars/${created.id}`, { headers: { cookie: login.sessionCookie } }),
136 );
137 expect(detailAfterDelete.status).toBe(404);
138 });
139 
140 it("rejects calendar names that cannot be DAV path segments", async () => {
141 const login = await completeOidcLogin();
142 const response = await fetchWorker(
143 controlRequest("/api/v1/calendars", {
144 method: "POST",
145 headers: jsonHeaders(login.sessionCookie),
146 body: JSON.stringify({ name: "bad/name" }),
147 }),
148 );
149 
150 expect(response.status).toBe(400);
151 });
152});