Skip to content
File

Blob: tests/worker/control/auth/pats.workers.test.ts

typescript173 lines
1import { env } from "cloudflare:workers";
2import { describe, expect, it } from "vitest";
3 
4import { createControlPlaneDb } from "@/worker/db/d1/client";
5import { bootstrapSubject } from "@/worker/db/d1/repository";
6import { MeResponseSchema, PatCreateResponseSchema, PatResponseSchema } from "@/worker/openapi/schemas";
7import { completeOidcLogin } from "@tests/worker/helpers/auth";
8import { basicPat } from "@tests/worker/helpers/dav";
9import { controlRequest, fetchWorker, subjectRequest } from "@tests/worker/helpers/http";
10import type { z } from "zod";
11 
12type PatCreateResponse = z.infer<typeof PatCreateResponseSchema>;
13 
14async function createPat(sessionCookie: string, input: Record<string, unknown> = {}): Promise<PatCreateResponse> {
15 const response = await fetchWorker(
16 controlRequest("/api/v1/pats", {
17 method: "POST",
18 headers: {
19 cookie: sessionCookie,
20 origin: "https://dav.example.com",
21 "content-type": "application/json",
22 },
23 body: JSON.stringify({
24 name: "test token",
25 scopes: ["files.full"],
26 ...input,
27 }),
28 }),
29 );
30 expect(response.status).toBe(201);
31 return PatCreateResponseSchema.parse(await response.json());
32}
33 
34describe("Personal Access Tokens", () => {
35 it("returns PAT plaintext only on create, never on list or detail", async () => {
36 const login = await completeOidcLogin();
37 const created = await createPat(login.sessionCookie);
38 
39 expect(created.token).toMatch(/^dab_pat_[0-9a-f]{24,}_[a-z2-7]{52,}$/);
40 
41 const listResponse = await fetchWorker(
42 controlRequest("/api/v1/pats", { headers: { cookie: login.sessionCookie } }),
43 );
44 expect(listResponse.status).toBe(200);
45 const list = PatResponseSchema.array().parse(await listResponse.json());
46 const listed = list.find((row) => row.id === created.id);
47 expect(listed).toBeDefined();
48 expect(listed).not.toHaveProperty("token");
49 
50 const detailResponse = await fetchWorker(
51 controlRequest(`/api/v1/pats/${created.id}`, { headers: { cookie: login.sessionCookie } }),
52 );
53 expect(detailResponse.status).toBe(200);
54 const detail = PatResponseSchema.parse(await detailResponse.json());
55 expect(detail.id).toBe(created.id);
56 expect(detail).not.toHaveProperty("token");
57 });
58 
59 it("authenticates subject-host DAV requests through AUTH and records last use", async () => {
60 const login = await completeOidcLogin();
61 const meResponse = await fetchWorker(controlRequest("/api/v1/me", { headers: { cookie: login.sessionCookie } }));
62 const me = MeResponseSchema.parse(await meResponse.json());
63 const pat = await createPat(login.sessionCookie);
64 expect(pat.token).toBeDefined();
65 
66 const davResponse = await fetchWorker(
67 subjectRequest(me.host_label, "/files/", {
68 method: "PROPFIND",
69 headers: { authorization: basicPat(pat.token!), depth: "0" },
70 }),
71 );
72 expect(davResponse.status).toBe(207);
73 
74 const detailResponse = await fetchWorker(
75 controlRequest(`/api/v1/pats/${pat.id}`, { headers: { cookie: login.sessionCookie } }),
76 );
77 const detail = PatResponseSchema.parse(await detailResponse.json());
78 expect(detail.last_used_at).toEqual(expect.any(String));
79 });
80 
81 it("fails closed for malformed, revoked, expired, and wrong-subject PATs", async () => {
82 const login = await completeOidcLogin();
83 const meResponse = await fetchWorker(controlRequest("/api/v1/me", { headers: { cookie: login.sessionCookie } }));
84 const me = MeResponseSchema.parse(await meResponse.json());
85 
86 const malformed = await fetchWorker(
87 subjectRequest(me.host_label, "/files/", {
88 headers: { authorization: basicPat("dab_pat_bad") },
89 }),
90 );
91 expect(malformed.status).toBe(401);
92 
93 const revoked = await createPat(login.sessionCookie);
94 const revokeResponse = await fetchWorker(
95 controlRequest(`/api/v1/pats/${revoked.id}`, {
96 method: "DELETE",
97 headers: {
98 cookie: login.sessionCookie,
99 origin: "https://dav.example.com",
100 },
101 }),
102 );
103 expect(revokeResponse.status).toBe(204);
104 const revokedAuth = await fetchWorker(
105 subjectRequest(me.host_label, "/files/", {
106 headers: { authorization: basicPat(revoked.token!) },
107 }),
108 );
109 expect(revokedAuth.status).toBe(401);
110 
111 const expired = await createPat(login.sessionCookie, { expires_at: "2000-01-01T00:00:00.000Z" });
112 const expiredAuth = await fetchWorker(
113 subjectRequest(me.host_label, "/files/", {
114 headers: { authorization: basicPat(expired.token!) },
115 }),
116 );
117 expect(expiredAuth.status).toBe(401);
118 
119 const other = await bootstrapSubject(createControlPlaneDb(env.DAV_CONTROL_PLANE), {
120 sub: "other-subject-for-pat-test",
121 email: "other@example.test",
122 displayName: "Other Subject",
123 });
124 const wrongSubject = await createPat(login.sessionCookie);
125 const wrongSubjectAuth = await fetchWorker(
126 subjectRequest(other.subject.hostLabel, "/files/", {
127 headers: { authorization: basicPat(wrongSubject.token!) },
128 }),
129 );
130 expect(wrongSubjectAuth.status).toBe(401);
131 });
132 
133 it("updates and revokes PAT projections through same-origin API routes", async () => {
134 const login = await completeOidcLogin();
135 const pat = await createPat(login.sessionCookie);
136 
137 const patchResponse = await fetchWorker(
138 controlRequest(`/api/v1/pats/${pat.id}`, {
139 method: "PATCH",
140 headers: {
141 cookie: login.sessionCookie,
142 origin: "https://dav.example.com",
143 "content-type": "application/json",
144 },
145 body: JSON.stringify({ name: "renamed token", expires_at: null }),
146 }),
147 );
148 expect(patchResponse.status).toBe(200);
149 expect(PatResponseSchema.parse(await patchResponse.json())).toMatchObject({
150 id: pat.id,
151 name: "renamed token",
152 expires_at: null,
153 });
154 
155 const revokeResponse = await fetchWorker(
156 controlRequest(`/api/v1/pats/${pat.id}`, {
157 method: "DELETE",
158 headers: {
159 cookie: login.sessionCookie,
160 origin: "https://dav.example.com",
161 },
162 }),
163 );
164 expect(revokeResponse.status).toBe(204);
165 
166 const detailResponse = await fetchWorker(
167 controlRequest(`/api/v1/pats/${pat.id}`, { headers: { cookie: login.sessionCookie } }),
168 );
169 const detail = PatResponseSchema.parse(await detailResponse.json());
170 expect(detail.revoked_at).toEqual(expect.any(String));
171 });
172});