1import * as AWS from "@/AWS";
2import { Role } from "@/AWS/IAM/Role.ts";
3import {
4 Destination,
5 DeviceProfile,
6 ServiceProfile,
7 WirelessDevice,
8 WirelessGateway,
9} from "@/AWS/IoTWireless";
10import * as Test from "@/Test/Alchemy";
11import * as iotw from "@distilled.cloud/aws/iot-wireless";
12import { expect } from "alchemy-test";
13import * as Effect from "effect/Effect";
14import * as Redacted from "effect/Redacted";
15
16const { test } = Test.make({ providers: AWS.providers() });
17
18// Ungated typed-error probe: prove the distilled error union carries the
19// not-found tag every IoTWireless provider's read/delete path depends on.
20// Runs in every CI pass at near-zero cost.
21test.provider(
22 "getServiceProfile on a bogus id fails with ResourceNotFoundException",
23 () =>
24 Effect.gen(function* () {
25 const error = yield* Effect.flip(
26 iotw.getServiceProfile({
27 Id: "00000000-0000-0000-0000-000000000000",
28 }),
29 );
30 expect(error._tag).toBe("ResourceNotFoundException");
31 }),
32 { tags: ["provider:aws", "provider:aws:iotwireless", "live"] },
33);
34
35// The IAM role IoT Wireless assumes to deliver uplinks to the rule/topic.
36const deliveryRole = Role("IotWirelessDelivery", {
37 assumeRolePolicyDocument: {
38 Version: "2012-10-17",
39 Statement: [
40 {
41 Effect: "Allow",
42 Principal: { Service: "iotwireless.amazonaws.com" },
43 Action: ["sts:AssumeRole"],
44 },
45 ],
46 },
47 inlinePolicies: {
48 deliver: {
49 Version: "2012-10-17",
50 Statement: [
51 {
52 Effect: "Allow",
53 Action: ["iot:DescribeEndpoint", "iot:Publish"],
54 Resource: ["*"],
55 },
56 ],
57 },
58 },
59});
60
61// Service profiles, device profiles, and destinations are free, provision
62// instantly, and need no radio hardware — full lifecycle runs ungated.
63test.provider(
64 "lifecycle: service profile + device profile + destination",
65 (stack) =>
66 Effect.gen(function* () {
67 yield* stack.destroy();
68
69 // Create.
70 const deployed = yield* stack.deploy(
71 Effect.gen(function* () {
72 const role = yield* deliveryRole;
73 const serviceProfile = yield* ServiceProfile("Fleet", {
74 loRaWAN: { AddGwMetadata: true },
75 tags: { fixture: "iot-wireless" },
76 });
77 const deviceProfile = yield* DeviceProfile("SensorModel", {
78 loRaWAN: {
79 MacVersion: "1.0.3",
80 RegParamsRevision: "RP002-1.0.1",
81 RfRegion: "US915",
82 MaxEirp: 10,
83 SupportsJoin: true,
84 },
85 tags: { fixture: "iot-wireless" },
86 });
87 const destination = yield* Destination("Uplinks", {
88 expressionType: "RuleName",
89 expression: "alchemy_iot_wireless_test_rule",
90 description: "alchemy iot-wireless test destination",
91 roleArn: role.roleArn,
92 tags: { fixture: "iot-wireless" },
93 });
94 return { serviceProfile, deviceProfile, destination };
95 }),
96 );
97
98 expect(deployed.serviceProfile.serviceProfileId).toBeDefined();
99 expect(deployed.serviceProfile.serviceProfileArn).toContain(
100 "iotwireless",
101 );
102 expect(deployed.deviceProfile.deviceProfileId).toBeDefined();
103 expect(deployed.destination.destinationArn).toContain("iotwireless");
104 expect(deployed.destination.expression).toBe(
105 "alchemy_iot_wireless_test_rule",
106 );
107
108 // Out-of-band verification via distilled.
109 const liveServiceProfile = yield* iotw.getServiceProfile({
110 Id: deployed.serviceProfile.serviceProfileId,
111 });
112 expect(liveServiceProfile.Name).toBe(
113 deployed.serviceProfile.serviceProfileName,
114 );
115 expect(liveServiceProfile.LoRaWAN?.AddGwMetadata).toBe(true);
116
117 const liveDeviceProfile = yield* iotw.getDeviceProfile({
118 Id: deployed.deviceProfile.deviceProfileId,
119 });
120 expect(liveDeviceProfile.LoRaWAN?.RfRegion).toBe("US915");
121 expect(liveDeviceProfile.LoRaWAN?.MacVersion).toBe("1.0.3");
122
123 const liveDestination = yield* iotw.getDestination({
124 Name: deployed.destination.destinationName,
125 });
126 expect(liveDestination.ExpressionType).toBe("RuleName");
127 expect(liveDestination.Expression).toBe("alchemy_iot_wireless_test_rule");
128
129 const destinationTags = yield* iotw.listTagsForResource({
130 ResourceArn: deployed.destination.destinationArn,
131 });
132 expect(
133 destinationTags.Tags?.some(
134 (t) => t.Key === "alchemy::id" && t.Value === "Uplinks",
135 ),
136 ).toBe(true);
137 expect(
138 destinationTags.Tags?.some(
139 (t) => t.Key === "fixture" && t.Value === "iot-wireless",
140 ),
141 ).toBe(true);
142
143 // Update — the destination's expression and the service profile's
144 // tags change in place; the service profile's LoRaWAN config is
145 // immutable so changing it REPLACES the profile (new id).
146 const updated = yield* stack.deploy(
147 Effect.gen(function* () {
148 const role = yield* deliveryRole;
149 const serviceProfile = yield* ServiceProfile("Fleet", {
150 loRaWAN: { AddGwMetadata: false },
151 tags: { fixture: "iot-wireless", env: "test" },
152 });
153 const deviceProfile = yield* DeviceProfile("SensorModel", {
154 loRaWAN: {
155 MacVersion: "1.0.3",
156 RegParamsRevision: "RP002-1.0.1",
157 RfRegion: "US915",
158 MaxEirp: 10,
159 SupportsJoin: true,
160 },
161 tags: { fixture: "iot-wireless" },
162 });
163 const destination = yield* Destination("Uplinks", {
164 expressionType: "RuleName",
165 expression: "alchemy_iot_wireless_test_rule_v2",
166 description: "alchemy iot-wireless test destination",
167 roleArn: role.roleArn,
168 tags: { fixture: "iot-wireless" },
169 });
170 return { serviceProfile, deviceProfile, destination };
171 }),
172 );
173
174 // Destination updated in place (same identity).
175 expect(updated.destination.destinationName).toBe(
176 deployed.destination.destinationName,
177 );
178 const updatedDestination = yield* iotw.getDestination({
179 Name: updated.destination.destinationName,
180 });
181 expect(updatedDestination.Expression).toBe(
182 "alchemy_iot_wireless_test_rule_v2",
183 );
184
185 // Device profile untouched (same id).
186 expect(updated.deviceProfile.deviceProfileId).toBe(
187 deployed.deviceProfile.deviceProfileId,
188 );
189
190 // Service profile replaced (new id) and the old one deleted.
191 expect(updated.serviceProfile.serviceProfileId).not.toBe(
192 deployed.serviceProfile.serviceProfileId,
193 );
194 const replacedServiceProfile = yield* iotw.getServiceProfile({
195 Id: updated.serviceProfile.serviceProfileId,
196 });
197 expect(replacedServiceProfile.LoRaWAN?.AddGwMetadata).toBe(false);
198 const oldServiceProfileError = yield* Effect.flip(
199 iotw.getServiceProfile({
200 Id: deployed.serviceProfile.serviceProfileId,
201 }),
202 );
203 expect(oldServiceProfileError._tag).toBe("ResourceNotFoundException");
204 const serviceProfileTags = yield* iotw.listTagsForResource({
205 ResourceArn: updated.serviceProfile.serviceProfileArn,
206 });
207 expect(
208 serviceProfileTags.Tags?.some(
209 (t) => t.Key === "env" && t.Value === "test",
210 ),
211 ).toBe(true);
212
213 // Destroy — everything gone, typed.
214 yield* stack.destroy();
215 const destinationError = yield* Effect.flip(
216 iotw.getDestination({ Name: updated.destination.destinationName }),
217 );
218 expect(destinationError._tag).toBe("ResourceNotFoundException");
219 const serviceProfileError = yield* Effect.flip(
220 iotw.getServiceProfile({
221 Id: updated.serviceProfile.serviceProfileId,
222 }),
223 );
224 expect(serviceProfileError._tag).toBe("ResourceNotFoundException");
225 const deviceProfileError = yield* Effect.flip(
226 iotw.getDeviceProfile({ Id: updated.deviceProfile.deviceProfileId }),
227 );
228 expect(deviceProfileError._tag).toBe("ResourceNotFoundException");
229 }),
230 {
231 tags: [
232 "provider:aws",
233 "provider:aws:iam",
234 "provider:aws:iotwireless",
235 "live",
236 ],
237 timeout: 240_000,
238 },
239);
240
241// Deterministic fabricated radio identities — devices and gateways can be
242// registered without hardware (they just never join), but accounts shared
243// with real LoRaWAN fleets should not accumulate phantom radios, so the
244// full device/gateway lifecycle is gated behind AWS_TEST_IOT_WIRELESS=1.
245const DEV_EUI = "1a2b3c4d5e6f7081";
246const APP_EUI = "0000000000000001";
247const APP_KEY = Redacted.make("000102030405060708090a0b0c0d0e0f");
248const GATEWAY_EUI = "aa555a0000000101";
249
250test.provider.skipIf(!process.env.AWS_TEST_IOT_WIRELESS)(
251 "lifecycle: wireless device + wireless gateway",
252 (stack) =>
253 Effect.gen(function* () {
254 yield* stack.destroy();
255
256 // Create — a full LoRaWAN registration: destination + profiles +
257 // OTAA v1.0.x device + gateway.
258 const deployed = yield* stack.deploy(
259 Effect.gen(function* () {
260 const role = yield* deliveryRole;
261 const destination = yield* Destination("DeviceUplinks", {
262 expressionType: "RuleName",
263 expression: "alchemy_iot_wireless_device_rule",
264 roleArn: role.roleArn,
265 tags: { fixture: "iot-wireless" },
266 });
267 const serviceProfile = yield* ServiceProfile("DeviceFleet", {
268 loRaWAN: { AddGwMetadata: true },
269 });
270 const deviceProfile = yield* DeviceProfile("DeviceModel", {
271 loRaWAN: {
272 MacVersion: "1.0.3",
273 RegParamsRevision: "RP002-1.0.1",
274 RfRegion: "US915",
275 MaxEirp: 10,
276 SupportsJoin: true,
277 },
278 });
279 const device = yield* WirelessDevice("Sensor", {
280 type: "LoRaWAN",
281 destinationName: destination.destinationName,
282 description: "alchemy test sensor",
283 loRaWAN: {
284 DevEui: DEV_EUI,
285 DeviceProfileId: deviceProfile.deviceProfileId,
286 ServiceProfileId: serviceProfile.serviceProfileId,
287 OtaaV1_0_x: { AppKey: APP_KEY, AppEui: APP_EUI },
288 },
289 tags: { fixture: "iot-wireless" },
290 });
291 const gateway = yield* WirelessGateway("RooftopGw", {
292 description: "alchemy test gateway",
293 loRaWAN: { GatewayEui: GATEWAY_EUI, RfRegion: "US915" },
294 tags: { fixture: "iot-wireless" },
295 });
296 return {
297 destination,
298 serviceProfile,
299 deviceProfile,
300 device,
301 gateway,
302 };
303 }),
304 );
305
306 expect(deployed.device.wirelessDeviceId).toBeDefined();
307 expect(deployed.device.type).toBe("LoRaWAN");
308 expect(deployed.device.destinationName).toBe(
309 deployed.destination.destinationName,
310 );
311 expect(deployed.gateway.wirelessGatewayId).toBeDefined();
312 expect(deployed.gateway.gatewayEui).toBe(GATEWAY_EUI);
313
314 // Out-of-band verification via distilled.
315 const liveDevice = yield* iotw.getWirelessDevice({
316 Identifier: deployed.device.wirelessDeviceId,
317 IdentifierType: "WirelessDeviceId",
318 });
319 expect(liveDevice.LoRaWAN?.DevEui?.toLowerCase()).toBe(DEV_EUI);
320 expect(liveDevice.LoRaWAN?.DeviceProfileId).toBe(
321 deployed.deviceProfile.deviceProfileId,
322 );
323 expect(liveDevice.LoRaWAN?.ServiceProfileId).toBe(
324 deployed.serviceProfile.serviceProfileId,
325 );
326 const liveGateway = yield* iotw.getWirelessGateway({
327 Identifier: deployed.gateway.wirelessGatewayId,
328 IdentifierType: "WirelessGatewayId",
329 });
330 expect(liveGateway.LoRaWAN?.GatewayEui?.toLowerCase()).toBe(GATEWAY_EUI);
331 expect(liveGateway.LoRaWAN?.RfRegion).toBe("US915");
332
333 // Update — device description and gateway MaxEirp change in place;
334 // identities (DevEui / GatewayEui) are stable.
335 const updated = yield* stack.deploy(
336 Effect.gen(function* () {
337 const role = yield* deliveryRole;
338 const destination = yield* Destination("DeviceUplinks", {
339 expressionType: "RuleName",
340 expression: "alchemy_iot_wireless_device_rule",
341 roleArn: role.roleArn,
342 tags: { fixture: "iot-wireless" },
343 });
344 const serviceProfile = yield* ServiceProfile("DeviceFleet", {
345 loRaWAN: { AddGwMetadata: true },
346 });
347 const deviceProfile = yield* DeviceProfile("DeviceModel", {
348 loRaWAN: {
349 MacVersion: "1.0.3",
350 RegParamsRevision: "RP002-1.0.1",
351 RfRegion: "US915",
352 MaxEirp: 10,
353 SupportsJoin: true,
354 },
355 });
356 const device = yield* WirelessDevice("Sensor", {
357 type: "LoRaWAN",
358 destinationName: destination.destinationName,
359 description: "alchemy test sensor v2",
360 loRaWAN: {
361 DevEui: DEV_EUI,
362 DeviceProfileId: deviceProfile.deviceProfileId,
363 ServiceProfileId: serviceProfile.serviceProfileId,
364 OtaaV1_0_x: { AppKey: APP_KEY, AppEui: APP_EUI },
365 },
366 tags: { fixture: "iot-wireless", env: "test" },
367 });
368 const gateway = yield* WirelessGateway("RooftopGw", {
369 description: "alchemy test gateway",
370 loRaWAN: {
371 GatewayEui: GATEWAY_EUI,
372 RfRegion: "US915",
373 MaxEirp: 27,
374 },
375 tags: { fixture: "iot-wireless" },
376 });
377 return { device, gateway };
378 }),
379 );
380
381 expect(updated.device.wirelessDeviceId).toBe(
382 deployed.device.wirelessDeviceId,
383 );
384 expect(updated.gateway.wirelessGatewayId).toBe(
385 deployed.gateway.wirelessGatewayId,
386 );
387 const updatedDevice = yield* iotw.getWirelessDevice({
388 Identifier: updated.device.wirelessDeviceId,
389 IdentifierType: "WirelessDeviceId",
390 });
391 expect(updatedDevice.Description).toBe("alchemy test sensor v2");
392 const updatedGateway = yield* iotw.getWirelessGateway({
393 Identifier: updated.gateway.wirelessGatewayId,
394 IdentifierType: "WirelessGatewayId",
395 });
396 expect(updatedGateway.LoRaWAN?.MaxEirp).toBe(27);
397 const deviceTags = yield* iotw.listTagsForResource({
398 ResourceArn: updated.device.wirelessDeviceArn,
399 });
400 expect(
401 deviceTags.Tags?.some((t) => t.Key === "env" && t.Value === "test"),
402 ).toBe(true);
403
404 // Destroy — device and gateway gone, typed.
405 yield* stack.destroy();
406 const deviceError = yield* Effect.flip(
407 iotw.getWirelessDevice({
408 Identifier: updated.device.wirelessDeviceId,
409 IdentifierType: "WirelessDeviceId",
410 }),
411 );
412 expect(deviceError._tag).toBe("ResourceNotFoundException");
413 const gatewayError = yield* Effect.flip(
414 iotw.getWirelessGateway({
415 Identifier: updated.gateway.wirelessGatewayId,
416 IdentifierType: "WirelessGatewayId",
417 }),
418 );
419 expect(gatewayError._tag).toBe("ResourceNotFoundException");
420 }),
421 {
422 tags: [
423 "provider:aws",
424 "provider:aws:iam",
425 "provider:aws:iotwireless",
426 "live",
427 ],
428 timeout: 240_000,
429 },
430);