ABIANAPP_NODE_PRODUCCION/test/driver-license.upload.integration.test.js

648 lines
21 KiB
JavaScript

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);
});