1import * as AWS from "@/AWS";
2import { Region } from "@/AWS/Region.ts";
3import * as Core from "@/Test/Core";
4import * as Test from "@/Test/Alchemy";
5import * as mi from "@distilled.cloud/aws/iot-managed-integrations";
6import { describe, expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import IoTMITestFunctionLive, { IoTMITestFunction } from "./bindings-handler";
11
12const testOptions = { providers: AWS.providers() };
13const { test } = Test.make(testOptions);
14
15// IoT Managed Integrations is only deployed in a few regions (eu-west-1,
16// ca-central-1, ...). The testing profile's default region (us-west-2) has no
17// endpoint, so the ungated probes pin a supported region explicitly. The
18// gated fixture below requires an AWS profile whose region IS a supported
19// one, 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// ---------------------------------------------------------------------------
25// Ungated typed-error probes: the operations the runtime bindings wrap are
26// exercised directly through distilled and must answer with typed tags (or
27// succeed, for the public schema catalog). These prove the distilled error
28// unions and request serialization at near-zero cost on every CI pass; the
29// full Lambda fixture is gated on a supported-region profile.
30// ---------------------------------------------------------------------------
31
32describe(
33 "IoTManagedIntegrations binding operations (typed probes)",
34 { tags: ["provider:aws", "provider:aws:iotmanagedintegrations", "live"] },
35 () => {
36 test.provider(
37 "getManagedThingState on a nonexistent thing fails with a typed tag",
38 () =>
39 Effect.gen(function* () {
40 const error = yield* Effect.flip(
41 pin(
42 mi.getManagedThingState({
43 ManagedThingId: "alchemynonexistentthingprobe",
44 }),
45 ),
46 );
47 expect([
48 "ResourceNotFoundException",
49 "ValidationException",
50 ]).toContain(error._tag);
51 }),
52 );
53
54 test.provider(
55 "getDeviceDiscovery on a nonexistent discovery fails with a typed tag",
56 () =>
57 Effect.gen(function* () {
58 const error = yield* Effect.flip(
59 pin(
60 mi.getDeviceDiscovery({
61 Identifier: "alchemynonexistentdiscoveryprobe",
62 }),
63 ),
64 );
65 expect([
66 "ResourceNotFoundException",
67 "ValidationException",
68 ]).toContain(error._tag);
69 }),
70 );
71
72 test.provider(
73 "sendConnectorEvent to a nonexistent connector fails with a typed tag",
74 () =>
75 Effect.gen(function* () {
76 const error = yield* Effect.flip(
77 pin(
78 mi.sendConnectorEvent({
79 ConnectorId: "alchemynonexistentconnectorprobe",
80 Operation: "DEVICE_EVENT",
81 }),
82 ),
83 );
84 expect([
85 "ResourceNotFoundException",
86 "ValidationException",
87 ]).toContain(error._tag);
88 }),
89 );
90
91 test.provider(
92 "listSchemaVersions reads the public capability schema catalog",
93 () =>
94 Effect.gen(function* () {
95 const result = yield* pin(
96 mi.listSchemaVersions({ Type: "capability", MaxResults: 3 }),
97 );
98 expect(result.Items).toBeDefined();
99 expect(result.Items!.length).toBeGreaterThan(0);
100 }),
101 );
102 },
103);
104
105// ---------------------------------------------------------------------------
106// Full runtime fixture: a Lambda bound to all 14 runtime bindings against a
107// live credential locker + (unprovisioned) managed thing. Gated behind
108// AWS_TEST_IOT_MI=1 with an AWS profile configured in a supported region
109// (same gate as the resource lifecycle tests).
110// ---------------------------------------------------------------------------
111
112const sharedStack = Core.scratchStack(testOptions, "IoTMIBindings");
113
114test.provider.skipIf(!process.env.AWS_TEST_IOT_MI)(
115 "runtime bindings against a live managed thing",
116 () =>
117 Effect.gen(function* () {
118 yield* sharedStack.destroy();
119
120 yield* Effect.gen(function* () {
121 const { functionUrl } = yield* sharedStack.deploy(
122 Effect.gen(function* () {
123 return yield* IoTMITestFunction;
124 }).pipe(Effect.provide(IoTMITestFunctionLive)),
125 );
126 expect(functionUrl).toBeTruthy();
127 const baseUrl = functionUrl!.replace(/\/+$/, "");
128
129 const getJson = (path: string) =>
130 HttpClient.get(`${baseUrl}${path}`).pipe(
131 Effect.flatMap((response) =>
132 response.status >= 500
133 ? Effect.fail(
134 new Error(`transient upstream ${response.status}`),
135 )
136 : Effect.succeed(response),
137 ),
138 Effect.retry({
139 schedule: Schedule.max([
140 Schedule.exponential("500 millis"),
141 Schedule.recurs(10),
142 ]),
143 }),
144 Effect.flatMap((r) => r.json),
145 );
146
147 // All 15 capabilities initialized in the runtime.
148 const bindings = (yield* getJson("/bindings")) as { bound: string[] };
149 expect(bindings.bound).toHaveLength(15);
150
151 // Account-level ops must be authorized (Resource "*") and succeed.
152 const schemas = (yield* getJson("/schema-versions")) as {
153 count?: number;
154 errorTag?: string;
155 };
156 expect(schemas.errorTag).toBeUndefined();
157 expect(schemas.count).toBeGreaterThan(0);
158
159 // Thing-scoped reads must be authorized on the thing ARN. Metadata
160 // and capabilities succeed even for an unprovisioned device.
161 const metadata = (yield* getJson("/metadata")) as {
162 managedThingId?: string;
163 errorTag?: string;
164 };
165 expect(metadata.errorTag).toBeUndefined();
166 expect(metadata.managedThingId).toBeTruthy();
167
168 const capabilities = (yield* getJson("/capabilities")) as {
169 errorTag?: string;
170 };
171 expect(capabilities.errorTag).toBeUndefined();
172
173 // The remaining routes exercise ops that cannot fully succeed
174 // against an unprovisioned device / absent connector: each must
175 // answer with data or a TYPED tag that is not an IAM denial —
176 // proving the wiring, identifier injection, and grants.
177 for (const path of [
178 "/state",
179 "/certificate",
180 "/connectivity",
181 "/thing-schemas",
182 "/command",
183 "/discovery",
184 "/discoveries",
185 "/get-discovery",
186 "/discovered",
187 "/connector-event",
188 "/custom-endpoint",
189 ]) {
190 const result = (yield* getJson(path)) as { errorTag?: string };
191 expect(result.errorTag).not.toBe("AccessDeniedException");
192 }
193 }).pipe(Effect.ensuring(sharedStack.destroy().pipe(Effect.orDie)));
194 }),
195 {
196 tags: [
197 "provider:aws",
198 "provider:aws:iotmanagedintegrations",
199 "provider:aws:lambda",
200 "live",
201 ],
202 timeout: 600_000,
203 },
204);