const assert = require('node:assert/strict'); const fs = require('node:fs'); const http = require('node:http'); const path = require('node:path'); const test = require('node:test'); const jwt = require('jsonwebtoken'); const TEST_SECURE_STORAGE_DIR = path.resolve(__dirname, '..', 'tmp', 'test-driver-license-storage'); const TEST_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString('hex'); const app = require('../app'); const db = require('../src/config/db'); const { encryptBuffer } = require('../src/services/driverLicenseCrypto'); const { persistEncryptedBuffer } = require('../src/services/driverLicenseStorage'); process.env.DRIVER_LICENSE_STORAGE_DIR = TEST_SECURE_STORAGE_DIR; process.env.DRIVER_LICENSE_ENCRYPTION_KEY = TEST_ENCRYPTION_KEY; process.env.DRIVER_LICENSE_KEY_VERSION = 'test-v2'; process.env.DRIVER_LICENSE_RETENTION_DAYS = '30'; const JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret'; process.env.JWT_SECRET = JWT_SECRET; let originalQuery; let originalGetConnection; const FRONT_JPEG_BUFFER = Buffer.from([ 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0xff, 0xd9, 0x00, 0x00 ]); const listFilesRecursively = (rootDir) => { if (!fs.existsSync(rootDir)) { return []; } const collected = []; const visit = (currentDir) => { for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) { const fullPath = path.join(currentDir, entry.name); if (entry.isDirectory()) { visit(fullPath); continue; } if (entry.isFile()) { collected.push(fullPath); } } }; visit(rootDir); return collected; }; const createToken = (payload = {}) => jwt.sign( { id: 1, dni: '58045340X', id_proveedor: 675, ...payload }, JWT_SECRET, { expiresIn: '1h' } ); const withServer = async (callback) => new Promise((resolve, reject) => { const server = app.listen(0, '127.0.0.1'); server.on('error', reject); server.on('listening', async () => { try { const result = await callback(server); server.close((closeError) => { if (closeError) { reject(closeError); return; } resolve(result); }); } catch (error) { server.close(() => reject(error)); } }); }); const requestMultipart = async ({ port, method, path: requestPath, authorization, fields = {}, files = [] }) => new Promise((resolve, reject) => { const boundary = `----NodeBoundary${Date.now().toString(16)}`; const chunks = []; for (const [key, value] of Object.entries(fields)) { chunks.push(Buffer.from(`--${boundary}\r\n`)); chunks.push( Buffer.from(`Content-Disposition: form-data; name="${key}"\r\n\r\n${String(value)}\r\n`) ); } for (const file of files) { chunks.push(Buffer.from(`--${boundary}\r\n`)); chunks.push( Buffer.from( `Content-Disposition: form-data; name="${file.fieldName}"; filename="${file.filename}"\r\n` + `Content-Type: ${file.contentType}\r\n\r\n` ) ); chunks.push(file.content); chunks.push(Buffer.from('\r\n')); } chunks.push(Buffer.from(`--${boundary}--\r\n`)); const bodyBuffer = Buffer.concat(chunks); const req = http.request( { hostname: '127.0.0.1', port, method, path: requestPath, headers: { ...(authorization ? { authorization } : {}), 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': bodyBuffer.length } }, (res) => { let responseBody = ''; res.on('data', (chunk) => { responseBody += chunk; }); res.on('end', () => { resolve({ statusCode: res.statusCode, body: responseBody ? JSON.parse(responseBody) : null, headers: res.headers }); }); } ); req.on('error', reject); req.write(bodyBuffer); req.end(); }); const requestBinary = async ({ port, path: requestPath, authorization }) => new Promise((resolve, reject) => { const req = http.request( { hostname: '127.0.0.1', port, method: 'GET', path: requestPath, headers: { ...(authorization ? { authorization } : {}) } }, (res) => { const chunks = []; res.on('data', (chunk) => chunks.push(chunk)); res.on('end', () => { resolve({ statusCode: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }); }); } ); req.on('error', reject); req.end(); }); test.before(() => { originalQuery = db.query; originalGetConnection = db.getConnection; fs.rmSync(TEST_SECURE_STORAGE_DIR, { recursive: true, force: true }); }); test.after(() => { db.query = originalQuery; db.getConnection = originalGetConnection; fs.rmSync(TEST_SECURE_STORAGE_DIR, { recursive: true, force: true }); }); test.afterEach(() => { db.query = originalQuery; db.getConnection = originalGetConnection; }); test('POST /api/update_driver_license devuelve 401 sin token', async () => { db.query = async () => { throw new Error('db.query should not be called when no token is provided'); }; const response = await withServer(async (server) => requestMultipart({ port: server.address().port, method: 'POST', path: '/api/update_driver_license', fields: { dni: '58045340X', id_proveedor: 675, document_type: 'driver_license', document_side: 'front' }, files: [ { fieldName: 'carnet_conducir_frontal', filename: 'front.jpg', contentType: 'image/jpeg', content: FRONT_JPEG_BUFFER } ] }) ); assert.equal(response.statusCode, 401); assert.deepEqual(response.body, { error: 'Unauthorized' }); }); test('POST /api/update_driver_license valida document_side', async () => { db.query = async () => { throw new Error('db.query should not be called for invalid side'); }; const response = await withServer(async (server) => requestMultipart({ port: server.address().port, method: 'POST', path: '/api/update_driver_license', authorization: `Bearer ${createToken()}`, fields: { dni: '58045340X', id_proveedor: 675, document_type: 'driver_license', document_side: 'left' }, files: [ { fieldName: 'carnet_conducir_frontal', filename: 'front.jpg', contentType: 'image/jpeg', content: FRONT_JPEG_BUFFER } ] }) ); assert.equal(response.statusCode, 400); assert.deepEqual(response.body, { success: false, error: 'document_side invalido. Valores permitidos: front, back.' }); }); test('POST /api/update_driver_license exige campo correcto segĂșn side', async () => { db.query = async () => { throw new Error('db.query should not be called for side/file mismatch'); }; const response = await withServer(async (server) => requestMultipart({ port: server.address().port, method: 'POST', path: '/api/update_driver_license', authorization: `Bearer ${createToken()}`, fields: { dni: '58045340X', id_proveedor: 675, document_type: 'driver_license', document_side: 'front' }, files: [ { fieldName: 'carnet_conducir_trasera', filename: 'back.jpg', contentType: 'image/jpeg', content: FRONT_JPEG_BUFFER } ] }) ); assert.equal(response.statusCode, 400); assert.deepEqual(response.body, { success: false, error: 'Para document_side=front debe enviarse carnet_conducir_frontal.' }); }); test('POST /api/update_driver_license rechaza MIME/extension no permitidos', async () => { db.query = async () => { throw new Error('db.query should not be called for invalid MIME'); }; const response = await withServer(async (server) => requestMultipart({ port: server.address().port, method: 'POST', path: '/api/update_driver_license', authorization: `Bearer ${createToken()}`, fields: { dni: '58045340X', id_proveedor: 675, document_type: 'driver_license', document_side: 'front' }, files: [ { fieldName: 'carnet_conducir_frontal', filename: 'front.txt', contentType: 'text/plain', content: Buffer.from('not-image') } ] }) ); assert.equal(response.statusCode, 400); assert.deepEqual(response.body, { success: false, error: 'Tipo de archivo invalido. Solo image/jpeg, image/png o image/webp.' }); }); test('POST /api/update_driver_license rechaza archivo mayor a 5MB', async () => { db.query = async () => { throw new Error('db.query should not be called for oversized files'); }; const oversizedBuffer = Buffer.alloc(5 * 1024 * 1024 + 1, 0xff); oversizedBuffer[0] = 0xff; oversizedBuffer[1] = 0xd8; oversizedBuffer[2] = 0xff; const response = await withServer(async (server) => requestMultipart({ port: server.address().port, method: 'POST', path: '/api/update_driver_license', authorization: `Bearer ${createToken()}`, fields: { dni: '58045340X', id_proveedor: 675, document_type: 'driver_license', document_side: 'front' }, files: [ { fieldName: 'carnet_conducir_frontal', filename: 'front.jpg', contentType: 'image/jpeg', content: oversizedBuffer } ] }) ); assert.equal(response.statusCode, 400); assert.deepEqual(response.body, { success: false, error: 'Archivo demasiado grande. Maximo 5MB.' }); }); test('POST /api/update_driver_license devuelve 403 para destino no autorizado', async () => { db.query = async (sql) => { if (sql.includes('INSERT INTO driver_license_access_audit')) { return [{ affectedRows: 1 }]; } throw new Error(`Unexpected SQL in forbidden test: ${sql}`); }; const response = await withServer(async (server) => requestMultipart({ port: server.address().port, method: 'POST', path: '/api/update_driver_license', authorization: `Bearer ${createToken({ dni: '11111111A', id_proveedor: 999 })}`, fields: { dni: '58045340X', id_proveedor: 675, document_type: 'driver_license', document_side: 'front' }, files: [ { fieldName: 'carnet_conducir_frontal', filename: 'front.jpg', contentType: 'image/jpeg', content: FRONT_JPEG_BUFFER } ] }) ); assert.equal(response.statusCode, 403); assert.deepEqual(response.body, { success: false, error: 'Forbidden' }); }); test('POST /api/update_driver_license side=front guarda cifrado y responde contrato esperado', async () => { let insertedStorageKey = null; let beginCalled = false; let commitCalled = false; let rollbackCalled = false; db.query = async (sql, params) => { if (sql.includes('FROM m_proveedores_trasportistas')) { assert.deepEqual(params, ['58045340X', 675]); return [[{ id_transportista: 1 }]]; } if (sql.includes('INSERT INTO driver_license_access_audit')) { return [{ affectedRows: 1 }]; } throw new Error(`Unexpected SQL in success test: ${sql}`); }; db.getConnection = async () => ({ beginTransaction: async () => { beginCalled = true; }, query: async (sql, params) => { if (sql.includes('UPDATE driver_license_files')) { assert.deepEqual(params, ['58045340X', 675, 'front', 'driver_license']); return [{ affectedRows: 1 }]; } if (sql.includes('INSERT INTO driver_license_files')) { insertedStorageKey = params[5]; assert.equal(params[1], '58045340X'); assert.equal(params[2], 675); assert.equal(params[3], 'front'); assert.equal(params[4], 'driver_license'); assert.equal(params[6], 'image/jpeg'); assert.equal(params[7], FRONT_JPEG_BUFFER.length); assert.match(params[8], /^[a-f0-9]{64}$/); assert.equal(params[9], 'aes-256-gcm'); assert.match(params[10], /^[a-f0-9]{24}$/); assert.match(params[11], /^[a-f0-9]{32}$/); assert.equal(params[12], 'test-v2'); return [{ insertId: 77, affectedRows: 1 }]; } throw new Error(`Unexpected TX SQL in success test: ${sql}`); }, commit: async () => { commitCalled = true; }, rollback: async () => { rollbackCalled = true; }, release: () => {} }); const response = await withServer(async (server) => requestMultipart({ port: server.address().port, method: 'POST', path: '/api/update_driver_license', authorization: `Bearer ${createToken()}`, fields: { dni: '58045340X', id_proveedor: 675, document_type: 'driver_license', document_side: 'front' }, files: [ { fieldName: 'carnet_conducir_frontal', filename: 'front.jpg', contentType: 'image/jpeg', content: FRONT_JPEG_BUFFER } ] }) ); assert.equal(response.statusCode, 200); assert.equal(response.body.success, true); assert.equal(response.body.document_side, 'front'); assert.match(response.body.carnet_conducir_frontal, /^secure\/driver-license\/front\/[0-9a-f-]{36}$/i); assert.equal(response.body.driverLicenseFrontImage, response.body.carnet_conducir_frontal); assert.equal(beginCalled, true); assert.equal(commitCalled, true); assert.equal(rollbackCalled, false); assert.equal(typeof insertedStorageKey, 'string'); const storedFiles = listFilesRecursively(TEST_SECURE_STORAGE_DIR).filter((item) => item.endsWith('.bin')); assert.equal(storedFiles.length > 0, true); const encryptedBody = fs.readFileSync(storedFiles[0]); assert.equal(encryptedBody.equals(FRONT_JPEG_BUFFER), false); }); test('POST /api/upload_driver_license side=back responde contrato esperado', async () => { db.query = async (sql) => { if (sql.includes('FROM m_proveedores_trasportistas')) { return [[{ id_transportista: 1 }]]; } if (sql.includes('INSERT INTO driver_license_access_audit')) { return [{ affectedRows: 1 }]; } throw new Error(`Unexpected SQL in back upload test: ${sql}`); }; db.getConnection = async () => ({ beginTransaction: async () => {}, query: async (sql, params) => { if (sql.includes('UPDATE driver_license_files')) { assert.deepEqual(params, ['58045340X', 675, 'back', 'driver_license']); return [{ affectedRows: 1 }]; } if (sql.includes('INSERT INTO driver_license_files')) { assert.equal(params[3], 'back'); return [{ insertId: 78, affectedRows: 1 }]; } throw new Error(`Unexpected TX SQL in back upload test: ${sql}`); }, commit: async () => {}, rollback: async () => {}, release: () => {} }); const response = await withServer(async (server) => requestMultipart({ port: server.address().port, method: 'POST', path: '/api/upload_driver_license', authorization: `Bearer ${createToken()}`, fields: { dni: '58045340X', id_proveedor: 675, document_type: 'driver_license', document_side: 'back' }, files: [ { fieldName: 'carnet_conducir_trasera', filename: 'back.jpg', contentType: 'image/jpeg', content: FRONT_JPEG_BUFFER } ] }) ); assert.equal(response.statusCode, 200); assert.equal(response.body.success, true); assert.equal(response.body.document_side, 'back'); assert.match(response.body.carnet_conducir_trasera, /^secure\/driver-license\/back\/[0-9a-f-]{36}$/i); assert.equal(response.body.driverLicenseBackImage, response.body.carnet_conducir_trasera); }); test('GET /api/secure/driver-license/side/:side devuelve 403 para destino no autorizado', async () => { db.query = async (sql) => { if (sql.includes('INSERT INTO driver_license_access_audit')) { return [{ affectedRows: 1 }]; } throw new Error(`Unexpected SQL in forbidden download test: ${sql}`); }; const response = await withServer(async (server) => requestBinary({ port: server.address().port, path: '/api/secure/driver-license/side/front?dni=58045340X&id_proveedor=675', authorization: `Bearer ${createToken({ dni: '11111111A', id_proveedor: 999 })}` }) ); assert.equal(response.statusCode, 403); assert.deepEqual(JSON.parse(response.body.toString('utf8')), { success: false, error: 'Forbidden' }); }); test('GET /api/secure/driver-license/side/:side desencripta y devuelve binario', async () => { const encrypted = encryptBuffer(FRONT_JPEG_BUFFER); const persisted = await persistEncryptedBuffer(encrypted.ciphertext); db.query = async (sql, params) => { if (sql.includes('FROM driver_license_files') && sql.includes('AND side = ?')) { assert.deepEqual(params, ['58045340X', 675, 'front', 'driver_license']); return [[{ id: 77, public_id: '11111111-1111-4111-8111-111111111111', dni: '58045340X', id_proveedor: 675, side: 'front', storage_key: persisted.storageKey, mime_type: 'image/jpeg', encryption_alg: 'aes-256-gcm', encryption_iv: encrypted.ivHex, encryption_tag: encrypted.authTagHex, expires_at: new Date(Date.now() + 3600_000), deleted_at: null }]]; } if (sql.includes('INSERT INTO driver_license_access_audit')) { return [{ affectedRows: 1 }]; } throw new Error(`Unexpected SQL in download success test: ${sql}`); }; const response = await withServer(async (server) => requestBinary({ port: server.address().port, path: '/api/secure/driver-license/side/front?dni=58045340X&id_proveedor=675', authorization: `Bearer ${createToken()}` }) ); assert.equal(response.statusCode, 200); assert.equal(response.headers['content-type'], 'image/jpeg'); assert.equal(response.body.equals(FRONT_JPEG_BUFFER), true); });