@marcusrbrown/infra 0.13.1 → 0.13.2
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/dist/cli.js +4977 -0
- package/package.json +7 -3
- package/src/__snapshots__/cli.test.ts.snap +0 -219
- package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
- package/src/__test__/mcp-ctx-fixture.ts +0 -104
- package/src/cli.test.ts +0 -66
- package/src/cli.ts +0 -43
- package/src/commands/cliproxy/config.test.ts +0 -475
- package/src/commands/cliproxy/config.ts +0 -238
- package/src/commands/cliproxy/deploy.test.ts +0 -181
- package/src/commands/cliproxy/deploy.ts +0 -157
- package/src/commands/cliproxy/host.test.ts +0 -73
- package/src/commands/cliproxy/host.ts +0 -31
- package/src/commands/cliproxy/index.ts +0 -21
- package/src/commands/cliproxy/keys.test.ts +0 -326
- package/src/commands/cliproxy/keys.ts +0 -201
- package/src/commands/cliproxy/login.test.ts +0 -415
- package/src/commands/cliproxy/login.ts +0 -131
- package/src/commands/cliproxy/models.test.ts +0 -592
- package/src/commands/cliproxy/models.ts +0 -237
- package/src/commands/cliproxy/open.test.ts +0 -132
- package/src/commands/cliproxy/open.ts +0 -78
- package/src/commands/cliproxy/setup/gh.test.ts +0 -218
- package/src/commands/cliproxy/setup/gh.ts +0 -250
- package/src/commands/cliproxy/setup/preview.test.ts +0 -159
- package/src/commands/cliproxy/setup/preview.ts +0 -41
- package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
- package/src/commands/cliproxy/setup/prompts.ts +0 -99
- package/src/commands/cliproxy/setup/providers.test.ts +0 -245
- package/src/commands/cliproxy/setup/providers.ts +0 -136
- package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
- package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
- package/src/commands/cliproxy/setup/templates.test.ts +0 -357
- package/src/commands/cliproxy/setup/templates.ts +0 -145
- package/src/commands/cliproxy/setup/validation.test.ts +0 -567
- package/src/commands/cliproxy/setup/validation.ts +0 -209
- package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
- package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
- package/src/commands/cliproxy/setup.test.ts +0 -2366
- package/src/commands/cliproxy/setup.ts +0 -809
- package/src/commands/cliproxy/shared.test.ts +0 -118
- package/src/commands/cliproxy/shared.ts +0 -84
- package/src/commands/cliproxy/status.test.ts +0 -1157
- package/src/commands/cliproxy/status.ts +0 -597
- package/src/commands/dashboard/deploy.test.ts +0 -215
- package/src/commands/dashboard/deploy.ts +0 -135
- package/src/commands/dashboard/host.test.ts +0 -79
- package/src/commands/dashboard/host.ts +0 -33
- package/src/commands/dashboard/index.ts +0 -13
- package/src/commands/dashboard/logs.test.ts +0 -317
- package/src/commands/dashboard/logs.ts +0 -180
- package/src/commands/dashboard/status.test.ts +0 -708
- package/src/commands/dashboard/status.ts +0 -293
- package/src/commands/gateway/backup.test.ts +0 -426
- package/src/commands/gateway/backup.ts +0 -205
- package/src/commands/gateway/deploy.test.ts +0 -491
- package/src/commands/gateway/deploy.ts +0 -173
- package/src/commands/gateway/host.test.ts +0 -89
- package/src/commands/gateway/host.ts +0 -33
- package/src/commands/gateway/index.ts +0 -17
- package/src/commands/gateway/logs.test.ts +0 -263
- package/src/commands/gateway/logs.ts +0 -160
- package/src/commands/gateway/restore.test.ts +0 -650
- package/src/commands/gateway/restore.ts +0 -308
- package/src/commands/gateway/status.test.ts +0 -755
- package/src/commands/gateway/status.ts +0 -255
- package/src/commands/keeweb/deploy.test.ts +0 -177
- package/src/commands/keeweb/deploy.ts +0 -191
- package/src/commands/keeweb/index.ts +0 -11
- package/src/commands/keeweb/open.test.ts +0 -54
- package/src/commands/keeweb/open.ts +0 -30
- package/src/commands/keeweb/status.test.ts +0 -509
- package/src/commands/keeweb/status.ts +0 -359
- package/src/commands/mcp.test.ts +0 -260
- package/src/commands/mcp.ts +0 -58
- package/src/commands/status.test.ts +0 -222
- package/src/commands/status.ts +0 -161
- package/src/commands/umami/deploy.test.ts +0 -202
- package/src/commands/umami/deploy.ts +0 -132
- package/src/commands/umami/host.test.ts +0 -79
- package/src/commands/umami/host.ts +0 -33
- package/src/commands/umami/index.ts +0 -13
- package/src/commands/umami/logs.test.ts +0 -191
- package/src/commands/umami/logs.ts +0 -163
- package/src/commands/umami/status.test.ts +0 -633
- package/src/commands/umami/status.ts +0 -285
- package/src/commands/vpn/client.test.ts +0 -949
- package/src/commands/vpn/client.ts +0 -467
- package/src/commands/vpn/deploy.test.ts +0 -208
- package/src/commands/vpn/deploy.ts +0 -143
- package/src/commands/vpn/host.test.ts +0 -89
- package/src/commands/vpn/host.ts +0 -36
- package/src/commands/vpn/index.ts +0 -15
- package/src/commands/vpn/logs.test.ts +0 -234
- package/src/commands/vpn/logs.ts +0 -136
- package/src/commands/vpn/peers.test.ts +0 -478
- package/src/commands/vpn/shared.test.ts +0 -454
- package/src/commands/vpn/shared.ts +0 -170
- package/src/commands/vpn/status.test.ts +0 -142
- package/src/commands/vpn/status.ts +0 -78
- package/src/conventions.test.ts +0 -1180
- package/src/lib/action-ctx.ts +0 -25
- package/src/lib/known-hosts.test.ts +0 -192
- package/src/lib/known-hosts.ts +0 -76
- package/src/lib/redact.ts +0 -21
- package/src/lib/ssh-identity.test.ts +0 -134
- package/src/lib/ssh-identity.ts +0 -76
- /package/{src/resources/known_hosts → dist/known_hosts-tk724cp0.} +0 -0
|
@@ -1,949 +0,0 @@
|
|
|
1
|
-
import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'
|
|
2
|
-
import {readFile, stat} from 'node:fs/promises'
|
|
3
|
-
import {tmpdir} from 'node:os'
|
|
4
|
-
import {join, resolve} from 'node:path'
|
|
5
|
-
import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
|
|
6
|
-
|
|
7
|
-
import {vpnClientAdd, vpnClientList, vpnClientRemove, type KeypairGenFn, type SpawnFn} from './client'
|
|
8
|
-
|
|
9
|
-
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
10
|
-
|
|
11
|
-
function makeKeypairGen(privateKey: string, publicKey: string): KeypairGenFn {
|
|
12
|
-
return async () => ({privateKey, publicKey})
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function makePeersJson(peers: {name: string; publicKey: string; tunnelIp: string}[]): string {
|
|
16
|
-
return `${JSON.stringify({peers}, null, 2)}\n`
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Inert spawn stub — never touches real processes.
|
|
21
|
-
* Returns a mock proc: stdin {write, end}, exited resolves 0, empty stdout/stderr.
|
|
22
|
-
* Inject this into every call that doesn't explicitly test sync behavior.
|
|
23
|
-
*/
|
|
24
|
-
function makeNoopSpawnFn(): SpawnFn {
|
|
25
|
-
return (_cmd, _opts) => ({
|
|
26
|
-
stdout: new ReadableStream({
|
|
27
|
-
start(c) {
|
|
28
|
-
c.enqueue(new TextEncoder().encode(''))
|
|
29
|
-
c.close()
|
|
30
|
-
},
|
|
31
|
-
}),
|
|
32
|
-
stderr: new ReadableStream({
|
|
33
|
-
start(c) {
|
|
34
|
-
c.enqueue(new TextEncoder().encode(''))
|
|
35
|
-
c.close()
|
|
36
|
-
},
|
|
37
|
-
}),
|
|
38
|
-
stdin: {
|
|
39
|
-
write: (_data: Uint8Array) => {},
|
|
40
|
-
end: () => {},
|
|
41
|
-
},
|
|
42
|
-
exited: Promise.resolve(0),
|
|
43
|
-
})
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// ─── generateKeypair ─────────────────────────────────────────────────────────
|
|
47
|
-
|
|
48
|
-
describe('generateKeypair', () => {
|
|
49
|
-
it('returns an object with privateKey and publicKey strings via injected keypairGen', async () => {
|
|
50
|
-
// Use dependency injection — never depends on host `wg` binary.
|
|
51
|
-
const mockPrivateKey = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
|
|
52
|
-
const mockPublicKey = 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB='
|
|
53
|
-
const keypairGen = makeKeypairGen(mockPrivateKey, mockPublicKey)
|
|
54
|
-
|
|
55
|
-
// vpnClientAdd uses keypairGen internally; test the injected path directly
|
|
56
|
-
// by calling the mock and asserting the returned shape
|
|
57
|
-
const keypair = await keypairGen()
|
|
58
|
-
|
|
59
|
-
expect(typeof keypair.privateKey).toBe('string')
|
|
60
|
-
expect(typeof keypair.publicKey).toBe('string')
|
|
61
|
-
expect(keypair.privateKey).toBe(mockPrivateKey)
|
|
62
|
-
expect(keypair.publicKey).toBe(mockPublicKey)
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
it('vpnClientAdd uses the injected keypairGen — no dependency on host wg binary', async () => {
|
|
66
|
-
// This test verifies that vpnClientAdd correctly uses the injected keypairGen
|
|
67
|
-
// and returns the keypair values from it (not from a real wg invocation).
|
|
68
|
-
const {mkdtempSync: mkdtemp, mkdirSync, writeFileSync: writeFile, rmSync: rm} = await import('node:fs')
|
|
69
|
-
const {tmpdir} = await import('node:os')
|
|
70
|
-
const {join: joinPath} = await import('node:path')
|
|
71
|
-
|
|
72
|
-
const tmpDir = mkdtemp(joinPath(tmpdir(), 'vpn-keypair-test-'))
|
|
73
|
-
const peersJsonPath = joinPath(tmpDir, 'peers.json')
|
|
74
|
-
const clientsDir = joinPath(tmpDir, 'clients')
|
|
75
|
-
mkdirSync(clientsDir, {recursive: true})
|
|
76
|
-
writeFile(peersJsonPath, `${JSON.stringify({peers: []}, null, 2)}\n`)
|
|
77
|
-
|
|
78
|
-
try {
|
|
79
|
-
const injectedPrivateKey = 'INJECTEDPRIVATEKEYVALUE1234567890123456789='
|
|
80
|
-
const injectedPublicKey = 'INJECTEDPUBLICKEYVALUE12345678901234567890='
|
|
81
|
-
const keypairGen = makeKeypairGen(injectedPrivateKey, injectedPublicKey)
|
|
82
|
-
|
|
83
|
-
const result = await vpnClientAdd('testdevice', {
|
|
84
|
-
peersJsonPath,
|
|
85
|
-
clientsDir,
|
|
86
|
-
serverPublicKey: 'SERVERPUBKEY==',
|
|
87
|
-
endpoint: '1.2.3.4',
|
|
88
|
-
keypairGen,
|
|
89
|
-
spawnFn: makeNoopSpawnFn(),
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
// The result must use the injected keypair values
|
|
93
|
-
expect(result.tunnelIp).toBe('10.8.0.2')
|
|
94
|
-
|
|
95
|
-
// Client .conf must contain the injected private key
|
|
96
|
-
const {readFile} = await import('node:fs/promises')
|
|
97
|
-
const confContent = await readFile(result.confPath, 'utf-8')
|
|
98
|
-
expect(confContent).toContain(injectedPrivateKey)
|
|
99
|
-
|
|
100
|
-
// peers.json must contain the injected public key
|
|
101
|
-
const peersContent = await readFile(peersJsonPath, 'utf-8')
|
|
102
|
-
expect(peersContent).toContain(injectedPublicKey)
|
|
103
|
-
expect(peersContent).not.toContain(injectedPrivateKey)
|
|
104
|
-
} finally {
|
|
105
|
-
rm(tmpDir, {recursive: true, force: true})
|
|
106
|
-
}
|
|
107
|
-
})
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
// ─── vpnClientAdd ─────────────────────────────────────────────────────────────
|
|
111
|
-
|
|
112
|
-
describe('vpnClientAdd', () => {
|
|
113
|
-
let tmpDir: string
|
|
114
|
-
let peersJsonPath: string
|
|
115
|
-
let clientsDir: string
|
|
116
|
-
|
|
117
|
-
beforeEach(() => {
|
|
118
|
-
tmpDir = mkdtempSync(join(tmpdir(), 'vpn-client-test-'))
|
|
119
|
-
peersJsonPath = join(tmpDir, 'peers.json')
|
|
120
|
-
clientsDir = join(tmpDir, 'clients')
|
|
121
|
-
mkdirSync(clientsDir, {recursive: true})
|
|
122
|
-
|
|
123
|
-
// Start with empty peers.json
|
|
124
|
-
writeFileSync(peersJsonPath, makePeersJson([]))
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
afterEach(() => {
|
|
128
|
-
rmSync(tmpDir, {recursive: true, force: true})
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
it('generates a keypair, assigns next IP, appends public key to peers.json, writes client .conf', async () => {
|
|
132
|
-
const keypairGen = makeKeypairGen('PRIVATEKEYVALUE==', 'PUBLICKEYVALUE==')
|
|
133
|
-
|
|
134
|
-
const result = await vpnClientAdd('laptop', {
|
|
135
|
-
peersJsonPath,
|
|
136
|
-
clientsDir,
|
|
137
|
-
serverPublicKey: 'SERVERPUBKEY==',
|
|
138
|
-
endpoint: '1.2.3.4',
|
|
139
|
-
keypairGen,
|
|
140
|
-
spawnFn: makeNoopSpawnFn(),
|
|
141
|
-
})
|
|
142
|
-
|
|
143
|
-
// Check result
|
|
144
|
-
expect(result.tunnelIp).toBe('10.8.0.2')
|
|
145
|
-
expect(result.confPath).toBe(join(clientsDir, 'laptop.conf'))
|
|
146
|
-
|
|
147
|
-
// Check peers.json was updated with PUBLIC key only
|
|
148
|
-
const peersContent = await readFile(peersJsonPath, 'utf-8')
|
|
149
|
-
const peersData = JSON.parse(peersContent) as {peers: {name: string; publicKey: string; tunnelIp: string}[]}
|
|
150
|
-
expect(peersData.peers).toHaveLength(1)
|
|
151
|
-
expect(peersData.peers[0]?.name).toBe('laptop')
|
|
152
|
-
expect(peersData.peers[0]?.publicKey).toBe('PUBLICKEYVALUE==')
|
|
153
|
-
expect(peersData.peers[0]?.tunnelIp).toBe('10.8.0.2')
|
|
154
|
-
|
|
155
|
-
// Check client .conf was written with PRIVATE key
|
|
156
|
-
const confContent = await readFile(result.confPath, 'utf-8')
|
|
157
|
-
expect(confContent).toContain('PRIVATEKEYVALUE==')
|
|
158
|
-
expect(confContent).toContain('SERVERPUBKEY==')
|
|
159
|
-
expect(confContent).toContain('1.2.3.4:51820')
|
|
160
|
-
expect(confContent).toContain('10.8.0.2/32')
|
|
161
|
-
expect(confContent).toContain('AllowedIPs = 0.0.0.0/0')
|
|
162
|
-
})
|
|
163
|
-
|
|
164
|
-
it('assigns sequential IPs for multiple clients', async () => {
|
|
165
|
-
const keypairGen1 = makeKeypairGen('PRIV1==', 'PUB1==')
|
|
166
|
-
const keypairGen2 = makeKeypairGen('PRIV2==', 'PUB2==')
|
|
167
|
-
|
|
168
|
-
await vpnClientAdd('laptop', {
|
|
169
|
-
peersJsonPath,
|
|
170
|
-
clientsDir,
|
|
171
|
-
serverPublicKey: 'SERVERPUBKEY==',
|
|
172
|
-
endpoint: '1.2.3.4',
|
|
173
|
-
keypairGen: keypairGen1,
|
|
174
|
-
spawnFn: makeNoopSpawnFn(),
|
|
175
|
-
})
|
|
176
|
-
|
|
177
|
-
const result2 = await vpnClientAdd('phone', {
|
|
178
|
-
peersJsonPath,
|
|
179
|
-
clientsDir,
|
|
180
|
-
serverPublicKey: 'SERVERPUBKEY==',
|
|
181
|
-
endpoint: '1.2.3.4',
|
|
182
|
-
keypairGen: keypairGen2,
|
|
183
|
-
spawnFn: makeNoopSpawnFn(),
|
|
184
|
-
})
|
|
185
|
-
|
|
186
|
-
expect(result2.tunnelIp).toBe('10.8.0.3')
|
|
187
|
-
})
|
|
188
|
-
|
|
189
|
-
it('supports split-tunnel via allowedIps option', async () => {
|
|
190
|
-
const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
|
|
191
|
-
|
|
192
|
-
const result = await vpnClientAdd('laptop', {
|
|
193
|
-
peersJsonPath,
|
|
194
|
-
clientsDir,
|
|
195
|
-
serverPublicKey: 'SERVERPUBKEY==',
|
|
196
|
-
endpoint: '1.2.3.4',
|
|
197
|
-
keypairGen,
|
|
198
|
-
allowedIps: '10.0.0.0/8,192.168.0.0/16',
|
|
199
|
-
spawnFn: makeNoopSpawnFn(),
|
|
200
|
-
})
|
|
201
|
-
|
|
202
|
-
const confContent = await readFile(result.confPath, 'utf-8')
|
|
203
|
-
expect(confContent).toContain('AllowedIPs = 10.0.0.0/8,192.168.0.0/16')
|
|
204
|
-
})
|
|
205
|
-
|
|
206
|
-
it('throws when a peer with the same name already exists', async () => {
|
|
207
|
-
const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
|
|
208
|
-
|
|
209
|
-
await vpnClientAdd('laptop', {
|
|
210
|
-
peersJsonPath,
|
|
211
|
-
clientsDir,
|
|
212
|
-
serverPublicKey: 'SERVERPUBKEY==',
|
|
213
|
-
endpoint: '1.2.3.4',
|
|
214
|
-
keypairGen,
|
|
215
|
-
spawnFn: makeNoopSpawnFn(),
|
|
216
|
-
})
|
|
217
|
-
|
|
218
|
-
await expect(
|
|
219
|
-
vpnClientAdd('laptop', {
|
|
220
|
-
peersJsonPath,
|
|
221
|
-
clientsDir,
|
|
222
|
-
serverPublicKey: 'SERVERPUBKEY==',
|
|
223
|
-
endpoint: '1.2.3.4',
|
|
224
|
-
keypairGen,
|
|
225
|
-
spawnFn: makeNoopSpawnFn(),
|
|
226
|
-
}),
|
|
227
|
-
).rejects.toThrow('already exists')
|
|
228
|
-
})
|
|
229
|
-
|
|
230
|
-
it('refuses to write client .conf outside the clients directory', async () => {
|
|
231
|
-
const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
|
|
232
|
-
|
|
233
|
-
// Attempt path traversal: name with ../ to escape clients dir
|
|
234
|
-
await expect(
|
|
235
|
-
vpnClientAdd('../evil', {
|
|
236
|
-
peersJsonPath,
|
|
237
|
-
clientsDir,
|
|
238
|
-
serverPublicKey: 'SERVERPUBKEY==',
|
|
239
|
-
endpoint: '1.2.3.4',
|
|
240
|
-
keypairGen,
|
|
241
|
-
spawnFn: makeNoopSpawnFn(),
|
|
242
|
-
}),
|
|
243
|
-
).rejects.toThrow()
|
|
244
|
-
})
|
|
245
|
-
|
|
246
|
-
it('client .conf contains private key but peers.json contains only public key', async () => {
|
|
247
|
-
const keypairGen = makeKeypairGen('SECRETPRIVATEKEY==', 'PUBLICKEY==')
|
|
248
|
-
|
|
249
|
-
const result = await vpnClientAdd('laptop', {
|
|
250
|
-
peersJsonPath,
|
|
251
|
-
clientsDir,
|
|
252
|
-
serverPublicKey: 'SERVERPUBKEY==',
|
|
253
|
-
endpoint: '1.2.3.4',
|
|
254
|
-
keypairGen,
|
|
255
|
-
spawnFn: makeNoopSpawnFn(),
|
|
256
|
-
})
|
|
257
|
-
|
|
258
|
-
// peers.json must NOT contain the private key
|
|
259
|
-
const peersContent = await readFile(peersJsonPath, 'utf-8')
|
|
260
|
-
expect(peersContent).not.toContain('SECRETPRIVATEKEY==')
|
|
261
|
-
expect(peersContent).toContain('PUBLICKEY==')
|
|
262
|
-
|
|
263
|
-
// client .conf must contain the private key
|
|
264
|
-
const confContent = await readFile(result.confPath, 'utf-8')
|
|
265
|
-
expect(confContent).toContain('SECRETPRIVATEKEY==')
|
|
266
|
-
})
|
|
267
|
-
|
|
268
|
-
// Fix #2: ENOENT bootstrap on fresh checkout
|
|
269
|
-
it('succeeds on fresh checkout (no peers.json) — creates first peer', async () => {
|
|
270
|
-
// Do NOT write peers.json — simulate fresh checkout
|
|
271
|
-
const freshDir = mkdtempSync(join(tmpdir(), 'vpn-fresh-test-'))
|
|
272
|
-
const freshPeersJsonPath = join(freshDir, 'peers.json')
|
|
273
|
-
const freshClientsDir = join(freshDir, 'clients')
|
|
274
|
-
mkdirSync(freshClientsDir, {recursive: true})
|
|
275
|
-
|
|
276
|
-
try {
|
|
277
|
-
const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
|
|
278
|
-
const result = await vpnClientAdd('firstpeer', {
|
|
279
|
-
peersJsonPath: freshPeersJsonPath,
|
|
280
|
-
clientsDir: freshClientsDir,
|
|
281
|
-
serverPublicKey: 'SERVERPUBKEY==',
|
|
282
|
-
endpoint: '1.2.3.4',
|
|
283
|
-
keypairGen,
|
|
284
|
-
spawnFn: makeNoopSpawnFn(),
|
|
285
|
-
})
|
|
286
|
-
|
|
287
|
-
expect(result.tunnelIp).toBe('10.8.0.2')
|
|
288
|
-
|
|
289
|
-
// peers.json must now exist with the new peer
|
|
290
|
-
const peersContent = await readFile(freshPeersJsonPath, 'utf-8')
|
|
291
|
-
const peersData = JSON.parse(peersContent) as {peers: {name: string}[]}
|
|
292
|
-
expect(peersData.peers).toHaveLength(1)
|
|
293
|
-
expect(peersData.peers[0]?.name).toBe('firstpeer')
|
|
294
|
-
|
|
295
|
-
// client .conf must exist with private key
|
|
296
|
-
const confContent = await readFile(result.confPath, 'utf-8')
|
|
297
|
-
expect(confContent).toContain('PRIV==')
|
|
298
|
-
} finally {
|
|
299
|
-
rmSync(freshDir, {recursive: true, force: true})
|
|
300
|
-
}
|
|
301
|
-
})
|
|
302
|
-
|
|
303
|
-
// Fix #4: write client .conf BEFORE gh sync
|
|
304
|
-
it('client .conf exists even when spawnFn throws (conf written before sync)', async () => {
|
|
305
|
-
const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
|
|
306
|
-
const throwingSpawnFn: SpawnFn = () => {
|
|
307
|
-
throw new Error('gh not found')
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
const warnMessages: string[] = []
|
|
311
|
-
const originalWarn = console.warn
|
|
312
|
-
console.warn = (...args: unknown[]) => {
|
|
313
|
-
warnMessages.push(args.join(' '))
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
try {
|
|
317
|
-
const result = await vpnClientAdd('laptop', {
|
|
318
|
-
peersJsonPath,
|
|
319
|
-
clientsDir,
|
|
320
|
-
serverPublicKey: 'SERVERPUBKEY==',
|
|
321
|
-
endpoint: '1.2.3.4',
|
|
322
|
-
keypairGen,
|
|
323
|
-
spawnFn: throwingSpawnFn,
|
|
324
|
-
})
|
|
325
|
-
|
|
326
|
-
// .conf must exist with the private key
|
|
327
|
-
const confContent = await readFile(result.confPath, 'utf-8')
|
|
328
|
-
expect(confContent).toContain('PRIV==')
|
|
329
|
-
|
|
330
|
-
// A warning must have been printed (not a throw)
|
|
331
|
-
const allWarnings = warnMessages.join('\n')
|
|
332
|
-
expect(allWarnings).toMatch(/VPN_PEERS|stale|warning/i)
|
|
333
|
-
} finally {
|
|
334
|
-
console.warn = originalWarn
|
|
335
|
-
}
|
|
336
|
-
})
|
|
337
|
-
})
|
|
338
|
-
|
|
339
|
-
// ─── vpnClientList ────────────────────────────────────────────────────────────
|
|
340
|
-
|
|
341
|
-
describe('vpnClientList', () => {
|
|
342
|
-
let tmpDir: string
|
|
343
|
-
let peersJsonPath: string
|
|
344
|
-
|
|
345
|
-
beforeEach(() => {
|
|
346
|
-
tmpDir = mkdtempSync(join(tmpdir(), 'vpn-client-test-'))
|
|
347
|
-
peersJsonPath = join(tmpDir, 'peers.json')
|
|
348
|
-
})
|
|
349
|
-
|
|
350
|
-
afterEach(() => {
|
|
351
|
-
rmSync(tmpDir, {recursive: true, force: true})
|
|
352
|
-
})
|
|
353
|
-
|
|
354
|
-
it('returns empty array when no peers', async () => {
|
|
355
|
-
writeFileSync(peersJsonPath, makePeersJson([]))
|
|
356
|
-
|
|
357
|
-
const peers = await vpnClientList(peersJsonPath)
|
|
358
|
-
|
|
359
|
-
expect(peers).toHaveLength(0)
|
|
360
|
-
})
|
|
361
|
-
|
|
362
|
-
it('returns all peers with name, publicKey, tunnelIp', async () => {
|
|
363
|
-
writeFileSync(
|
|
364
|
-
peersJsonPath,
|
|
365
|
-
makePeersJson([
|
|
366
|
-
{name: 'laptop', publicKey: 'PUB1==', tunnelIp: '10.8.0.2'},
|
|
367
|
-
{name: 'phone', publicKey: 'PUB2==', tunnelIp: '10.8.0.3'},
|
|
368
|
-
]),
|
|
369
|
-
)
|
|
370
|
-
|
|
371
|
-
const peers = await vpnClientList(peersJsonPath)
|
|
372
|
-
|
|
373
|
-
expect(peers).toHaveLength(2)
|
|
374
|
-
expect(peers[0]?.name).toBe('laptop')
|
|
375
|
-
expect(peers[0]?.publicKey).toBe('PUB1==')
|
|
376
|
-
expect(peers[0]?.tunnelIp).toBe('10.8.0.2')
|
|
377
|
-
expect(peers[1]?.name).toBe('phone')
|
|
378
|
-
})
|
|
379
|
-
|
|
380
|
-
// Fix #2: list on fresh checkout (no peers.json) returns empty
|
|
381
|
-
it('returns empty array when peers.json does not exist (fresh checkout)', async () => {
|
|
382
|
-
// Do NOT write peers.json
|
|
383
|
-
const peers = await vpnClientList(peersJsonPath)
|
|
384
|
-
expect(peers).toHaveLength(0)
|
|
385
|
-
})
|
|
386
|
-
})
|
|
387
|
-
|
|
388
|
-
// ─── vpnClientRemove ──────────────────────────────────────────────────────────
|
|
389
|
-
|
|
390
|
-
describe('vpnClientRemove', () => {
|
|
391
|
-
let tmpDir: string
|
|
392
|
-
let peersJsonPath: string
|
|
393
|
-
|
|
394
|
-
beforeEach(() => {
|
|
395
|
-
tmpDir = mkdtempSync(join(tmpdir(), 'vpn-client-test-'))
|
|
396
|
-
peersJsonPath = join(tmpDir, 'peers.json')
|
|
397
|
-
})
|
|
398
|
-
|
|
399
|
-
afterEach(() => {
|
|
400
|
-
rmSync(tmpDir, {recursive: true, force: true})
|
|
401
|
-
})
|
|
402
|
-
|
|
403
|
-
it('removes a peer from peers.json', async () => {
|
|
404
|
-
writeFileSync(
|
|
405
|
-
peersJsonPath,
|
|
406
|
-
makePeersJson([
|
|
407
|
-
{name: 'laptop', publicKey: 'PUB1==', tunnelIp: '10.8.0.2'},
|
|
408
|
-
{name: 'phone', publicKey: 'PUB2==', tunnelIp: '10.8.0.3'},
|
|
409
|
-
]),
|
|
410
|
-
)
|
|
411
|
-
|
|
412
|
-
await vpnClientRemove('laptop', peersJsonPath, {spawnFn: makeNoopSpawnFn()})
|
|
413
|
-
|
|
414
|
-
const peersContent = await readFile(peersJsonPath, 'utf-8')
|
|
415
|
-
const peersData = JSON.parse(peersContent) as {peers: {name: string}[]}
|
|
416
|
-
expect(peersData.peers).toHaveLength(1)
|
|
417
|
-
expect(peersData.peers[0]?.name).toBe('phone')
|
|
418
|
-
})
|
|
419
|
-
|
|
420
|
-
it('throws when removing a non-existent peer', async () => {
|
|
421
|
-
writeFileSync(peersJsonPath, makePeersJson([]))
|
|
422
|
-
|
|
423
|
-
await expect(vpnClientRemove('nonexistent', peersJsonPath, {spawnFn: makeNoopSpawnFn()})).rejects.toThrow(
|
|
424
|
-
'not found',
|
|
425
|
-
)
|
|
426
|
-
})
|
|
427
|
-
|
|
428
|
-
it('after remove, list reflects the removal', async () => {
|
|
429
|
-
writeFileSync(
|
|
430
|
-
peersJsonPath,
|
|
431
|
-
makePeersJson([
|
|
432
|
-
{name: 'laptop', publicKey: 'PUB1==', tunnelIp: '10.8.0.2'},
|
|
433
|
-
{name: 'phone', publicKey: 'PUB2==', tunnelIp: '10.8.0.3'},
|
|
434
|
-
]),
|
|
435
|
-
)
|
|
436
|
-
|
|
437
|
-
await vpnClientRemove('laptop', peersJsonPath, {spawnFn: makeNoopSpawnFn()})
|
|
438
|
-
const peers = await vpnClientList(peersJsonPath)
|
|
439
|
-
|
|
440
|
-
expect(peers).toHaveLength(1)
|
|
441
|
-
expect(peers[0]?.name).toBe('phone')
|
|
442
|
-
})
|
|
443
|
-
|
|
444
|
-
// Fix #2: remove of nonexistent peer on fresh checkout gives not-found error, not ENOENT
|
|
445
|
-
it('throws not-found (not ENOENT) when removing from a nonexistent peers.json', async () => {
|
|
446
|
-
// Do NOT write peers.json — simulate fresh checkout
|
|
447
|
-
await expect(vpnClientRemove('nonexistent', peersJsonPath, {spawnFn: makeNoopSpawnFn()})).rejects.toThrow(
|
|
448
|
-
'not found',
|
|
449
|
-
)
|
|
450
|
-
})
|
|
451
|
-
})
|
|
452
|
-
|
|
453
|
-
// ─── gh secret sync after vpnClientAdd ───────────────────────────────────────
|
|
454
|
-
|
|
455
|
-
describe('vpnClientAdd: gh secret sync', () => {
|
|
456
|
-
let tmpDir: string
|
|
457
|
-
let peersJsonPath: string
|
|
458
|
-
let clientsDir: string
|
|
459
|
-
|
|
460
|
-
beforeEach(() => {
|
|
461
|
-
tmpDir = mkdtempSync(join(tmpdir(), 'vpn-client-sync-test-'))
|
|
462
|
-
peersJsonPath = join(tmpDir, 'peers.json')
|
|
463
|
-
clientsDir = join(tmpDir, 'clients')
|
|
464
|
-
mkdirSync(clientsDir, {recursive: true})
|
|
465
|
-
writeFileSync(peersJsonPath, makePeersJson([]))
|
|
466
|
-
})
|
|
467
|
-
|
|
468
|
-
afterEach(() => {
|
|
469
|
-
rmSync(tmpDir, {recursive: true, force: true})
|
|
470
|
-
})
|
|
471
|
-
|
|
472
|
-
it('spawns gh secret set with roster on STDIN after successful add', async () => {
|
|
473
|
-
const keypairGen = makeKeypairGen('PRIV==', 'NEWPUBKEY==')
|
|
474
|
-
const spawnCalls: {cmd: string[]; stdin: string}[] = []
|
|
475
|
-
|
|
476
|
-
const mockSpawnFn: SpawnFn = (cmd, _opts) => {
|
|
477
|
-
let stdinContent = ''
|
|
478
|
-
const stdinPipe = {
|
|
479
|
-
write: (data: Uint8Array) => {
|
|
480
|
-
stdinContent += new TextDecoder().decode(data)
|
|
481
|
-
},
|
|
482
|
-
end: () => {
|
|
483
|
-
spawnCalls.push({cmd, stdin: stdinContent})
|
|
484
|
-
},
|
|
485
|
-
}
|
|
486
|
-
return {
|
|
487
|
-
stdout: new ReadableStream({
|
|
488
|
-
start(c) {
|
|
489
|
-
c.enqueue(new TextEncoder().encode(''))
|
|
490
|
-
c.close()
|
|
491
|
-
},
|
|
492
|
-
}),
|
|
493
|
-
stderr: new ReadableStream({
|
|
494
|
-
start(c) {
|
|
495
|
-
c.enqueue(new TextEncoder().encode(''))
|
|
496
|
-
c.close()
|
|
497
|
-
},
|
|
498
|
-
}),
|
|
499
|
-
stdin: stdinPipe,
|
|
500
|
-
exited: Promise.resolve(0),
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
await vpnClientAdd('newpeer', {
|
|
505
|
-
peersJsonPath,
|
|
506
|
-
clientsDir,
|
|
507
|
-
serverPublicKey: 'SERVERPUBKEY==',
|
|
508
|
-
endpoint: '1.2.3.4',
|
|
509
|
-
keypairGen,
|
|
510
|
-
spawnFn: mockSpawnFn,
|
|
511
|
-
})
|
|
512
|
-
|
|
513
|
-
// Must have spawned gh secret set
|
|
514
|
-
const ghCall = spawnCalls.find(c => c.cmd.includes('gh') && c.cmd.includes('secret') && c.cmd.includes('set'))
|
|
515
|
-
expect(ghCall).toBeDefined()
|
|
516
|
-
|
|
517
|
-
// The new peer's public key must appear in STDIN, not in argv
|
|
518
|
-
expect(ghCall?.stdin).toContain('NEWPUBKEY==')
|
|
519
|
-
const argvContainsPubkey = ghCall?.cmd.some(arg => arg.includes('NEWPUBKEY=='))
|
|
520
|
-
expect(argvContainsPubkey).toBe(false)
|
|
521
|
-
|
|
522
|
-
// Must pass --env vpn and --repo marcusrbrown/infra
|
|
523
|
-
expect(ghCall?.cmd).toContain('--env')
|
|
524
|
-
expect(ghCall?.cmd).toContain('vpn')
|
|
525
|
-
expect(ghCall?.cmd).toContain('--repo')
|
|
526
|
-
expect(ghCall?.cmd).toContain('marcusrbrown/infra')
|
|
527
|
-
})
|
|
528
|
-
|
|
529
|
-
it('gh failure → command still succeeds, warning printed with remediation', async () => {
|
|
530
|
-
const keypairGen = makeKeypairGen('PRIV==', 'NEWPUBKEY==')
|
|
531
|
-
const warnMessages: string[] = []
|
|
532
|
-
const originalWarn = console.warn
|
|
533
|
-
console.warn = (...args: unknown[]) => {
|
|
534
|
-
warnMessages.push(args.join(' '))
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
const mockSpawnFn: SpawnFn = (_cmd, _opts) => {
|
|
538
|
-
return {
|
|
539
|
-
stdout: new ReadableStream({
|
|
540
|
-
start(c) {
|
|
541
|
-
c.enqueue(new TextEncoder().encode(''))
|
|
542
|
-
c.close()
|
|
543
|
-
},
|
|
544
|
-
}),
|
|
545
|
-
stderr: new ReadableStream({
|
|
546
|
-
start(c) {
|
|
547
|
-
c.enqueue(new TextEncoder().encode('auth error'))
|
|
548
|
-
c.close()
|
|
549
|
-
},
|
|
550
|
-
}),
|
|
551
|
-
stdin: {
|
|
552
|
-
write: (_data: Uint8Array) => {},
|
|
553
|
-
end: () => {},
|
|
554
|
-
},
|
|
555
|
-
exited: Promise.resolve(1), // gh fails
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
try {
|
|
560
|
-
// Must NOT throw even though gh fails
|
|
561
|
-
const result = await vpnClientAdd('newpeer', {
|
|
562
|
-
peersJsonPath,
|
|
563
|
-
clientsDir,
|
|
564
|
-
serverPublicKey: 'SERVERPUBKEY==',
|
|
565
|
-
endpoint: '1.2.3.4',
|
|
566
|
-
keypairGen,
|
|
567
|
-
spawnFn: mockSpawnFn,
|
|
568
|
-
})
|
|
569
|
-
|
|
570
|
-
// The add itself must succeed
|
|
571
|
-
expect(result.tunnelIp).toBe('10.8.0.2')
|
|
572
|
-
|
|
573
|
-
// A warning must have been printed with remediation
|
|
574
|
-
const allWarnings = warnMessages.join('\n')
|
|
575
|
-
expect(allWarnings).toMatch(/VPN_PEERS|stale|warning/i)
|
|
576
|
-
expect(allWarnings).toMatch(/gh secret set/)
|
|
577
|
-
} finally {
|
|
578
|
-
console.warn = originalWarn
|
|
579
|
-
}
|
|
580
|
-
})
|
|
581
|
-
|
|
582
|
-
// Fix #3: spawnFn throws synchronously → command still succeeds + warning printed
|
|
583
|
-
it('spawnFn throws synchronously → command still succeeds, warning printed with remediation', async () => {
|
|
584
|
-
const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
|
|
585
|
-
const warnMessages: string[] = []
|
|
586
|
-
const originalWarn = console.warn
|
|
587
|
-
console.warn = (...args: unknown[]) => {
|
|
588
|
-
warnMessages.push(args.join(' '))
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
const throwingSpawnFn: SpawnFn = () => {
|
|
592
|
-
throw new Error('ENOENT: gh not found')
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
try {
|
|
596
|
-
const result = await vpnClientAdd('newpeer', {
|
|
597
|
-
peersJsonPath,
|
|
598
|
-
clientsDir,
|
|
599
|
-
serverPublicKey: 'SERVERPUBKEY==',
|
|
600
|
-
endpoint: '1.2.3.4',
|
|
601
|
-
keypairGen,
|
|
602
|
-
spawnFn: throwingSpawnFn,
|
|
603
|
-
})
|
|
604
|
-
|
|
605
|
-
// Command must succeed
|
|
606
|
-
expect(result.tunnelIp).toBe('10.8.0.2')
|
|
607
|
-
|
|
608
|
-
// Warning must be printed with remediation
|
|
609
|
-
const allWarnings = warnMessages.join('\n')
|
|
610
|
-
expect(allWarnings).toMatch(/VPN_PEERS|stale|warning/i)
|
|
611
|
-
expect(allWarnings).toMatch(/gh secret set/)
|
|
612
|
-
} finally {
|
|
613
|
-
console.warn = originalWarn
|
|
614
|
-
}
|
|
615
|
-
})
|
|
616
|
-
|
|
617
|
-
// Fix #3: timeout — spawnFn whose exited never resolves → warning after timeout
|
|
618
|
-
it('spawnFn whose exited never resolves → warning after timeout (injectable timeoutMs)', async () => {
|
|
619
|
-
const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
|
|
620
|
-
const warnMessages: string[] = []
|
|
621
|
-
const originalWarn = console.warn
|
|
622
|
-
console.warn = (...args: unknown[]) => {
|
|
623
|
-
warnMessages.push(args.join(' '))
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
const hangingSpawnFn: SpawnFn = (_cmd, _opts) => ({
|
|
627
|
-
stdout: new ReadableStream({
|
|
628
|
-
start(c) {
|
|
629
|
-
c.enqueue(new TextEncoder().encode(''))
|
|
630
|
-
c.close()
|
|
631
|
-
},
|
|
632
|
-
}),
|
|
633
|
-
stderr: new ReadableStream({
|
|
634
|
-
start(c) {
|
|
635
|
-
c.enqueue(new TextEncoder().encode(''))
|
|
636
|
-
c.close()
|
|
637
|
-
},
|
|
638
|
-
}),
|
|
639
|
-
stdin: {
|
|
640
|
-
write: (_data: Uint8Array) => {},
|
|
641
|
-
end: () => {},
|
|
642
|
-
},
|
|
643
|
-
// Never resolves
|
|
644
|
-
exited: new Promise<number>(() => {}),
|
|
645
|
-
})
|
|
646
|
-
|
|
647
|
-
try {
|
|
648
|
-
// Use a very short timeout (50ms) so the test doesn't hang
|
|
649
|
-
const result = await vpnClientAdd('newpeer', {
|
|
650
|
-
peersJsonPath,
|
|
651
|
-
clientsDir,
|
|
652
|
-
serverPublicKey: 'SERVERPUBKEY==',
|
|
653
|
-
endpoint: '1.2.3.4',
|
|
654
|
-
keypairGen,
|
|
655
|
-
spawnFn: hangingSpawnFn,
|
|
656
|
-
syncTimeoutMs: 50,
|
|
657
|
-
})
|
|
658
|
-
|
|
659
|
-
// Command must succeed
|
|
660
|
-
expect(result.tunnelIp).toBe('10.8.0.2')
|
|
661
|
-
|
|
662
|
-
// Warning must be printed
|
|
663
|
-
const allWarnings = warnMessages.join('\n')
|
|
664
|
-
expect(allWarnings).toMatch(/VPN_PEERS|stale|warning|timeout/i)
|
|
665
|
-
} finally {
|
|
666
|
-
console.warn = originalWarn
|
|
667
|
-
}
|
|
668
|
-
})
|
|
669
|
-
|
|
670
|
-
// Fix #5: remediation message must include --repo marcusrbrown/infra
|
|
671
|
-
it('remediation message includes --repo marcusrbrown/infra', async () => {
|
|
672
|
-
const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
|
|
673
|
-
const warnMessages: string[] = []
|
|
674
|
-
const originalWarn = console.warn
|
|
675
|
-
console.warn = (...args: unknown[]) => {
|
|
676
|
-
warnMessages.push(args.join(' '))
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
const failingSpawnFn: SpawnFn = (_cmd, _opts) => ({
|
|
680
|
-
stdout: new ReadableStream({
|
|
681
|
-
start(c) {
|
|
682
|
-
c.enqueue(new TextEncoder().encode(''))
|
|
683
|
-
c.close()
|
|
684
|
-
},
|
|
685
|
-
}),
|
|
686
|
-
stderr: new ReadableStream({
|
|
687
|
-
start(c) {
|
|
688
|
-
c.enqueue(new TextEncoder().encode('error'))
|
|
689
|
-
c.close()
|
|
690
|
-
},
|
|
691
|
-
}),
|
|
692
|
-
stdin: {write: (_data: Uint8Array) => {}, end: () => {}},
|
|
693
|
-
exited: Promise.resolve(1),
|
|
694
|
-
})
|
|
695
|
-
|
|
696
|
-
try {
|
|
697
|
-
await vpnClientAdd('newpeer', {
|
|
698
|
-
peersJsonPath,
|
|
699
|
-
clientsDir,
|
|
700
|
-
serverPublicKey: 'SERVERPUBKEY==',
|
|
701
|
-
endpoint: '1.2.3.4',
|
|
702
|
-
keypairGen,
|
|
703
|
-
spawnFn: failingSpawnFn,
|
|
704
|
-
})
|
|
705
|
-
|
|
706
|
-
const allWarnings = warnMessages.join('\n')
|
|
707
|
-
expect(allWarnings).toContain('--repo marcusrbrown/infra')
|
|
708
|
-
} finally {
|
|
709
|
-
console.warn = originalWarn
|
|
710
|
-
}
|
|
711
|
-
})
|
|
712
|
-
})
|
|
713
|
-
|
|
714
|
-
// ─── gh secret sync after vpnClientRemove ────────────────────────────────────
|
|
715
|
-
|
|
716
|
-
describe('vpnClientRemove: gh secret sync', () => {
|
|
717
|
-
let tmpDir: string
|
|
718
|
-
let peersJsonPath: string
|
|
719
|
-
|
|
720
|
-
beforeEach(() => {
|
|
721
|
-
tmpDir = mkdtempSync(join(tmpdir(), 'vpn-client-remove-sync-test-'))
|
|
722
|
-
peersJsonPath = join(tmpDir, 'peers.json')
|
|
723
|
-
writeFileSync(
|
|
724
|
-
peersJsonPath,
|
|
725
|
-
makePeersJson([
|
|
726
|
-
{name: 'laptop', publicKey: 'PUB1==', tunnelIp: '10.8.0.2'},
|
|
727
|
-
{name: 'phone', publicKey: 'PUB2==', tunnelIp: '10.8.0.3'},
|
|
728
|
-
]),
|
|
729
|
-
)
|
|
730
|
-
})
|
|
731
|
-
|
|
732
|
-
afterEach(() => {
|
|
733
|
-
rmSync(tmpDir, {recursive: true, force: true})
|
|
734
|
-
})
|
|
735
|
-
|
|
736
|
-
it('spawns gh secret set with updated roster on STDIN after successful remove', async () => {
|
|
737
|
-
const spawnCalls: {cmd: string[]; stdin: string}[] = []
|
|
738
|
-
|
|
739
|
-
const mockSpawnFn: SpawnFn = (cmd, _opts) => {
|
|
740
|
-
let stdinContent = ''
|
|
741
|
-
const stdinPipe = {
|
|
742
|
-
write: (data: Uint8Array) => {
|
|
743
|
-
stdinContent += new TextDecoder().decode(data)
|
|
744
|
-
},
|
|
745
|
-
end: () => {
|
|
746
|
-
spawnCalls.push({cmd, stdin: stdinContent})
|
|
747
|
-
},
|
|
748
|
-
}
|
|
749
|
-
return {
|
|
750
|
-
stdout: new ReadableStream({
|
|
751
|
-
start(c) {
|
|
752
|
-
c.enqueue(new TextEncoder().encode(''))
|
|
753
|
-
c.close()
|
|
754
|
-
},
|
|
755
|
-
}),
|
|
756
|
-
stderr: new ReadableStream({
|
|
757
|
-
start(c) {
|
|
758
|
-
c.enqueue(new TextEncoder().encode(''))
|
|
759
|
-
c.close()
|
|
760
|
-
},
|
|
761
|
-
}),
|
|
762
|
-
stdin: stdinPipe,
|
|
763
|
-
exited: Promise.resolve(0),
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
await vpnClientRemove('laptop', peersJsonPath, {spawnFn: mockSpawnFn})
|
|
768
|
-
|
|
769
|
-
// Must have spawned gh secret set
|
|
770
|
-
const ghCall = spawnCalls.find(c => c.cmd.includes('gh') && c.cmd.includes('secret') && c.cmd.includes('set'))
|
|
771
|
-
expect(ghCall).toBeDefined()
|
|
772
|
-
|
|
773
|
-
// Removed peer must NOT appear in STDIN
|
|
774
|
-
expect(ghCall?.stdin).not.toContain('PUB1==')
|
|
775
|
-
// Remaining peer must appear in STDIN
|
|
776
|
-
expect(ghCall?.stdin).toContain('PUB2==')
|
|
777
|
-
|
|
778
|
-
// Roster bytes must not appear in argv
|
|
779
|
-
const argvContainsRoster = ghCall?.cmd.some(arg => arg.includes('PUB1==') || arg.includes('PUB2=='))
|
|
780
|
-
expect(argvContainsRoster).toBe(false)
|
|
781
|
-
})
|
|
782
|
-
|
|
783
|
-
it('gh failure on remove → command still succeeds, warning printed', async () => {
|
|
784
|
-
const warnMessages: string[] = []
|
|
785
|
-
const originalWarn = console.warn
|
|
786
|
-
console.warn = (...args: unknown[]) => {
|
|
787
|
-
warnMessages.push(args.join(' '))
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
const mockSpawnFn: SpawnFn = (_cmd, _opts) => {
|
|
791
|
-
return {
|
|
792
|
-
stdout: new ReadableStream({
|
|
793
|
-
start(c) {
|
|
794
|
-
c.enqueue(new TextEncoder().encode(''))
|
|
795
|
-
c.close()
|
|
796
|
-
},
|
|
797
|
-
}),
|
|
798
|
-
stderr: new ReadableStream({
|
|
799
|
-
start(c) {
|
|
800
|
-
c.enqueue(new TextEncoder().encode('network error'))
|
|
801
|
-
c.close()
|
|
802
|
-
},
|
|
803
|
-
}),
|
|
804
|
-
stdin: {
|
|
805
|
-
write: (_data: Uint8Array) => {},
|
|
806
|
-
end: () => {},
|
|
807
|
-
},
|
|
808
|
-
exited: Promise.resolve(1), // gh fails
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
try {
|
|
813
|
-
// Must NOT throw even though gh fails
|
|
814
|
-
await vpnClientRemove('laptop', peersJsonPath, {spawnFn: mockSpawnFn})
|
|
815
|
-
|
|
816
|
-
// A warning must have been printed with remediation
|
|
817
|
-
const allWarnings = warnMessages.join('\n')
|
|
818
|
-
expect(allWarnings).toMatch(/VPN_PEERS|stale|warning/i)
|
|
819
|
-
expect(allWarnings).toMatch(/gh secret set/)
|
|
820
|
-
} finally {
|
|
821
|
-
console.warn = originalWarn
|
|
822
|
-
}
|
|
823
|
-
})
|
|
824
|
-
|
|
825
|
-
// Fix #5: remediation message must include --repo marcusrbrown/infra
|
|
826
|
-
it('remediation message on remove failure includes --repo marcusrbrown/infra', async () => {
|
|
827
|
-
const warnMessages: string[] = []
|
|
828
|
-
const originalWarn = console.warn
|
|
829
|
-
console.warn = (...args: unknown[]) => {
|
|
830
|
-
warnMessages.push(args.join(' '))
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
const failingSpawnFn: SpawnFn = (_cmd, _opts) => ({
|
|
834
|
-
stdout: new ReadableStream({
|
|
835
|
-
start(c) {
|
|
836
|
-
c.enqueue(new TextEncoder().encode(''))
|
|
837
|
-
c.close()
|
|
838
|
-
},
|
|
839
|
-
}),
|
|
840
|
-
stderr: new ReadableStream({
|
|
841
|
-
start(c) {
|
|
842
|
-
c.enqueue(new TextEncoder().encode('error'))
|
|
843
|
-
c.close()
|
|
844
|
-
},
|
|
845
|
-
}),
|
|
846
|
-
stdin: {write: (_data: Uint8Array) => {}, end: () => {}},
|
|
847
|
-
exited: Promise.resolve(1),
|
|
848
|
-
})
|
|
849
|
-
|
|
850
|
-
try {
|
|
851
|
-
await vpnClientRemove('laptop', peersJsonPath, {spawnFn: failingSpawnFn})
|
|
852
|
-
|
|
853
|
-
const allWarnings = warnMessages.join('\n')
|
|
854
|
-
expect(allWarnings).toContain('--repo marcusrbrown/infra')
|
|
855
|
-
} finally {
|
|
856
|
-
console.warn = originalWarn
|
|
857
|
-
}
|
|
858
|
-
})
|
|
859
|
-
})
|
|
860
|
-
|
|
861
|
-
// ─── Write-guard: client .conf must stay in clients/ ─────────────────────────
|
|
862
|
-
|
|
863
|
-
describe('write-guard: client .conf path safety', () => {
|
|
864
|
-
let tmpDir: string
|
|
865
|
-
let peersJsonPath: string
|
|
866
|
-
let clientsDir: string
|
|
867
|
-
|
|
868
|
-
beforeEach(() => {
|
|
869
|
-
tmpDir = mkdtempSync(join(tmpdir(), 'vpn-client-test-'))
|
|
870
|
-
peersJsonPath = join(tmpDir, 'peers.json')
|
|
871
|
-
clientsDir = join(tmpDir, 'clients')
|
|
872
|
-
mkdirSync(clientsDir, {recursive: true})
|
|
873
|
-
writeFileSync(peersJsonPath, makePeersJson([]))
|
|
874
|
-
})
|
|
875
|
-
|
|
876
|
-
afterEach(() => {
|
|
877
|
-
rmSync(tmpDir, {recursive: true, force: true})
|
|
878
|
-
})
|
|
879
|
-
|
|
880
|
-
it('resolves the output path under clientsDir', async () => {
|
|
881
|
-
const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
|
|
882
|
-
|
|
883
|
-
const result = await vpnClientAdd('mydevice', {
|
|
884
|
-
peersJsonPath,
|
|
885
|
-
clientsDir,
|
|
886
|
-
serverPublicKey: 'SERVERPUBKEY==',
|
|
887
|
-
endpoint: '1.2.3.4',
|
|
888
|
-
keypairGen,
|
|
889
|
-
spawnFn: makeNoopSpawnFn(),
|
|
890
|
-
})
|
|
891
|
-
|
|
892
|
-
// The resolved path must be under clientsDir
|
|
893
|
-
const resolvedClientsDir = resolve(clientsDir)
|
|
894
|
-
expect(result.confPath.startsWith(resolvedClientsDir)).toBe(true)
|
|
895
|
-
})
|
|
896
|
-
|
|
897
|
-
it('rejects a name that would escape the clients directory via path traversal', async () => {
|
|
898
|
-
const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
|
|
899
|
-
|
|
900
|
-
// Path traversal attempt
|
|
901
|
-
await expect(
|
|
902
|
-
vpnClientAdd('../../etc/passwd', {
|
|
903
|
-
peersJsonPath,
|
|
904
|
-
clientsDir,
|
|
905
|
-
serverPublicKey: 'SERVERPUBKEY==',
|
|
906
|
-
endpoint: '1.2.3.4',
|
|
907
|
-
keypairGen,
|
|
908
|
-
spawnFn: makeNoopSpawnFn(),
|
|
909
|
-
}),
|
|
910
|
-
).rejects.toThrow()
|
|
911
|
-
})
|
|
912
|
-
})
|
|
913
|
-
|
|
914
|
-
// ─── client .conf file permissions ───────────────────────────────────────────
|
|
915
|
-
|
|
916
|
-
describe('vpnClientAdd: client .conf file permissions', () => {
|
|
917
|
-
let tmpDir: string
|
|
918
|
-
let peersJsonPath: string
|
|
919
|
-
let clientsDir: string
|
|
920
|
-
|
|
921
|
-
beforeEach(() => {
|
|
922
|
-
tmpDir = mkdtempSync(join(tmpdir(), 'vpn-client-perm-test-'))
|
|
923
|
-
peersJsonPath = join(tmpDir, 'peers.json')
|
|
924
|
-
clientsDir = join(tmpDir, 'clients')
|
|
925
|
-
mkdirSync(clientsDir, {recursive: true})
|
|
926
|
-
writeFileSync(peersJsonPath, makePeersJson([]))
|
|
927
|
-
})
|
|
928
|
-
|
|
929
|
-
afterEach(() => {
|
|
930
|
-
rmSync(tmpDir, {recursive: true, force: true})
|
|
931
|
-
})
|
|
932
|
-
|
|
933
|
-
it('writes client .conf with mode 0o600 (owner read/write only)', async () => {
|
|
934
|
-
const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
|
|
935
|
-
|
|
936
|
-
const result = await vpnClientAdd('laptop', {
|
|
937
|
-
peersJsonPath,
|
|
938
|
-
clientsDir,
|
|
939
|
-
serverPublicKey: 'SERVERPUBKEY==',
|
|
940
|
-
endpoint: '1.2.3.4',
|
|
941
|
-
keypairGen,
|
|
942
|
-
spawnFn: makeNoopSpawnFn(),
|
|
943
|
-
})
|
|
944
|
-
|
|
945
|
-
const fileStat = await stat(result.confPath)
|
|
946
|
-
const permBits = fileStat.mode & 0o777
|
|
947
|
-
expect(permBits).toBe(0o600)
|
|
948
|
-
})
|
|
949
|
-
})
|