| 1 | import * as AWS from "@/AWS"; |
| 2 | import { TrustedServiceAccess } 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-scoped collection that enumerates every |
| 12 | // service principal granted trusted access to the organization). Resolve the |
| 13 | // provider from context via the typed `Provider.findProvider`, call `list()`, |
| 14 | // and assert the result is a well-typed `Attributes[]`. |
| 15 | // |
| 16 | // `list()` is designed to degrade gracefully off the org management account: |
| 17 | // `listAWSServiceAccessForOrganization` rejects with |
| 18 | // `AWSOrganizationsNotInUseException` / `AccessDeniedException` 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 trusted service access. |
| 22 | test.provider( |
| 23 | "list enumerates trusted service access", |
| 24 | () => |
| 25 | Effect.gen(function* () { |
| 26 | const provider = yield* Provider.findProvider(TrustedServiceAccess); |
| 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.servicePrincipal).toBe("string"); |
| 33 | if (item.dateEnabled !== undefined) { |
| 34 | expect(item.dateEnabled).toBeInstanceOf(Date); |
| 35 | } |
| 36 | } |
| 37 | }), |
| 38 | { tags: ["provider:aws", "provider:aws:organizations", "live"] }, |
| 39 | ); |
| 40 | |
| 41 | // Full lifecycle list test — requires an org MANAGEMENT account. Gate behind an |
| 42 | // env var so an entitled account runs it unchanged. Off a management account |
| 43 | // `enableAWSServiceAccess` rejects with `AWSOrganizationsNotInUseException` / |
| 44 | // `AccessDeniedException`, so this is skipped by default. |
| 45 | const servicePrincipal = |
| 46 | process.env.AWS_ORG_TRUSTED_SERVICE_PRINCIPAL ?? "config.amazonaws.com"; |
| 47 | |
| 48 | test.provider.skipIf(!process.env.AWS_ORG_MANAGEMENT_ACCOUNT)( |
| 49 | "list contains the deployed trusted service access", |
| 50 | (stack) => |
| 51 | Effect.gen(function* () { |
| 52 | yield* stack.destroy(); |
| 53 | |
| 54 | const access = yield* stack.deploy( |
| 55 | Effect.gen(function* () { |
| 56 | return yield* TrustedServiceAccess("ListTrustedServiceAccess", { |
| 57 | servicePrincipal, |
| 58 | }); |
| 59 | }), |
| 60 | ); |
| 61 | |
| 62 | const provider = yield* Provider.findProvider(TrustedServiceAccess); |
| 63 | const all = yield* provider.list(); |
| 64 | |
| 65 | expect( |
| 66 | all.some((item) => item.servicePrincipal === access.servicePrincipal), |
| 67 | ).toBe(true); |
| 68 | |
| 69 | yield* stack.destroy(); |
| 70 | }), |
| 71 | { tags: ["provider:aws", "provider:aws:organizations", "live"] }, |
| 72 | ); |
| 73 | |
| 74 | // Regression test for https://github.com/alchemy-run/alchemy/issues/736. |
| 75 | // |
| 76 | // An interrupted first deploy persists the enablement as `status: "creating"` |
| 77 | // with no attributes and props that could not round-trip. Plan's |
| 78 | // creating-recovery branch then calls `provider.read` with those junk olds; |
| 79 | // pre-fix the `olds!.servicePrincipal` dereference crashed the plan when the |
| 80 | // persisted row carried no recoverable props at all (`BaseResourceState` |
| 81 | // declares `props?:` — a row whose Output-valued props failed to round-trip |
| 82 | // can surface with none). |
| 83 | // |
| 84 | // Simulate exactly that state row after a real deploy and assert the next |
| 85 | // deploy recovers: `read` reports "not found", the engine re-drives the |
| 86 | // create, and reconcile observes the access already enabled — SAME |
| 87 | // servicePrincipal and SAME enablement date, no re-enable. |
| 88 | test.provider.skipIf(!process.env.AWS_ORG_MANAGEMENT_ACCOUNT)( |
| 89 | "recovers a half-created trusted service access whose creating-state lost its props (#736)", |
| 90 | (stack) => |
| 91 | Effect.gen(function* () { |
| 92 | yield* stack.destroy(); |
| 93 | |
| 94 | const deployAccess = () => |
| 95 | stack.deploy( |
| 96 | Effect.gen(function* () { |
| 97 | return yield* TrustedServiceAccess("WedgedTrustedAccess", { |
| 98 | servicePrincipal, |
| 99 | }); |
| 100 | }), |
| 101 | ); |
| 102 | |
| 103 | const created = yield* deployAccess(); |
| 104 | |
| 105 | // Rewrite the persisted row into the wedged shape an interrupted |
| 106 | // deploy leaves behind: `creating`, no attributes, and no recoverable |
| 107 | // props (the realistic field-level loss — `servicePrincipal: |
| 108 | // undefined` — is tolerated even pre-fix because |
| 109 | // `readTrustedServiceAccess` is list-and-find; the crash this fix |
| 110 | // guards is the `olds!` deref on a row with no props at all). |
| 111 | const state = yield* yield* State; |
| 112 | const stage = stack.stage; |
| 113 | const fqns = yield* state.list({ stack: stack.name, stage }); |
| 114 | const rows = yield* Effect.forEach(fqns, (fqn) => |
| 115 | state |
| 116 | .get({ stack: stack.name, stage, fqn }) |
| 117 | .pipe(Effect.map((row) => ({ fqn, row }))), |
| 118 | ); |
| 119 | const wedged = rows.find( |
| 120 | (r): r is { fqn: string; row: ResourceState } => |
| 121 | isResourceState(r.row) && |
| 122 | r.row.resourceType === "AWS.Organizations.TrustedServiceAccess", |
| 123 | ); |
| 124 | if (!wedged) { |
| 125 | return yield* Effect.die( |
| 126 | new Error( |
| 127 | "no AWS.Organizations.TrustedServiceAccess state row found after deploy", |
| 128 | ), |
| 129 | ); |
| 130 | } |
| 131 | yield* state.set({ |
| 132 | stack: stack.name, |
| 133 | stage, |
| 134 | fqn: wedged.fqn, |
| 135 | value: { |
| 136 | ...wedged.row, |
| 137 | status: "creating", |
| 138 | attr: undefined, |
| 139 | props: undefined, |
| 140 | // `CreatingResourceState` requires `props`, but persisted rows may |
| 141 | // lack it (`BaseResourceState.props?:`) — simulate that corruption. |
| 142 | } as unknown as ResourceState, |
| 143 | }); |
| 144 | |
| 145 | // Before the fix this crashed in plan with |
| 146 | // `TypeError: undefined is not an object (evaluating 'olds.servicePrincipal')`. |
| 147 | const recovered = yield* deployAccess(); |
| 148 | expect(recovered.servicePrincipal).toEqual(created.servicePrincipal); |
| 149 | // Same enablement observed — not disabled/re-enabled. |
| 150 | expect(recovered.dateEnabled?.getTime()).toEqual( |
| 151 | created.dateEnabled?.getTime(), |
| 152 | ); |
| 153 | |
| 154 | yield* stack.destroy(); |
| 155 | }), |
| 156 | { |
| 157 | tags: ["provider:aws", "provider:aws:organizations", "live"], |
| 158 | timeout: 240_000, |
| 159 | }, |
| 160 | ); |