@marcusrbrown/infra 0.4.9 → 0.4.10

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.
@@ -0,0 +1,494 @@
1
+ import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
2
+
3
+ import {restoreGatewayCa, validateBackupArchive, type RestoreSpawnFn} from './restore'
4
+
5
+ // ─── SpawnFn helpers ──────────────────────────────────────────────────────────
6
+
7
+ /**
8
+ * Creates a multi-step spawn mock. Each call to the returned function pops the
9
+ * next response from the queue. Throws if called more times than expected.
10
+ */
11
+ function makeSpawnSequence(responses: {stdout: string; stderr: string; exitCode: number}[]): RestoreSpawnFn {
12
+ const queue = [...responses]
13
+ return (_cmd, _opts) => {
14
+ const next = queue.shift()
15
+ if (!next) {
16
+ throw new Error('Unexpected spawn call — queue exhausted')
17
+ }
18
+ const encoder = new TextEncoder()
19
+ return {
20
+ stdout: new ReadableStream({
21
+ start(controller) {
22
+ if (next.stdout) controller.enqueue(encoder.encode(next.stdout))
23
+ controller.close()
24
+ },
25
+ }),
26
+ stderr: new ReadableStream({
27
+ start(controller) {
28
+ if (next.stderr) controller.enqueue(encoder.encode(next.stderr))
29
+ controller.close()
30
+ },
31
+ }),
32
+ exited: Promise.resolve(next.exitCode),
33
+ }
34
+ }
35
+ }
36
+
37
+ // ─── Fixture helpers ──────────────────────────────────────────────────────────
38
+
39
+ /**
40
+ * Creates a minimal non-empty tar file at the given path and returns the path.
41
+ * Content is arbitrary bytes — tests only care that the file is non-empty.
42
+ */
43
+ async function writeFakeTar(path: string): Promise<string> {
44
+ await Bun.write(path, new Uint8Array([0x75, 0x73, 0x74, 0x61, 0x72])) // "ustar" magic
45
+ return path
46
+ }
47
+
48
+ // ─── restoreGatewayCa — happy path ───────────────────────────────────────────
49
+
50
+ describe('restoreGatewayCa — happy path', () => {
51
+ let tmpInput: string
52
+
53
+ beforeEach(async () => {
54
+ tmpInput = `/tmp/test-restore-input-${Date.now()}.tar`
55
+ await writeFakeTar(tmpInput)
56
+ })
57
+
58
+ afterEach(() => {
59
+ Bun.spawnSync(['rm', '-f', tmpInput])
60
+ })
61
+
62
+ it('succeeds when SCP + docker copy + restart + confirmation all pass', async () => {
63
+ // Build a real tar with the two CA files so local extraction works
64
+ const certContent = '-----BEGIN CERTIFICATE-----\nFAKECERT\n-----END CERTIFICATE-----\n'
65
+ const keyContent = '-----BEGIN PRIVATE KEY-----\nFAKEKEY\n-----END PRIVATE KEY-----\n'
66
+
67
+ const tarDir = `/tmp/test-tar-dir-${Date.now()}`
68
+ const realTar = `/tmp/test-real-tar-${Date.now()}.tar`
69
+ Bun.spawnSync(['mkdir', '-p', tarDir])
70
+ await Bun.write(`${tarDir}/mitmproxy-ca-cert.pem`, certContent)
71
+ await Bun.write(`${tarDir}/mitmproxy-ca.pem`, keyContent)
72
+ Bun.spawnSync(['tar', '-cf', realTar, '-C', tarDir, 'mitmproxy-ca-cert.pem', 'mitmproxy-ca.pem'])
73
+
74
+ try {
75
+ // Sequence: tar -tf (validate), mktemp (ssh), scp, chmod, docker run (extract),
76
+ // docker compose restart, docker run read cert, docker run read key, rm cleanup
77
+ const spawn = makeSpawnSequence([
78
+ {stdout: 'mitmproxy-ca-cert.pem\nmitmproxy-ca.pem\n', stderr: '', exitCode: 0}, // tar -tf validate
79
+ {stdout: '/tmp/gateway-ca-restore-abc123.tar\n', stderr: '', exitCode: 0}, // mktemp ssh
80
+ {stdout: '', stderr: '', exitCode: 0}, // scp
81
+ {stdout: '', stderr: '', exitCode: 0}, // chmod 600
82
+ {stdout: '', stderr: '', exitCode: 0}, // docker run extract
83
+ {stdout: '', stderr: '', exitCode: 0}, // docker compose restart
84
+ {stdout: certContent, stderr: '', exitCode: 0}, // docker run read cert
85
+ {stdout: keyContent, stderr: '', exitCode: 0}, // docker run read key
86
+ {stdout: '', stderr: '', exitCode: 0}, // rm cleanup
87
+ ])
88
+
89
+ const result = await restoreGatewayCa({host: 'gateway.example.com', input: realTar, includeCa: true}, spawn)
90
+
91
+ expect(result.ok).toBe(true)
92
+ if (result.ok) {
93
+ expect(result.confirmed).toBe(true)
94
+ }
95
+ } finally {
96
+ Bun.spawnSync(['rm', '-rf', tarDir, realTar])
97
+ }
98
+ })
99
+
100
+ it('deletes the tmp file from the droplet on success (cleanup)', async () => {
101
+ const deletedPaths: string[] = []
102
+
103
+ // Track which commands are called to verify cleanup
104
+ const spawnCapture: RestoreSpawnFn = (cmd, _opts) => {
105
+ const cmdStr = cmd.join(' ')
106
+ // The rm -f is issued as an SSH command: ['ssh', ..., 'rm -f <path>']
107
+ // The last element of the ssh command array is the remote command string
108
+ if (cmdStr.includes('rm -f')) {
109
+ const remoteCmd = cmd.at(-1) ?? ''
110
+ const match = remoteCmd.match(/rm -f (.+)/)
111
+ if (match?.[1]) deletedPaths.push(match[1])
112
+ }
113
+
114
+ const encoder = new TextEncoder()
115
+ const isTarTf = cmd[0] === 'tar' && cmd.includes('-tf')
116
+ const isMktemp = cmdStr.includes('mktemp')
117
+ return {
118
+ stdout: new ReadableStream({
119
+ start(controller) {
120
+ if (isTarTf) controller.enqueue(encoder.encode('mitmproxy-ca-cert.pem\nmitmproxy-ca.pem\n'))
121
+ else if (isMktemp) controller.enqueue(encoder.encode('/tmp/gateway-ca-restore-cleanup-test.tar\n'))
122
+ controller.close()
123
+ },
124
+ }),
125
+ stderr: new ReadableStream({
126
+ start(controller) {
127
+ controller.close()
128
+ },
129
+ }),
130
+ exited: Promise.resolve(0),
131
+ }
132
+ }
133
+
134
+ await restoreGatewayCa({host: 'gateway.example.com', input: tmpInput, includeCa: true}, spawnCapture)
135
+
136
+ // At least one rm -f was issued for the tmp file
137
+ expect(deletedPaths.length).toBeGreaterThan(0)
138
+ })
139
+ })
140
+
141
+ describe('restoreGatewayCa — tmp cleanup on failure', () => {
142
+ let tmpInput: string
143
+
144
+ beforeEach(async () => {
145
+ tmpInput = `/tmp/test-restore-fail-${Date.now()}.tar`
146
+ await writeFakeTar(tmpInput)
147
+ })
148
+
149
+ afterEach(() => {
150
+ Bun.spawnSync(['rm', '-f', tmpInput])
151
+ })
152
+
153
+ it('deletes the tmp file from the droplet even when docker run fails', async () => {
154
+ const deletedPaths: string[] = []
155
+
156
+ const spawnCapture: RestoreSpawnFn = (cmd, _opts) => {
157
+ const cmdStr = cmd.join(' ')
158
+ // rm -f is issued as SSH remote command string
159
+ if (cmdStr.includes('rm -f')) {
160
+ const remoteCmd = cmd.at(-1) ?? ''
161
+ const match = remoteCmd.match(/rm -f (.+)/)
162
+ if (match?.[1]) deletedPaths.push(match[1])
163
+ }
164
+
165
+ const encoder = new TextEncoder()
166
+ const isTarTf = cmd[0] === 'tar' && cmd.includes('-tf')
167
+ const isMktemp = cmdStr.includes('mktemp')
168
+ const isDockerRun = cmdStr.includes('docker run') && cmdStr.includes('tar -xf')
169
+ return {
170
+ stdout: new ReadableStream({
171
+ start(controller) {
172
+ if (isTarTf) controller.enqueue(encoder.encode('mitmproxy-ca-cert.pem\nmitmproxy-ca.pem\n'))
173
+ else if (isMktemp) controller.enqueue(encoder.encode('/tmp/gateway-ca-restore-fail-test.tar\n'))
174
+ controller.close()
175
+ },
176
+ }),
177
+ stderr: new ReadableStream({
178
+ start(controller) {
179
+ if (isDockerRun) controller.enqueue(encoder.encode('tar: error'))
180
+ controller.close()
181
+ },
182
+ }),
183
+ exited: Promise.resolve(isDockerRun ? 1 : 0),
184
+ }
185
+ }
186
+
187
+ const result = await restoreGatewayCa({host: 'gateway.example.com', input: tmpInput, includeCa: true}, spawnCapture)
188
+
189
+ expect(result.ok).toBe(false)
190
+ expect(deletedPaths.length).toBeGreaterThan(0)
191
+ })
192
+ })
193
+
194
+ describe('restoreGatewayCa — error paths', () => {
195
+ it('exits before SSH when --input points at an empty file', async () => {
196
+ const emptyPath = `/tmp/test-empty-${Date.now()}.tar`
197
+ await Bun.write(emptyPath, new Uint8Array(0))
198
+
199
+ try {
200
+ const neverSpawn: RestoreSpawnFn = () => {
201
+ throw new Error('spawn must not be called for empty input')
202
+ }
203
+
204
+ const result = await restoreGatewayCa(
205
+ {host: 'gateway.example.com', input: emptyPath, includeCa: true},
206
+ neverSpawn,
207
+ )
208
+
209
+ expect(result.ok).toBe(false)
210
+ if (!result.ok) {
211
+ expect(result.error).toMatch(/empty/i)
212
+ }
213
+ } finally {
214
+ Bun.spawnSync(['rm', '-f', emptyPath])
215
+ }
216
+ })
217
+
218
+ it('rejects a malicious host and does not invoke ssh', async () => {
219
+ const tmpInput2 = `/tmp/test-restore-sec-${Date.now()}.tar`
220
+ await writeFakeTar(tmpInput2)
221
+
222
+ try {
223
+ const neverSpawn: RestoreSpawnFn = () => {
224
+ throw new Error('spawn must not be called for an invalid host')
225
+ }
226
+
227
+ await expect(
228
+ restoreGatewayCa({host: '-oProxyCommand=touch /tmp/sec5-pwned', input: tmpInput2, includeCa: true}, neverSpawn),
229
+ ).rejects.toThrow('Invalid GATEWAY_HOST')
230
+ } finally {
231
+ Bun.spawnSync(['rm', '-f', tmpInput2])
232
+ }
233
+ })
234
+
235
+ it('exits non-zero with mismatch diagnostic when confirmation fails', async () => {
236
+ const tmpInput3 = `/tmp/test-restore-mismatch-${Date.now()}.tar`
237
+ await writeFakeTar(tmpInput3)
238
+
239
+ try {
240
+ // Sequence: tar -tf validate, mktemp, scp, chmod, docker run extract, restart,
241
+ // cert read returns WRONG content, key read, rm cleanup
242
+ const spawn = makeSpawnSequence([
243
+ {stdout: 'mitmproxy-ca-cert.pem\nmitmproxy-ca.pem\n', stderr: '', exitCode: 0}, // tar -tf validate
244
+ {stdout: '/tmp/gateway-ca-restore-abc123.tar\n', stderr: '', exitCode: 0}, // mktemp
245
+ {stdout: '', stderr: '', exitCode: 0}, // scp
246
+ {stdout: '', stderr: '', exitCode: 0}, // chmod 600
247
+ {stdout: '', stderr: '', exitCode: 0}, // docker run extract
248
+ {stdout: '', stderr: '', exitCode: 0}, // docker compose restart
249
+ {stdout: 'WRONG_CERT', stderr: '', exitCode: 0}, // docker exec cert — mismatch
250
+ {stdout: 'WRONG_KEY', stderr: '', exitCode: 0}, // docker exec key — mismatch
251
+ {stdout: '', stderr: '', exitCode: 0}, // rm cleanup
252
+ ])
253
+
254
+ const result = await restoreGatewayCa({host: 'gateway.example.com', input: tmpInput3, includeCa: true}, spawn)
255
+
256
+ expect(result.ok).toBe(false)
257
+ if (!result.ok) {
258
+ expect(result.error).toMatch(/mismatch/i)
259
+ }
260
+ } finally {
261
+ Bun.spawnSync(['rm', '-f', tmpInput3])
262
+ }
263
+ })
264
+ })
265
+
266
+ describe('restoreGatewayCa — edge case: --no-include-ca', () => {
267
+ it('refuses with a clear message when includeCa is false', async () => {
268
+ const tmpInput4 = `/tmp/test-restore-noca-${Date.now()}.tar`
269
+ await writeFakeTar(tmpInput4)
270
+
271
+ try {
272
+ const result = await restoreGatewayCa({host: 'gateway.example.com', input: tmpInput4, includeCa: false})
273
+
274
+ expect(result.ok).toBe(false)
275
+ if (!result.ok) {
276
+ expect(result.error).toMatch(/only CA restore/i)
277
+ }
278
+ } finally {
279
+ Bun.spawnSync(['rm', '-f', tmpInput4])
280
+ }
281
+ })
282
+ })
283
+
284
+ // ─── COR3: Cleanup failure preserves primary error ────────────────────────────
285
+
286
+ describe('restoreGatewayCa — cleanup failure preserves primary error (COR3)', () => {
287
+ it('rejects with the docker failure error when both docker run and rm cleanup fail', async () => {
288
+ const tmpInput5 = `/tmp/test-restore-cor3-${Date.now()}.tar`
289
+ await writeFakeTar(tmpInput5)
290
+
291
+ try {
292
+ const encoder = new TextEncoder()
293
+ const spawnBothFail: RestoreSpawnFn = (cmd, _opts) => {
294
+ const cmdStr = cmd.join(' ')
295
+ const isTarTf = cmd[0] === 'tar' && cmd.includes('-tf')
296
+ const isMktemp = cmdStr.includes('mktemp')
297
+ const isDockerExtract = cmdStr.includes('docker run') && cmdStr.includes('tar -xf')
298
+ const isRmCleanup = cmdStr.includes('rm -f')
299
+
300
+ // tar -tf and mktemp succeed; scp succeeds; docker extract fails; rm cleanup also fails
301
+ const exitCode = isDockerExtract || isRmCleanup ? 1 : 0
302
+ const stderrMsg = isDockerExtract ? 'docker: fatal error' : isRmCleanup ? 'rm: permission denied' : ''
303
+
304
+ return {
305
+ stdout: new ReadableStream({
306
+ start(controller) {
307
+ if (isTarTf) controller.enqueue(encoder.encode('mitmproxy-ca-cert.pem\nmitmproxy-ca.pem\n'))
308
+ else if (isMktemp) controller.enqueue(encoder.encode('/tmp/gateway-ca-restore-cor3-test.tar\n'))
309
+ controller.close()
310
+ },
311
+ }),
312
+ stderr: new ReadableStream({
313
+ start(controller) {
314
+ if (stderrMsg) controller.enqueue(encoder.encode(stderrMsg))
315
+ controller.close()
316
+ },
317
+ }),
318
+ exited: Promise.resolve(exitCode),
319
+ }
320
+ }
321
+
322
+ const result = await restoreGatewayCa(
323
+ {host: 'gateway.example.com', input: tmpInput5, includeCa: true},
324
+ spawnBothFail,
325
+ )
326
+
327
+ // Must reflect the docker failure, not the rm failure
328
+ expect(result.ok).toBe(false)
329
+ if (!result.ok) {
330
+ expect(result.error).toContain('docker run extract failed')
331
+ expect(result.error).not.toContain('rm: permission denied')
332
+ }
333
+ } finally {
334
+ Bun.spawnSync(['rm', '-f', tmpInput5])
335
+ }
336
+ })
337
+ })
338
+
339
+ // ─── SEC2: mktemp-based unguessable remote path ───────────────────────────────
340
+
341
+ describe('restoreGatewayCa — SEC2: unguessable remote tmp path via mktemp', () => {
342
+ it('uses the mktemp-returned path as the SCP destination and for chmod before docker run', async () => {
343
+ const tarDir = `/tmp/test-sec2-tar-dir-${Date.now()}`
344
+ const realTar = `/tmp/test-sec2-tar-${Date.now()}.tar`
345
+ const certContent = 'CERT'
346
+ const keyContent = 'KEY'
347
+ Bun.spawnSync(['mkdir', '-p', tarDir])
348
+ await Bun.write(`${tarDir}/mitmproxy-ca-cert.pem`, certContent)
349
+ await Bun.write(`${tarDir}/mitmproxy-ca.pem`, keyContent)
350
+ Bun.spawnSync(['tar', '-cf', realTar, '-C', tarDir, 'mitmproxy-ca-cert.pem', 'mitmproxy-ca.pem'])
351
+
352
+ const capturedCmds: string[][] = []
353
+ const unguessablePath = '/tmp/gateway-ca-restore-X7k9mQ.tar'
354
+
355
+ const spawnCapture: RestoreSpawnFn = (cmd, _opts) => {
356
+ capturedCmds.push([...cmd])
357
+ const cmdStr = cmd.join(' ')
358
+ const isTarTf = cmd[0] === 'tar' && cmd.includes('-tf')
359
+ const isMktemp = cmdStr.includes('mktemp')
360
+ let stdout: string
361
+ if (isTarTf) {
362
+ stdout = 'mitmproxy-ca-cert.pem\nmitmproxy-ca.pem\n'
363
+ } else if (isMktemp) {
364
+ stdout = `${unguessablePath}\n`
365
+ } else {
366
+ stdout = certContent
367
+ }
368
+ const encoder = new TextEncoder()
369
+ return {
370
+ stdout: new ReadableStream({
371
+ start(controller) {
372
+ controller.enqueue(encoder.encode(stdout))
373
+ controller.close()
374
+ },
375
+ }),
376
+ stderr: new ReadableStream({
377
+ start(controller) {
378
+ controller.close()
379
+ },
380
+ }),
381
+ exited: Promise.resolve(0),
382
+ }
383
+ }
384
+
385
+ await restoreGatewayCa({host: 'gateway.example.com', input: realTar, includeCa: true}, spawnCapture)
386
+
387
+ // SCP destination must use the unguessable path
388
+ const scpCmd = capturedCmds.find(c => c[0] === 'scp')
389
+ expect(scpCmd).toBeDefined()
390
+ expect(scpCmd?.at(-1)).toBe(`root@gateway.example.com:${unguessablePath}`)
391
+
392
+ // chmod 600 must be called on the unguessable path before docker run
393
+ const chmodIdx = capturedCmds.findIndex(c => c.join(' ').includes('chmod 600'))
394
+ const dockerRunIdx = capturedCmds.findIndex(
395
+ c => c.join(' ').includes('docker run') && c.join(' ').includes('tar -xf'),
396
+ )
397
+ expect(chmodIdx).toBeGreaterThan(-1)
398
+ expect(dockerRunIdx).toBeGreaterThan(-1)
399
+ expect(chmodIdx).toBeLessThan(dockerRunIdx)
400
+ expect(capturedCmds[chmodIdx]?.join(' ')).toContain(unguessablePath)
401
+
402
+ Bun.spawnSync(['rm', '-rf', tarDir, realTar])
403
+ })
404
+ })
405
+
406
+ // ─── COR1: validateBackupArchive ─────────────────────────────────────────────
407
+
408
+ describe('validateBackupArchive — COR1', () => {
409
+ const env = {PATH: '/usr/bin:/bin', HOME: '/tmp'}
410
+
411
+ it('returns undefined for a valid archive with exactly the two expected files', async () => {
412
+ const tarDir = `/tmp/test-cor1-valid-${Date.now()}`
413
+ const tarPath = `/tmp/test-cor1-valid-${Date.now()}.tar`
414
+ Bun.spawnSync(['mkdir', '-p', tarDir])
415
+ await Bun.write(`${tarDir}/mitmproxy-ca-cert.pem`, 'cert')
416
+ await Bun.write(`${tarDir}/mitmproxy-ca.pem`, 'key')
417
+ Bun.spawnSync(['tar', '-cf', tarPath, '-C', tarDir, 'mitmproxy-ca-cert.pem', 'mitmproxy-ca.pem'])
418
+
419
+ try {
420
+ const spawn = makeSpawnSequence([{stdout: 'mitmproxy-ca-cert.pem\nmitmproxy-ca.pem\n', stderr: '', exitCode: 0}])
421
+ const result = await validateBackupArchive(tarPath, spawn, env)
422
+ expect(result).toBeUndefined()
423
+ } finally {
424
+ Bun.spawnSync(['rm', '-rf', tarDir, tarPath])
425
+ }
426
+ })
427
+
428
+ it('returns an error when only the cert is present (missing key)', async () => {
429
+ const spawn = makeSpawnSequence([{stdout: 'mitmproxy-ca-cert.pem\n', stderr: '', exitCode: 0}])
430
+ const result = await validateBackupArchive('/fake/path.tar', spawn, env)
431
+ expect(result).toBeDefined()
432
+ expect(result).toMatch(/malformed/i)
433
+ expect(result).toContain('mitmproxy-ca.pem')
434
+ })
435
+
436
+ it('returns an error when extra files are present alongside the two expected files', async () => {
437
+ const spawn = makeSpawnSequence([
438
+ {stdout: 'mitmproxy-ca-cert.pem\nmitmproxy-ca.pem\nextra-file.txt\n', stderr: '', exitCode: 0},
439
+ ])
440
+ const result = await validateBackupArchive('/fake/path.tar', spawn, env)
441
+ expect(result).toBeDefined()
442
+ expect(result).toMatch(/malformed/i)
443
+ expect(result).toContain('extra-file.txt')
444
+ })
445
+
446
+ it('returns an error when filenames differ from expected (cert.pem, key.pem)', async () => {
447
+ const spawn = makeSpawnSequence([{stdout: 'cert.pem\nkey.pem\n', stderr: '', exitCode: 0}])
448
+ const result = await validateBackupArchive('/fake/path.tar', spawn, env)
449
+ expect(result).toBeDefined()
450
+ expect(result).toMatch(/malformed/i)
451
+ })
452
+
453
+ it('returns an error and does not proceed to SCP when archive is malformed', async () => {
454
+ const tmpInput = `/tmp/test-cor1-malformed-${Date.now()}.tar`
455
+ await writeFakeTar(tmpInput)
456
+
457
+ try {
458
+ let scpCalled = false
459
+ const spawnCapture: RestoreSpawnFn = (cmd, _opts) => {
460
+ if (cmd[0] === 'scp') scpCalled = true
461
+ const isTarTf = cmd[0] === 'tar' && cmd.includes('-tf')
462
+ const encoder = new TextEncoder()
463
+ return {
464
+ stdout: new ReadableStream({
465
+ start(controller) {
466
+ // Only cert, no key — malformed
467
+ if (isTarTf) controller.enqueue(encoder.encode('mitmproxy-ca-cert.pem\n'))
468
+ controller.close()
469
+ },
470
+ }),
471
+ stderr: new ReadableStream({
472
+ start(controller) {
473
+ controller.close()
474
+ },
475
+ }),
476
+ exited: Promise.resolve(0),
477
+ }
478
+ }
479
+
480
+ const result = await restoreGatewayCa(
481
+ {host: 'gateway.example.com', input: tmpInput, includeCa: true},
482
+ spawnCapture,
483
+ )
484
+
485
+ expect(result.ok).toBe(false)
486
+ if (!result.ok) {
487
+ expect(result.error).toMatch(/malformed/i)
488
+ }
489
+ expect(scpCalled).toBe(false)
490
+ } finally {
491
+ Bun.spawnSync(['rm', '-f', tmpInput])
492
+ }
493
+ })
494
+ })