puls-dev 0.3.6 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/dist/bin/install-shell.js +5 -6
- package/dist/bin/puls.js +10 -3
- package/dist/core/config.d.ts +4 -0
- package/dist/core/decorators.d.ts +4 -0
- package/dist/core/decorators.js +2 -0
- package/dist/core/parallel.test.js +4 -3
- package/dist/core/resource.d.ts +2 -1
- package/dist/core/resource.js +4 -2
- package/dist/core/stack.d.ts +4 -0
- package/dist/core/stack.js +8 -8
- package/dist/providers/aws/acm.test.d.ts +1 -0
- package/dist/providers/aws/acm.test.js +167 -0
- package/dist/providers/aws/cloudfront.test.d.ts +1 -0
- package/dist/providers/aws/cloudfront.test.js +170 -0
- package/dist/providers/aws/fargate.test.d.ts +1 -0
- package/dist/providers/aws/fargate.test.js +244 -0
- package/dist/providers/aws/rds.test.d.ts +1 -0
- package/dist/providers/aws/rds.test.js +219 -0
- package/dist/providers/aws/sqs.test.d.ts +1 -0
- package/dist/providers/aws/sqs.test.js +181 -0
- package/dist/providers/cloudflare/api.d.ts +15 -0
- package/dist/providers/cloudflare/api.js +199 -0
- package/dist/providers/cloudflare/index.d.ts +14 -0
- package/dist/providers/cloudflare/index.js +19 -0
- package/dist/providers/cloudflare/kv.d.ts +20 -0
- package/dist/providers/cloudflare/kv.js +69 -0
- package/dist/providers/cloudflare/kv.test.d.ts +1 -0
- package/dist/providers/cloudflare/kv.test.js +134 -0
- package/dist/providers/cloudflare/r2.d.ts +14 -0
- package/dist/providers/cloudflare/r2.js +57 -0
- package/dist/providers/cloudflare/r2.test.d.ts +1 -0
- package/dist/providers/cloudflare/r2.test.js +132 -0
- package/dist/providers/cloudflare/worker.d.ts +28 -0
- package/dist/providers/cloudflare/worker.js +172 -0
- package/dist/providers/cloudflare/worker.test.d.ts +1 -0
- package/dist/providers/cloudflare/worker.test.js +220 -0
- package/dist/providers/cloudflare/zone.d.ts +42 -0
- package/dist/providers/cloudflare/zone.js +280 -0
- package/dist/providers/cloudflare/zone.test.d.ts +1 -0
- package/dist/providers/cloudflare/zone.test.js +284 -0
- package/dist/providers/firebase/auth.test.d.ts +1 -0
- package/dist/providers/firebase/auth.test.js +145 -0
- package/dist/providers/firebase/hosting.test.js +7 -6
- package/dist/providers/firebase/storage.test.d.ts +1 -0
- package/dist/providers/firebase/storage.test.js +148 -0
- package/package.json +6 -2
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
2
|
+
import { Output } from "../../core/output.js";
|
|
3
|
+
import { getCloudflareApi, getCloudflareAccountId } from "./api.js";
|
|
4
|
+
import { loadRecordsFromFile } from "../../core/parser.js";
|
|
5
|
+
function getFullRecordName(name, zone) {
|
|
6
|
+
const cleanName = name.trim();
|
|
7
|
+
if (cleanName === "" || cleanName === "@")
|
|
8
|
+
return zone;
|
|
9
|
+
if (cleanName.endsWith(zone))
|
|
10
|
+
return cleanName;
|
|
11
|
+
return `${cleanName}.${zone}`;
|
|
12
|
+
}
|
|
13
|
+
export class ZoneBuilder extends BaseBuilder {
|
|
14
|
+
domainName;
|
|
15
|
+
out = {
|
|
16
|
+
id: new Output(),
|
|
17
|
+
};
|
|
18
|
+
resolvedId = null;
|
|
19
|
+
records = [];
|
|
20
|
+
constructor(domainName) {
|
|
21
|
+
super(domainName);
|
|
22
|
+
this.domainName = domainName;
|
|
23
|
+
this.discoveryPromise = this.discoverZone(domainName);
|
|
24
|
+
}
|
|
25
|
+
async discoverZone(name) {
|
|
26
|
+
try {
|
|
27
|
+
const api = getCloudflareApi();
|
|
28
|
+
const res = await api.get(`/zones?name=${name}`);
|
|
29
|
+
return (res.result ?? []).find((z) => z.name === name) ?? null;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
record(nameOrPath, type, value, ttl, priority, port, weight, flags, tag, proxied) {
|
|
36
|
+
if (arguments.length === 1 &&
|
|
37
|
+
typeof nameOrPath === "string" &&
|
|
38
|
+
(nameOrPath.endsWith(".yaml") || nameOrPath.endsWith(".yml") || nameOrPath.endsWith(".json"))) {
|
|
39
|
+
const loaded = loadRecordsFromFile(nameOrPath);
|
|
40
|
+
for (const r of loaded) {
|
|
41
|
+
this.records.push({
|
|
42
|
+
name: r.name,
|
|
43
|
+
type: r.type,
|
|
44
|
+
value: r.value,
|
|
45
|
+
ttl: r.ttl,
|
|
46
|
+
priority: r.priority,
|
|
47
|
+
port: r.port,
|
|
48
|
+
weight: r.weight,
|
|
49
|
+
flags: r.flags,
|
|
50
|
+
tag: r.tag,
|
|
51
|
+
proxied: r.proxied,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return this;
|
|
55
|
+
}
|
|
56
|
+
this.records.push({
|
|
57
|
+
name: nameOrPath,
|
|
58
|
+
type: type,
|
|
59
|
+
value: value,
|
|
60
|
+
ttl,
|
|
61
|
+
priority,
|
|
62
|
+
port,
|
|
63
|
+
weight,
|
|
64
|
+
flags,
|
|
65
|
+
tag,
|
|
66
|
+
proxied,
|
|
67
|
+
});
|
|
68
|
+
return this;
|
|
69
|
+
}
|
|
70
|
+
pointer(name, target, proxied) {
|
|
71
|
+
this.records.push({ type: "A", name, value: target, proxied });
|
|
72
|
+
return this;
|
|
73
|
+
}
|
|
74
|
+
cname(name, target, proxied) {
|
|
75
|
+
this.records.push({ type: "CNAME", name, value: target, proxied });
|
|
76
|
+
return this;
|
|
77
|
+
}
|
|
78
|
+
aaaa(name, target, proxied) {
|
|
79
|
+
this.records.push({ type: "AAAA", name, value: target, proxied });
|
|
80
|
+
return this;
|
|
81
|
+
}
|
|
82
|
+
txt(name, target) {
|
|
83
|
+
this.records.push({ type: "TXT", name, value: target });
|
|
84
|
+
return this;
|
|
85
|
+
}
|
|
86
|
+
mx(name, target, priority = 10) {
|
|
87
|
+
this.records.push({ type: "MX", name, value: target, priority });
|
|
88
|
+
return this;
|
|
89
|
+
}
|
|
90
|
+
srv(name, target, port, priority = 10, weight = 10) {
|
|
91
|
+
this.records.push({ type: "SRV", name, value: target, port, priority, weight });
|
|
92
|
+
return this;
|
|
93
|
+
}
|
|
94
|
+
caa(name, tag, target, flags = 0) {
|
|
95
|
+
this.records.push({ type: "CAA", name, value: target, tag, flags });
|
|
96
|
+
return this;
|
|
97
|
+
}
|
|
98
|
+
async deploy() {
|
|
99
|
+
const dryRun = this.isDryRunActive();
|
|
100
|
+
const existing = await this.discoveryPromise;
|
|
101
|
+
const api = getCloudflareApi();
|
|
102
|
+
console.log(`\n🌐 Finalizing DNS Zone for "${this.domainName}"...`);
|
|
103
|
+
if (existing) {
|
|
104
|
+
this.resolvedId = existing.id;
|
|
105
|
+
this.out.id.resolve(existing.id);
|
|
106
|
+
console.log(` ✅ Zone "${this.domainName}" exists (id=${existing.id})`);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
if (dryRun) {
|
|
110
|
+
console.log(` 📝 [PLAN] Create Cloudflare Zone "${this.domainName}"`);
|
|
111
|
+
this.resolvedId = "PENDING";
|
|
112
|
+
this.out.id.resolve("PENDING");
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
const accountId = getCloudflareAccountId();
|
|
116
|
+
const res = await api.post("/zones", {
|
|
117
|
+
name: this.domainName,
|
|
118
|
+
account: { id: accountId },
|
|
119
|
+
type: "full",
|
|
120
|
+
});
|
|
121
|
+
this.resolvedId = res.result.id;
|
|
122
|
+
this.out.id.resolve(this.resolvedId);
|
|
123
|
+
console.log(`🚀 Created Cloudflare Zone "${this.domainName}" (id=${this.resolvedId})`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
let existingRecords = [];
|
|
127
|
+
if (existing && !dryRun) {
|
|
128
|
+
try {
|
|
129
|
+
const res = await api.get(`/zones/${this.resolvedId}/dns_records?per_page=100`);
|
|
130
|
+
existingRecords = res.result ?? [];
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
existingRecords = [];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const consumedRecordIds = new Set();
|
|
137
|
+
for (const record of this.records) {
|
|
138
|
+
let data;
|
|
139
|
+
if (record.value instanceof Output) {
|
|
140
|
+
data = await record.value.get();
|
|
141
|
+
}
|
|
142
|
+
else if (record.value && typeof record.value === "object" && "out" in record.value) {
|
|
143
|
+
const out = record.value.out;
|
|
144
|
+
if (out && out.ip instanceof Output) {
|
|
145
|
+
data = await out.ip.get();
|
|
146
|
+
}
|
|
147
|
+
else if (out && out.publicIp instanceof Output) {
|
|
148
|
+
data = await out.publicIp.get();
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
data = String(record.value);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
else if (record.value instanceof BaseBuilder) {
|
|
155
|
+
if (typeof record.value.getPublicIp === "function") {
|
|
156
|
+
data = await record.value.getPublicIp();
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
data = String(record.value);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
data = String(record.value);
|
|
164
|
+
}
|
|
165
|
+
const targetFullName = getFullRecordName(record.name, this.domainName);
|
|
166
|
+
const targetTtl = record.ttl ?? 3600;
|
|
167
|
+
const targetProxied = !!record.proxied;
|
|
168
|
+
// Match logic helper
|
|
169
|
+
const isMatch = (r) => {
|
|
170
|
+
if (r.type !== record.type || r.name !== targetFullName)
|
|
171
|
+
return false;
|
|
172
|
+
if (record.type === "MX") {
|
|
173
|
+
return r.content === data && (r.priority ?? 10) === (record.priority ?? 10);
|
|
174
|
+
}
|
|
175
|
+
if (record.type === "SRV") {
|
|
176
|
+
return (r.data?.target === data &&
|
|
177
|
+
r.data?.port === (record.port ?? 5060) &&
|
|
178
|
+
r.data?.priority === (record.priority ?? 10) &&
|
|
179
|
+
r.data?.weight === (record.weight ?? 10));
|
|
180
|
+
}
|
|
181
|
+
if (record.type === "CAA") {
|
|
182
|
+
return (r.data?.value === data &&
|
|
183
|
+
r.data?.tag === (record.tag ?? "issue") &&
|
|
184
|
+
r.data?.flags === (record.flags ?? 0));
|
|
185
|
+
}
|
|
186
|
+
return r.content === data && !!r.proxied === targetProxied;
|
|
187
|
+
};
|
|
188
|
+
const perfectMatch = existingRecords.find((r) => !consumedRecordIds.has(r.id) && isMatch(r));
|
|
189
|
+
if (perfectMatch) {
|
|
190
|
+
consumedRecordIds.add(perfectMatch.id);
|
|
191
|
+
console.log(` ✅ ${record.type} ${targetFullName} is up to date (→ ${data})`);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const updateableMatch = existingRecords.find((r) => !consumedRecordIds.has(r.id) && r.type === record.type && r.name === targetFullName);
|
|
195
|
+
// Construct payload
|
|
196
|
+
const payload = {
|
|
197
|
+
type: record.type,
|
|
198
|
+
name: record.name,
|
|
199
|
+
ttl: targetTtl,
|
|
200
|
+
};
|
|
201
|
+
if (record.type === "MX") {
|
|
202
|
+
payload.content = data;
|
|
203
|
+
payload.priority = record.priority ?? 10;
|
|
204
|
+
}
|
|
205
|
+
else if (record.type === "SRV") {
|
|
206
|
+
payload.data = {
|
|
207
|
+
priority: record.priority ?? 10,
|
|
208
|
+
weight: record.weight ?? 10,
|
|
209
|
+
port: record.port ?? 5060,
|
|
210
|
+
target: data,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
else if (record.type === "CAA") {
|
|
214
|
+
payload.data = {
|
|
215
|
+
flags: record.flags ?? 0,
|
|
216
|
+
tag: record.tag ?? "issue",
|
|
217
|
+
value: data,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
payload.content = data;
|
|
222
|
+
payload.proxied = targetProxied;
|
|
223
|
+
}
|
|
224
|
+
if (updateableMatch) {
|
|
225
|
+
consumedRecordIds.add(updateableMatch.id);
|
|
226
|
+
if (dryRun) {
|
|
227
|
+
console.log(` 📝 [PLAN] Update ${record.type} ${targetFullName} → ${data}`);
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
await api.put(`/zones/${this.resolvedId}/dns_records/${updateableMatch.id}`, payload);
|
|
231
|
+
console.log(` 🔄 Updated ${record.type} ${targetFullName} → ${data}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
if (dryRun) {
|
|
236
|
+
console.log(` 📝 [PLAN] Create ${record.type} ${targetFullName} → ${data}`);
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
await api.post(`/zones/${this.resolvedId}/dns_records`, payload);
|
|
240
|
+
console.log(` 🚀 Created ${record.type} ${targetFullName} → ${data}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// Purge stale
|
|
245
|
+
const declaredKeySet = new Set(this.records.map((r) => `${r.type}:${getFullRecordName(r.name, this.domainName)}`));
|
|
246
|
+
for (const r of existingRecords) {
|
|
247
|
+
if (consumedRecordIds.has(r.id))
|
|
248
|
+
continue;
|
|
249
|
+
if (declaredKeySet.has(`${r.type}:${r.name}`)) {
|
|
250
|
+
if (dryRun) {
|
|
251
|
+
console.log(` 📝 [PLAN] Delete stale ${r.type} ${r.name}`);
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
await api.delete(`/zones/${this.resolvedId}/dns_records/${r.id}`);
|
|
255
|
+
console.log(` 🗑️ Deleted stale ${r.type} ${r.name}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
await this.deploySidecars();
|
|
260
|
+
return { zone: this.domainName, zoneId: this.resolvedId };
|
|
261
|
+
}
|
|
262
|
+
async destroy() {
|
|
263
|
+
const dryRun = this.isDryRunActive();
|
|
264
|
+
const existing = await this.discoveryPromise;
|
|
265
|
+
const api = getCloudflareApi();
|
|
266
|
+
console.log(`\n🗑️ Destroying Cloudflare DNS Zone "${this.domainName}"...`);
|
|
267
|
+
if (!existing) {
|
|
268
|
+
console.log(` ─ Zone "${this.domainName}" not found`);
|
|
269
|
+
return { destroyed: false };
|
|
270
|
+
}
|
|
271
|
+
if (dryRun) {
|
|
272
|
+
console.log(` 📝 [PLAN] Delete Cloudflare Zone "${this.domainName}" (id=${existing.id})`);
|
|
273
|
+
return { destroyed: this.domainName };
|
|
274
|
+
}
|
|
275
|
+
await api.delete(`/zones/${existing.id}`);
|
|
276
|
+
console.log(` 🗑️ Removed Cloudflare Zone "${this.domainName}" (id=${existing.id})`);
|
|
277
|
+
await this.destroySidecars();
|
|
278
|
+
return { destroyed: this.domainName };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { ZoneBuilder } from "./zone.js";
|
|
6
|
+
import { Config } from "../../core/config.js";
|
|
7
|
+
describe("ZoneBuilder Unit Tests", () => {
|
|
8
|
+
let originalFetch;
|
|
9
|
+
let fetchCalls = [];
|
|
10
|
+
let mockResponses = {};
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
Config.set({
|
|
13
|
+
dryRun: false,
|
|
14
|
+
providers: {
|
|
15
|
+
cloudflare: { token: "fake-cf-token", accountId: "fake-cf-account" }
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
originalFetch = globalThis.fetch;
|
|
19
|
+
fetchCalls = [];
|
|
20
|
+
mockResponses = {};
|
|
21
|
+
globalThis.fetch = async (input, init) => {
|
|
22
|
+
const url = String(input);
|
|
23
|
+
const method = init?.method ?? "GET";
|
|
24
|
+
let body;
|
|
25
|
+
if (init?.body) {
|
|
26
|
+
if (typeof init.body === "string") {
|
|
27
|
+
try {
|
|
28
|
+
body = JSON.parse(init.body);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
body = init.body;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
body = init.body;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const headers = init?.headers;
|
|
39
|
+
fetchCalls.push({ url, method, body, headers });
|
|
40
|
+
const matchKey = Object.keys(mockResponses).find(key => {
|
|
41
|
+
const [mMethod, mPath] = key.split(" ");
|
|
42
|
+
return method === mMethod && url.endsWith(mPath);
|
|
43
|
+
});
|
|
44
|
+
if (matchKey) {
|
|
45
|
+
const resp = mockResponses[matchKey];
|
|
46
|
+
return {
|
|
47
|
+
ok: resp.status >= 200 && resp.status < 300,
|
|
48
|
+
status: resp.status,
|
|
49
|
+
json: async () => resp.body,
|
|
50
|
+
text: async () => JSON.stringify(resp.body),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
status: 404,
|
|
56
|
+
json: async () => ({ errors: [{ message: "Not found" }] }),
|
|
57
|
+
text: async () => "Not found",
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
globalThis.fetch = originalFetch;
|
|
63
|
+
});
|
|
64
|
+
test("discovers zone successfully if it already exists", async () => {
|
|
65
|
+
mockResponses["GET /zones?name=example.com"] = {
|
|
66
|
+
status: 200,
|
|
67
|
+
body: {
|
|
68
|
+
result: [
|
|
69
|
+
{ id: "zone-123", name: "example.com", status: "active" }
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
mockResponses["GET /zones/zone-123/dns_records?per_page=100"] = {
|
|
74
|
+
status: 200,
|
|
75
|
+
body: { result: [] }
|
|
76
|
+
};
|
|
77
|
+
const builder = new ZoneBuilder("example.com");
|
|
78
|
+
const result = await builder.deploy();
|
|
79
|
+
assert.strictEqual(result.zoneId, "zone-123");
|
|
80
|
+
assert.strictEqual(builder.resolvedId, "zone-123");
|
|
81
|
+
const posts = fetchCalls.filter(c => c.method === "POST");
|
|
82
|
+
assert.strictEqual(posts.length, 0); // No zone creation
|
|
83
|
+
});
|
|
84
|
+
test("creates zone if it does not exist", async () => {
|
|
85
|
+
mockResponses["GET /zones?name=example.com"] = {
|
|
86
|
+
status: 200,
|
|
87
|
+
body: { result: [] }
|
|
88
|
+
};
|
|
89
|
+
mockResponses["POST /zones"] = {
|
|
90
|
+
status: 200,
|
|
91
|
+
body: { result: { id: "zone-new-456" } }
|
|
92
|
+
};
|
|
93
|
+
const builder = new ZoneBuilder("example.com");
|
|
94
|
+
const result = await builder.deploy();
|
|
95
|
+
assert.strictEqual(result.zoneId, "zone-new-456");
|
|
96
|
+
assert.strictEqual(builder.resolvedId, "zone-new-456");
|
|
97
|
+
const postCall = fetchCalls.find(c => c.method === "POST" && c.url.endsWith("/zones"));
|
|
98
|
+
assert.ok(postCall);
|
|
99
|
+
assert.deepStrictEqual(postCall.body, {
|
|
100
|
+
name: "example.com",
|
|
101
|
+
account: { id: "fake-cf-account" },
|
|
102
|
+
type: "full"
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
test("handles dry-run deploy properly", async () => {
|
|
106
|
+
Config.set({
|
|
107
|
+
dryRun: true,
|
|
108
|
+
providers: {
|
|
109
|
+
cloudflare: { token: "fake-cf-token", accountId: "fake-cf-account" }
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
mockResponses["GET /zones?name=example.com"] = {
|
|
113
|
+
status: 200,
|
|
114
|
+
body: { result: [] }
|
|
115
|
+
};
|
|
116
|
+
const builder = new ZoneBuilder("example.com");
|
|
117
|
+
const result = await builder.deploy();
|
|
118
|
+
assert.strictEqual(result.zoneId, "PENDING");
|
|
119
|
+
const posts = fetchCalls.filter(c => c.method === "POST");
|
|
120
|
+
assert.strictEqual(posts.length, 0);
|
|
121
|
+
});
|
|
122
|
+
test("reconciles records correctly (creates missing, updates out of date, deletes stale)", async () => {
|
|
123
|
+
mockResponses["GET /zones?name=example.com"] = {
|
|
124
|
+
status: 200,
|
|
125
|
+
body: {
|
|
126
|
+
result: [{ id: "zone-123", name: "example.com" }]
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
mockResponses["GET /zones/zone-123/dns_records?per_page=100"] = {
|
|
130
|
+
status: 200,
|
|
131
|
+
body: {
|
|
132
|
+
result: [
|
|
133
|
+
// Perfect match - should be skipped
|
|
134
|
+
{ id: "rec-1", type: "A", name: "www.example.com", content: "1.1.1.1", proxied: true },
|
|
135
|
+
// Out of date - should be updated (content mismatch)
|
|
136
|
+
{ id: "rec-2", type: "CNAME", name: "blog.example.com", content: "old.example.com", proxied: false },
|
|
137
|
+
// Duplicate/stale record of a declared name/type - should be deleted
|
|
138
|
+
{ id: "rec-3", type: "A", name: "www.example.com", content: "8.8.8.8", proxied: true }
|
|
139
|
+
]
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
mockResponses["PUT /zones/zone-123/dns_records/rec-2"] = {
|
|
143
|
+
status: 200,
|
|
144
|
+
body: { result: { id: "rec-2" } }
|
|
145
|
+
};
|
|
146
|
+
mockResponses["POST /zones/zone-123/dns_records"] = {
|
|
147
|
+
status: 200,
|
|
148
|
+
body: { result: { id: "rec-4" } }
|
|
149
|
+
};
|
|
150
|
+
mockResponses["DELETE /zones/zone-123/dns_records/rec-3"] = {
|
|
151
|
+
status: 200,
|
|
152
|
+
body: {}
|
|
153
|
+
};
|
|
154
|
+
const builder = new ZoneBuilder("example.com");
|
|
155
|
+
// rec-1
|
|
156
|
+
builder.pointer("www", "1.1.1.1", true);
|
|
157
|
+
// rec-2 (updated)
|
|
158
|
+
builder.cname("blog", "new.example.com", false);
|
|
159
|
+
// rec-4 (created)
|
|
160
|
+
builder.pointer("api", "2.2.2.2", false);
|
|
161
|
+
await builder.deploy();
|
|
162
|
+
const putCall = fetchCalls.find(c => c.method === "PUT");
|
|
163
|
+
assert.ok(putCall);
|
|
164
|
+
assert.ok(putCall.url.endsWith("/zones/zone-123/dns_records/rec-2"));
|
|
165
|
+
assert.deepStrictEqual(putCall.body, {
|
|
166
|
+
type: "CNAME",
|
|
167
|
+
name: "blog",
|
|
168
|
+
content: "new.example.com",
|
|
169
|
+
ttl: 3600,
|
|
170
|
+
proxied: false
|
|
171
|
+
});
|
|
172
|
+
const postCall = fetchCalls.find(c => c.method === "POST" && c.url.includes("/dns_records"));
|
|
173
|
+
assert.ok(postCall);
|
|
174
|
+
assert.ok(postCall.url.endsWith("/zones/zone-123/dns_records"));
|
|
175
|
+
assert.deepStrictEqual(postCall.body, {
|
|
176
|
+
type: "A",
|
|
177
|
+
name: "api",
|
|
178
|
+
content: "2.2.2.2",
|
|
179
|
+
ttl: 3600,
|
|
180
|
+
proxied: false
|
|
181
|
+
});
|
|
182
|
+
const deleteCall = fetchCalls.find(c => c.method === "DELETE");
|
|
183
|
+
assert.ok(deleteCall);
|
|
184
|
+
assert.ok(deleteCall.url.endsWith("/zones/zone-123/dns_records/rec-3"));
|
|
185
|
+
});
|
|
186
|
+
test("loads records from configuration file (YAML) successfully", async () => {
|
|
187
|
+
mockResponses["GET /zones?name=file-example.com"] = {
|
|
188
|
+
status: 200,
|
|
189
|
+
body: {
|
|
190
|
+
result: [{ id: "zone-555", name: "file-example.com" }]
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
mockResponses["GET /zones/zone-555/dns_records?per_page=100"] = {
|
|
194
|
+
status: 200,
|
|
195
|
+
body: { result: [] }
|
|
196
|
+
};
|
|
197
|
+
mockResponses["POST /zones/zone-555/dns_records"] = {
|
|
198
|
+
status: 200,
|
|
199
|
+
body: { result: { id: "rec-new" } }
|
|
200
|
+
};
|
|
201
|
+
const tempYamlPath = path.resolve(process.cwd(), "temp-cf-records.yaml");
|
|
202
|
+
const yamlContent = `
|
|
203
|
+
- name: mail
|
|
204
|
+
type: MX
|
|
205
|
+
value: mail.file-example.com
|
|
206
|
+
priority: 5
|
|
207
|
+
- name: _sip._udp
|
|
208
|
+
type: SRV
|
|
209
|
+
value: sip.file-example.com
|
|
210
|
+
port: 5061
|
|
211
|
+
priority: 20
|
|
212
|
+
weight: 20
|
|
213
|
+
- name: '@'
|
|
214
|
+
type: CAA
|
|
215
|
+
value: letsencrypt.org
|
|
216
|
+
tag: issue
|
|
217
|
+
flags: 0
|
|
218
|
+
`;
|
|
219
|
+
fs.writeFileSync(tempYamlPath, yamlContent, "utf-8");
|
|
220
|
+
try {
|
|
221
|
+
const builder = new ZoneBuilder("file-example.com")
|
|
222
|
+
.record("temp-cf-records.yaml");
|
|
223
|
+
await builder.deploy();
|
|
224
|
+
const mxPost = fetchCalls.find(c => c.method === "POST" && c.body?.type === "MX");
|
|
225
|
+
assert.ok(mxPost);
|
|
226
|
+
assert.deepStrictEqual(mxPost.body, {
|
|
227
|
+
type: "MX",
|
|
228
|
+
name: "mail",
|
|
229
|
+
content: "mail.file-example.com",
|
|
230
|
+
ttl: 3600,
|
|
231
|
+
priority: 5
|
|
232
|
+
});
|
|
233
|
+
const srvPost = fetchCalls.find(c => c.method === "POST" && c.body?.type === "SRV");
|
|
234
|
+
assert.ok(srvPost);
|
|
235
|
+
assert.deepStrictEqual(srvPost.body, {
|
|
236
|
+
type: "SRV",
|
|
237
|
+
name: "_sip._udp",
|
|
238
|
+
ttl: 3600,
|
|
239
|
+
data: {
|
|
240
|
+
priority: 20,
|
|
241
|
+
weight: 20,
|
|
242
|
+
port: 5061,
|
|
243
|
+
target: "sip.file-example.com"
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
const caaPost = fetchCalls.find(c => c.method === "POST" && c.body?.type === "CAA");
|
|
247
|
+
assert.ok(caaPost);
|
|
248
|
+
assert.deepStrictEqual(caaPost.body, {
|
|
249
|
+
type: "CAA",
|
|
250
|
+
name: "@",
|
|
251
|
+
ttl: 3600,
|
|
252
|
+
data: {
|
|
253
|
+
flags: 0,
|
|
254
|
+
tag: "issue",
|
|
255
|
+
value: "letsencrypt.org"
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
if (fs.existsSync(tempYamlPath))
|
|
261
|
+
fs.unlinkSync(tempYamlPath);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
test("destroys zone successfully if exists", async () => {
|
|
265
|
+
mockResponses["GET /zones?name=example.com"] = {
|
|
266
|
+
status: 200,
|
|
267
|
+
body: {
|
|
268
|
+
result: [
|
|
269
|
+
{ id: "zone-123", name: "example.com" }
|
|
270
|
+
]
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
mockResponses["DELETE /zones/zone-123"] = {
|
|
274
|
+
status: 200,
|
|
275
|
+
body: {}
|
|
276
|
+
};
|
|
277
|
+
const builder = new ZoneBuilder("example.com");
|
|
278
|
+
const result = await builder.destroy();
|
|
279
|
+
assert.deepStrictEqual(result, { destroyed: "example.com" });
|
|
280
|
+
const deleteCall = fetchCalls.find(c => c.method === "DELETE");
|
|
281
|
+
assert.ok(deleteCall);
|
|
282
|
+
assert.ok(deleteCall.url.endsWith("/zones/zone-123"));
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|