1import * as AWS from "@/AWS";
2import { TrustedServiceAccess } 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-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.
22test.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.
45const servicePrincipal =
46 process.env.AWS_ORG_TRUSTED_SERVICE_PRINCIPAL ?? "config.amazonaws.com";
47
48test.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.
88test.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);