diff --git a/server/providers/cctv/ontario511.test.mjs b/server/providers/cctv/ontario511.test.mjs index d0cb1f73e..51334b3cb 100644 --- a/server/providers/cctv/ontario511.test.mjs +++ b/server/providers/cctv/ontario511.test.mjs @@ -1,7 +1,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { loadOntarioSourcesFromOpenData } from './sources.js'; +import { + _resetOntarioKeyWarningForTest, + loadOntarioSourcesFromOpenData, +} from './sources.js'; const ROW = { Id: '42', @@ -18,19 +21,35 @@ const ROW = { ], }; +function captureConsole(t) { + const lines = []; + const capture = (...args) => lines.push(args.join(' ')); + t.mock.method(console, 'warn', capture); + t.mock.method(console, 'log', capture); + return lines; +} + +test.beforeEach(() => _resetOntarioKeyWarningForTest()); + test('without ONTARIO_511_API_KEY the Ontario pack is skipped, logged once', async (t) => { - const warnings = []; - t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' '))); - t.mock.method(globalThis, 'fetch', async () => - assert.fail('no request without a key'), + const lines = captureConsole(t); + let fetches = 0; + t.mock.method(globalThis, 'fetch', async () => { + fetches += 1; + return Response.json([ROW]); + }); + assert.deepEqual(await loadOntarioSourcesFromOpenData({}, {}), []); + assert.deepEqual( + await loadOntarioSourcesFromOpenData({}, { ONTARIO_511_API_KEY: ' ' }), + [], ); - assert.deepEqual(await loadOntarioSourcesFromOpenData({}, {}), []); - assert.deepEqual(await loadOntarioSourcesFromOpenData({}, {}), []); - assert.equal(warnings.length, 1); - assert.match(warnings[0], /ONTARIO_511_API_KEY/); + assert.equal(fetches, 0); + assert.equal(lines.length, 1); + assert.match(lines[0], /ONTARIO_511_API_KEY/); }); test('with a key the camera list is requested with it and parsed', async (t) => { + const lines = captureConsole(t); const requested = []; t.mock.method(globalThis, 'fetch', async (url) => { requested.push(String(url)); @@ -40,6 +59,7 @@ test('with a key the camera list is requested with it and parsed', async (t) => { sourceRoot: '/ignored' }, { ONTARIO_511_API_KEY: ' test-key ' }, ); + assert.equal(requested.length, 1); const url = new URL(requested[0]); assert.equal(url.host, '511on.ca'); assert.equal(url.searchParams.get('key'), 'test-key'); @@ -47,19 +67,63 @@ test('with a key the camera list is requested with it and parsed', async (t) => assert.equal(cameras.length, 1); assert.equal(cameras[0].id, 'on-42'); assert.equal(cameras[0].url, 'https://511on.ca/map/Cctv/4201'); + // The records go to the browser through /api/cctv; the key must stay on + // the server-side catalog request. + assert.doesNotMatch(JSON.stringify(cameras), /test-key/); + assert.doesNotMatch(lines.join('\n'), /test-key/); }); -test('a rejected key is reported by status, never by the key itself', async (t) => { - const warnings = []; - t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' '))); - t.mock.method(globalThis, 'fetch', async () => - Response.json({ Message: 'Invalid Key' }, { status: 400 }), - ); +test('the source cap is read from the same environment as the key', async (t) => { + captureConsole(t); + const rows = Array.from({ length: 12 }, (_, i) => ({ + ...ROW, + Id: String(100 + i), + Latitude: ROW.Latitude + i * 0.01, + Views: [{ ...ROW.Views[0], Url: `https://511on.ca/map/Cctv/${100 + i}` }], + })); + t.mock.method(globalThis, 'fetch', async () => Response.json(rows)); const cameras = await loadOntarioSourcesFromOpenData( {}, - { ONTARIO_511_API_KEY: 'secret-key-value' }, + { ONTARIO_511_API_KEY: 'test-key', CCTV_ONTARIO_MAX_SOURCES: '8' }, ); - assert.deepEqual(cameras, []); - assert.match(warnings.join('\n'), /400 \(check ONTARIO_511_API_KEY\)/); - assert.doesNotMatch(warnings.join('\n'), /secret-key-value/); + assert.equal(cameras.length, 8); }); + +for (const status of [400, 401, 403]) { + test(`a ${status} points at the key by name, never by value`, async (t) => { + const lines = captureConsole(t); + t.mock.method(globalThis, 'fetch', async () => + Response.json({ Message: 'Invalid Key' }, { status }), + ); + const cameras = await loadOntarioSourcesFromOpenData( + {}, + { ONTARIO_511_API_KEY: 'secret-key-value' }, + ); + assert.deepEqual(cameras, []); + assert.match( + lines.join('\n'), + new RegExp(`failed: ${status} \\(check ONTARIO_511_API_KEY\\)`), + ); + assert.doesNotMatch(lines.join('\n'), /secret-key-value/); + }); +} + +for (const status of [429, 500, 503]) { + test(`a ${status} is reported without blaming the key`, async (t) => { + const lines = captureConsole(t); + t.mock.method(globalThis, 'fetch', async () => + Response.json({}, { status }), + ); + const cameras = await loadOntarioSourcesFromOpenData( + {}, + { ONTARIO_511_API_KEY: 'secret-key-value' }, + ); + assert.deepEqual(cameras, []); + assert.equal(lines.length, 1); + assert.equal( + lines[0], + `[CCTV] Ontario 511 camera download failed: ${status}`, + ); + assert.doesNotMatch(lines[0], /secret-key-value/); + }); +} diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 3804ed956..e4b7cf48b 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -511,6 +511,11 @@ function pickOntarioCctvView(views) { let ontarioKeyMissingLogged = false; +/** Test seam: let the next keyless call log the missing-key line again. */ +export function _resetOntarioKeyWarningForTest() { + ontarioKeyMissingLogged = false; +} + /** * Fetch Ontario 511 CCTV cameras. The camera list comes from the 511 API, * which now requires a free developer key (511on.ca/developers/doc, "Requires a @@ -521,7 +526,7 @@ let ontarioKeyMissingLogged = false; * view are kept. * * @param {object} [_options] The catalog's loader options (unused). - * @param {object} [env] Environment to read the key from. + * @param {object} [env] Environment to read the key and the source cap from. * @returns {Promise>} Normalized camera source objects. */ export async function loadOntarioSourcesFromOpenData( @@ -547,12 +552,12 @@ export async function loadOntarioSourcesFromOpenData( }); if (!resp.ok) { // The API answers 400 "Invalid Key" to a wrong or revoked key. - console.warn( - '[CCTV] Ontario 511 camera download failed:', - resp.status, + const keyHint = resp.status === 400 || resp.status === 401 || resp.status === 403 - ? '(check ONTARIO_511_API_KEY)' - : '', + ? ' (check ONTARIO_511_API_KEY)' + : ''; + console.warn( + `[CCTV] Ontario 511 camera download failed: ${resp.status}${keyHint}`, ); return []; } @@ -616,7 +621,7 @@ export async function loadOntarioSourcesFromOpenData( new Map(cameras.map((camera) => [camera.id, camera])).values(), ); const maxRaw = Number( - process.env.CCTV_ONTARIO_MAX_SOURCES || DEFAULT_ONTARIO_MAX_SOURCES, + env.CCTV_ONTARIO_MAX_SOURCES || DEFAULT_ONTARIO_MAX_SOURCES, ); const maxCount = Number.isFinite(maxRaw) ? Math.max(8, Math.min(1000, Math.floor(maxRaw)))