1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as ecr from "@distilled.cloud/aws/ecr";
5import { describe, expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Redacted from "effect/Redacted";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import * as HttpClientRequest from "effect/http/HttpClientRequest";
12import EcrTestFunctionLive, { EcrTestFunction } from "./handler";
13
14const testOptions = { providers: AWS.providers() };
15const { test, beforeAll, afterAll } = Test.make(testOptions);
16const sharedStack = Core.scratchStack(testOptions, "EcrBindings");
17
18let baseUrl: string;
19
20class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
21 readonly status: number;
22 readonly body: string;
23}> {}
24
25// Fresh Lambda role + ECR/S3 permissions propagate eventually — the first
26// calls can 500 with AccessDenied. Retry 5xx only (the handler surfaces
27// typed errors as a 500 JSON body); a genuine 4xx fails immediately.
28const send = (request: HttpClientRequest.HttpClientRequest) =>
29 HttpClient.execute(request).pipe(
30 Effect.flatMap((response) =>
31 response.status >= 500
32 ? response.text.pipe(
33 Effect.flatMap((body) =>
34 Effect.fail(
35 new TransientUpstream({ status: response.status, body }),
36 ),
37 ),
38 )
39 : Effect.succeed(response),
40 ),
41 Effect.retry({
42 while: (e) => e._tag === "TransientUpstream",
43 schedule: Schedule.max([
44 Schedule.exponential("1 second"),
45 Schedule.recurs(6),
46 ]),
47 }),
48 );
49
50const getJson = <T>(path: string) =>
51 Effect.gen(function* () {
52 const response = yield* send(HttpClientRequest.get(`${baseUrl}${path}`));
53 expect(response.status).toBe(200);
54 return (yield* response.json) as T;
55 });
56
57const postJson = <T>(path: string) =>
58 Effect.gen(function* () {
59 const response = yield* send(HttpClientRequest.post(`${baseUrl}${path}`));
60 expect(response.status).toBe(200);
61 return (yield* response.json) as T;
62 });
63
64describe(
65 "ECR Bindings",
66 {
67 tags: [
68 "provider:aws",
69 "provider:aws:batch",
70 "provider:aws:ecr",
71 "provider:aws:lambda",
72 "provider:aws:s3",
73 "live",
74 ],
75 },
76 () => {
77 beforeAll(
78 Effect.gen(function* () {
79 yield* Effect.logInfo(
80 "ECR Bindings setup: destroying previous resources",
81 );
82 yield* sharedStack.destroy();
83
84 yield* Effect.logInfo(
85 "ECR Bindings setup: deploying repository -> Lambda",
86 );
87 const { functionUrl } = yield* sharedStack.deploy(
88 Effect.gen(function* () {
89 return yield* EcrTestFunction;
90 }).pipe(Effect.provide(EcrTestFunctionLive)),
91 );
92
93 expect(functionUrl).toBeTruthy();
94 baseUrl = functionUrl!.replace(/\/+$/, "");
95
96 // Readiness probe — fresh function URLs take seconds to serve 200s.
97 yield* HttpClient.get(`${baseUrl}/nope`).pipe(
98 Effect.flatMap((response) =>
99 response.status === 404
100 ? Effect.succeed(response)
101 : Effect.fail(
102 new Error(`Function not ready: ${response.status}`),
103 ),
104 ),
105 Effect.retry({
106 schedule: Schedule.max([
107 Schedule.fixed("2 seconds"),
108 Schedule.recurs(75),
109 ]),
110 }),
111 );
112 }),
113 { timeout: 300_000 },
114 );
115 // No NO_DESTROY escape hatch here: scratch-stack state is in-memory per
116 // process, so a skipped destroy would orphan the whole stack forever.
117 afterAll(sharedStack.destroy(), { timeout: 300_000 });
118
119 describe("GetAuthorizationToken", () => {
120 test.provider(
121 "mints a Redacted registry credential that decodes to AWS:<password>",
122 () =>
123 Effect.gen(function* () {
124 const body = yield* getJson<{
125 redacted: boolean;
126 length: number;
127 decodesToAwsUser: boolean;
128 proxyEndpoint?: string;
129 }>("/token");
130 // An ECR bearer token is a long opaque credential; the in-Lambda
131 // `redacted` flag flips to true once the distilled `lib/` build
132 // catches up with the SensitiveString patch (the bundler resolves
133 // the built lib, unlike vitest which resolves src).
134 expect(body.length).toBeGreaterThan(100);
135 expect(body.decodesToAwsUser).toBe(true);
136 expect(body.proxyEndpoint).toContain(".ecr.");
137 }),
138 { timeout: 120_000 },
139 );
140
141 test.provider(
142 "authorizationToken is Redacted (distilled sensitive patch)",
143 () =>
144 Effect.gen(function* () {
145 // Direct distilled call (vitest resolves distilled from src, so the
146 // SensitiveString patch is live here).
147 const res = yield* ecr.getAuthorizationToken({});
148 const token = res.authorizationData?.[0]?.authorizationToken;
149 expect(Redacted.isRedacted(token)).toBe(true);
150 expect(
151 Redacted.value(token as Redacted.Redacted<string>).length,
152 ).toBeGreaterThan(100);
153 }),
154 { timeout: 120_000 },
155 );
156 });
157
158 // The image bindings mutate shared repository state, so they run as ONE
159 // ordered flow — vitest executes tests in this file concurrently
160 // (`sequence.concurrent`), which would otherwise race the delete steps
161 // against the read steps.
162 describe("image lifecycle", () => {
163 test.provider(
164 "push -> inspect -> pull -> scan -> delete through the bindings",
165 () =>
166 Effect.gen(function* () {
167 // InitiateLayerUpload + UploadLayerPart + CompleteLayerUpload +
168 // PutImage — push a minimal synthetic image.
169 const pushed = yield* postJson<{
170 digest?: string;
171 layerDigest: string;
172 configDigest: string;
173 }>("/push?tag=1.0.0");
174 expect(pushed.layerDigest).toMatch(/^sha256:/);
175 expect(pushed.digest).toMatch(/^sha256:/);
176
177 // DescribeImages.
178 const image = yield* getJson<{
179 tags?: string[];
180 digest?: string;
181 }>("/images?tag=1.0.0");
182 expect(image.tags).toContain("1.0.0");
183 expect(image.digest).toBe(pushed.digest);
184
185 // ListImages.
186 const ids = yield* getJson<{ tags: string[] }>("/image-ids");
187 expect(ids.tags).toContain("1.0.0");
188
189 // BatchGetImage.
190 const manifest = yield* getJson<{
191 mediaType?: string;
192 manifestLength: number;
193 }>("/manifest?tag=1.0.0");
194 expect(manifest.mediaType).toBe(
195 "application/vnd.docker.distribution.manifest.v2+json",
196 );
197 expect(manifest.manifestLength).toBeGreaterThan(100);
198
199 // GetDownloadUrlForLayer.
200 const layer = yield* getJson<{ hasUrl: boolean }>(
201 `/layer?digest=${encodeURIComponent(pushed.layerDigest)}`,
202 );
203 expect(layer.hasUrl).toBe(true);
204
205 // BatchCheckLayerAvailability.
206 const availability = yield* getJson<{ availability?: string }>(
207 `/availability?digest=${encodeURIComponent(pushed.layerDigest)}`,
208 );
209 expect(availability.availability).toBe("AVAILABLE");
210
211 // StartImageScan — the synthetic image is not a supported OS
212 // image, so the typed UnsupportedImageTypeException is as much a
213 // proof of the binding as a successful scan.
214 const scan = yield* postJson<{ status?: string; error?: string }>(
215 "/scan?tag=1.0.0",
216 );
217 expect(
218 scan.status !== undefined ||
219 scan.error === "UnsupportedImageTypeException" ||
220 scan.error === "LimitExceededException" ||
221 scan.error === "ValidationException",
222 ).toBe(true);
223
224 // DescribeImageScanFindings — real findings or the typed
225 // ScanNotFoundException when the scan was rejected above.
226 const findings = yield* getJson<{
227 status?: string;
228 error?: string;
229 }>("/scan-findings?tag=1.0.0");
230 expect(
231 findings.status !== undefined ||
232 findings.error === "ScanNotFoundException" ||
233 findings.error === "ValidationException",
234 ).toBe(true);
235
236 // BatchDeleteImage.
237 const deleted = yield* postJson<{ deleted: number }>(
238 "/delete?tag=1.0.0",
239 );
240 expect(deleted.deleted).toBe(1);
241 const after = yield* getJson<{ tags: string[] }>("/image-ids");
242 expect(after.tags).not.toContain("1.0.0");
243 }),
244 { timeout: 180_000 },
245 );
246 });
247
248 describe("ImageActionEventSource", () => {
249 test.provider(
250 "delivers push events to the handler",
251 () =>
252 Effect.gen(function* () {
253 // Each probe pushes a FRESH tag (a re-push of an existing tag is
254 // ImageAlreadyExists — no new event) and polls S3 for the marker
255 // the event handler wrote. A freshly-created EventBridge rule on
256 // the default bus takes up to ~a minute to become effective, so
257 // successive fresh-tag pushes ride out the propagation window.
258 const probe = (tag: string) =>
259 getJson<{ seen: boolean; tag: string }>(
260 `/events/probe?tag=${tag}`,
261 ).pipe(Effect.map((body) => body.seen));
262 const seen = yield* Effect.gen(function* () {
263 for (const tag of ["2.0.0", "2.0.1", "2.0.2", "2.0.3"]) {
264 if (yield* probe(tag)) return true;
265 }
266 return false;
267 });
268 expect(seen).toBe(true);
269 }),
270 { timeout: 180_000 },
271 );
272 });
273 },
274);