1import * as AWS from "@/AWS";
2import { Profile, ProfileResourceAssociation } from "@/AWS/Route53Profiles";
3import * as Test from "@/Test/Alchemy";
4import * as profiles from "@distilled.cloud/aws/route53profiles";
5import * as resolver from "@distilled.cloud/aws/route53resolver";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9
10const { test } = Test.make({ providers: AWS.providers() });
11
12const FRG_NAME = "alchemy-test-r53p-frg";
13
14// The DNS Firewall rule group under test is created out-of-band via
15// distilled (free, instant, and not the resource under test). Creation is
16// idempotent via a deterministic CreatorRequestId; a leaked group from a
17// prior crashed run is reused.
18const ensureFirewallRuleGroup = Effect.gen(function* () {
19 const existing = yield* resolver.listFirewallRuleGroups({});
20 const found = existing.FirewallRuleGroups?.find((g) => g.Name === FRG_NAME);
21 if (found?.Arn !== undefined && found.Id !== undefined) {
22 return { arn: found.Arn, id: found.Id };
23 }
24 const created = yield* resolver.createFirewallRuleGroup({
25 CreatorRequestId: FRG_NAME,
26 Name: FRG_NAME,
27 });
28 return {
29 arn: created.FirewallRuleGroup!.Arn!,
30 id: created.FirewallRuleGroup!.Id!,
31 };
32});
33
34// Deleting the rule group races the association's asynchronous drain.
35const deleteFirewallRuleGroup = (id: string) =>
36 resolver.deleteFirewallRuleGroup({ FirewallRuleGroupId: id }).pipe(
37 Effect.retry({
38 while: (e) => e._tag === "ConflictException",
39 schedule: Schedule.max([
40 Schedule.fixed("4 seconds"),
41 Schedule.recurs(15),
42 ]),
43 }),
44 Effect.asVoid,
45 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
46 );
47
48const assertAssociationGone = (profileResourceAssociationId: string) =>
49 profiles
50 .getProfileResourceAssociation({
51 ProfileResourceAssociationId: profileResourceAssociationId,
52 })
53 .pipe(
54 Effect.flatMap((r) =>
55 r.ProfileResourceAssociation?.Status === "DELETING" ||
56 r.ProfileResourceAssociation?.Status === "DELETED"
57 ? Effect.void
58 : Effect.fail(
59 new Error(
60 `association still ${r.ProfileResourceAssociation?.Status}`,
61 ),
62 ),
63 ),
64 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
65 Effect.retry({
66 while: (e) => e instanceof Error,
67 schedule: Schedule.max([
68 Schedule.fixed("3 seconds"),
69 Schedule.recurs(10),
70 ]),
71 }),
72 );
73
74test.provider(
75 "attach a DNS Firewall rule group, update its priority, destroy",
76 (stack) =>
77 Effect.gen(function* () {
78 yield* stack.destroy();
79 const ruleGroup = yield* ensureFirewallRuleGroup;
80
81 const deploy = (priority: number) =>
82 stack.deploy(
83 Effect.gen(function* () {
84 const profile = yield* Profile("FirewallProfile", {});
85 const attachment = yield* ProfileResourceAssociation(
86 "FirewallRules",
87 {
88 profileId: profile.profileId,
89 resourceArn: ruleGroup.arn,
90 resourceProperties: JSON.stringify({ priority }),
91 },
92 );
93 return { profile, attachment };
94 }),
95 );
96
97 yield* Effect.gen(function* () {
98 const first = yield* deploy(102);
99 expect(first.attachment.profileResourceAssociationId).toMatch(/^rpr-/);
100 expect(first.attachment.resourceArn).toBe(ruleGroup.arn);
101 expect(first.attachment.resourceType).toBe("FIREWALL_RULE_GROUP");
102 expect(JSON.parse(first.attachment.resourceProperties ?? "{}")).toEqual(
103 { priority: 102 },
104 );
105
106 // Out-of-band: association exists with the requested priority.
107 const live = yield* profiles.getProfileResourceAssociation({
108 ProfileResourceAssociationId:
109 first.attachment.profileResourceAssociationId,
110 });
111 expect(
112 JSON.parse(
113 live.ProfileResourceAssociation?.ResourceProperties ?? "{}",
114 ),
115 ).toEqual({ priority: 102 });
116
117 // Priority updates in place — same association id.
118 const second = yield* deploy(103);
119 expect(second.attachment.profileResourceAssociationId).toBe(
120 first.attachment.profileResourceAssociationId,
121 );
122 const updated = yield* profiles.getProfileResourceAssociation({
123 ProfileResourceAssociationId:
124 second.attachment.profileResourceAssociationId,
125 });
126 expect(
127 JSON.parse(
128 updated.ProfileResourceAssociation?.ResourceProperties ?? "{}",
129 ),
130 ).toEqual({ priority: 103 });
131
132 yield* stack.destroy();
133 yield* assertAssociationGone(
134 first.attachment.profileResourceAssociationId,
135 );
136 }).pipe(
137 // Always clean up the out-of-band rule group, even on failure.
138 Effect.ensuring(
139 deleteFirewallRuleGroup(ruleGroup.id).pipe(Effect.ignore),
140 ),
141 );
142 }),
143 {
144 tags: [
145 "provider:aws",
146 "provider:aws:route53profiles",
147 "provider:aws:route53resolver",
148 "live",
149 ],
150 timeout: 360_000,
151 },
152);