mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Fix CCTV provider cancellation and credential-safe errors
This commit is contained in:
1 parent
1bccc295f0
commit
5b4f5ed4a0
8 files changed
+293
-63
No files matched your search
@@ -127,3 +127,43 @@ for (const status of [429, 500, 503]) {
|
||||
assert.doesNotMatch(lines[0], /secret-key-value/);
|
||||
});
|
||||
}
|
||||
|
||||
for (const failure of ['transport', 'invalid-json']) {
|
||||
test(`Ontario ${failure} failures cannot log reflected keys or request URLs`, async (t) => {
|
||||
const lines = captureConsole(t);
|
||||
const key = 'synthetic secret&/=%value';
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
if (failure === 'transport')
|
||||
throw new Error(`request failed: ${url} ${key}`);
|
||||
return new Response(`not JSON: ${url} ${key}`);
|
||||
});
|
||||
assert.deepEqual(
|
||||
await loadOntarioSourcesFromOpenData({}, { ONTARIO_511_API_KEY: key }),
|
||||
[],
|
||||
);
|
||||
assert.equal(lines.length, 1);
|
||||
assert.match(lines[0], /Ontario 511 camera download error/);
|
||||
assert.equal(lines[0].includes(key), false);
|
||||
assert.equal(lines[0].includes('synthetic'), false);
|
||||
assert.equal(lines[0].includes('https://'), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('Ontario encodes reserved key characters and refuses credential-bearing redirects', async (t) => {
|
||||
const lines = captureConsole(t);
|
||||
const key = 'synthetic &/=% value';
|
||||
let request;
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
request = { url: new URL(url), init };
|
||||
return Response.json([ROW]);
|
||||
});
|
||||
const cameras = await loadOntarioSourcesFromOpenData(
|
||||
{},
|
||||
{ ONTARIO_511_API_KEY: key },
|
||||
);
|
||||
assert.equal(request.url.searchParams.get('key'), key);
|
||||
assert.equal(request.init.redirect, 'error');
|
||||
assert.equal(cameras.length, 1);
|
||||
assert.equal(JSON.stringify(cameras).includes('synthetic'), false);
|
||||
assert.equal(lines.join(' ').includes('synthetic'), false);
|
||||
});
|
||||
@@ -563,6 +563,7 @@ export async function loadOntarioSourcesFromOpenData(
|
||||
url.searchParams.set('key', key);
|
||||
const resp = await fetch(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
@@ -646,10 +647,11 @@ export async function loadOntarioSourcesFromOpenData(
|
||||
`[CCTV] Loaded Ontario 511 camera sources: ${unique.length} enabled (using nearest ${prioritized.length})`,
|
||||
);
|
||||
return prioritized;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Transport and JSON errors may reflect the credential-bearing request.
|
||||
// Do not log upstream exception text, response bodies or URLs.
|
||||
console.warn(
|
||||
'[CCTV] Ontario 511 camera download error:',
|
||||
error?.message || error,
|
||||
'[CCTV] Ontario 511 camera download error (network or invalid response)',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
@@ -1246,66 +1248,50 @@ export function parseTarkteeDatexImages(xml) {
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
export async function loadTarkteeSourcesFromDatex() {
|
||||
// Own both transports before either can reject. A response that arrives
|
||||
// after cleanup must also be discarded instead of escaping Promise.all.
|
||||
const abort = new AbortController();
|
||||
const signal = AbortSignal.any([
|
||||
abort.signal,
|
||||
AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS),
|
||||
]);
|
||||
const responses = [];
|
||||
const discardBoth = async () => {
|
||||
abort.abort();
|
||||
await Promise.allSettled(
|
||||
responses.map((response) => response.body?.cancel()),
|
||||
);
|
||||
return [];
|
||||
};
|
||||
const fetchXml = async (url) => {
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: 'application/xml,text/xml,*/*' },
|
||||
redirect: 'manual',
|
||||
signal,
|
||||
});
|
||||
if (signal.aborted) {
|
||||
await Promise.allSettled([response.body?.cancel()]);
|
||||
signal.throwIfAborted();
|
||||
}
|
||||
responses.push(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
response.status >= 300 && response.status < 400
|
||||
? 'catalog redirected; redirects are not followed'
|
||||
: `catalog HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
return response;
|
||||
};
|
||||
try {
|
||||
const [locResp, imgResp] = await Promise.all([
|
||||
fetch(TARKTEE_LOCATIONS_URL, {
|
||||
headers: { Accept: 'application/xml,text/xml,*/*' },
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS),
|
||||
}),
|
||||
fetch(TARKTEE_IMAGES_URL, {
|
||||
headers: { Accept: 'application/xml,text/xml,*/*' },
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS),
|
||||
}),
|
||||
fetchXml(TARKTEE_LOCATIONS_URL),
|
||||
fetchXml(TARKTEE_IMAGES_URL),
|
||||
]);
|
||||
const [locXml, imgXml] = await Promise.all([
|
||||
readResponseTextCapped(locResp, TARKTEE_MAX_CATALOG_BYTES, signal),
|
||||
readResponseTextCapped(imgResp, TARKTEE_MAX_CATALOG_BYTES, signal),
|
||||
]);
|
||||
// Both responses are fetched in parallel; if either is rejected or overflows
|
||||
// its byte cap, both bodies must be released so neither transport leaks.
|
||||
const discardBoth = async () => {
|
||||
await Promise.allSettled([
|
||||
locResp?.body?.cancel(),
|
||||
imgResp?.body?.cancel(),
|
||||
]);
|
||||
return [];
|
||||
};
|
||||
if (
|
||||
(locResp.status >= 300 && locResp.status < 400) ||
|
||||
(imgResp.status >= 300 && imgResp.status < 400)
|
||||
) {
|
||||
console.warn(
|
||||
'[CCTV] Tarktee DATEX catalog redirected; redirects are not followed',
|
||||
);
|
||||
return discardBoth();
|
||||
}
|
||||
if (!locResp.ok) {
|
||||
console.warn('[CCTV] Tarktee locations download failed:', locResp.status);
|
||||
return discardBoth();
|
||||
}
|
||||
if (!imgResp.ok) {
|
||||
console.warn('[CCTV] Tarktee images download failed:', imgResp.status);
|
||||
return discardBoth();
|
||||
}
|
||||
let locXml;
|
||||
let imgXml;
|
||||
const readAbort = new AbortController();
|
||||
try {
|
||||
[locXml, imgXml] = await Promise.all([
|
||||
readResponseTextCapped(
|
||||
locResp,
|
||||
TARKTEE_MAX_CATALOG_BYTES,
|
||||
readAbort.signal,
|
||||
),
|
||||
readResponseTextCapped(
|
||||
imgResp,
|
||||
TARKTEE_MAX_CATALOG_BYTES,
|
||||
readAbort.signal,
|
||||
),
|
||||
]);
|
||||
} catch (error) {
|
||||
readAbort.abort();
|
||||
await discardBoth();
|
||||
throw error;
|
||||
}
|
||||
const locations = parseTarkteeDatexLocations(locXml);
|
||||
const images = parseTarkteeDatexImages(imgXml);
|
||||
if (!locations.size || !images.size) {
|
||||
@@ -1366,6 +1352,7 @@ export async function loadTarkteeSourcesFromDatex() {
|
||||
);
|
||||
return prioritized;
|
||||
} catch (error) {
|
||||
await discardBoth();
|
||||
console.warn(
|
||||
'[CCTV] Tarktee DATEX download error:',
|
||||
error?.message || error,
|
||||
|
||||
Reference in new issue
Block a user