1import * as AWS from "@/AWS";
2import { AdminAccount, pinFms } from "@/AWS/FMS/AdminAccount.ts";
3import { AWSEnvironment } from "@/AWS/Environment.ts";
4import * as Test from "@/Test/Alchemy";
5import * as fms from "@distilled.cloud/aws/fms";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as Result from "effect/Result";
9import * as Schedule from "effect/Schedule";
10
11const { test } = Test.make({ providers: AWS.providers() });
12
13// FMS admin-account APIs are only served from us-east-1 — all direct distilled
14// calls below are pinned there via `pinFms` (same as the provider).
15const getAdmin = pinFms(fms.getAdminAccount({})).pipe(
16 Effect.map((r) => r as fms.GetAdminAccountResponse | undefined),
17 Effect.catchTag("ResourceNotFoundException", () => Effect.succeed(undefined)),
18);
19
20// Ungated typed-error probe: on an organization with no FMS admin designated,
21// `getAdminAccount` must surface as a typed `ResourceNotFoundException` from
22// distilled's `GetAdminAccountError` union. This proves the SDK's typed error
23// mapping at near-zero cost and never mutates the organization.
24test.provider(
25 "getAdminAccount with no admin designated returns a typed ResourceNotFoundException",
26 (_stack) =>
27 Effect.gen(function* () {
28 const existing = yield* getAdmin;
29 if (existing?.AdminAccount) {
30 yield* Effect.logInfo(
31 `FMS admin already configured (${existing.AdminAccount}) — probe skipped`,
32 );
33 return;
34 }
35
36 const result = yield* Effect.result(pinFms(fms.getAdminAccount({})));
37 expect(Result.isFailure(result)).toBe(true);
38 if (Result.isFailure(result)) {
39 expect(result.failure._tag).toBe("ResourceNotFoundException");
40 }
41 }),
42 { tags: ["provider:aws", "provider:aws:fms", "live"], timeout: 60_000 },
43);
44
45// Full live lifecycle. FMS requires the caller to be the AWS Organizations
46// management account (the testing account is), with the admin APIs pinned to
47// us-east-1 by the provider. The lifecycle is gated because FMS organization
48// onboarding is genuinely slow: for >10 minutes after `associateAdminAccount`,
49// `disassociateAdminAccount` fails with the typed
50// `InvalidOperationException: Failed to offboard AWS Account <id> as an AWS
51// Firewall Manager administrator account. Your AWS Organization is currently
52// onboarding with AWS Firewall Manager and cannot be offboarded.`
53// — far beyond the ungated polling budget. Set AWS_TEST_FMS=1 to run it; the
54// destroy below is retried until onboarding settles and offboarding succeeds,
55// leaving the organization exactly as found (no FMS admin). Note: association
56// auto-creates the AWS-managed `AWSServiceRoleForFMS` service-linked role,
57// which AWS retains after offboarding; reap it with
58// `aws iam delete-service-linked-role --role-name AWSServiceRoleForFMS`
59// (deletion only succeeds a few minutes after offboarding fully settles).
60test.provider.skipIf(!process.env.AWS_TEST_FMS)(
61 "lifecycle: designate the FMS admin account, then disassociate",
62 (stack) =>
63 Effect.gen(function* () {
64 const { accountId } = yield* AWSEnvironment.current;
65
66 // Bail out (without failing) if some out-of-band actor already
67 // designated a different FMS admin — we must not disassociate an admin
68 // we don't own.
69 const preExisting = yield* getAdmin;
70 if (preExisting?.AdminAccount && preExisting.AdminAccount !== accountId) {
71 yield* Effect.logInfo(
72 `FMS admin already designated out-of-band (${preExisting.AdminAccount}) — lifecycle skipped`,
73 );
74 return;
75 }
76
77 yield* stack.destroy();
78
79 const created = yield* stack.deploy(
80 Effect.gen(function* () {
81 return yield* AdminAccount("FmsAdmin", {});
82 }),
83 );
84 expect(created.adminAccount).toBe(accountId);
85
86 const live = yield* pinFms(fms.getAdminAccount({}));
87 expect(live.AdminAccount).toBe(accountId);
88
89 // Onboarding blocks offboarding for >10 minutes; the provider's delete
90 // retries boundedly (~64s), so re-run destroy until it goes through.
91 yield* stack
92 .destroy()
93 .pipe(
94 Effect.retry({ schedule: Schedule.spaced("30 seconds"), times: 25 }),
95 );
96 // Offboarding propagates asynchronously — `getAdminAccount` keeps
97 // returning the old admin for ~90s after disassociate succeeds.
98 const after = yield* getAdmin.pipe(
99 Effect.repeat({
100 schedule: Schedule.spaced("10 seconds"),
101 until: (a) => a?.AdminAccount === undefined,
102 times: 18,
103 }),
104 );
105 expect(after?.AdminAccount).toBeUndefined();
106 }),
107 { tags: ["provider:aws", "provider:aws:fms", "live"], timeout: 1_500_000 },
108);