1import * as AWS from "@/AWS";
2import {
3 ResolverEndpoint,
4 ResolverRule,
5 ResolverRuleAssociation,
6} from "@/AWS/Route53Resolver";
7import * as Test from "@/Test/Alchemy";
8import * as r53r from "@distilled.cloud/aws/route53resolver";
9import { expect } from "alchemy-test";
10import * as Effect from "effect/Effect";
11import * as Schedule from "effect/Schedule";
12import {
13 assertEndpointDeleting,
14 assertRuleGone,
15 defaultNetwork,
16} from "./helpers.ts";
17
18const { test } = Test.make({ providers: AWS.providers() });
19
20// Ungated typed-error probe: association APIs surface the typed
21// ResourceNotFoundException tag for a nonexistent rule (against a real VPC
22// — AWS validates the VPC id first with InvalidParameterException).
23test.provider(
24 "associating a nonexistent rule fails with a typed tag",
25 () =>
26 Effect.gen(function* () {
27 const net = yield* defaultNetwork;
28 const result = yield* r53r
29 .associateResolverRule({
30 ResolverRuleId: "rslvr-rr-00000000000000000",
31 VPCId: net.vpcId,
32 })
33 .pipe(
34 Effect.map(() => "created" as const),
35 Effect.catchTag("ResourceNotFoundException", () =>
36 Effect.succeed("not-found" as const),
37 ),
38 );
39 expect(result).toBe("not-found");
40 }),
41 {
42 tags: [
43 "provider:aws",
44 "provider:aws:ec2",
45 "provider:aws:route53resolver",
46 "live",
47 ],
48 },
49);
50
51// The full lifecycle is gated: a VPC rule association takes ~1-2 minutes to
52// reach COMPLETE and another ~1-2 minutes to drain on disassociation, which
53// does not fit the ungated 240s budget.
54test.provider.skipIf(!process.env.AWS_TEST_SLOW)(
55 "associate a forward rule with the default VPC and tear it down",
56 (stack) =>
57 Effect.gen(function* () {
58 yield* stack.destroy();
59
60 const net = yield* defaultNetwork;
61 const deployed = yield* stack.deploy(
62 Effect.gen(function* () {
63 const endpoint = yield* ResolverEndpoint("Outbound", {
64 direction: "OUTBOUND",
65 securityGroupIds: [net.securityGroupId],
66 ipAddresses: net.subnetIds.map((subnetId) => ({ subnetId })),
67 });
68 const rule = yield* ResolverRule("Forward", {
69 domainName: "assoc.alchemy-r53r-test.internal",
70 resolverEndpointId: endpoint.resolverEndpointId,
71 targetIps: [{ ip: "192.168.20.10" }],
72 });
73 const association = yield* ResolverRuleAssociation("Assoc", {
74 resolverRuleId: rule.resolverRuleId,
75 vpcId: net.vpcId,
76 });
77 return { endpoint, rule, association };
78 }),
79 );
80
81 expect(deployed.association.resolverRuleAssociationId).toContain(
82 "rslvr-rrassoc-",
83 );
84 expect(deployed.association.resolverRuleId).toBe(
85 deployed.rule.resolverRuleId,
86 );
87 expect(deployed.association.vpcId).toBe(net.vpcId);
88
89 // Out-of-band: the association exists and converges to COMPLETE
90 // (VPC associations routinely take 1-2 minutes).
91 const status = yield* r53r
92 .getResolverRuleAssociation({
93 ResolverRuleAssociationId:
94 deployed.association.resolverRuleAssociationId,
95 })
96 .pipe(
97 Effect.map((r) => r.ResolverRuleAssociation?.Status),
98 Effect.repeat({
99 schedule: Schedule.fixed("5 seconds"),
100 until: (s) => s === "COMPLETE" || s === "FAILED",
101 times: 36,
102 }),
103 );
104 expect(status).toBe("COMPLETE");
105
106 // Idempotent redeploy — same association (no replacement).
107 const again = yield* stack.deploy(
108 Effect.gen(function* () {
109 const endpoint = yield* ResolverEndpoint("Outbound", {
110 direction: "OUTBOUND",
111 securityGroupIds: [net.securityGroupId],
112 ipAddresses: net.subnetIds.map((subnetId) => ({ subnetId })),
113 });
114 const rule = yield* ResolverRule("Forward", {
115 domainName: "assoc.alchemy-r53r-test.internal",
116 resolverEndpointId: endpoint.resolverEndpointId,
117 targetIps: [{ ip: "192.168.20.10" }],
118 });
119 const association = yield* ResolverRuleAssociation("Assoc", {
120 resolverRuleId: rule.resolverRuleId,
121 vpcId: net.vpcId,
122 });
123 return { association };
124 }),
125 );
126 expect(again.association.resolverRuleAssociationId).toBe(
127 deployed.association.resolverRuleAssociationId,
128 );
129
130 // Destroy tears down association → rule → endpoint in order; the
131 // association's delete waits for the drain so the rule delete
132 // converges.
133 yield* stack.destroy();
134 const gone = yield* r53r
135 .getResolverRuleAssociation({
136 ResolverRuleAssociationId:
137 deployed.association.resolverRuleAssociationId,
138 })
139 .pipe(
140 Effect.map(() => false),
141 Effect.catchTag("ResourceNotFoundException", () =>
142 Effect.succeed(true),
143 ),
144 );
145 expect(gone).toBe(true);
146 yield* assertRuleGone(deployed.rule.resolverRuleId);
147 yield* assertEndpointDeleting(deployed.endpoint.resolverEndpointId);
148 }),
149 {
150 tags: [
151 "provider:aws",
152 "provider:aws:ec2",
153 "provider:aws:route53resolver",
154 "live",
155 ],
156 timeout: 480_000,
157 },
158);