File
Blob: test/util/repoSeed.ts
| 1 | import { newPrefixedId } from "@/worker/common"; |
| 2 | import { SESSION_COOKIE_HEADER_NAME } from "@/worker/auth/cookies"; |
| 3 | import { generatePatPlaintext, hashPatPlaintext } from "@/worker/auth/pat"; |
| 4 | import { __test as sessionTest } from "@/worker/auth/session"; |
| 5 | import { createDb } from "@/worker/db/d1/client"; |
| 6 | import { |
| 7 | claimNamespace, |
| 8 | findNamespaceBySlug, |
| 9 | findRepositoryByNamespaceAndSlug, |
| 10 | insertMembershipIfMissing, |
| 11 | insertPatWithGrants, |
| 12 | insertRepositoryIfNew, |
| 13 | insertUserIfNew, |
| 14 | } from "@/worker/db/d1/dal"; |
| 15 | import { putRouteCacheRecord, routeCacheKey } from "@/worker/repositories/routeCache"; |
| 16 | |
| 17 | import { ensureD1Migrations } from "./d1Setup"; |
| 18 | |
| 19 | // Seed a single user, namespace, membership, and repository row plus the |
| 20 | // route-cache KV record. Idempotent: re-runs against an existing namespace |
| 21 | // reuse it, and re-runs against an existing repo return its canonical id. |
| 22 | // |
| 23 | // Default `doName` follows the legacy `<namespaceSlug>/<repoSlug>` shape so |
| 24 | // existing fetch/push/UI tests can reuse their `uniqueRepoId(...)` patterns |
| 25 | // unchanged. Pass `doName: "repo:<id>"` to exercise the new-repo path. |
| 26 | |
| 27 | export type SeedRepoArgs = { |
| 28 | // Optional ids; tests usually let the helper generate them. |
| 29 | userId?: string; |
| 30 | namespaceId?: string; |
| 31 | repositoryId?: string; |
| 32 | // Logical inputs. |
| 33 | tesseraSub?: string; |
| 34 | namespaceSlug: string; |
| 35 | repoSlug: string; |
| 36 | visibility?: "public" | "private"; |
| 37 | doName?: string; |
| 38 | // Skip the ROUTES KV write (some tests want to assert the resolver's D1 |
| 39 | // fallback without a KV candidate). |
| 40 | skipRouteCache?: boolean; |
| 41 | }; |
| 42 | |
| 43 | export type SeededRepo = { |
| 44 | userId: string; |
| 45 | namespaceId: string; |
| 46 | repositoryId: string; |
| 47 | doName: string; |
| 48 | namespaceSlug: string; |
| 49 | repoSlug: string; |
| 50 | visibility: "public" | "private"; |
| 51 | routeCacheKey: string; |
| 52 | }; |
| 53 | |
| 54 | export async function seedRepo(env: Env, args: SeedRepoArgs): Promise<SeededRepo> { |
| 55 | const db = createDb(env.DB); |
| 56 | const now = Date.now(); |
| 57 | const visibility = args.visibility ?? "public"; |
| 58 | const doName = args.doName ?? `${args.namespaceSlug}/${args.repoSlug}`; |
| 59 | |
| 60 | const userId = args.userId ?? newPrefixedId("user"); |
| 61 | await insertUserIfNew(db, { |
| 62 | id: userId, |
| 63 | tesseraSub: args.tesseraSub ?? `seed-${userId}`, |
| 64 | createdAt: now, |
| 65 | }); |
| 66 | |
| 67 | let namespaceId = args.namespaceId; |
| 68 | if (!namespaceId) { |
| 69 | namespaceId = newPrefixedId("ns"); |
| 70 | } |
| 71 | const claimed = await claimNamespace(db, { |
| 72 | id: namespaceId, |
| 73 | slug: args.namespaceSlug, |
| 74 | createdBy: userId, |
| 75 | createdAt: now, |
| 76 | }); |
| 77 | let resolvedNamespaceId: string; |
| 78 | if (claimed) { |
| 79 | resolvedNamespaceId = claimed.id; |
| 80 | } else { |
| 81 | const existing = await findNamespaceBySlug(db, args.namespaceSlug); |
| 82 | if (!existing) { |
| 83 | throw new Error(`seedRepo: namespace ${args.namespaceSlug} disappeared mid-claim`); |
| 84 | } |
| 85 | resolvedNamespaceId = existing.id; |
| 86 | } |
| 87 | |
| 88 | await insertMembershipIfMissing(db, { |
| 89 | namespaceId: resolvedNamespaceId, |
| 90 | userId, |
| 91 | createdAt: now, |
| 92 | }); |
| 93 | |
| 94 | const repositoryId = args.repositoryId ?? newPrefixedId("repo"); |
| 95 | const inserted = await insertRepositoryIfNew(db, { |
| 96 | id: repositoryId, |
| 97 | namespaceId: resolvedNamespaceId, |
| 98 | createdBy: userId, |
| 99 | slug: args.repoSlug, |
| 100 | doName, |
| 101 | visibility, |
| 102 | createdAt: now, |
| 103 | updatedAt: now, |
| 104 | }); |
| 105 | let resolvedRepoId: string; |
| 106 | let resolvedDoName: string; |
| 107 | if (inserted) { |
| 108 | resolvedRepoId = inserted.id; |
| 109 | resolvedDoName = inserted.doName; |
| 110 | } else { |
| 111 | const existing = await findRepositoryByNamespaceAndSlug(db, resolvedNamespaceId, args.repoSlug); |
| 112 | if (!existing) { |
| 113 | throw new Error(`seedRepo: repository ${args.namespaceSlug}/${args.repoSlug} disappeared`); |
| 114 | } |
| 115 | resolvedRepoId = existing.id; |
| 116 | resolvedDoName = existing.doName; |
| 117 | } |
| 118 | |
| 119 | if (!args.skipRouteCache) { |
| 120 | await putRouteCacheRecord(env, args.namespaceSlug, args.repoSlug, { |
| 121 | repositoryId: resolvedRepoId, |
| 122 | namespaceId: resolvedNamespaceId, |
| 123 | doName: resolvedDoName, |
| 124 | updatedAt: now, |
| 125 | }); |
| 126 | } |
| 127 | |
| 128 | return { |
| 129 | userId, |
| 130 | namespaceId: resolvedNamespaceId, |
| 131 | repositoryId: resolvedRepoId, |
| 132 | doName: resolvedDoName, |
| 133 | namespaceSlug: args.namespaceSlug, |
| 134 | repoSlug: args.repoSlug, |
| 135 | visibility, |
| 136 | routeCacheKey: routeCacheKey(args.namespaceSlug, args.repoSlug), |
| 137 | }; |
| 138 | } |
| 139 | |
| 140 | export type SetupRepoForTestsResult = SeededRepo & { |
| 141 | cookieHeader: string; |
| 142 | pushAuthHeader: string; |
| 143 | patPlaintext: string; |
| 144 | }; |
| 145 | |
| 146 | export async function setupRepoForTests( |
| 147 | env: Env, |
| 148 | namespaceSlug: string, |
| 149 | repoSlug: string, |
| 150 | opts: Partial<Omit<SeedRepoArgs, "namespaceSlug" | "repoSlug">> = {} |
| 151 | ): Promise<SetupRepoForTestsResult> { |
| 152 | await ensureD1Migrations(env); |
| 153 | const seeded = await seedRepo(env, { namespaceSlug, repoSlug, ...opts }); |
| 154 | const cookieHeader = await mintSessionCookie(env, seeded.userId); |
| 155 | const pat = await mintNamespacePushPat(env, seeded.userId, seeded.namespaceId); |
| 156 | const pushAuthHeader = `Basic ${btoa(`${namespaceSlug}:${pat.plaintext}`)}`; |
| 157 | rememberPushAuth(namespaceSlug, repoSlug, pushAuthHeader); |
| 158 | return { ...seeded, cookieHeader, pushAuthHeader, patPlaintext: pat.plaintext }; |
| 159 | } |
| 160 | |
| 161 | async function mintNamespacePushPat( |
| 162 | env: Env, |
| 163 | userId: string, |
| 164 | namespaceId: string |
| 165 | ): Promise<{ patId: string; plaintext: string }> { |
| 166 | const db = createDb(env.DB); |
| 167 | const generated = generatePatPlaintext(); |
| 168 | const hash = await hashPatPlaintext(generated.plaintext); |
| 169 | const patId = newPrefixedId("pat"); |
| 170 | const now = Date.now(); |
| 171 | await insertPatWithGrants(db, { |
| 172 | pat: { |
| 173 | id: patId, |
| 174 | userId, |
| 175 | name: "test-system-push", |
| 176 | prefix: generated.publicPrefix, |
| 177 | hash, |
| 178 | createdAt: now, |
| 179 | expiresAt: null, |
| 180 | revokedAt: null, |
| 181 | lastUsedAt: null, |
| 182 | }, |
| 183 | namespaceGrants: [{ patId, namespaceId, level: "push" }], |
| 184 | repoGrants: [], |
| 185 | }); |
| 186 | return { patId, plaintext: generated.plaintext }; |
| 187 | } |
| 188 | |
| 189 | // Lookup table so push helpers (`pushBody`, `pushStreamingUpdate`) can |
| 190 | // retrieve the seeded namespace's push PAT without each test threading the |
| 191 | // header through. |
| 192 | const pushAuthByRepo = new Map<string, string>(); |
| 193 | |
| 194 | export function rememberPushAuth(owner: string, repo: string, header: string): void { |
| 195 | pushAuthByRepo.set(`${owner}/${repo}`, header); |
| 196 | } |
| 197 | |
| 198 | export function lookupPushAuth(owner: string, repo: string): string | undefined { |
| 199 | return pushAuthByRepo.get(`${owner}/${repo}`); |
| 200 | } |
| 201 | |
| 202 | export async function mintSessionCookie(env: Env, userId: string): Promise<string> { |
| 203 | const secret = env.SESSION_SECRET; |
| 204 | if (!secret) throw new Error("mintSessionCookie: SESSION_SECRET not set"); |
| 205 | const now = Date.now(); |
| 206 | const token = await sessionTest.sealSession(secret, { |
| 207 | version: 1, |
| 208 | userId, |
| 209 | createdAt: now, |
| 210 | expiresAt: now + 60 * 60 * 1000, |
| 211 | }); |
| 212 | return `${SESSION_COOKIE_HEADER_NAME}=${token}`; |
| 213 | } |