1import * as AWS from "@/AWS";
2import {
3 MicrosoftTeamsChannelConfiguration,
4 SlackChannelConfiguration,
5} from "@/AWS/Chatbot";
6import { Role } from "@/AWS/IAM/Role.ts";
7import * as Test from "@/Test/Alchemy";
8import * as chatbot from "@distilled.cloud/aws/chatbot";
9import { expect } from "alchemy-test";
10import * as Data from "effect/Data";
11import * as Effect from "effect/Effect";
12import * as Schedule from "effect/Schedule";
13
14const { test } = Test.make({ providers: AWS.providers() });
15
16// ---------------------------------------------------------------------------
17// Ungated probes — Slack/Teams channel configurations require a workspace
18// onboarded via the AWS Chatbot console OAuth flow, which cannot be scripted.
19// These probes prove the typed error semantics the providers depend on, in
20// every CI pass, at near-zero cost.
21// ---------------------------------------------------------------------------
22
23// Workspace-onboarding gate: creating a configuration against a workspace
24// that has not been authorized via the console OAuth flow is rejected with
25// the synthetic SlackWorkspaceNotAuthorized tag (patched in
26// submodules/distilled/packages/aws/patches/chatbot.json). A failed create still
27// reserves the configuration name server-side (undeletable tombstone), so
28// repeated runs of this probe surface the typed ConflictException instead —
29// both tags prove the create is rejected without onboarding.
30test.provider(
31 "createSlackChannelConfiguration without an onboarded workspace fails with SlackWorkspaceNotAuthorized",
32 () =>
33 Effect.gen(function* () {
34 const { accountId } = yield* AWS.AWSEnvironment.current;
35 const error = yield* Effect.flip(
36 chatbot.createSlackChannelConfiguration({
37 SlackTeamId: "T0000000000",
38 SlackChannelId: "C0000000000",
39 ConfigurationName: "alchemy-chatbot-probe-slack",
40 IamRoleArn: `arn:aws:iam::${accountId}:role/alchemy-nonexistent-role`,
41 }),
42 );
43 expect(["SlackWorkspaceNotAuthorized", "ConflictException"]).toContain(
44 error._tag,
45 );
46 }),
47 { tags: ["provider:aws", "provider:aws:chatbot", "live"], timeout: 30_000 },
48);
49
50// Team-onboarding gate: the Teams create fails at team validation (before
51// any name reservation), so this probe is exactly repeatable.
52test.provider(
53 "createMicrosoftTeamsChannelConfiguration without a configured team fails with MicrosoftTeamsTeamNotConfigured",
54 () =>
55 Effect.gen(function* () {
56 const { accountId } = yield* AWS.AWSEnvironment.current;
57 const error = yield* Effect.flip(
58 chatbot.createMicrosoftTeamsChannelConfiguration({
59 ChannelId: "19%3aalchemyprobe%40thread.tacv2",
60 TeamId: "0a1b2c3d-4e5f-1a2b-3c4d-0a1b2c3d4e5f",
61 TenantId: "1a2b3c4d-5e6f-1a2b-3c4d-1a2b3c4d5e6f",
62 ConfigurationName: "alchemy-chatbot-probe-teams",
63 IamRoleArn: `arn:aws:iam::${accountId}:role/alchemy-nonexistent-role`,
64 }),
65 );
66 expect(error._tag).toBe("MicrosoftTeamsTeamNotConfigured");
67 }),
68 { tags: ["provider:aws", "provider:aws:chatbot", "live"], timeout: 30_000 },
69);
70
71// Validates the distilled chatbot patch: the wire ResourceNotFoundException
72// is outside the Smithy model's union for this operation and is added via
73// patches/chatbot.json. The Teams read endpoint occasionally has >30s tail
74// latency under a full c128 sweep, so this probe gets a narrow 60s budget;
75// the typed assertion is unchanged and no service error is masked.
76test.provider(
77 "getMicrosoftTeamsChannelConfiguration on a nonexistent configuration fails with ResourceNotFoundException",
78 () =>
79 Effect.gen(function* () {
80 const { accountId } = yield* AWS.AWSEnvironment.current;
81 const error = yield* Effect.flip(
82 chatbot.getMicrosoftTeamsChannelConfiguration({
83 ChatConfigurationArn: `arn:aws:chatbot::${accountId}:chat-configuration/microsoft-teams-channel/alchemy-probe-nonexistent`,
84 }),
85 );
86 expect(error._tag).toBe("ResourceNotFoundException");
87 }),
88 { tags: ["provider:aws", "provider:aws:chatbot", "live"], timeout: 60_000 },
89);
90
91// The Slack read path is a filter — an unknown ARN yields an empty list, not
92// a not-found error.
93test.provider(
94 "describeSlackChannelConfigurations with an unknown ARN yields an empty list",
95 () =>
96 Effect.gen(function* () {
97 const { accountId } = yield* AWS.AWSEnvironment.current;
98 const result = yield* chatbot.describeSlackChannelConfigurations({
99 ChatConfigurationArn: `arn:aws:chatbot::${accountId}:chat-configuration/slack-channel/alchemy-probe-nonexistent`,
100 });
101 expect(result.SlackChannelConfigurations).toEqual([]);
102 }),
103 { tags: ["provider:aws", "provider:aws:chatbot", "live"], timeout: 30_000 },
104);
105
106// ---------------------------------------------------------------------------
107// Gated live lifecycles — require AWS_TEST_CHATBOT=1 plus an account whose
108// Slack workspace / Microsoft Teams team has been onboarded via the console
109// OAuth flow, with the workspace identifiers supplied via env vars.
110// ---------------------------------------------------------------------------
111
112const chatbotAssumeRolePolicy: AWS.IAM.PolicyDocument = {
113 Version: "2012-10-17",
114 Statement: [
115 {
116 Effect: "Allow",
117 Principal: { Service: "chatbot.amazonaws.com" },
118 Action: ["sts:AssumeRole"],
119 },
120 ],
121};
122
123class ConfigurationStillExists extends Data.TaggedError(
124 "ConfigurationStillExists",
125)<{ readonly arn: string }> {}
126
127const assertSlackConfigurationDeleted = (arn: string) =>
128 chatbot
129 .describeSlackChannelConfigurations({ ChatConfigurationArn: arn })
130 .pipe(
131 Effect.flatMap((r) =>
132 (r.SlackChannelConfigurations ?? []).length === 0
133 ? Effect.void
134 : Effect.fail(new ConfigurationStillExists({ arn })),
135 ),
136 Effect.retry({
137 while: (e) => e._tag === "ConfigurationStillExists",
138 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(8)]),
139 }),
140 );
141
142test.provider.skipIf(
143 !process.env.AWS_TEST_CHATBOT ||
144 !process.env.AWS_CHATBOT_SLACK_TEAM_ID ||
145 !process.env.AWS_CHATBOT_SLACK_CHANNEL_ID,
146)(
147 "create, update, delete Slack channel configuration",
148 (stack) =>
149 Effect.gen(function* () {
150 yield* stack.destroy();
151
152 const slackTeamId = process.env.AWS_CHATBOT_SLACK_TEAM_ID!;
153 const slackChannelId = process.env.AWS_CHATBOT_SLACK_CHANNEL_ID!;
154
155 const deploy = (loggingLevel: "ERROR" | "INFO") =>
156 stack.deploy(
157 Effect.gen(function* () {
158 const role = yield* Role("ChatbotSlackRole", {
159 assumeRolePolicyDocument: chatbotAssumeRolePolicy,
160 managedPolicyArns: ["arn:aws:iam::aws:policy/ReadOnlyAccess"],
161 });
162 const config = yield* SlackChannelConfiguration("Config", {
163 slackTeamId,
164 slackChannelId,
165 iamRoleArn: role.roleArn,
166 loggingLevel,
167 tags: { Environment: "test" },
168 });
169 return { config };
170 }),
171 );
172
173 const { config } = yield* deploy("ERROR");
174 expect(config.chatConfigurationArn).toContain(
175 ":chat-configuration/slack-channel/",
176 );
177 expect(config.slackTeamId).toBe(slackTeamId);
178 expect(config.slackChannelId).toBe(slackChannelId);
179
180 // out-of-band verification via distilled
181 const observed = yield* chatbot
182 .describeSlackChannelConfigurations({
183 ChatConfigurationArn: config.chatConfigurationArn,
184 })
185 .pipe(Effect.map((r) => r.SlackChannelConfigurations?.[0]));
186 expect(observed?.SlackChannelId).toBe(slackChannelId);
187 expect(observed?.LoggingLevel).toBe("ERROR");
188
189 // update the logging level in place
190 const { config: updated } = yield* deploy("INFO");
191 expect(updated.chatConfigurationArn).toBe(config.chatConfigurationArn);
192 const afterUpdate = yield* chatbot
193 .describeSlackChannelConfigurations({
194 ChatConfigurationArn: config.chatConfigurationArn,
195 })
196 .pipe(Effect.map((r) => r.SlackChannelConfigurations?.[0]));
197 expect(afterUpdate?.LoggingLevel).toBe("INFO");
198
199 yield* stack.destroy();
200 yield* assertSlackConfigurationDeleted(config.chatConfigurationArn);
201 }),
202 {
203 tags: ["provider:aws", "provider:aws:chatbot", "provider:aws:iam", "live"],
204 timeout: 300_000,
205 },
206);
207
208const findTeamsConfiguration = (arn: string) =>
209 chatbot
210 .getMicrosoftTeamsChannelConfiguration({ ChatConfigurationArn: arn })
211 .pipe(
212 Effect.map((r) => r.ChannelConfiguration),
213 Effect.catchTag("ResourceNotFoundException", () =>
214 Effect.succeed(undefined),
215 ),
216 );
217
218const assertTeamsConfigurationDeleted = (arn: string) =>
219 findTeamsConfiguration(arn).pipe(
220 Effect.flatMap((config) =>
221 config === undefined
222 ? Effect.void
223 : Effect.fail(new ConfigurationStillExists({ arn })),
224 ),
225 Effect.retry({
226 while: (e) => e._tag === "ConfigurationStillExists",
227 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(8)]),
228 }),
229 );
230
231test.provider.skipIf(
232 !process.env.AWS_TEST_CHATBOT ||
233 !process.env.AWS_CHATBOT_TEAMS_TEAM_ID ||
234 !process.env.AWS_CHATBOT_TEAMS_TENANT_ID ||
235 !process.env.AWS_CHATBOT_TEAMS_CHANNEL_ID,
236)(
237 "create, update, delete Microsoft Teams channel configuration",
238 (stack) =>
239 Effect.gen(function* () {
240 yield* stack.destroy();
241
242 const teamId = process.env.AWS_CHATBOT_TEAMS_TEAM_ID!;
243 const tenantId = process.env.AWS_CHATBOT_TEAMS_TENANT_ID!;
244 const teamsChannelId = process.env.AWS_CHATBOT_TEAMS_CHANNEL_ID!;
245
246 const deploy = (loggingLevel: "ERROR" | "INFO") =>
247 stack.deploy(
248 Effect.gen(function* () {
249 const role = yield* Role("ChatbotTeamsRole", {
250 assumeRolePolicyDocument: chatbotAssumeRolePolicy,
251 managedPolicyArns: ["arn:aws:iam::aws:policy/ReadOnlyAccess"],
252 });
253 const config = yield* MicrosoftTeamsChannelConfiguration("Config", {
254 teamId,
255 tenantId,
256 teamsChannelId,
257 iamRoleArn: role.roleArn,
258 loggingLevel,
259 tags: { Environment: "test" },
260 });
261 return { config };
262 }),
263 );
264
265 const { config } = yield* deploy("ERROR");
266 expect(config.chatConfigurationArn).toContain(
267 ":chat-configuration/microsoft-teams-channel/",
268 );
269 expect(config.teamId).toBe(teamId);
270 expect(config.tenantId).toBe(tenantId);
271
272 // out-of-band verification via distilled
273 const observed = yield* findTeamsConfiguration(
274 config.chatConfigurationArn,
275 );
276 expect(observed?.ChannelId).toBe(teamsChannelId);
277 expect(observed?.LoggingLevel).toBe("ERROR");
278
279 // update the logging level in place
280 const { config: updated } = yield* deploy("INFO");
281 expect(updated.chatConfigurationArn).toBe(config.chatConfigurationArn);
282 const afterUpdate = yield* findTeamsConfiguration(
283 config.chatConfigurationArn,
284 );
285 expect(afterUpdate?.LoggingLevel).toBe("INFO");
286
287 yield* stack.destroy();
288 yield* assertTeamsConfigurationDeleted(config.chatConfigurationArn);
289 }),
290 {
291 tags: ["provider:aws", "provider:aws:chatbot", "provider:aws:iam", "live"],
292 timeout: 300_000,
293 },
294);