const test = require('node:test'); const assert = require('node:assert/strict'); const { buildApiGroupPayload, saveApiGroup, fetchApiGroupById } = require('../src/data/api-source-groups'); const { getPendingMigrations } = require('../src/db/migrations'); const { fetchApiSourcesData, fetchApiSourceById, testApiSourceAuthentication } = require('../src/data/api-sources'); function payload(requests, auth) { return buildApiGroupPayload(Object.assign({ name: 'Tiltify', auth_method: 'none', requests_json: JSON.stringify(requests) }, auth)); } function createPool(existing, slides, groupEnabled = 1) { const calls = []; const connection = { async beginTransaction() { calls.push(['begin']); }, async commit() { calls.push(['commit']); }, async rollback() { calls.push(['rollback']); }, release() { calls.push(['release']); }, async query(sql, params) { calls.push([sql, params]); if (sql.includes('SELECT id, enabled FROM i_api_source_groups WHERE id')) return [[{ id: 5, enabled: groupEnabled }]]; if (sql.includes('SELECT id FROM i_api_source_groups WHERE name')) return [[]]; if (sql.includes('SELECT id FROM i_api_sources')) return [existing.map(id => ({ id }))]; if (sql.includes('SELECT content_json')) return [(slides || []).map(value => ({ content_json: value }))]; if (sql.startsWith('INSERT INTO i_api_sources')) return [{ insertId: 99 }]; if (sql.startsWith('INSERT INTO i_api_source_groups')) return [{ insertId: 10 }]; return [{}]; } }; return { calls, async getConnection() { return connection; } }; } test('group payload supports public requests and independent request settings', () => { const group = payload([{ id: 5, name: 'Event', api_url: 'https://example.com/event', update_interval_value: 2, update_interval_unit: 'hours' }, { name: 'Donors', api_url: 'https://example.com/donors', items_path: 'data', update_interval_value: 30, update_interval_unit: 'seconds', enabled: false }]); assert.equal(group.auth.auth_method, 'none'); assert.equal(group.requests[0].id, 5); assert.equal(group.requests[0].updateIntervalUnit, 'hours'); assert.equal(group.requests[1].itemsPath, 'data'); assert.equal(group.requests[1].enabled, 0); assert.throws(() => payload([]), /between 1 and 50/); assert.throws(() => payload([{ name: 'Event', api_url: 'https://example.com' }, { name: 'event', api_url: 'https://example.com' }]), /unique name/); assert.throws(() => payload([{ id: 5, name: 'Event', api_url: 'https://example.com' }, { id: 5, name: 'Donors', api_url: 'https://example.com' }]), /repeated/); }); test('request-level auth cannot override group authentication', () => { const group = payload([{ name: 'Event', api_url: 'https://example.com', auth_method: 'none' }, { name: 'Donors', api_url: 'https://example.com', auth_bearer_token: 'wrong-token' }], { auth_method: 'bearer', auth_bearer_token: 'shared-token' }); assert.ok(group.requests.every(request => request.authBearerToken === 'shared-token')); }); test('saving a group preserves request IDs and does not overwrite response snapshots', async () => { const pool = createPool([5]); const result = await saveApiGroup(pool, 5, payload([{ id: 5, name: 'Event', api_url: 'https://example.com/event' }, { name: 'Donors', api_url: 'https://example.com/donors' }]), 1, true); assert.deepEqual(result.requests.map(request => request.id), [5, 99]); assert.equal(result.id, 5); const update = pool.calls.find(([sql]) => sql.startsWith('UPDATE i_api_sources')); assert.ok(update); assert.doesNotMatch(update[0], /last_response|auth_password|auth_bearer_token/); assert.deepEqual(update[1].slice(-2), [5, 5]); assert.ok(pool.calls.some(([sql]) => sql === 'commit')); }); test('request ownership, slide usage, and delete permission are enforced before writes', async () => { const request = { id: 5, name: 'Event', api_url: 'https://example.com/event' }; await assert.rejects(saveApiGroup(createPool([5]), 5, payload([{ ...request, id: 6 }]), 1, true), /does not belong/); const used = createPool([5, 6], [JSON.stringify({ region: { type: 'api', source_id: 6 } })]); await assert.rejects(saveApiGroup(used, 5, payload([request]), 1, true), /still used/); assert.ok(!used.calls.some(([sql]) => sql.startsWith('UPDATE'))); await assert.rejects(saveApiGroup(createPool([5, 6]), 5, payload([request]), 1, false), /permission/); const unused = createPool([5, 6]); const result = await saveApiGroup(unused, 5, payload([request]), 1, true); assert.deepEqual(result.removed, [6]); assert.ok(unused.calls.some(([sql]) => sql.startsWith('DELETE FROM i_api_sources'))); }); test('2.16.1 migration preserves source IDs and backfills parent authentication', async () => { const calls = []; const pool = { async query(sql, params) { calls.push([sql, params]); if (sql.includes('information_schema.COLUMNS')) return [[{ column_count: 1 }]]; if (sql.includes('information_schema.TABLES')) return [[{ table_count: 0 }]]; if (sql.includes('information_schema.KEY_COLUMN_USAGE')) return [[{ fk_count: 1 }]]; return [[]]; } }; const pending = await getPendingMigrations(pool, { currentVersion: '2.16.0' }); const migration = pending.find(value => value.version === '2.16.1'); assert.ok(migration); await migration.run(pool); assert.ok(calls.some(([sql]) => sql.includes('INSERT IGNORE INTO i_api_source_groups (id,') && sql.includes('SELECT id,'))); assert.ok(calls.some(([sql]) => sql.includes('SET auth_source_id = id WHERE auth_source_id IS NULL'))); assert.ok(!calls.some(([sql]) => /UPDATE i_api_sources SET id\s*=/.test(sql))); }); test('2.16.2 adds an enabled master switch without rewriting individual requests', async () => { const calls = []; const pool = { async query(sql) { calls.push(sql); if (sql.includes('information_schema.COLUMNS')) return [[{ column_count: 0 }]]; return [[]]; } }; const pending = await getPendingMigrations(pool, { currentVersion: '2.16.1' }); assert.equal(pending.length, 1); assert.equal(pending[0].version, '2.16.2'); await pending[0].run(pool); assert.ok(calls.some(sql => sql.includes('ALTER TABLE i_api_source_groups ADD COLUMN enabled TINYINT(1) NOT NULL DEFAULT 1'))); assert.ok(!calls.some(sql => sql.includes('UPDATE i_api_sources'))); }); test('editor/player API lists exclude credentials while refresh lookup resolves group authentication', async () => { const queries = []; const pool = { async query(sql) { queries.push(sql); return [[]]; } }; await fetchApiSourcesData(pool); await fetchApiSourceById(pool, 5); assert.doesNotMatch(queries[0], /auth_password|token_request_body_json|request\.\*/); assert.match(queries[0], /group_name/); assert.match(queries[1], /auth\.auth_password/); assert.match(queries[1], /WHERE request.id = \?/); }); test('authentication-only tests never call a data endpoint', async () => { const originalFetch = global.fetch; const calls = []; global.fetch = async function (url) { calls.push(url); return { status: 200, ok: true, headers: { get: () => 'application/json' }, text: async () => '{"access_token":"private-token"}' }; }; try { const result = await testApiSourceAuthentication({ auth_method: 'token_login', token_url: 'https://example.com/token', token_request_body_json: '{"secret":"private-secret"}', api_url: 'https://example.com/donors' }); assert.equal(result.ok, true); assert.deepEqual(calls, ['https://example.com/token']); assert.doesNotMatch(JSON.stringify(result), /private-token|private-secret/); const publicAuth = await testApiSourceAuthentication({ auth_method: 'none' }); assert.equal(publicAuth.ok, true); assert.equal(calls.length, 1); } finally { global.fetch = originalFetch; } }); test('API refresh queues a saved request without redirects and scopes task status to that request', async () => { const registerRoutes = require('../src/web/routes/data-sources/api-sources/routes'); const routes = { get: {}, post: {} }; const queued = []; let enabled = 1; let taskKey = 'api-source-refresh:43'; const pool = { async query(sql, params) { if (sql.startsWith('SELECT * FROM i_api_source_groups')) return [[{ id: 5, name: 'Tiltify' }]]; if (sql.includes('WHERE request.auth_source_id')) return [[{ id: 43, request_name: 'Event', enabled }]]; if (sql.startsWith('SELECT id FROM i_api_sources WHERE id')) return [params[0] === 43 && params[1] === 5 ? [{ id: 43 }] : []]; throw new Error('Unexpected query: ' + sql); } }; registerRoutes({ get(path, ...handlers) { routes.get[path] = handlers; }, post(path, ...handlers) { routes.post[path] = handlers; } }, { pool, common: {}, requirePermission: permission => permission, getAuditUserId: () => 1, backgroundTaskQueue: { async enqueueTask(task) { queued.push(task); return { id: 101 }; } }, dataSourceTasks: { async getTaskStatusById() { return { id: 101, key: taskKey, status: 'completed' }; } } }); function response() { return { statusCode: 200, headers: {}, status(code) { this.statusCode = code; return this; }, set(key, value) { this.headers[key] = value; return this; }, json(body) { this.body = body; return this; }, redirect() { throw new Error('AJAX refresh must not redirect'); } }; } const refresh = routes.post['/data-sources/api-sources/:id/requests/:requestId/refresh']; const state = routes.get['/data-sources/api-sources/:id/requests/:requestId/state']; assert.equal(refresh[0], 'api-sources.allow'); assert.equal(state[0], 'api-sources.allow'); const req = { params: { id: '5', requestId: '43' }, get: () => 'application/json', query: { refresh_task_id: '101' } }; const next = error => { throw error; }; const started = response(); await refresh[1](req, started, next); assert.equal(started.statusCode, 202); assert.equal(started.body.taskId, 101); assert.equal(started.body.stateUrl, '/data-sources/api-sources/5/requests/43/state'); assert.equal(queued[0].payload.sourceId, 43); const completed = response(); await state[1](req, completed, next); assert.equal(completed.body.status, 'completed'); assert.equal(completed.headers['Cache-Control'], 'no-store'); taskKey = 'api-source-refresh:44'; const wrongTask = response(); await state[1](req, wrongTask, next); assert.equal(wrongTask.statusCode, 404); const wrongGroup = response(); await state[1]({ ...req, params: { id: '6', requestId: '43' } }, wrongGroup, next); assert.equal(wrongGroup.statusCode, 404); enabled = 0; const disabled = response(); await refresh[1](req, disabled, next); assert.equal(disabled.statusCode, 409); assert.equal(queued.length, 1); }); test('saving a disabled group does not re-enable it or erase individual request switches', async () => { const pool = createPool([5, 6], [], 0); const result = await saveApiGroup(pool, 5, payload([ { id: 5, name: 'Event', api_url: 'https://example.com/event', enabled: true }, { id: 6, name: 'Donors', api_url: 'https://example.com/donors', enabled: false } ]), 1, true); assert.equal(result.enabled, 0); assert.deepEqual(result.requests.map(request => request.enabled), [1, 0]); const parentUpdate = pool.calls.find(([sql]) => sql.startsWith('UPDATE i_api_source_groups')); assert.doesNotMatch(parentUpdate[0], /\benabled\s*=/); }); test('disabled parent groups preserve raw request switches in the editor', async () => { const pool = { async query(sql) { if (sql.startsWith('SELECT * FROM i_api_source_groups')) return [[{ id: 5, enabled: 0 }]]; return [[{ id: 43, request_name: 'Event', enabled: 0, request_enabled: 1, source_enabled: 0 }]]; } }; const group = await fetchApiGroupById(pool, 5); assert.equal(group.enabled, 0); assert.equal(group.requests[0].enabled, 1); assert.equal(group.requests[0].source_enabled, 0); }); test('the master toggle changes only the group and resumes only individually enabled requests', async () => { const registerRoutes = require('../src/web/routes/data-sources/api-sources/routes'); const routes = {}; const queries = []; const removed = []; const registered = []; const requests = [{ id: 43, request_name: 'Event', request_enabled: 1 }, { id: 44, request_name: 'Donors', request_enabled: 0 }]; let enabled = 1; const pool = { async query(sql, params) { queries.push(sql); if (sql.startsWith('SELECT * FROM i_api_source_groups')) return [[{ id: 5, name: 'Tiltify', enabled }]]; if (sql.includes('WHERE request.auth_source_id')) return [requests.map(request => ({ ...request, enabled: enabled && request.request_enabled, source_enabled: enabled, update_interval_value: 30, update_interval_unit: 'seconds' }))]; if (sql.startsWith('UPDATE i_api_source_groups SET enabled')) { enabled = params[0]; return [{}]; } throw new Error('Unexpected query: ' + sql); } }; registerRoutes({ get() {}, post(path, ...handlers) { routes[path] = handlers; } }, { pool, common: {}, requirePermission: permission => permission, getAuditUserId: () => 1, dataSourceTasks: { removeRecurringRefresh(type, id) { removed.push(id); }, registerRecurringRefresh(type, id) { registered.push(id); } } }); const res = { redirect(url) { this.url = url; }, status(code) { this.statusCode = code; return this; }, json(body) { this.body = body; } }; const next = error => { throw error; }; const route = routes['/data-sources/api-sources/:id']; assert.equal(route[0], 'api-sources.update'); const req = { params: { id: '5' }, body: { data_source_action: 'toggle', name: 'Unsaved draft must not be saved' } }; await route[1](req, res, next); assert.equal(enabled, 0); assert.deepEqual(removed, [43, 44]); assert.deepEqual(registered, []); assert.deepEqual(requests.map(request => request.request_enabled), [1, 0]); await routes['/data-sources/api-sources/:id/requests/:requestId/refresh'][1]({ params: { id: '5', requestId: '43' } }, res, next); assert.equal(res.statusCode, 409); assert.match(res.body.error, /source is disabled/); await route[1](req, res, next); assert.equal(enabled, 1); assert.deepEqual(registered, [43]); assert.ok(!queries.some(sql => sql.startsWith('UPDATE i_api_sources'))); });