Skip to content
File

Blob: test/repository-delete.worker.test.ts

typescript121 lines
1import { beforeAll, describe, expect, it } from "vitest";
2import { env } from "cloudflare:workers";
3 
4import { newPrefixedId, getRepoStub } from "@/worker/common";
5import { createDb } from "@/worker/db/d1/client";
6import { findPatGrantForRepo, insertPatWithGrants } from "@/worker/db/d1/dal";
7import { routeCacheKey } from "@/worker/repositories/routeCache";
8import { findRepositoryById } from "@/worker/db/d1/dal/repositories";
9import { generatePatPlaintext, hashPatPlaintext } from "@/worker/auth/pat";
10import { doPrefix } from "@/worker/keys";
11import type { RepositoryDeleteMessage } from "@/worker/tasks/queue";
12 
13import { ensureD1Migrations } from "./util/d1Setup";
14import { seedRepo, type SeededRepo } from "./util/repoSeed";
15import { runQueueMessage } from "./util/queue";
16 
17beforeAll(async () => {
18 await ensureD1Migrations(env);
19});
20 
21function uniqueNs(): string {
22 return `rd-${Math.random().toString(36).slice(2, 8)}`;
23}
24 
25async 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 
55function 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 
68async 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 
74describe("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});