File
Blob: tests/worker/control/auth/pats.workers.test.ts
| 1 | import { env } from "cloudflare:workers"; |
| 2 | import { describe, expect, it } from "vitest"; |
| 3 | |
| 4 | import { createControlPlaneDb } from "@/worker/db/d1/client"; |
| 5 | import { bootstrapSubject } from "@/worker/db/d1/repository"; |
| 6 | import { MeResponseSchema, PatCreateResponseSchema, PatResponseSchema } from "@/worker/openapi/schemas"; |
| 7 | import { completeOidcLogin } from "@tests/worker/helpers/auth"; |
| 8 | import { basicPat } from "@tests/worker/helpers/dav"; |
| 9 | import { controlRequest, fetchWorker, subjectRequest } from "@tests/worker/helpers/http"; |
| 10 | import type { z } from "zod"; |
| 11 | |
| 12 | type PatCreateResponse = z.infer<typeof PatCreateResponseSchema>; |
| 13 | |
| 14 | async 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 | |
| 34 | describe("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 | }); |