1import * as AWS from "@/AWS";
2import { Repository } from "@/AWS/ECR/Repository.ts";
3import type { PolicyDocument } from "@/AWS/IAM/Policy.ts";
4import { normalizePolicyDocument } from "@/AWS/IAM/Policy.ts";
5import * as Provider from "@/Provider";
6import * as Test from "@/Test/Alchemy";
7import * as ecr from "@distilled.cloud/aws/ecr";
8import { expect } from "alchemy-test";
9import * as Data from "effect/Data";
10import * as Effect from "effect/Effect";
11import * as Schedule from "effect/Schedule";
12
13const { test } = Test.make({ providers: AWS.providers() });
14
15class RepositoryStillExists extends Data.TaggedError("RepositoryStillExists") {}
16
17// Out-of-band proof that the trailing destroy actually removed the
18// repository: describeRepositories must settle on the typed
19// RepositoryNotFoundException.
20const assertRepositoryDeleted = Effect.fn(function* (repositoryName: string) {
21 yield* ecr.describeRepositories({ repositoryNames: [repositoryName] }).pipe(
22 Effect.flatMap(() => Effect.fail(new RepositoryStillExists())),
23 Effect.retry({
24 while: (e) => e._tag === "RepositoryStillExists",
25 schedule: Schedule.max([Schedule.exponential(250), Schedule.recurs(8)]),
26 }),
27 Effect.catchTag("RepositoryNotFoundException", () => Effect.void),
28 );
29});
30
31// Canonical `list()` test (AWS account/region-scoped collection): deploy a real
32// repository, resolve the provider from context via the typed `findProvider`,
33// call `list()`, and assert the deployed repository appears in the
34// exhaustively-paginated result.
35test.provider(
36 "list enumerates the deployed repository",
37 (stack) =>
38 Effect.gen(function* () {
39 yield* stack.destroy();
40
41 const repo = yield* stack.deploy(
42 Effect.gen(function* () {
43 return yield* Repository("ListRepository", {
44 repositoryName: "alchemy-test-ecr-repo-list",
45 });
46 }),
47 );
48
49 const provider = yield* Provider.findProvider(Repository);
50 const all = yield* provider.list();
51
52 expect(all.some((r) => r.repositoryName === repo.repositoryName)).toBe(
53 true,
54 );
55
56 yield* stack.destroy();
57 yield* assertRepositoryDeleted(repo.repositoryName);
58 }),
59 { tags: ["provider:aws", "provider:aws:ecr", "live"] },
60);
61
62test.provider(
63 "reconciles tag mutability and scan-on-push on an existing repository",
64 (stack) =>
65 Effect.gen(function* () {
66 yield* stack.destroy();
67
68 const deployRepository = (
69 imageTagMutability: "MUTABLE" | "IMMUTABLE",
70 scanOnPush: boolean,
71 ) =>
72 stack.deploy(
73 Repository("SettingsRepository", {
74 repositoryName: "alchemy-test-ecr-repo-settings",
75 imageTagMutability,
76 scanOnPush,
77 }),
78 );
79
80 yield* deployRepository("MUTABLE", false);
81 const updated = yield* deployRepository("IMMUTABLE", true);
82
83 const described = yield* ecr.describeRepositories({
84 repositoryNames: [updated.repositoryName],
85 });
86 const observed = described.repositories?.[0];
87 expect(observed?.imageTagMutability).toBe("IMMUTABLE");
88 expect(observed?.imageScanningConfiguration?.scanOnPush).toBe(true);
89 expect(updated.imageTagMutability).toBe("IMMUTABLE");
90 expect(updated.scanOnPush).toBe(true);
91
92 yield* stack.destroy();
93 yield* assertRepositoryDeleted(updated.repositoryName);
94 }),
95 { tags: ["provider:aws", "provider:aws:ecr", "live"], timeout: 120_000 },
96);
97
98const repositoryPolicy: PolicyDocument = {
99 Version: "2012-10-17",
100 Statement: [
101 {
102 Sid: "LambdaECRImageRetrieval",
103 Effect: "Allow",
104 Principal: { Service: "lambda.amazonaws.com" },
105 Action: ["ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer"],
106 },
107 ],
108};
109
110const readRepositoryPolicy = (repositoryName: string) =>
111 ecr.getRepositoryPolicy({ repositoryName }).pipe(
112 Effect.map((response) => response.policyText),
113 Effect.catchTag("RepositoryPolicyNotFoundException", () =>
114 Effect.succeed(undefined),
115 ),
116 );
117
118test.provider(
119 "PolicyDocument-valued policy deploys and re-deploys clean",
120 (stack) =>
121 Effect.gen(function* () {
122 yield* stack.destroy();
123
124 const deployWithPolicy = () =>
125 stack.deploy(
126 Effect.gen(function* () {
127 return yield* Repository("PolicyRepository", {
128 repositoryName: "alchemy-test-ecr-repo-policy",
129 policy: repositoryPolicy,
130 });
131 }),
132 );
133
134 const repo = yield* deployWithPolicy();
135 expect(repo.policy).toBeDefined();
136
137 // Out-of-band: the applied policy is equivalent to the structured
138 // document (normalized comparison — key order / whitespace agnostic).
139 const observed = yield* readRepositoryPolicy(repo.repositoryName);
140 expect(observed).toBeDefined();
141 expect(normalizePolicyDocument(observed!)).toBe(
142 normalizePolicyDocument(repositoryPolicy),
143 );
144
145 // Re-deploy the identical PolicyDocument — must converge cleanly (the
146 // provider diffs normalized observed vs desired and no-ops the set).
147 const again = yield* deployWithPolicy();
148 expect(again.repositoryArn).toBe(repo.repositoryArn);
149 const observedAgain = yield* readRepositoryPolicy(repo.repositoryName);
150 expect(normalizePolicyDocument(observedAgain!)).toBe(
151 normalizePolicyDocument(repositoryPolicy),
152 );
153
154 // Removing the prop deletes the repository policy.
155 const removed = yield* stack.deploy(
156 Effect.gen(function* () {
157 return yield* Repository("PolicyRepository", {
158 repositoryName: "alchemy-test-ecr-repo-policy",
159 });
160 }),
161 );
162 expect(removed.policy).toBeUndefined();
163 expect(yield* readRepositoryPolicy(repo.repositoryName)).toBeUndefined();
164
165 yield* stack.destroy();
166 yield* assertRepositoryDeleted(repo.repositoryName);
167 }),
168 {
169 tags: ["provider:aws", "provider:aws:ecr", "provider:aws:iam", "live"],
170 timeout: 120_000,
171 },
172);