1import * as AWS from "@/AWS";
2import { Role } from "@/AWS/IAM";
3import { Bucket } from "@/AWS/S3";
4import {
5 Portfolio,
6 PortfolioProductAssociation,
7 PrincipalPortfolioAssociation,
8 Product,
9} from "@/AWS/ServiceCatalog";
10import * as Provider from "@/Provider";
11import * as Test from "@/Test/Alchemy";
12import * as s3 from "@distilled.cloud/aws/s3";
13import * as servicecatalog from "@distilled.cloud/aws/service-catalog";
14import { expect } from "alchemy-test";
15import * as Data from "effect/Data";
16import * as Effect from "effect/Effect";
17import * as Schedule from "effect/Schedule";
18
19const { test } = Test.make({ providers: AWS.providers() });
20
21const TEMPLATE = JSON.stringify({
22 AWSTemplateFormatVersion: "2010-09-09",
23 Description: "alchemy Service Catalog association test template",
24 Resources: {
25 NoOp: { Type: "AWS::CloudFormation::WaitConditionHandle" },
26 },
27});
28
29class PortfolioStillExists extends Data.TaggedError(
30 "AssocPortfolioStillExists",
31)<{ portfolioId: string }> {}
32
33const assertPortfolioGone = (portfolioId: string) =>
34 servicecatalog.describePortfolio({ Id: portfolioId }).pipe(
35 Effect.flatMap(() =>
36 Effect.fail(new PortfolioStillExists({ portfolioId })),
37 ),
38 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
39 Effect.retry({
40 while: (e) => e._tag === "AssocPortfolioStillExists",
41 schedule: Schedule.max([
42 Schedule.fixed("2 seconds"),
43 Schedule.recurs(10),
44 ]),
45 }),
46 );
47
48test.provider(
49 "associates a product and a principal with a portfolio",
50 (stack) =>
51 Effect.gen(function* () {
52 yield* stack.destroy();
53
54 // Phase 1: a bucket to host the CloudFormation template.
55 const phase1 = yield* stack.deploy(
56 Effect.gen(function* () {
57 const bucket = yield* Bucket("AssocTemplateBucket", {
58 forceDestroy: true,
59 });
60 return { bucketName: bucket.bucketName, region: bucket.region };
61 }),
62 );
63 yield* s3.putObject({
64 Bucket: phase1.bucketName,
65 Key: "assoc-template.json",
66 Body: new TextEncoder().encode(TEMPLATE),
67 ContentType: "application/json",
68 });
69 const templateUrl = `https://${phase1.bucketName}.s3.${phase1.region}.amazonaws.com/assoc-template.json`;
70
71 // Phase 2: portfolio + product + principal + both associations.
72 const deployed = yield* stack.deploy(
73 Effect.gen(function* () {
74 yield* Bucket("AssocTemplateBucket", { forceDestroy: true });
75 const portfolio = yield* Portfolio("AssocPortfolio", {
76 providerName: "alchemy-tests",
77 });
78 const product = yield* Product("AssocProduct", {
79 owner: "alchemy-tests",
80 provisioningArtifact: { templateUrl },
81 });
82 const role = yield* Role("AssocPrincipalRole", {
83 assumeRolePolicyDocument: {
84 Version: "2012-10-17",
85 Statement: [
86 {
87 Effect: "Allow",
88 Principal: { Service: "servicecatalog.amazonaws.com" },
89 Action: ["sts:AssumeRole"],
90 },
91 ],
92 },
93 });
94 yield* PortfolioProductAssociation("ProductInPortfolio", {
95 portfolioId: portfolio.portfolioId,
96 productId: product.productId,
97 });
98 yield* PrincipalPortfolioAssociation("RoleAccess", {
99 portfolioId: portfolio.portfolioId,
100 principalArn: role.roleArn,
101 });
102 return {
103 portfolioId: portfolio.portfolioId,
104 productId: product.productId,
105 roleArn: role.roleArn,
106 };
107 }),
108 );
109
110 // out-of-band verify both associations via distilled
111 const portfolios = yield* servicecatalog.listPortfoliosForProduct({
112 ProductId: deployed.productId,
113 });
114 expect(
115 (portfolios.PortfolioDetails ?? []).some(
116 (d) => d.Id === deployed.portfolioId,
117 ),
118 ).toBe(true);
119
120 const principals = yield* servicecatalog.listPrincipalsForPortfolio({
121 PortfolioId: deployed.portfolioId,
122 });
123 expect(
124 (principals.Principals ?? []).some(
125 (p) => p.PrincipalARN === deployed.roleArn,
126 ),
127 ).toBe(true);
128
129 // Account-wide provider enumeration drives `alchemy unsafe nuke`.
130 // Both association providers must discover their child resources so
131 // teardown can schedule them before the portfolio and product.
132 const productAssociationProvider = yield* Provider.findProvider(
133 PortfolioProductAssociation,
134 );
135 const principalAssociationProvider = yield* Provider.findProvider(
136 PrincipalPortfolioAssociation,
137 );
138 const [productAssociations, principalAssociations] = yield* Effect.all([
139 productAssociationProvider.list(),
140 principalAssociationProvider.list(),
141 ]);
142 expect(
143 productAssociations.some(
144 (association) =>
145 association.portfolioId === deployed.portfolioId &&
146 association.productId === deployed.productId,
147 ),
148 ).toBe(true);
149 expect(
150 principalAssociations.some(
151 (association) =>
152 association.portfolioId === deployed.portfolioId &&
153 association.principalArn === deployed.roleArn &&
154 association.principalType === "IAM",
155 ),
156 ).toBe(true);
157
158 // idempotent re-deploy — associations already exist, reconcile no-ops
159 yield* stack.deploy(
160 Effect.gen(function* () {
161 yield* Bucket("AssocTemplateBucket", { forceDestroy: true });
162 const portfolio = yield* Portfolio("AssocPortfolio", {
163 providerName: "alchemy-tests",
164 });
165 const product = yield* Product("AssocProduct", {
166 owner: "alchemy-tests",
167 provisioningArtifact: { templateUrl },
168 });
169 const role = yield* Role("AssocPrincipalRole", {
170 assumeRolePolicyDocument: {
171 Version: "2012-10-17",
172 Statement: [
173 {
174 Effect: "Allow",
175 Principal: { Service: "servicecatalog.amazonaws.com" },
176 Action: ["sts:AssumeRole"],
177 },
178 ],
179 },
180 });
181 yield* PortfolioProductAssociation("ProductInPortfolio", {
182 portfolioId: portfolio.portfolioId,
183 productId: product.productId,
184 });
185 yield* PrincipalPortfolioAssociation("RoleAccess", {
186 portfolioId: portfolio.portfolioId,
187 principalArn: role.roleArn,
188 });
189 }),
190 );
191
192 // teardown removes associations first, then the portfolio/product
193 yield* stack.destroy();
194 yield* assertPortfolioGone(deployed.portfolioId);
195 }),
196 {
197 tags: [
198 "provider:aws",
199 "provider:aws:iam",
200 "provider:aws:s3",
201 "provider:aws:servicecatalog",
202 "live",
203 ],
204 timeout: 240_000,
205 },
206);