1import * as AWS from "@/AWS";
2import { DelegatedAdministrator } from "@/AWS/Organizations";
3import * as Provider from "@/Provider";
4import { isResourceState, State, type ResourceState } from "@/State";
5import * as Test from "@/Test/Alchemy";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8
9const { test } = Test.make({ providers: AWS.providers() });
10
11// Read-only `list()` test (AWS account/region-scoped collection with a
12// per-account service fan-out). Resolve the provider from context via the
13// typed `Provider.findProvider`, call `list()`, and assert the result is a
14// well-typed `Attributes[]`.
15//
16// `list()` is designed to degrade gracefully off the org management account:
17// `listDelegatedAdministrators` rejects with `AWSOrganizationsNotInUseException`
18// / `AccessDeniedException` / `UnsupportedAPIEndpointException` when the caller
19// isn't an org management/delegated account, which `list()` catches and maps to
20// `[]`. So this case passes on any account — it just returns `[]` when the
21// account can't enumerate delegated administrators.
22test.provider(
23 "list enumerates delegated administrators",
24 () =>
25 Effect.gen(function* () {
26 const provider = yield* Provider.findProvider(DelegatedAdministrator);
27 const all = yield* provider.list();
28
29 expect(Array.isArray(all)).toBe(true);
30
31 for (const item of all) {
32 expect(typeof item.accountId).toBe("string");
33 expect(typeof item.servicePrincipal).toBe("string");
34 }
35 }),
36 { tags: ["provider:aws", "provider:aws:organizations", "live"] },
37);
38
39// Full lifecycle list test — requires an org MANAGEMENT account plus a member
40// account to register as a delegated administrator. Gate behind env vars so an
41// entitled account runs it unchanged. Off a management account
42// `registerDelegatedAdministrator` rejects with
43// `AWSOrganizationsNotInUseException` / `AccessDeniedException`, so this is
44// skipped by default.
45const memberAccountId = process.env.AWS_ORG_DELEGATED_ADMIN_ACCOUNT_ID;
46const servicePrincipal =
47 process.env.AWS_ORG_DELEGATED_ADMIN_SERVICE_PRINCIPAL ??
48 "config.amazonaws.com";
49
50test.provider.skipIf(!memberAccountId)(
51 "list contains the deployed delegated administrator",
52 (stack) =>
53 Effect.gen(function* () {
54 yield* stack.destroy();
55
56 const admin = yield* stack.deploy(
57 Effect.gen(function* () {
58 return yield* DelegatedAdministrator("ListDelegatedAdmin", {
59 accountId: memberAccountId!,
60 servicePrincipal,
61 });
62 }),
63 );
64
65 const provider = yield* Provider.findProvider(DelegatedAdministrator);
66 const all = yield* provider.list();
67
68 expect(
69 all.some(
70 (item) =>
71 item.accountId === admin.accountId &&
72 item.servicePrincipal === admin.servicePrincipal,
73 ),
74 ).toBe(true);
75
76 yield* stack.destroy();
77 }),
78 { tags: ["provider:aws", "provider:aws:organizations", "live"] },
79);
80
81// Regression test for https://github.com/alchemy-run/alchemy/issues/736.
82//
83// An interrupted first deploy persists the registration as
84// `status: "creating"` with no attributes — and the Output-valued props
85// (`accountId` typically comes from an `Account` resource) do not survive
86// the state round-trip: they deserialize as `undefined`. Plan's
87// creating-recovery branch then calls `provider.read` with those junk olds,
88// which pre-fix crashed in
89// `listDelegatedServicesForAccount({ AccountId: undefined })`
90// (`ParseError: Expected string, got undefined`) and wedged the stack.
91//
92// Simulate exactly that state row after a real deploy and assert the next
93// deploy recovers: `read` reports "not found", the engine re-drives the
94// create, and reconcile observes the existing registration before
95// registering — SAME (accountId, servicePrincipal) identity, no duplicate.
96//
97// Same gating as the lifecycle test above: requires an org MANAGEMENT
98// account plus a member account to register (a management account cannot
99// delegate itself — `ConstraintViolationException`), so this is skipped
100// unless AWS_ORG_DELEGATED_ADMIN_ACCOUNT_ID is set.
101test.provider.skipIf(!memberAccountId)(
102 "recovers a half-created delegated administrator whose creating-state lost Output-valued props (#736)",
103 (stack) =>
104 Effect.gen(function* () {
105 yield* stack.destroy();
106
107 const deployAdmin = () =>
108 stack.deploy(
109 Effect.gen(function* () {
110 return yield* DelegatedAdministrator("WedgedDelegatedAdmin", {
111 accountId: memberAccountId!,
112 servicePrincipal,
113 });
114 }),
115 );
116
117 const created = yield* deployAdmin();
118
119 // Rewrite the registration's persisted row into the wedged shape an
120 // interrupted deploy leaves behind: `creating`, no attributes, and
121 // every Output-valued prop lost in the round-trip.
122 const state = yield* yield* State;
123 const stage = stack.stage;
124 const fqns = yield* state.list({ stack: stack.name, stage });
125 const rows = yield* Effect.forEach(fqns, (fqn) =>
126 state
127 .get({ stack: stack.name, stage, fqn })
128 .pipe(Effect.map((row) => ({ fqn, row }))),
129 );
130 const wedged = rows.find(
131 (r): r is { fqn: string; row: ResourceState } =>
132 isResourceState(r.row) &&
133 r.row.resourceType === "AWS.Organizations.DelegatedAdministrator",
134 );
135 if (!wedged) {
136 return yield* Effect.die(
137 new Error(
138 "no AWS.Organizations.DelegatedAdministrator state row found after deploy",
139 ),
140 );
141 }
142 yield* state.set({
143 stack: stack.name,
144 stage,
145 fqn: wedged.fqn,
146 value: {
147 ...wedged.row,
148 status: "creating",
149 attr: undefined,
150 props: {
151 ...wedged.row.props,
152 accountId: undefined,
153 servicePrincipal: undefined,
154 },
155 },
156 });
157
158 // Before the fix this failed in plan: `read` called
159 // `listDelegatedServicesForAccount({ AccountId: undefined })` with the
160 // junk olds.
161 const recovered = yield* deployAdmin();
162 expect(recovered.accountId).toEqual(created.accountId);
163 expect(recovered.servicePrincipal).toEqual(created.servicePrincipal);
164
165 yield* stack.destroy();
166 }),
167 {
168 tags: ["provider:aws", "provider:aws:organizations", "live"],
169 timeout: 240_000,
170 },
171);