1import * as AWS from "@/AWS";
2import { ReportDefinition } from "@/AWS/CostAndUsageReport";
3import { AWSEnvironment } from "@/AWS/Environment.ts";
4import { Bucket } from "@/AWS/S3";
5import * as Test from "@/Test/Alchemy";
6import { Region } from "@distilled.cloud/aws/Region";
7import * as cur from "@distilled.cloud/aws/cost-and-usage-report-service";
8import { expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import * as Schedule from "effect/Schedule";
11import * as Stream from "effect/Stream";
12
13const { test } = Test.make({ providers: AWS.providers() });
14
15// The CUR control plane is only hosted in us-east-1 — pin every out-of-band
16// distilled call there, exactly like the provider does.
17const pin = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
18 effect.pipe(Effect.provideService(Region, Effect.succeed("us-east-1")));
19
20const findReport = (name: string) =>
21 pin(cur.describeReportDefinitions.pages({}).pipe(Stream.runCollect)).pipe(
22 Effect.map((pages) =>
23 Array.from(pages)
24 .flatMap((page) => page.ReportDefinitions ?? [])
25 .find((r) => r.ReportName === name),
26 ),
27 );
28
29const assertReportGone = (name: string) =>
30 Effect.gen(function* () {
31 const found = yield* findReport(name);
32 if (found) {
33 return yield* Effect.fail(
34 new Error(`report definition '${name}' still exists`),
35 );
36 }
37 }).pipe(
38 Effect.retry({
39 schedule: Schedule.max([
40 Schedule.fixed("3 seconds"),
41 Schedule.recurs(10),
42 ]),
43 }),
44 );
45
46// Ungated probe: proves auth + response schema against the live us-east-1
47// CUR endpoint on every CI pass at near-zero cost.
48test.provider(
49 "describeReportDefinitions lists report definitions (auth + schema probe)",
50 () =>
51 Effect.gen(function* () {
52 const response = yield* pin(cur.describeReportDefinitions({}));
53 expect(Array.isArray(response.ReportDefinitions ?? [])).toBe(true);
54 }),
55 { tags: ["provider:aws", "provider:aws:costandusagereport", "live"] },
56);
57
58const REPORT_NAME = "alchemy-test-cur-report";
59const REPLACED_REPORT_NAME = "alchemy-test-cur-report-v2";
60const BUCKET_NAME = "alchemy-test-cur-reports";
61
62test.provider(
63 "create, update, replace, delete report definition",
64 (stack) =>
65 Effect.gen(function* () {
66 yield* stack.destroy();
67
68 const { accountId } = yield* AWSEnvironment.current;
69
70 // The CUR bucket policy AWS requires before it will deliver reports.
71 const curBucketPolicy = [
72 {
73 Effect: "Allow" as const,
74 Principal: { Service: "billingreports.amazonaws.com" },
75 Action: ["s3:GetBucketAcl", "s3:GetBucketPolicy"],
76 Resource: `arn:aws:s3:::${BUCKET_NAME}`,
77 Condition: {
78 StringEquals: {
79 "aws:SourceAccount": accountId,
80 "aws:SourceArn": `arn:aws:cur:us-east-1:${accountId}:definition/*`,
81 },
82 },
83 },
84 {
85 Effect: "Allow" as const,
86 Principal: { Service: "billingreports.amazonaws.com" },
87 Action: ["s3:PutObject"],
88 Resource: `arn:aws:s3:::${BUCKET_NAME}/*`,
89 Condition: {
90 StringEquals: {
91 "aws:SourceAccount": accountId,
92 "aws:SourceArn": `arn:aws:cur:us-east-1:${accountId}:definition/*`,
93 },
94 },
95 },
96 ];
97
98 const program = (opts: {
99 reportName: string;
100 timeUnit: "DAILY" | "HOURLY";
101 compression: "GZIP" | "ZIP";
102 }) =>
103 Effect.gen(function* () {
104 const bucket = yield* Bucket("ReportBucket", {
105 bucketName: BUCKET_NAME,
106 forceDestroy: true,
107 policy: curBucketPolicy,
108 });
109 const report = yield* ReportDefinition("Report", {
110 reportName: opts.reportName,
111 timeUnit: opts.timeUnit,
112 format: "textORcsv",
113 compression: opts.compression,
114 additionalSchemaElements: ["RESOURCES"],
115 s3Bucket: bucket.bucketName,
116 s3Prefix: "cur",
117 s3Region: bucket.region,
118 tags: { fixture: "cur-report" },
119 });
120 return { bucket, report };
121 });
122
123 // CREATE
124 const created = yield* stack.deploy(
125 program({
126 reportName: REPORT_NAME,
127 timeUnit: "DAILY",
128 compression: "GZIP",
129 }),
130 );
131 expect(created.report.reportName).toBe(REPORT_NAME);
132 expect(created.report.reportArn).toBe(
133 `arn:aws:cur:us-east-1:${accountId}:definition/${REPORT_NAME}`,
134 );
135 expect(created.report.s3Bucket).toBe(BUCKET_NAME);
136 expect(created.report.s3Prefix).toBe("cur");
137 expect(created.report.timeUnit).toBe("DAILY");
138
139 // Out-of-band verification via distilled.
140 const observed = yield* findReport(REPORT_NAME);
141 expect(observed?.TimeUnit).toBe("DAILY");
142 expect(observed?.Format).toBe("textORcsv");
143 expect(observed?.Compression).toBe("GZIP");
144 expect(observed?.S3Bucket).toBe(BUCKET_NAME);
145 expect(observed?.AdditionalSchemaElements).toContain("RESOURCES");
146
147 // Tags: user tag + alchemy internal tags.
148 const tags = yield* pin(
149 cur.listTagsForResource({ ReportName: REPORT_NAME }),
150 );
151 expect(tags.Tags).toContainEqual({ Key: "fixture", Value: "cur-report" });
152 expect(tags.Tags?.some((t) => t.Key.startsWith("alchemy:"))).toBe(true);
153
154 // Typed duplicate probe: a second putReportDefinition with the same
155 // name must surface the typed DuplicateReportNameException tag.
156 const duplicate = yield* Effect.flip(
157 pin(
158 cur.putReportDefinition({
159 ReportDefinition: {
160 ReportName: REPORT_NAME,
161 TimeUnit: "DAILY",
162 Format: "textORcsv",
163 Compression: "GZIP",
164 AdditionalSchemaElements: ["RESOURCES"],
165 S3Bucket: BUCKET_NAME,
166 S3Prefix: "cur",
167 S3Region: observed!.S3Region,
168 },
169 }),
170 ),
171 );
172 expect(duplicate._tag).toBe("DuplicateReportNameException");
173
174 // UPDATE in place (timeUnit + compression via modifyReportDefinition).
175 const updated = yield* stack.deploy(
176 program({
177 reportName: REPORT_NAME,
178 timeUnit: "HOURLY",
179 compression: "ZIP",
180 }),
181 );
182 expect(updated.report.reportName).toBe(REPORT_NAME);
183 const modified = yield* findReport(REPORT_NAME);
184 expect(modified?.TimeUnit).toBe("HOURLY");
185 expect(modified?.Compression).toBe("ZIP");
186
187 // REPLACE on reportName change: new report created, old one deleted.
188 const replaced = yield* stack.deploy(
189 program({
190 reportName: REPLACED_REPORT_NAME,
191 timeUnit: "HOURLY",
192 compression: "ZIP",
193 }),
194 );
195 expect(replaced.report.reportName).toBe(REPLACED_REPORT_NAME);
196 const replacement = yield* findReport(REPLACED_REPORT_NAME);
197 expect(replacement?.TimeUnit).toBe("HOURLY");
198 yield* assertReportGone(REPORT_NAME);
199
200 // DELETE
201 yield* stack.destroy();
202 yield* assertReportGone(REPLACED_REPORT_NAME);
203 }),
204 {
205 tags: [
206 "provider:aws",
207 "provider:aws:costandusagereport",
208 "provider:aws:s3",
209 "live",
210 ],
211 timeout: 240_000,
212 },
213);