@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('not found')
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
+ })