648 lines
21 KiB
JavaScript
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);
|
|
});
|