1import * as AWS from "@/AWS";
2import { Export } from "@/AWS/BCMDataExports/Export.ts";
3import { AWSEnvironment } from "@/AWS/Environment.ts";
4import { Bucket } from "@/AWS/S3/Bucket.ts";
5import * as Provider from "@/Provider";
6import * as Test from "@/Test/Alchemy";
7import * as bcm from "@distilled.cloud/aws/bcm-data-exports";
8import { expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import * as Schedule from "effect/Schedule";
11
12const { test } = Test.make({ providers: AWS.providers() });
13
14// Ungated typed-error probe: prove the distilled error union carries the
15// not-found tag this provider's read/delete paths depend on. Runs in every
16// CI pass at near-zero cost.
17test.provider(
18 "getExport on a nonexistent export ARN fails with ResourceNotFoundException",
19 () =>
20 Effect.gen(function* () {
21 const { accountId } = yield* AWSEnvironment.current;
22 const error = yield* Effect.flip(
23 bcm.getExport({
24 ExportArn: `arn:aws:bcm-data-exports:us-east-1:${accountId}:export/alchemy-nonexistent-probe/00000000-0000-0000-0000-000000000000`,
25 }),
26 );
27 expect(error._tag).toBe("ResourceNotFoundException");
28 }),
29 { tags: ["provider:aws", "provider:aws:bcmdataexports", "live"] },
30);
31
32const bucketName = "alchemy-test-bcm-export-dest";
33const exportName = "alchemy-test-bcm-export";
34const renamedExportName = "alchemy-test-bcm-export-renamed";
35
36const queryStatement =
37 "SELECT identity_line_item_id, identity_time_interval, line_item_unblended_cost FROM COST_AND_USAGE_REPORT";
38const tableConfigurations = {
39 COST_AND_USAGE_REPORT: {
40 TIME_GRANULARITY: "HOURLY",
41 INCLUDE_RESOURCES: "FALSE",
42 INCLUDE_MANUAL_DISCOUNT_COMPATIBILITY: "FALSE",
43 INCLUDE_SPLIT_COST_ALLOCATION_DATA: "FALSE",
44 },
45};
46
47// The destination bucket must let the Data Exports service principals write
48// to it and read its policy.
49const destinationBucket = (accountId: string) =>
50 Bucket("ExportDest", {
51 bucketName,
52 forceDestroy: true,
53 policy: [
54 {
55 Sid: "EnableAWSDataExportsToWriteToS3AndCheckPolicy",
56 Effect: "Allow",
57 Principal: {
58 Service: [
59 "billingreports.amazonaws.com",
60 "bcm-data-exports.amazonaws.com",
61 ],
62 },
63 Action: ["s3:PutObject", "s3:GetBucketPolicy"],
64 Resource: [
65 `arn:aws:s3:::${bucketName}`,
66 `arn:aws:s3:::${bucketName}/*`,
67 ],
68 Condition: {
69 StringLike: {
70 "aws:SourceAccount": accountId,
71 "aws:SourceArn": [
72 `arn:aws:cur:us-east-1:${accountId}:definition/*`,
73 `arn:aws:bcm-data-exports:us-east-1:${accountId}:export/*`,
74 ],
75 },
76 },
77 },
78 ],
79 });
80
81const getExportByArn = (arn: string) =>
82 bcm.getExport({ ExportArn: arn }).pipe(
83 Effect.map((r) => r.Export),
84 Effect.catchTag("ResourceNotFoundException", () =>
85 Effect.succeed(undefined),
86 ),
87 );
88
89// Typed wait-until-gone: bounded, declarative.
90const assertExportGone = (arn: string) =>
91 Effect.gen(function* () {
92 const live = yield* getExportByArn(arn);
93 if (live !== undefined) {
94 return yield* Effect.fail(
95 new Error(`export '${arn}' still exists after destroy`),
96 );
97 }
98 }).pipe(
99 Effect.retry({
100 schedule: Schedule.max([
101 Schedule.fixed("3 seconds"),
102 Schedule.recurs(10),
103 ]),
104 }),
105 );
106
107test.provider(
108 "lifecycle: create CUR 2.0 export, update in place, replace on rename, destroy",
109 (stack) =>
110 Effect.gen(function* () {
111 yield* stack.destroy();
112
113 const { accountId, region } = yield* AWSEnvironment.current;
114
115 // CREATE — bucket (with Data Exports write policy) + export.
116 const deployed = yield* stack.deploy(
117 Effect.gen(function* () {
118 const bucket = yield* destinationBucket(accountId);
119 const exp = yield* Export("CurExport", {
120 exportName,
121 description: "alchemy bcm-data-exports test",
122 dataQuery: { queryStatement, tableConfigurations },
123 s3Destination: {
124 s3Bucket: bucket.bucketName,
125 s3Prefix: "cur2",
126 s3Region: region,
127 },
128 tags: { fixture: "bcm-data-exports" },
129 });
130 return { exportArn: exp.exportArn, exportName: exp.exportName };
131 }),
132 );
133
134 expect(deployed.exportName).toBe(exportName);
135 expect(deployed.exportArn).toContain(":bcm-data-exports:");
136 expect(deployed.exportArn).toContain(`:export/`);
137
138 // Out-of-band verification via distilled.
139 const created = yield* getExportByArn(deployed.exportArn);
140 expect(created?.Name).toBe(exportName);
141 expect(created?.Description).toBe("alchemy bcm-data-exports test");
142 expect(created?.DataQuery.QueryStatement).toBe(queryStatement);
143 expect(created?.DestinationConfigurations.S3Destination.S3Bucket).toBe(
144 bucketName,
145 );
146 expect(created?.DestinationConfigurations.S3Destination.S3Prefix).toBe(
147 "cur2",
148 );
149 expect(created?.RefreshCadence.Frequency).toBe("SYNCHRONOUS");
150
151 // Tags: internal branding + user tag attached at create.
152 const tags = yield* bcm
153 .listTagsForResource({ ResourceArn: deployed.exportArn })
154 .pipe(
155 Effect.map((r) =>
156 Object.fromEntries(
157 (r.ResourceTags ?? []).map((t) => [t.Key, t.Value]),
158 ),
159 ),
160 );
161 expect(tags.fixture).toBe("bcm-data-exports");
162 expect(tags["alchemy::id"]).toBe("CurExport");
163
164 // Canonical list() coverage.
165 const provider = yield* Provider.findProvider(Export);
166 const all = yield* provider.list();
167 expect(all.some((e) => e.exportName === exportName)).toBe(true);
168
169 // UPDATE in place — new description + prefix, tag change. Same ARN.
170 const updated = yield* stack.deploy(
171 Effect.gen(function* () {
172 const bucket = yield* destinationBucket(accountId);
173 const exp = yield* Export("CurExport", {
174 exportName,
175 description: "alchemy bcm-data-exports test (updated)",
176 dataQuery: { queryStatement, tableConfigurations },
177 s3Destination: {
178 s3Bucket: bucket.bucketName,
179 s3Prefix: "cur2-updated",
180 s3Region: region,
181 },
182 tags: { fixture: "bcm-data-exports", phase: "two" },
183 });
184 return { exportArn: exp.exportArn };
185 }),
186 );
187 expect(updated.exportArn).toBe(deployed.exportArn);
188
189 const afterUpdate = yield* getExportByArn(deployed.exportArn);
190 expect(afterUpdate?.Description).toBe(
191 "alchemy bcm-data-exports test (updated)",
192 );
193 expect(
194 afterUpdate?.DestinationConfigurations.S3Destination.S3Prefix,
195 ).toBe("cur2-updated");
196
197 const tagsAfterUpdate = yield* bcm
198 .listTagsForResource({ ResourceArn: deployed.exportArn })
199 .pipe(
200 Effect.map((r) =>
201 Object.fromEntries(
202 (r.ResourceTags ?? []).map((t) => [t.Key, t.Value]),
203 ),
204 ),
205 );
206 expect(tagsAfterUpdate.phase).toBe("two");
207
208 // REPLACE — renaming the export replaces it (new ARN, old one gone).
209 const renamed = yield* stack.deploy(
210 Effect.gen(function* () {
211 const bucket = yield* destinationBucket(accountId);
212 const exp = yield* Export("CurExport", {
213 exportName: renamedExportName,
214 description: "alchemy bcm-data-exports test (updated)",
215 dataQuery: { queryStatement, tableConfigurations },
216 s3Destination: {
217 s3Bucket: bucket.bucketName,
218 s3Prefix: "cur2-updated",
219 s3Region: region,
220 },
221 tags: { fixture: "bcm-data-exports", phase: "two" },
222 });
223 return { exportArn: exp.exportArn, exportName: exp.exportName };
224 }),
225 );
226 expect(renamed.exportName).toBe(renamedExportName);
227 expect(renamed.exportArn).not.toBe(deployed.exportArn);
228 yield* assertExportGone(deployed.exportArn);
229
230 // DESTROY — export and bucket removed; verify out-of-band.
231 yield* stack.destroy();
232 yield* assertExportGone(renamed.exportArn);
233 }),
234 {
235 tags: [
236 "provider:aws",
237 "provider:aws:bcmdataexports",
238 "provider:aws:s3",
239 "live",
240 ],
241 timeout: 300_000,
242 },
243);