388 lines
12 KiB
JavaScript
388 lines
12 KiB
JavaScript
const { describe, it } = require('node:test');
|
|
const assert = require('node:assert');
|
|
const axios = require('axios');
|
|
const routingRouter = require('../routes/routing');
|
|
const placesRouter = require('../routes/places');
|
|
|
|
describe('Routing Input Hardening & Validations', () => {
|
|
const calcLayer = routingRouter.stack.find((l) => l.route && l.route.path === '/calculate');
|
|
assert.ok(calcLayer, '/calculate endpoint must exist');
|
|
const calcHandler = calcLayer.route.stack[calcLayer.route.stack.length - 1].handle;
|
|
|
|
it('rejects less than 2 stops with 400 invalidRequest', async () => {
|
|
process.env.STADIA_API_KEY = 'test-key';
|
|
try {
|
|
const req = { body: { locations: [{ lat: 37.77, lon: -122.42 }] } };
|
|
let statusCode = null;
|
|
let body = null;
|
|
const res = {
|
|
status: (c) => {
|
|
statusCode = c;
|
|
return { json: (b) => { body = b; } };
|
|
}
|
|
};
|
|
await calcHandler(req, res);
|
|
assert.strictEqual(statusCode, 400);
|
|
assert.strictEqual(body?.error, 'invalidRequest');
|
|
assert.match(body?.message, /At least 2 locations/);
|
|
} finally {
|
|
delete process.env.STADIA_API_KEY;
|
|
}
|
|
});
|
|
|
|
it('rejects more than 25 stops with 400 invalidRequest', async () => {
|
|
process.env.STADIA_API_KEY = 'test-key';
|
|
try {
|
|
const stops = Array.from({ length: 26 }, (_, i) => ({
|
|
lat: 37.0 + i * 0.01,
|
|
lon: -122.0
|
|
}));
|
|
const req = { body: { locations: stops } };
|
|
let statusCode = null;
|
|
let body = null;
|
|
const res = {
|
|
status: (c) => {
|
|
statusCode = c;
|
|
return { json: (b) => { body = b; } };
|
|
}
|
|
};
|
|
await calcHandler(req, res);
|
|
assert.strictEqual(statusCode, 400);
|
|
assert.strictEqual(body?.error, 'invalidRequest');
|
|
assert.match(body?.message, /Maximum of 25/);
|
|
} finally {
|
|
delete process.env.STADIA_API_KEY;
|
|
}
|
|
});
|
|
|
|
it('accepts exactly 25 stops and passes motorcycle costing to provider', async () => {
|
|
process.env.STADIA_API_KEY = 'test-secret-key-123';
|
|
const originalPost = axios.post;
|
|
try {
|
|
let interceptedUrl = null;
|
|
let interceptedPayload = null;
|
|
let interceptedParams = null;
|
|
|
|
axios.post = async (url, payload, config) => {
|
|
interceptedUrl = url;
|
|
interceptedPayload = payload;
|
|
interceptedParams = config?.params;
|
|
return {
|
|
data: {
|
|
trip: {
|
|
legs: [
|
|
{
|
|
summary: { length: 15.5, time: 1200 },
|
|
shape: 'test_encoded_polyline'
|
|
}
|
|
],
|
|
warnings: []
|
|
}
|
|
}
|
|
};
|
|
};
|
|
|
|
const stops = Array.from({ length: 25 }, (_, i) => ({
|
|
lat: 37.0 + i * 0.01,
|
|
lon: -122.0
|
|
}));
|
|
|
|
const req = { body: { locations: stops } };
|
|
let responseData = null;
|
|
const res = {
|
|
json: (b) => { responseData = b; }
|
|
};
|
|
|
|
await calcHandler(req, res);
|
|
|
|
assert.strictEqual(interceptedUrl, 'https://api.stadiamaps.com/route/v1');
|
|
assert.strictEqual(interceptedParams?.api_key, 'test-secret-key-123');
|
|
assert.strictEqual(interceptedPayload?.costing, 'motorcycle');
|
|
assert.strictEqual(interceptedPayload?.locations?.length, 25);
|
|
assert.strictEqual(responseData?.distanceMeters, 15500);
|
|
assert.strictEqual(responseData?.durationSeconds, 1200);
|
|
assert.strictEqual(responseData?.encodedPolyline, 'test_encoded_polyline');
|
|
assert.deepStrictEqual(responseData?.encodedPolylines, ['test_encoded_polyline']);
|
|
// Verify API key is NOT leaked in client response
|
|
assert.strictEqual(JSON.stringify(responseData).includes('test-secret-key-123'), false);
|
|
} finally {
|
|
axios.post = originalPost;
|
|
delete process.env.STADIA_API_KEY;
|
|
}
|
|
});
|
|
|
|
it('sanitizes upstream timeout error to 504 providerTimeout without leaking internals', async () => {
|
|
process.env.STADIA_API_KEY = 'test-key';
|
|
const originalPost = axios.post;
|
|
try {
|
|
axios.post = async () => {
|
|
const err = new Error('connect ECONNABORTED');
|
|
err.code = 'ECONNABORTED';
|
|
throw err;
|
|
};
|
|
|
|
const req = {
|
|
body: {
|
|
locations: [
|
|
{ lat: 37.7, lon: -122.4 },
|
|
{ lat: 37.8, lon: -122.4 }
|
|
]
|
|
}
|
|
};
|
|
let statusCode = null;
|
|
let body = null;
|
|
const res = {
|
|
status: (c) => {
|
|
statusCode = c;
|
|
return { json: (b) => { body = b; } };
|
|
}
|
|
};
|
|
|
|
await calcHandler(req, res);
|
|
assert.strictEqual(statusCode, 504);
|
|
assert.strictEqual(body?.error, 'providerTimeout');
|
|
assert.strictEqual(JSON.stringify(body).includes('ECONNABORTED'), false);
|
|
} finally {
|
|
axios.post = originalPost;
|
|
delete process.env.STADIA_API_KEY;
|
|
}
|
|
});
|
|
|
|
it('rejects invalid latitude bounds (< -90 or > 90)', async () => {
|
|
process.env.STADIA_API_KEY = 'test-key';
|
|
try {
|
|
const req = {
|
|
body: {
|
|
locations: [
|
|
{ lat: 95.0, lon: -122.4 },
|
|
{ lat: 37.7, lon: -122.4 }
|
|
]
|
|
}
|
|
};
|
|
let statusCode = null;
|
|
let body = null;
|
|
const res = {
|
|
status: (c) => {
|
|
statusCode = c;
|
|
return { json: (b) => { body = b; } };
|
|
}
|
|
};
|
|
await calcHandler(req, res);
|
|
assert.strictEqual(statusCode, 400);
|
|
assert.strictEqual(body?.error, 'invalidRequest');
|
|
assert.match(body?.message, /out of geographic bounds/);
|
|
} finally {
|
|
delete process.env.STADIA_API_KEY;
|
|
}
|
|
});
|
|
|
|
it('rejects invalid longitude bounds (< -180 or > 180)', async () => {
|
|
process.env.STADIA_API_KEY = 'test-key';
|
|
try {
|
|
const req = {
|
|
body: {
|
|
locations: [
|
|
{ lat: 37.7, lon: -185.0 },
|
|
{ lat: 37.7, lon: -122.4 }
|
|
]
|
|
}
|
|
};
|
|
let statusCode = null;
|
|
let body = null;
|
|
const res = {
|
|
status: (c) => {
|
|
statusCode = c;
|
|
return { json: (b) => { body = b; } };
|
|
}
|
|
};
|
|
await calcHandler(req, res);
|
|
assert.strictEqual(statusCode, 400);
|
|
assert.strictEqual(body?.error, 'invalidRequest');
|
|
assert.match(body?.message, /out of geographic bounds/);
|
|
} finally {
|
|
delete process.env.STADIA_API_KEY;
|
|
}
|
|
});
|
|
|
|
it('rejects non-finite coordinates (NaN/Infinity)', async () => {
|
|
process.env.STADIA_API_KEY = 'test-key';
|
|
try {
|
|
const req = {
|
|
body: {
|
|
locations: [
|
|
{ lat: NaN, lon: -122.4 },
|
|
{ lat: 37.7, lon: -122.4 }
|
|
]
|
|
}
|
|
};
|
|
let statusCode = null;
|
|
let body = null;
|
|
const res = {
|
|
status: (c) => {
|
|
statusCode = c;
|
|
return { json: (b) => { body = b; } };
|
|
}
|
|
};
|
|
await calcHandler(req, res);
|
|
assert.strictEqual(statusCode, 400);
|
|
assert.strictEqual(body?.error, 'invalidRequest');
|
|
assert.match(body?.message, /Invalid or non-finite/);
|
|
} finally {
|
|
delete process.env.STADIA_API_KEY;
|
|
}
|
|
});
|
|
|
|
it('fails with 503 providerUnavailable if STADIA_API_KEY is not configured', async () => {
|
|
delete process.env.STADIA_API_KEY;
|
|
const req = { body: { locations: [{ lat: 37.7, lon: -122.4 }, { lat: 37.8, lon: -122.4 }] } };
|
|
let statusCode = null;
|
|
let body = null;
|
|
const res = {
|
|
status: (c) => {
|
|
statusCode = c;
|
|
return { json: (b) => { body = b; } };
|
|
}
|
|
};
|
|
await calcHandler(req, res);
|
|
assert.strictEqual(statusCode, 503);
|
|
assert.strictEqual(body?.error, 'providerUnavailable');
|
|
});
|
|
});
|
|
|
|
describe('Places Input Hardening & Validations', () => {
|
|
const searchLayer = placesRouter.stack.find((l) => l.route && l.route.path === '/search');
|
|
assert.ok(searchLayer, '/search endpoint must exist');
|
|
const searchHandler = searchLayer.route.stack[searchLayer.route.stack.length - 1].handle;
|
|
|
|
const resolveLayer = placesRouter.stack.find((l) => l.route && l.route.path === '/resolve');
|
|
assert.ok(resolveLayer, '/resolve endpoint must exist');
|
|
const resolveHandler = resolveLayer.route.stack[resolveLayer.route.stack.length - 1].handle;
|
|
|
|
it('search rejects empty or short query with 400', async () => {
|
|
process.env.STADIA_API_KEY = 'test-key';
|
|
try {
|
|
const req = { query: { query: 'a' } };
|
|
let statusCode = null;
|
|
let body = null;
|
|
const res = {
|
|
status: (c) => {
|
|
statusCode = c;
|
|
return { json: (b) => { body = b; } };
|
|
}
|
|
};
|
|
await searchHandler(req, res);
|
|
assert.strictEqual(statusCode, 400);
|
|
assert.strictEqual(body?.error, 'invalidRequest');
|
|
} finally {
|
|
delete process.env.STADIA_API_KEY;
|
|
}
|
|
});
|
|
|
|
it('search rejects excessive query (>200 chars) with 400', async () => {
|
|
process.env.STADIA_API_KEY = 'test-key';
|
|
try {
|
|
const req = { query: { query: 'A'.repeat(201) } };
|
|
let statusCode = null;
|
|
let body = null;
|
|
const res = {
|
|
status: (c) => {
|
|
statusCode = c;
|
|
return { json: (b) => { body = b; } };
|
|
}
|
|
};
|
|
await searchHandler(req, res);
|
|
assert.strictEqual(statusCode, 400);
|
|
assert.strictEqual(body?.error, 'invalidRequest');
|
|
} finally {
|
|
delete process.env.STADIA_API_KEY;
|
|
}
|
|
});
|
|
|
|
it('search normalizes Pelias GeoJSON features into place results with coordinates', async () => {
|
|
process.env.STADIA_API_KEY = 'test-places-key';
|
|
const originalGet = axios.get;
|
|
try {
|
|
axios.get = async (url, config) => {
|
|
assert.strictEqual(url, 'https://api.stadiamaps.com/geocoding/v1/autocomplete');
|
|
assert.strictEqual(config?.params?.api_key, 'test-places-key');
|
|
return {
|
|
data: {
|
|
features: [
|
|
{
|
|
properties: {
|
|
id: 'venue_123',
|
|
name: 'Skyline Boulevard',
|
|
label: 'Skyline Boulevard, Oakland, CA',
|
|
region: 'California',
|
|
country: 'USA'
|
|
},
|
|
geometry: {
|
|
coordinates: [-122.18, 37.81]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
};
|
|
};
|
|
|
|
const req = { query: { query: 'Skyline' } };
|
|
let responseData = null;
|
|
const res = {
|
|
json: (d) => { responseData = d; }
|
|
};
|
|
|
|
await searchHandler(req, res);
|
|
assert.ok(Array.isArray(responseData?.results));
|
|
assert.strictEqual(responseData.results.length, 1);
|
|
const place = responseData.results[0];
|
|
assert.strictEqual(place.providerId, 'venue_123');
|
|
assert.strictEqual(place.primaryText, 'Skyline Boulevard');
|
|
assert.strictEqual(place.latitude, 37.81);
|
|
assert.strictEqual(place.longitude, -122.18);
|
|
} finally {
|
|
axios.get = originalGet;
|
|
delete process.env.STADIA_API_KEY;
|
|
}
|
|
});
|
|
|
|
it('resolve rejects missing or empty placeId with 400', async () => {
|
|
process.env.STADIA_API_KEY = 'test-key';
|
|
try {
|
|
const req = { query: { placeId: ' ' } };
|
|
let statusCode = null;
|
|
let body = null;
|
|
const res = {
|
|
status: (c) => {
|
|
statusCode = c;
|
|
return { json: (b) => { body = b; } };
|
|
}
|
|
};
|
|
await resolveHandler(req, res);
|
|
assert.strictEqual(statusCode, 400);
|
|
assert.strictEqual(body?.error, 'invalidRequest');
|
|
} finally {
|
|
delete process.env.STADIA_API_KEY;
|
|
}
|
|
});
|
|
|
|
it('resolve rejects excessive placeId (>200 chars) with 400', async () => {
|
|
process.env.STADIA_API_KEY = 'test-key';
|
|
try {
|
|
const req = { query: { placeId: 'x'.repeat(201) } };
|
|
let statusCode = null;
|
|
let body = null;
|
|
const res = {
|
|
status: (c) => {
|
|
statusCode = c;
|
|
return { json: (b) => { body = b; } };
|
|
}
|
|
};
|
|
await resolveHandler(req, res);
|
|
assert.strictEqual(statusCode, 400);
|
|
assert.strictEqual(body?.error, 'invalidRequest');
|
|
} finally {
|
|
delete process.env.STADIA_API_KEY;
|
|
}
|
|
});
|
|
});
|