1import * as CodeArtifact from "@/AWS/CodeArtifact";
2import * as Lambda from "@/AWS/Lambda";
3import * as S3 from "@/AWS/S3";
4import crypto from "node:crypto";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Redacted from "effect/Redacted";
9import * as Schedule from "effect/Schedule";
10import * as Stream from "effect/Stream";
11import { HttpServerRequest } from "effect/http/HttpServerRequest";
12import * as HttpServerResponse from "effect/http/HttpServerResponse";
13import path from "pathe";
14
15const main = path.resolve(import.meta.dirname, "handler.ts");
16
17// Deterministic, lowercase CodeArtifact identifiers scoped to this suite
18// (distinct from Repository.test.ts's `alchemy-test-ca` domain).
19export const DOMAIN = "alchemy-test-ca-bind";
20export const REPO = "alchemy-test-ca-bind-repo";
21export const MIRROR = "alchemy-test-ca-bind-mirror";
22const FORMAT = "generic" as const;
23const NAMESPACE = "alchemy";
24const PKG = "test-package";
25const ASSET = "artifact.txt";
26
27const contentFor = (version: string) => `hello codeartifact ${version}`;
28
29export class CodeArtifactTestFunction extends Lambda.Function<Lambda.Function>()(
30 "CodeArtifactTestFunction",
31) {}
32
33export default CodeArtifactTestFunction.make(
34 {
35 main,
36 functionUrl: true,
37 // The publish → copy → dispose flows fan out several SDK calls; AWS's
38 // 3s default would intermittently time out.
39 timeout: Duration.seconds(60),
40 },
41 Effect.gen(function* () {
42 const domain = yield* CodeArtifact.Domain("Domain", {
43 domainName: DOMAIN,
44 });
45 const repo = yield* CodeArtifact.Repository("Repo", {
46 domain: domain.domainName,
47 repositoryName: REPO,
48 });
49 const mirror = yield* CodeArtifact.Repository("Mirror", {
50 domain: domain.domainName,
51 repositoryName: MIRROR,
52 });
53
54 // Marker store for the event source (events may arrive on another
55 // Lambda instance, so they must be observable out-of-band).
56 const bucket = yield* S3.Bucket("EventBucket", { forceDestroy: true });
57 const putObject = yield* S3.PutObject(bucket);
58 const getObject = yield* S3.GetObject(bucket);
59
60 // --- domain-scoped binding ---
61 const getToken = yield* CodeArtifact.GetAuthorizationToken(domain);
62
63 // --- repository-scoped bindings ---
64 const getEndpoint = yield* CodeArtifact.GetRepositoryEndpoint(repo);
65 const listPackages = yield* CodeArtifact.ListPackages(repo);
66 const describePackage = yield* CodeArtifact.DescribePackage(repo);
67 const describeVersion = yield* CodeArtifact.DescribePackageVersion(repo);
68 const listVersions = yield* CodeArtifact.ListPackageVersions(repo);
69 const listAssets = yield* CodeArtifact.ListPackageVersionAssets(repo);
70 const listDeps = yield* CodeArtifact.ListPackageVersionDependencies(repo);
71 const getReadme = yield* CodeArtifact.GetPackageVersionReadme(repo);
72 const getAsset = yield* CodeArtifact.GetPackageVersionAsset(repo);
73 const publish = yield* CodeArtifact.PublishPackageVersion(repo);
74 const updateStatus = yield* CodeArtifact.UpdatePackageVersionsStatus(repo);
75 const putOrigin = yield* CodeArtifact.PutPackageOriginConfiguration(repo);
76 const deletePackage = yield* CodeArtifact.DeletePackage(repo);
77
78 // --- mirror-scoped bindings (destination of the copy) ---
79 const copyToMirror = yield* CodeArtifact.CopyPackageVersions(mirror);
80 const listMirrorPackages = yield* CodeArtifact.ListPackages(mirror);
81 const disposeMirror = yield* CodeArtifact.DisposePackageVersions(mirror);
82 const deleteMirrorVersions =
83 yield* CodeArtifact.DeletePackageVersions(mirror);
84 const deleteMirrorPackage = yield* CodeArtifact.DeletePackage(mirror);
85
86 // --- event source ---
87 // CodeArtifact publishes every package version change to the default
88 // bus; write a marker object per event so /events/probe can observe the
89 // delivery out-of-band.
90 yield* CodeArtifact.consumePackageVersionStateChanges(
91 { repositories: [REPO, MIRROR] },
92 (events) =>
93 Stream.runForEach(events, (event) =>
94 putObject({
95 Key: `events/${event.detail.packageVersion}`,
96 Body: JSON.stringify(event.detail),
97 ContentType: "application/json",
98 }).pipe(Effect.orDie, Effect.asVoid),
99 ),
100 );
101
102 const publishVersion = Effect.fn(function* (
103 version: string,
104 unfinished: boolean,
105 ) {
106 const content = contentFor(version);
107 const sha = yield* Effect.sync(() =>
108 crypto.createHash("sha256").update(content).digest("hex"),
109 );
110 return yield* publish({
111 format: FORMAT,
112 namespace: NAMESPACE,
113 package: PKG,
114 packageVersion: version,
115 assetName: ASSET,
116 assetContent: content,
117 assetSHA256: sha,
118 ...(unfinished ? { unfinished: true } : {}),
119 });
120 });
121
122 return {
123 fetch: Effect.gen(function* () {
124 const request = yield* HttpServerRequest;
125 const url = new URL(request.originalUrl);
126 const pathname = url.pathname;
127 const param = (name: string) => url.searchParams.get(name) ?? "";
128
129 // Mint a domain authorization token; prove it comes back Redacted.
130 if (request.method === "GET" && pathname === "/token") {
131 const res = yield* getToken({ duration: "15 minutes" });
132 const token = res.authorizationToken;
133 return yield* HttpServerResponse.json({
134 redacted: Redacted.isRedacted(token),
135 length: (Redacted.isRedacted(token)
136 ? Redacted.value(token)
137 : (token ?? "")
138 ).length,
139 });
140 }
141
142 if (request.method === "GET" && pathname === "/endpoint") {
143 const res = yield* getEndpoint({ format: FORMAT });
144 return yield* HttpServerResponse.json({
145 endpoint: res.repositoryEndpoint,
146 });
147 }
148
149 // Delete the test package from both repositories so a retried test
150 // body starts from a clean slate — republishing an already-Published
151 // generic version raises the (deterministic) ConflictException.
152 if (request.method === "POST" && pathname === "/reset") {
153 yield* deletePackage({
154 format: FORMAT,
155 namespace: NAMESPACE,
156 package: PKG,
157 }).pipe(
158 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
159 );
160 yield* deleteMirrorPackage({
161 format: FORMAT,
162 namespace: NAMESPACE,
163 package: PKG,
164 }).pipe(
165 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
166 );
167 return yield* HttpServerResponse.json({ reset: true });
168 }
169
170 if (request.method === "POST" && pathname === "/publish") {
171 const res = yield* publishVersion(
172 param("version"),
173 param("unfinished") === "1",
174 );
175 return yield* HttpServerResponse.json({ status: res.status });
176 }
177
178 if (request.method === "GET" && pathname === "/package") {
179 const res = yield* describePackage({
180 format: FORMAT,
181 namespace: NAMESPACE,
182 package: PKG,
183 });
184 return yield* HttpServerResponse.json({
185 name: res.package?.name,
186 format: res.package?.format,
187 namespace: res.package?.namespace,
188 });
189 }
190
191 if (request.method === "GET" && pathname === "/version") {
192 const res = yield* describeVersion({
193 format: FORMAT,
194 namespace: NAMESPACE,
195 package: PKG,
196 packageVersion: param("version"),
197 });
198 return yield* HttpServerResponse.json({
199 version: res.packageVersion?.version,
200 status: res.packageVersion?.status,
201 });
202 }
203
204 if (request.method === "GET" && pathname === "/packages") {
205 const res = yield* listPackages({});
206 return yield* HttpServerResponse.json({
207 names: (res.packages ?? []).map((p) => p.package),
208 });
209 }
210
211 if (request.method === "GET" && pathname === "/versions") {
212 const res = yield* listVersions({
213 format: FORMAT,
214 namespace: NAMESPACE,
215 package: PKG,
216 });
217 return yield* HttpServerResponse.json({
218 versions: (res.versions ?? []).map((v) => v.version),
219 });
220 }
221
222 if (request.method === "GET" && pathname === "/assets") {
223 const res = yield* listAssets({
224 format: FORMAT,
225 namespace: NAMESPACE,
226 package: PKG,
227 packageVersion: param("version"),
228 });
229 return yield* HttpServerResponse.json({
230 assets: (res.assets ?? []).map((a) => a.name),
231 });
232 }
233
234 if (request.method === "GET" && pathname === "/asset") {
235 const res = yield* getAsset({
236 format: FORMAT,
237 namespace: NAMESPACE,
238 package: PKG,
239 packageVersion: param("version"),
240 asset: ASSET,
241 });
242 const content = yield* Stream.mkString(
243 Stream.decodeText(res.asset!),
244 ).pipe(Effect.orDie);
245 return yield* HttpServerResponse.json({ content });
246 }
247
248 // Generic packages have no readme — CodeArtifact answers with the
249 // typed ResourceNotFoundException ("The readme file of this package
250 // version is not found"); surface the tag instead of dying.
251 if (request.method === "GET" && pathname === "/readme") {
252 const res = yield* getReadme({
253 format: FORMAT,
254 namespace: NAMESPACE,
255 package: PKG,
256 packageVersion: param("version"),
257 }).pipe(
258 Effect.map((r) => ({ readme: r.readme ?? "", error: undefined })),
259 Effect.catchTag(
260 ["ResourceNotFoundException", "ValidationException"],
261 (e) => Effect.succeed({ readme: undefined, error: e._tag }),
262 ),
263 );
264 return yield* HttpServerResponse.json(res);
265 }
266
267 // Generic packages record no dependency manifest — surfaces the
268 // typed not-found/validation error instead of dying.
269 if (request.method === "GET" && pathname === "/deps") {
270 const res = yield* listDeps({
271 format: FORMAT,
272 namespace: NAMESPACE,
273 package: PKG,
274 packageVersion: param("version"),
275 }).pipe(
276 Effect.map((r) => ({
277 dependencies: (r.dependencies ?? []).map((d) => d.package),
278 error: undefined,
279 })),
280 Effect.catchTag(
281 ["ResourceNotFoundException", "ValidationException"],
282 (e) => Effect.succeed({ dependencies: undefined, error: e._tag }),
283 ),
284 );
285 return yield* HttpServerResponse.json(res);
286 }
287
288 if (request.method === "POST" && pathname === "/status") {
289 const res = yield* updateStatus({
290 format: FORMAT,
291 namespace: NAMESPACE,
292 package: PKG,
293 versions: [param("version")],
294 targetStatus: param("target"),
295 });
296 return yield* HttpServerResponse.json({
297 successful: Object.keys(res.successfulVersions ?? {}),
298 });
299 }
300
301 if (request.method === "POST" && pathname === "/origin") {
302 const res = yield* putOrigin({
303 format: FORMAT,
304 namespace: NAMESPACE,
305 package: PKG,
306 restrictions: { publish: "ALLOW", upstream: "BLOCK" },
307 });
308 return yield* HttpServerResponse.json({
309 restrictions: res.originConfiguration?.restrictions,
310 });
311 }
312
313 if (request.method === "POST" && pathname === "/copy") {
314 const res = yield* copyToMirror({
315 sourceRepository: REPO,
316 format: FORMAT,
317 namespace: NAMESPACE,
318 package: PKG,
319 versions: [param("version")],
320 });
321 return yield* HttpServerResponse.json({
322 successful: Object.keys(res.successfulVersions ?? {}),
323 });
324 }
325
326 if (request.method === "GET" && pathname === "/mirror/packages") {
327 const res = yield* listMirrorPackages({});
328 return yield* HttpServerResponse.json({
329 names: (res.packages ?? []).map((p) => p.package),
330 });
331 }
332
333 if (request.method === "POST" && pathname === "/mirror/dispose") {
334 const res = yield* disposeMirror({
335 format: FORMAT,
336 namespace: NAMESPACE,
337 package: PKG,
338 versions: [param("version")],
339 });
340 return yield* HttpServerResponse.json({
341 successful: Object.keys(res.successfulVersions ?? {}),
342 });
343 }
344
345 if (
346 request.method === "POST" &&
347 pathname === "/mirror/delete-versions"
348 ) {
349 const res = yield* deleteMirrorVersions({
350 format: FORMAT,
351 namespace: NAMESPACE,
352 package: PKG,
353 versions: [param("version")],
354 });
355 return yield* HttpServerResponse.json({
356 successful: Object.keys(res.successfulVersions ?? {}),
357 });
358 }
359
360 if (
361 request.method === "POST" &&
362 pathname === "/mirror/delete-package"
363 ) {
364 yield* deleteMirrorPackage({
365 format: FORMAT,
366 namespace: NAMESPACE,
367 package: PKG,
368 }).pipe(
369 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
370 );
371 return yield* HttpServerResponse.json({ deleted: true });
372 }
373
374 // Publish a fresh version, then poll for the event-source marker the
375 // state-change handler wrote to S3 (bounded well under the 60s
376 // function timeout).
377 if (request.method === "GET" && pathname === "/events/probe") {
378 const version = param("version");
379 // A retried probe re-publishes the same version — CodeArtifact
380 // rejects updates to a Published generic version with the typed
381 // ConflictException; the event already fired, so just poll.
382 yield* publishVersion(version, false).pipe(
383 Effect.catchTag("ConflictException", () =>
384 Effect.succeed(undefined),
385 ),
386 );
387 const seen = yield* getObject({ Key: `events/${version}` }).pipe(
388 Effect.retry({
389 while: (e): boolean => e._tag === "NoSuchKey",
390 schedule: Schedule.spaced("2 seconds"),
391 times: 12,
392 }),
393 Effect.map(() => true),
394 Effect.catchTag("NoSuchKey", () => Effect.succeed(false)),
395 );
396 return yield* HttpServerResponse.json({ seen, version });
397 }
398
399 return yield* HttpServerResponse.json(
400 { error: "Not found", method: request.method, pathname },
401 { status: 404 },
402 );
403 }).pipe(
404 // Surface typed errors in the 500 body so the test's transient-retry
405 // logs show the real failure instead of an opaque Internal Server
406 // Error.
407 Effect.catch((e) =>
408 HttpServerResponse.json(
409 {
410 error: (e as { _tag?: string })._tag ?? "UnknownError",
411 message: String(e),
412 },
413 { status: 500 },
414 ),
415 ),
416 Effect.orDie,
417 ),
418 };
419 }).pipe(
420 Effect.provide(
421 Layer.mergeAll(
422 Lambda.EventSource,
423 CodeArtifact.GetAuthorizationTokenHttp,
424 CodeArtifact.GetRepositoryEndpointHttp,
425 CodeArtifact.ListPackagesHttp,
426 CodeArtifact.DescribePackageHttp,
427 CodeArtifact.DescribePackageVersionHttp,
428 CodeArtifact.ListPackageVersionsHttp,
429 CodeArtifact.ListPackageVersionAssetsHttp,
430 CodeArtifact.ListPackageVersionDependenciesHttp,
431 CodeArtifact.GetPackageVersionReadmeHttp,
432 CodeArtifact.GetPackageVersionAssetHttp,
433 CodeArtifact.PublishPackageVersionHttp,
434 CodeArtifact.UpdatePackageVersionsStatusHttp,
435 CodeArtifact.PutPackageOriginConfigurationHttp,
436 CodeArtifact.CopyPackageVersionsHttp,
437 CodeArtifact.DisposePackageVersionsHttp,
438 CodeArtifact.DeletePackageVersionsHttp,
439 CodeArtifact.DeletePackageHttp,
440 S3.PutObjectHttp,
441 S3.GetObjectHttp,
442 ),
443 ),
444 ),
445);