1import * as AWS from "@/AWS";
2import { Analyzer, ArchiveRule } from "@/AWS/AccessAnalyzer";
3import * as Test from "@/Test/Alchemy";
4import * as aa from "@distilled.cloud/aws/accessanalyzer";
5import { expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import type * as Duration from "effect/Duration";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import { makeAccessAnalyzerTestLease } from "./TestLease.ts";
11
12const { test, beforeAll, afterAll } = Test.make({
13 providers: AWS.providers(),
14});
15const testLease = makeAccessAnalyzerTestLease();
16
17beforeAll(testLease.acquire, { timeout: 240_000 });
18afterAll(testLease.release);
19
20const findAnalyzer = (name: string) =>
21 aa.getAnalyzer({ analyzerName: name }).pipe(
22 Effect.map((r) => r.analyzer),
23 Effect.catchTag("ResourceNotFoundException", () =>
24 Effect.succeed(undefined),
25 ),
26 );
27
28const findArchiveRule = (analyzerName: string, ruleName: string) =>
29 aa.getArchiveRule({ analyzerName, ruleName }).pipe(
30 Effect.map((r) => r.archiveRule),
31 Effect.catchTag("ResourceNotFoundException", () =>
32 Effect.succeed(undefined),
33 ),
34 );
35
36class AnalyzerStillExists extends Data.TaggedError("AnalyzerStillExists")<{
37 readonly name: string;
38}> {}
39
40const assertAnalyzerDeleted = (name: string) =>
41 findAnalyzer(name).pipe(
42 Effect.flatMap((analyzer) =>
43 analyzer === undefined
44 ? Effect.void
45 : Effect.fail(new AnalyzerStillExists({ name })),
46 ),
47 Effect.retry({
48 while: (e) => e._tag === "AnalyzerStillExists",
49 schedule: Schedule.max([
50 Schedule.spaced("2 seconds"),
51 Schedule.recurs(15),
52 ]),
53 }),
54 );
55
56test.provider(
57 "analyzer lifecycle: create ACCOUNT analyzer, update tags, manage archive rule, destroy",
58 (stack) =>
59 Effect.gen(function* () {
60 yield* stack.destroy();
61
62 const analyzerName = "alchemy-test-external-access";
63
64 const makeStack = (tagValue: string, ruleFilterValue: string) =>
65 Effect.gen(function* () {
66 const analyzer = yield* Analyzer("AccountAnalyzer", {
67 analyzerName,
68 type: "ACCOUNT",
69 tags: { Environment: tagValue },
70 });
71 const rule = yield* ArchiveRule("TrustedAccount", {
72 analyzerName: analyzer.analyzerName,
73 ruleName: "trusted-account",
74 filter: {
75 "principal.AWS": { eq: [ruleFilterValue] },
76 },
77 });
78 return { analyzer, rule };
79 });
80
81 // create
82 const { analyzer } = yield* stack.deploy(
83 makeStack("test", "111111111111"),
84 );
85 expect(analyzer.analyzerName).toBe(analyzerName);
86 expect(analyzer.analyzerArn).toContain(":analyzer/");
87 expect(analyzer.type).toBe("ACCOUNT");
88
89 // out-of-band verify
90 const created = yield* findAnalyzer(analyzerName);
91 expect(created?.type).toBe("ACCOUNT");
92 expect(created?.tags?.Environment).toBe("test");
93 expect(created?.tags?.["alchemy::id"]).toBe("AccountAnalyzer");
94
95 const createdRule = yield* findArchiveRule(
96 analyzerName,
97 "trusted-account",
98 );
99 expect(createdRule?.filter?.["principal.AWS"]?.eq).toEqual([
100 "111111111111",
101 ]);
102
103 // update tags + archive rule filter in place (no replacement)
104 const updated = yield* stack.deploy(makeStack("prod", "222222222222"));
105 expect(updated.analyzer.analyzerArn).toBe(analyzer.analyzerArn);
106
107 const afterTagUpdate = yield* findAnalyzer(analyzerName);
108 expect(afterTagUpdate?.tags?.Environment).toBe("prod");
109
110 const afterRuleUpdate = yield* findArchiveRule(
111 analyzerName,
112 "trusted-account",
113 );
114 expect(afterRuleUpdate?.filter?.["principal.AWS"]?.eq).toEqual([
115 "222222222222",
116 ]);
117
118 // destroy
119 yield* stack.destroy();
120 yield* assertAnalyzerDeleted(analyzerName);
121 }),
122 {
123 tags: ["provider:aws", "provider:aws:accessanalyzer", "live"],
124 timeout: 180_000,
125 },
126);
127
128const observedUnusedAccessAge = (
129 analyzer: aa.AnalyzerSummary | undefined,
130): number | undefined => analyzer?.configuration?.unusedAccess?.unusedAccessAge;
131
132test.provider(
133 "unused-access analyzer: create with a tracking period, replace on change, destroy",
134 (stack) =>
135 Effect.gen(function* () {
136 yield* stack.destroy();
137
138 const analyzerName = "alchemy-test-unused-access-age";
139
140 const makeStack = (unusedAccessAge: Duration.Input) =>
141 Effect.gen(function* () {
142 return {
143 analyzer: yield* Analyzer("UnusedAccessAnalyzer", {
144 analyzerName,
145 type: "ACCOUNT_UNUSED_ACCESS",
146 unusedAccessAge,
147 }),
148 };
149 });
150
151 // create with a 180-day tracking period
152 const { analyzer } = yield* stack.deploy(makeStack("180 days"));
153 expect(analyzer.type).toBe("ACCOUNT_UNUSED_ACCESS");
154
155 const created = yield* findAnalyzer(analyzerName);
156 expect(observedUnusedAccessAge(created)).toBe(180);
157
158 // the tracking period is create-only — changing it replaces the
159 // analyzer (delete-first, keeping the pinned name). The new observed
160 // period is the proof: the API rejects in-place updates.
161 const updated = yield* stack.deploy(makeStack("365 days"));
162 expect(updated.analyzer.analyzerName).toBe(analyzerName);
163
164 const afterReplace = yield* findAnalyzer(analyzerName);
165 expect(observedUnusedAccessAge(afterReplace)).toBe(365);
166
167 // destroy
168 yield* stack.destroy();
169 yield* assertAnalyzerDeleted(analyzerName);
170 }),
171 {
172 tags: ["provider:aws", "provider:aws:accessanalyzer", "live"],
173 timeout: 180_000,
174 },
175);