| 1 | import * as AWS from "@/AWS"; |
| 2 | import { NotificationChannel } from "@/AWS/DevOpsGuru/NotificationChannel.ts"; |
| 3 | import { Topic } from "@/AWS/SNS/Topic.ts"; |
| 4 | import * as Provider from "@/Provider"; |
| 5 | import * as Test from "@/Test/Alchemy"; |
| 6 | import * as devopsguru from "@distilled.cloud/aws/devops-guru"; |
| 7 | import * as SNS from "@distilled.cloud/aws/sns"; |
| 8 | import { expect } from "alchemy-test"; |
| 9 | import * as Data from "effect/Data"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as Schedule from "effect/Schedule"; |
| 12 | import * as Stream from "effect/Stream"; |
| 13 | |
| 14 | const { test } = Test.make({ providers: AWS.providers() }); |
| 15 | |
| 16 | // Ungated typed-error probe: prove distilled returns a typed tag (never an |
| 17 | // untyped catch-all). A not-yet-onboarded account answers a bogus insight id |
| 18 | // with ResourceNotFoundException; once DevOps Guru has been onboarded the |
| 19 | // same request is rejected earlier as ValidationException — both are typed. |
| 20 | test.provider( |
| 21 | "describeInsight on a nonexistent insight fails with a typed error", |
| 22 | () => |
| 23 | Effect.gen(function* () { |
| 24 | const error = yield* Effect.flip( |
| 25 | devopsguru.describeInsight({ |
| 26 | Id: "alchemy-nonexistent-devopsguru-insight-probe", |
| 27 | }), |
| 28 | ); |
| 29 | expect(["ResourceNotFoundException", "ValidationException"]).toContain( |
| 30 | error._tag, |
| 31 | ); |
| 32 | }), |
| 33 | { tags: ["provider:aws", "provider:aws:devopsguru", "live"] }, |
| 34 | ); |
| 35 | |
| 36 | const listChannels = devopsguru.listNotificationChannels.items({}).pipe( |
| 37 | Stream.runCollect, |
| 38 | Effect.map((chunk) => Array.from(chunk)), |
| 39 | ); |
| 40 | |
| 41 | class TopicStillExists extends Data.TaggedError("TopicStillExists") {} |
| 42 | |
| 43 | // Assert-gone for the SNS topic the test deploys alongside the channel: |
| 44 | // getTopicAttributes converges to the typed NotFoundException after destroy. |
| 45 | const assertTopicDeleted = Effect.fn(function* (topicArn: string) { |
| 46 | yield* SNS.getTopicAttributes({ TopicArn: topicArn }).pipe( |
| 47 | Effect.flatMap(() => Effect.fail(new TopicStillExists())), |
| 48 | Effect.retry({ |
| 49 | while: (error) => error._tag === "TopicStillExists", |
| 50 | schedule: Schedule.exponential(100), |
| 51 | times: 8, |
| 52 | }), |
| 53 | Effect.catchTag("NotFoundException", () => Effect.void), |
| 54 | ); |
| 55 | }); |
| 56 | |
| 57 | test.provider( |
| 58 | "lifecycle: add channel, converge filters, remove", |
| 59 | (stack) => |
| 60 | Effect.gen(function* () { |
| 61 | yield* stack.destroy(); |
| 62 | |
| 63 | // Create — an SNS topic and a channel with severity filters. |
| 64 | const created = yield* stack.deploy( |
| 65 | Effect.gen(function* () { |
| 66 | const topic = yield* Topic("Alerts", {}); |
| 67 | const channel = yield* NotificationChannel("Channel", { |
| 68 | topicArn: topic.topicArn, |
| 69 | severities: ["HIGH"], |
| 70 | }); |
| 71 | return { topic, channel }; |
| 72 | }), |
| 73 | ); |
| 74 | expect(created.channel.id).toBeDefined(); |
| 75 | expect(created.channel.topicArn).toBe(created.topic.topicArn); |
| 76 | |
| 77 | // Out-of-band verification via distilled. |
| 78 | const observed = yield* listChannels; |
| 79 | const live = observed.find((c) => c.Id === created.channel.id); |
| 80 | expect(live?.Config?.Sns?.TopicArn).toBe(created.topic.topicArn); |
| 81 | expect(live?.Config?.Filters?.Severities).toEqual(["HIGH"]); |
| 82 | |
| 83 | // Canonical list() coverage. |
| 84 | const provider = yield* Provider.findProvider(NotificationChannel); |
| 85 | const all = yield* provider.list(); |
| 86 | expect(all.some((c) => c.id === created.channel.id)).toBe(true); |
| 87 | |
| 88 | // Update — change the filters. The AWS config is immutable, so the |
| 89 | // provider converges by remove + re-add (the channel id changes, the |
| 90 | // topic stays put). |
| 91 | const updated = yield* stack.deploy( |
| 92 | Effect.gen(function* () { |
| 93 | const topic = yield* Topic("Alerts", {}); |
| 94 | const channel = yield* NotificationChannel("Channel", { |
| 95 | topicArn: topic.topicArn, |
| 96 | severities: ["HIGH", "MEDIUM"], |
| 97 | messageTypes: ["NEW_INSIGHT"], |
| 98 | }); |
| 99 | return { topic, channel }; |
| 100 | }), |
| 101 | ); |
| 102 | expect(updated.channel.topicArn).toBe(created.topic.topicArn); |
| 103 | const afterUpdate = yield* listChannels; |
| 104 | const liveUpdated = afterUpdate.find((c) => c.Id === updated.channel.id); |
| 105 | expect(liveUpdated?.Config?.Sns?.TopicArn).toBe(created.topic.topicArn); |
| 106 | expect( |
| 107 | [...(liveUpdated?.Config?.Filters?.Severities ?? [])].sort(), |
| 108 | ).toEqual(["HIGH", "MEDIUM"]); |
| 109 | expect(liveUpdated?.Config?.Filters?.MessageTypes).toEqual([ |
| 110 | "NEW_INSIGHT", |
| 111 | ]); |
| 112 | // The old channel is gone (not duplicated). |
| 113 | expect( |
| 114 | afterUpdate.filter( |
| 115 | (c) => c.Config?.Sns?.TopicArn === created.topic.topicArn, |
| 116 | ), |
| 117 | ).toHaveLength(1); |
| 118 | |
| 119 | // Idempotent redeploy — no drift, id is unchanged. |
| 120 | const redeployed = yield* stack.deploy( |
| 121 | Effect.gen(function* () { |
| 122 | const topic = yield* Topic("Alerts", {}); |
| 123 | const channel = yield* NotificationChannel("Channel", { |
| 124 | topicArn: topic.topicArn, |
| 125 | severities: ["HIGH", "MEDIUM"], |
| 126 | messageTypes: ["NEW_INSIGHT"], |
| 127 | }); |
| 128 | return { topic, channel }; |
| 129 | }), |
| 130 | ); |
| 131 | expect(redeployed.channel.id).toBe(updated.channel.id); |
| 132 | |
| 133 | // Destroy — the channel is removed and the SNS topic is gone. |
| 134 | yield* stack.destroy(); |
| 135 | const afterDestroy = yield* listChannels; |
| 136 | expect( |
| 137 | afterDestroy.some( |
| 138 | (c) => c.Config?.Sns?.TopicArn === created.topic.topicArn, |
| 139 | ), |
| 140 | ).toBe(false); |
| 141 | yield* assertTopicDeleted(created.topic.topicArn); |
| 142 | }), |
| 143 | { |
| 144 | tags: [ |
| 145 | "provider:aws", |
| 146 | "provider:aws:devopsguru", |
| 147 | "provider:aws:sns", |
| 148 | "live", |
| 149 | ], |
| 150 | timeout: 180_000, |
| 151 | }, |
| 152 | ); |