474 lines
24 KiB
JavaScript
474 lines
24 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const { fetchApiSourceResponse, buildApiSourcePayload, testApiSourceConnection } = require('../src/data/api-sources');
|
|
|
|
function jsonResponse(status, body) {
|
|
return {
|
|
status: status,
|
|
ok: status >= 200 && status < 300,
|
|
headers: { get: () => 'application/json' },
|
|
text: async () => JSON.stringify(body)
|
|
};
|
|
}
|
|
|
|
test('requests in one API group share a token while keeping separate responses', async () => {
|
|
const originalFetch = global.fetch;
|
|
let logins = 0;
|
|
global.fetch = async function (url, options) {
|
|
if (url.endsWith('/login')) {
|
|
logins += 1;
|
|
return jsonResponse(200, { access_token: 'group-token', expires_in: 600 });
|
|
}
|
|
assert.equal(options.headers.Authorization, 'Bearer group-token');
|
|
return jsonResponse(200, { request: url });
|
|
};
|
|
const auth = { auth_source_id: 876543, auth_method: 'token_login', token_url: 'https://example.com/login', token_request_body_json: '{"client_secret":"group-secret"}' };
|
|
try {
|
|
const [event, donors] = await Promise.all([
|
|
fetchApiSourceResponse({ ...auth, id: 876544, api_url: 'https://example.com/event' }),
|
|
fetchApiSourceResponse({ ...auth, id: 876545, api_url: 'https://example.com/donors' })
|
|
]);
|
|
assert.equal(logins, 1);
|
|
assert.equal(JSON.parse(event.responseJson).request, 'https://example.com/event');
|
|
assert.equal(JSON.parse(donors.responseJson).request, 'https://example.com/donors');
|
|
await testApiSourceConnection({ ...auth, id: 876544, api_url: 'https://example.com/event' });
|
|
await fetchApiSourceResponse({ ...auth, id: 876545, api_url: 'https://example.com/donors' });
|
|
assert.equal(logins, 2);
|
|
} finally { global.fetch = originalFetch; }
|
|
});
|
|
|
|
test('connection test identifies a token endpoint 404 and redacts credentials', async () => {
|
|
const originalFetch = global.fetch;
|
|
const calls = [];
|
|
global.fetch = async function (url) {
|
|
calls.push(url);
|
|
return jsonResponse(404, { error: 'not_found', detail: 'No route for private-client-secret', access_token: 'server-token' });
|
|
};
|
|
try {
|
|
const result = await testApiSourceConnection({
|
|
api_url: 'https://example.com/campaign', auth_method: 'token_login',
|
|
token_url: 'https://example.com/wrong-token-url?key=hidden-query',
|
|
token_request_body_json: '{"client_secret":"private-client-secret"}'
|
|
});
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.steps.length, 1);
|
|
assert.equal(result.steps[0].stage, 'Authentication');
|
|
assert.equal(result.steps[0].status, 404);
|
|
assert.match(result.error, /Check the endpoint URL and API version/);
|
|
assert.match(result.error, /https:\/\/example.com\/wrong-token-url/);
|
|
assert.match(result.error, /not_found/);
|
|
assert.equal(calls.length, 1);
|
|
assert.doesNotMatch(JSON.stringify(result), /private-client-secret|server-token|hidden-query/);
|
|
} finally { global.fetch = originalFetch; }
|
|
});
|
|
|
|
test('token diagnostics preserve null/empty refresh tokens and OAuth metadata but mask real tokens', async () => {
|
|
const originalFetch = global.fetch;
|
|
let refreshToken = null;
|
|
global.fetch = async function (url) {
|
|
if (url.endsWith('/login')) return jsonResponse(200, {
|
|
scope: 'public', access_token: 'private-access-token', refresh_token: refreshToken,
|
|
expires_in: 7200, token_type: 'bearer', created_at: '2026-10-20T22:04:47Z'
|
|
});
|
|
return jsonResponse(200, { data: [] });
|
|
};
|
|
const source = {
|
|
api_url: 'https://example.com/data', auth_method: 'token_login',
|
|
token_url: 'https://example.com/login',
|
|
token_request_body_json: '{"client_secret":"private-client-secret","grant_type":"client_credentials","scope":"public"}'
|
|
};
|
|
try {
|
|
for (const value of [null, '', 'private-refresh-token']) {
|
|
refreshToken = value;
|
|
const result = await testApiSourceConnection(source);
|
|
assert.equal(result.ok, true);
|
|
const response = JSON.parse(result.steps[0].response);
|
|
assert.equal(response.scope, 'public');
|
|
assert.equal(response.token_type, 'bearer');
|
|
assert.equal(response.expires_in, 7200);
|
|
assert.equal(response.refresh_token, value ? '[REDACTED]' : value);
|
|
assert.equal(response.access_token, '[REDACTED]');
|
|
assert.doesNotMatch(JSON.stringify(result), /private-access-token|private-refresh-token|private-client-secret/);
|
|
}
|
|
} finally { global.fetch = originalFetch; }
|
|
});
|
|
|
|
test('connection tests use fresh authentication and check the items path', async () => {
|
|
const originalFetch = global.fetch;
|
|
let logins = 0;
|
|
global.fetch = async function (url) {
|
|
if (url.endsWith('/login')) {
|
|
logins += 1;
|
|
return jsonResponse(200, { access_token: 'private-access-token', refresh_token: 'private-refresh-token' });
|
|
}
|
|
return jsonResponse(200, { data: [{ name: 'Campaign', total: 42 }], token: 'private-access-token' });
|
|
};
|
|
const source = {
|
|
id: 345678, api_url: 'https://example.com/campaign', auth_method: 'token_login',
|
|
token_url: 'https://example.com/login', token_request_body_json: '{"client_secret":"private-secret"}', items_path: 'data'
|
|
};
|
|
try {
|
|
await fetchApiSourceResponse(source);
|
|
const result = await testApiSourceConnection(source);
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.steps.length, 3);
|
|
assert.match(result.steps[2].message, /1 items/);
|
|
assert.ok(result.paths.includes('data.0.total'));
|
|
assert.doesNotMatch(JSON.stringify(result), /private-access-token|private-refresh-token|private-secret/);
|
|
await fetchApiSourceResponse(source);
|
|
assert.equal(logins, 2);
|
|
const missing = await testApiSourceConnection({ ...source, items_path: 'missing' });
|
|
assert.equal(missing.ok, false);
|
|
assert.match(missing.steps[2].message, /did not match/);
|
|
} finally { global.fetch = originalFetch; }
|
|
});
|
|
|
|
test('API source payload accepts POST request and token-login settings', () => {
|
|
const payload = buildApiSourcePayload({
|
|
body: {
|
|
name: 'Protected report',
|
|
api_url: 'https://example.com/report',
|
|
request_method: 'POST',
|
|
request_body_json: '{"site_id":42}',
|
|
auth_method: 'token_login',
|
|
token_url: 'https://example.com/login',
|
|
token_request_body_json: '{"username":"demo","password":"secret"}',
|
|
token_response_path: 'data.accessToken',
|
|
token_refresh_url: 'https://example.com/refresh',
|
|
token_refresh_request_body_json: '{"grant_type":"refresh_token","refresh_token":"{{refresh_token}}"}',
|
|
token_refresh_response_path: 'data.refreshToken',
|
|
token_header_name: 'Authorization',
|
|
token_header_prefix: 'Bearer',
|
|
update_interval_value: '5',
|
|
update_interval_unit: 'minutes'
|
|
}
|
|
});
|
|
|
|
assert.equal(payload.requestMethod, 'POST');
|
|
assert.equal(payload.requestBodyJson, '{"site_id":42}');
|
|
assert.equal(payload.authMethod, 'token_login');
|
|
assert.equal(payload.tokenUrl, 'https://example.com/login');
|
|
assert.equal(payload.tokenResponsePath, 'data.accessToken');
|
|
assert.equal(payload.tokenRefreshUrl, 'https://example.com/refresh');
|
|
assert.equal(payload.tokenRefreshResponsePath, 'data.refreshToken');
|
|
});
|
|
|
|
test('connection test redacts custom token paths and reports invalid data JSON separately', async () => {
|
|
const originalFetch = global.fetch;
|
|
global.fetch = async function (url) {
|
|
if (url.endsWith('/login')) return jsonResponse(200, { session: { value: 'custom-private-token' } });
|
|
return { status: 200, ok: true, headers: { get: () => 'text/html' }, text: async () => '<html>Not JSON</html>' };
|
|
};
|
|
try {
|
|
const result = await testApiSourceConnection({
|
|
api_url: 'https://example.com/data', auth_method: 'token_login',
|
|
token_url: 'https://example.com/login', token_request_body_json: '{"secret":"private-secret"}',
|
|
token_response_path: 'session.value'
|
|
});
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.steps[0].ok, true);
|
|
assert.equal(result.steps[1].ok, true);
|
|
assert.equal(result.steps[2].stage, 'JSON / items extraction');
|
|
assert.equal(result.steps[2].ok, false);
|
|
assert.doesNotMatch(JSON.stringify(result), /custom-private-token|<html>/);
|
|
} finally { global.fetch = originalFetch; }
|
|
});
|
|
|
|
test('connection test routes enforce create/update permission and never persist data', async () => {
|
|
const registerRoutes = require('../src/web/routes/data-sources/api-sources/routes');
|
|
const handlers = {};
|
|
const app = { get() {}, post(path, ...routeHandlers) { handlers[path] = routeHandlers; } };
|
|
registerRoutes(app, {
|
|
pool: new Proxy({}, { get() { throw new Error('Diagnostic route must not access the database'); } }),
|
|
common: {}, requirePermission(permission) { return permission; }
|
|
});
|
|
assert.equal(handlers['/data-sources/api-sources/test'][0], 'api-sources.create');
|
|
assert.equal(handlers['/data-sources/api-sources/:id/test'][0], 'api-sources.update');
|
|
const originalFetch = global.fetch;
|
|
global.fetch = async () => jsonResponse(200, { data: [{ total: 42 }] });
|
|
const res = {
|
|
headers: {}, statusCode: 200,
|
|
set(key, value) { this.headers[key] = value; return this; },
|
|
status(value) { this.statusCode = value; return this; },
|
|
json(value) { this.body = value; return this; }
|
|
};
|
|
try {
|
|
await handlers['/data-sources/api-sources/test'][1]({ body: { api_url: 'https://example.com/data', items_path: 'data' } }, res);
|
|
assert.equal(res.body.ok, true);
|
|
assert.equal(res.headers['Cache-Control'], 'no-store');
|
|
await handlers['/data-sources/api-sources/:id/test'][1]({ body: { api_url: 'invalid' } }, res);
|
|
assert.equal(res.statusCode, 400);
|
|
assert.match(res.body.error, /valid API URL/);
|
|
} finally { global.fetch = originalFetch; }
|
|
});
|
|
|
|
test('API source add and edit forms render connection diagnostics with the correct endpoint', () => {
|
|
const renderAdd = require('../src/web/routes/data-sources/api-sources/add');
|
|
const renderEdit = require('../src/web/routes/data-sources/api-sources/edit');
|
|
const user = { permissionKeys: ['api-sources.create', 'api-sources.update'] };
|
|
const add = renderAdd({}, '', user);
|
|
const edit = renderEdit({ id: 42, name: 'Campaign' }, {}, '', user);
|
|
const preview = renderEdit({ id: 42, name: 'Campaign' }, {}, '', {
|
|
isViewingAs: true,
|
|
permissionKeys: ['api-sources.update', 'api-sources.allow', 'api-sources.delete']
|
|
});
|
|
assert.match(add, /<p>Configure shared authentication and the requests for this API source\.<\/p>/);
|
|
assert.match(edit, /<p>Manage shared authentication, request URLs, and latest responses\.<\/p>/);
|
|
assert.match(add, /data-api-source-test-url="\/data-sources\/api-sources\/test"/);
|
|
assert.match(edit, /data-api-source-test-url="\/data-sources\/api-sources\/42\/test"/);
|
|
for (const html of [add, edit]) {
|
|
assert.match(html, /<form id="api-source-form" method="post" action="[^"]+" data-async-save/);
|
|
assert.match(html, /data-api-source-diagnostics hidden/);
|
|
assert.match(html, /data-api-source-copy-report disabled/);
|
|
assert.match(html, /data-api-source-test-status role="status"/);
|
|
assert.match(html, /class="card card-outline card-info admin-form-card mt-3" data-api-source-diagnostics/);
|
|
assert.match(html, /data-api-source-diagnostics[^>]*>\s*<div class="card-header bg-body-tertiary d-flex align-items-center row-gap-2 flex-wrap">/);
|
|
assert.match(html, /type="button"[^>]*data-api-source-dismiss-diagnostics[^>]*aria-label="Dismiss connection test"/);
|
|
assert.match(html, /class="btn btn-tool p-0 m-0" data-api-source-dismiss-diagnostics/);
|
|
const connectionActions = html.slice(html.indexOf('data-api-auth-test-url'), html.indexOf('data-api-source-auth-card'));
|
|
assert.match(connectionActions, />Save</);
|
|
assert.match(connectionActions, />Cancel</);
|
|
assert.match(connectionActions, />Delete</);
|
|
assert.match(connectionActions, /data-api-request-add/);
|
|
assert.match(html, /card-footer d-flex align-items-center justify-content-between row-gap-2 flex-wrap/);
|
|
assert.match(html, /class="card-tools d-flex align-items-center ms-auto me-0 flex-shrink-0">\s*<button[^>]*data-api-request-remove/);
|
|
assert.match(html, /aria-label="Delete request"><i[^>]*><\/i>Delete<\/button>/);
|
|
assert.match(html, /class="form-check-input float-none ms-0 mt-0 flex-shrink-0" data-request-field="enabled"/);
|
|
assert.match(html, /class="col-12 col-lg-9"><label class="form-label w-100">Request name/);
|
|
assert.match(html, /class="col-12 col-lg-9"><label class="form-label w-100">API URL/);
|
|
assert.match(html, /api-source-auth-card collapsed-card" data-api-source-auth-card/);
|
|
assert.doesNotMatch(html, />Authentication details<\/h4>/);
|
|
assert.doesNotMatch(html, /data-api-requests-header/);
|
|
assert.match(html, /data-api-request-title>Request - /);
|
|
}
|
|
assert.match(preview, /data-manual-refresh-url="\/data-sources\/api-sources\/42\/requests\/42\/refresh"[^>]*disabled/);
|
|
assert.match(preview, /data-api-source-test-url="\/data-sources\/api-sources\/42\/test"[^>]*disabled/);
|
|
assert.match(preview, /data-api-auth-test-url="\/data-sources\/api-sources\/42\/auth-test"[^>]*disabled/);
|
|
assert.match(preview, /data-async-data-source-toggle[^>]*disabled/);
|
|
assert.match(preview, /data-api-request-add disabled/);
|
|
assert.match(preview, /data-api-request-remove data-request-protected disabled/);
|
|
});
|
|
|
|
test('saved requests show visible full-width response trees with independent expand controls', () => {
|
|
const renderEdit = require('../src/web/routes/data-sources/api-sources/edit');
|
|
const html = renderEdit({ id: 42, name: 'Campaign', requests: [
|
|
{ id: 43, name: 'Event', last_response_status: 200, last_response_json: '{"data":{"total":42}}' },
|
|
{ id: 44, name: 'Donors', last_response_status: 200, last_response_json: '{"data":[{"name":"Donor"}]}' }
|
|
] }, {}, '', { permissionKeys: ['api-sources.update', 'api-sources.allow'] });
|
|
assert.equal((html.match(/data-api-request-response/g) || []).length, 2);
|
|
assert.equal((html.match(/data-json-toggle-expand-all/g) || []).length, 2);
|
|
assert.equal((html.match(/data-json-toggle-collapse-all/g) || []).length, 2);
|
|
assert.doesNotMatch(html, /<summary>Latest response/);
|
|
assert.doesNotMatch(html, /max-height:24rem;overflow:auto" data-json-toggle-output/);
|
|
assert.match(html, /HTTP 200/);
|
|
assert.match(html, /data-api-request-title>Request - Event<\/h3>/);
|
|
assert.match(html, /data-api-request-title>Request - Donors<\/h3>/);
|
|
for (const id of [43, 44]) {
|
|
assert.ok(html.includes('id="api-request-response-' + id + '"'));
|
|
assert.ok(html.includes('data-manual-refresh-target="#api-request-response-' + id + '"'));
|
|
}
|
|
});
|
|
|
|
test('the Connection master toggle disables refresh actions without changing request switches', () => {
|
|
const renderEdit = require('../src/web/routes/data-sources/api-sources/edit');
|
|
const user = { permissionKeys: ['api-sources.update', 'api-sources.allow'] };
|
|
const requests = [{ id: 43, name: 'Event', enabled: 1 }];
|
|
const enabled = renderEdit({ id: 5, name: 'Tiltify', enabled: 1, requests }, {}, '', user);
|
|
const disabled = renderEdit({ id: 5, name: 'Tiltify', enabled: 0, requests }, {}, '', user);
|
|
assert.match(enabled, /form="api-source-toggle-form" data-async-data-source-toggle data-enabled="true"/);
|
|
assert.match(disabled, /form="api-source-toggle-form" data-async-data-source-toggle data-enabled="false"/);
|
|
assert.match(disabled, /data-request-field="enabled" checked/);
|
|
assert.match(disabled, /data-source-item-enabled="true" disabled/);
|
|
assert.match(disabled, /data-async-command><input type="hidden" name="data_source_action" value="toggle"/);
|
|
for (const html of [enabled, disabled]) {
|
|
assert.ok(html.indexOf('data-api-auth-test-url') < html.indexOf('data-async-data-source-toggle'));
|
|
assert.ok(html.indexOf('data-async-data-source-toggle') < html.indexOf('data-api-request-add'));
|
|
}
|
|
});
|
|
|
|
test('connection diagnostics follow the tested section and can be dismissed and reopened', async () => {
|
|
const fs = require('node:fs');
|
|
const vm = require('node:vm');
|
|
const handlers = {};
|
|
const placements = [];
|
|
let requestCard = null;
|
|
const connectionCard = { after() { placements.push('connection'); } };
|
|
const authCard = { hidden: false, after() { placements.push('authentication'); } };
|
|
let focusCount = 0;
|
|
const testButton = {
|
|
addEventListener(event, handler) { handlers.test = handler; },
|
|
getAttribute() { return '/data-sources/api-sources/test'; },
|
|
closest(selector) { return selector === '[data-api-request-card]' ? requestCard : connectionCard; },
|
|
focus() { focusCount += 1; }
|
|
};
|
|
const diagnostics = { hidden: true, setAttribute() {} };
|
|
const elements = {
|
|
'[data-api-source-auth-method]': { value: 'bearer', addEventListener() {} },
|
|
'[data-api-source-auth-card]': authCard,
|
|
'[data-api-source-test-url]': testButton,
|
|
'[data-api-source-diagnostics]': diagnostics,
|
|
'[data-api-source-test-status]': {},
|
|
'[data-api-source-test-results]': { replaceChildren() {} },
|
|
'[data-api-source-copy-report]': { addEventListener() {} },
|
|
'[data-api-source-dismiss-diagnostics]': { addEventListener(event, handler) { handlers.dismiss = handler; } }
|
|
};
|
|
const form = { querySelector(selector) { return elements[selector] || null; }, querySelectorAll() { return []; } };
|
|
vm.runInNewContext(fs.readFileSync(require.resolve('../src/web/public/js/data-sources/api-source-form.js'), 'utf8'), {
|
|
document: { getElementById() { return form; } },
|
|
FormData: class { [Symbol.iterator]() { return [][Symbol.iterator](); } },
|
|
fetch: async () => ({ headers: { get: () => 'application/json' }, json: async () => ({ ok: true, steps: [] }) })
|
|
});
|
|
await handlers.test();
|
|
assert.equal(diagnostics.hidden, false);
|
|
assert.deepEqual(placements, ['authentication']);
|
|
handlers.dismiss();
|
|
assert.equal(diagnostics.hidden, true);
|
|
assert.equal(focusCount, 1);
|
|
requestCard = { after() { placements.push('request'); }, getAttribute() { return '43'; }, querySelectorAll() { return []; } };
|
|
await handlers.test();
|
|
assert.equal(diagnostics.hidden, false);
|
|
assert.equal(testButton.disabled, false);
|
|
assert.deepEqual(placements, ['authentication', 'request']);
|
|
requestCard = { after() { placements.push('another request'); }, getAttribute() { return '44'; }, querySelectorAll() { return []; } };
|
|
await handlers.test();
|
|
assert.deepEqual(placements, ['authentication', 'request', 'another request']);
|
|
requestCard = null;
|
|
authCard.hidden = true;
|
|
await handlers.test();
|
|
assert.equal(placements.at(-1), 'connection');
|
|
});
|
|
|
|
test('request deletion confirms the request name and preserves the card on cancel', () => {
|
|
const fs = require('node:fs');
|
|
const vm = require('node:vm');
|
|
const handlers = {};
|
|
const messages = [];
|
|
let confirmed = false;
|
|
let focusCount = 0;
|
|
let cards = [];
|
|
function createCard(name) {
|
|
const card = {
|
|
remove() { cards = cards.filter(value => value !== card); },
|
|
querySelector(selector) { return selector === '[data-api-request-remove]' ? button : { value: name }; }
|
|
};
|
|
const button = { disabled: false, hasAttribute() { return false; }, closest() { return card; } };
|
|
return card;
|
|
}
|
|
cards = [createCard('Event'), createCard('Donors')];
|
|
const addButton = { addEventListener() {}, focus() { focusCount += 1; } };
|
|
const list = { querySelectorAll() { return cards; }, addEventListener(event, handler) { handlers[event] = handler; } };
|
|
const elements = { '[data-api-request-list]': list, '[data-api-request-add]': addButton };
|
|
const form = { querySelector(selector) { return elements[selector] || null; }, querySelectorAll() { return []; }, addEventListener() {} };
|
|
vm.runInNewContext(fs.readFileSync(require.resolve('../src/web/public/js/data-sources/api-source-form.js'), 'utf8'), {
|
|
document: { getElementById() { return form; } },
|
|
window: { confirm(message) { messages.push(message); return confirmed; } }
|
|
});
|
|
const remove = cards[1].querySelector('[data-api-request-remove]');
|
|
const event = { target: { closest(selector) { return selector === '[data-api-request-remove]' ? remove : null; } } };
|
|
handlers.click(event);
|
|
assert.equal(cards.length, 2);
|
|
assert.equal(focusCount, 0);
|
|
assert.match(messages[0], /Delete request "Donors"/);
|
|
assert.match(messages[0], /when you save/);
|
|
confirmed = true;
|
|
handlers.click(event);
|
|
assert.equal(cards.length, 1);
|
|
assert.equal(focusCount, 1);
|
|
assert.equal(cards[0].querySelector('[data-api-request-remove]').disabled, true);
|
|
});
|
|
|
|
test('API source sends login POST, uses token, and refreshes once after 401', async () => {
|
|
const originalFetch = global.fetch;
|
|
const calls = [];
|
|
let loginCount = 0;
|
|
let apiCount = 0;
|
|
global.fetch = async function (url, options) {
|
|
calls.push({ url, options });
|
|
if (url === 'https://example.com/login') {
|
|
loginCount += 1;
|
|
return jsonResponse(200, { accessToken: 'token-' + loginCount, expires_in: 300 });
|
|
}
|
|
apiCount += 1;
|
|
return apiCount === 1
|
|
? jsonResponse(401, { error: 'expired' })
|
|
: jsonResponse(200, { items: [{ id: 1 }] });
|
|
};
|
|
|
|
try {
|
|
const result = await fetchApiSourceResponse({
|
|
id: 987654,
|
|
api_url: 'https://example.com/report',
|
|
request_method: 'POST',
|
|
request_body_json: JSON.stringify({ site_id: 42 }),
|
|
auth_method: 'token_login',
|
|
token_url: 'https://example.com/login',
|
|
token_request_body_json: JSON.stringify({ username: 'demo', password: 'secret' }),
|
|
token_response_path: 'accessToken',
|
|
token_header_name: 'Authorization',
|
|
token_header_prefix: 'Bearer'
|
|
});
|
|
|
|
assert.deepEqual(JSON.parse(result.responseJson), { items: [{ id: 1 }] });
|
|
assert.equal(loginCount, 2);
|
|
assert.equal(apiCount, 2);
|
|
assert.equal(calls[0].options.method, 'POST');
|
|
assert.equal(calls[0].options.headers['Content-Type'], 'application/json');
|
|
assert.deepEqual(JSON.parse(calls[0].options.body), { username: 'demo', password: 'secret' });
|
|
assert.equal(calls[1].options.headers.Authorization, 'Bearer token-1');
|
|
assert.equal(calls[3].options.headers.Authorization, 'Bearer token-2');
|
|
assert.deepEqual(JSON.parse(calls[3].options.body), { site_id: 42 });
|
|
} finally {
|
|
global.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
test('API source reuses an unexpired token and refreshes it after expiry', async () => {
|
|
const originalFetch = global.fetch;
|
|
const originalDateNow = Date.now;
|
|
const calls = [];
|
|
let currentTime = 1000000;
|
|
Date.now = () => currentTime;
|
|
global.fetch = async function (url, options) {
|
|
calls.push({ url, options });
|
|
if (url === 'https://example.com/login') {
|
|
return jsonResponse(200, { access_token: 'initial-token', refresh_token: 'initial-refresh', expires_in: 120 });
|
|
}
|
|
if (url === 'https://example.com/refresh') {
|
|
return jsonResponse(200, { access_token: 'refreshed-token', refresh_token: 'rotated-refresh', expires_in: 300 });
|
|
}
|
|
return jsonResponse(200, { items: [] });
|
|
};
|
|
|
|
const source = {
|
|
id: 987655,
|
|
api_url: 'https://example.com/report',
|
|
auth_method: 'token_login',
|
|
token_url: 'https://example.com/login',
|
|
token_request_body_json: JSON.stringify({ username: 'demo', password: 'secret' }),
|
|
token_refresh_url: 'https://example.com/refresh',
|
|
token_refresh_request_body_json: JSON.stringify({ grant_type: 'refresh_token', refresh_token: '{{refresh_token}}' })
|
|
};
|
|
|
|
try {
|
|
await fetchApiSourceResponse(source);
|
|
await fetchApiSourceResponse(source);
|
|
assert.equal(calls.filter(call => call.url === 'https://example.com/login').length, 1);
|
|
assert.equal(calls.filter(call => call.url === 'https://example.com/report').length, 2);
|
|
|
|
currentTime += 120000;
|
|
await fetchApiSourceResponse(source);
|
|
assert.equal(calls.filter(call => call.url === 'https://example.com/refresh').length, 1);
|
|
assert.deepEqual(JSON.parse(calls[3].options.body), { grant_type: 'refresh_token', refresh_token: 'initial-refresh' });
|
|
assert.equal(calls[4].options.headers.Authorization, 'Bearer refreshed-token');
|
|
|
|
currentTime += 300000;
|
|
await fetchApiSourceResponse(source);
|
|
assert.equal(calls.filter(call => call.url === 'https://example.com/refresh').length, 2);
|
|
assert.deepEqual(JSON.parse(calls[5].options.body), { grant_type: 'refresh_token', refresh_token: 'rotated-refresh' });
|
|
} finally {
|
|
Date.now = originalDateNow;
|
|
global.fetch = originalFetch;
|
|
}
|
|
});
|