@marcusrbrown/infra 0.10.0 → 0.10.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.
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'
|
|
2
|
-
import {readFile} from 'node:fs/promises'
|
|
2
|
+
import {readFile, stat} from 'node:fs/promises'
|
|
3
3
|
import {tmpdir} from 'node:os'
|
|
4
4
|
import {join, resolve} from 'node:path'
|
|
5
5
|
import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
|
|
6
6
|
|
|
7
|
-
import {vpnClientAdd, vpnClientList, vpnClientRemove, type KeypairGenFn} from './client'
|
|
7
|
+
import {vpnClientAdd, vpnClientList, vpnClientRemove, type KeypairGenFn, type SpawnFn} from './client'
|
|
8
8
|
|
|
9
9
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
10
10
|
|
|
@@ -16,6 +16,33 @@ function makePeersJson(peers: {name: string; publicKey: string; tunnelIp: string
|
|
|
16
16
|
return `${JSON.stringify({peers}, null, 2)}\n`
|
|
17
17
|
}
|
|
18
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
|
+
|
|
19
46
|
// ─── generateKeypair ─────────────────────────────────────────────────────────
|
|
20
47
|
|
|
21
48
|
describe('generateKeypair', () => {
|
|
@@ -59,6 +86,7 @@ describe('generateKeypair', () => {
|
|
|
59
86
|
serverPublicKey: 'SERVERPUBKEY==',
|
|
60
87
|
endpoint: '1.2.3.4',
|
|
61
88
|
keypairGen,
|
|
89
|
+
spawnFn: makeNoopSpawnFn(),
|
|
62
90
|
})
|
|
63
91
|
|
|
64
92
|
// The result must use the injected keypair values
|
|
@@ -109,6 +137,7 @@ describe('vpnClientAdd', () => {
|
|
|
109
137
|
serverPublicKey: 'SERVERPUBKEY==',
|
|
110
138
|
endpoint: '1.2.3.4',
|
|
111
139
|
keypairGen,
|
|
140
|
+
spawnFn: makeNoopSpawnFn(),
|
|
112
141
|
})
|
|
113
142
|
|
|
114
143
|
// Check result
|
|
@@ -142,6 +171,7 @@ describe('vpnClientAdd', () => {
|
|
|
142
171
|
serverPublicKey: 'SERVERPUBKEY==',
|
|
143
172
|
endpoint: '1.2.3.4',
|
|
144
173
|
keypairGen: keypairGen1,
|
|
174
|
+
spawnFn: makeNoopSpawnFn(),
|
|
145
175
|
})
|
|
146
176
|
|
|
147
177
|
const result2 = await vpnClientAdd('phone', {
|
|
@@ -150,6 +180,7 @@ describe('vpnClientAdd', () => {
|
|
|
150
180
|
serverPublicKey: 'SERVERPUBKEY==',
|
|
151
181
|
endpoint: '1.2.3.4',
|
|
152
182
|
keypairGen: keypairGen2,
|
|
183
|
+
spawnFn: makeNoopSpawnFn(),
|
|
153
184
|
})
|
|
154
185
|
|
|
155
186
|
expect(result2.tunnelIp).toBe('10.8.0.3')
|
|
@@ -165,6 +196,7 @@ describe('vpnClientAdd', () => {
|
|
|
165
196
|
endpoint: '1.2.3.4',
|
|
166
197
|
keypairGen,
|
|
167
198
|
allowedIps: '10.0.0.0/8,192.168.0.0/16',
|
|
199
|
+
spawnFn: makeNoopSpawnFn(),
|
|
168
200
|
})
|
|
169
201
|
|
|
170
202
|
const confContent = await readFile(result.confPath, 'utf-8')
|
|
@@ -180,6 +212,7 @@ describe('vpnClientAdd', () => {
|
|
|
180
212
|
serverPublicKey: 'SERVERPUBKEY==',
|
|
181
213
|
endpoint: '1.2.3.4',
|
|
182
214
|
keypairGen,
|
|
215
|
+
spawnFn: makeNoopSpawnFn(),
|
|
183
216
|
})
|
|
184
217
|
|
|
185
218
|
await expect(
|
|
@@ -189,6 +222,7 @@ describe('vpnClientAdd', () => {
|
|
|
189
222
|
serverPublicKey: 'SERVERPUBKEY==',
|
|
190
223
|
endpoint: '1.2.3.4',
|
|
191
224
|
keypairGen,
|
|
225
|
+
spawnFn: makeNoopSpawnFn(),
|
|
192
226
|
}),
|
|
193
227
|
).rejects.toThrow('already exists')
|
|
194
228
|
})
|
|
@@ -204,6 +238,7 @@ describe('vpnClientAdd', () => {
|
|
|
204
238
|
serverPublicKey: 'SERVERPUBKEY==',
|
|
205
239
|
endpoint: '1.2.3.4',
|
|
206
240
|
keypairGen,
|
|
241
|
+
spawnFn: makeNoopSpawnFn(),
|
|
207
242
|
}),
|
|
208
243
|
).rejects.toThrow()
|
|
209
244
|
})
|
|
@@ -217,6 +252,7 @@ describe('vpnClientAdd', () => {
|
|
|
217
252
|
serverPublicKey: 'SERVERPUBKEY==',
|
|
218
253
|
endpoint: '1.2.3.4',
|
|
219
254
|
keypairGen,
|
|
255
|
+
spawnFn: makeNoopSpawnFn(),
|
|
220
256
|
})
|
|
221
257
|
|
|
222
258
|
// peers.json must NOT contain the private key
|
|
@@ -228,6 +264,76 @@ describe('vpnClientAdd', () => {
|
|
|
228
264
|
const confContent = await readFile(result.confPath, 'utf-8')
|
|
229
265
|
expect(confContent).toContain('SECRETPRIVATEKEY==')
|
|
230
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
|
+
})
|
|
231
337
|
})
|
|
232
338
|
|
|
233
339
|
// ─── vpnClientList ────────────────────────────────────────────────────────────
|
|
@@ -270,6 +376,13 @@ describe('vpnClientList', () => {
|
|
|
270
376
|
expect(peers[0]?.tunnelIp).toBe('10.8.0.2')
|
|
271
377
|
expect(peers[1]?.name).toBe('phone')
|
|
272
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
|
+
})
|
|
273
386
|
})
|
|
274
387
|
|
|
275
388
|
// ─── vpnClientRemove ──────────────────────────────────────────────────────────
|
|
@@ -296,7 +409,7 @@ describe('vpnClientRemove', () => {
|
|
|
296
409
|
]),
|
|
297
410
|
)
|
|
298
411
|
|
|
299
|
-
await vpnClientRemove('laptop', peersJsonPath)
|
|
412
|
+
await vpnClientRemove('laptop', peersJsonPath, {spawnFn: makeNoopSpawnFn()})
|
|
300
413
|
|
|
301
414
|
const peersContent = await readFile(peersJsonPath, 'utf-8')
|
|
302
415
|
const peersData = JSON.parse(peersContent) as {peers: {name: string}[]}
|
|
@@ -307,7 +420,9 @@ describe('vpnClientRemove', () => {
|
|
|
307
420
|
it('throws when removing a non-existent peer', async () => {
|
|
308
421
|
writeFileSync(peersJsonPath, makePeersJson([]))
|
|
309
422
|
|
|
310
|
-
await expect(vpnClientRemove('nonexistent', peersJsonPath)).rejects.toThrow(
|
|
423
|
+
await expect(vpnClientRemove('nonexistent', peersJsonPath, {spawnFn: makeNoopSpawnFn()})).rejects.toThrow(
|
|
424
|
+
'not found',
|
|
425
|
+
)
|
|
311
426
|
})
|
|
312
427
|
|
|
313
428
|
it('after remove, list reflects the removal', async () => {
|
|
@@ -319,12 +434,428 @@ describe('vpnClientRemove', () => {
|
|
|
319
434
|
]),
|
|
320
435
|
)
|
|
321
436
|
|
|
322
|
-
await vpnClientRemove('laptop', peersJsonPath)
|
|
437
|
+
await vpnClientRemove('laptop', peersJsonPath, {spawnFn: makeNoopSpawnFn()})
|
|
323
438
|
const peers = await vpnClientList(peersJsonPath)
|
|
324
439
|
|
|
325
440
|
expect(peers).toHaveLength(1)
|
|
326
441
|
expect(peers[0]?.name).toBe('phone')
|
|
327
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
|
+
})
|
|
328
859
|
})
|
|
329
860
|
|
|
330
861
|
// ─── Write-guard: client .conf must stay in clients/ ─────────────────────────
|
|
@@ -355,6 +886,7 @@ describe('write-guard: client .conf path safety', () => {
|
|
|
355
886
|
serverPublicKey: 'SERVERPUBKEY==',
|
|
356
887
|
endpoint: '1.2.3.4',
|
|
357
888
|
keypairGen,
|
|
889
|
+
spawnFn: makeNoopSpawnFn(),
|
|
358
890
|
})
|
|
359
891
|
|
|
360
892
|
// The resolved path must be under clientsDir
|
|
@@ -373,7 +905,45 @@ describe('write-guard: client .conf path safety', () => {
|
|
|
373
905
|
serverPublicKey: 'SERVERPUBKEY==',
|
|
374
906
|
endpoint: '1.2.3.4',
|
|
375
907
|
keypairGen,
|
|
908
|
+
spawnFn: makeNoopSpawnFn(),
|
|
376
909
|
}),
|
|
377
910
|
).rejects.toThrow()
|
|
378
911
|
})
|
|
379
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
|
+
})
|