1import * as AWS from "@/AWS";
2import {
3 Association,
4 CustomAction,
5 SlackChannelConfiguration,
6} from "@/AWS/Chatbot";
7import { Role } from "@/AWS/IAM/Role.ts";
8import * as Test from "@/Test/Alchemy";
9import * as chatbot from "@distilled.cloud/aws/chatbot";
10import { expect } from "alchemy-test";
11import * as Data from "effect/Data";
12import * as Effect from "effect/Effect";
13import * as Schedule from "effect/Schedule";
14import * as Stream from "effect/Stream";
15
16const { test } = Test.make({ providers: AWS.providers() });
17
18// ---------------------------------------------------------------------------
19// Ungated typed-error probes — a full association lifecycle needs a Slack or
20// Teams channel configuration, which requires a workspace onboarded via the
21// AWS Chatbot console OAuth flow (cannot be scripted). These probes prove the
22// typed error semantics the Association provider's observe/associate paths
23// depend on, in every CI pass, at near-zero cost.
24// ---------------------------------------------------------------------------
25
26// The observe path the provider depends on: listAssociations against an
27// unknown (well-formed) configuration ARN yields an empty list rather than a
28// not-found error, so observation reads it as "no association".
29test.provider(
30 "listAssociations with an unknown configuration ARN yields an empty list",
31 () =>
32 Effect.gen(function* () {
33 const { accountId } = yield* AWS.AWSEnvironment.current;
34 const listings = yield* chatbot.listAssociations
35 .items({
36 ChatConfiguration: `arn:aws:chatbot::${accountId}:chat-configuration/slack-channel/alchemy-probe-nonexistent`,
37 })
38 .pipe(Stream.runCollect, Effect.timeout("45 seconds"));
39 expect(Array.from(listings)).toEqual([]);
40 }),
41 { tags: ["provider:aws", "provider:aws:chatbot", "live"], timeout: 60_000 },
42);
43
44// Validates the distilled chatbot patch: associateToConfiguration against an
45// unknown configuration ARN is rejected with the wire
46// ResourceNotFoundException ("Channel Arn ... does not exist!") rather than
47// creating a dangling association.
48test.provider(
49 "associateToConfiguration with an unknown configuration ARN fails with ResourceNotFoundException",
50 () =>
51 Effect.gen(function* () {
52 const { accountId } = yield* AWS.AWSEnvironment.current;
53 const error = yield* Effect.flip(
54 chatbot.associateToConfiguration({
55 ChatConfiguration: `arn:aws:chatbot::${accountId}:chat-configuration/slack-channel/alchemy-probe-nonexistent`,
56 Resource: `arn:aws:chatbot::${accountId}:custom-action/alchemy-probe-nonexistent`,
57 }),
58 );
59 expect(error._tag).toBe("ResourceNotFoundException");
60 }),
61 { tags: ["provider:aws", "provider:aws:chatbot", "live"], timeout: 30_000 },
62);
63
64// ---------------------------------------------------------------------------
65// Gated live lifecycle — requires AWS_TEST_CHATBOT=1 plus an account whose
66// Slack workspace has been onboarded via the console OAuth flow, with the
67// workspace identifiers supplied via env vars.
68// ---------------------------------------------------------------------------
69
70class AssociationMismatch extends Data.TaggedError("AssociationMismatch")<{
71 readonly configuration: string;
72 readonly expected: readonly string[];
73}> {}
74
75const listAssociatedResources = (configuration: string) =>
76 chatbot.listAssociations.items({ ChatConfiguration: configuration }).pipe(
77 Stream.runCollect,
78 Effect.map((listings) =>
79 Array.from(listings).map((listing) => listing.Resource),
80 ),
81 Effect.catchTag("InvalidRequestException", () =>
82 Effect.succeed([] as string[]),
83 ),
84 );
85
86// Bounded wait until the configuration's association list equals `expected`.
87const assertAssociations = (
88 configuration: string,
89 expected: readonly string[],
90) =>
91 listAssociatedResources(configuration).pipe(
92 Effect.flatMap((resources) =>
93 JSON.stringify([...resources].sort()) ===
94 JSON.stringify([...expected].sort())
95 ? Effect.void
96 : Effect.fail(new AssociationMismatch({ configuration, expected })),
97 ),
98 Effect.retry({
99 while: (e): boolean => e._tag === "AssociationMismatch",
100 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(8)]),
101 }),
102 );
103
104const chatbotAssumeRolePolicy: AWS.IAM.PolicyDocument = {
105 Version: "2012-10-17",
106 Statement: [
107 {
108 Effect: "Allow",
109 Principal: { Service: "chatbot.amazonaws.com" },
110 Action: ["sts:AssumeRole"],
111 },
112 ],
113};
114
115test.provider.skipIf(
116 !process.env.AWS_TEST_CHATBOT ||
117 !process.env.AWS_CHATBOT_SLACK_TEAM_ID ||
118 !process.env.AWS_CHATBOT_SLACK_CHANNEL_ID,
119)(
120 "create, replace (re-point to a second action), delete association",
121 (stack) =>
122 Effect.gen(function* () {
123 yield* stack.destroy();
124
125 const slackTeamId = process.env.AWS_CHATBOT_SLACK_TEAM_ID!;
126 const slackChannelId = process.env.AWS_CHATBOT_SLACK_CHANNEL_ID!;
127
128 // Both custom actions stay deployed across the replacement step — a
129 // deploy that replaces a resource while simultaneously removing its old
130 // dependency deadlocks the engine.
131 const deploy = (target: "a" | "b") =>
132 stack.deploy(
133 Effect.gen(function* () {
134 const role = yield* Role("AssocChatbotRole", {
135 assumeRolePolicyDocument: chatbotAssumeRolePolicy,
136 managedPolicyArns: ["arn:aws:iam::aws:policy/ReadOnlyAccess"],
137 });
138 const config = yield* SlackChannelConfiguration("AssocConfig", {
139 slackTeamId,
140 slackChannelId,
141 iamRoleArn: role.roleArn,
142 });
143 const actionA = yield* CustomAction("AssocActionA", {
144 commandText: "aws lambda list-functions",
145 });
146 const actionB = yield* CustomAction("AssocActionB", {
147 commandText: "aws s3 ls",
148 });
149 const association = yield* Association("Assoc", {
150 chatConfiguration: config.chatConfigurationArn,
151 resource:
152 target === "a"
153 ? actionA.customActionArn
154 : actionB.customActionArn,
155 });
156 return { config, actionA, actionB, association };
157 }),
158 );
159
160 const first = yield* deploy("a");
161 expect(first.association.chatConfigurationArn).toBe(
162 first.config.chatConfigurationArn,
163 );
164 expect(first.association.resourceArn).toBe(first.actionA.customActionArn);
165
166 // out-of-band verification via distilled
167 yield* assertAssociations(first.config.chatConfigurationArn, [
168 first.actionA.customActionArn,
169 ]);
170
171 // re-pointing the association to a different resource is a replacement:
172 // the new pair is associated and the old one disassociated
173 const second = yield* deploy("b");
174 expect(second.association.resourceArn).toBe(
175 second.actionB.customActionArn,
176 );
177 yield* assertAssociations(second.config.chatConfigurationArn, [
178 second.actionB.customActionArn,
179 ]);
180
181 yield* stack.destroy();
182 }),
183 {
184 tags: ["provider:aws", "provider:aws:chatbot", "provider:aws:iam", "live"],
185 timeout: 300_000,
186 },
187);