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 () => 'Not JSON' };
};
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|/);
} 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);
assert.match(add, /
Configure shared authentication and the requests for this API source\.<\/p>/);
assert.match(edit, /
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, /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*
/);
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*