puls-dev 0.1.8 → 0.2.0
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 +8 -8
- package/dist/index.d.ts +0 -7
- package/dist/index.js +0 -7
- package/dist/providers/aws/index.d.ts +1 -0
- package/dist/providers/aws/index.js +1 -0
- package/dist/providers/aws/lambda.js +6 -6
- package/dist/providers/aws/lambda.test.d.ts +1 -0
- package/dist/providers/aws/lambda.test.js +189 -0
- package/dist/providers/aws/route53.d.ts +1 -1
- package/dist/providers/aws/route53.js +20 -12
- package/dist/providers/aws/route53.test.d.ts +1 -0
- package/dist/providers/aws/route53.test.js +229 -0
- package/dist/providers/aws/s3.d.ts +3 -0
- package/dist/providers/aws/s3.js +65 -3
- package/dist/providers/aws/s3.test.d.ts +1 -0
- package/dist/providers/aws/s3.test.js +172 -0
- package/dist/providers/do/api.js +5 -1
- package/dist/providers/do/certificate.test.d.ts +1 -0
- package/dist/providers/do/certificate.test.js +133 -0
- package/dist/providers/do/domain.d.ts +12 -1
- package/dist/providers/do/domain.js +129 -13
- package/dist/providers/do/domain.test.d.ts +1 -0
- package/dist/providers/do/domain.test.js +200 -0
- package/dist/providers/do/droplet.js +2 -2
- package/dist/providers/do/droplet.test.d.ts +1 -0
- package/dist/providers/do/droplet.test.js +265 -0
- package/dist/providers/do/firewall.test.d.ts +1 -0
- package/dist/providers/do/firewall.test.js +176 -0
- package/dist/providers/do/index.d.ts +1 -0
- package/dist/providers/do/index.js +1 -0
- package/dist/providers/do/load_balancer.d.ts +39 -5
- package/dist/providers/do/load_balancer.js +272 -30
- package/dist/providers/do/load_balancer.test.d.ts +1 -0
- package/dist/providers/do/load_balancer.test.js +269 -0
- package/dist/providers/firebase/api.js +2 -2
- package/dist/providers/firebase/functions.d.ts +1 -0
- package/dist/providers/firebase/functions.js +24 -10
- package/dist/providers/firebase/functions.test.d.ts +1 -0
- package/dist/providers/firebase/functions.test.js +297 -0
- package/dist/providers/firebase/hosting.d.ts +2 -0
- package/dist/providers/firebase/hosting.js +15 -9
- package/dist/providers/firebase/hosting.test.d.ts +1 -0
- package/dist/providers/firebase/hosting.test.js +181 -0
- package/dist/providers/proxmox/index.d.ts +1 -0
- package/dist/providers/proxmox/index.js +1 -0
- package/dist/providers/proxmox/vm.d.ts +0 -1
- package/dist/providers/proxmox/vm.js +4 -50
- package/package.json +78 -5
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { DomainBuilder } from "./domain.js";
|
|
4
|
+
import { Config } from "../../core/config.js";
|
|
5
|
+
describe("DomainBuilder Unit Tests", () => {
|
|
6
|
+
let originalFetch;
|
|
7
|
+
let fetchCalls = [];
|
|
8
|
+
let mockResponses = {};
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
Config.set({
|
|
11
|
+
dryRun: false,
|
|
12
|
+
providers: {
|
|
13
|
+
do: { token: "fake-do-token" }
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
originalFetch = globalThis.fetch;
|
|
17
|
+
fetchCalls = [];
|
|
18
|
+
mockResponses = {};
|
|
19
|
+
globalThis.fetch = async (input, init) => {
|
|
20
|
+
const url = String(input);
|
|
21
|
+
const method = init?.method ?? "GET";
|
|
22
|
+
const body = init?.body ? JSON.parse(init.body) : undefined;
|
|
23
|
+
const headers = init?.headers;
|
|
24
|
+
fetchCalls.push({ url, method, body, headers });
|
|
25
|
+
const matchKey = Object.keys(mockResponses).find(key => {
|
|
26
|
+
const [mMethod, mPath] = key.split(" ");
|
|
27
|
+
return method === mMethod && url.endsWith(mPath);
|
|
28
|
+
});
|
|
29
|
+
if (matchKey) {
|
|
30
|
+
const resp = mockResponses[matchKey];
|
|
31
|
+
return {
|
|
32
|
+
ok: resp.status >= 200 && resp.status < 300,
|
|
33
|
+
status: resp.status,
|
|
34
|
+
json: async () => resp.body,
|
|
35
|
+
text: async () => JSON.stringify(resp.body),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
status: 404,
|
|
41
|
+
json: async () => ({ message: "Not found" }),
|
|
42
|
+
text: async () => "Not found",
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
globalThis.fetch = originalFetch;
|
|
48
|
+
});
|
|
49
|
+
test("gracefully handles discovery when domain does not exist", async () => {
|
|
50
|
+
mockResponses["GET /domains/new-domain.com"] = {
|
|
51
|
+
status: 404,
|
|
52
|
+
body: { message: "Domain not found" }
|
|
53
|
+
};
|
|
54
|
+
const builder = new DomainBuilder("new-domain.com");
|
|
55
|
+
const discoveryResult = await builder.discoveryPromise;
|
|
56
|
+
assert.strictEqual(discoveryResult, null);
|
|
57
|
+
assert.strictEqual(fetchCalls.length, 1);
|
|
58
|
+
assert.strictEqual(fetchCalls[0].method, "GET");
|
|
59
|
+
assert.ok(fetchCalls[0].url.endsWith("/domains/new-domain.com"));
|
|
60
|
+
});
|
|
61
|
+
test("discovers domain successfully when it exists", async () => {
|
|
62
|
+
mockResponses["GET /domains/exists.com"] = {
|
|
63
|
+
status: 200,
|
|
64
|
+
body: {
|
|
65
|
+
domain: {
|
|
66
|
+
name: "exists.com",
|
|
67
|
+
ttl: 1800,
|
|
68
|
+
zone_file: "..."
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const builder = new DomainBuilder("exists.com");
|
|
73
|
+
const discoveryResult = await builder.discoveryPromise;
|
|
74
|
+
assert.ok(discoveryResult);
|
|
75
|
+
assert.strictEqual(discoveryResult.name, "exists.com");
|
|
76
|
+
assert.strictEqual(fetchCalls.length, 1);
|
|
77
|
+
});
|
|
78
|
+
test("performs clean dry-run planning without making write requests", async () => {
|
|
79
|
+
Config.set({
|
|
80
|
+
dryRun: true,
|
|
81
|
+
providers: { do: { token: "fake-token" } }
|
|
82
|
+
});
|
|
83
|
+
mockResponses["GET /domains/dryrun.com"] = {
|
|
84
|
+
status: 404,
|
|
85
|
+
body: { message: "Domain not found" }
|
|
86
|
+
};
|
|
87
|
+
const builder = new DomainBuilder("dryrun.com");
|
|
88
|
+
builder.pointer("www", "1.2.3.4");
|
|
89
|
+
builder.cname("blog", "blog.dryrun.com");
|
|
90
|
+
const result = await builder.deploy();
|
|
91
|
+
assert.deepStrictEqual(result, {
|
|
92
|
+
domain: "dryrun.com",
|
|
93
|
+
records: [
|
|
94
|
+
{ type: "A", name: "www", value: "1.2.3.4" },
|
|
95
|
+
{ type: "CNAME", name: "blog", value: "blog.dryrun.com" }
|
|
96
|
+
]
|
|
97
|
+
});
|
|
98
|
+
const writeCalls = fetchCalls.filter(c => c.method !== "GET");
|
|
99
|
+
assert.strictEqual(writeCalls.length, 0);
|
|
100
|
+
});
|
|
101
|
+
test("deploys new domain and creates records when domain is missing", async () => {
|
|
102
|
+
mockResponses["GET /domains/new.com"] = {
|
|
103
|
+
status: 404,
|
|
104
|
+
body: { message: "Domain not found" }
|
|
105
|
+
};
|
|
106
|
+
mockResponses["POST /domains"] = {
|
|
107
|
+
status: 201,
|
|
108
|
+
body: { domain: { name: "new.com" } }
|
|
109
|
+
};
|
|
110
|
+
mockResponses["POST /domains/new.com/records"] = {
|
|
111
|
+
status: 201,
|
|
112
|
+
body: { domain_record: { id: 101 } }
|
|
113
|
+
};
|
|
114
|
+
const builder = new DomainBuilder("new.com");
|
|
115
|
+
builder.pointer("www", "5.6.7.8");
|
|
116
|
+
await builder.deploy();
|
|
117
|
+
assert.strictEqual(fetchCalls.length, 3);
|
|
118
|
+
assert.strictEqual(fetchCalls[0].method, "GET");
|
|
119
|
+
assert.strictEqual(fetchCalls[1].method, "POST");
|
|
120
|
+
assert.ok(fetchCalls[1].url.endsWith("/domains"));
|
|
121
|
+
assert.deepStrictEqual(fetchCalls[1].body, { name: "new.com" });
|
|
122
|
+
assert.strictEqual(fetchCalls[2].method, "POST");
|
|
123
|
+
assert.ok(fetchCalls[2].url.endsWith("/domains/new.com/records"));
|
|
124
|
+
assert.deepStrictEqual(fetchCalls[2].body, {
|
|
125
|
+
type: "A",
|
|
126
|
+
name: "www",
|
|
127
|
+
data: "5.6.7.8",
|
|
128
|
+
ttl: 3600,
|
|
129
|
+
priority: null,
|
|
130
|
+
port: null,
|
|
131
|
+
weight: null,
|
|
132
|
+
flags: null,
|
|
133
|
+
tag: null
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
test("syncs records: skips matching, updates out-of-date, deletes stale/duplicate", async () => {
|
|
137
|
+
mockResponses["GET /domains/sync.com"] = {
|
|
138
|
+
status: 200,
|
|
139
|
+
body: { domain: { name: "sync.com" } }
|
|
140
|
+
};
|
|
141
|
+
mockResponses["GET /domains/sync.com/records?per_page=200"] = {
|
|
142
|
+
status: 200,
|
|
143
|
+
body: {
|
|
144
|
+
domain_records: [
|
|
145
|
+
{ id: 10, type: "A", name: "www", data: "1.1.1.1" },
|
|
146
|
+
{ id: 20, type: "A", name: "api", data: "2.2.2.2" },
|
|
147
|
+
{ id: 30, type: "A", name: "api", data: "4.4.4.4" }
|
|
148
|
+
]
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
mockResponses["PUT /domains/sync.com/records/20"] = {
|
|
152
|
+
status: 200,
|
|
153
|
+
body: { domain_record: { id: 20 } }
|
|
154
|
+
};
|
|
155
|
+
mockResponses["DELETE /domains/sync.com/records/30"] = {
|
|
156
|
+
status: 204,
|
|
157
|
+
body: {}
|
|
158
|
+
};
|
|
159
|
+
const builder = new DomainBuilder("sync.com");
|
|
160
|
+
builder.pointer("www", "1.1.1.1");
|
|
161
|
+
builder.pointer("api", "3.3.3.3");
|
|
162
|
+
await builder.deploy();
|
|
163
|
+
const putCall = fetchCalls.find(c => c.method === "PUT");
|
|
164
|
+
assert.ok(putCall);
|
|
165
|
+
assert.ok(putCall.url.endsWith("/domains/sync.com/records/20"));
|
|
166
|
+
assert.deepStrictEqual(putCall.body, {
|
|
167
|
+
type: "A",
|
|
168
|
+
name: "api",
|
|
169
|
+
data: "3.3.3.3",
|
|
170
|
+
ttl: 3600,
|
|
171
|
+
priority: null,
|
|
172
|
+
port: null,
|
|
173
|
+
weight: null,
|
|
174
|
+
flags: null,
|
|
175
|
+
tag: null
|
|
176
|
+
});
|
|
177
|
+
const deleteCall = fetchCalls.find(c => c.method === "DELETE");
|
|
178
|
+
assert.ok(deleteCall);
|
|
179
|
+
assert.ok(deleteCall.url.endsWith("/domains/sync.com/records/30"));
|
|
180
|
+
const postCall = fetchCalls.find(c => c.method === "POST");
|
|
181
|
+
assert.strictEqual(postCall, undefined);
|
|
182
|
+
});
|
|
183
|
+
test("destroys domain successfully", async () => {
|
|
184
|
+
mockResponses["GET /domains/destroy.com"] = {
|
|
185
|
+
status: 200,
|
|
186
|
+
body: { domain: { name: "destroy.com" } }
|
|
187
|
+
};
|
|
188
|
+
mockResponses["DELETE /domains/destroy.com"] = {
|
|
189
|
+
status: 204,
|
|
190
|
+
body: {}
|
|
191
|
+
};
|
|
192
|
+
const builder = new DomainBuilder("destroy.com");
|
|
193
|
+
await builder.discoveryPromise;
|
|
194
|
+
const result = await builder.destroy();
|
|
195
|
+
assert.deepStrictEqual(result, { destroyed: "destroy.com" });
|
|
196
|
+
const deleteCall = fetchCalls.find(c => c.method === "DELETE");
|
|
197
|
+
assert.ok(deleteCall);
|
|
198
|
+
assert.ok(deleteCall.url.endsWith("/domains/destroy.com"));
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import fs from 'fs';
|
|
2
2
|
import { homedir } from 'os';
|
|
3
3
|
import { OS, REGION, SIZE, NETWORK } from '../../types/do.js';
|
|
4
4
|
import { Config } from '../../core/config.js';
|
|
@@ -71,7 +71,7 @@ export class DropletBuilder extends BaseBuilder {
|
|
|
71
71
|
}
|
|
72
72
|
async resolveOrRegisterSshKey(api) {
|
|
73
73
|
const pubPath = this.sshKeyPath.replace(/\.pub$/, '') + '.pub';
|
|
74
|
-
const pubKey = readFileSync(pubPath, 'utf8').trim();
|
|
74
|
+
const pubKey = fs.readFileSync(pubPath, 'utf8').trim();
|
|
75
75
|
const { ssh_keys } = await api.get('/account/keys?per_page=200');
|
|
76
76
|
const existing = ssh_keys.find(k => k.public_key.trim() === pubKey);
|
|
77
77
|
if (existing)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach, mock } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { DropletBuilder } from './droplet.js';
|
|
5
|
+
import { Config } from '../../core/config.js';
|
|
6
|
+
describe('DropletBuilder Unit Tests', () => {
|
|
7
|
+
let originalFetch;
|
|
8
|
+
let fetchCalls = [];
|
|
9
|
+
let mockResponses = {};
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
Config.set({
|
|
12
|
+
dryRun: false,
|
|
13
|
+
providers: {
|
|
14
|
+
do: { token: 'fake-do-token', defaultRegion: 'nyc3' }
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
originalFetch = globalThis.fetch;
|
|
18
|
+
fetchCalls = [];
|
|
19
|
+
mockResponses = {};
|
|
20
|
+
globalThis.fetch = async (input, init) => {
|
|
21
|
+
const url = String(input);
|
|
22
|
+
const method = init?.method ?? 'GET';
|
|
23
|
+
const body = init?.body ? JSON.parse(init.body) : undefined;
|
|
24
|
+
const headers = init?.headers;
|
|
25
|
+
fetchCalls.push({ url, method, body, headers });
|
|
26
|
+
const matchKey = Object.keys(mockResponses)
|
|
27
|
+
.filter(key => {
|
|
28
|
+
const [mMethod, mPath] = key.split(' ');
|
|
29
|
+
return method === mMethod && url.includes(mPath);
|
|
30
|
+
})
|
|
31
|
+
.sort((a, b) => b.split(' ')[1].length - a.split(' ')[1].length)[0];
|
|
32
|
+
if (matchKey) {
|
|
33
|
+
const resp = mockResponses[matchKey];
|
|
34
|
+
return {
|
|
35
|
+
ok: resp.status >= 200 && resp.status < 300,
|
|
36
|
+
status: resp.status,
|
|
37
|
+
json: async () => resp.body,
|
|
38
|
+
text: async () => JSON.stringify(resp.body),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
status: 404,
|
|
44
|
+
json: async () => ({ message: 'Not found' }),
|
|
45
|
+
text: async () => 'Not found',
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
// Mock readFileSync so tests don't hit the real filesystem for SSH keys
|
|
49
|
+
mock.method(fs, 'readFileSync', () => {
|
|
50
|
+
return 'ssh-rsa AAAA_FAKE_PUBLIC_KEY test@example.com';
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
globalThis.fetch = originalFetch;
|
|
55
|
+
mock.restoreAll();
|
|
56
|
+
});
|
|
57
|
+
test('gracefully handles discovery when droplet does not exist', async () => {
|
|
58
|
+
mockResponses['GET /droplets'] = {
|
|
59
|
+
status: 200,
|
|
60
|
+
body: { droplets: [] }
|
|
61
|
+
};
|
|
62
|
+
const builder = new DropletBuilder('my-droplet');
|
|
63
|
+
const discoveryResult = await builder.discoveryPromise;
|
|
64
|
+
assert.strictEqual(discoveryResult, null);
|
|
65
|
+
assert.strictEqual(fetchCalls.length, 1);
|
|
66
|
+
assert.strictEqual(fetchCalls[0].method, 'GET');
|
|
67
|
+
assert.ok(fetchCalls[0].url.includes('/droplets?name=my-droplet'));
|
|
68
|
+
});
|
|
69
|
+
test('discovers droplet successfully when it exists', async () => {
|
|
70
|
+
mockResponses['GET /droplets'] = {
|
|
71
|
+
status: 200,
|
|
72
|
+
body: {
|
|
73
|
+
droplets: [
|
|
74
|
+
{
|
|
75
|
+
id: 123,
|
|
76
|
+
name: 'my-droplet',
|
|
77
|
+
networks: {
|
|
78
|
+
v4: [{ ip_address: '1.2.3.4', type: 'public' }]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
const builder = new DropletBuilder('my-droplet');
|
|
85
|
+
const discoveryResult = await builder.discoveryPromise;
|
|
86
|
+
assert.ok(discoveryResult);
|
|
87
|
+
assert.strictEqual(discoveryResult.id, 123);
|
|
88
|
+
assert.strictEqual(discoveryResult.name, 'my-droplet');
|
|
89
|
+
const resolvedId = await builder.out.id.get();
|
|
90
|
+
const resolvedIp = await builder.out.ip.get();
|
|
91
|
+
assert.strictEqual(resolvedId, 123);
|
|
92
|
+
assert.strictEqual(resolvedIp, '1.2.3.4');
|
|
93
|
+
});
|
|
94
|
+
test('performs clean dry-run planning without making write requests', async () => {
|
|
95
|
+
Config.set({
|
|
96
|
+
dryRun: true,
|
|
97
|
+
providers: { do: { token: 'fake-token' } }
|
|
98
|
+
});
|
|
99
|
+
mockResponses['GET /droplets'] = {
|
|
100
|
+
status: 200,
|
|
101
|
+
body: { droplets: [] }
|
|
102
|
+
};
|
|
103
|
+
const builder = new DropletBuilder('my-droplet');
|
|
104
|
+
builder
|
|
105
|
+
.region('nyc3')
|
|
106
|
+
.size('s-1vcpu-1gb')
|
|
107
|
+
.sslKey('~/.ssh/id_rsa.pub');
|
|
108
|
+
const result = await builder.deploy();
|
|
109
|
+
assert.ok(result);
|
|
110
|
+
assert.strictEqual(result.region, 'nyc3');
|
|
111
|
+
assert.strictEqual(result.size, 's-1vcpu-1gb');
|
|
112
|
+
const writeCalls = fetchCalls.filter(c => c.method !== 'GET');
|
|
113
|
+
assert.strictEqual(writeCalls.length, 0);
|
|
114
|
+
const resolvedId = await builder.out.id.get();
|
|
115
|
+
const resolvedIp = await builder.out.ip.get();
|
|
116
|
+
assert.strictEqual(resolvedId, -1);
|
|
117
|
+
assert.strictEqual(resolvedIp, '0.0.0.0');
|
|
118
|
+
});
|
|
119
|
+
test('deploys new droplet, registers SSH key, and awaits status: active', async () => {
|
|
120
|
+
mockResponses['GET /droplets'] = {
|
|
121
|
+
status: 200,
|
|
122
|
+
body: { droplets: [] }
|
|
123
|
+
};
|
|
124
|
+
mockResponses['GET /account/keys'] = {
|
|
125
|
+
status: 200,
|
|
126
|
+
body: { ssh_keys: [] } // SSH key does not exist yet
|
|
127
|
+
};
|
|
128
|
+
mockResponses['POST /account/keys'] = {
|
|
129
|
+
status: 201,
|
|
130
|
+
body: { ssh_key: { id: 999, name: 'id_rsa' } }
|
|
131
|
+
};
|
|
132
|
+
mockResponses['POST /droplets'] = {
|
|
133
|
+
status: 202,
|
|
134
|
+
body: { droplet: { id: 12345, name: 'my-droplet' } }
|
|
135
|
+
};
|
|
136
|
+
// First poll returns state: new, second poll returns state: active
|
|
137
|
+
let pollCount = 0;
|
|
138
|
+
mockResponses['GET /droplets/12345'] = {
|
|
139
|
+
status: 200,
|
|
140
|
+
get body() {
|
|
141
|
+
pollCount++;
|
|
142
|
+
if (pollCount === 1) {
|
|
143
|
+
return { droplet: { status: 'new' } };
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
droplet: {
|
|
147
|
+
status: 'active',
|
|
148
|
+
networks: {
|
|
149
|
+
v4: [{ ip_address: '9.9.9.9', type: 'public' }]
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
const builder = new DropletBuilder('my-droplet');
|
|
156
|
+
builder
|
|
157
|
+
.region('nyc3')
|
|
158
|
+
.size('s-1vcpu-1gb')
|
|
159
|
+
.sslKey('/path/to/id_rsa.pub');
|
|
160
|
+
// Override the protected waitFor method to poll instantly
|
|
161
|
+
builder.waitFor = async (label, condition) => {
|
|
162
|
+
let done = false;
|
|
163
|
+
while (!done) {
|
|
164
|
+
done = await condition();
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
const result = await builder.deploy();
|
|
168
|
+
assert.ok(result);
|
|
169
|
+
const resolvedId = await builder.out.id.get();
|
|
170
|
+
const resolvedIp = await builder.out.ip.get();
|
|
171
|
+
assert.strictEqual(resolvedId, 12345);
|
|
172
|
+
assert.strictEqual(resolvedIp, '9.9.9.9');
|
|
173
|
+
// Assert SSH key registration was posted
|
|
174
|
+
const sshKeyRegisterCall = fetchCalls.find(c => c.method === 'POST' && c.url.includes('/account/keys'));
|
|
175
|
+
assert.ok(sshKeyRegisterCall);
|
|
176
|
+
assert.deepStrictEqual(sshKeyRegisterCall.body, {
|
|
177
|
+
name: 'id_rsa',
|
|
178
|
+
public_key: 'ssh-rsa AAAA_FAKE_PUBLIC_KEY test@example.com'
|
|
179
|
+
});
|
|
180
|
+
// Assert Droplet creation was posted with registered SSH key ID
|
|
181
|
+
const dropletCreateCall = fetchCalls.find(c => c.method === 'POST' && c.url.includes('/droplets'));
|
|
182
|
+
assert.ok(dropletCreateCall);
|
|
183
|
+
assert.deepStrictEqual(dropletCreateCall.body.ssh_keys, [999]);
|
|
184
|
+
});
|
|
185
|
+
test('skips update deployment if droplet configuration is up-to-date', async () => {
|
|
186
|
+
mockResponses['GET /droplets'] = {
|
|
187
|
+
status: 200,
|
|
188
|
+
body: {
|
|
189
|
+
droplets: [
|
|
190
|
+
{
|
|
191
|
+
id: 123,
|
|
192
|
+
name: 'my-droplet',
|
|
193
|
+
size_slug: 's-1vcpu-1gb',
|
|
194
|
+
region: { slug: 'nyc3' },
|
|
195
|
+
networks: {
|
|
196
|
+
v4: [{ ip_address: '1.2.3.4', type: 'public' }]
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
]
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
const builder = new DropletBuilder('my-droplet');
|
|
203
|
+
builder
|
|
204
|
+
.region('nyc3')
|
|
205
|
+
.size('s-1vcpu-1gb');
|
|
206
|
+
await builder.deploy();
|
|
207
|
+
// Verify no POST writes or updates
|
|
208
|
+
const writeCalls = fetchCalls.filter(c => c.method === 'POST' || c.method === 'PUT');
|
|
209
|
+
assert.strictEqual(writeCalls.length, 0);
|
|
210
|
+
});
|
|
211
|
+
test('resizes existing droplet when size configuration changes', async () => {
|
|
212
|
+
mockResponses['GET /droplets'] = {
|
|
213
|
+
status: 200,
|
|
214
|
+
body: {
|
|
215
|
+
droplets: [
|
|
216
|
+
{
|
|
217
|
+
id: 123,
|
|
218
|
+
name: 'my-droplet',
|
|
219
|
+
size_slug: 's-1vcpu-1gb', // different from desired s-2vcpu-2gb
|
|
220
|
+
region: { slug: 'nyc3' },
|
|
221
|
+
networks: {
|
|
222
|
+
v4: [{ ip_address: '1.2.3.4', type: 'public' }]
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
]
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
mockResponses['POST /droplets/123/actions'] = {
|
|
229
|
+
status: 201,
|
|
230
|
+
body: { action: { id: 888, status: 'in-progress', type: 'resize' } }
|
|
231
|
+
};
|
|
232
|
+
const builder = new DropletBuilder('my-droplet');
|
|
233
|
+
builder
|
|
234
|
+
.region('nyc3')
|
|
235
|
+
.size('s-2vcpu-2gb');
|
|
236
|
+
await builder.deploy();
|
|
237
|
+
const resizeCall = fetchCalls.find(c => c.method === 'POST' && c.url.includes('/droplets/123/actions'));
|
|
238
|
+
assert.ok(resizeCall);
|
|
239
|
+
assert.deepStrictEqual(resizeCall.body, {
|
|
240
|
+
type: 'resize',
|
|
241
|
+
size: 's-2vcpu-2gb'
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
test('destroys droplet successfully', async () => {
|
|
245
|
+
mockResponses['GET /droplets'] = {
|
|
246
|
+
status: 200,
|
|
247
|
+
body: {
|
|
248
|
+
droplets: [
|
|
249
|
+
{ id: 123, name: 'my-droplet' }
|
|
250
|
+
]
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
mockResponses['DELETE /droplets/123'] = {
|
|
254
|
+
status: 204,
|
|
255
|
+
body: {}
|
|
256
|
+
};
|
|
257
|
+
const builder = new DropletBuilder('my-droplet');
|
|
258
|
+
await builder.discoveryPromise;
|
|
259
|
+
const result = await builder.destroy();
|
|
260
|
+
assert.deepStrictEqual(result, { destroyed: 'my-droplet' });
|
|
261
|
+
const deleteCall = fetchCalls.find(c => c.method === 'DELETE');
|
|
262
|
+
assert.ok(deleteCall);
|
|
263
|
+
assert.ok(deleteCall.url.endsWith('/droplets/123'));
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { FirewallBuilder } from './firewall.js';
|
|
4
|
+
import { Config } from '../../core/config.js';
|
|
5
|
+
describe('FirewallBuilder Unit Tests', () => {
|
|
6
|
+
let originalFetch;
|
|
7
|
+
let fetchCalls = [];
|
|
8
|
+
let mockResponses = {};
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
Config.set({
|
|
11
|
+
dryRun: false,
|
|
12
|
+
providers: {
|
|
13
|
+
do: { token: 'fake-do-token' }
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
originalFetch = globalThis.fetch;
|
|
17
|
+
fetchCalls = [];
|
|
18
|
+
mockResponses = {};
|
|
19
|
+
globalThis.fetch = async (input, init) => {
|
|
20
|
+
const url = String(input);
|
|
21
|
+
const method = init?.method ?? 'GET';
|
|
22
|
+
const body = init?.body ? JSON.parse(init.body) : undefined;
|
|
23
|
+
const headers = init?.headers;
|
|
24
|
+
fetchCalls.push({ url, method, body, headers });
|
|
25
|
+
const matchKey = Object.keys(mockResponses).find(key => {
|
|
26
|
+
const [mMethod, mPath] = key.split(' ');
|
|
27
|
+
return method === mMethod && url.includes(mPath);
|
|
28
|
+
});
|
|
29
|
+
if (matchKey) {
|
|
30
|
+
const resp = mockResponses[matchKey];
|
|
31
|
+
return {
|
|
32
|
+
ok: resp.status >= 200 && resp.status < 300,
|
|
33
|
+
status: resp.status,
|
|
34
|
+
json: async () => resp.body,
|
|
35
|
+
text: async () => JSON.stringify(resp.body),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
status: 404,
|
|
41
|
+
json: async () => ({ message: 'Not found' }),
|
|
42
|
+
text: async () => 'Not found',
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
globalThis.fetch = originalFetch;
|
|
48
|
+
});
|
|
49
|
+
test('gracefully handles discovery when firewall does not exist', async () => {
|
|
50
|
+
mockResponses['GET /firewalls'] = {
|
|
51
|
+
status: 200,
|
|
52
|
+
body: { firewalls: [] }
|
|
53
|
+
};
|
|
54
|
+
const builder = new FirewallBuilder('my-fw');
|
|
55
|
+
const discoveryResult = await builder.discoveryPromise;
|
|
56
|
+
assert.strictEqual(discoveryResult, null);
|
|
57
|
+
assert.strictEqual(fetchCalls.length, 1);
|
|
58
|
+
assert.strictEqual(fetchCalls[0].method, 'GET');
|
|
59
|
+
assert.ok(fetchCalls[0].url.endsWith('/firewalls?per_page=200'));
|
|
60
|
+
});
|
|
61
|
+
test('discovers firewall successfully when it exists', async () => {
|
|
62
|
+
mockResponses['GET /firewalls'] = {
|
|
63
|
+
status: 200,
|
|
64
|
+
body: {
|
|
65
|
+
firewalls: [
|
|
66
|
+
{ id: 'fw-123', name: 'my-fw' }
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const builder = new FirewallBuilder('my-fw');
|
|
71
|
+
const discoveryResult = await builder.discoveryPromise;
|
|
72
|
+
assert.ok(discoveryResult);
|
|
73
|
+
assert.strictEqual(discoveryResult.id, 'fw-123');
|
|
74
|
+
assert.strictEqual(discoveryResult.name, 'my-fw');
|
|
75
|
+
});
|
|
76
|
+
test('performs clean dry-run planning without making write requests', async () => {
|
|
77
|
+
Config.set({
|
|
78
|
+
dryRun: true,
|
|
79
|
+
providers: { do: { token: 'fake-token' } }
|
|
80
|
+
});
|
|
81
|
+
mockResponses['GET /firewalls'] = {
|
|
82
|
+
status: 200,
|
|
83
|
+
body: { firewalls: [] }
|
|
84
|
+
};
|
|
85
|
+
const builder = new FirewallBuilder('my-fw');
|
|
86
|
+
builder
|
|
87
|
+
.ingress('tcp', 80, ['0.0.0.0/0'])
|
|
88
|
+
.egress('tcp', 'all', ['0.0.0.0/0'])
|
|
89
|
+
.attachTo('app-vm');
|
|
90
|
+
const result = await builder.deploy();
|
|
91
|
+
assert.ok(result);
|
|
92
|
+
assert.strictEqual(result.name, 'my-fw');
|
|
93
|
+
const writeCalls = fetchCalls.filter(c => c.method !== 'GET');
|
|
94
|
+
assert.strictEqual(writeCalls.length, 0);
|
|
95
|
+
});
|
|
96
|
+
test('deploys new firewall, resolves droplet names, and posts creation request', async () => {
|
|
97
|
+
mockResponses['GET /firewalls'] = {
|
|
98
|
+
status: 200,
|
|
99
|
+
body: { firewalls: [] }
|
|
100
|
+
};
|
|
101
|
+
mockResponses['GET /droplets?name=app-vm-1'] = {
|
|
102
|
+
status: 200,
|
|
103
|
+
body: { droplets: [{ id: 111, name: 'app-vm-1' }] }
|
|
104
|
+
};
|
|
105
|
+
mockResponses['GET /droplets?name=app-vm-2'] = {
|
|
106
|
+
status: 200,
|
|
107
|
+
body: { droplets: [{ id: 222, name: 'app-vm-2' }] }
|
|
108
|
+
};
|
|
109
|
+
mockResponses['POST /firewalls'] = {
|
|
110
|
+
status: 201,
|
|
111
|
+
body: {
|
|
112
|
+
firewall: { id: 'fw-789', name: 'my-fw' }
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const builder = new FirewallBuilder('my-fw');
|
|
116
|
+
builder
|
|
117
|
+
.ingress('tcp', 80, ['0.0.0.0/0'])
|
|
118
|
+
.egress('tcp', 'all', ['0.0.0.0/0'])
|
|
119
|
+
.attachTo('app-vm-1')
|
|
120
|
+
.attachTo('app-vm-2');
|
|
121
|
+
const result = await builder.deploy();
|
|
122
|
+
assert.ok(result);
|
|
123
|
+
assert.strictEqual(result.name, 'my-fw');
|
|
124
|
+
const postCall = fetchCalls.find(c => c.method === 'POST');
|
|
125
|
+
assert.ok(postCall);
|
|
126
|
+
assert.ok(postCall.url.endsWith('/firewalls'));
|
|
127
|
+
assert.deepStrictEqual(postCall.body, {
|
|
128
|
+
name: 'my-fw',
|
|
129
|
+
inbound_rules: [
|
|
130
|
+
{ protocol: 'tcp', ports: '80', sources: { addresses: ['0.0.0.0/0'] } }
|
|
131
|
+
],
|
|
132
|
+
outbound_rules: [
|
|
133
|
+
{ protocol: 'tcp', ports: 'all', destinations: { addresses: ['0.0.0.0/0'] } }
|
|
134
|
+
],
|
|
135
|
+
droplet_ids: [111, 222]
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
test('updates existing firewall using PUT', async () => {
|
|
139
|
+
mockResponses['GET /firewalls'] = {
|
|
140
|
+
status: 200,
|
|
141
|
+
body: {
|
|
142
|
+
firewalls: [
|
|
143
|
+
{ id: 'fw-123', name: 'my-fw' }
|
|
144
|
+
]
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
mockResponses['GET /droplets?name=app-vm-1'] = {
|
|
148
|
+
status: 200,
|
|
149
|
+
body: { droplets: [{ id: 111, name: 'app-vm-1' }] }
|
|
150
|
+
};
|
|
151
|
+
mockResponses['PUT /firewalls/fw-123'] = {
|
|
152
|
+
status: 200,
|
|
153
|
+
body: {
|
|
154
|
+
firewall: { id: 'fw-123', name: 'my-fw' }
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
const builder = new FirewallBuilder('my-fw');
|
|
158
|
+
builder
|
|
159
|
+
.ingress('tcp', 443, ['0.0.0.0/0'])
|
|
160
|
+
.attachTo('app-vm-1');
|
|
161
|
+
const result = await builder.deploy();
|
|
162
|
+
assert.ok(result);
|
|
163
|
+
assert.strictEqual(result.name, 'my-fw');
|
|
164
|
+
const putCall = fetchCalls.find(c => c.method === 'PUT');
|
|
165
|
+
assert.ok(putCall);
|
|
166
|
+
assert.ok(putCall.url.endsWith('/firewalls/fw-123'));
|
|
167
|
+
assert.deepStrictEqual(putCall.body, {
|
|
168
|
+
name: 'my-fw',
|
|
169
|
+
inbound_rules: [
|
|
170
|
+
{ protocol: 'tcp', ports: '443', sources: { addresses: ['0.0.0.0/0'] } }
|
|
171
|
+
],
|
|
172
|
+
outbound_rules: [],
|
|
173
|
+
droplet_ids: [111]
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|