1import * as AWS from "@/AWS";
2import * as Location from "@/AWS/Location";
3import * as Test from "@/Test/Alchemy";
4import * as location from "@distilled.cloud/aws/location";
5import { describe, expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Redacted from "effect/Redacted";
8import * as Result from "effect/Result";
9import * as Schedule from "effect/Schedule";
10
11const { test } = Test.make({ providers: AWS.providers() });
12
13// Bounded wait-until-gone: a Describe on a deleted Location resource returns a
14// typed ResourceNotFoundException. Poll a few times so the assertion tolerates
15// the brief eventual-consistency window after Delete.
16const assertGone = <R>(probe: Effect.Effect<unknown, { _tag: string }, R>) =>
17 probe.pipe(
18 Effect.flatMap(() => Effect.fail({ _tag: "StillExists" as const })),
19 Effect.retry({
20 while: (e: { _tag: string }) => e._tag === "StillExists",
21 schedule: Schedule.max([
22 Schedule.spaced("2 seconds"),
23 Schedule.recurs(10),
24 ]),
25 }),
26 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
27 );
28
29describe.skipIf(!!process.env.FAST)(
30 "AWS.Location",
31 { tags: ["provider:aws", "provider:aws:location", "live"] },
32 () => {
33 test.provider(
34 "Map: create, update description, delete",
35 (stack) =>
36 Effect.gen(function* () {
37 yield* stack.destroy();
38 const map = yield* stack.deploy(
39 Effect.gen(function* () {
40 return yield* Location.Map("TestMap", {
41 configuration: { style: "VectorEsriNavigation" },
42 tags: { Environment: "test" },
43 });
44 }),
45 );
46
47 expect(map.mapName).toBeDefined();
48 expect(map.mapArn).toContain(":map/");
49 expect(map.style).toEqual("VectorEsriNavigation");
50
51 const described = yield* location.describeMap({
52 MapName: map.mapName,
53 });
54 expect(described.Configuration.Style).toEqual("VectorEsriNavigation");
55 expect(described.Tags?.Environment).toEqual("test");
56 expect(described.Tags?.["alchemy::id"]).toEqual("TestMap");
57
58 // Update the description in place (no replacement).
59 const updated = yield* stack.deploy(
60 Effect.gen(function* () {
61 return yield* Location.Map("TestMap", {
62 configuration: { style: "VectorEsriNavigation" },
63 description: "updated map",
64 tags: { Environment: "prod" },
65 });
66 }),
67 );
68 expect(updated.mapName).toEqual(map.mapName);
69 expect(updated.description).toEqual("updated map");
70 expect(updated.tags.Environment).toEqual("prod");
71
72 yield* stack.destroy();
73 yield* assertGone(location.describeMap({ MapName: map.mapName }));
74 }),
75 { timeout: 180_000 },
76 );
77
78 test.provider(
79 "PlaceIndex: create, update, delete",
80 (stack) =>
81 Effect.gen(function* () {
82 yield* stack.destroy();
83 const index = yield* stack.deploy(
84 Effect.gen(function* () {
85 return yield* Location.PlaceIndex("TestIndex", {
86 dataSource: "Esri",
87 });
88 }),
89 );
90
91 expect(index.indexArn).toContain(":place-index/");
92 expect(index.dataSource).toEqual("Esri");
93
94 const described = yield* location.describePlaceIndex({
95 IndexName: index.indexName,
96 });
97 expect(described.DataSource).toEqual("Esri");
98 expect(described.Tags?.["alchemy::id"]).toEqual("TestIndex");
99
100 const updated = yield* stack.deploy(
101 Effect.gen(function* () {
102 return yield* Location.PlaceIndex("TestIndex", {
103 dataSource: "Esri",
104 description: "updated index",
105 });
106 }),
107 );
108 expect(updated.indexName).toEqual(index.indexName);
109 expect(updated.description).toEqual("updated index");
110
111 yield* stack.destroy();
112 yield* assertGone(
113 location.describePlaceIndex({ IndexName: index.indexName }),
114 );
115 }),
116 { timeout: 180_000 },
117 );
118
119 test.provider(
120 "RouteCalculator: create and delete",
121 (stack) =>
122 Effect.gen(function* () {
123 yield* stack.destroy();
124 const calc = yield* stack.deploy(
125 Effect.gen(function* () {
126 return yield* Location.RouteCalculator("TestCalc", {
127 dataSource: "Esri",
128 });
129 }),
130 );
131
132 expect(calc.calculatorArn).toContain(":route-calculator/");
133 expect(calc.dataSource).toEqual("Esri");
134
135 const described = yield* location.describeRouteCalculator({
136 CalculatorName: calc.calculatorName,
137 });
138 expect(described.DataSource).toEqual("Esri");
139 expect(described.Tags?.["alchemy::id"]).toEqual("TestCalc");
140
141 yield* stack.destroy();
142 yield* assertGone(
143 location.describeRouteCalculator({
144 CalculatorName: calc.calculatorName,
145 }),
146 );
147 }),
148 { timeout: 180_000 },
149 );
150
151 test.provider(
152 "GeofenceCollection: create, update, delete",
153 (stack) =>
154 Effect.gen(function* () {
155 yield* stack.destroy();
156 const collection = yield* stack.deploy(
157 Effect.gen(function* () {
158 return yield* Location.GeofenceCollection("TestFences", {});
159 }),
160 );
161
162 expect(collection.collectionArn).toContain(":geofence-collection/");
163
164 const described = yield* location.describeGeofenceCollection({
165 CollectionName: collection.collectionName,
166 });
167 expect(described.Tags?.["alchemy::id"]).toEqual("TestFences");
168
169 const updated = yield* stack.deploy(
170 Effect.gen(function* () {
171 return yield* Location.GeofenceCollection("TestFences", {
172 description: "updated fences",
173 });
174 }),
175 );
176 expect(updated.collectionName).toEqual(collection.collectionName);
177 expect(updated.description).toEqual("updated fences");
178
179 yield* stack.destroy();
180 yield* assertGone(
181 location.describeGeofenceCollection({
182 CollectionName: collection.collectionName,
183 }),
184 );
185 }),
186 { timeout: 180_000 },
187 );
188
189 // Ungated probe: this account cannot create Location V1 API keys — even
190 // AdministratorAccess gets an AccessDeniedException with an EMPTY action
191 // name ("is not authorized to perform: because no resource-based policy
192 // allows the action") from `geo:CreateKey` in every region. That is a
193 // service-side gate (Location classic is closed to newer accounts), not an
194 // IAM misconfiguration. The probe pins the typed tag forever at near-zero
195 // cost; an entitled account instead exercises cleanup of the probe key.
196 test.provider(
197 "ApiKey: CreateKey is service-gated (typed AccessDeniedException) or succeeds",
198 (_stack) =>
199 Effect.gen(function* () {
200 const created = yield* Effect.result(
201 location.createKey({
202 KeyName: "alchemy-apikey-probe",
203 NoExpiry: true,
204 Restrictions: {
205 AllowActions: ["geo:GetMap*"],
206 AllowResources: ["arn:aws:geo:*:*:map/*"],
207 },
208 }),
209 );
210 if (Result.isSuccess(created)) {
211 // Entitled account: clean up the probe key immediately.
212 yield* location.deleteKey({
213 KeyName: "alchemy-apikey-probe",
214 ForceDelete: true,
215 });
216 } else {
217 expect(created.failure._tag).toBe("AccessDeniedException");
218 }
219 }),
220 { timeout: 60_000 },
221 );
222
223 test.provider.skipIf(!process.env.AWS_TEST_LOCATION_API_KEYS)(
224 "ApiKey: create, update description, delete",
225 (stack) =>
226 Effect.gen(function* () {
227 yield* stack.destroy();
228 const key = yield* stack.deploy(
229 Effect.gen(function* () {
230 return yield* Location.ApiKey("TestKey", {
231 restrictions: {
232 allowActions: ["geo:GetMap*"],
233 allowResources: ["arn:aws:geo:*:*:map/*"],
234 },
235 });
236 }),
237 );
238
239 expect(key.keyArn).toContain(":api-key/");
240 expect(Redacted.value(key.key)).toMatch(/^v1\.public\./);
241 expect(key.restrictions.allowActions).toEqual(["geo:GetMap*"]);
242
243 const described = yield* location.describeKey({
244 KeyName: key.keyName,
245 });
246 expect(described.Tags?.["alchemy::id"]).toEqual("TestKey");
247
248 const updated = yield* stack.deploy(
249 Effect.gen(function* () {
250 return yield* Location.ApiKey("TestKey", {
251 restrictions: {
252 allowActions: ["geo:GetMap*"],
253 allowResources: ["arn:aws:geo:*:*:map/*"],
254 allowReferers: ["https://example.com/*"],
255 },
256 description: "updated key",
257 });
258 }),
259 );
260 expect(updated.keyName).toEqual(key.keyName);
261 expect(updated.description).toEqual("updated key");
262 expect(updated.restrictions.allowReferers).toEqual([
263 "https://example.com/*",
264 ]);
265 expect(Redacted.value(updated.key)).toEqual(Redacted.value(key.key));
266
267 yield* stack.destroy();
268 yield* assertGone(location.describeKey({ KeyName: key.keyName }));
269 }),
270 { timeout: 180_000 },
271 );
272
273 test.provider(
274 "TrackerConsumer: link tracker to geofence collection, delete",
275 (stack) =>
276 Effect.gen(function* () {
277 yield* stack.destroy();
278 const out = yield* stack.deploy(
279 Effect.gen(function* () {
280 const tracker = yield* Location.Tracker("ConsumerTracker", {});
281 const collection = yield* Location.GeofenceCollection(
282 "ConsumerFences",
283 {},
284 );
285 const link = yield* Location.TrackerConsumer("Link", {
286 trackerName: tracker.trackerName,
287 consumerArn: collection.collectionArn,
288 });
289 return {
290 trackerName: tracker.trackerName,
291 collectionArn: collection.collectionArn,
292 linkTracker: link.trackerName,
293 linkConsumer: link.consumerArn,
294 };
295 }),
296 );
297
298 expect(out.linkTracker).toEqual(out.trackerName);
299 expect(out.linkConsumer).toEqual(out.collectionArn);
300
301 const consumers = yield* location.listTrackerConsumers({
302 TrackerName: out.trackerName,
303 });
304 expect(consumers.ConsumerArns).toContain(out.collectionArn);
305
306 yield* stack.destroy();
307 yield* assertGone(
308 location.describeTracker({ TrackerName: out.trackerName }),
309 );
310 }),
311 { timeout: 180_000 },
312 );
313
314 test.provider(
315 "Tracker: create, update filtering, delete",
316 (stack) =>
317 Effect.gen(function* () {
318 yield* stack.destroy();
319 const tracker = yield* stack.deploy(
320 Effect.gen(function* () {
321 return yield* Location.Tracker("TestTracker", {});
322 }),
323 );
324
325 expect(tracker.trackerArn).toContain(":tracker/");
326 expect(tracker.positionFiltering).toEqual("TimeBased");
327
328 const described = yield* location.describeTracker({
329 TrackerName: tracker.trackerName,
330 });
331 expect(described.Tags?.["alchemy::id"]).toEqual("TestTracker");
332
333 const updated = yield* stack.deploy(
334 Effect.gen(function* () {
335 return yield* Location.Tracker("TestTracker", {
336 positionFiltering: "DistanceBased",
337 description: "fleet tracker",
338 });
339 }),
340 );
341 expect(updated.trackerName).toEqual(tracker.trackerName);
342 expect(updated.positionFiltering).toEqual("DistanceBased");
343 expect(updated.description).toEqual("fleet tracker");
344
345 yield* stack.destroy();
346 yield* assertGone(
347 location.describeTracker({ TrackerName: tracker.trackerName }),
348 );
349 }),
350 { timeout: 180_000 },
351 );
352 },
353);