puls-dev 0.3.5 → 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 +165 -54
- 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,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 {};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach, mock } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { GoogleAuth } from 'google-auth-library';
|
|
4
|
+
import { FirebaseAuthBuilder } from './auth.js';
|
|
5
|
+
import { Config } from '../../core/config.js';
|
|
6
|
+
describe('FirebaseAuthBuilder Unit Tests', () => {
|
|
7
|
+
let originalFetch;
|
|
8
|
+
let fetchCalls = [];
|
|
9
|
+
let mockResponses = {};
|
|
10
|
+
function matchResponse(method, url) {
|
|
11
|
+
const key = Object.keys(mockResponses)
|
|
12
|
+
.filter((k) => {
|
|
13
|
+
const [m, path] = k.split(' ');
|
|
14
|
+
return method === m && url.includes(path);
|
|
15
|
+
})
|
|
16
|
+
.sort((a, b) => b.split(' ')[1].length - a.split(' ')[1].length)[0];
|
|
17
|
+
return key ? mockResponses[key] : null;
|
|
18
|
+
}
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
Config.set({
|
|
21
|
+
dryRun: false,
|
|
22
|
+
providers: { firebase: { projectId: 'my-project', serviceAccountPath: '/fake/sa.json' } },
|
|
23
|
+
});
|
|
24
|
+
originalFetch = globalThis.fetch;
|
|
25
|
+
fetchCalls = [];
|
|
26
|
+
mockResponses = {};
|
|
27
|
+
globalThis.fetch = async (input, init) => {
|
|
28
|
+
const url = String(input);
|
|
29
|
+
const method = init?.method ?? 'GET';
|
|
30
|
+
let body;
|
|
31
|
+
if (init?.body && typeof init.body === 'string') {
|
|
32
|
+
try {
|
|
33
|
+
body = JSON.parse(init.body);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
body = init.body;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
fetchCalls.push({ url, method, body });
|
|
40
|
+
const resp = matchResponse(method, url);
|
|
41
|
+
if (resp) {
|
|
42
|
+
return {
|
|
43
|
+
ok: resp.status >= 200 && resp.status < 300,
|
|
44
|
+
status: resp.status,
|
|
45
|
+
json: async () => resp.body,
|
|
46
|
+
text: async () => JSON.stringify(resp.body),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
ok: true,
|
|
51
|
+
status: 200,
|
|
52
|
+
json: async () => ({}),
|
|
53
|
+
text: async () => '{}',
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
mock.method(GoogleAuth.prototype, 'getClient', async () => ({
|
|
57
|
+
getAccessToken: async () => ({ token: 'fake-token' }),
|
|
58
|
+
}));
|
|
59
|
+
});
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
globalThis.fetch = originalFetch;
|
|
62
|
+
mock.restoreAll();
|
|
63
|
+
});
|
|
64
|
+
test('performs dry-run without any API write calls', async () => {
|
|
65
|
+
Config.set({
|
|
66
|
+
dryRun: true,
|
|
67
|
+
providers: { firebase: { projectId: 'my-project', serviceAccountPath: '/fake/sa.json' } },
|
|
68
|
+
});
|
|
69
|
+
const builder = new FirebaseAuthBuilder();
|
|
70
|
+
builder.emailPassword().anonymous().phone();
|
|
71
|
+
const result = await builder.deploy();
|
|
72
|
+
assert.ok(result);
|
|
73
|
+
assert.strictEqual(result.project, 'my-project');
|
|
74
|
+
const writeCalls = fetchCalls.filter((c) => c.method !== 'GET');
|
|
75
|
+
assert.strictEqual(writeCalls.length, 0);
|
|
76
|
+
});
|
|
77
|
+
test('configures email/password and anonymous sign-in via PATCH', async () => {
|
|
78
|
+
const builder = new FirebaseAuthBuilder();
|
|
79
|
+
builder.emailPassword({ passwordRequired: false }).anonymous();
|
|
80
|
+
await builder.deploy();
|
|
81
|
+
const patchCall = fetchCalls.find((c) => c.method === 'PATCH' && c.url.includes('/config'));
|
|
82
|
+
assert.ok(patchCall);
|
|
83
|
+
assert.ok(patchCall.url.includes('signIn.email'));
|
|
84
|
+
assert.ok(patchCall.url.includes('signIn.anonymous'));
|
|
85
|
+
assert.strictEqual(patchCall.body.signIn.email.enabled, true);
|
|
86
|
+
assert.strictEqual(patchCall.body.signIn.email.passwordRequired, false);
|
|
87
|
+
assert.strictEqual(patchCall.body.signIn.anonymous.enabled, true);
|
|
88
|
+
});
|
|
89
|
+
test('enables phone sign-in via PATCH', async () => {
|
|
90
|
+
const builder = new FirebaseAuthBuilder();
|
|
91
|
+
builder.phone();
|
|
92
|
+
await builder.deploy();
|
|
93
|
+
const patchCall = fetchCalls.find((c) => c.method === 'PATCH' && c.url.includes('/config'));
|
|
94
|
+
assert.ok(patchCall);
|
|
95
|
+
assert.ok(patchCall.url.includes('signIn.phoneNumber'));
|
|
96
|
+
assert.strictEqual(patchCall.body.signIn.phoneNumber.enabled, true);
|
|
97
|
+
});
|
|
98
|
+
test('configures OAuth provider via POST when IDP does not exist', async () => {
|
|
99
|
+
// getIdp returns 404 (not configured yet)
|
|
100
|
+
mockResponses['GET /defaultSupportedIdpConfigs/google.com'] = {
|
|
101
|
+
status: 404,
|
|
102
|
+
body: { error: { message: 'not found' } },
|
|
103
|
+
};
|
|
104
|
+
const builder = new FirebaseAuthBuilder();
|
|
105
|
+
builder.google({ clientId: 'client-id-123', clientSecret: 'secret-456' });
|
|
106
|
+
await builder.deploy();
|
|
107
|
+
const postCall = fetchCalls.find((c) => c.method === 'POST' && c.url.includes('defaultSupportedIdpConfigs'));
|
|
108
|
+
assert.ok(postCall);
|
|
109
|
+
assert.ok(postCall.url.includes('idpId=google.com'));
|
|
110
|
+
assert.strictEqual(postCall.body.clientId, 'client-id-123');
|
|
111
|
+
assert.strictEqual(postCall.body.clientSecret, 'secret-456');
|
|
112
|
+
assert.strictEqual(postCall.body.enabled, true);
|
|
113
|
+
});
|
|
114
|
+
test('updates OAuth provider via PATCH when IDP already exists', async () => {
|
|
115
|
+
mockResponses['GET /defaultSupportedIdpConfigs/github.com'] = {
|
|
116
|
+
status: 200,
|
|
117
|
+
body: { name: 'projects/my-project/defaultSupportedIdpConfigs/github.com', enabled: true },
|
|
118
|
+
};
|
|
119
|
+
const builder = new FirebaseAuthBuilder();
|
|
120
|
+
builder.github({ clientId: 'gh-id', clientSecret: 'gh-secret' });
|
|
121
|
+
await builder.deploy();
|
|
122
|
+
const patchCall = fetchCalls.find((c) => c.method === 'PATCH' && c.url.includes('defaultSupportedIdpConfigs/github.com'));
|
|
123
|
+
assert.ok(patchCall);
|
|
124
|
+
assert.strictEqual(patchCall.body.clientId, 'gh-id');
|
|
125
|
+
});
|
|
126
|
+
test('sets authorized domains via PATCH', async () => {
|
|
127
|
+
const builder = new FirebaseAuthBuilder();
|
|
128
|
+
builder.authorizedDomains(['example.com', 'app.example.com', 'localhost']);
|
|
129
|
+
await builder.deploy();
|
|
130
|
+
const patchCall = fetchCalls.find((c) => c.method === 'PATCH' && c.url.includes('/config'));
|
|
131
|
+
assert.ok(patchCall);
|
|
132
|
+
assert.ok(patchCall.url.includes('authorizedDomains'));
|
|
133
|
+
assert.deepStrictEqual(patchCall.body.authorizedDomains, [
|
|
134
|
+
'example.com',
|
|
135
|
+
'app.example.com',
|
|
136
|
+
'localhost',
|
|
137
|
+
]);
|
|
138
|
+
});
|
|
139
|
+
test('destroy returns without making API calls', async () => {
|
|
140
|
+
const builder = new FirebaseAuthBuilder();
|
|
141
|
+
const result = await builder.destroy();
|
|
142
|
+
assert.deepStrictEqual(result, { destroyed: 'auth' });
|
|
143
|
+
assert.strictEqual(fetchCalls.filter((c) => c.method !== 'GET').length, 0);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { test, describe, beforeEach, afterEach, mock } from 'node:test';
|
|
2
2
|
import assert from 'node:assert';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { gzipSync } from 'node:zlib';
|
|
4
6
|
import { GoogleAuth } from 'google-auth-library';
|
|
5
7
|
import { FirebaseHostingBuilder } from './hosting.js';
|
|
6
8
|
import { Config } from '../../core/config.js';
|
|
@@ -129,6 +131,7 @@ describe('FirebaseHostingBuilder Unit Tests', () => {
|
|
|
129
131
|
assert.strictEqual(writeCalls.length, 0);
|
|
130
132
|
});
|
|
131
133
|
test('deploys new version and creates release when site exists', async () => {
|
|
134
|
+
const expectedHash = createHash('sha256').update(gzipSync(Buffer.from('hello from index.html'))).digest('hex');
|
|
132
135
|
mockResponses['GET /projects/my-project/sites/my-site'] = {
|
|
133
136
|
status: 200,
|
|
134
137
|
body: { name: 'projects/my-project/sites/my-site' }
|
|
@@ -141,10 +144,10 @@ describe('FirebaseHostingBuilder Unit Tests', () => {
|
|
|
141
144
|
status: 200,
|
|
142
145
|
body: {
|
|
143
146
|
uploadUrl: 'https://upload-firebasehosting.googleapis.com/upload/v111',
|
|
144
|
-
uploadRequiredHashes: [
|
|
147
|
+
uploadRequiredHashes: [expectedHash]
|
|
145
148
|
}
|
|
146
149
|
};
|
|
147
|
-
mockResponses[
|
|
150
|
+
mockResponses[`POST /upload/v111/${expectedHash}`] = {
|
|
148
151
|
status: 200,
|
|
149
152
|
body: {}
|
|
150
153
|
};
|
|
@@ -166,10 +169,8 @@ describe('FirebaseHostingBuilder Unit Tests', () => {
|
|
|
166
169
|
assert.ok(createVersionCall);
|
|
167
170
|
const populateCall = fetchCalls.find(c => c.method === 'POST' && c.url.endsWith('/versions/v111:populateFiles'));
|
|
168
171
|
assert.ok(populateCall);
|
|
169
|
-
assert.deepStrictEqual(populateCall.body.files, {
|
|
170
|
-
|
|
171
|
-
});
|
|
172
|
-
const uploadCall = fetchCalls.find(c => c.method === 'POST' && c.url.includes('/upload/v111/mock-hash-index'));
|
|
172
|
+
assert.deepStrictEqual(populateCall.body.files, { '/index.html': expectedHash });
|
|
173
|
+
const uploadCall = fetchCalls.find(c => c.method === 'POST' && c.url.includes(`/upload/v111/${expectedHash}`));
|
|
173
174
|
assert.ok(uploadCall);
|
|
174
175
|
assert.strictEqual(uploadCall.headers?.['Authorization'], 'Bearer fake-access-token');
|
|
175
176
|
const patchVersionCall = fetchCalls.find(c => c.method === 'PATCH' && c.url.endsWith('/versions/v111'));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach, mock } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { GoogleAuth } from 'google-auth-library';
|
|
7
|
+
import { FirebaseStorageBuilder } from './storage.js';
|
|
8
|
+
import { Config } from '../../core/config.js';
|
|
9
|
+
describe('FirebaseStorageBuilder Unit Tests', () => {
|
|
10
|
+
let originalFetch;
|
|
11
|
+
let fetchCalls = [];
|
|
12
|
+
let mockResponses = {};
|
|
13
|
+
// Real temp file: storage.ts uses a named ESM import that bypasses mock.method on the fs object
|
|
14
|
+
const tmpRulesFile = path.join(os.tmpdir(), 'puls-storage-test.rules');
|
|
15
|
+
function matchResponse(method, url) {
|
|
16
|
+
const key = Object.keys(mockResponses)
|
|
17
|
+
.filter((k) => {
|
|
18
|
+
const [m, path] = k.split(' ');
|
|
19
|
+
return method === m && url.includes(path);
|
|
20
|
+
})
|
|
21
|
+
.sort((a, b) => b.split(' ')[1].length - a.split(' ')[1].length)[0];
|
|
22
|
+
return key ? mockResponses[key] : null;
|
|
23
|
+
}
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
Config.set({
|
|
26
|
+
dryRun: false,
|
|
27
|
+
providers: { firebase: { projectId: 'my-project', serviceAccountPath: '/fake/sa.json' } },
|
|
28
|
+
});
|
|
29
|
+
originalFetch = globalThis.fetch;
|
|
30
|
+
fetchCalls = [];
|
|
31
|
+
mockResponses = {};
|
|
32
|
+
globalThis.fetch = async (input, init) => {
|
|
33
|
+
const url = String(input);
|
|
34
|
+
const method = init?.method ?? 'GET';
|
|
35
|
+
let body;
|
|
36
|
+
if (init?.body && typeof init.body === 'string') {
|
|
37
|
+
try {
|
|
38
|
+
body = JSON.parse(init.body);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
body = init.body;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
fetchCalls.push({ url, method, body });
|
|
45
|
+
const resp = matchResponse(method, url);
|
|
46
|
+
if (resp) {
|
|
47
|
+
return {
|
|
48
|
+
ok: resp.status >= 200 && resp.status < 300,
|
|
49
|
+
status: resp.status,
|
|
50
|
+
json: async () => resp.body,
|
|
51
|
+
text: async () => JSON.stringify(resp.body),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
ok: true, status: 200, json: async () => ({}), text: async () => '{}',
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
mock.method(GoogleAuth.prototype, 'getClient', async () => ({
|
|
59
|
+
getAccessToken: async () => ({ token: 'fake-token' }),
|
|
60
|
+
}));
|
|
61
|
+
fs.writeFileSync(tmpRulesFile, 'service firebase.storage { match /b/{b}/o/{a=**} { allow read; } }');
|
|
62
|
+
});
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
globalThis.fetch = originalFetch;
|
|
65
|
+
mock.restoreAll();
|
|
66
|
+
try {
|
|
67
|
+
fs.unlinkSync(tmpRulesFile);
|
|
68
|
+
}
|
|
69
|
+
catch { /* ignore */ }
|
|
70
|
+
});
|
|
71
|
+
test('deploys without writing anything when no rules, cors, or lifecycle configured', async () => {
|
|
72
|
+
const builder = new FirebaseStorageBuilder('my-project.appspot.com');
|
|
73
|
+
const result = await builder.deploy();
|
|
74
|
+
assert.ok(result);
|
|
75
|
+
assert.strictEqual(result.bucket, 'my-project.appspot.com');
|
|
76
|
+
assert.strictEqual(result.project, 'my-project');
|
|
77
|
+
const writeCalls = fetchCalls.filter((c) => c.method !== 'GET');
|
|
78
|
+
assert.strictEqual(writeCalls.length, 0);
|
|
79
|
+
});
|
|
80
|
+
test('deploys storage rules by creating a ruleset and updating the release', async () => {
|
|
81
|
+
mockResponses['POST /rulesets'] = {
|
|
82
|
+
status: 200,
|
|
83
|
+
body: { name: 'projects/my-project/rulesets/ruleset-abc' },
|
|
84
|
+
};
|
|
85
|
+
mockResponses['PUT /releases/firebase.storage/my-project.appspot.com'] = {
|
|
86
|
+
status: 200,
|
|
87
|
+
body: {},
|
|
88
|
+
};
|
|
89
|
+
const builder = new FirebaseStorageBuilder('my-project.appspot.com');
|
|
90
|
+
builder.rules(tmpRulesFile);
|
|
91
|
+
await builder.deploy();
|
|
92
|
+
const rulesetCall = fetchCalls.find((c) => c.method === 'POST' && c.url.includes('/rulesets'));
|
|
93
|
+
assert.ok(rulesetCall);
|
|
94
|
+
assert.ok(rulesetCall.body.source.files[0].name === 'storage.rules');
|
|
95
|
+
const releaseCall = fetchCalls.find((c) => c.method === 'PUT' && c.url.includes('/releases/'));
|
|
96
|
+
assert.ok(releaseCall);
|
|
97
|
+
assert.ok(releaseCall.body.rulesetName.includes('ruleset-abc'));
|
|
98
|
+
});
|
|
99
|
+
test('deploys CORS configuration via PATCH to GCS API', async () => {
|
|
100
|
+
const builder = new FirebaseStorageBuilder('my-bucket');
|
|
101
|
+
builder.cors([
|
|
102
|
+
{ origin: ['https://example.com'], method: ['GET', 'PUT'], maxAge: 7200 },
|
|
103
|
+
]);
|
|
104
|
+
await builder.deploy();
|
|
105
|
+
const corsCall = fetchCalls.find((c) => c.method === 'PATCH' && c.url.includes('/b/my-bucket'));
|
|
106
|
+
assert.ok(corsCall);
|
|
107
|
+
assert.ok(Array.isArray(corsCall.body.cors));
|
|
108
|
+
assert.strictEqual(corsCall.body.cors[0].maxAgeSeconds, 7200);
|
|
109
|
+
assert.deepStrictEqual(corsCall.body.cors[0].origin, ['https://example.com']);
|
|
110
|
+
});
|
|
111
|
+
test('deploys lifecycle policy via PATCH to GCS API', async () => {
|
|
112
|
+
const builder = new FirebaseStorageBuilder('my-bucket');
|
|
113
|
+
builder.lifecycle({ deleteAfterDays: 30, matchesPrefix: ['tmp/', 'cache/'] });
|
|
114
|
+
await builder.deploy();
|
|
115
|
+
const lcCall = fetchCalls.find((c) => c.method === 'PATCH' && c.url.includes('/b/my-bucket'));
|
|
116
|
+
assert.ok(lcCall);
|
|
117
|
+
const rule = lcCall.body.lifecycle.rule[0];
|
|
118
|
+
assert.strictEqual(rule.action.type, 'Delete');
|
|
119
|
+
assert.strictEqual(rule.condition.age, 30);
|
|
120
|
+
assert.deepStrictEqual(rule.condition.matchesPrefix, ['tmp/', 'cache/']);
|
|
121
|
+
});
|
|
122
|
+
test('performs dry-run without any write API calls', async () => {
|
|
123
|
+
Config.set({
|
|
124
|
+
dryRun: true,
|
|
125
|
+
providers: { firebase: { projectId: 'my-project', serviceAccountPath: '/fake/sa.json' } },
|
|
126
|
+
});
|
|
127
|
+
const builder = new FirebaseStorageBuilder('my-project.appspot.com');
|
|
128
|
+
builder
|
|
129
|
+
.cors([{ origin: ['*'], method: ['GET'] }])
|
|
130
|
+
.lifecycle({ deleteAfterDays: 90 });
|
|
131
|
+
await builder.deploy();
|
|
132
|
+
const writeCalls = fetchCalls.filter((c) => c.method !== 'GET');
|
|
133
|
+
assert.strictEqual(writeCalls.length, 0);
|
|
134
|
+
});
|
|
135
|
+
test('uses project-derived default bucket name when none provided', async () => {
|
|
136
|
+
const builder = new FirebaseStorageBuilder();
|
|
137
|
+
const result = await builder.deploy();
|
|
138
|
+
// Default bucket is projectId.appspot.com
|
|
139
|
+
assert.strictEqual(result.bucket, 'my-project.appspot.com');
|
|
140
|
+
});
|
|
141
|
+
test('destroy returns without making write API calls', async () => {
|
|
142
|
+
const builder = new FirebaseStorageBuilder('my-project.appspot.com');
|
|
143
|
+
const result = await builder.destroy();
|
|
144
|
+
assert.ok(result.destroyed.includes('appspot.com'));
|
|
145
|
+
const writeCalls = fetchCalls.filter((c) => c.method !== 'GET');
|
|
146
|
+
assert.strictEqual(writeCalls.length, 0);
|
|
147
|
+
});
|
|
148
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "puls-dev",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
4
4
|
"description": "Intent-driven infrastructure-as-code with eager discovery and no state files.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
"./gcp": {
|
|
31
31
|
"types": "./dist/providers/gcp/index.d.ts",
|
|
32
32
|
"default": "./dist/providers/gcp/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./cloudflare": {
|
|
35
|
+
"types": "./dist/providers/cloudflare/index.d.ts",
|
|
36
|
+
"default": "./dist/providers/cloudflare/index.js"
|
|
33
37
|
}
|
|
34
38
|
},
|
|
35
39
|
"files": [
|
|
@@ -44,7 +48,7 @@
|
|
|
44
48
|
"build": "tsc",
|
|
45
49
|
"postbuild": "node -e \"const fs=require('fs'),f='dist/bin/puls.js',c=fs.readFileSync(f,'utf8');if(!c.startsWith('#!'))fs.writeFileSync(f,'#!/usr/bin/env node\\n'+c);\" && chmod +x dist/bin/puls.js",
|
|
46
50
|
"prepublishOnly": "npm run build",
|
|
47
|
-
"test": "tsx --test
|
|
51
|
+
"test": "find src -name '*.test.ts' | xargs tsx --test --test-concurrency=1"
|
|
48
52
|
},
|
|
49
53
|
"keywords": [
|
|
50
54
|
"iac",
|