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 OrganizationsTestFunctionLive, {
12 OrganizationsTestFunction,
13} from "./handler";
14
15const testOptions = { providers: AWS.providers() };
16const { test, beforeAll, afterAll } = Test.make(testOptions);
17const sharedStack = Core.scratchStack(testOptions, "OrganizationsBindings");
18
19// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
20// over 60s on a fresh deploy.
21const readinessPolicy = Schedule.max([
22 Schedule.fixed("2 seconds"),
23 Schedule.recurs(75),
24]);
25
26let baseUrl: string;
27let functionArn: string;
28
29class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
30 readonly status: number;
31 readonly body: string;
32}> {}
33
34// The shared Lambda fixture occasionally answers a transient 5xx under load
35// (cold re-init, IAM propagation on the freshly attached policy that the
36// handler's `Effect.orDie` surfaces as a 500). Retry only 5xx; a genuine
37// 4xx/assertion failure surfaces immediately.
38const send = (request: HttpClientRequest.HttpClientRequest) =>
39 HttpClient.execute(request).pipe(
40 Effect.flatMap((response) =>
41 response.status >= 500
42 ? response.text.pipe(
43 Effect.flatMap((body) =>
44 Effect.fail(
45 new TransientUpstream({ status: response.status, body }),
46 ),
47 ),
48 )
49 : Effect.succeed(response),
50 ),
51 Effect.retry({
52 while: (e) => e._tag === "TransientUpstream",
53 schedule: Schedule.max([
54 Schedule.exponential("500 millis"),
55 Schedule.recurs(6),
56 ]),
57 }),
58 );
59
60// The freshly attached IAM policy can take ~10-30s to propagate to the
61// Lambda's role; until it does, granted calls surface a transient
62// AccessDeniedException. Poll (bounded) until the response is no longer
63// access-denied so grant-proof assertions see steady-state behavior.
64const getJson = (path: string) =>
65 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
66 Effect.flatMap((r) => r.json),
67 Effect.repeat({
68 schedule: Schedule.spaced("3 seconds"),
69 until: (response): boolean =>
70 (response as { tag?: string }).tag !== "AccessDeniedException",
71 times: 10,
72 }),
73 );
74
75// The testing account is the management account of a standing organization
76// (SCPs available), so the tree/policy/delegation reads return live data.
77// Every route still tolerates the typed not-in-organization tag so the suite
78// stays correct on a detached account.
79describe.sequential(
80 "Organizations Bindings",
81 {
82 tags: [
83 "provider:aws",
84 "provider:aws:lambda",
85 "provider:aws:organizations",
86 "live",
87 ],
88 },
89 () => {
90 beforeAll(
91 Effect.gen(function* () {
92 yield* Effect.logInfo(
93 "Organizations test setup: destroying previous resources",
94 );
95 yield* sharedStack.destroy();
96
97 yield* Effect.logInfo("Organizations test setup: deploying fixture");
98 const attrs = yield* sharedStack.deploy(
99 Effect.gen(function* () {
100 return yield* OrganizationsTestFunction;
101 }).pipe(Effect.provide(OrganizationsTestFunctionLive)),
102 );
103
104 expect(attrs.functionUrl).toBeTruthy();
105 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
106 functionArn = attrs.functionArn;
107
108 const readinessUrl = `${baseUrl}/bindings`;
109 yield* Effect.logInfo(
110 `Organizations test setup: probing readiness at ${readinessUrl}`,
111 );
112 yield* HttpClient.get(readinessUrl).pipe(
113 Effect.flatMap((response) =>
114 response.status === 200
115 ? Effect.succeed(response)
116 : Effect.fail(
117 new Error(`Function not ready: ${response.status}`),
118 ),
119 ),
120 Effect.tapError((error) =>
121 Effect.logWarning(
122 `Organizations test setup: fixture not ready yet (${String(error)})`,
123 ),
124 ),
125 Effect.retry({ schedule: readinessPolicy }),
126 );
127 }),
128 { timeout: 240_000 },
129 );
130
131 afterAll(sharedStack.destroy(), { timeout: 120_000 });
132
133 describe("binding registration", () => {
134 test.provider("all 26 capabilities initialize in the runtime", (_stack) =>
135 Effect.gen(function* () {
136 const response = (yield* getJson("/bindings")) as {
137 bound: string[];
138 };
139 expect(response.bound).toHaveLength(26);
140 }),
141 );
142 });
143
144 describe("DescribeOrganization", () => {
145 test.provider("reads the organization", (_stack) =>
146 Effect.gen(function* () {
147 const response = (yield* getJson("/organization")) as
148 | {
149 ok: true;
150 id: string | null;
151 managementAccountId: string | null;
152 }
153 | { ok: false; tag: string };
154 if (response.ok) {
155 expect(response.id).toMatch(/^o-/);
156 expect(response.managementAccountId).toMatch(/^\d{12}$/);
157 } else {
158 expect(["AWSOrganizationsNotInUseException"]).toContain(
159 response.tag,
160 );
161 }
162 }),
163 );
164 });
165
166 describe("ListRoots", () => {
167 test.provider("lists the organization roots", (_stack) =>
168 Effect.gen(function* () {
169 const response = (yield* getJson("/roots")) as
170 | { ok: true; count: number; rootId: string | null }
171 | { ok: false; tag: string };
172 if (response.ok) {
173 expect(response.count).toBeGreaterThanOrEqual(1);
174 expect(response.rootId).toMatch(/^r-/);
175 } else {
176 expect(["AWSOrganizationsNotInUseException"]).toContain(
177 response.tag,
178 );
179 }
180 }),
181 );
182 });
183
184 describe("ListAccounts", () => {
185 test.provider("lists the organization accounts", (_stack) =>
186 Effect.gen(function* () {
187 const response = (yield* getJson("/accounts")) as
188 | { ok: true; count: number }
189 | { ok: false; tag: string };
190 if (response.ok) {
191 expect(response.count).toBeGreaterThanOrEqual(1);
192 } else {
193 expect(["AWSOrganizationsNotInUseException"]).toContain(
194 response.tag,
195 );
196 }
197 }),
198 );
199 });
200
201 describe("ListAccountsForParent", () => {
202 test.provider("lists the accounts directly under the root", (_stack) =>
203 Effect.gen(function* () {
204 const response = (yield* getJson("/accounts-for-parent")) as
205 | { ok: true; count: number }
206 | { ok: false; tag: string };
207 if (response.ok) {
208 expect(response.count).toBeGreaterThanOrEqual(0);
209 } else {
210 expect(["AWSOrganizationsNotInUseException", "NoRoot"]).toContain(
211 response.tag,
212 );
213 }
214 }),
215 );
216 });
217
218 describe("ListOrganizationalUnitsForParent", () => {
219 test.provider("lists the OUs directly under the root", (_stack) =>
220 Effect.gen(function* () {
221 const response = (yield* getJson("/ous-for-parent")) as
222 | { ok: true; count: number }
223 | { ok: false; tag: string };
224 if (response.ok) {
225 expect(response.count).toBeGreaterThanOrEqual(0);
226 } else {
227 expect(["AWSOrganizationsNotInUseException", "NoRoot"]).toContain(
228 response.tag,
229 );
230 }
231 }),
232 );
233 });
234
235 describe("ListChildren", () => {
236 test.provider("lists the account children of the root", (_stack) =>
237 Effect.gen(function* () {
238 const response = (yield* getJson("/children")) as
239 | { ok: true; count: number }
240 | { ok: false; tag: string };
241 if (response.ok) {
242 expect(response.count).toBeGreaterThanOrEqual(0);
243 } else {
244 expect(["AWSOrganizationsNotInUseException", "NoRoot"]).toContain(
245 response.tag,
246 );
247 }
248 }),
249 );
250 });
251
252 describe("ListParents", () => {
253 test.provider("finds the management account's parent", (_stack) =>
254 Effect.gen(function* () {
255 const response = (yield* getJson("/parents")) as
256 | { ok: true; count: number }
257 | { ok: false; tag: string };
258 if (response.ok) {
259 expect(response.count).toBeGreaterThanOrEqual(1);
260 } else {
261 expect([
262 "AWSOrganizationsNotInUseException",
263 "NoOrganization",
264 ]).toContain(response.tag);
265 }
266 }),
267 );
268 });
269
270 describe("ListPolicies", () => {
271 test.provider("lists the service control policies", (_stack) =>
272 Effect.gen(function* () {
273 const response = (yield* getJson("/policies")) as
274 | { ok: true; count: number }
275 | { ok: false; tag: string };
276 if (response.ok) {
277 // Every organization with SCPs available has p-FullAWSAccess.
278 expect(response.count).toBeGreaterThanOrEqual(1);
279 } else {
280 expect(["AWSOrganizationsNotInUseException"]).toContain(
281 response.tag,
282 );
283 }
284 }),
285 );
286 });
287
288 describe("ListPoliciesForTarget", () => {
289 test.provider("lists the SCPs attached to the root", (_stack) =>
290 Effect.gen(function* () {
291 const response = (yield* getJson("/policies-for-target")) as
292 | { ok: true; count: number }
293 | { ok: false; tag: string };
294 if (response.ok) {
295 expect(response.count).toBeGreaterThanOrEqual(0);
296 } else {
297 expect(["AWSOrganizationsNotInUseException", "NoRoot"]).toContain(
298 response.tag,
299 );
300 }
301 }),
302 );
303 });
304
305 describe("ListTargetsForPolicy", () => {
306 test.provider("lists the targets of a discovered SCP", (_stack) =>
307 Effect.gen(function* () {
308 const response = (yield* getJson("/targets-for-policy")) as
309 | { ok: true; count: number }
310 | { ok: false; tag: string };
311 if (response.ok) {
312 expect(response.count).toBeGreaterThanOrEqual(0);
313 } else {
314 expect(["AWSOrganizationsNotInUseException", "NoPolicy"]).toContain(
315 response.tag,
316 );
317 }
318 }),
319 );
320 });
321
322 describe("DescribeEffectivePolicy", () => {
323 test.provider(
324 "yields the effective tag policy or the typed not-found tag",
325 (_stack) =>
326 Effect.gen(function* () {
327 const response = (yield* getJson("/effective-policy")) as
328 | { ok: true; policyType: string | null }
329 | { ok: false; tag: string };
330 if (response.ok) {
331 expect(response.policyType).toBe("TAG_POLICY");
332 } else {
333 // No tag policies exist in the standing organization — the typed
334 // tag proves the grant.
335 expect([
336 "AWSOrganizationsNotInUseException",
337 "ConstraintViolationException",
338 "EffectivePolicyNotFoundException",
339 "TargetNotFoundException",
340 ]).toContain(response.tag);
341 }
342 }),
343 );
344 });
345
346 describe("ListAccountsWithInvalidEffectivePolicy", () => {
347 test.provider(
348 "yields a count or the policy-type-gated typed tag",
349 (_stack) =>
350 Effect.gen(function* () {
351 const response = (yield* getJson(
352 "/invalid-effective-policy-accounts",
353 )) as { ok: true; count: number } | { ok: false; tag: string };
354 if (response.ok) {
355 expect(response.count).toBeGreaterThanOrEqual(0);
356 } else {
357 expect([
358 "AWSOrganizationsNotInUseException",
359 "ConstraintViolationException",
360 "EffectivePolicyNotFoundException",
361 "InvalidInputException",
362 "UnsupportedAPIEndpointException",
363 ]).toContain(response.tag);
364 }
365 }),
366 );
367 });
368
369 describe("ListEffectivePolicyValidationErrors", () => {
370 test.provider(
371 "yields a count or the policy-type-gated typed tag",
372 (_stack) =>
373 Effect.gen(function* () {
374 const response = (yield* getJson(
375 "/effective-policy-validation-errors",
376 )) as { ok: true; count: number } | { ok: false; tag: string };
377 if (response.ok) {
378 expect(response.count).toBeGreaterThanOrEqual(0);
379 } else {
380 expect([
381 "AWSOrganizationsNotInUseException",
382 "AccountNotFoundException",
383 "ConstraintViolationException",
384 "EffectivePolicyNotFoundException",
385 "InvalidInputException",
386 "UnsupportedAPIEndpointException",
387 "NoOrganization",
388 ]).toContain(response.tag);
389 }
390 }),
391 );
392 });
393
394 describe("ListDelegatedAdministrators", () => {
395 test.provider("lists the delegated administrators", (_stack) =>
396 Effect.gen(function* () {
397 const response = (yield* getJson("/delegated-administrators")) as
398 | { ok: true; count: number }
399 | { ok: false; tag: string };
400 if (response.ok) {
401 expect(response.count).toBeGreaterThanOrEqual(0);
402 } else {
403 expect(["AWSOrganizationsNotInUseException"]).toContain(
404 response.tag,
405 );
406 }
407 }),
408 );
409 });
410
411 describe("ListDelegatedServicesForAccount", () => {
412 test.provider(
413 "surfaces the typed not-registered tag for the management account",
414 (_stack) =>
415 Effect.gen(function* () {
416 const response = (yield* getJson("/delegated-services")) as
417 | { ok: true; count: number }
418 | { ok: false; tag: string };
419 if (response.ok) {
420 expect(response.count).toBeGreaterThanOrEqual(0);
421 } else {
422 // The management account is never a delegated administrator —
423 // the typed tag proves the grant.
424 expect([
425 "AWSOrganizationsNotInUseException",
426 "AccountNotRegisteredException",
427 "NoOrganization",
428 ]).toContain(response.tag);
429 }
430 }),
431 );
432 });
433
434 describe("ListAWSServiceAccessForOrganization", () => {
435 test.provider("lists the trusted-access service principals", (_stack) =>
436 Effect.gen(function* () {
437 const response = (yield* getJson("/service-access")) as
438 | { ok: true; count: number }
439 | { ok: false; tag: string };
440 if (response.ok) {
441 expect(response.count).toBeGreaterThanOrEqual(0);
442 } else {
443 expect(["AWSOrganizationsNotInUseException"]).toContain(
444 response.tag,
445 );
446 }
447 }),
448 );
449 });
450
451 describe("ListTagsForResource", () => {
452 test.provider("reads the management account's tags", (_stack) =>
453 Effect.gen(function* () {
454 const response = (yield* getJson("/tags")) as
455 | { ok: true; count: number }
456 | { ok: false; tag: string };
457 if (response.ok) {
458 expect(response.count).toBeGreaterThanOrEqual(0);
459 } else {
460 expect([
461 "AWSOrganizationsNotInUseException",
462 "TargetNotFoundException",
463 "NoOrganization",
464 ]).toContain(response.tag);
465 }
466 }),
467 );
468 });
469
470 describe("ListCreateAccountStatus", () => {
471 test.provider("lists the account-creation requests", (_stack) =>
472 Effect.gen(function* () {
473 const response = (yield* getJson("/create-account-statuses")) as
474 | { ok: true; count: number }
475 | { ok: false; tag: string };
476 if (response.ok) {
477 expect(response.count).toBeGreaterThanOrEqual(0);
478 } else {
479 expect([
480 "AWSOrganizationsNotInUseException",
481 "UnsupportedAPIEndpointException",
482 ]).toContain(response.tag);
483 }
484 }),
485 );
486 });
487
488 describe("DescribeCreateAccountStatus", () => {
489 test.provider(
490 "surfaces a typed error for a nonexistent request (proving the grant)",
491 (_stack) =>
492 Effect.gen(function* () {
493 const response = (yield* getJson(
494 "/create-account-status-not-found",
495 )) as { tag: string };
496 expect([
497 "AWSOrganizationsNotInUseException",
498 "CreateAccountStatusNotFoundException",
499 "InvalidInputException",
500 "UnsupportedAPIEndpointException",
501 ]).toContain(response.tag);
502 }),
503 );
504 });
505
506 describe("ListHandshakesForAccount", () => {
507 test.provider("lists the account's handshakes", (_stack) =>
508 Effect.gen(function* () {
509 const response = (yield* getJson("/handshakes-account")) as
510 | { ok: true; count: number }
511 | { ok: false; tag: string };
512 if (response.ok) {
513 expect(response.count).toBeGreaterThanOrEqual(0);
514 } else {
515 expect(["ConcurrentModificationException"]).toContain(response.tag);
516 }
517 }),
518 );
519 });
520
521 describe("ListHandshakesForOrganization", () => {
522 test.provider("lists the organization's handshakes", (_stack) =>
523 Effect.gen(function* () {
524 const response = (yield* getJson("/handshakes-org")) as
525 | { ok: true; count: number }
526 | { ok: false; tag: string };
527 if (response.ok) {
528 expect(response.count).toBeGreaterThanOrEqual(0);
529 } else {
530 expect([
531 "AWSOrganizationsNotInUseException",
532 "ConcurrentModificationException",
533 ]).toContain(response.tag);
534 }
535 }),
536 );
537 });
538
539 describe("DescribeHandshake", () => {
540 test.provider(
541 "surfaces a typed error for a nonexistent handshake (proving the grant)",
542 (_stack) =>
543 Effect.gen(function* () {
544 const response = (yield* getJson("/handshake-not-found")) as {
545 tag: string;
546 };
547 expect([
548 "HandshakeNotFoundException",
549 "InvalidInputException",
550 ]).toContain(response.tag);
551 }),
552 );
553 });
554
555 describe("AcceptHandshake", () => {
556 test.provider(
557 "surfaces a typed error for a nonexistent handshake (proving the grant)",
558 (_stack) =>
559 Effect.gen(function* () {
560 const response = (yield* getJson(
561 "/accept-handshake-not-found",
562 )) as {
563 tag: string;
564 };
565 expect([
566 "AWSOrganizationsNotInUseException",
567 "HandshakeNotFoundException",
568 "InvalidInputException",
569 ]).toContain(response.tag);
570 }),
571 );
572 });
573
574 describe("DeclineHandshake", () => {
575 test.provider(
576 "surfaces a typed error for a nonexistent handshake (proving the grant)",
577 (_stack) =>
578 Effect.gen(function* () {
579 const response = (yield* getJson(
580 "/decline-handshake-not-found",
581 )) as {
582 tag: string;
583 };
584 expect([
585 "HandshakeNotFoundException",
586 "InvalidInputException",
587 ]).toContain(response.tag);
588 }),
589 );
590 });
591
592 describe("CancelHandshake", () => {
593 test.provider(
594 "surfaces a typed error for a nonexistent handshake (proving the grant)",
595 (_stack) =>
596 Effect.gen(function* () {
597 const response = (yield* getJson(
598 "/cancel-handshake-not-found",
599 )) as {
600 tag: string;
601 };
602 expect([
603 "HandshakeNotFoundException",
604 "InvalidInputException",
605 ]).toContain(response.tag);
606 }),
607 );
608 });
609
610 describe("InviteAccountToOrganization", () => {
611 test.provider(
612 "surfaces a typed error for an invalid target (proving the grant)",
613 (_stack) =>
614 Effect.gen(function* () {
615 const response = (yield* getJson("/invite-invalid")) as {
616 tag: string;
617 };
618 expect([
619 "AWSOrganizationsNotInUseException",
620 "ConstraintViolationException",
621 "HandshakeConstraintViolationException",
622 "InvalidInputException",
623 ]).toContain(response.tag);
624 }),
625 );
626 });
627
628 describe(
629 "consumeOrganizationsEvents",
630 { tags: ["provider:aws:eventbridge"] },
631 () => {
632 test.provider(
633 "the deploy created an EventBridge rule targeting the function",
634 (_stack) =>
635 Effect.gen(function* () {
636 // Out-of-band via distilled: the fixture's
637 // consumeOrganizationsEvents must have materialized as a rule on
638 // the default bus with the Lambda as target. (Runtime firing needs
639 // a real organization change surfaced through CloudTrail in
640 // us-east-1, far slower than the speed doctrine allows.)
641 const { RuleNames } = yield* eventbridge.listRuleNamesByTarget({
642 TargetArn: functionArn,
643 });
644 expect((RuleNames ?? []).length).toBeGreaterThanOrEqual(1);
645 }),
646 );
647 },
648 );
649 },
650);