1import * as AWS from "@/AWS";
2import type { PolicyDocument } from "@/AWS/IAM/Policy.ts";
3import { Role } from "@/AWS/IAM/Role.ts";
4import {
5 Destination,
6 NotificationConfiguration,
7} from "@/AWS/IoTManagedIntegrations";
8import { Stream } from "@/AWS/Kinesis";
9import { Region } from "@/AWS/Region.ts";
10import * as Test from "@/Test/Alchemy";
11import * as mi from "@distilled.cloud/aws/iot-managed-integrations";
12import { expect } from "alchemy-test";
13import * as Data from "effect/Data";
14import * as Effect from "effect/Effect";
15import * as Schedule from "effect/Schedule";
16
17const { test } = Test.make({ providers: AWS.providers() });
18
19// IoT Managed Integrations is only deployed in a few regions (eu-west-1,
20// ca-central-1, ...). The testing profile's default region (us-west-2) has no
21// endpoint, so the ungated probe pins a supported region explicitly. The
22// gated lifecycle below requires an AWS profile whose region IS a supported
23// one, e.g. a profile with region = eu-west-1.
24const MI_REGION = "eu-west-1";
25const pin = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
26 effect.pipe(Effect.provideService(Region, Effect.succeed(MI_REGION)));
27
28// Ungated typed-error probe: prove the distilled error union carries the
29// not-found tag this provider's read/delete paths depend on.
30test.provider(
31 "getNotificationConfiguration on an unconfigured event type fails with ResourceNotFoundException",
32 () =>
33 Effect.gen(function* () {
34 const error = yield* Effect.flip(
35 pin(
36 mi.getNotificationConfiguration({
37 EventType: "CONNECTOR_ERROR_REPORT",
38 }),
39 ),
40 );
41 expect(error._tag).toBe("ResourceNotFoundException");
42 }),
43 { tags: ["provider:aws", "provider:aws:iotmanagedintegrations", "live"] },
44);
45
46class ConfigurationStillExists extends Data.TaggedError(
47 "ConfigurationStillExists",
48)<{ readonly eventType: string }> {}
49
50const assertConfigurationGone = (eventType: string) =>
51 mi.getNotificationConfiguration({ EventType: eventType }).pipe(
52 Effect.flatMap(() =>
53 Effect.fail(new ConfigurationStillExists({ eventType })),
54 ),
55 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
56 Effect.retry({
57 while: (e) => e._tag === "ConfigurationStillExists",
58 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(8)]),
59 }),
60 );
61
62// Full lifecycle is gated: the service only exists in a few regions. Run with
63// AWS_TEST_IOT_MI=1 and an AWS profile configured in a supported region.
64test.provider.skipIf(!process.env.AWS_TEST_IOT_MI)(
65 "create notification configuration, verify, retarget destination, destroy",
66 (stack) =>
67 Effect.gen(function* () {
68 yield* stack.destroy();
69
70 const makeStack = (target: "A" | "B") =>
71 Effect.gen(function* () {
72 const stream = yield* Stream("Events", {});
73 const assumeRolePolicyDocument: PolicyDocument = {
74 Version: "2012-10-17",
75 Statement: [
76 {
77 Effect: "Allow",
78 Principal: {
79 Service: "iotmanagedintegrations.amazonaws.com",
80 },
81 Action: ["sts:AssumeRole"],
82 },
83 ],
84 };
85 const role = yield* Role("DeliveryRole", {
86 assumeRolePolicyDocument,
87 inlinePolicies: {
88 kinesis: {
89 Version: "2012-10-17",
90 Statement: [
91 {
92 Effect: "Allow",
93 Action: ["kinesis:PutRecord", "kinesis:PutRecords"],
94 Resource: [stream.streamArn],
95 },
96 ],
97 },
98 },
99 });
100 const destinationA = yield* Destination("DestinationA", {
101 deliveryDestinationArn: stream.streamArn,
102 roleArn: role.roleArn,
103 });
104 const destinationB = yield* Destination("DestinationB", {
105 deliveryDestinationArn: stream.streamArn,
106 roleArn: role.roleArn,
107 });
108 const routing = yield* NotificationConfiguration("Routing", {
109 eventType: "DEVICE_LIFE_CYCLE",
110 destinationName:
111 target === "A"
112 ? destinationA.destinationName
113 : destinationB.destinationName,
114 tags: { fixture: "iot-mi-notification-configuration" },
115 });
116 return { routing, destinationA, destinationB };
117 });
118
119 const { routing, destinationA } = yield* stack.deploy(makeStack("A"));
120
121 expect(routing.eventType).toBe("DEVICE_LIFE_CYCLE");
122 expect(routing.destinationName).toBe(destinationA.destinationName);
123 expect(routing.tags.fixture).toBe("iot-mi-notification-configuration");
124
125 // Out-of-band verification via distilled.
126 const observed = yield* mi.getNotificationConfiguration({
127 EventType: "DEVICE_LIFE_CYCLE",
128 });
129 expect(observed.DestinationName).toBe(destinationA.destinationName);
130 expect(observed.Tags?.fixture).toBe("iot-mi-notification-configuration");
131
132 // Retarget the configuration at destination B in place (no replace —
133 // the event type is unchanged).
134 const { routing: updated, destinationB } = yield* stack.deploy(
135 makeStack("B"),
136 );
137 expect(updated.eventType).toBe("DEVICE_LIFE_CYCLE");
138 expect(updated.destinationName).toBe(destinationB.destinationName);
139 const retargeted = yield* mi.getNotificationConfiguration({
140 EventType: "DEVICE_LIFE_CYCLE",
141 });
142 expect(retargeted.DestinationName).toBe(destinationB.destinationName);
143
144 yield* stack.destroy();
145 yield* assertConfigurationGone("DEVICE_LIFE_CYCLE");
146 }),
147 {
148 tags: [
149 "provider:aws",
150 "provider:aws:iam",
151 "provider:aws:iotmanagedintegrations",
152 "provider:aws:kinesis",
153 "live",
154 ],
155 timeout: 240_000,
156 },
157);