| 1 | import { describe, expect, it } from "alchemy-test"; |
| 2 | import * as Git from "@/Git/index.ts"; |
| 3 | import * as GitHttp from "@/Git/Http.ts"; |
| 4 | import * as Http from "@/Http/index.ts"; |
| 5 | import { RuntimeContext } from "@/RuntimeContext.ts"; |
| 6 | import * as Context from "effect/Context"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as Stream from "effect/Stream"; |
| 10 | import { gzipSync } from "node:zlib"; |
| 11 | import * as Result from "effect/Result"; |
| 12 | import * as HttpApi from "effect/http-api/HttpApi"; |
| 13 | import * as HttpApiGroup from "effect/http-api/HttpApiGroup"; |
| 14 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 15 | import * as HttpApiMiddleware from "effect/http-api/HttpApiMiddleware"; |
| 16 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 17 | import * as HttpServerRequest from "effect/http/HttpServerRequest"; |
| 18 | import * as HttpRouter from "effect/http/HttpRouter"; |
| 19 | import { concatBytes } from "@/Git/Protocol/ObjectCodec.ts"; |
| 20 | import { pktText, flushPkt } from "@/Git/Protocol/Pkt.ts"; |
| 21 | import { makeMemoryBlobStore } from "./harness/store.ts"; |
| 22 | |
| 23 | const oid = "a".repeat(40); |
| 24 | const zero = "0".repeat(40); |
| 25 | const 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 | }; |
| 39 | const 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 | |
| 52 | const 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 | |
| 121 | describe( |
| 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 | ); |