1import * as AWS from "@/AWS";
2import { HostedZone, QueryLoggingConfig } from "@/AWS/Route53";
3import * as Test from "@/Test/Alchemy";
4import { Region as AwsRegion } from "@distilled.cloud/aws/Region";
5import * as logs from "@distilled.cloud/aws/cloudwatch-logs";
6import * as route53 from "@distilled.cloud/aws/route-53";
7import * as sts from "@distilled.cloud/aws/sts";
8import { expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import * as Schedule from "effect/Schedule";
11
12const { test } = Test.make({ providers: AWS.providers() });
13
14// Route 53 only delivers query logs to us-east-1 log groups, and the
15// CloudWatch Logs resource policy that authorizes it must live there too.
16const inUsEast1 = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
17 effect.pipe(Effect.provideService(AwsRegion, Effect.succeed("us-east-1")));
18
19// Deterministic, test-owned names. The account resource-policy quota is 10
20// per region and cannot be raised — this suite is quota-aware by owning a
21// single dedicated policy (net-zero after cleanup) instead of touching any
22// shared policy.
23const POLICY_NAME = "alchemy-test-route53-query-logging";
24const LOG_GROUP_A = "/aws/route53/alchemy-test-qlc-a";
25const LOG_GROUP_B = "/aws/route53/alchemy-test-qlc-b";
26
27const ensureLogGroup = (logGroupName: string) =>
28 inUsEast1(
29 logs
30 .createLogGroup({ logGroupName })
31 .pipe(
32 Effect.catchTag("ResourceAlreadyExistsException", () => Effect.void),
33 ),
34 );
35
36const removeLogGroup = (logGroupName: string) =>
37 inUsEast1(
38 logs
39 .deleteLogGroup({ logGroupName })
40 .pipe(Effect.catchTag("ResourceNotFoundException", () => Effect.void)),
41 );
42
43const removePolicy = inUsEast1(
44 logs
45 .deleteResourcePolicy({ policyName: POLICY_NAME })
46 .pipe(Effect.catchTag("ResourceNotFoundException", () => Effect.void)),
47);
48
49const assertConfigGone = (id: string) =>
50 route53.getQueryLoggingConfig({ Id: id }).pipe(
51 Effect.flatMap(() =>
52 Effect.fail(new Error("query logging config still exists")),
53 ),
54 Effect.catchTag("NoSuchQueryLoggingConfig", () => Effect.void),
55 Effect.retry({
56 while: (e) => e instanceof Error,
57 schedule: Schedule.max([
58 Schedule.fixed("2 seconds"),
59 Schedule.recurs(10),
60 ]),
61 }),
62 );
63
64test.provider(
65 "create, replace on log-group change, and delete query logging config",
66 (stack) =>
67 Effect.gen(function* () {
68 yield* stack.destroy();
69
70 const { Account: accountId } = yield* sts.getCallerIdentity({});
71 const logGroupArnA = `arn:aws:logs:us-east-1:${accountId}:log-group:${LOG_GROUP_A}`;
72 const logGroupArnB = `arn:aws:logs:us-east-1:${accountId}:log-group:${LOG_GROUP_B}`;
73
74 const cleanup = Effect.gen(function* () {
75 yield* stack.destroy();
76 yield* removeLogGroup(LOG_GROUP_A);
77 yield* removeLogGroup(LOG_GROUP_B);
78 yield* removePolicy;
79 });
80
81 // Everything from the first out-of-band create onwards runs under
82 // `Effect.ensuring(cleanup)` so a failure at ANY point (including while
83 // provisioning the prerequisites themselves) still tears down the log
84 // groups + resource policy. All ops are idempotent (AlreadyExists /
85 // NotFound caught), so re-runs reclaim any leftovers from a crash.
86 const replacedId = yield* Effect.gen(function* () {
87 // Out-of-band us-east-1 prerequisites: two log groups and the resource
88 // policy that lets Route 53 write to them. (The Logs LogGroup /
89 // ResourcePolicy resources follow the ambient region, so the test
90 // provisions these via distilled directly.)
91 yield* ensureLogGroup(LOG_GROUP_A);
92 yield* ensureLogGroup(LOG_GROUP_B);
93 yield* inUsEast1(
94 logs.putResourcePolicy({
95 policyName: POLICY_NAME,
96 policyDocument: JSON.stringify({
97 Version: "2012-10-17",
98 Statement: [
99 {
100 Effect: "Allow",
101 Principal: { Service: "route53.amazonaws.com" },
102 Action: ["logs:CreateLogStream", "logs:PutLogEvents"],
103 Resource: `arn:aws:logs:us-east-1:${accountId}:log-group:/aws/route53/alchemy-test-qlc-*`,
104 },
105 ],
106 }),
107 }),
108 );
109
110 // Create.
111 const created = yield* stack.deploy(
112 Effect.gen(function* () {
113 const zone = yield* HostedZone("QueryLogZone", {
114 name: "alchemy-test-qlc.alchemy.",
115 forceDestroy: true,
116 });
117 return yield* QueryLoggingConfig("QueryLogging", {
118 hostedZoneId: zone.id,
119 cloudWatchLogsLogGroupArn: logGroupArnA,
120 });
121 }),
122 );
123
124 expect(created.id).toBeDefined();
125 expect(created.cloudWatchLogsLogGroupArn).toBe(logGroupArnA);
126
127 // Out-of-band verification.
128 const observed = yield* route53.getQueryLoggingConfig({
129 Id: created.id,
130 });
131 expect(observed.QueryLoggingConfig.HostedZoneId).toBe(
132 created.hostedZoneId,
133 );
134 expect(observed.QueryLoggingConfig.CloudWatchLogsLogGroupArn).toBe(
135 logGroupArnA,
136 );
137
138 // Changing the log group replaces the configuration (no update API).
139 const replaced = yield* stack.deploy(
140 Effect.gen(function* () {
141 const zone = yield* HostedZone("QueryLogZone", {
142 name: "alchemy-test-qlc.alchemy.",
143 forceDestroy: true,
144 });
145 return yield* QueryLoggingConfig("QueryLogging", {
146 hostedZoneId: zone.id,
147 cloudWatchLogsLogGroupArn: logGroupArnB,
148 });
149 }),
150 );
151
152 expect(replaced.id).not.toBe(created.id);
153 expect(replaced.cloudWatchLogsLogGroupArn).toBe(logGroupArnB);
154 expect(replaced.hostedZoneId).toBe(created.hostedZoneId);
155
156 // The old configuration must be gone.
157 yield* assertConfigGone(created.id);
158
159 return replaced.id;
160 }).pipe(Effect.ensuring(cleanup.pipe(Effect.ignore)));
161
162 // `cleanup` owns the successful destroy as well as failure cleanup. Do
163 // not destroy twice on the success path: under a saturated account the
164 // redundant refresh can consume the test's remaining timeout budget.
165 yield* assertConfigGone(replacedId);
166 }),
167 { tags: ["provider:aws", "provider:aws:route53", "live"], timeout: 120_000 },
168);