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); const preview = renderEdit({ id: 42, name: 'Campaign' }, {}, '', { isViewingAs: true, permissionKeys: ['api-sources.update', 'api-sources.allow', 'api-sources.delete'] }); 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"/); assert.match(add, /data-async-save-new-redirect="response-url"/); assert.doesNotMatch(edit, /data-async-save-new-redirect|data-async-save-refresh-page/); for (const html of [add, edit]) { assert.match(html, /