1import * as AWS from "@/AWS";
2import { CredentialLocker, ManagedThing } from "@/AWS/IoTManagedIntegrations";
3import { Region } from "@/AWS/Region.ts";
4import * as Test from "@/Test/Alchemy";
5import * as mi from "@distilled.cloud/aws/iot-managed-integrations";
6import { expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Redacted from "effect/Redacted";
10import * as Schedule from "effect/Schedule";
11
12const { test } = Test.make({ providers: AWS.providers() });
13
14// IoT Managed Integrations is only deployed in a few regions (eu-west-1,
15// ca-central-1, ...). The testing profile's default region (us-west-2) has no
16// endpoint, so the ungated probe pins a supported region explicitly. The gated
17// lifecycle below requires an AWS profile whose region IS a supported one
18// (SSO profiles resolve region from ~/.aws/config; the AWS_REGION env var is
19// not consulted), e.g. a profile with region = eu-west-1.
20const MI_REGION = "eu-west-1";
21const pin = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
22 effect.pipe(Effect.provideService(Region, Effect.succeed(MI_REGION)));
23
24// Ungated typed-error probe: prove the distilled error union carries the
25// not-found tag this provider's read/delete paths depend on.
26test.provider(
27 "getManagedThing on a nonexistent thing fails with ResourceNotFoundException",
28 () =>
29 Effect.gen(function* () {
30 const error = yield* Effect.flip(
31 pin(mi.getManagedThing({ Identifier: "alchemynonexistentthingprobe" })),
32 );
33 expect(error._tag).toBe("ResourceNotFoundException");
34 }),
35 { tags: ["provider:aws", "provider:aws:iotmanagedintegrations", "live"] },
36);
37
38class ThingStillExists extends Data.TaggedError("ThingStillExists")<{
39 readonly managedThingId: string;
40}> {}
41
42const assertThingGone = (managedThingId: string) =>
43 mi.getManagedThing({ Identifier: managedThingId }).pipe(
44 Effect.flatMap((thing) =>
45 thing.ProvisioningStatus === "DELETED" ||
46 thing.ProvisioningStatus === "DELETE_IN_PROGRESS"
47 ? Effect.void
48 : Effect.fail(new ThingStillExists({ managedThingId })),
49 ),
50 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
51 Effect.retry({
52 while: (e) => e._tag === "ThingStillExists",
53 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(8)]),
54 }),
55 );
56
57// A synthetic Zigbee install-code QR payload. CreateManagedThing validates the
58// payload shape, not that the device exists, so a well-formed constant is
59// accepted for a DEVICE role. Checked in as a constant (never generated at
60// test time).
61const ZIGBEE_QR_PAYLOAD = "Z:24FD5B0000015C63$I:83FED3407A939738";
62
63// Full lifecycle is gated: the service only exists in a few regions and a
64// managed thing represents onboarded device state. Run with
65// AWS_TEST_IOT_MI=1 and an AWS profile configured in a supported region.
66test.provider.skipIf(!process.env.AWS_TEST_IOT_MI)(
67 "create managed thing, verify, update, destroy",
68 (stack) =>
69 Effect.gen(function* () {
70 yield* stack.destroy();
71
72 const makeStack = (brand: string) =>
73 Effect.gen(function* () {
74 const locker = yield* CredentialLocker("Locker", {});
75 const thing = yield* ManagedThing("Device", {
76 role: "DEVICE",
77 authenticationMaterial: Redacted.make(ZIGBEE_QR_PAYLOAD),
78 authenticationMaterialType: "ZIGBEE_QR_BAR_CODE",
79 credentialLockerId: locker.credentialLockerId,
80 brand,
81 serialNumber: "SN-ALCHEMY-0001",
82 tags: { fixture: "iot-mi-managed-thing" },
83 });
84 return { thing };
85 });
86
87 const { thing } = yield* stack.deploy(makeStack("alchemy"));
88
89 expect(thing.managedThingId).toBeDefined();
90 expect(thing.managedThingArn).toContain(":managed-thing/");
91 expect(thing.role).toBe("DEVICE");
92 expect(thing.tags.fixture).toBe("iot-mi-managed-thing");
93
94 // Out-of-band verification via distilled.
95 const observed = yield* mi.getManagedThing({
96 Identifier: thing.managedThingId,
97 });
98 expect(observed.Arn).toBe(thing.managedThingArn);
99
100 // Update a mutable field in place.
101 const { thing: updated } = yield* stack.deploy(makeStack("alchemy-two"));
102 expect(updated.managedThingId).toBe(thing.managedThingId);
103
104 yield* stack.destroy();
105 yield* assertThingGone(thing.managedThingId);
106 }),
107 {
108 tags: ["provider:aws", "provider:aws:iotmanagedintegrations", "live"],
109 timeout: 240_000,
110 },
111);