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 CognitoTestFunctionLive, { CognitoTestFunction } from "./handler";
11
12const testOptions = { providers: AWS.providers() };
13const { test, beforeAll, afterAll } = Test.make(testOptions);
14const sharedStack = Core.scratchStack(testOptions, "CognitoBindings");
15
16// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
17// over 60s on a fresh deploy under parallel-suite load.
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// Retry transient 5xx only (cold re-init, IAM propagation); 4xx and
31// assertion failures surface immediately.
32const send = (request: HttpClientRequest.HttpClientRequest) =>
33 HttpClient.execute(request).pipe(
34 Effect.flatMap((response) =>
35 response.status >= 500
36 ? response.text.pipe(
37 Effect.flatMap((body) =>
38 Effect.fail(
39 new TransientUpstream({ status: response.status, body }),
40 ),
41 ),
42 )
43 : Effect.succeed(response),
44 ),
45 Effect.retry({
46 while: (e) => e._tag === "TransientUpstream",
47 schedule: Schedule.max([
48 Schedule.exponential("500 millis"),
49 Schedule.recurs(6),
50 ]),
51 }),
52 );
53
54const decodeJwtPayload = (token: string): Record<string, unknown> =>
55 JSON.parse(Buffer.from(token.split(".")[1]!, "base64url").toString("utf8"));
56
57describe(
58 "Cognito Bindings",
59 {
60 tags: [
61 "provider:aws",
62 "provider:aws:cognito",
63 "provider:aws:iam",
64 "provider:aws:lambda",
65 "live",
66 ],
67 },
68 () => {
69 beforeAll(
70 Effect.gen(function* () {
71 yield* Effect.logInfo("Cognito test setup: destroying previous stack");
72 yield* sharedStack.destroy();
73
74 yield* Effect.logInfo("Cognito test setup: deploying fixture");
75 const { functionUrl } = yield* sharedStack.deploy(
76 Effect.gen(function* () {
77 return yield* CognitoTestFunction;
78 }).pipe(Effect.provide(CognitoTestFunctionLive)),
79 );
80
81 expect(functionUrl).toBeTruthy();
82 baseUrl = functionUrl!.replace(/\/+$/, "");
83 const readinessUrl = `${baseUrl}/config`;
84
85 yield* Effect.logInfo(
86 `Cognito test setup: probing readiness at ${readinessUrl}`,
87 );
88 yield* HttpClient.get(readinessUrl).pipe(
89 Effect.flatMap((response) =>
90 response.status === 200
91 ? Effect.succeed(response)
92 : Effect.fail(
93 new Error(`Function not ready: ${response.status}`),
94 ),
95 ),
96 Effect.tapError((error) =>
97 Effect.logWarning(
98 `Cognito test setup: fixture not ready yet (${String(error)})`,
99 ),
100 ),
101 Effect.retry({ schedule: readinessPolicy }),
102 );
103 }),
104 { timeout: 240_000 },
105 );
106
107 afterAll(sharedStack.destroy(), { timeout: 120_000 });
108
109 describe("UserPoolAuth", () => {
110 test.provider(
111 "USER_PASSWORD_AUTH returns valid Cognito JWTs",
112 (_stack) =>
113 Effect.gen(function* () {
114 const config = (yield* send(
115 HttpClientRequest.get(`${baseUrl}/config`),
116 ).pipe(Effect.flatMap((r) => r.json))) as {
117 userPoolId: string;
118 clientId: string;
119 };
120 expect(config.userPoolId).toMatch(/^[a-z0-9-]+_[A-Za-z0-9]+$/);
121
122 const response = (yield* send(
123 HttpClientRequest.post(
124 `${baseUrl}/auth-flow?username=authflow-user`,
125 ),
126 ).pipe(Effect.flatMap((r) => r.json))) as {
127 challengeName: string | undefined;
128 idToken: string | undefined;
129 accessToken: string | undefined;
130 refreshToken: string | undefined;
131 tokenType: string | undefined;
132 getUserUsername: string | undefined;
133 };
134
135 expect(response.challengeName).toBeUndefined();
136 expect(response.tokenType).toBe("Bearer");
137 expect(response.idToken).toBeTruthy();
138 expect(response.accessToken).toBeTruthy();
139 expect(response.refreshToken).toBeTruthy();
140 expect(response.getUserUsername).toBe("authflow-user");
141
142 // The ID token must be a real Cognito JWT issued by our pool.
143 const payload = decodeJwtPayload(response.idToken!);
144 expect(payload.iss).toContain("cognito-idp.");
145 expect(payload.iss).toContain(`/${config.userPoolId}`);
146 expect(payload.token_use).toBe("id");
147 expect(payload["cognito:username"]).toBe("authflow-user");
148 expect(payload.aud).toBe(config.clientId);
149
150 const accessPayload = decodeJwtPayload(response.accessToken!);
151 expect(accessPayload.token_use).toBe("access");
152 expect(accessPayload.client_id).toBe(config.clientId);
153 }),
154 { timeout: 120_000 },
155 );
156
157 test.provider(
158 "signUp + adminConfirmSignUp enables password auth",
159 (_stack) =>
160 Effect.gen(function* () {
161 const response = (yield* send(
162 HttpClientRequest.post(
163 `${baseUrl}/sign-up-flow?username=signup-user`,
164 ),
165 ).pipe(Effect.flatMap((r) => r.json))) as {
166 userConfirmed: boolean;
167 userSub: string;
168 confirmedStatus: string;
169 hasIdToken: boolean;
170 };
171
172 expect(response.userConfirmed).toBe(false);
173 expect(response.userSub).toBeTruthy();
174 expect(response.confirmedStatus).toBe("CONFIRMED");
175 expect(response.hasIdToken).toBe(true);
176 }),
177 { timeout: 120_000 },
178 );
179 });
180
181 describe("UserPoolAdmin", () => {
182 test.provider(
183 "admin user lifecycle: create, disable, enable, groups, list, delete",
184 (_stack) =>
185 Effect.gen(function* () {
186 const response = (yield* send(
187 HttpClientRequest.post(
188 `${baseUrl}/user-lifecycle?username=lifecycle-user`,
189 ),
190 ).pipe(Effect.flatMap((r) => r.json))) as {
191 createdStatus: string;
192 disabledEnabled: boolean;
193 groupMembers: string[];
194 listedCount: number;
195 deleted: boolean;
196 };
197
198 expect(response.createdStatus).toBe("FORCE_CHANGE_PASSWORD");
199 expect(response.disabledEnabled).toBe(false);
200 expect(response.groupMembers).toContain("lifecycle-user");
201 expect(response.listedCount).toBe(1);
202 expect(response.deleted).toBe(true);
203 }),
204 { timeout: 120_000 },
205 );
206
207 test.provider(
208 "extended admin surface: attribute deletion, group listings, devices",
209 (_stack) =>
210 Effect.gen(function* () {
211 const response = (yield* send(
212 HttpClientRequest.post(
213 `${baseUrl}/admin-extended?username=admin-extended-user`,
214 ),
215 ).pipe(Effect.flatMap((r) => r.json))) as {
216 nickname: string | undefined;
217 nicknameAfter: string | null;
218 userGroups: string[];
219 allGroups: string[];
220 deviceCount: number;
221 };
222
223 expect(response.nickname).toBe("admin-extended");
224 expect(response.nicknameAfter).toBeNull();
225 expect(response.userGroups.length).toBe(1);
226 expect(response.allGroups).toEqual(
227 expect.arrayContaining(response.userGroups),
228 );
229 // no remembered devices on a fresh user (or device tracking off)
230 expect(response.deviceCount).toBeLessThanOrEqual(0);
231 }),
232 { timeout: 120_000 },
233 );
234 });
235
236 describe("UserPoolAuth self-service", () => {
237 test.provider(
238 "changePassword, attribute self-service, token refresh, deleteUser",
239 (_stack) =>
240 Effect.gen(function* () {
241 const response = (yield* send(
242 HttpClientRequest.post(
243 `${baseUrl}/self-service?username=self-service-user`,
244 ),
245 ).pipe(Effect.flatMap((r) => r.json))) as {
246 changedPasswordAuth: boolean;
247 nickname: string | undefined;
248 nicknameAfter: string | null;
249 refreshedHasAccessToken: boolean;
250 deleted: boolean;
251 };
252
253 expect(response.changedPasswordAuth).toBe(true);
254 expect(response.nickname).toBe("self-service");
255 expect(response.nicknameAfter).toBeNull();
256 expect(response.refreshedHasAccessToken).toBe(true);
257 expect(response.deleted).toBe(true);
258 }),
259 { timeout: 120_000 },
260 );
261 });
262
263 describe("IdentityPoolAuth", () => {
264 test.provider(
265 "guest getId vends AWS credentials and an OIDC token",
266 (_stack) =>
267 Effect.gen(function* () {
268 const response = (yield* send(
269 HttpClientRequest.post(`${baseUrl}/identity-flow`),
270 ).pipe(Effect.flatMap((r) => r.json))) as {
271 identityId: string;
272 hasAccessKeyId: boolean;
273 hasSessionToken: boolean;
274 hasOpenIdToken: boolean;
275 };
276
277 expect(response.identityId).toMatch(/^[a-z0-9-]+:/);
278 expect(response.hasAccessKeyId).toBe(true);
279 expect(response.hasSessionToken).toBe(true);
280 expect(response.hasOpenIdToken).toBe(true);
281 }),
282 { timeout: 120_000 },
283 );
284 });
285
286 describe("IdentityPoolAdmin", () => {
287 test.provider(
288 "describeIdentity, listIdentities, deleteIdentities",
289 (_stack) =>
290 Effect.gen(function* () {
291 const response = (yield* send(
292 HttpClientRequest.post(`${baseUrl}/identity-flow`),
293 ).pipe(Effect.flatMap((r) => r.json))) as {
294 identityId: string;
295 describedIdentityId: string;
296 listedContains: boolean;
297 };
298
299 expect(response.describedIdentityId).toBe(response.identityId);
300 expect(response.listedContains).toBe(true);
301 }),
302 { timeout: 120_000 },
303 );
304 });
305 },
306);