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.
Files changed (47) hide show
  1. package/README.md +165 -54
  2. package/dist/bin/install-shell.js +5 -6
  3. package/dist/bin/puls.js +10 -3
  4. package/dist/core/config.d.ts +4 -0
  5. package/dist/core/decorators.d.ts +4 -0
  6. package/dist/core/decorators.js +2 -0
  7. package/dist/core/parallel.test.js +4 -3
  8. package/dist/core/resource.d.ts +2 -1
  9. package/dist/core/resource.js +4 -2
  10. package/dist/core/stack.d.ts +4 -0
  11. package/dist/core/stack.js +8 -8
  12. package/dist/providers/aws/acm.test.d.ts +1 -0
  13. package/dist/providers/aws/acm.test.js +167 -0
  14. package/dist/providers/aws/cloudfront.test.d.ts +1 -0
  15. package/dist/providers/aws/cloudfront.test.js +170 -0
  16. package/dist/providers/aws/fargate.test.d.ts +1 -0
  17. package/dist/providers/aws/fargate.test.js +244 -0
  18. package/dist/providers/aws/rds.test.d.ts +1 -0
  19. package/dist/providers/aws/rds.test.js +219 -0
  20. package/dist/providers/aws/sqs.test.d.ts +1 -0
  21. package/dist/providers/aws/sqs.test.js +181 -0
  22. package/dist/providers/cloudflare/api.d.ts +15 -0
  23. package/dist/providers/cloudflare/api.js +199 -0
  24. package/dist/providers/cloudflare/index.d.ts +14 -0
  25. package/dist/providers/cloudflare/index.js +19 -0
  26. package/dist/providers/cloudflare/kv.d.ts +20 -0
  27. package/dist/providers/cloudflare/kv.js +69 -0
  28. package/dist/providers/cloudflare/kv.test.d.ts +1 -0
  29. package/dist/providers/cloudflare/kv.test.js +134 -0
  30. package/dist/providers/cloudflare/r2.d.ts +14 -0
  31. package/dist/providers/cloudflare/r2.js +57 -0
  32. package/dist/providers/cloudflare/r2.test.d.ts +1 -0
  33. package/dist/providers/cloudflare/r2.test.js +132 -0
  34. package/dist/providers/cloudflare/worker.d.ts +28 -0
  35. package/dist/providers/cloudflare/worker.js +172 -0
  36. package/dist/providers/cloudflare/worker.test.d.ts +1 -0
  37. package/dist/providers/cloudflare/worker.test.js +220 -0
  38. package/dist/providers/cloudflare/zone.d.ts +42 -0
  39. package/dist/providers/cloudflare/zone.js +280 -0
  40. package/dist/providers/cloudflare/zone.test.d.ts +1 -0
  41. package/dist/providers/cloudflare/zone.test.js +284 -0
  42. package/dist/providers/firebase/auth.test.d.ts +1 -0
  43. package/dist/providers/firebase/auth.test.js +145 -0
  44. package/dist/providers/firebase/hosting.test.js +7 -6
  45. package/dist/providers/firebase/storage.test.d.ts +1 -0
  46. package/dist/providers/firebase/storage.test.js +148 -0
  47. 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: ['mock-hash-index']
147
+ uploadRequiredHashes: [expectedHash]
145
148
  }
146
149
  };
147
- mockResponses['POST /upload/v111/mock-hash-index'] = {
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
- '/index.html': '8bde9ea78d892f46c76f95e789dfe2000427a3fb3abff9afd8f6b31456703c1d'
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.5",
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 \"src/**/*.test.ts\""
51
+ "test": "find src -name '*.test.ts' | xargs tsx --test --test-concurrency=1"
48
52
  },
49
53
  "keywords": [
50
54
  "iac",