| 1 | import * as AWS from "@/AWS"; |
| 2 | import { AnomalyMonitor } from "@/AWS/CostExplorer/AnomalyMonitor.ts"; |
| 3 | import * as Provider from "@/Provider"; |
| 4 | import * as Test from "@/Test/Alchemy"; |
| 5 | import { Region as AwsRegion } from "@distilled.cloud/aws/Region"; |
| 6 | import * as ce from "@distilled.cloud/aws/cost-explorer"; |
| 7 | import * as sts from "@distilled.cloud/aws/sts"; |
| 8 | import { expect } from "alchemy-test"; |
| 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Schedule from "effect/Schedule"; |
| 11 | |
| 12 | const { test } = Test.make({ providers: AWS.providers() }); |
| 13 | |
| 14 | // Cost Explorer is served exclusively from us-east-1. |
| 15 | const pin = <A, E, R>(effect: Effect.Effect<A, E, R>) => |
| 16 | effect.pipe(Effect.provideService(AwsRegion, Effect.succeed("us-east-1"))); |
| 17 | |
| 18 | const monitorName = "alchemy-test-anomaly-monitor"; |
| 19 | |
| 20 | // getAnomalyMonitors returns an empty list (NOT an error) for an unknown ARN, |
| 21 | // so absence is `AnomalyMonitors[0] === undefined`. |
| 22 | const getMonitor = (monitorArn: string) => |
| 23 | pin(ce.getAnomalyMonitors({ MonitorArnList: [monitorArn] })).pipe( |
| 24 | Effect.map((r) => r.AnomalyMonitors[0]), |
| 25 | Effect.catchTag("UnknownMonitorException", () => Effect.succeed(undefined)), |
| 26 | ); |
| 27 | |
| 28 | // Typed wait-until-gone: deletion is synchronous but tolerate a short |
| 29 | // eventual-consistency window. |
| 30 | const assertMonitorGone = (monitorArn: string) => |
| 31 | Effect.gen(function* () { |
| 32 | const found = yield* getMonitor(monitorArn); |
| 33 | if (found !== undefined) { |
| 34 | return yield* Effect.fail( |
| 35 | new Error(`monitor '${monitorArn}' still exists`), |
| 36 | ); |
| 37 | } |
| 38 | }).pipe( |
| 39 | Effect.retry({ |
| 40 | schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(5)]), |
| 41 | }), |
| 42 | ); |
| 43 | |
| 44 | // Ungated typed-error probe: prove the distilled error union carries the |
| 45 | // not-found tag this provider's delete path depends on. (GetAnomalyMonitors |
| 46 | // succeeds with an empty list for unknown ARNs — delete is the op that |
| 47 | // raises the typed tag.) |
| 48 | test.provider( |
| 49 | "deleteAnomalyMonitor on a nonexistent monitor ARN fails with UnknownMonitorException", |
| 50 | () => |
| 51 | Effect.gen(function* () { |
| 52 | // The ARN must carry the caller's account — a foreign account ARN is |
| 53 | // rejected earlier with AccessDeniedException. |
| 54 | const identity = yield* sts.getCallerIdentity({}); |
| 55 | const error = yield* Effect.flip( |
| 56 | pin( |
| 57 | ce.deleteAnomalyMonitor({ |
| 58 | MonitorArn: `arn:aws:ce::${identity.Account}:anomalymonitor/00000000-0000-0000-0000-000000000000`, |
| 59 | }), |
| 60 | ), |
| 61 | ); |
| 62 | expect(error._tag).toBe("UnknownMonitorException"); |
| 63 | }), |
| 64 | { |
| 65 | tags: ["provider:aws", "provider:aws:costexplorer", "live"], |
| 66 | timeout: 60_000, |
| 67 | }, |
| 68 | ); |
| 69 | |
| 70 | test.provider( |
| 71 | "lifecycle: create CUSTOM monitor with explicit name, rename in place, destroy", |
| 72 | (stack) => |
| 73 | Effect.gen(function* () { |
| 74 | yield* stack.destroy(); |
| 75 | |
| 76 | // CUSTOM avoids the one-DIMENSIONAL-SERVICE-monitor-per-account quota. |
| 77 | const deployed = yield* stack.deploy( |
| 78 | Effect.gen(function* () { |
| 79 | const monitor = yield* AnomalyMonitor("Monitor", { |
| 80 | monitorName, |
| 81 | monitorType: "CUSTOM", |
| 82 | monitorSpecification: { |
| 83 | Tags: { Key: "CostCenter", Values: ["alchemy-test"] }, |
| 84 | }, |
| 85 | tags: { fixture: "cost-explorer-anomaly-monitor" }, |
| 86 | }); |
| 87 | return { monitorArn: monitor.monitorArn }; |
| 88 | }), |
| 89 | ); |
| 90 | |
| 91 | // Out-of-band verification via distilled. |
| 92 | const created = yield* getMonitor(deployed.monitorArn); |
| 93 | expect(created?.MonitorName).toBe(monitorName); |
| 94 | expect(created?.MonitorType).toBe("CUSTOM"); |
| 95 | const tags = yield* pin( |
| 96 | ce.listTagsForResource({ ResourceArn: deployed.monitorArn }), |
| 97 | ); |
| 98 | const tagRecord = Object.fromEntries( |
| 99 | (tags.ResourceTags ?? []).map((t) => [t.Key, t.Value]), |
| 100 | ); |
| 101 | expect(tagRecord.fixture).toBe("cost-explorer-anomaly-monitor"); |
| 102 | expect(tagRecord["alchemy::id"]).toBe("Monitor"); |
| 103 | |
| 104 | // Canonical list() coverage. |
| 105 | const provider = yield* Provider.findProvider(AnomalyMonitor); |
| 106 | const all = yield* provider.list(); |
| 107 | expect(all.some((m) => m.monitorArn === deployed.monitorArn)).toBe(true); |
| 108 | |
| 109 | // Rename — the name is mutable in place; the ARN must be stable. |
| 110 | const renamed = yield* stack.deploy( |
| 111 | Effect.gen(function* () { |
| 112 | const monitor = yield* AnomalyMonitor("Monitor", { |
| 113 | monitorName: `${monitorName}-renamed`, |
| 114 | monitorType: "CUSTOM", |
| 115 | monitorSpecification: { |
| 116 | Tags: { Key: "CostCenter", Values: ["alchemy-test"] }, |
| 117 | }, |
| 118 | tags: { fixture: "cost-explorer-anomaly-monitor" }, |
| 119 | }); |
| 120 | return { monitorArn: monitor.monitorArn }; |
| 121 | }), |
| 122 | ); |
| 123 | expect(renamed.monitorArn).toBe(deployed.monitorArn); |
| 124 | const afterRename = yield* getMonitor(deployed.monitorArn); |
| 125 | expect(afterRename?.MonitorName).toBe(`${monitorName}-renamed`); |
| 126 | |
| 127 | // Destroy — the monitor is gone. |
| 128 | yield* stack.destroy(); |
| 129 | yield* assertMonitorGone(deployed.monitorArn); |
| 130 | }), |
| 131 | { |
| 132 | tags: ["provider:aws", "provider:aws:costexplorer", "live"], |
| 133 | timeout: 120_000, |
| 134 | }, |
| 135 | ); |
| 136 | |
| 137 | // Monitor names are unique per account and the specification is create-only, |
| 138 | // so replacement coverage uses the engine-generated physical name (a new |
| 139 | // instance ID yields a new name, letting create-before-delete succeed). |
| 140 | test.provider( |
| 141 | "replacement: changing the monitor specification replaces the monitor", |
| 142 | (stack) => |
| 143 | Effect.gen(function* () { |
| 144 | yield* stack.destroy(); |
| 145 | |
| 146 | const makeMonitor = (costCenter: string) => |
| 147 | Effect.gen(function* () { |
| 148 | const monitor = yield* AnomalyMonitor("ReplaceMonitor", { |
| 149 | monitorType: "CUSTOM", |
| 150 | monitorSpecification: { |
| 151 | Tags: { Key: "CostCenter", Values: [costCenter] }, |
| 152 | }, |
| 153 | }); |
| 154 | return { monitorArn: monitor.monitorArn }; |
| 155 | }); |
| 156 | |
| 157 | const deployed = yield* stack.deploy(makeMonitor("alchemy-replace-1")); |
| 158 | const created = yield* getMonitor(deployed.monitorArn); |
| 159 | expect(created?.MonitorType).toBe("CUSTOM"); |
| 160 | |
| 161 | // Change the specification — create-only, must replace (new ARN). |
| 162 | const replaced = yield* stack.deploy(makeMonitor("alchemy-replace-2")); |
| 163 | expect(replaced.monitorArn).not.toBe(deployed.monitorArn); |
| 164 | yield* assertMonitorGone(deployed.monitorArn); |
| 165 | const after = yield* getMonitor(replaced.monitorArn); |
| 166 | expect(after?.MonitorSpecification?.Tags?.Values).toEqual([ |
| 167 | "alchemy-replace-2", |
| 168 | ]); |
| 169 | |
| 170 | // Destroy — the replacement monitor is gone. |
| 171 | yield* stack.destroy(); |
| 172 | yield* assertMonitorGone(replaced.monitorArn); |
| 173 | }), |
| 174 | { |
| 175 | tags: ["provider:aws", "provider:aws:costexplorer", "live"], |
| 176 | timeout: 120_000, |
| 177 | }, |
| 178 | ); |