@marcusrbrown/infra 0.4.9 → 0.4.11
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/package.json +2 -2
- package/src/__snapshots__/cli.test.ts.snap +32 -1
- package/src/cli.ts +2 -0
- package/src/commands/gateway/backup.test.ts +288 -0
- package/src/commands/gateway/backup.ts +188 -0
- package/src/commands/gateway/deploy.test.ts +297 -0
- package/src/commands/gateway/deploy.ts +148 -0
- package/src/commands/gateway/host.test.ts +73 -0
- package/src/commands/gateway/host.ts +31 -0
- package/src/commands/gateway/index.ts +17 -0
- package/src/commands/gateway/logs.test.ts +222 -0
- package/src/commands/gateway/logs.ts +158 -0
- package/src/commands/gateway/restore.test.ts +494 -0
- package/src/commands/gateway/restore.ts +297 -0
- package/src/commands/gateway/status.test.ts +354 -0
- package/src/commands/gateway/status.ts +226 -0
- package/src/commands/status.test.ts +25 -0
- package/src/commands/status.ts +13 -3
|
@@ -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
|
+
})
|