1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as iam from "@distilled.cloud/aws/iam";
5import * as licensemanager from "@distilled.cloud/aws/license-manager";
6import * as sts from "@distilled.cloud/aws/sts";
7import { describe, expect } from "alchemy-test";
8import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";
10import * as Schedule from "effect/Schedule";
11import * as HttpClient from "effect/http/HttpClient";
12import * as HttpClientRequest from "effect/http/HttpClientRequest";
13import LicenseManagerSellerFunctionLive, {
14 LicenseManagerSellerFunction,
15} from "./seller-handler";
16
17const testOptions = { providers: AWS.providers() };
18const { test, beforeAll, afterAll } = Test.make(testOptions);
19const sharedStack = Core.scratchStack(testOptions, "LicenseManagerSeller");
20
21// The deterministic license name the fixture issues (and sweeps) — used for
22// the out-of-band zero-orphan verification below.
23const FIXTURE_LICENSE_NAME = "alchemy-lm-seller-e2e";
24
25// License Manager requires one-time account onboarding (see
26// LicenseConfiguration.test.ts): create the service-linked role
27// idempotently, then probe with a bounded typed retry through IAM
28// propagation. Raw distilled calls need the AWS layer, so this runs inside
29// `test.provider` bodies (beforeAll provides no cloud credentials).
30const ensureOnboarded = Effect.gen(function* () {
31 yield* iam
32 .createServiceLinkedRole({
33 AWSServiceName: "license-manager.amazonaws.com",
34 })
35 .pipe(Effect.catchTag("InvalidInputException", () => Effect.void));
36 yield* licensemanager.listLicenseConfigurations({ MaxResults: 1 }).pipe(
37 Effect.retry({
38 while: (e) => e._tag === "AccessDeniedException",
39 schedule: Schedule.exponential("1 second"),
40 times: 8,
41 }),
42 );
43});
44
45// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
46// over 60s on a fresh deploy.
47const readinessPolicy = Schedule.max([
48 Schedule.fixed("2 seconds"),
49 Schedule.recurs(75),
50]);
51
52let baseUrl: string;
53
54class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
55 readonly status: number;
56 readonly body: string;
57}> {}
58
59// The Lambda fixture occasionally answers a transient 5xx (cold re-init,
60// IAM propagation on the freshly attached policy that the handler's
61// `Effect.orDie` surfaces as a 500). Retry only 5xx; a genuine 4xx or
62// assertion failure surfaces immediately.
63const send = (request: HttpClientRequest.HttpClientRequest) =>
64 HttpClient.execute(request).pipe(
65 Effect.flatMap((response) =>
66 response.status >= 500
67 ? response.text.pipe(
68 Effect.flatMap((body) =>
69 Effect.fail(
70 new TransientUpstream({ status: response.status, body }),
71 ),
72 ),
73 )
74 : Effect.succeed(response),
75 ),
76 Effect.retry({
77 while: (e) => e._tag === "TransientUpstream",
78 schedule: Schedule.max([
79 Schedule.exponential("2 seconds"),
80 Schedule.recurs(4),
81 ]),
82 }),
83 );
84
85const getJson = (path: string) =>
86 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
87 Effect.flatMap((r) => r.json),
88 );
89
90const postJson = (path: string) =>
91 send(HttpClientRequest.post(`${baseUrl}${path}`)).pipe(
92 Effect.flatMap((r) => r.json),
93 );
94
95describe.sequential(
96 "LicenseManager Seller Lifecycle",
97 {
98 tags: [
99 "provider:aws",
100 "provider:aws:lambda",
101 "provider:aws:licensemanager",
102 "live",
103 ],
104 },
105 () => {
106 beforeAll(
107 Effect.gen(function* () {
108 yield* Effect.logInfo(
109 "LicenseManager seller setup: destroying previous resources",
110 );
111 yield* sharedStack.destroy();
112
113 yield* Effect.logInfo("LicenseManager seller setup: deploying fixture");
114 const { functionUrl } = yield* sharedStack.deploy(
115 Effect.gen(function* () {
116 return yield* LicenseManagerSellerFunction;
117 }).pipe(Effect.provide(LicenseManagerSellerFunctionLive)),
118 );
119
120 expect(functionUrl).toBeTruthy();
121 baseUrl = functionUrl!.replace(/\/+$/, "");
122
123 const readinessUrl = `${baseUrl}/bindings`;
124 yield* Effect.logInfo(
125 `LicenseManager seller setup: probing readiness at ${readinessUrl}`,
126 );
127 yield* HttpClient.get(readinessUrl).pipe(
128 Effect.flatMap((response) =>
129 response.status === 200
130 ? Effect.succeed(response)
131 : Effect.fail(
132 new Error(`Function not ready: ${response.status}`),
133 ),
134 ),
135 Effect.tapError((error) =>
136 Effect.logWarning(
137 `LicenseManager seller setup: fixture not ready yet (${String(error)})`,
138 ),
139 ),
140 Effect.retry({ schedule: readinessPolicy }),
141 );
142 }),
143 { timeout: 240_000 },
144 );
145
146 afterAll(sharedStack.destroy(), { timeout: 120_000 });
147
148 describe("binding registration", () => {
149 test.provider(
150 "all 9 seller-plane capabilities initialize in the runtime",
151 (_stack) =>
152 Effect.gen(function* () {
153 const response = (yield* getJson("/bindings")) as {
154 bound: string[];
155 };
156 expect(response.bound).toHaveLength(9);
157 }),
158 );
159 });
160
161 describe("seller data plane", { tags: ["provider:aws:iam"] }, () => {
162 test.provider(
163 "issue -> version -> token mint/exchange -> checkout/extend/checkin -> grant -> delete",
164 (_stack) =>
165 Effect.gen(function* () {
166 yield* ensureOnboarded;
167 const { Account } = yield* sts.getCallerIdentity({});
168 const response = (yield* postJson(
169 `/lifecycle?account=${Account}`,
170 )) as {
171 licenseArn: string;
172 licenseStatus: string | null;
173 bumpedVersion: string | null;
174 tokenListed: number;
175 accessToken: string;
176 checkedOut: boolean;
177 extended: boolean;
178 grant: string;
179 deletionStatus: string | null;
180 };
181
182 // CreateLicense issued a real license that reached AVAILABLE.
183 expect(response.licenseArn).toContain(":license:");
184 expect(response.licenseStatus).toBe("AVAILABLE");
185 // CreateLicenseVersion published version 2.
186 expect(response.bumpedVersion).toBe("2");
187 // CreateToken + ListTokens round-tripped the token metadata.
188 expect(response.tokenListed).toBe(1);
189 // GetAccessToken exchanged the (Redacted) refresh token — or
190 // surfaced one of its typed rejections; both prove the grant.
191 expect([
192 "Ok",
193 "AuthorizationException",
194 "ValidationException",
195 "AccessDeniedException",
196 "InvalidParameterValueException",
197 ]).toContain(response.accessToken);
198 // CheckoutLicense / ExtendLicenseConsumption ran for real.
199 expect(response.checkedOut).toBe(true);
200 expect(response.extended).toBe(true);
201 // CreateGrant + DeleteGrant — a self-account grant may be rejected
202 // with a typed validation error; both outcomes prove the wiring.
203 expect([
204 "CreatedAndDeleted",
205 "InvalidParameterValueException",
206 "ValidationException",
207 "AuthorizationException",
208 "ResourceLimitExceededException",
209 ]).toContain(response.grant);
210 // DeleteLicense retired it.
211 expect(["PENDING_DELETE", "DELETED"]).toContain(
212 response.deletionStatus,
213 );
214
215 // Out-of-band zero-orphan verification via distilled: no fixture
216 // license remains AVAILABLE.
217 const { Licenses } = yield* licensemanager.listLicenses({});
218 const leftovers = (Licenses ?? []).filter(
219 (l): boolean =>
220 l.LicenseName === FIXTURE_LICENSE_NAME &&
221 l.Status === "AVAILABLE",
222 );
223 expect(leftovers).toHaveLength(0);
224 }),
225 { timeout: 120_000 },
226 );
227 });
228
229 describe("CreateGrantVersion", () => {
230 test.provider(
231 "surfaces a typed rejection for a nonexistent grant ARN (proving the grant)",
232 (_stack) =>
233 Effect.gen(function* () {
234 const response = (yield* getJson("/grant-version-invalid")) as {
235 tag: string;
236 };
237 expect([
238 "InvalidParameterValueException",
239 "ValidationException",
240 "AuthorizationException",
241 ]).toContain(response.tag);
242 }),
243 { timeout: 60_000 },
244 );
245 });
246 },
247);