1import { Action } from "@/Action";
2import { adopt } from "@/AdoptPolicy";
3import * as Cloudflare from "@/Cloudflare";
4import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
5import { findZoneByName } from "@/Cloudflare/Zone/lookup";
6import * as Test from "@/Test/Alchemy";
7import * as dns from "@distilled.cloud/cloudflare/dns";
8import { expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import { MinimumLogLevel } from "effect/References";
11import * as Stream from "effect/Stream";
12
13const { test } = Test.make({ providers: Cloudflare.providers() });
14
15const logLevel = Effect.provideService(
16 MinimumLogLevel,
17 process.env.DEBUG ? "Debug" : "Info",
18);
19
20const zoneName =
21 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
22
23// Deterministic record name — reused on every run (never derive from
24// Date.now()/random), owns its own subdomain so it never collides with the
25// managed-Record tests.
26const RECORD_NAME = `alchemy-dnslocal.${zoneName}`;
27const RECORD_TYPE = "TXT";
28const RECORD_CONTENT = '"alchemy-dnslocal-seed"';
29
30const resolveZoneId = Effect.gen(function* () {
31 const { accountId } = yield* yield* CloudflareEnvironment;
32 const zone = yield* findZoneByName({ accountId, name: zoneName });
33 if (!zone) {
34 return yield* Effect.die(
35 new Error(`zone "${zoneName}" not found in account`),
36 );
37 }
38 return zone.id;
39});
40
41// Delete every record matching (name, type) — used to clear leftovers from
42// interrupted runs and to guarantee cleanup on finish.
43const purgeRecords = (zoneId: string) =>
44 dns.listRecords
45 .items({
46 zoneId,
47 name: { exact: RECORD_NAME },
48 type: RECORD_TYPE,
49 })
50 .pipe(
51 Stream.filter((r) => r.name === RECORD_NAME && r.type === RECORD_TYPE),
52 Stream.runCollect,
53 Effect.map((chunk) => Array.from(chunk)),
54 Effect.flatMap(
55 Effect.forEach((r) =>
56 dns
57 .deleteRecord({ zoneId, dnsRecordId: r.id })
58 .pipe(Effect.catch(() => Effect.void)),
59 ),
60 ),
61 );
62
63const findRecord = (zoneId: string) =>
64 dns.listRecords
65 .items({ zoneId, name: { exact: RECORD_NAME }, type: RECORD_TYPE })
66 .pipe(
67 Stream.filter((r) => r.name === RECORD_NAME && r.type === RECORD_TYPE),
68 Stream.runCollect,
69 Effect.map((chunk) => Array.from(chunk)[0]),
70 );
71
72// Binding a DNS zone inside an Action via `ReadWriteDnsLocal` — the local
73// (current-credentials) implementation of the `ReadWriteDns` binding. Exercises
74// the write (create), read (get), list, and delete client surface plus the
75// deferred zone-id accessor (`yield* zone.zoneId` resolved at apply time).
76test.provider(
77 "ReadWriteDnsLocal: create, read, list and delete a TXT record from an Action",
78 (stack) =>
79 Effect.gen(function* () {
80 const zoneId = yield* resolveZoneId;
81
82 yield* stack.destroy();
83 // Clear leftovers from interrupted runs (Cloudflare allows duplicate
84 // records for the same name/type).
85 yield* purgeRecords(zoneId);
86
87 const out = yield* stack
88 .deploy(
89 Effect.gen(function* () {
90 // Adopt the standing test zone. Zones default to `retain` on
91 // removal, so `stack.destroy()` never deletes it.
92 const zone = yield* Cloudflare.Zone.Zone("DnsLocalZone", {
93 name: zoneName,
94 }).pipe(adopt(true));
95
96 const Seed = Action(
97 "Seed",
98 Effect.gen(function* () {
99 const dnsClient = yield* Cloudflare.DNS.ReadWriteDns(zone);
100 // Accessor — resolved at apply time against the tracker.
101 const zoneIdAccessor = yield* zone.zoneId;
102
103 return Effect.fn(function* () {
104 const created = yield* dnsClient.createDnsRecord({
105 type: RECORD_TYPE,
106 name: RECORD_NAME,
107 content: RECORD_CONTENT,
108 ttl: 60,
109 });
110
111 const record = yield* dnsClient.getDnsRecord(created.id);
112
113 const listed = yield* dnsClient.listDnsRecords({
114 type: RECORD_TYPE,
115 name: { exact: RECORD_NAME },
116 });
117
118 yield* dnsClient.deleteDnsRecord(created.id);
119
120 return {
121 zoneId: yield* zoneIdAccessor,
122 recordId: created.id,
123 content: record.content,
124 listedContent: listed.result.find(
125 (r) => r.id === created.id,
126 )?.content,
127 listedCount: listed.result.length,
128 };
129 });
130 }).pipe(Effect.provide(Cloudflare.DNS.ReadWriteDnsLocal)),
131 );
132
133 return yield* Seed({});
134 }),
135 )
136 .pipe(Effect.ensuring(purgeRecords(zoneId).pipe(Effect.ignore)));
137
138 expect(out.zoneId).toEqual(zoneId);
139 expect(out.recordId).toBeTruthy();
140 expect(out.content).toEqual(RECORD_CONTENT);
141 expect(out.listedContent).toEqual(RECORD_CONTENT);
142 expect(out.listedCount).toBeGreaterThan(0);
143
144 // The Action deleted the record — it is gone out-of-band.
145 const gone = yield* findRecord(zoneId);
146 expect(gone).toBeUndefined();
147
148 yield* stack.destroy();
149 }).pipe(logLevel),
150 {
151 tags: [
152 "provider:cloudflare",
153 "provider:cloudflare:dns",
154 "provider:cloudflare:zone",
155 "live",
156 ],
157 timeout: 120_000,
158 },
159);