import { describe, expect, it } from "vitest";
import { AddressbookResponseSchema, MeResponseSchema, PatCreateResponseSchema } from "@/worker/openapi/schemas";
import { completeOidcLogin } from "@tests/worker/helpers/auth";
import { basicPat } from "@tests/worker/helpers/dav";
import { controlRequest, fetchWorker, subjectRequest } from "@tests/worker/helpers/http";
function jsonHeaders(sessionCookie: string): HeadersInit {
return {
cookie: sessionCookie,
origin: "https://dav.example.com",
"content-type": "application/json",
};
}
function propfindBody(props: string): string {
return `${props}`;
}
describe("address book JSON API", () => {
it("manages address books and exposes API-created books over CardDAV", async () => {
const login = await completeOidcLogin();
const meResponse = await fetchWorker(controlRequest("/api/v1/me", { headers: { cookie: login.sessionCookie } }));
const me = MeResponseSchema.parse(await meResponse.json());
const listDefaultResponse = await fetchWorker(
controlRequest("/api/v1/addressbooks", { headers: { cookie: login.sessionCookie } }),
);
expect(listDefaultResponse.status).toBe(200);
const initialBooks = AddressbookResponseSchema.array().parse(await listDefaultResponse.json());
const defaultBook = initialBooks.find((book) => book.name === "default");
expect(defaultBook).toBeDefined();
const bookName = `api-${crypto.randomUUID()}`;
const createResponse = await fetchWorker(
controlRequest("/api/v1/addressbooks", {
method: "POST",
headers: jsonHeaders(login.sessionCookie),
body: JSON.stringify({
name: bookName,
display_name: "Team Contacts",
description: "Shared team address book",
}),
}),
);
expect(createResponse.status).toBe(201);
const created = AddressbookResponseSchema.parse(await createResponse.json());
expect(created).toMatchObject({
name: bookName,
display_name: "Team Contacts",
description: "Shared team address book",
href: `/addressbooks/${bookName}/`,
});
const duplicateResponse = await fetchWorker(
controlRequest("/api/v1/addressbooks", {
method: "POST",
headers: jsonHeaders(login.sessionCookie),
body: JSON.stringify({ name: bookName }),
}),
);
expect(duplicateResponse.status).toBe(409);
const patchResponse = await fetchWorker(
controlRequest(`/api/v1/addressbooks/${created.id}`, {
method: "PATCH",
headers: jsonHeaders(login.sessionCookie),
body: JSON.stringify({ display_name: "Renamed Contacts", description: null }),
}),
);
expect(patchResponse.status).toBe(200);
const patched = AddressbookResponseSchema.parse(await patchResponse.json());
expect(patched).toMatchObject({
id: created.id,
name: bookName,
display_name: "Renamed Contacts",
description: null,
});
expect(patched.sync_token).not.toBe(created.sync_token);
const patResponse = await fetchWorker(
controlRequest("/api/v1/pats", {
method: "POST",
headers: jsonHeaders(login.sessionCookie),
body: JSON.stringify({ name: "carddav api", scopes: ["carddav.full"] }),
}),
);
expect(patResponse.status).toBe(201);
const pat = PatCreateResponseSchema.parse(await patResponse.json());
const propfind = await fetchWorker(
subjectRequest(me.host_label, "/addressbooks/", {
method: "PROPFIND",
headers: {
authorization: basicPat(pat.token),
depth: "1",
"content-type": "application/xml",
},
body: propfindBody(""),
}),
);
expect(propfind.status).toBe(207);
const xml = await propfind.text();
expect(xml).toContain(`/addressbooks/${bookName}/`);
expect(xml).toContain("Renamed Contacts");
const deleteDefaultResponse = await fetchWorker(
controlRequest(`/api/v1/addressbooks/${defaultBook!.id}`, {
method: "DELETE",
headers: {
cookie: login.sessionCookie,
origin: "https://dav.example.com",
},
}),
);
expect(deleteDefaultResponse.status).toBe(403);
const deleteResponse = await fetchWorker(
controlRequest(`/api/v1/addressbooks/${created.id}`, {
method: "DELETE",
headers: {
cookie: login.sessionCookie,
origin: "https://dav.example.com",
},
}),
);
expect(deleteResponse.status).toBe(204);
const detailAfterDelete = await fetchWorker(
controlRequest(`/api/v1/addressbooks/${created.id}`, { headers: { cookie: login.sessionCookie } }),
);
expect(detailAfterDelete.status).toBe(404);
});
it("rejects address book names that cannot be DAV path segments", async () => {
const login = await completeOidcLogin();
const response = await fetchWorker(
controlRequest("/api/v1/addressbooks", {
method: "POST",
headers: jsonHeaders(login.sessionCookie),
body: JSON.stringify({ name: "bad/name" }),
}),
);
expect(response.status).toBe(400);
});
});