Files
gods-eye-view/server/providers/news.test.mjs
T

221 lines
6.8 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { newsOrigin, newsProxy } from './news.js';
import { createKeyedNews } from './news/keyed.js';
/** Mount the proxy on a fake middleware stack and call it like Connect does. */
function mount(options) {
let handler = null;
newsProxy(options).configureServer({
middlewares: {
use(prefix, fn) {
assert.equal(prefix, '/api/news');
handler = fn;
},
},
});
return async (url, method = 'GET') => {
const res = {
destroyed: false,
status: 0,
headers: {},
body: '',
writeHead(status, headers) {
this.status = status;
this.headers = headers;
},
end(body) {
this.body = body;
},
};
await handler({ url, method }, res);
return { ...res, json: res.body ? JSON.parse(res.body) : null };
};
}
test('the origin must be a plain http(s) origin', () => {
assert.equal(
newsOrigin('http://news.example:8080'),
'http://news.example:8080',
);
assert.equal(
newsOrigin('https://news.example/base/'),
'https://news.example',
);
for (const bad of [
undefined,
'',
' ',
'ftp://x',
'http://u:p@x',
'http://x/?a=1',
'http://x/#f',
'not a url',
])
assert.equal(newsOrigin(bad), null, String(bad));
});
test('without GEV_NEWS_ORIGIN Core answers itself: Conflicts from GDELT, Local saying it needs its key', async () => {
const hosts = [];
const fetchImpl = async (url) => {
hosts.push(new URL(String(url)).host);
throw new Error('offline');
};
const call = mount({
origin: () => null,
fetchImpl,
keyed: createKeyedNews({ env: () => ({}), fetchImpl, log: () => {} }),
});
// GDELT's exports need no key; offline, the read fails like any upstream.
const hotspots = await call('/hotspots');
assert.equal(hotspots.status, 502);
assert.deepEqual(hotspots.json, { error: 'news_upstream_error' });
const local = await call('/feed?hotspot=all&at=31.95,35.93');
assert.equal(local.status, 503);
assert.deepEqual(local.json, { error: 'no_key', key: 'worldnewsapi' });
// The area is read from the bundled countries, with no key.
const area = await call('/outline?at=31.95,35.93&v=4');
assert.equal(area.status, 200);
assert.equal(area.json.area.name, 'Jordan');
// The client's allowlist still holds.
assert.equal((await call('/feed?side=UA')).status, 400);
assert.ok(hosts.length > 0);
assert.ok(hosts.every((host) => host === 'data.gdeltproject.org'));
});
test('forwards only the client routes, by GET, and relays the JSON', async () => {
const calls = [];
const call = mount({
origin: () => 'https://news.example',
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init });
return new Response('{"reports":[],"nextBefore":null}', { status: 200 });
},
});
const ok = await call('/feed?hotspot=all&limit=60&today=1&events=1');
assert.equal(ok.status, 200);
assert.deepEqual(ok.json, { reports: [], nextBefore: null });
assert.equal(ok.headers['Cache-Control'], 'no-store');
assert.equal(
calls[0].url,
'https://news.example/api/news/feed?hotspot=all&limit=60&today=1&events=1',
);
assert.equal(calls[0].init.redirect, 'error');
assert.ok(calls[0].init.signal instanceof AbortSignal);
for (const url of [
'/air-alerts',
'/feed?side=UA',
'/feed?limit=9999',
'/../admin',
'/hotspots?x=1',
]) {
const refused = await call(url);
assert.equal(refused.status, 400, url);
}
const post = await call('/hotspots', 'POST');
assert.equal(post.status, 405);
assert.equal(post.headers.Allow, 'GET');
assert.equal(calls.length, 1);
});
test('upstream failures, bad JSON and timeouts become bounded errors', async () => {
const failing = mount({
origin: () => 'https://news.example',
fetchImpl: async () => new Response('down', { status: 500 }),
});
const down = await failing('/hotspots');
assert.equal(down.status, 502);
assert.deepEqual(down.json, { error: 'news_upstream_error', status: 500 });
const garbled = mount({
origin: () => 'https://news.example',
fetchImpl: async () => new Response('<html>', { status: 200 }),
});
assert.equal((await garbled('/hotspots')).status, 502);
const slow = mount({
origin: () => 'https://news.example',
timeoutMs: 10,
// AbortSignal.timeout does not hold the event loop open: the hanging
// fetch does, until its signal aborts, so the run cannot end before it.
fetchImpl: (_url, { signal }) =>
new Promise((_, reject) => {
const alive = setTimeout(() => {}, 60_000);
signal.addEventListener('abort', () => {
clearTimeout(alive);
reject(signal.reason);
});
}),
});
const timedOut = await slow('/hotspots');
assert.equal(timedOut.status, 504);
assert.deepEqual(timedOut.json, { error: 'news_unavailable' });
});
test('a GEV_NEWS_ORIGIN that is no plain origin is warned of once, and Core answers itself', async () => {
const logs = [];
const asked = [];
const call = mount({
env: () => ({ GEV_NEWS_ORIGIN: 'https://user:[email protected]' }),
log: (line) => logs.push(line),
fetchImpl: async () => assert.fail('nothing is forwarded'),
keyed: {
answer: async (path) => {
asked.push(path);
return { status: 200, body: { reports: [], nextBefore: null } };
},
},
});
assert.equal((await call('/feed?hotspot=all')).status, 200);
assert.equal((await call('/feed?hotspot=all')).status, 200);
assert.deepEqual(asked, ['/feed', '/feed']);
assert.equal(logs.length, 1);
assert.doesNotMatch(logs[0], /secret|news\.example/);
});
test('a built-in answer that throws is a 502, logged in one line without addresses', async () => {
const logs = [];
const call = mount({
env: () => ({}),
log: (line) => logs.push(line),
keyed: {
answer: async () => {
throw new TypeError('bad https://api.example/?key=abc\nmore');
},
},
});
const failed = await call('/hotspots');
assert.equal(failed.status, 502);
assert.deepEqual(failed.json, { error: 'news_unavailable' });
assert.equal(logs.length, 1);
assert.match(logs[0], /TypeError/);
assert.doesNotMatch(logs[0], /key=abc|\n/);
});
test('a host that serves news only from its own server turns the built-in backend off', async () => {
let fetches = 0;
const call = mount({
origin: () => null,
builtIn: false,
fetchImpl: async () => {
fetches++;
throw new Error('unexpected');
},
});
for (const url of [
'/hotspots',
'/feed?hotspot=all',
'/outline?at=31.95,35.93&v=4',
]) {
const answer = await call(url);
assert.equal(answer.status, 503, url);
assert.deepEqual(answer.json, {
error: 'news_not_configured',
unavailable: true,
retryable: false,
});
}
assert.equal(fetches, 0);
});