1import * as Hetzner from "@/Hetzner";
2import * as Provider from "@/Provider";
3import * as Test from "@/Test/Alchemy";
4import * as zones from "@distilled.cloud/hetzner/zones";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import { MinimumLogLevel } from "effect/References";
8import * as Schedule from "effect/Schedule";
9
10const { test } = Test.make({ providers: Hetzner.providers() });
11
12const logLevel = Effect.provideService(
13 MinimumLogLevel,
14 process.env.DEBUG ? "Debug" : "Info",
15);
16
17const hasHetznerCreds = !!process.env.HCLOUD_TOKEN;
18
19const waitUntilGone = (zoneId: number) =>
20 zones.getZone({ id_or_name: String(zoneId) }).pipe(
21 Effect.as("found" as const),
22 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
23 Effect.repeat({
24 schedule: Schedule.spaced("1 second"),
25 until: (status) => status === "gone",
26 times: 10,
27 }),
28 );
29
30test.provider.skipIf(!hasHetznerCreds)(
31 "create, update, and delete a zone",
32 (stack) =>
33 Effect.gen(function* () {
34 yield* stack.destroy();
35
36 const created = yield* stack.deploy(
37 Effect.gen(function* () {
38 return yield* Hetzner.Zone("BasicZone", {
39 ttl: 3600,
40 labels: { env: "test" },
41 });
42 }),
43 );
44
45 expect(created.zoneId).toEqual(expect.any(Number));
46 expect(created.name).toMatch(/\.com$/);
47 expect(created.mode).toEqual("primary");
48 expect(created.ttl).toEqual(3600);
49 expect(created.deleteProtection).toEqual(false);
50 expect(created.labels).toMatchObject({ env: "test" });
51 expect(created.assignedNameservers.length).toBeGreaterThan(0);
52 expect(created.created).toEqual(expect.any(String));
53
54 const fetched = yield* zones.getZone({
55 id_or_name: String(created.zoneId),
56 });
57 expect(fetched.zone.id).toEqual(created.zoneId);
58 expect(fetched.zone.name).toEqual(created.name);
59 expect(fetched.zone.ttl).toEqual(3600);
60 expect(fetched.zone.protection.delete).toEqual(false);
61 expect(fetched.zone.labels.env).toEqual("test");
62
63 const updated = yield* stack.deploy(
64 Effect.gen(function* () {
65 return yield* Hetzner.Zone("BasicZone", {
66 name: created.name,
67 ttl: 7200,
68 labels: { env: "prod", role: "dns" },
69 deleteProtection: true,
70 });
71 }),
72 );
73
74 expect(updated.zoneId).toEqual(created.zoneId);
75 expect(updated.name).toEqual(created.name);
76 expect(updated.ttl).toEqual(7200);
77 expect(updated.deleteProtection).toEqual(true);
78 expect(updated.labels).toMatchObject({ env: "prod", role: "dns" });
79
80 const refetched = yield* zones.getZone({
81 id_or_name: String(updated.zoneId),
82 });
83 expect(refetched.zone.ttl).toEqual(7200);
84 expect(refetched.zone.protection.delete).toEqual(true);
85 expect(refetched.zone.labels.env).toEqual("prod");
86 expect(refetched.zone.labels.role).toEqual("dns");
87
88 yield* stack.destroy();
89
90 const gone = yield* waitUntilGone(created.zoneId);
91 expect(gone).toEqual("gone");
92 }).pipe(logLevel),
93 {
94 tags: [
95 "provider:hetzner",
96 "provider:hetzner:service",
97 "provider:hetzner:zone",
98 "live",
99 ],
100 timeout: 120_000,
101 },
102);
103
104test.provider.skipIf(!hasHetznerCreds)(
105 "replace when name changes",
106 (stack) =>
107 Effect.gen(function* () {
108 yield* stack.destroy();
109
110 const created = yield* stack.deploy(
111 Effect.gen(function* () {
112 return yield* Hetzner.Zone("ReplaceZone", {
113 ttl: 3600,
114 });
115 }),
116 );
117
118 // Keep the first label ≤ 63 chars (DNS). The generated apex is already
119 // at the cap, so we rewrite the tail instead of appending.
120 const apex = created.name.replace(/\.com$/, "");
121 const replacementName = `${apex.slice(0, 61)}r.com`;
122
123 const replaced = yield* stack.deploy(
124 Effect.gen(function* () {
125 return yield* Hetzner.Zone("ReplaceZone", {
126 name: replacementName,
127 ttl: 3600,
128 });
129 }),
130 );
131
132 expect(replaced.zoneId).not.toEqual(created.zoneId);
133 expect(replaced.name).toEqual(replacementName);
134
135 const fetched = yield* zones.getZone({
136 id_or_name: String(replaced.zoneId),
137 });
138 expect(fetched.zone.id).toEqual(replaced.zoneId);
139 expect(fetched.zone.name).toEqual(replacementName);
140
141 const oldGone = yield* waitUntilGone(created.zoneId);
142 expect(oldGone).toEqual("gone");
143
144 yield* stack.destroy();
145
146 const gone = yield* waitUntilGone(replaced.zoneId);
147 expect(gone).toEqual("gone");
148 }).pipe(logLevel),
149 {
150 tags: [
151 "provider:hetzner",
152 "provider:hetzner:service",
153 "provider:hetzner:zone",
154 "live",
155 ],
156 timeout: 120_000,
157 },
158);
159
160test.provider.skipIf(!hasHetznerCreds)(
161 "list enumerates the deployed zone",
162 (stack) =>
163 Effect.gen(function* () {
164 yield* stack.destroy();
165
166 const deployed = yield* stack.deploy(
167 Effect.gen(function* () {
168 return yield* Hetzner.Zone("ListZone", {
169 ttl: 3600,
170 });
171 }),
172 );
173
174 const provider = yield* Provider.findProvider(Hetzner.Zone);
175 const all = yield* provider.list();
176 const found = all.find((zone) => zone.zoneId === deployed.zoneId);
177 expect(found).toBeDefined();
178 expect(found?.name).toEqual(deployed.name);
179 expect(found?.ttl).toEqual(3600);
180 expect(found?.mode).toEqual("primary");
181
182 yield* stack.destroy();
183
184 const gone = yield* waitUntilGone(deployed.zoneId);
185 expect(gone).toEqual("gone");
186 }).pipe(logLevel),
187 {
188 tags: [
189 "provider:hetzner",
190 "provider:hetzner:service",
191 "provider:hetzner:zone",
192 "live",
193 ],
194 timeout: 120_000,
195 },
196);