const test = require('node:test'); const assert = require('node:assert/strict'); const { buildApiGroupPayload, saveApiGroup } = 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) { 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 FROM i_api_source_groups WHERE id')) return [[{ id: 5 }]]; 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' }); assert.equal(pending.length, 1); assert.equal(pending[0].version, '2.16.1'); await pending[0].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('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; } });