1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as eventbridge from "@distilled.cloud/aws/eventbridge";
5import { describe, expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11import ShieldTestFunctionLive, { ShieldTestFunction } from "./handler";
12
13const testOptions = { providers: AWS.providers() };
14const { test, beforeAll, afterAll } = Test.make(testOptions);
15const sharedStack = Core.scratchStack(testOptions, "ShieldBindings");
16
17// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
18// over 60s on a fresh deploy.
19const readinessPolicy = Schedule.max([
20 Schedule.fixed("2 seconds"),
21 Schedule.recurs(75),
22]);
23
24let baseUrl: string;
25let functionArn: string;
26
27class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
28 readonly status: number;
29 readonly body: string;
30}> {}
31
32// The shared Lambda fixture occasionally answers a transient 5xx under load
33// (cold re-init, IAM propagation on the freshly attached policy that the
34// handler's `Effect.orDie` surfaces as a 500). Retry only 5xx; a genuine
35// 4xx/assertion failure surfaces immediately.
36const send = (request: HttpClientRequest.HttpClientRequest) =>
37 HttpClient.execute(request).pipe(
38 Effect.flatMap((response) =>
39 response.status >= 500
40 ? response.text.pipe(
41 Effect.flatMap((body) =>
42 Effect.fail(
43 new TransientUpstream({ status: response.status, body }),
44 ),
45 ),
46 )
47 : Effect.succeed(response),
48 ),
49 Effect.retry({
50 while: (e) => e._tag === "TransientUpstream",
51 schedule: Schedule.max([
52 Schedule.exponential("500 millis"),
53 Schedule.recurs(6),
54 ]),
55 }),
56 );
57
58const getJson = (path: string) =>
59 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
60 Effect.flatMap((r) => r.json),
61 );
62
63// The testing account is NOT (and must never be) subscribed to Shield
64// Advanced. GetSubscriptionState and DescribeAttackStatistics succeed on any
65// account; the subscription-gated operations answer with their typed
66// entitlement/not-found tags — asserting the tag proves the binding + IAM
67// grant end-to-end at near-zero cost.
68describe.sequential(
69 "Shield Bindings",
70 {
71 tags: [
72 "provider:aws",
73 "provider:aws:lambda",
74 "provider:aws:shield",
75 "live",
76 ],
77 },
78 () => {
79 beforeAll(
80 Effect.gen(function* () {
81 yield* Effect.logInfo(
82 "Shield test setup: destroying previous resources",
83 );
84 yield* sharedStack.destroy();
85
86 yield* Effect.logInfo("Shield test setup: deploying fixture");
87 const attrs = yield* sharedStack.deploy(
88 Effect.gen(function* () {
89 return yield* ShieldTestFunction;
90 }).pipe(Effect.provide(ShieldTestFunctionLive)),
91 );
92
93 expect(attrs.functionUrl).toBeTruthy();
94 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
95 functionArn = attrs.functionArn;
96
97 const readinessUrl = `${baseUrl}/bindings`;
98 yield* Effect.logInfo(
99 `Shield test setup: probing readiness at ${readinessUrl}`,
100 );
101 yield* HttpClient.get(readinessUrl).pipe(
102 Effect.flatMap((response) =>
103 response.status === 200
104 ? Effect.succeed(response)
105 : Effect.fail(
106 new Error(`Function not ready: ${response.status}`),
107 ),
108 ),
109 Effect.tapError((error) =>
110 Effect.logWarning(
111 `Shield test setup: fixture not ready yet (${String(error)})`,
112 ),
113 ),
114 Effect.retry({ schedule: readinessPolicy }),
115 );
116 }),
117 { timeout: 240_000 },
118 );
119
120 afterAll(sharedStack.destroy(), { timeout: 120_000 });
121
122 describe("binding registration", () => {
123 test.provider("all 6 capabilities initialize in the runtime", (_stack) =>
124 Effect.gen(function* () {
125 const response = (yield* getJson("/bindings")) as { bound: string[] };
126 expect(response.bound).toHaveLength(6);
127 expect(response.bound).toContain("getSubscriptionState");
128 expect(response.bound).toContain("listAttacks");
129 }),
130 );
131 });
132
133 describe("GetSubscriptionState", () => {
134 test.provider("answers on any account, subscribed or not", (_stack) =>
135 Effect.gen(function* () {
136 const response = (yield* getJson("/subscription-state")) as {
137 state: string;
138 };
139 expect(["ACTIVE", "INACTIVE"]).toContain(response.state);
140 }),
141 );
142 });
143
144 describe("DescribeAttackStatistics", () => {
145 test.provider(
146 "returns the yearly attack statistics (available to Standard customers)",
147 (_stack) =>
148 Effect.gen(function* () {
149 const response = (yield* getJson("/attack-stats")) as {
150 dataItems?: number;
151 errorTag?: string;
152 };
153 expect(response.errorTag).toBeUndefined();
154 expect(response.dataItems).toBeGreaterThanOrEqual(0);
155 }),
156 );
157 });
158
159 describe("ListAttacks", () => {
160 test.provider(
161 "answers with attacks or the typed subscription-gate tag",
162 (_stack) =>
163 Effect.gen(function* () {
164 const response = (yield* getJson("/attacks")) as {
165 count?: number;
166 errorTag?: string;
167 };
168 if (response.errorTag) {
169 expect([
170 "InvalidOperationException",
171 "SubscriptionNotFound",
172 ]).toContain(response.errorTag);
173 } else {
174 expect(response.count).toBeGreaterThanOrEqual(0);
175 }
176 }),
177 );
178 });
179
180 describe("DescribeAttack", () => {
181 test.provider(
182 "a nonexistent attack id answers with an empty document or the typed AccessDeniedException",
183 (_stack) =>
184 Effect.gen(function* () {
185 // Observed live: Shield answers a nonexistent attack id with an
186 // empty success (no Attack document); the documented alternative is
187 // the typed AccessDeniedException. Either outcome proves the
188 // binding + IAM grant end-to-end.
189 const response = (yield* getJson("/attack-detail")) as {
190 attackId?: string | null;
191 errorTag?: string;
192 };
193 if (response.errorTag) {
194 expect(response.errorTag).toBe("AccessDeniedException");
195 } else {
196 expect(response.attackId ?? null).toBeNull();
197 }
198 }),
199 );
200 });
201
202 describe("DescribeDRTAccess", () => {
203 test.provider(
204 "a non-subscribed account answers with the typed ResourceNotFoundException",
205 (_stack) =>
206 Effect.gen(function* () {
207 const response = (yield* getJson("/drt-access")) as {
208 roleArn?: string | null;
209 errorTag?: string;
210 };
211 if (response.errorTag) {
212 expect(response.errorTag).toBe("ResourceNotFoundException");
213 } else {
214 // A subscribed account answers with the (possibly empty) grant.
215 expect(response.roleArn !== undefined).toBe(true);
216 }
217 }),
218 );
219 });
220
221 describe("ListResourcesInProtectionGroup", () => {
222 test.provider(
223 "a nonexistent group answers with the typed ResourceNotFoundException",
224 (_stack) =>
225 Effect.gen(function* () {
226 const response = (yield* getJson("/group-members")) as {
227 errorTag?: string;
228 };
229 expect(response.errorTag).toBe("ResourceNotFoundException");
230 }),
231 );
232 });
233
234 describe(
235 "consumeAttackEvents",
236 { tags: ["provider:aws:eventbridge"] },
237 () => {
238 test.provider(
239 "the deploy created an EventBridge rule targeting the function",
240 (_stack) =>
241 Effect.gen(function* () {
242 // Out-of-band via distilled: the fixture's consumeAttackEvents must
243 // have materialized as a rule on the default bus with the Lambda as
244 // target.
245 const { RuleNames } = yield* eventbridge.listRuleNamesByTarget({
246 TargetArn: functionArn,
247 });
248 expect((RuleNames ?? []).length).toBeGreaterThanOrEqual(1);
249 }),
250 );
251 },
252 );
253 },
254);