import { test } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; import { CATALONIA_CAMERAS_URL, CATALONIA_MAX_CATALOG_BYTES, } from '../../server/providers/cctv/constants.js'; import { decodeNumericEntities } from '../../server/providers/cctv/normalize.js'; import { parseCataloniaXml, normalizeCataloniaImageUrl, cataloniaCameraName, loadCataloniaSourcesFromOpenData, } from '../../server/providers/cctv/sources.js'; /** Set (or, for `undefined`, delete) environment variables for one test. */ function withEnv(t, env) { for (const [name, value] of Object.entries(env)) { const previous = process.env[name]; t.after(() => { if (previous === undefined) delete process.env[name]; else process.env[name] = previous; }); if (value === undefined) delete process.env[name]; else process.env[name] = value; } } /** Silence the loaders' progress and failure logging. */ function quiet(t) { t.mock.method(console, 'log', () => {}); t.mock.method(console, 'warn', () => {}); } /** * A response whose body is a live stream, plus a flag that flips when the * stream is cancelled. A rejection path that returns without cancelling holds * the transport open, so the flag is what the refusal tests actually assert. */ const streamingResponse = (init = {}) => { const state = { cancelled: false }; const body = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('` block matching the real feed's shape. */ function cataloniaFeature({ fid, lon, lat, carretera = '', municipi = '', pk = '', link = '', font = '', }) { const pkTag = pk ? `${pk}` : ''; return ( `` + `` + `${lon},${lat}` + `` + `${carretera}` + `${municipi}${pkTag}` + `${link}` + `${font}` + `` ); } function cataloniaFeed(features) { return ( '' + features.join('') + '' ); } test('parseCataloniaXml extracts every field from a feature block', () => { const xml = cataloniaFeed([ cataloniaFeature({ fid: 'cameres.fid-52bf6eca_1a0a06bea1c_386f', lon: '2.1849528', lat: '41.45989301', carretera: 'C-58', municipi: 'Nus Trinitat', pk: '0.50', link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif', font: 'SCT', }), ]); const rows = parseCataloniaXml(xml); assert.equal(rows.length, 1); assert.deepEqual(rows[0], { fid: 'cameres.fid-52bf6eca_1a0a06bea1c_386f', lon: 2.1849528, lat: 41.45989301, carretera: 'C-58', municipi: 'Nus Trinitat', pk: '0.50', link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif', font: 'SCT', }); }); test('normalizeCataloniaImageUrl rewrites SCT RenderService links to the direct http TransitCamera URL', () => { // RenderService always 302s to a hardcoded http:// Location (which // fetchWithinHost refuses as a scheme downgrade), and mct.gencat.cat's TLS // setup fails the handshake with Node's fetch regardless — TransitCamera // over plain http is the only transport that actually works here, so we // build that URL from RenderService's sctidcam id instead of registering // the redirector. assert.equal( normalizeCataloniaImageUrl( 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif', ), 'http://mct.gencat.cat/mct2bo/TransitCamera?nom=nc87.gif&visualitzacio=imatge', ); assert.equal( normalizeCataloniaImageUrl( 'https://mct.gencat.cat/mct2bo/RenderService?sctidcam=sc64.gif', ), 'http://mct.gencat.cat/mct2bo/TransitCamera?nom=sc64.gif&visualitzacio=imatge', 'an already-https RenderService link is rewritten the same way', ); assert.equal( normalizeCataloniaImageUrl('https://mct.gencat.cat/mct2bo/RenderService'), '', 'a RenderService link with no sctidcam id is rejected', ); }); test('normalizeCataloniaImageUrl accepts the other registered hosts, upgraded to https', () => { assert.equal( normalizeCataloniaImageUrl('http://www.bcn.cat/transit/imatges/a.gif'), 'https://www.bcn.cat/transit/imatges/a.gif', ); assert.equal( normalizeCataloniaImageUrl( 'https://emap.terrassa.cat/it_terrassa/cam02.jpeg', ), 'https://emap.terrassa.cat/it_terrassa/cam02.jpeg', ); assert.equal( normalizeCataloniaImageUrl('https://evil.example.com/mct.gencat.cat.gif'), '', 'a look-alike host is rejected', ); assert.equal( normalizeCataloniaImageUrl('https://unlisted-publisher.example/frame.gif'), '', 'any host outside the allowlist is rejected', ); assert.equal(normalizeCataloniaImageUrl(''), ''); assert.equal(normalizeCataloniaImageUrl('not a url'), ''); }); test('normalizeCataloniaImageUrl rejects credential-bearing URLs on every allowed host', () => { // The hostname matches the allowlist, so only an explicit userinfo check // stops catalog data injecting credentials into the frame request. for (const link of [ 'https://user:pass@www.bcn.cat/transit/imatges/a.gif', 'http://user@www.bcn.cat/transit/imatges/a.gif', 'https://:pass@emap.terrassa.cat/it_terrassa/cam02.jpeg', 'http://user:pass@mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif', ]) { assert.equal(normalizeCataloniaImageUrl(link), '', link); } }); test("normalizeCataloniaImageUrl rejects explicit ports and paths outside each host's frame directory", () => { // An allowlisted hostname alone would let catalog data aim the frame // request at another service or path on that host. for (const link of [ 'http://www.bcn.cat:8080/transit/imatges/a.gif', 'https://emap.terrassa.cat:8443/it_terrassa/cam02.jpeg', 'http://mct.gencat.cat:8080/mct2bo/RenderService?sctidcam=nc87.gif', 'http://www.bcn.cat/admin/a.gif', 'http://www.bcn.cat/transit/imatges/', 'http://www.bcn.cat/transit/imatges/sub/a.gif', 'http://www.bcn.cat/transit/imatges/../../admin/a.gif', 'https://emap.terrassa.cat/other/cam02.jpeg', 'http://mct.gencat.cat/mct2bo/OtherService?sctidcam=nc87.gif', ]) { assert.equal(normalizeCataloniaImageUrl(link), '', link); } }); test('normalizeCataloniaImageUrl accepts only a bare image filename as the SCT sctidcam', () => { for (const camId of [ '../../etc/passwd', '..%2F..%2Fetc%2Fpasswd', 'nc87.png', 'nc87', 'nc87.gif/../x.gif', 'nc87.gif&nom=other.gif', 'nc-87.gif', ]) { const link = `http://mct.gencat.cat/mct2bo/RenderService?sctidcam=${encodeURIComponent(camId)}`; assert.equal(normalizeCataloniaImageUrl(link), '', camId); } assert.equal( normalizeCataloniaImageUrl( 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=CB10.JPEG', ), 'http://mct.gencat.cat/mct2bo/TransitCamera?nom=CB10.JPEG&visualitzacio=imatge', ); }); test('decodeNumericEntities decodes decimal and hex references and leaves invalid ones as written', () => { assert.equal(decodeNumericEntities('Lluís'), 'Lluís'); assert.equal(decodeNumericEntities('Lluís 🚦'), 'Lluís 🚦'); assert.equal(decodeNumericEntities('Lluís'), 'Lluís'); for (const raw of [ 'a�b', 'a�b', 'a�b', 'a�b', 'a�b', 'a�b', 'a�b', ]) { assert.equal(decodeNumericEntities(raw), raw, raw); } }); test('cataloniaCameraName joins road and municipality, skipping a redundant municipality', () => { assert.equal( cataloniaCameraName('C-58', 'Nus Trinitat'), 'C-58 (Nus Trinitat)', ); assert.equal( cataloniaCameraName('Plaça Urquinaona', 'Barcelona'), 'Plaça Urquinaona (Barcelona)', ); assert.equal( cataloniaCameraName('Barcelona Ronda Litoral', 'Barcelona'), 'Barcelona Ronda Litoral', ); assert.equal(cataloniaCameraName('', ''), 'Càmera de trànsit'); assert.equal(cataloniaCameraName('C-58', ''), 'C-58'); }); test('Catalonia loader keeps SCT, Barcelona and Terrassa cameras with per-publisher credit', async (t) => { quiet(t); withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined }); const requested = []; t.mock.method(globalThis, 'fetch', async (url, init) => { requested.push([String(url), init.redirect]); const xml = cataloniaFeed([ cataloniaFeature({ fid: 'cameres.fid-1', lon: '2.1849528', lat: '41.45989301', carretera: 'C-58', municipi: 'Nus Trinitat', pk: '0.50', link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif', font: 'SCT', }), cataloniaFeature({ fid: 'cameres.fid-2', lon: '2.17228212', lat: '41.38890733', carretera: 'Plaça Urquinaona', municipi: 'Barcelona', link: 'http://www.bcn.cat/transit/imatges/PlUrquinaona.gif?a=1', font: 'IMI', }), cataloniaFeature({ fid: 'cameres.fid-3', lon: '2.0111', lat: '41.5636', carretera: 'Cam02', municipi: 'Terrassa', link: 'https://emap.terrassa.cat/it_terrassa/cam02.jpeg?a=1', font: 'Terrassa', }), // Rejected: link host not in the allowlist. cataloniaFeature({ fid: 'cameres.fid-4', lon: '2.0', lat: '41.5', link: 'https://example.com/fake.gif', font: 'SCT', }), // Rejected: outside the Catalonia bounding box. cataloniaFeature({ fid: 'cameres.fid-5', lon: '-3.7', lat: '40.4', link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=x.gif', font: 'SCT', }), ]); return new Response(xml, { status: 200, headers: { 'Content-Type': 'application/xml' }, }); }); const cameras = await loadCataloniaSourcesFromOpenData(); assert.deepEqual(requested, [[CATALONIA_CAMERAS_URL, 'manual']]); assert.ok( CATALONIA_CAMERAS_URL.startsWith('https://'), 'the catalog is fetched over https', ); assert.deepEqual( cameras.map((camera) => camera.id).sort(), [ 'cat-emap-terrassa-cat-it-terrassa-cam02-jpeg', 'cat-sct-nc87', 'cat-www-bcn-cat-transit-imatges-plurquinaona-gif', ], 'the off-allowlist link (fid-4) and the out-of-bbox row (fid-5) are excluded', ); const sct = cameras.find((c) => c.id === 'cat-sct-nc87'); assert.equal(sct.name, 'C-58 (Nus Trinitat)'); assert.equal(sct.provider, 'Servei Català de Trànsit'); assert.equal(sct.credit, '', 'SCT is the publisher, no separate credit'); assert.equal( sct.url, 'http://mct.gencat.cat/mct2bo/TransitCamera?nom=nc87.gif&visualitzacio=imatge', 'registers the direct, non-redirecting http frame URL, not the RenderService redirector', ); assert.equal(sct.groundElevationM, 80); assert.equal(sct.headingConfidence, 'low', 'the feed carries no heading'); const bcn = cameras.find( (c) => c.id === 'cat-www-bcn-cat-transit-imatges-plurquinaona-gif', ); assert.equal(bcn.credit, 'Ajuntament de Barcelona'); assert.equal(bcn.city, 'Barcelona'); assert.equal(bcn.groundElevationM, 20); const terrassa = cameras.find( (c) => c.id === 'cat-emap-terrassa-cat-it-terrassa-cam02-jpeg', ); assert.equal(terrassa.credit, 'Ajuntament de Terrassa'); assert.equal(terrassa.groundElevationM, 270); }); test('Catalonia loader fails soft on HTTP errors, empty payloads and network errors', async (t) => { quiet(t); let respond; t.mock.method(globalThis, 'fetch', (...args) => respond(...args)); for (respond of [ async () => new Response('unavailable', { status: 503 }), async () => new Response(''), async () => { throw new TypeError('fetch failed'); }, ]) { assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []); } }); test('the Catalonia catalog fetch refuses redirects and oversized bodies', async (t) => { t.mock.method(console, 'warn', () => {}); // A redirect is never followed: the list host cannot be steered or // downgraded. Its body is a live stream, so the test also proves the // refusal releases the transport. const seen = []; const redirected = streamingResponse({ status: 302, headers: { location: 'http://www.gencat.cat/transit/opendata/cameres.xml' }, }); t.mock.method(globalThis, 'fetch', async (url, init) => { seen.push([String(url), init.redirect]); return redirected.response; }); assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []); assert.deepEqual(seen, [[CATALONIA_CAMERAS_URL, 'manual']]); assert.equal( redirected.state.cancelled, true, 'the redirect body is cancelled', ); // An HTTP error body is released too. t.mock.restoreAll(); t.mock.method(console, 'warn', () => {}); const failed = streamingResponse({ status: 503 }); t.mock.method(globalThis, 'fetch', async () => failed.response); assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []); assert.equal(failed.state.cancelled, true, 'the error body is cancelled'); const validFeed = cataloniaFeed([ cataloniaFeature({ fid: 'cameres.fid-1', lon: '2.1849528', lat: '41.45989301', carretera: 'C-58', link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif', font: 'SCT', }), ]); // A body that declares a size over the cap is refused rather than buffered, // even though its actual content would parse. t.mock.restoreAll(); t.mock.method(console, 'warn', () => {}); t.mock.method(globalThis, 'fetch', async () => { return new Response(validFeed, { headers: { 'Content-Type': 'application/xml', 'content-length': String(CATALONIA_MAX_CATALOG_BYTES + 1), }, }); }); assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []); // So is a body with no declared size that only turns out too long while // streaming — a valid feed padded past the cap. t.mock.restoreAll(); t.mock.method(console, 'warn', () => {}); t.mock.method(globalThis, 'fetch', async () => { const oversized = validFeed + ' '.repeat(CATALONIA_MAX_CATALOG_BYTES + 1024); return new Response( new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode(oversized)); controller.close(); }, }), { headers: { 'Content-Type': 'application/xml' } }, ); }); assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []); }); test('CCTV catalog merges Catalonia cameras and CCTV_CATALONIA_ENABLED=0 skips the request', async (t) => { quiet(t); const sourceRoot = fs.mkdtempSync( path.join(os.tmpdir(), 'gev-cctv-catalog-catalonia-'), ); t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true })); withEnv(t, { CCTV_SOURCES_FILE: undefined, CCTV_SOURCES_JSON: undefined, CCTV_FORCE_AUSTIN: undefined, CCTV_PREFER_AUSTIN: undefined, CCTV_MAX_SOURCES: undefined, CCTV_CALTRANS_DISTRICTS: undefined, CCTV_TFL_ENABLED: undefined, CCTV_CATALONIA_ENABLED: undefined, CCTV_CATALONIA_MAX_SOURCES: undefined, }); const requested = []; t.mock.method(globalThis, 'fetch', async (url, init) => { requested.push([String(url), init.redirect]); if (String(url) !== CATALONIA_CAMERAS_URL) { return new Response('unavailable', { status: 503 }); } const xml = cataloniaFeed([ cataloniaFeature({ fid: 'cameres.fid-1', lon: '2.1849528', lat: '41.45989301', link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif', font: 'SCT', }), ]); return new Response(xml, { status: 200 }); }); const sources = await createCctvCatalog({ sourceRoot })(); assert.deepEqual( sources.map((source) => source.id), ['cat-sct-nc87'], ); process.env.CCTV_CATALONIA_ENABLED = '0'; requested.length = 0; assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []); assert.ok(requested.length > 0, 'the other live packs still load'); assert.equal( requested.some(([url]) => url === CATALONIA_CAMERAS_URL), false, 'a disabled pack makes no request', ); }); /** The two SCT, one Barcelona and one Terrassa rows of a real feed snapshot, * under one upstream run's `fid` prefix. */ function snapshotFeed(fidPrefix) { return cataloniaFeed([ cataloniaFeature({ fid: `cameres.fid-${fidPrefix}_386f`, lon: '2.1849528', lat: '41.45989301', carretera: 'C-58', link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif', font: 'SCT', }), cataloniaFeature({ fid: `cameres.fid-${fidPrefix}_b48`, lon: '2.18', lat: '41.46', carretera: 'C-31', link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc85.gif', font: 'SCT', }), cataloniaFeature({ fid: `cameres.fid-${fidPrefix}_1c2`, lon: '2.17228212', lat: '41.38890733', carretera: 'Plaça Urquinaona', municipi: 'Barcelona', link: 'http://www.bcn.cat/transit/imatges/PlUrquinaona.gif?a=1', font: 'IMI', }), cataloniaFeature({ fid: `cameres.fid-${fidPrefix}_1c3`, lon: '2.0111', lat: '41.5636', municipi: 'Terrassa', link: 'https://emap.terrassa.cat/it_terrassa/cam01.jpeg?a=1 ', font: 'Terrassa', }), ]); } async function loadFromFeed(t, xml) { t.mock.method(globalThis, 'fetch', async () => { return new Response(xml, { status: 200, headers: { 'Content-Type': 'application/xml' }, }); }); try { return await loadCataloniaSourcesFromOpenData(); } finally { t.mock.restoreAll(); } } test('Catalonia camera ids survive an upstream restart that changes every fid', async (t) => { quiet(t); withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined }); // The fid embeds the upstream server-start stamp: the PR fixture's // 1a0a06bea1c run and a later 1a140311953 run name the same cameras. const before = await loadFromFeed(t, snapshotFeed('52bf6eca_1a0a06bea1c')); const after = await loadFromFeed(t, snapshotFeed('52bf6eca_1a140311953')); const ids = (cameras) => cameras.map((camera) => camera.id).sort(); assert.deepEqual(ids(before), [ 'cat-emap-terrassa-cat-it-terrassa-cam01-jpeg', 'cat-sct-nc85', 'cat-sct-nc87', 'cat-www-bcn-cat-transit-imatges-plurquinaona-gif', ]); assert.deepEqual(ids(after), ids(before)); }); test('Catalonia loader collapses rows the feed repeats for one camera', async (t) => { quiet(t); withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined }); // The live feed lists nc85, cb5, cb10, cb25 and cd17 twice each, under // different fids. const row = (fid, camId) => cataloniaFeature({ fid, lon: '2.18', lat: '41.46', carretera: 'C-31', link: `http://mct.gencat.cat/mct2bo/RenderService?sctidcam=${camId}`, font: 'SCT', }); const cameras = await loadFromFeed( t, cataloniaFeed([ row('cameres.fid-x_1', 'nc85.gif'), row('cameres.fid-x_2', 'cb5.gif'), row('cameres.fid-x_3', 'nc85.gif'), row('cameres.fid-x_4', 'cb5.gif'), row('cameres.fid-x_5', 'cb10.gif'), ]), ); assert.deepEqual(cameras.map((camera) => camera.id).sort(), [ 'cat-sct-cb10', 'cat-sct-cb5', 'cat-sct-nc85', ]); }); test('Catalonia loader drops ported and bad-sctidcam links but keeps the rest', async (t) => { quiet(t); withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined }); const cameras = await loadFromFeed( t, cataloniaFeed([ cataloniaFeature({ fid: 'cameres.fid-x_1', lon: '2.17', lat: '41.39', link: 'http://www.bcn.cat:8080/transit/imatges/PlUrquinaona.gif?a=1', font: 'IMI', }), cataloniaFeature({ fid: 'cameres.fid-x_2', lon: '2.0111', lat: '41.5636', link: 'https://emap.terrassa.cat:8443/it_terrassa/cam02.jpeg?a=1', font: 'Terrassa', }), cataloniaFeature({ fid: 'cameres.fid-x_3', lon: '2.18', lat: '41.46', link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=../../etc/passwd', font: 'SCT', }), cataloniaFeature({ fid: 'cameres.fid-x_4', lon: '2.18', lat: '41.46', link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif', font: 'SCT', }), ]), ); assert.deepEqual( cameras.map((camera) => [camera.id, camera.url]), [ [ 'cat-sct-nc87', 'http://mct.gencat.cat/mct2bo/TransitCamera?nom=nc87.gif&visualitzacio=imatge', ], ], ); }); test('a malformed numeric entity in one row neither throws nor empties the Catalonia pack', async (t) => { quiet(t); withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined }); const cameras = await loadFromFeed( t, cataloniaFeed([ cataloniaFeature({ fid: 'cameres.fid-x_1', lon: '2.18', lat: '41.46', carretera: 'C-31 �', municipi: 'Bad�', link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc85.gif', font: 'SCT', }), cataloniaFeature({ fid: 'cameres.fid-x_2', lon: '2.18', lat: '41.46', carretera: 'Plaça Catalunya', municipi: 'Llinars del Vallès', link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif', font: 'SCT', }), ]), ); const byId = new Map(cameras.map((camera) => [camera.id, camera])); assert.equal(cameras.length, 2); assert.equal( byId.get('cat-sct-nc85').name, 'C-31 � (Bad�)', 'an invalid reference is kept as written', ); assert.equal( byId.get('cat-sct-nc87').name, 'Plaça Catalunya (Llinars del Vallès)', ); });