Files
gods-eye-view/src/data/gbfsProxy.test.mjs
T
Sameh Khamis 0ad75095a7 Validate provider response formats
CCTV frames and the Street View fallback accept only JPEG, PNG, GIF, WebP and
AVIF, parsed from the media type essence and sent under the canonical type;
any other type is cancelled and falls through to the next fallback as a
failed upstream does. Live media must match its feed kind (video/mp4,
video/webm, or multipart/x-mixed-replace with a boundary) before the camera
is reported healthy or any byte is relayed, and its redirects now stay on the
registered origin like frames. Every /api/cctv route uses the same-site gate.

The GBFS proxy relays only bodies that parse as JSON, re-serialized under a
fixed application/json type.
2026-10-21 15:07:20 -07:00

365 lines
11 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
GBFS_CLIENT_IDENTIFIER,
GBFS_MAX_BODY_BYTES,
fetchGbfsUpstream,
} from '../../server/providers/gbfs.js';
const STATION_URL = 'https://gbfs.lyft.com/gbfs/2.3/bay/en/station_status.json';
const STATION_BODY = '{"data":{"stations":[]}}';
/**
* A Response whose body streams `chunk` forever until the reader cancels it.
* A zero high-water mark keeps the stream from pulling eagerly at construction,
* so `pulls()` counts only the chunks a reader actually asked for.
*/
function endlessResponse(chunk, onCancel) {
let pulls = 0;
const stream = new ReadableStream(
{
pull(controller) {
pulls += 1;
controller.enqueue(chunk);
},
cancel: onCancel,
},
{ highWaterMark: 0 },
);
return {
response: new Response(stream, { status: 200 }),
pulls: () => pulls,
};
}
test('GBFS upstream fetch passes a direct 200 through unchanged', async () => {
let observedOptions = null;
const result = await fetchGbfsUpstream(STATION_URL, {
fetchImpl: async (_url, options) => {
observedOptions = options;
return new Response(STATION_BODY, {
status: 200,
headers: { 'Content-Type': 'application/json; charset=utf-8' },
});
},
});
assert.equal(
observedOptions.redirect,
'manual',
'fetch must not follow redirects on its own',
);
assert.ok(observedOptions.signal instanceof AbortSignal);
assert.deepEqual(result, {
status: 200,
contentType: 'application/json; charset=utf-8',
body: STATION_BODY,
});
});
test('GBFS upstream fetch names the app in the Client-Identifier header', async () => {
let observedHeaders = null;
await fetchGbfsUpstream(
'https://gbfs.urbansharing.com/oslobysykkel.no/station_status.json',
{
fetchImpl: async (_url, options) => {
observedHeaders = options.headers;
return new Response(STATION_BODY, { status: 200 });
},
},
);
assert.equal(GBFS_CLIENT_IDENTIFIER, 'gods-eye-view');
assert.equal(observedHeaders['Client-Identifier'], GBFS_CLIENT_IDENTIFIER);
});
test('GBFS upstream fetch adds ET-Client-Name for Entur and only for Entur', async () => {
const headersFor = async (url) => {
let observed = null;
await fetchGbfsUpstream(url, {
fetchImpl: async (_url, options) => {
observed = options.headers;
return new Response(STATION_BODY, { status: 200 });
},
});
return observed;
};
const entur = await headersFor(
'https://api.entur.io/mobility/v2/gbfs/v2/kolumbusbysykkel/station_status',
);
assert.equal(entur['ET-Client-Name'], 'gods-eye-view-bikeshare');
assert.equal(entur['Client-Identifier'], GBFS_CLIENT_IDENTIFIER);
const lyft = await headersFor(STATION_URL);
assert.equal(lyft['ET-Client-Name'], undefined);
});
test('GBFS upstream fetch forwards a non-redirect error status for the middleware to relay', async () => {
const result = await fetchGbfsUpstream(STATION_URL, {
fetchImpl: async () => new Response(null, { status: 404 }),
});
assert.equal(result.status, 404);
assert.equal(
result.contentType,
'application/json',
'a missing upstream content type defaults to JSON',
);
assert.equal(result.body, '');
});
test('GBFS upstream fetch rejects every redirect status and never fetches the target', async () => {
for (const status of [301, 302, 303, 307, 308]) {
let calls = 0;
await assert.rejects(
fetchGbfsUpstream(STATION_URL, {
fetchImpl: async () => {
calls += 1;
return new Response('moved', {
status,
headers: {
Location: 'https://attacker.example/station_status.json',
},
});
},
}),
(error) =>
error?.code === 'GBFS_REDIRECT' &&
error.redirectHost === 'attacker.example',
`status ${status} must be refused`,
);
assert.equal(
calls,
1,
`status ${status}: the redirect target must not be requested`,
);
}
});
test('GBFS upstream fetch names the host of a relative redirect', async () => {
await assert.rejects(
fetchGbfsUpstream(STATION_URL, {
fetchImpl: async () =>
new Response(null, {
status: 302,
headers: { Location: '/v2/station_status.json' },
}),
}),
(error) =>
error?.code === 'GBFS_REDIRECT' && error.redirectHost === 'gbfs.lyft.com',
);
});
test('GBFS upstream fetch rejects an oversized Content-Length before reading the body', async () => {
const { response, pulls } = endlessResponse(new Uint8Array(1024));
response.headers.set('Content-Length', String(GBFS_MAX_BODY_BYTES + 1));
await assert.rejects(
fetchGbfsUpstream(STATION_URL, { fetchImpl: async () => response }),
(error) => error?.code === 'RESPONSE_TOO_LARGE',
);
assert.equal(
pulls(),
0,
'the body must not be read when the declared length already exceeds the cap',
);
});
test('GBFS upstream fetch cancels a length-less body the moment it passes the cap', async () => {
let cancelled = false;
const chunk = new Uint8Array(1024);
const { response, pulls } = endlessResponse(chunk, () => {
cancelled = true;
});
await assert.rejects(
fetchGbfsUpstream(STATION_URL, {
fetchImpl: async () => response,
maxBytes: 4 * chunk.byteLength,
}),
(error) => error?.code === 'RESPONSE_TOO_LARGE',
);
assert.equal(
cancelled,
true,
'the upstream stream must be cancelled, not drained',
);
assert.ok(
pulls() <= 8,
`only a few chunks may be read past the cap, saw ${pulls()}`,
);
});
test('GBFS upstream fetch measures the cap in bytes and accepts a body exactly at it', async () => {
const body = 'é'.repeat(64);
const result = await fetchGbfsUpstream(STATION_URL, {
fetchImpl: async () => new Response(body, { status: 200 }),
maxBytes: Buffer.byteLength(body),
});
assert.equal(result.body, body);
assert.equal(GBFS_MAX_BODY_BYTES, 5 * 1024 * 1024);
});
test('GBFS upstream fetch aborts a stalled connection with the timeout signal', async () => {
const startedAt = Date.now();
await assert.rejects(
fetchGbfsUpstream(STATION_URL, {
timeoutMs: 20,
fetchImpl: (_url, options) =>
new Promise((_resolve, reject) => {
options.signal.addEventListener(
'abort',
() => reject(options.signal.reason),
{ once: true },
);
}),
}),
(error) => error?.name === 'AbortError',
);
assert.ok(Date.now() - startedAt < 500, 'the timeout must settle promptly');
});
test('GBFS cancels an oversized declared body without pulling it', async () => {
let cancelled = false;
const { response, pulls } = endlessResponse(new Uint8Array(1), () => {
cancelled = true;
});
response.headers.set('Content-Length', '100');
await assert.rejects(
fetchGbfsUpstream(STATION_URL, {
maxBytes: 10,
fetchImpl: async () => response,
}),
{ code: 'RESPONSE_TOO_LARGE' },
);
assert.equal(cancelled, true);
assert.equal(pulls(), 0);
});
test('GBFS deadline includes a stalled body after successful headers', async () => {
let cancelled = false;
let signal;
const response = new Response(
new ReadableStream({
pull() {},
cancel() {
cancelled = true;
},
}),
);
await assert.rejects(
fetchGbfsUpstream(STATION_URL, {
timeoutMs: 20,
fetchImpl: async (_url, options) => {
signal = options.signal;
return response;
},
}),
{ name: 'AbortError' },
);
assert.equal(signal.aborted, true);
assert.equal(cancelled, true);
assert.equal(response.body.locked, false);
});
test('GBFS rejects redirects and caps bodies through both server hooks', async () => {
const { gbfsProxy } = await import('../../server/providers/gbfs.js');
const originalFetch = globalThis.fetch;
try {
for (const hook of ['configureServer', 'configurePreviewServer']) {
let handler;
gbfsProxy()[hook]({
middlewares: {
use(path, callback) {
assert.equal(path, '/api/gbfs');
handler = callback;
},
},
});
for (const response of [
new Response(null, { status: 302 }),
new Response('x', {
headers: { 'Content-Length': String(GBFS_MAX_BODY_BYTES + 1) },
}),
new Response(STATION_BODY),
]) {
const expectedStatus =
response.status === 302 || response.headers.has('content-length')
? 502
: 200;
globalThis.fetch = async () => response;
let status, body;
await handler(
{ method: 'GET', url: '/' + encodeURIComponent(STATION_URL) },
{
writeHead(code) {
status = code;
},
end(value) {
body = value;
},
},
);
assert.equal(status, expectedStatus);
if (status === 200) assert.equal(body, STATION_BODY);
else assert.equal(typeof JSON.parse(body).error, 'string');
}
}
} finally {
globalThis.fetch = originalFetch;
}
});
test('GBFS relays only JSON, re-serialized under a fixed type, through both server hooks', async () => {
const { gbfsProxy } = await import('../../server/providers/gbfs.js');
const originalFetch = globalThis.fetch;
try {
for (const hook of ['configureServer', 'configurePreviewServer']) {
let handler;
gbfsProxy()[hook]({
middlewares: {
use(_path, callback) {
handler = callback;
},
},
});
const request = async (response) => {
globalThis.fetch = async () => response;
const res = {};
await handler(
{ method: 'GET', url: '/' + encodeURIComponent(STATION_URL) },
{
writeHead(code, headers) {
Object.assign(res, { status: code, headers });
},
end(value) {
res.body = value;
},
},
);
return res;
};
for (const [contentType, body] of [
['text/html', '<!doctype html><p>x</p>'],
['application/json', '<!doctype html><p>x</p>'],
['image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg"/>'],
['application/json', ''],
]) {
const res = await request(
new Response(body, { headers: { 'Content-Type': contentType } }),
);
assert.equal(res.status, 502, `${hook}: ${contentType} ${body}`);
assert.equal(res.headers['Content-Type'], 'application/json');
assert.equal(typeof JSON.parse(res.body).error, 'string');
assert.doesNotMatch(res.body, /doctype|svg/);
}
const res = await request(
new Response(' { "data" : { "stations" : [] } } ', {
headers: { 'Content-Type': 'text/html; charset=iso-8859-1' },
}),
);
assert.equal(res.status, 200);
assert.equal(res.headers['Content-Type'], 'application/json; charset=utf-8');
assert.equal(res.headers['Cache-Control'], 'no-store');
assert.equal(res.body, STATION_BODY);
}
} finally {
globalThis.fetch = originalFetch;
}
});