1import * as AWS from "@/AWS";
2import { ResolverEndpoint, ResolverRule } from "@/AWS/Route53Resolver";
3import * as Test from "@/Test/Alchemy";
4import * as r53r from "@distilled.cloud/aws/route53resolver";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import {
8 assertEndpointDeleting,
9 assertRuleGone,
10 defaultNetwork,
11} from "./helpers.ts";
12
13const { test } = Test.make({ providers: AWS.providers() });
14
15test.provider(
16 "forward rule lifecycle: create, update target IPs, replace on domain change",
17 (stack) =>
18 Effect.gen(function* () {
19 yield* stack.destroy();
20
21 const net = yield* defaultNetwork;
22 const make = (targetIp: string, domainName: string) =>
23 Effect.gen(function* () {
24 const endpoint = yield* ResolverEndpoint("Outbound", {
25 direction: "OUTBOUND",
26 securityGroupIds: [net.securityGroupId],
27 ipAddresses: net.subnetIds.map((subnetId) => ({ subnetId })),
28 });
29 const rule = yield* ResolverRule("Forward", {
30 domainName,
31 resolverEndpointId: endpoint.resolverEndpointId,
32 targetIps: [{ ip: targetIp }],
33 tags: { fixture: "r53r-rule" },
34 });
35 return { endpoint, rule };
36 });
37
38 const deployed = yield* stack.deploy(
39 make("192.168.10.10", "corp.alchemy-r53r-test.internal"),
40 );
41
42 expect(deployed.endpoint.resolverEndpointId).toMatch(/^rslvr-out-/);
43 expect(deployed.rule.resolverRuleId).toMatch(/^rslvr-rr-/);
44 expect(deployed.rule.resolverRuleArn).toContain(":resolver-rule/");
45 expect(deployed.rule.domainName.replace(/\.$/, "")).toBe(
46 "corp.alchemy-r53r-test.internal",
47 );
48
49 // Out-of-band: the rule forwards to the target through the endpoint.
50 const rule = yield* r53r.getResolverRule({
51 ResolverRuleId: deployed.rule.resolverRuleId,
52 });
53 expect(rule.ResolverRule?.RuleType).toBe("FORWARD");
54 expect(rule.ResolverRule?.ResolverEndpointId).toBe(
55 deployed.endpoint.resolverEndpointId,
56 );
57 expect(rule.ResolverRule?.TargetIps?.map((t) => t.Ip)).toEqual([
58 "192.168.10.10",
59 ]);
60 const tags = yield* r53r.listTagsForResource({
61 ResourceArn: deployed.rule.resolverRuleArn,
62 });
63 const tagRecord = Object.fromEntries(
64 (tags.Tags ?? []).map((t) => [t.Key, t.Value]),
65 );
66 expect(tagRecord.fixture).toBe("r53r-rule");
67 expect(tagRecord["alchemy::id"]).toBe("Forward");
68
69 // In-place update: change the target IP — same rule (no replacement).
70 const updated = yield* stack.deploy(
71 make("192.168.10.11", "corp.alchemy-r53r-test.internal"),
72 );
73 expect(updated.rule.resolverRuleId).toBe(deployed.rule.resolverRuleId);
74 const afterUpdate = yield* r53r.getResolverRule({
75 ResolverRuleId: deployed.rule.resolverRuleId,
76 });
77 expect(afterUpdate.ResolverRule?.TargetIps?.map((t) => t.Ip)).toEqual([
78 "192.168.10.11",
79 ]);
80
81 // Replacement: change the domain name — new rule, old rule deleted.
82 const replaced = yield* stack.deploy(
83 make("192.168.10.11", "corp2.alchemy-r53r-test.internal"),
84 );
85 expect(replaced.rule.resolverRuleId).not.toBe(
86 deployed.rule.resolverRuleId,
87 );
88 expect(replaced.rule.domainName.replace(/\.$/, "")).toBe(
89 "corp2.alchemy-r53r-test.internal",
90 );
91 yield* assertRuleGone(deployed.rule.resolverRuleId);
92
93 yield* stack.destroy();
94 yield* assertRuleGone(replaced.rule.resolverRuleId);
95 yield* assertEndpointDeleting(replaced.endpoint.resolverEndpointId);
96 }),
97 {
98 tags: [
99 "provider:aws",
100 "provider:aws:ec2",
101 "provider:aws:route53resolver",
102 "live",
103 ],
104 timeout: 220_000,
105 },
106);