diff --git a/package-lock.json b/package-lock.json index e7a79c7..e6a6400 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,8 @@ "jsonwebtoken": "^9.0.2", "mantine-datatable": "^7.12.4", "multer": "^1.4.5-lts.1", - "next-swagger-doc": "^0.4" + "next-swagger-doc": "^0.4", + "uuid": "^11.0.3" }, "devDependencies": { "@playwright/test": "^1.46", @@ -9355,6 +9356,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz", + "integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", diff --git a/package.json b/package.json index 6abbb2b..cd8ad96 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "jsonwebtoken": "^9.0.2", "mantine-datatable": "^7.12.4", "multer": "^1.4.5-lts.1", - "next-swagger-doc": "^0.4" + "next-swagger-doc": "^0.4", + "uuid": "^11.0.3" } } diff --git a/src/app/api/run/[runId]/upload/route.test.ts b/src/app/api/run/[runId]/upload/route.test.ts index fc365fb..81514e8 100644 --- a/src/app/api/run/[runId]/upload/route.test.ts +++ b/src/app/api/run/[runId]/upload/route.test.ts @@ -1,14 +1,20 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, beforeEach } from 'vitest' import { POST } from './route' import { NextRequest } from 'next/server' import path from 'path' import fs from 'fs' import { UPLOAD_DIR } from '@/app/utils' +import mockFs from 'mock-fs' +import { v4 as uuidv4 } from 'uuid' + +beforeEach(() => { + fs.rmSync(UPLOAD_DIR, { recursive: true, force: true }) +}) describe('POST /api/run/[runId]/upload', () => { it('should upload a file successfully', async () => { const mockFile = new Blob(['id,name\n1,John'], { type: 'text/csv' }) - const mockRunId = '123' + const mockRunId = uuidv4() const formData = new FormData() formData.append('file', new File([mockFile], mockRunId)) @@ -38,9 +44,43 @@ describe('POST /api/run/[runId]/upload', () => { const req = { formData: async () => formData, } as NextRequest - const params = { runId: '123' } + const params = { runId: uuidv4() } + const response = await POST(req, { params }) + expect(response.status).toBe(400) + expect((await response.json()).error).toBe('Form data does not include expected file key') + }) + + it('should return failure if unexpected form data is included', async () => { + const mockFile = new Blob(['id,name\n1,John'], { type: 'text/csv' }) + const mockRunId = uuidv4() + + const formData = new FormData() + formData.append('file', new File([mockFile], mockRunId)) + formData.append('file2', new File([mockFile], mockRunId)) + + const req = { + formData: async () => formData, + } as NextRequest + const params = { runId: uuidv4() } const response = await POST(req, { params }) expect(response.status).toBe(400) + expect((await response.json()).error).toBe('Form data includes unexpected data keys') + }) + + it('should return failure if runId is not a UUID', async () => { + const mockRunId = '123' + + const formData = new FormData() + + const req = { + formData: async () => formData, + } as NextRequest + + const params = { runId: mockRunId } + + const response = await POST(req, { params }) + expect(response.status).toBe(400) + expect((await response.json()).error).toBe('runId is not a UUID') }) it('should return an error if no runID is provided', async () => { @@ -55,4 +95,27 @@ describe('POST /api/run/[runId]/upload', () => { const response = await POST(req, { params }) expect(response.status).toBe(400) }) + + it('should return an error if the runID has results already', async () => { + const mockRunId = uuidv4() + + mockFs({ + [UPLOAD_DIR]: { + [mockRunId]: '', + }, + }) + + const formData = new FormData() + formData.append('file', '') + + const req = { + formData: async () => formData, + } as NextRequest + + const params = { runId: mockRunId } + + const response = await POST(req, { params }) + expect(response.status).toBe(400) + expect((await response.json()).error).toBe('Data already exists for runId') + }) }) diff --git a/src/app/api/run/[runId]/upload/route.ts b/src/app/api/run/[runId]/upload/route.ts index 74b5458..d13d3bb 100644 --- a/src/app/api/run/[runId]/upload/route.ts +++ b/src/app/api/run/[runId]/upload/route.ts @@ -1,5 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' -import { saveFile } from '@/app/utils' +import { saveFile, UPLOAD_DIR, isValidUUID } from '@/app/utils' +import path from 'path' +import fs from 'fs' function isFile(obj: any): obj is File { return obj instanceof File @@ -14,6 +16,23 @@ export const POST = async (req: NextRequest, { params }: { params: { runId: stri return NextResponse.json({ error: 'Missing runId' }, { status: 400 }) } + if (!isValidUUID(runId)) { + return NextResponse.json({ error: 'runId is not a UUID' }, { status: 400 }) + } + + if (!('file' in body)) { + return NextResponse.json({ error: 'Form data does not include expected file key' }, { status: 400 }) + } + + if (Object.keys(body).length !== 1) { + return NextResponse.json({ error: 'Form data includes unexpected data keys' }, { status: 400 }) + } + + const filePath = path.join(UPLOAD_DIR, runId) + if (fs.existsSync(filePath)) { + return NextResponse.json({ error: 'Data already exists for runId' }, { status: 400 }) + } + if ('file' in body && isFile(body.file)) { await saveFile(body.file, runId) return NextResponse.json({}, { status: 200 }) diff --git a/src/app/utils.test.ts b/src/app/utils.test.ts index df27d1f..3a65fc6 100644 --- a/src/app/utils.test.ts +++ b/src/app/utils.test.ts @@ -1,4 +1,11 @@ -import { createUploadDirIfNotExists, deleteFile, generateAuthorizationHeaders, saveFile, UPLOAD_DIR } from './utils' +import { + createUploadDirIfNotExists, + deleteFile, + generateAuthorizationHeaders, + isValidUUID, + saveFile, + UPLOAD_DIR, +} from './utils' import mockFs from 'mock-fs' import path from 'path' import jwt from 'jsonwebtoken' @@ -99,4 +106,14 @@ describe('Utils', () => { }) }) }) + + describe('isValidUUID', () => { + it('should return false on an invalid UUID', () => { + expect(isValidUUID('123')).toBe(false) + }) + + it('should return true on a valid UUID', () => { + expect(isValidUUID('a9bcdef7-575e-4083-8fbf-e1a743f29f24')).toBe(true) + }) + }) }) diff --git a/src/app/utils.ts b/src/app/utils.ts index 6eae516..59050f2 100644 --- a/src/app/utils.ts +++ b/src/app/utils.ts @@ -2,6 +2,7 @@ import path from 'path' import fs from 'fs' import os from 'os' import jwt from 'jsonwebtoken' +import { validate as uuidValidate } from 'uuid' export const UPLOAD_DIR = path.resolve(os.tmpdir(), 'public/uploads') @@ -40,3 +41,7 @@ export const generateAuthorizationHeaders = () => { Authorization: `Bearer ${token}`, } } + +export const isValidUUID = (value: string): boolean => { + return uuidValidate(value) +}