File
Blob: test/repository-delete.worker.test.ts
| 1 | import { beforeAll, describe, expect, it } from "vitest"; |
| 2 | import { env } from "cloudflare:workers"; |
| 3 | |
| 4 | import { newPrefixedId, getRepoStub } from "@/worker/common"; |
| 5 | import { createDb } from "@/worker/db/d1/client"; |
| 6 | import { findPatGrantForRepo, insertPatWithGrants } from "@/worker/db/d1/dal"; |
| 7 | import { routeCacheKey } from "@/worker/repositories/routeCache"; |
| 8 | import { findRepositoryById } from "@/worker/db/d1/dal/repositories"; |
| 9 | import { generatePatPlaintext, hashPatPlaintext } from "@/worker/auth/pat"; |
| 10 | import { doPrefix } from "@/worker/keys"; |
| 11 | import type { RepositoryDeleteMessage } from "@/worker/tasks/queue"; |
| 12 | |
| 13 | import { ensureD1Migrations } from "./util/d1Setup"; |
| 14 | import { seedRepo, type SeededRepo } from "./util/repoSeed"; |
| 15 | import { runQueueMessage } from "./util/queue"; |
| 16 | |
| 17 | beforeAll(async () => { |
| 18 | await ensureD1Migrations(env); |
| 19 | }); |
| 20 | |
| 21 | function uniqueNs(): string { |
| 22 | return `rd-${Math.random().toString(36).slice(2, 8)}`; |
| 23 | } |
| 24 | |
| 25 | async function seedDeletable(): Promise<SeededRepo & { patId: string }> { |
| 26 | const s = await seedRepo(env, { |
| 27 | namespaceSlug: uniqueNs(), |
| 28 | repoSlug: "site", |
| 29 | visibility: "public", |
| 30 | doName: `repo:${newPrefixedId("repo").slice("repo_".length)}`, |
| 31 | }); |
| 32 | // Add a repo-scoped PAT grant so we can verify the cascade on D1 delete. |
| 33 | const db = createDb(env.DB); |
| 34 | const patId = newPrefixedId("pat"); |
| 35 | const generated = generatePatPlaintext(); |
| 36 | const hash = await hashPatPlaintext(generated.plaintext); |
| 37 | await insertPatWithGrants(db, { |
| 38 | pat: { |
| 39 | id: patId, |
| 40 | userId: s.userId, |
| 41 | name: "delete-test", |
| 42 | prefix: generated.publicPrefix, |
| 43 | hash, |
| 44 | createdAt: Date.now(), |
| 45 | expiresAt: null, |
| 46 | revokedAt: null, |
| 47 | lastUsedAt: null, |
| 48 | }, |
| 49 | namespaceGrants: [], |
| 50 | repoGrants: [{ patId, repoId: s.repositoryId, level: "push" }], |
| 51 | }); |
| 52 | return { ...s, patId }; |
| 53 | } |
| 54 | |
| 55 | function deleteMessage(s: SeededRepo): RepositoryDeleteMessage { |
| 56 | return { |
| 57 | kind: "repository-delete", |
| 58 | repositoryId: s.repositoryId, |
| 59 | namespaceId: s.namespaceId, |
| 60 | namespaceSlug: s.namespaceSlug, |
| 61 | repoSlug: s.repoSlug, |
| 62 | doName: s.doName, |
| 63 | actor: s.userId, |
| 64 | requestedAt: Date.now(), |
| 65 | }; |
| 66 | } |
| 67 | |
| 68 | async function r2HasObjectsForDoName(doName: string): Promise<boolean> { |
| 69 | const doId = env.REPO_DO.idFromName(doName).toString(); |
| 70 | const listing = await env.REPO_BUCKET.list({ prefix: doPrefix(doId) }); |
| 71 | return (listing.objects?.length ?? 0) > 0; |
| 72 | } |
| 73 | |
| 74 | describe("repository-delete consumer", () => { |
| 75 | it("intact state -> deletes D1 row, cascades repo grants, removes ROUTES, clears DO", async () => { |
| 76 | const s = await seedDeletable(); |
| 77 | // Touch DO storage so the consumer's clear path has something to remove. |
| 78 | const stub = getRepoStub(env, s.doName); |
| 79 | await stub.listRefs(); |
| 80 | const db = createDb(env.DB); |
| 81 | expect(await findRepositoryById(db, s.repositoryId)).toBeDefined(); |
| 82 | expect(await findPatGrantForRepo(db, s.patId, s.repositoryId)).toBeDefined(); |
| 83 | expect(await env.ROUTES.get(routeCacheKey(s.namespaceSlug, s.repoSlug))).not.toBeNull(); |
| 84 | |
| 85 | const result = await runQueueMessage(deleteMessage(s)); |
| 86 | expect(result.acked).toBe(true); |
| 87 | expect(await findRepositoryById(db, s.repositoryId)).toBeUndefined(); |
| 88 | expect(await findPatGrantForRepo(db, s.patId, s.repositoryId)).toBeUndefined(); |
| 89 | expect(await env.ROUTES.get(routeCacheKey(s.namespaceSlug, s.repoSlug))).toBeNull(); |
| 90 | expect(await r2HasObjectsForDoName(s.doName)).toBe(false); |
| 91 | }); |
| 92 | |
| 93 | it("replay after D1 row already gone is a clean no-op ack", async () => { |
| 94 | const s = await seedDeletable(); |
| 95 | const message = deleteMessage(s); |
| 96 | const first = await runQueueMessage(message); |
| 97 | expect(first.acked).toBe(true); |
| 98 | const second = await runQueueMessage(message); |
| 99 | expect(second.acked).toBe(true); |
| 100 | expect(second.retried).toBe(false); |
| 101 | }); |
| 102 | |
| 103 | it("R2 list failure -> retries the message; D1 already deleted from step 1", async () => { |
| 104 | const s = await seedDeletable(); |
| 105 | const failingEnv: Env = { |
| 106 | ...env, |
| 107 | REPO_BUCKET: { |
| 108 | ...env.REPO_BUCKET, |
| 109 | async list() { |
| 110 | throw new Error("simulated r2 outage"); |
| 111 | }, |
| 112 | } as R2Bucket, |
| 113 | } as Env; |
| 114 | const result = await runQueueMessage(deleteMessage(s), failingEnv); |
| 115 | expect(result.retried).toBe(true); |
| 116 | expect(result.acked).toBe(false); |
| 117 | const db = createDb(env.DB); |
| 118 | expect(await findRepositoryById(db, s.repositoryId)).toBeUndefined(); |
| 119 | }); |
| 120 | }); |