diff --git a/backend/config/contentDisposition.js b/backend/config/contentDisposition.js new file mode 100644 index 0000000..a1c59f8 --- /dev/null +++ b/backend/config/contentDisposition.js @@ -0,0 +1,16 @@ +export function toSafeFileName(fileName) { + const s = String(fileName ?? '') + // eslint-disable-next-line no-control-regex + .replace(/[\u0000-\u001F\u007F]/g, '') + .replace(/[<>:"/\\|?*]/g, '_') + .replace(/\s+/g, ' ') + .trim(); + + return s; +} + +export function contentDisposition(filename) { + const fallback = filename.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, "'"); + const encoded = encodeURIComponent(filename); + return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`; +} diff --git a/backend/routes/cases/download.js b/backend/routes/cases/download.js index 3714968..53f6c6a 100644 --- a/backend/routes/cases/download.js +++ b/backend/routes/cases/download.js @@ -7,6 +7,7 @@ import defineStep from '../../models/steps.js'; import defineFolder from '../../models/folders.js'; import authMiddleware from '../../middleware/auth.js'; import visibilityMiddleware from '../../middleware/verifyVisible.js'; +import { contentDisposition, toSafeFileName } from '../../config/contentDisposition.js'; import { testRunStatus, priorities, testTypes, automationStatus, templates } from '../../config/enums.js'; export default function (sequelize) { @@ -31,6 +32,16 @@ export default function (sequelize) { } try { + const folder = await Folder.findByPk(folderId); + if (!folder) { + return res.status(404).send('Folder not found'); + } + + const folderName = toSafeFileName(folder.name); + const filename = `${folderName}.${type}`; + res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition'); + res.setHeader('Content-Disposition', contentDisposition(filename)); + const cases = await Case.findAll({ attributes: { exclude: ['createdAt', 'updatedAt', 'caseSteps'] }, include: [ @@ -74,7 +85,6 @@ export default function (sequelize) { }); res.setHeader('Content-Type', 'text/csv; charset=utf-8'); - res.setHeader('Content-Disposition', `attachment; filename=cases_folder_${folderId}.csv`); return res.send(csv); } diff --git a/backend/routes/cases/download.test.js b/backend/routes/cases/download.test.js index e8a1b56..06f638e 100644 --- a/backend/routes/cases/download.test.js +++ b/backend/routes/cases/download.test.js @@ -34,7 +34,13 @@ vi.mock('../../middleware/verifyVisible.js', () => ({ }), })); -// mock defineCase +const mockFolder = { + findByPk: vi.fn(), +}; +vi.mock('../../models/folders.js', () => ({ + default: () => mockFolder, +})); + const mockCase = { findAll: vi.fn(), belongsToMany: vi.fn(), @@ -66,6 +72,8 @@ describe('GET /download with type=csv', () => { }); it('should return CSV with human-readable labels for state, priority, type, automationStatus, and template', async () => { + mockFolder.findByPk.mockResolvedValue({ id: 1, name: 'Test Folder' }); + mockCase.findAll.mockResolvedValue([ { id: 1, @@ -112,7 +120,7 @@ describe('GET /download with type=csv', () => { expect(response.status).toBe(200); expect(response.headers['content-type']).toBe('text/csv; charset=utf-8'); - expect(response.headers['content-disposition']).toBe('attachment; filename=cases_folder_1.csv'); + expect(response.headers['content-disposition']).toContain('attachment; filename="Test Folder.csv"'); const csvContent = response.text; diff --git a/backend/routes/runs/download.js b/backend/routes/runs/download.js index 1a30ca5..d2d25b0 100644 --- a/backend/routes/runs/download.js +++ b/backend/routes/runs/download.js @@ -10,6 +10,7 @@ import defineFolder from '../../models/folders.js'; import defineTag from '../../models/tags.js'; import authMiddleware from '../../middleware/auth.js'; import visibilityMiddleware from '../../middleware/verifyVisible.js'; +import { contentDisposition, toSafeFileName } from '../../config/contentDisposition.js'; import { testRunCaseStatus, testRunStatus, priorities, testTypes, automationStatus } from '../../config/enums.js'; export default function (sequelize) { @@ -40,6 +41,11 @@ export default function (sequelize) { return res.status(404).send('Run not found'); } + const runName = toSafeFileName(run.name); + const filename = `${runName}.${type}`; + res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition'); + res.setHeader('Content-Disposition', contentDisposition(filename)); + const runCases = await RunCase.findAll({ where: { runId }, include: [ @@ -107,7 +113,6 @@ export default function (sequelize) { const xmlString = xml.end({ prettyPrint: true }); res.setHeader('Content-Type', 'application/xml'); - res.setHeader('Content-Disposition', `attachment; filename=run_${runId}.xml`); return res.send(xmlString); } else if (type === 'json') { return res.json(runCases); @@ -129,7 +134,6 @@ export default function (sequelize) { }); res.setHeader('Content-Type', 'text/csv; charset=utf-8'); - res.setHeader('Content-Disposition', `attachment; filename=run_${runId}.csv`); return res.send(csv); } diff --git a/backend/routes/runs/download.test.js b/backend/routes/runs/download.test.js index 1c643a8..d701737 100644 --- a/backend/routes/runs/download.test.js +++ b/backend/routes/runs/download.test.js @@ -156,7 +156,7 @@ describe('GET /download/:runId with type=csv', () => { expect(response.status).toBe(200); expect(response.headers['content-type']).toBe('text/csv; charset=utf-8'); - expect(response.headers['content-disposition']).toBe('attachment; filename=run_1.csv'); + expect(response.headers['content-disposition']).toContain('attachment; filename="Test Run.csv"'); const csvContent = response.text; diff --git a/frontend/src/app/[locale]/projects/[projectId]/runs/runsControl.ts b/frontend/src/app/[locale]/projects/[projectId]/runs/runsControl.ts index 0a30414..faf1f62 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/runs/runsControl.ts +++ b/frontend/src/app/[locale]/projects/[projectId]/runs/runsControl.ts @@ -1,3 +1,4 @@ +import { getFilenameFromContentDisposition } from '@/utils/request'; import { logError } from '@/utils/errorHandler'; import { CaseType } from '@/types/case'; import { RunType, RunCaseType } from '@/types/run'; @@ -149,11 +150,14 @@ async function exportRun(jwt: string, runId: number, type: string) { throw new Error(`HTTP error! Status: ${response.status}`); } + const disposition = response.headers.get('content-disposition'); + const filename = getFilenameFromContentDisposition(disposition) ?? `cases.${type}`; + const blob = await response.blob(); const objectUrl = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = objectUrl; - a.download = `run_${runId}.${type}`; + a.download = filename; document.body.appendChild(a); a.click(); a.remove(); diff --git a/frontend/utils/caseControl.ts b/frontend/utils/caseControl.ts index 75197bf..b4f01bd 100644 --- a/frontend/utils/caseControl.ts +++ b/frontend/utils/caseControl.ts @@ -1,3 +1,4 @@ +import { getFilenameFromContentDisposition } from '@/utils/request'; import { logError } from '@/utils/errorHandler'; import Config from '@/config/config'; const apiServer = Config.apiServer; @@ -221,11 +222,14 @@ async function exportCases(jwt: string, folderId: number, type: string) { throw new Error(`HTTP error! Status: ${response.status}`); } + const disposition = response.headers.get('content-disposition'); + const filename = getFilenameFromContentDisposition(disposition) ?? `cases.${type}`; + const blob = await response.blob(); const objectUrl = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = objectUrl; - a.download = `folder_${folderId}.${type}`; + a.download = filename; document.body.appendChild(a); a.click(); a.remove(); diff --git a/frontend/utils/request.ts b/frontend/utils/request.ts new file mode 100644 index 0000000..2e2b104 --- /dev/null +++ b/frontend/utils/request.ts @@ -0,0 +1,17 @@ +export function getFilenameFromContentDisposition(disposition: string | null): string | null { + if (!disposition) return null; + + const filenameStar = disposition.match(/filename\*\s*=\s*UTF-8''([^;]+)/i); + if (filenameStar?.[1]) { + try { + return decodeURIComponent(filenameStar[1]); + } catch { + // ignore + } + } + + const filename = disposition.match(/filename\s*=\s*"([^"]+)"/i) || disposition.match(/filename\s*=\s*([^;]+)/i); + if (filename?.[1]) return filename[1].trim(); + + return null; +}