1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import { findZoneByName } from "@/Cloudflare/Zone/lookup";
4import * as Provider from "@/Provider";
5import * as Test from "@/Test/Alchemy";
6import * as dns from "@distilled.cloud/cloudflare/dns";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
10import * as Schedule from "effect/Schedule";
11
12const { test } = Test.make({ providers: Cloudflare.providers() });
13
14const logLevel = Effect.provideService(
15 MinimumLogLevel,
16 process.env.DEBUG ? "Debug" : "Info",
17);
18
19const zoneName =
20 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
21
22// Incoming zone transfers require a SECONDARY zone, and secondary zone
23// signup is not allowed on the testing account (`POST /zones` with
24// `type: "secondary"` fails with "Secondary zone signup not allowed").
25// On a regular (full) zone the incoming POST superficially succeeds but
26// the configuration does not persist — the follow-up GET reports the
27// typed `IncomingZoneTransferNotFound`. The full lifecycle test below
28// is gated behind a real secondary zone supplied via env.
29const secondaryZoneId = process.env.CLOUDFLARE_TEST_SECONDARY_ZONE_ID;
30
31const resolveZoneId = Effect.gen(function* () {
32 const { accountId } = yield* yield* CloudflareEnvironment;
33 const zone = yield* findZoneByName({ accountId, name: zoneName });
34 if (!zone) {
35 return yield* Effect.die(
36 new Error(`zone "${zoneName}" not found in account`),
37 );
38 }
39 return zone.id;
40});
41
42const retryForbidden = <A, E extends { _tag: string }, R>(
43 eff: Effect.Effect<A, E, R>,
44) =>
45 eff.pipe(
46 Effect.retry({
47 while: (e) => e._tag === "Forbidden",
48 schedule: Schedule.exponential("500 millis"),
49 times: 8,
50 }),
51 );
52
53// Canonical `list()` test (zone-scoped singleton): there is no
54// account-wide API for this per-zone config, so `list()` enumerates every
55// zone via `listAllZones` and reads the incoming transfer config in each,
56// skipping zones that have none (`IncomingZoneTransferNotFound`). The
57// read-only assertion always runs and proves the result is a well-typed
58// `Attributes[]`; if a real secondary zone is supplied, assert it appears.
59test.provider(
60 "list enumerates incoming configs across all zones",
61 (stack) =>
62 Effect.gen(function* () {
63 const provider = yield* Provider.findProvider(
64 Cloudflare.DNS.ZoneTransferIncoming,
65 );
66 const all = yield* provider.list();
67
68 expect(Array.isArray(all)).toBe(true);
69 for (const row of all) {
70 expect(typeof row.zoneId).toBe("string");
71 expect(Array.isArray(row.peers)).toBe(true);
72 }
73 if (secondaryZoneId) {
74 expect(all.some((c) => c.zoneId === secondaryZoneId)).toBe(true);
75 }
76
77 // `stack` is unused (list is a read-only account enumeration); keep the
78 // destroy bookend so the harness state stays clean.
79 yield* stack.destroy();
80 }).pipe(logLevel),
81 {
82 tags: ["provider:cloudflare", "provider:cloudflare:dns", "live"],
83 timeout: 120_000,
84 },
85);
86
87test.provider.skipIf(!!secondaryZoneId)(
88 "incoming config does not persist on non-secondary zones (typed not-found)",
89 (stack) =>
90 Effect.gen(function* () {
91 const zoneId = yield* resolveZoneId;
92
93 yield* stack.destroy();
94
95 // The GET on a zone that was never linked reports the typed tag.
96 const error = yield* retryForbidden(
97 dns.getZoneTransferIncoming({ zoneId }),
98 ).pipe(Effect.flip);
99 expect(error._tag).toEqual("IncomingZoneTransferNotFound");
100 }).pipe(logLevel),
101 {
102 tags: [
103 "provider:cloudflare",
104 "provider:cloudflare:dns",
105 "provider:cloudflare:zone",
106 "live",
107 ],
108 timeout: 120_000,
109 },
110);
111
112test.provider.skipIf(!secondaryZoneId)(
113 "create, update autoRefreshSeconds in place, and delete the incoming config",
114 (stack) =>
115 Effect.gen(function* () {
116 const zoneId = secondaryZoneId!;
117
118 yield* stack.destroy();
119
120 const { incoming, peer } = yield* stack.deploy(
121 Effect.gen(function* () {
122 const peer = yield* Cloudflare.DNS.ZoneTransferPeer("IncomingPeer", {
123 name: "alchemy-dnszt-incoming-peer",
124 ip: "192.0.2.53",
125 port: 53,
126 });
127 const incoming = yield* Cloudflare.DNS.ZoneTransferIncoming(
128 "Incoming",
129 {
130 zoneId,
131 name: `${zoneName}.`,
132 peers: [peer.peerId],
133 autoRefreshSeconds: 86400,
134 },
135 );
136 return { incoming, peer };
137 }),
138 );
139 expect(incoming.zoneId).toEqual(zoneId);
140 expect(incoming.peers).toEqual([peer.peerId]);
141 expect(incoming.autoRefreshSeconds).toEqual(86400);
142
143 // Update the refresh interval in place. Keep the peer deployed
144 // across the update (engine constraint: never drop a dependency
145 // in the same deploy that changes its dependent).
146 const updated = yield* stack.deploy(
147 Effect.gen(function* () {
148 const peer = yield* Cloudflare.DNS.ZoneTransferPeer("IncomingPeer", {
149 name: "alchemy-dnszt-incoming-peer",
150 ip: "192.0.2.53",
151 port: 53,
152 });
153 const incoming = yield* Cloudflare.DNS.ZoneTransferIncoming(
154 "Incoming",
155 {
156 zoneId,
157 name: `${zoneName}.`,
158 peers: [peer.peerId],
159 autoRefreshSeconds: 43200,
160 },
161 );
162 return incoming;
163 }),
164 );
165 expect(updated.autoRefreshSeconds).toEqual(43200);
166
167 yield* stack.destroy();
168
169 // Once deleted, the GET reports the typed not-found tag.
170 const gone = yield* retryForbidden(
171 dns.getZoneTransferIncoming({ zoneId }),
172 ).pipe(Effect.flip);
173 expect(gone._tag).toEqual("IncomingZoneTransferNotFound");
174
175 // Re-running destroy is idempotent.
176 yield* stack.destroy();
177 }).pipe(logLevel),
178 {
179 tags: ["provider:cloudflare", "provider:cloudflare:dns", "live"],
180 timeout: 300_000,
181 },
182);