1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import { describe, expect } from "alchemy-test";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import AccountTestFunctionLive, { AccountTestFunction } from "./handler";
11
12const testOptions = { providers: AWS.providers() };
13const { test, beforeAll, afterAll } = Test.make(testOptions);
14const sharedStack = Core.scratchStack(testOptions, "AccountBindings");
15
16// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
17// over 60s on a fresh deploy.
18const readinessPolicy = Schedule.max([
19 Schedule.fixed("2 seconds"),
20 Schedule.recurs(75),
21]);
22
23let baseUrl: string;
24
25class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
26 readonly status: number;
27 readonly body: string;
28}> {}
29
30// The shared Lambda fixture occasionally answers a transient 5xx under load
31// (cold re-init, IAM propagation on the freshly attached policy that the
32// handler's `Effect.orDie` surfaces as a 500). Retry only 5xx; a genuine
33// 4xx/assertion failure surfaces immediately.
34const send = (request: HttpClientRequest.HttpClientRequest) =>
35 HttpClient.execute(request).pipe(
36 Effect.flatMap((response) =>
37 response.status >= 500
38 ? response.text.pipe(
39 Effect.flatMap((body) =>
40 Effect.fail(
41 new TransientUpstream({ status: response.status, body }),
42 ),
43 ),
44 )
45 : Effect.succeed(response),
46 ),
47 Effect.retry({
48 while: (e) => e._tag === "TransientUpstream",
49 schedule: Schedule.max([
50 Schedule.exponential("500 millis"),
51 Schedule.recurs(6),
52 ]),
53 }),
54 );
55
56// The freshly attached IAM policy can take ~10-30s to propagate to the
57// Lambda's role; until it does, granted calls surface a transient
58// AccessDeniedException. Poll (bounded) until the response is no longer
59// access-denied so grant-proof assertions see steady-state behavior.
60const getJson = (path: string) =>
61 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
62 Effect.flatMap((r) => r.json),
63 Effect.repeat({
64 schedule: Schedule.spaced("3 seconds"),
65 until: (response): boolean =>
66 (response as { tag?: string }).tag !== "AccessDeniedException",
67 times: 10,
68 }),
69 );
70
71describe.sequential(
72 "Account Bindings",
73 {
74 tags: [
75 "provider:aws",
76 "provider:aws:account",
77 "provider:aws:lambda",
78 "live",
79 ],
80 },
81 () => {
82 beforeAll(
83 Effect.gen(function* () {
84 yield* Effect.logInfo(
85 "Account test setup: destroying previous resources",
86 );
87 yield* sharedStack.destroy();
88
89 yield* Effect.logInfo("Account test setup: deploying fixture");
90 const attrs = yield* sharedStack.deploy(
91 Effect.gen(function* () {
92 return yield* AccountTestFunction;
93 }).pipe(Effect.provide(AccountTestFunctionLive)),
94 );
95
96 expect(attrs.functionUrl).toBeTruthy();
97 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
98
99 const readinessUrl = `${baseUrl}/bindings`;
100 yield* Effect.logInfo(
101 `Account test setup: probing readiness at ${readinessUrl}`,
102 );
103 yield* HttpClient.get(readinessUrl).pipe(
104 Effect.flatMap((response) =>
105 response.status === 200
106 ? Effect.succeed(response)
107 : Effect.fail(
108 new Error(`Function not ready: ${response.status}`),
109 ),
110 ),
111 Effect.tapError((error) =>
112 Effect.logWarning(
113 `Account test setup: fixture not ready yet (${String(error)})`,
114 ),
115 ),
116 Effect.retry({ schedule: readinessPolicy }),
117 );
118 }),
119 { timeout: 240_000 },
120 );
121
122 afterAll(sharedStack.destroy(), { timeout: 120_000 });
123
124 describe("binding registration", () => {
125 test.provider("all 5 capabilities initialize in the runtime", (_stack) =>
126 Effect.gen(function* () {
127 const response = (yield* getJson("/bindings")) as { bound: string[] };
128 expect(response.bound).toEqual([
129 "getAccountInformation",
130 "getContactInformation",
131 "getAlternateContact",
132 "listRegions",
133 "getRegionOptStatus",
134 ]);
135 }),
136 );
137 });
138
139 describe("GetAccountInformation", () => {
140 test.provider("reads the account's metadata", (_stack) =>
141 Effect.gen(function* () {
142 const response = (yield* getJson("/account-info")) as
143 | {
144 ok: true;
145 accountId: string | null;
146 accountState: string | null;
147 hasAccountName: boolean;
148 }
149 | { ok: false; tag: string };
150 expect(response.ok).toBe(true);
151 if (response.ok) {
152 expect(response.accountId).toMatch(/^\d{12}$/);
153 expect(response.accountState).toBe("ACTIVE");
154 }
155 }),
156 );
157 });
158
159 describe("GetContactInformation", () => {
160 test.provider("reads the account's primary contact", (_stack) =>
161 Effect.gen(function* () {
162 const response = (yield* getJson("/contact-info")) as
163 | { ok: true; hasFullName: boolean; hasCountryCode: boolean }
164 | { ok: false; tag: string };
165 expect(response.ok).toBe(true);
166 if (response.ok) {
167 // Every account has a primary contact with a name and country.
168 expect(response.hasFullName).toBe(true);
169 expect(response.hasCountryCode).toBe(true);
170 }
171 }),
172 );
173 });
174
175 describe("GetAlternateContact", () => {
176 test.provider(
177 "reads the billing contact or surfaces the typed not-found tag",
178 (_stack) =>
179 Effect.gen(function* () {
180 const response = (yield* getJson("/alternate-contact")) as
181 | { ok: true; contactType: string | null }
182 | { ok: false; tag: string };
183 if (response.ok) {
184 expect(response.contactType).toBe("BILLING");
185 } else {
186 // The BILLING alternate contact isn't set on the account — the
187 // typed tag proves the grant (an IAM gap would surface
188 // AccessDeniedException, which getJson polls away and would
189 // fail the assertion below).
190 expect(response.tag).toBe("ResourceNotFoundException");
191 }
192 }),
193 );
194 });
195
196 describe("ListRegions", () => {
197 test.provider("lists the account's regions with opt statuses", (_stack) =>
198 Effect.gen(function* () {
199 const response = (yield* getJson("/regions")) as
200 | { ok: true; count: number; regionNames: string[] }
201 | { ok: false; tag: string };
202 expect(response.ok).toBe(true);
203 if (response.ok) {
204 expect(response.count).toBeGreaterThanOrEqual(1);
205 expect(response.regionNames).toContain("us-east-1");
206 }
207 }),
208 );
209 });
210
211 describe("GetRegionOptStatus", () => {
212 test.provider("reads us-east-1's opt status", (_stack) =>
213 Effect.gen(function* () {
214 const response = (yield* getJson("/region-opt-status")) as
215 | {
216 ok: true;
217 regionName: string | null;
218 regionOptStatus: string | null;
219 }
220 | { ok: false; tag: string };
221 expect(response.ok).toBe(true);
222 if (response.ok) {
223 expect(response.regionName).toBe("us-east-1");
224 expect(response.regionOptStatus).toBe("ENABLED_BY_DEFAULT");
225 }
226 }),
227 );
228 });
229 },
230);