Skip to content
File

Blob: test/util/repoSeed.ts

typescript214 lines
1import { newPrefixedId } from "@/worker/common";
2import { SESSION_COOKIE_HEADER_NAME } from "@/worker/auth/cookies";
3import { generatePatPlaintext, hashPatPlaintext } from "@/worker/auth/pat";
4import { __test as sessionTest } from "@/worker/auth/session";
5import { createDb } from "@/worker/db/d1/client";
6import {
7 claimNamespace,
8 findNamespaceBySlug,
9 findRepositoryByNamespaceAndSlug,
10 insertMembershipIfMissing,
11 insertPatWithGrants,
12 insertRepositoryIfNew,
13 insertUserIfNew,
14} from "@/worker/db/d1/dal";
15import { putRouteCacheRecord, routeCacheKey } from "@/worker/repositories/routeCache";
16 
17import { 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 
27export 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 
43export 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 
54export 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 
140export type SetupRepoForTestsResult = SeededRepo & {
141 cookieHeader: string;
142 pushAuthHeader: string;
143 patPlaintext: string;
144};
145 
146export 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 
161async 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.
192const pushAuthByRepo = new Map<string, string>();
193 
194export function rememberPushAuth(owner: string, repo: string, header: string): void {
195 pushAuthByRepo.set(`${owner}/${repo}`, header);
196}
197 
198export function lookupPushAuth(owner: string, repo: string): string | undefined {
199 return pushAuthByRepo.get(`${owner}/${repo}`);
200}
201 
202export 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}