| 1 | import * as AWS from "@/AWS"; |
| 2 | import { DelegatedAdministrator } from "@/AWS/Organizations"; |
| 3 | import * as Provider from "@/Provider"; |
| 4 | import { isResourceState, State, type ResourceState } from "@/State"; |
| 5 | import * as Test from "@/Test/Alchemy"; |
| 6 | import { expect } from "alchemy-test"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | |
| 9 | const { 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. |
| 22 | test.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. |
| 45 | const memberAccountId = process.env.AWS_ORG_DELEGATED_ADMIN_ACCOUNT_ID; |
| 46 | const servicePrincipal = |
| 47 | process.env.AWS_ORG_DELEGATED_ADMIN_SERVICE_PRINCIPAL ?? |
| 48 | "config.amazonaws.com"; |
| 49 | |
| 50 | test.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. |
| 101 | test.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 | ); |