mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
Cover every Norwegian station-based city-bike system with an open GBFS feed: Kolumbus Bysykkel (Stavanger) and Farte Bysykkel (Skien/Porsgrunn) through Entur Mobility, and Bysykkel Lillestrøm through JCDecaux Cyclocity. api.entur.io and api.cyclocity.fr join the GBFS allowlist, each kept to its GBFS path prefix since both hosts front other APIs. Entur serves its feeds without a .json suffix, so the endpoint checks accept either form, and requests to Entur carry the ET-Client-Name header it requires. Attributed under NLOD (Entur) and Licence Ouverte (JCDecaux). Co-Authored-By: Claude Opus 5.5 <[email protected]>
307 lines
9.1 KiB
JavaScript
307 lines
9.1 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;
|
|
}
|
|
});
|