1import { describe, expect, it } from "alchemy-test";
2import * as Git from "@/Git/index.ts";
3import * as GitHttp from "@/Git/Http.ts";
4import * as Http from "@/Http/index.ts";
5import { RuntimeContext } from "@/RuntimeContext.ts";
6import * as Context from "effect/Context";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import * as Stream from "effect/Stream";
10import { gzipSync } from "node:zlib";
11import * as Result from "effect/Result";
12import * as HttpApi from "effect/http-api/HttpApi";
13import * as HttpApiGroup from "effect/http-api/HttpApiGroup";
14import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
15import * as HttpApiMiddleware from "effect/http-api/HttpApiMiddleware";
16import * as HttpServerResponse from "effect/http/HttpServerResponse";
17import * as HttpServerRequest from "effect/http/HttpServerRequest";
18import * as HttpRouter from "effect/http/HttpRouter";
19import { concatBytes } from "@/Git/Protocol/ObjectCodec.ts";
20import { pktText, flushPkt } from "@/Git/Protocol/Pkt.ts";
21import { makeMemoryBlobStore } from "./harness/store.ts";
22
23const oid = "a".repeat(40);
24const zero = "0".repeat(40);
25const meta: Git.RepoMetaData = {
26 repoId: "repo",
27 owner: "alice",
28 name: "demo",
29 defaultBranch: "main",
30 description: null,
31 readOnly: false,
32 public: false,
33 forkOf: null,
34 status: "ready",
35 createdAt: 0,
36 lastPush: null,
37 objects: { loose: 0, resident: 0, packed: 0, r2: 0, bytes: 0 },
38};
39const request = () =>
40 HttpServerRequest.fromWeb(
41 new Request("http://test/alice/demo.git/git-receive-pack", {
42 method: "POST",
43 body: Buffer.from(
44 concatBytes([
45 pktText(`${zero} ${oid} refs/heads/main\0report-status atomic`),
46 flushPkt,
47 ]),
48 ),
49 }),
50 );
51
52const fixture = Effect.gen(function* () {
53 const calls = {
54 begin: 0,
55 abort: 0,
56 commit: 0,
57 update: undefined as unknown,
58 merge: undefined as unknown,
59 };
60 const stub = {
61 getRepoMeta: () => Effect.succeed(meta),
62 beginPush: () =>
63 Effect.sync(() => {
64 calls.begin++;
65 return { _tag: "ok" as const, pushId: "push" };
66 }),
67 abortPush: () =>
68 Effect.sync(() => {
69 calls.abort++;
70 return true;
71 }),
72 validatePush: () => Effect.void,
73 readPreparedObject: () =>
74 Effect.succeed({
75 type: 1 as const,
76 content: new TextEncoder().encode("commit"),
77 }),
78 commitPush: (input: Git.CommitPushInput) =>
79 Effect.sync(() => {
80 calls.commit++;
81 return {
82 unpack: "ok",
83 results: input.commands.map((c) => ({ ref: c.ref, ok: true })),
84 };
85 }),
86 getRef: () => Effect.fail(new Git.RefNotFound({ ref: "refs/heads/main" })),
87 updateRef: (input: unknown) =>
88 Effect.sync(() => {
89 calls.update = input;
90 return { name: "refs/heads/main", oid };
91 }),
92 getPull: () =>
93 Effect.succeed({
94 baseRef: "refs/heads/main",
95 headRef: "refs/heads/feature",
96 baseOid: oid,
97 headOid: "b".repeat(40),
98 }),
99 mergePull: (input: unknown) =>
100 Effect.sync(() => {
101 calls.merge = input;
102 return {};
103 }),
104 };
105 const engineLayer = Git.EngineLive.pipe(
106 Layer.provide(
107 Layer.mergeAll(
108 Layer.succeed(Git.RepoStore, { getByName: () => stub as never }),
109 Layer.succeed(Git.RegistryStore, {
110 resolve: () => Effect.succeed({ repoId: "repo", deletedAt: null }),
111 } as never),
112 Git.HasherInline,
113 ),
114 ),
115 Layer.provide(Layer.succeed(Git.BlobStore, makeMemoryBlobStore())),
116 );
117 const engine = yield* Git.Engine.pipe(Effect.provide(engineLayer));
118 return { engine, engineLayer, calls };
119});
120
121describe(
122 "Git operations and native HTTP middleware",
123 { tags: ["unit", "local"] },
124 () => {
125 it.effect("accepts raw pack streams without an HTTP request", () =>
126 Effect.scoped(
127 Effect.gen(function* () {
128 const { engine, calls } = yield* fixture;
129 const pack = new Uint8Array(
130 yield* Effect.promise(() =>
131 Bun.file(
132 new URL("./fixtures/packs/empty.pack", import.meta.url),
133 ).arrayBuffer(),
134 ),
135 );
136 const input = yield* Git.Push.fromStream(
137 [{ ref: "refs/heads/main", oldOid: zero, newOid: oid }],
138 Stream.make(pack),
139 );
140 yield* Effect.scoped(
141 Effect.gen(function* () {
142 const prepared = yield* engine.preparePush(meta, input);
143 yield* engine.commitPush(prepared);
144 }),
145 );
146 expect(calls.commit).toBe(1);
147 const invalid = yield* Effect.result(
148 Git.Push.fromStream(
149 [{ ref: "bad ref", oldOid: zero, newOid: oid }],
150 Stream.empty,
151 ),
152 );
153 expect(Result.isFailure(invalid)).toBe(true);
154 const truncated = yield* Git.Push.fromStream(
155 [{ ref: "refs/heads/main", oldOid: zero, newOid: oid }],
156 Stream.make(new Uint8Array([0x50, 0x41, 0x43, 0x4b])),
157 );
158 const failure = yield* Effect.result(
159 engine.preparePush(meta, truncated),
160 );
161 expect(failure).toMatchObject({
162 _tag: "Failure",
163 failure: { _tag: "StoreError", reason: "truncated pack header" },
164 });
165 expect(calls.commit).toBe(1);
166 }),
167 ).pipe(Effect.provide(RuntimeContext.phantom)),
168 );
169
170 it.effect(
171 "decodes gzip incrementally and recognizes Git's empty probe",
172 () =>
173 Effect.scoped(
174 Effect.gen(function* () {
175 const compressed = HttpServerRequest.fromWeb(
176 new Request("http://test/push", {
177 method: "POST",
178 headers: { "content-encoding": "gzip" },
179 body: gzipSync(
180 Buffer.from(
181 concatBytes([
182 pktText(`${zero} ${oid} refs/heads/main\0report-status`),
183 flushPkt,
184 ]),
185 ),
186 ),
187 }),
188 );
189 const decoded = yield* GitHttp.ReceivePack.decode(compressed);
190 expect(decoded._tag).toBe("Push");
191 const probe = yield* GitHttp.ReceivePack.decode(
192 HttpServerRequest.fromWeb(
193 new Request("http://test/push", {
194 method: "POST",
195 body: "0000",
196 }),
197 ),
198 );
199 expect(probe._tag).toBe("Probe");
200 const malformed = yield* Effect.result(
201 GitHttp.ReceivePack.decode(
202 HttpServerRequest.fromWeb(
203 new Request("http://test/push", {
204 method: "POST",
205 body: "xxxx",
206 }),
207 ),
208 ),
209 );
210 expect(Result.isFailure(malformed)).toBe(true);
211 }),
212 ),
213 );
214
215 it.effect("rejects before staging and encodes a Git report", () =>
216 Effect.gen(function* () {
217 const { calls } = yield* fixture;
218 const response = yield* Effect.scoped(
219 Effect.gen(function* () {
220 const push = yield* GitHttp.ReceivePack.decode(request());
221 if (push._tag !== "Push") return yield* Effect.die("expected push");
222 return GitHttp.ReceivePack.reject(
223 push,
224 "database branch is protected",
225 );
226 }),
227 );
228 const text = yield* Effect.promise(() =>
229 HttpServerResponse.toWeb(response).text(),
230 );
231 expect(text).toContain(
232 "ng refs/heads/main database branch is protected",
233 );
234 expect(calls.begin).toBe(0);
235 }).pipe(Effect.provide(RuntimeContext.phantom)),
236 );
237
238 it.effect(
239 "stages, reads objects, aborts on exit, and rejects an escaped transaction",
240 () =>
241 Effect.gen(function* () {
242 const { engine, calls } = yield* fixture;
243 const prepared = yield* Effect.scoped(
244 Effect.gen(function* () {
245 const push = yield* GitHttp.ReceivePack.decode(request());
246 if (push._tag !== "Push")
247 return yield* Effect.die("expected push");
248 const prepared = yield* engine.preparePush(meta, push.input);
249 expect((yield* prepared.readObject(oid))?.type).toBe(1);
250 expect(calls.commit).toBe(0);
251 return prepared;
252 }),
253 );
254 expect(calls.abort).toBe(1);
255 expect(
256 Result.isFailure(yield* Effect.result(engine.commitPush(prepared))),
257 ).toBe(true);
258 expect(
259 Result.isFailure(yield* Effect.result(prepared.readObject(oid))),
260 ).toBe(true);
261 expect(calls.commit).toBe(0);
262 }).pipe(Effect.provide(RuntimeContext.phantom)),
263 );
264
265 it.effect(
266 "commits once, retains committed data, and rejects a second prepare",
267 () =>
268 Effect.gen(function* () {
269 const { engine, calls } = yield* fixture;
270 yield* Effect.scoped(
271 Effect.gen(function* () {
272 const push = yield* GitHttp.ReceivePack.decode(request());
273 if (push._tag !== "Push")
274 return yield* Effect.die("expected push");
275 const prepared = yield* engine.preparePush(meta, push.input);
276 expect(
277 Result.isFailure(
278 yield* Effect.result(engine.preparePush(meta, push.input)),
279 ),
280 ).toBe(true);
281 yield* engine.commitPush(prepared);
282 expect(
283 Result.isFailure(
284 yield* Effect.result(engine.commitPush(prepared)),
285 ),
286 ).toBe(true);
287 }),
288 );
289 expect(calls.commit).toBe(1);
290 expect(calls.abort).toBe(0);
291 }).pipe(Effect.provide(RuntimeContext.phantom)),
292 );
293
294 it.effect(
295 "pins ref creation and both merge tips to the inspected state",
296 () =>
297 Effect.scoped(
298 Effect.gen(function* () {
299 const { engine, calls } = yield* fixture;
300 const ref = yield* engine.prepareRefUpdate(meta, {
301 ref: "refs/heads/main",
302 newOid: oid,
303 expectedOid: null,
304 });
305 expect(ref.updates[0]?.oldOid).toBe(zero);
306 yield* ref.commit;
307 expect(calls.update).toEqual({
308 name: "refs/heads/main",
309 newOid: oid,
310 expectedOid: null,
311 });
312 expect(Result.isFailure(yield* Effect.result(ref.commit))).toBe(
313 true,
314 );
315 const merge = yield* engine.prepareMerge(meta, { number: 1 });
316 yield* merge.commit;
317 expect(calls.merge).toMatchObject({
318 expectedBaseOid: oid,
319 expectedHeadOid: "b".repeat(40),
320 });
321 }),
322 ).pipe(Effect.provide(RuntimeContext.phantom)),
323 );
324
325 it.effect(
326 "raw API handlers retain middleware user and database dependencies",
327 () =>
328 Effect.gen(function* () {
329 class User extends Context.Service<User, { id: string }>()(
330 "test/push/User",
331 ) {}
332 class Database extends Context.Service<
333 Database,
334 { mayWrite: (user: string, ref: string) => Effect.Effect<boolean> }
335 >()("test/push/Database") {}
336 class Authentication extends HttpApiMiddleware.Service<
337 Authentication,
338 { provides: User }
339 >()("test/push/Auth") {}
340 class Api extends HttpApi.make("custom")
341 .add(HttpApiGroup.make("git").add(GitHttp.ReceivePack.endpoint))
342 .middleware(Authentication) {}
343 const seen: Array<string> = [];
344 const routes = HttpApiBuilder.layer(Api).pipe(
345 Layer.provide(
346 HttpApiBuilder.group(Api, "git", (h) =>
347 Effect.gen(function* () {
348 const db = yield* Database;
349 return h.handleRaw("receivePack", ({ request }) =>
350 Effect.scoped(
351 Effect.gen(function* () {
352 const user = yield* User;
353 const push = yield* GitHttp.ReceivePack.decode(
354 request,
355 ).pipe(Effect.orDie);
356 if (push._tag === "Probe")
357 return GitHttp.ReceivePack.probeResponse();
358 yield* db.mayWrite(user.id, push.updates[0]!.ref);
359 return GitHttp.ReceivePack.reject(
360 push,
361 "denied by database",
362 );
363 }),
364 ),
365 );
366 }),
367 ),
368 ),
369 Layer.provide(
370 Layer.succeed(Authentication, (effect) =>
371 Effect.provideService(effect, User, { id: "alice" }),
372 ),
373 ),
374 Layer.provide(
375 Layer.succeed(Database, {
376 mayWrite: (user, ref) =>
377 Effect.sync(() => {
378 seen.push(`${user}:${ref}`);
379 return false;
380 }),
381 }),
382 ),
383 Layer.provide(Http.Platform),
384 );
385 const fetch = yield* HttpRouter.toHttpEffect(routes);
386 const response = yield* fetch.pipe(
387 Effect.provideService(
388 HttpServerRequest.HttpServerRequest,
389 request(),
390 ),
391 );
392 expect(response.status).toBe(200);
393 expect(seen).toEqual(["alice:refs/heads/main"]);
394 }).pipe(Effect.provide(RuntimeContext.phantom)),
395 );
396 },
397);