feat: incorporar eCMR y mercancias desde 7fbfd30
Traslada los cambios de 7fbfd300b4ea17212c04528145d366a3f00b91e5 conservando la configuracion de entorno de produccion.
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const db = require('../src/config/db');
|
||||
const writer = require('../src/services/tripCargoWriter');
|
||||
const routes = require('../src/routes/tripsRoutes');
|
||||
const originalQuery = db.query;
|
||||
const originalSave = writer.save;
|
||||
process.env.JWT_SECRET = 'cargo-test-secret';
|
||||
let server;
|
||||
let base;
|
||||
const valid = { id_tipo_embalaje: 1, cantidad: 2, peso: 400, largo: 120, ancho: 80, alto: 150, remontable: true };
|
||||
|
||||
test.before(async () => {
|
||||
const app = express(); app.use(express.json()); app.use('/api', routes);
|
||||
await new Promise(resolve => { server = app.listen(0, '127.0.0.1', resolve); });
|
||||
base = `http://127.0.0.1:${server.address().port}/api/trips`;
|
||||
});
|
||||
test.afterEach(() => { db.query = originalQuery; writer.save = originalSave; });
|
||||
test.after(async () => { await new Promise(resolve => server.close(resolve)); });
|
||||
const request = async (method = 'GET', suffix = '/123/cargo', body, identity = { dni: '12345678A' }) => {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (identity) headers.Authorization = `Bearer ${jwt.sign(identity, process.env.JWT_SECRET)}`;
|
||||
const response = await fetch(base + suffix, { method, headers, body: method === 'GET' || body === undefined ? undefined : JSON.stringify(body) });
|
||||
return { status: response.status, body: await response.json() };
|
||||
};
|
||||
for (const method of ['GET', 'POST', 'PUT']) {
|
||||
for (const identity of [null, { id: 1 }]) {
|
||||
test(`${method} requires authenticated driver ${JSON.stringify(identity)}`, async () => {
|
||||
db.query = writer.save = async () => assert.fail('Must not access data');
|
||||
assert.equal((await request(method, method === 'PUT' ? '/123/cargo/7' : '/123/cargo', valid, identity)).status, 401);
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const id of ['0', '-1', '123abc', '2147483648']) {
|
||||
test(`invalid trip ${id}`, async () => {
|
||||
writer.save = async () => assert.fail('Must not save');
|
||||
assert.equal((await request('POST', `/${id}/cargo`, valid)).status, 404);
|
||||
});
|
||||
}
|
||||
test('unknown trip and unauthorized driver cannot read cargo', async () => {
|
||||
db.query = async () => [[]];
|
||||
assert.equal((await request()).status, 404);
|
||||
let calls = 0;
|
||||
db.query = async (sql, params) => {
|
||||
calls++;
|
||||
if (calls === 1) return [[{ id_viaje: 123 }]];
|
||||
assert.deepEqual(params, [123, '12345678A']);
|
||||
return [[]];
|
||||
};
|
||||
assert.equal((await request()).status, 403);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
test('reads cargo, active packaging and document status', async () => {
|
||||
let calls = 0;
|
||||
db.query = async (sql, params) => {
|
||||
calls++;
|
||||
if (calls === 1) return [[{ id_viaje: 123, observaciones_mercancia: 'Material' }]];
|
||||
if (calls === 2) return [[{ n_proveedor: 1 }]];
|
||||
if (calls === 3) { assert.deepEqual(params, [123]); return [[{ id_mercancia: 7, peso: '400' }]]; }
|
||||
if (calls === 4) { assert.match(sql, /activo = 1/); return [[{ id_tipo_embalaje: 1 }]]; }
|
||||
assert.deepEqual(params, [123]); return [[{ id_documento: 8 }]];
|
||||
};
|
||||
const response = await request();
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.body.data.ecmr_emitido, true);
|
||||
assert.equal(response.body.data.mercancias[0].id_mercancia, 7);
|
||||
assert.equal(response.body.data.observaciones_mercancia, 'Material');
|
||||
});
|
||||
for (const [field, value] of [
|
||||
['cantidad', 0], ['cantidad', 1.5], ['cantidad', '2'], ['cantidad', null],
|
||||
['peso', -1], ['peso', 1.5], ['peso', 10000000000], ['peso', null],
|
||||
['largo', -10], ['ancho', 2147483648], ['alto', 0.5],
|
||||
['remontable', 'true'], ['remontable', 2], ['id_tipo_embalaje', 0]
|
||||
]) {
|
||||
test(`rejects invalid ${field}=${value}`, async () => {
|
||||
writer.save = async () => assert.fail('Must not save invalid cargo');
|
||||
const response = await request('POST', '/123/cargo', { ...valid, [field]: value });
|
||||
assert.equal(response.status, 422);
|
||||
assert.equal(response.body.field, field);
|
||||
});
|
||||
}
|
||||
for (const editing of [false, true]) {
|
||||
test(`saves ${editing ? 'edited' : 'new'} cargo using server identity and new eCMR versions`, async () => {
|
||||
writer.save = async payload => {
|
||||
assert.equal(payload.dni, '12345678A');
|
||||
assert.equal(payload.trip_id, 123);
|
||||
assert.equal(payload.cargo_id, editing ? 7 : null);
|
||||
assert.equal(payload.mercancia.remontable, 1);
|
||||
assert.equal(payload.database, process.env.DB_NAME);
|
||||
return { status: editing ? 200 : 201, body: { success: true, data: { id_mercancia: 7 }, ecmr_versiones: [{ id_documento: 9, version: 2 }] } };
|
||||
};
|
||||
const response = await request(editing ? 'PUT' : 'POST', editing ? '/123/cargo/7' : '/123/cargo', { ...valid, dni: 'forged', trip_id: 999 });
|
||||
assert.equal(response.status, editing ? 200 : 201);
|
||||
assert.equal(response.body.ecmr_versiones[0].version, 2);
|
||||
});
|
||||
}
|
||||
for (const status of [403, 404, 422, 500]) {
|
||||
test(`propagates transaction failure ${status}`, async () => {
|
||||
writer.save = async () => ({ status, body: { success: false, error: 'Failed' } });
|
||||
const response = await request('POST', '/123/cargo', valid);
|
||||
assert.equal(response.status, status);
|
||||
assert.equal(response.body.success, false);
|
||||
});
|
||||
}
|
||||
test('process failures return 500 without exposing internals', async () => {
|
||||
writer.save = async () => { throw new Error('/private/internal'); };
|
||||
const response = await request('POST', '/123/cargo', valid);
|
||||
assert.equal(response.status, 500);
|
||||
assert.doesNotMatch(response.body.error, /private/);
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const http = require('node:http');
|
||||
const crypto = require('node:crypto');
|
||||
const test = require('node:test');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const express = require('express');
|
||||
const db = require('../src/config/db');
|
||||
const routes = require('../src/routes/tripsRoutes');
|
||||
|
||||
process.env.JWT_SECRET = 'ecmr-test-secret';
|
||||
const originalQuery = db.query;
|
||||
const originalGetConnection = db.getConnection;
|
||||
let transaction;
|
||||
let server;
|
||||
let port;
|
||||
|
||||
test.before(async () => {
|
||||
const app = express();
|
||||
app.use('/api', routes);
|
||||
server = http.createServer(app);
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
port = server.address().port;
|
||||
});
|
||||
test.beforeEach(() => {
|
||||
transaction = { committed: false, rolledBack: false, released: false };
|
||||
db.getConnection = async () => ({
|
||||
beginTransaction: async () => {},
|
||||
query: (...args) => db.query(...args),
|
||||
commit: async () => { transaction.committed = true; },
|
||||
rollback: async () => { transaction.rolledBack = true; },
|
||||
release: () => { transaction.released = true; }
|
||||
});
|
||||
});
|
||||
test.after(async () => {
|
||||
db.getConnection = originalGetConnection;
|
||||
db.query = originalQuery;
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
});
|
||||
test.afterEach(() => { db.query = originalQuery; });
|
||||
|
||||
const request = async (id = '123', payload = { dni: '12345678A' }) => {
|
||||
const headers = payload === null ? {} : {
|
||||
authorization: `Bearer ${jwt.sign(payload, process.env.JWT_SECRET)}`
|
||||
};
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/trips/${id}/ecmr`, { headers });
|
||||
return { status: response.status, body: await response.json(), cache: response.headers.get('cache-control') };
|
||||
};
|
||||
|
||||
for (const payload of [null, { id: 1 }]) {
|
||||
test(`rejects missing authentication or driver: ${JSON.stringify(payload)}`, async () => {
|
||||
db.query = async () => { assert.fail('Must not query database'); };
|
||||
assert.equal((await request('123', payload)).status, 401);
|
||||
});
|
||||
}
|
||||
for (const id of ['0', '-1', '123abc', '1.5', '9007199254740992']) {
|
||||
test(`rejects invalid trip id ${id}`, async () => {
|
||||
db.query = async () => { assert.fail('Must not query database'); };
|
||||
assert.equal((await request(id)).status, 404);
|
||||
});
|
||||
}
|
||||
test('returns 404 for unknown trip', async () => {
|
||||
db.query = async () => [[]];
|
||||
assert.equal((await request()).status, 404);
|
||||
});
|
||||
test('does not retrieve documents for another driver', async () => {
|
||||
let calls = 0;
|
||||
db.query = async (sql, params) => {
|
||||
calls++;
|
||||
if (calls === 1) return [[{ id_viaje: 123 }]];
|
||||
assert.equal(calls, 2);
|
||||
assert.match(sql, /dni = \?/);
|
||||
assert.deepEqual(params, [123, '12345678A']);
|
||||
return [[]];
|
||||
};
|
||||
assert.equal((await request()).status, 403);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
for (const [loadSigned, unloadSigned] of [[0, 0], [1, 0], [0, 1], [1, 1]]) {
|
||||
test(`returns links only for unsigned roles (${loadSigned}, ${unloadSigned})`, async () => {
|
||||
const inserted = [];
|
||||
let calls = 0;
|
||||
db.query = async (sql, params) => {
|
||||
calls++;
|
||||
if (calls === 1) return [[{ id_viaje: 123 }]];
|
||||
if (calls === 2) return [[{ authorized: 1 }]];
|
||||
if (calls === 3) {
|
||||
assert.deepEqual(params, [123, '12345678A']);
|
||||
assert.match(sql, /p\.n_proveedor = d\.n_proveedor/);
|
||||
assert.match(sql, /p\.dni = \?/);
|
||||
assert.match(sql, /d\.activo = 1/);
|
||||
assert.match(sql, /d\.pdf_ruta IS NOT NULL/);
|
||||
assert.match(sql, /FOR UPDATE/);
|
||||
assert.match(sql, /ecmr_firmas/);
|
||||
return [[{ id_documento: 7, document_url: 'https://example.test/ecmr/documento.php?t=pdf-token',
|
||||
firma_carga_completada: loadSigned, firma_descarga_completada: unloadSigned }]];
|
||||
}
|
||||
assert.match(sql, /INSERT INTO ecmr_invitaciones/);
|
||||
assert.match(sql, /'app'/);
|
||||
assert.match(sql, /NOW\(\)/);
|
||||
assert.equal(params[0], 7);
|
||||
assert.equal(params[2], '12345678A');
|
||||
assert.match(params[3], /^[a-f0-9]{64}$/);
|
||||
assert.match(params[4], /^[a-f0-9]{64}$/);
|
||||
inserted.push(params);
|
||||
return [{ insertId: 100 + calls }];
|
||||
};
|
||||
const response = await request();
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.cache, 'no-store');
|
||||
const document = response.body.data[0];
|
||||
assert.equal(document.firma_carga_completada, Boolean(loadSigned));
|
||||
assert.equal(document.firma_descarga_completada, Boolean(unloadSigned));
|
||||
assert.equal(document.firma_expira_en_segundos, 900);
|
||||
const tokens = [];
|
||||
for (const [role, field, signed] of [
|
||||
['toma_carga', 'firma_carga_url', loadSigned],
|
||||
['entrega', 'firma_descarga_url', unloadSigned]
|
||||
]) {
|
||||
if (signed) { assert.equal(document[field], null); continue; }
|
||||
const url = new URL(document[field]);
|
||||
assert.equal(url.origin + url.pathname, 'https://example.test/ecmr/firmar.php');
|
||||
const token = url.searchParams.get('t');
|
||||
assert.match(token, /^[a-f0-9]{64}$/);
|
||||
tokens.push(token);
|
||||
const row = inserted.find(params => params[1] === role);
|
||||
assert.equal(row[3], crypto.createHash('sha256').update(token).digest('hex'));
|
||||
assert.notEqual(row[4], token);
|
||||
}
|
||||
assert.equal(new Set(tokens).size, tokens.length);
|
||||
assert.equal(inserted.length, 2 - loadSigned - unloadSigned);
|
||||
assert.deepEqual(transaction, { committed: true, rolledBack: false, released: true });
|
||||
});
|
||||
}
|
||||
test('returns an empty list without creating invitations', async () => {
|
||||
let calls = 0;
|
||||
db.query = async () => {
|
||||
calls++;
|
||||
assert.ok(calls <= 3);
|
||||
return calls < 3 ? [[{ authorized: 1 }]] : [[]];
|
||||
};
|
||||
const response = await request();
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(response.body.data, []);
|
||||
assert.equal(transaction.committed, true);
|
||||
});
|
||||
test('rolls back all links if an invitation fails', async () => {
|
||||
let calls = 0;
|
||||
db.query = async () => {
|
||||
calls++;
|
||||
if (calls < 3) return [[{ authorized: 1 }]];
|
||||
if (calls === 3) return [[{ id_documento: 7, document_url: 'https://example.test/documento.php' }]];
|
||||
if (calls === 4) return [{ insertId: 1 }];
|
||||
throw new Error('forced insert failure');
|
||||
};
|
||||
const response = await request();
|
||||
assert.equal(response.status, 500);
|
||||
assert.equal(response.body.success, false);
|
||||
assert.deepEqual(transaction, { committed: false, rolledBack: true, released: true });
|
||||
});
|
||||
test('rejects signing URLs without HTTPS', async () => {
|
||||
let calls = 0;
|
||||
db.query = async () => {
|
||||
calls++;
|
||||
assert.ok(calls <= 3);
|
||||
if (calls < 3) return [[{ authorized: 1 }]];
|
||||
return [[{ id_documento: 7, document_url: 'http://example.test/documento.php' }]];
|
||||
};
|
||||
assert.equal((await request()).status, 500);
|
||||
assert.equal(transaction.rolledBack, true);
|
||||
});
|
||||
test('database errors do not expose internal details', async () => {
|
||||
db.query = async () => { throw new Error('internal database detail'); };
|
||||
const response = await request();
|
||||
assert.equal(response.status, 500);
|
||||
assert.equal(response.body.error, 'Could not load eCMR documents');
|
||||
});
|
||||
|
||||
const requestQr = async (tripId = '123', documentId = '7', payload = { dni: '12345678A' }) => {
|
||||
const headers = payload === null ? {} : { authorization: `Bearer ${jwt.sign(payload, process.env.JWT_SECRET)}` };
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/trips/${tripId}/ecmr/${documentId}/qr`, { headers });
|
||||
return { status: response.status, body: await response.json(), cache: response.headers.get('cache-control') };
|
||||
};
|
||||
for (const payload of [null, { id: 1 }]) {
|
||||
test(`QR rejects missing authentication or driver: ${JSON.stringify(payload)}`, async () => {
|
||||
db.query = async () => assert.fail('Must not query database');
|
||||
assert.equal((await requestQr('123', '7', payload)).status, 401);
|
||||
});
|
||||
}
|
||||
for (const invalid of ['0', '-1', '7abc', '1.5', '9007199254740992']) {
|
||||
test(`QR rejects invalid identifiers: ${invalid}`, async () => {
|
||||
db.query = async () => assert.fail('Must not query database');
|
||||
assert.equal((await requestQr(invalid, '7')).status, 404);
|
||||
assert.equal((await requestQr('123', invalid)).status, 404);
|
||||
});
|
||||
}
|
||||
test('QR returns only the requested active document of the assigned driver and creates no invitations', async () => {
|
||||
const QRCode = require('qrcode');
|
||||
const originalEncode = QRCode.toDataURL;
|
||||
const url = 'https://example.test/ecmr/documento.php?t=' + 'a'.repeat(64);
|
||||
let encoded;
|
||||
QRCode.toDataURL = async (text, options) => { encoded = text; return originalEncode(text, options); };
|
||||
try {
|
||||
db.getConnection = async () => assert.fail('QR must not create signing invitations');
|
||||
let queries = 0;
|
||||
db.query = async (sql, params) => {
|
||||
queries++;
|
||||
assert.match(sql, /^SELECT/);
|
||||
assert.match(sql, /p\.n_proveedor = d\.n_proveedor/);
|
||||
assert.match(sql, /d\.id_viaje = \? AND d\.id_documento = \? AND p\.dni = \? AND d\.activo = 1/);
|
||||
assert.match(sql, /d\.pdf_ruta IS NOT NULL AND d\.pdf_ruta <> ''/);
|
||||
assert.deepEqual(params, [123, 7, '12345678A']);
|
||||
return [[{ id_documento: 7, n_proveedor: 2, numero: 'ECMR-123', version: 3, document_url: url }]];
|
||||
};
|
||||
const response = await requestQr();
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.cache, 'no-store');
|
||||
assert.equal(response.body.data.id_documento, 7);
|
||||
assert.equal(response.body.data.version, 3);
|
||||
assert.equal(response.body.data.document_url, url);
|
||||
assert.equal(encoded, url);
|
||||
assert.equal(queries, 1);
|
||||
assert.match(response.body.data.qr_data_url, /^data:image\/png;base64,/);
|
||||
const png = Buffer.from(response.body.data.qr_data_url.split(',')[1], 'base64');
|
||||
assert.equal(png.subarray(0, 8).toString('hex'), '89504e470d0a1a0a');
|
||||
assert.equal(png.readUInt32BE(16), 320);
|
||||
assert.equal(png.readUInt32BE(20), 320);
|
||||
assert.equal(response.body.data.firma_carga_url, undefined);
|
||||
} finally { QRCode.toDataURL = originalEncode; }
|
||||
});
|
||||
test('QR returns 404 when no authorized active PDF matches', async () => {
|
||||
db.query = async () => [[]];
|
||||
const response = await requestQr();
|
||||
assert.equal(response.status, 404);
|
||||
assert.equal(response.cache, 'no-store');
|
||||
assert.equal(response.body.data, undefined);
|
||||
});
|
||||
for (const url of ['http://example.test/pdf', 'not-a-url', '']) {
|
||||
test(`QR rejects invalid PDF URL ${url}`, async () => {
|
||||
db.query = async () => [[{ document_url: url }]];
|
||||
assert.equal((await requestQr()).status, 500);
|
||||
});
|
||||
}
|
||||
test('QR handles database and image generation errors without leaking URLs or internals', async () => {
|
||||
db.query = async () => { throw new Error('private database details'); };
|
||||
assert.deepEqual((await requestQr()).body, { success: false, error: 'Could not load eCMR QR' });
|
||||
const QRCode = require('qrcode'), originalEncode = QRCode.toDataURL;
|
||||
try {
|
||||
db.query = async () => [[{ document_url: 'https://example.test/documento.php?t=private' }]];
|
||||
QRCode.toDataURL = async () => { throw new Error('private QR details'); };
|
||||
const response = await requestQr();
|
||||
assert.equal(response.status, 500);
|
||||
assert.deepEqual(response.body, { success: false, error: 'Could not load eCMR QR' });
|
||||
} finally { QRCode.toDataURL = originalEncode; }
|
||||
});
|
||||
Reference in New Issue
Block a user