Skip to content

Commit b4d2423

Browse files
Yerazeclaude
andauthored
fix(backupRoutes): async backup existence check + headersSent guards (#3524) (#3529)
Harden the system-backup download handler (carried verbatim from server.ts in #3523, now isolated and easy to fix): - Replace the synchronous fs.existsSync (which blocked the event loop in the request path) with an async fsp.access check via a static fs/promises import. - Guard both the archiver 'error' handler and the catch block with res.headersSent — the archive can error / finalize() can throw after archive.pipe(res) has begun streaming, which previously threw 'Cannot set headers after they are sent'. When already streaming, terminate via res.end(). Tests: add a 404 case (missing backup → async access rejects) and a happy-path streaming case (real temp dir → real archiver → 200 tar.gz + audit log). Closes #3524. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d741b92 commit b4d2423

2 files changed

Lines changed: 53 additions & 9 deletions

File tree

src/server/routes/backupRoutes.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest';
22
import request from 'supertest';
33
import express from 'express';
4+
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
5+
import { tmpdir } from 'node:os';
6+
import { join } from 'node:path';
47

58
const mockBackupFileService = vi.hoisted(() => ({
69
listBackups: vi.fn(),
@@ -171,6 +174,33 @@ describe('backupRoutes - system backups', () => {
171174
expect(res.status).toBe(400);
172175
});
173176

177+
it('GET /system/backup/download returns 404 when the backup is missing (async fs check, #3524)', async () => {
178+
// Points at a path that doesn't exist → the new async fsp.access rejects → 404.
179+
mockSystemBackupService.getBackupPath.mockReturnValue(
180+
join(tmpdir(), 'mm-3524-absent', '2099-01-01_000000')
181+
);
182+
const res = await request(app).get('/system/backup/download/2099-01-01_000000');
183+
expect(res.status).toBe(404);
184+
expect(res.body.error).toBe('Backup not found');
185+
});
186+
187+
it('GET /system/backup/download streams a tar.gz for an existing backup (#3524 happy path)', async () => {
188+
const dir = await mkdtemp(join(tmpdir(), 'mm-3524-backup-'));
189+
await writeFile(join(dir, 'meshmonitor.db'), 'fake-sqlite-bytes');
190+
mockSystemBackupService.getBackupPath.mockReturnValue(dir);
191+
try {
192+
const res = await request(app).get('/system/backup/download/2099-01-01_000000');
193+
expect(res.status).toBe(200);
194+
expect(res.headers['content-type']).toContain('gzip');
195+
expect(res.headers['content-disposition']).toContain('2099-01-01_000000.tar.gz');
196+
expect(databaseService.auditLogAsync).toHaveBeenCalledWith(
197+
1, 'system_backup_downloaded', 'system_backup', expect.any(String), expect.anything()
198+
);
199+
} finally {
200+
await rm(dir, { recursive: true, force: true });
201+
}
202+
});
203+
174204
it('DELETE /system/backup/delete rejects invalid dirname', async () => {
175205
const res = await request(app).delete('/system/backup/delete/not-a-date');
176206
expect(res.status).toBe(400);

src/server/routes/backupRoutes.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Router, Request, Response } from 'express';
2+
import { promises as fsp } from 'fs';
23
import { requirePermission } from '../auth/authMiddleware.js';
34
import databaseService from '../../services/database.js';
45
import { backupFileService } from '../services/backupFileService.js';
@@ -198,10 +199,11 @@ systemBackupRouter.get('/download/:dirname', requirePermission('configuration',
198199
const { TarArchive } = (await import('archiver')) as unknown as {
199200
TarArchive: new (opts: import('archiver').ArchiverOptions) => import('archiver').Archiver;
200201
};
201-
const fs = await import('fs');
202-
203-
// Check if backup exists
204-
if (!fs.existsSync(backupPath)) {
202+
// Check the backup exists — async so we don't block the event loop with
203+
// synchronous fs in the request path (#3524).
204+
try {
205+
await fsp.access(backupPath);
206+
} catch {
205207
return res.status(404).json({ error: 'Backup not found' });
206208
}
207209

@@ -216,7 +218,14 @@ systemBackupRouter.get('/download/:dirname', requirePermission('configuration',
216218

217219
archive.on('error', err => {
218220
logger.error('❌ Error creating archive:', err);
219-
res.status(500).json({ error: 'Failed to create archive' });
221+
// The archive can error mid-stream — after archive.pipe(res) has already
222+
// sent headers — so guard against "Cannot set headers after they are
223+
// sent" (#3524). If we're already streaming, just terminate the response.
224+
if (!res.headersSent) {
225+
res.status(500).json({ error: 'Failed to create archive' });
226+
} else {
227+
res.end();
228+
}
220229
});
221230

222231
// Audit log before streaming
@@ -235,10 +244,15 @@ systemBackupRouter.get('/download/:dirname', requirePermission('configuration',
235244
logger.info(`📥 System backup downloaded: ${dirname}`);
236245
} catch (error) {
237246
logger.error('❌ Error downloading system backup:', error);
238-
res.status(500).json({
239-
error: 'Failed to download system backup',
240-
details: error instanceof Error ? error.message : 'Unknown error',
241-
});
247+
// finalize() can throw after streaming began (headers sent) — same guard.
248+
if (!res.headersSent) {
249+
res.status(500).json({
250+
error: 'Failed to download system backup',
251+
details: error instanceof Error ? error.message : 'Unknown error',
252+
});
253+
} else {
254+
res.end();
255+
}
242256
}
243257
});
244258

0 commit comments

Comments
 (0)