File
Blob: test/pat-verify.worker.test.ts
| 1 | import { applyD1Migrations } from "cloudflare:test"; |
| 2 | import { env } from "cloudflare:workers"; |
| 3 | import { beforeAll, beforeEach, describe, expect, it } from "vitest"; |
| 4 | |
| 5 | import { createDb } from "@/worker/db/d1/client"; |
| 6 | import { generatePatPlaintext, hashPatPlaintext, verifyPat } from "@/worker/auth/pat"; |
| 7 | import { |
| 8 | insertMembershipIfMissing, |
| 9 | insertPatWithGrants, |
| 10 | insertRepositoryIfNew, |
| 11 | insertUserIfNew, |
| 12 | } from "@/worker/db/d1/dal"; |
| 13 | import { claimNamespace } from "@/worker/db/d1/dal/namespaces"; |
| 14 | |
| 15 | import { readAppD1Migrations } from "./util/d1Migrations"; |
| 16 | |
| 17 | beforeAll(async () => { |
| 18 | await applyD1Migrations(env.DB, readAppD1Migrations()); |
| 19 | }); |
| 20 | |
| 21 | const USER_ID = "user-pv"; |
| 22 | const NAMESPACE_ID = "ns-pv"; |
| 23 | const NAMESPACE_SLUG = "verify-rachel"; |
| 24 | const REPO_ID = "repo-pv"; |
| 25 | const REPO_SLUG = "site"; |
| 26 | const DO_NAME = "verify-rachel/site"; |
| 27 | |
| 28 | beforeEach(async () => { |
| 29 | const db = createDb(env.DB); |
| 30 | const now = Date.now(); |
| 31 | await insertUserIfNew(db, { id: USER_ID, tesseraSub: "sub-pv", createdAt: now }); |
| 32 | await claimNamespace(db, { |
| 33 | id: NAMESPACE_ID, |
| 34 | slug: NAMESPACE_SLUG, |
| 35 | createdBy: USER_ID, |
| 36 | createdAt: now, |
| 37 | }); |
| 38 | await insertMembershipIfMissing(db, { |
| 39 | namespaceId: NAMESPACE_ID, |
| 40 | userId: USER_ID, |
| 41 | createdAt: now, |
| 42 | }); |
| 43 | await insertRepositoryIfNew(db, { |
| 44 | id: REPO_ID, |
| 45 | namespaceId: NAMESPACE_ID, |
| 46 | createdBy: USER_ID, |
| 47 | slug: REPO_SLUG, |
| 48 | doName: DO_NAME, |
| 49 | visibility: "public", |
| 50 | createdAt: now, |
| 51 | updatedAt: now, |
| 52 | }); |
| 53 | }); |
| 54 | |
| 55 | async function seedPat(args: { |
| 56 | patId: string; |
| 57 | namespaceGrants?: Array<{ namespaceId: string; level: "pull" | "push" }>; |
| 58 | repoGrants?: Array<{ repoId: string; level: "pull" | "push" }>; |
| 59 | revokedAt?: number; |
| 60 | expiresAt?: number; |
| 61 | }): Promise<string> { |
| 62 | const db = createDb(env.DB); |
| 63 | const generated = generatePatPlaintext(); |
| 64 | const hash = await hashPatPlaintext(generated.plaintext); |
| 65 | await insertPatWithGrants(db, { |
| 66 | pat: { |
| 67 | id: args.patId, |
| 68 | userId: USER_ID, |
| 69 | name: "ci", |
| 70 | prefix: generated.publicPrefix, |
| 71 | hash, |
| 72 | createdAt: Date.now(), |
| 73 | expiresAt: args.expiresAt ?? null, |
| 74 | revokedAt: args.revokedAt ?? null, |
| 75 | lastUsedAt: null, |
| 76 | }, |
| 77 | namespaceGrants: args.namespaceGrants?.map((g) => ({ patId: args.patId, ...g })) ?? [], |
| 78 | repoGrants: args.repoGrants?.map((g) => ({ patId: args.patId, ...g })) ?? [], |
| 79 | }); |
| 80 | return generated.plaintext; |
| 81 | } |
| 82 | |
| 83 | describe("verifyPat", () => { |
| 84 | it("ok via repo grant returns level=push when granted", async () => { |
| 85 | const plaintext = await seedPat({ |
| 86 | patId: "pat-repo-ok", |
| 87 | repoGrants: [{ repoId: REPO_ID, level: "push" }], |
| 88 | }); |
| 89 | const result = await verifyPat(env, { |
| 90 | username: NAMESPACE_SLUG, |
| 91 | plaintext, |
| 92 | namespaceId: NAMESPACE_ID, |
| 93 | repositoryId: REPO_ID, |
| 94 | }); |
| 95 | expect(result.ok).toBe(true); |
| 96 | if (!result.ok) return; |
| 97 | expect(result.repositoryId).toBe(REPO_ID); |
| 98 | expect(result.level).toBe("push"); |
| 99 | }); |
| 100 | |
| 101 | it("ok via namespace grant returns level=pull for pull-only", async () => { |
| 102 | const plaintext = await seedPat({ |
| 103 | patId: "pat-ns-ok", |
| 104 | namespaceGrants: [{ namespaceId: NAMESPACE_ID, level: "pull" }], |
| 105 | }); |
| 106 | const result = await verifyPat(env, { |
| 107 | username: NAMESPACE_SLUG, |
| 108 | plaintext, |
| 109 | namespaceId: NAMESPACE_ID, |
| 110 | repositoryId: REPO_ID, |
| 111 | }); |
| 112 | expect(result.ok).toBe(true); |
| 113 | if (!result.ok) return; |
| 114 | expect(result.level).toBe("pull"); |
| 115 | expect(result.repositoryId).toBe(REPO_ID); |
| 116 | }); |
| 117 | |
| 118 | it("returns malformed for an obviously bad token", async () => { |
| 119 | expect(await verifyPat(env, { username: NAMESPACE_SLUG, plaintext: "" })).toEqual({ |
| 120 | ok: false, |
| 121 | reason: "malformed", |
| 122 | }); |
| 123 | }); |
| 124 | |
| 125 | it("returns token-not-found for a well-formed token with no DB row", async () => { |
| 126 | const orphan = generatePatPlaintext(); |
| 127 | expect(await verifyPat(env, { username: NAMESPACE_SLUG, plaintext: orphan.plaintext })).toEqual( |
| 128 | { ok: false, reason: "token-not-found" } |
| 129 | ); |
| 130 | }); |
| 131 | |
| 132 | it("returns token-revoked when the PAT is marked revoked", async () => { |
| 133 | const plaintext = await seedPat({ |
| 134 | patId: "pat-revoked", |
| 135 | namespaceGrants: [{ namespaceId: NAMESPACE_ID, level: "pull" }], |
| 136 | revokedAt: Date.now() - 10, |
| 137 | }); |
| 138 | expect(await verifyPat(env, { username: NAMESPACE_SLUG, plaintext })).toEqual({ |
| 139 | ok: false, |
| 140 | reason: "token-revoked", |
| 141 | }); |
| 142 | }); |
| 143 | |
| 144 | it("returns token-expired when expires_at is in the past", async () => { |
| 145 | const plaintext = await seedPat({ |
| 146 | patId: "pat-expired", |
| 147 | namespaceGrants: [{ namespaceId: NAMESPACE_ID, level: "pull" }], |
| 148 | expiresAt: Date.now() - 10, |
| 149 | }); |
| 150 | expect(await verifyPat(env, { username: NAMESPACE_SLUG, plaintext })).toEqual({ |
| 151 | ok: false, |
| 152 | reason: "token-expired", |
| 153 | }); |
| 154 | }); |
| 155 | |
| 156 | it("returns username-mismatch when the username segment does not match the namespace", async () => { |
| 157 | const plaintext = await seedPat({ |
| 158 | patId: "pat-username-mismatch", |
| 159 | namespaceGrants: [{ namespaceId: NAMESPACE_ID, level: "pull" }], |
| 160 | }); |
| 161 | expect( |
| 162 | await verifyPat(env, { |
| 163 | username: "someone-else", |
| 164 | plaintext, |
| 165 | namespaceId: NAMESPACE_ID, |
| 166 | repositoryId: REPO_ID, |
| 167 | }) |
| 168 | ).toEqual({ ok: false, reason: "username-mismatch" }); |
| 169 | }); |
| 170 | |
| 171 | it("returns grant-missing when no grant covers the requested resource", async () => { |
| 172 | const plaintext = await seedPat({ patId: "pat-no-grant" }); |
| 173 | expect( |
| 174 | await verifyPat(env, { |
| 175 | username: NAMESPACE_SLUG, |
| 176 | plaintext, |
| 177 | namespaceId: NAMESPACE_ID, |
| 178 | repositoryId: REPO_ID, |
| 179 | }) |
| 180 | ).toEqual({ ok: false, reason: "grant-missing" }); |
| 181 | }); |
| 182 | }); |