import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; import { buildingKey, jobsForApp, getBotToken, isBotEnabled, getBotConfig, getAuthorizedEntry, isAuthorized, isAdmin, getOAuthRedirectUri, buildAuthUrl, cleanCompletedJobs, msToTime, COMPLETED_RETENTION_DAYS, } from '../lib/helpers.js'; // A minimal, self-contained state triple used across the auth tests so each // test doesn't have to reconstruct one. Keeping novi enabled + techupdates // disabled + orphan-with-no-users covers the interesting bot states, and // splitting per-user data into an authorized doc mirrors the runtime split // between committed config.json and gitignored authorized.json. function makeState() { return { config: { webex: { bot: { novi: { label: 'Novi Communicator' }, techupdates: { label: 'Tech Updates' }, orphan: { label: 'Configured but no authorized users' }, }, }, }, botTokens: { novi: { token: 'novi-token', enabled: true }, techupdates: { token: 'techupdates-token', enabled: false }, orphan: { token: 'orphan-token', enabled: true }, flatstring: 'flat-token', }, authorized: { admins: [], bot: { novi: { 'personA': { id: 'personA' }, 'personB': { id: 'personB' } }, techupdates: { 'personA': { id: 'personA' } }, }, }, }; } describe('buildingKey', () => { test('composites cookieId and appName with a colon', () => { assert.equal(buildingKey('abc123', 'novi'), 'abc123:novi'); }); test('yields distinct keys for the same cookie across different bots', () => { assert.notEqual(buildingKey('abc123', 'novi'), buildingKey('abc123', 'techupdates')); }); test('stringifies an undefined cookieId (matches legacy behavior)', () => { assert.equal(buildingKey(undefined, 'novi'), 'undefined:novi'); }); }); describe('jobsForApp', () => { const jobs = [ { jobId: 1, appName: 'novi' }, { jobId: 2, appName: 'techupdates' }, { jobId: 3, appName: 'novi' }, { jobId: 4 }, null, ]; test('returns only jobs matching the given appName', () => { const result = jobsForApp(jobs, 'novi'); assert.deepEqual(result.map(j => j.jobId), [1, 3]); }); test('returns empty array when appName has no jobs', () => { assert.deepEqual(jobsForApp(jobs, 'nomatch'), []); }); test('null / undefined input returns []', () => { assert.deepEqual(jobsForApp(null, 'novi'), []); assert.deepEqual(jobsForApp(undefined, 'novi'), []); }); test('drops null entries and jobs missing appName', () => { assert.equal(jobsForApp(jobs, 'novi').every(j => j && j.appName === 'novi'), true); }); }); describe('getBotToken', () => { const { botTokens } = makeState(); test('returns the token for an enabled bot', () => { assert.equal(getBotToken(botTokens, 'novi'), 'novi-token'); }); test('returns null for an explicitly disabled bot', () => { assert.equal(getBotToken(botTokens, 'techupdates'), null); }); test('returns null for an unknown bot', () => { assert.equal(getBotToken(botTokens, 'nomatch'), null); }); test('tolerates the flat "appName -> tokenString" shape', () => { assert.equal(getBotToken(botTokens, 'flatstring'), 'flat-token'); }); test('returns null when botTokens is null or missing', () => { assert.equal(getBotToken(null, 'novi'), null); assert.equal(getBotToken(undefined, 'novi'), null); }); test('returns null when the entry has no token field', () => { assert.equal(getBotToken({ novi: { enabled: true } }, 'novi'), null); }); }); describe('isBotEnabled', () => { const { botTokens } = makeState(); test('true for enabled bot', () => { assert.equal(isBotEnabled(botTokens, 'novi'), true); }); test('false for disabled bot', () => { assert.equal(isBotEnabled(botTokens, 'techupdates'), false); }); test('false for unknown bot', () => { assert.equal(isBotEnabled(botTokens, 'nomatch'), false); }); }); describe('getBotConfig', () => { const { config, botTokens } = makeState(); test('returns the bot config block when the bot is defined and enabled', () => { const cfg = getBotConfig(config, botTokens, 'novi'); assert.equal(cfg.label, 'Novi Communicator'); }); test('returns null when the bot is disabled even if it is in config.json', () => { assert.equal(getBotConfig(config, botTokens, 'techupdates'), null); }); test('returns null when the bot is unknown', () => { assert.equal(getBotConfig(config, botTokens, 'nomatch'), null); }); test('returns null when appName is falsy', () => { assert.equal(getBotConfig(config, botTokens, undefined), null); assert.equal(getBotConfig(config, botTokens, ''), null); }); test('returns null when the bot has a token but is not in config.json', () => { // flatstring has a token entry but no matching config.webex.bot entry. assert.equal(getBotConfig(config, botTokens, 'flatstring'), null); }); }); describe('getAuthorizedEntry', () => { const { authorized } = makeState(); test('returns the per-user record when present', () => { assert.deepEqual(getAuthorizedEntry(authorized, 'novi', 'personA'), { id: 'personA' }); }); test('returns null for an unknown person on a known bot', () => { assert.equal(getAuthorizedEntry(authorized, 'novi', 'stranger'), null); }); test('returns null for a bot with no bucket', () => { assert.equal(getAuthorizedEntry(authorized, 'orphan', 'personA'), null); }); test('null on missing pieces', () => { assert.equal(getAuthorizedEntry(null, 'novi', 'personA'), null); assert.equal(getAuthorizedEntry({}, 'novi', 'personA'), null); assert.equal(getAuthorizedEntry(authorized, '', 'personA'), null); assert.equal(getAuthorizedEntry(authorized, 'novi', ''), null); }); }); describe('isAuthorized', () => { const { config, botTokens, authorized } = makeState(); test('true when the person is listed under the bot and the bot is enabled', () => { assert.equal(isAuthorized(config, botTokens, authorized, 'novi', 'personA'), true); }); test('false when the person is not listed under this bot', () => { assert.equal(isAuthorized(config, botTokens, authorized, 'novi', 'stranger'), false); }); test('false when the bot is disabled, even for a listed person', () => { assert.equal(isAuthorized(config, botTokens, authorized, 'techupdates', 'personA'), false); }); test('false when personId is falsy', () => { assert.equal(isAuthorized(config, botTokens, authorized, 'novi', undefined), false); assert.equal(isAuthorized(config, botTokens, authorized, 'novi', ''), false); }); test('false when the bot config has no authorized bucket', () => { assert.equal(isAuthorized(config, botTokens, authorized, 'orphan', 'personA'), false); }); test('false when authorized doc is missing entirely', () => { assert.equal(isAuthorized(config, botTokens, null, 'novi', 'personA'), false); }); }); describe('isAdmin', () => { test('true when the person appears in authorized.admins', () => { assert.equal(isAdmin({ admins: ['personA', 'personB'], bot: {} }, 'personA'), true); }); test('false when the person is not in authorized.admins', () => { assert.equal(isAdmin({ admins: ['personA'], bot: {} }, 'stranger'), false); }); test('false when personId is missing', () => { assert.equal(isAdmin({ admins: ['personA'] }, undefined), false); assert.equal(isAdmin({ admins: ['personA'] }, ''), false); }); test('false (fails closed) when admins is missing or not an array', () => { assert.equal(isAdmin({}, 'personA'), false); assert.equal(isAdmin({ admins: null }, 'personA'), false); assert.equal(isAdmin({ admins: 'personA' }, 'personA'), false); }); test('false on missing authorized doc entirely', () => { assert.equal(isAdmin(undefined, 'personA'), false); assert.equal(isAdmin(null, 'personA'), false); }); }); describe('getOAuthRedirectUri', () => { test('replaces the :app placeholder with the appName', () => { assert.equal( getOAuthRedirectUri('https://bot.example.com/CollabCentral/:app/oauth', 'novi'), 'https://bot.example.com/CollabCentral/novi/oauth', ); }); test('returns empty string when the template is missing', () => { assert.equal(getOAuthRedirectUri(undefined, 'novi'), ''); assert.equal(getOAuthRedirectUri('', 'novi'), ''); }); test('leaves the template unchanged when :app is absent', () => { assert.equal( getOAuthRedirectUri('https://bot.example.com/callback', 'novi'), 'https://bot.example.com/callback', ); }); }); describe('buildAuthUrl', () => { const validInputs = { clientId: 'test-client-id', template: 'https://bot.example.com/CollabCentral/:app/oauth', appName: 'novi', }; test('returns a well-formed Webex authorize URL when both env inputs are present', () => { const url = new URL(buildAuthUrl(validInputs)); assert.equal(url.origin + url.pathname, 'https://webexapis.com/v1/authorize'); assert.equal(url.searchParams.get('client_id'), 'test-client-id'); assert.equal(url.searchParams.get('response_type'), 'code'); assert.equal( url.searchParams.get('redirect_uri'), 'https://bot.example.com/CollabCentral/novi/oauth', ); assert.equal(url.searchParams.get('scope'), 'spark:kms spark:people_read'); }); test('substitutes appName into the redirect_uri per bot', () => { const noviUrl = new URL(buildAuthUrl(validInputs)); const techUrl = new URL(buildAuthUrl({ ...validInputs, appName: 'techupdates' })); assert.notEqual( noviUrl.searchParams.get('redirect_uri'), techUrl.searchParams.get('redirect_uri'), ); assert.ok(techUrl.searchParams.get('redirect_uri').endsWith('/techupdates/oauth')); }); test('returns null when clientId is missing', () => { assert.equal(buildAuthUrl({ ...validInputs, clientId: undefined }), null); assert.equal(buildAuthUrl({ ...validInputs, clientId: '' }), null); }); test('returns null when template is missing', () => { assert.equal(buildAuthUrl({ ...validInputs, template: undefined }), null); assert.equal(buildAuthUrl({ ...validInputs, template: '' }), null); }); }); describe('cleanCompletedJobs', () => { // Pin "now" to a fixed instant so the age arithmetic is deterministic. const NOW = new Date('2026-07-01T12:00:00Z').getTime(); const daysAgoIso = (n) => new Date(NOW - n * 24 * 60 * 60 * 1000).toISOString(); test('drops jobs older than the retention window', () => { const jobs = { completed: [ { jobId: 'old', endTime: daysAgoIso(45) }, { jobId: 'edge', endTime: daysAgoIso(31) }, { jobId: 'recent', endTime: daysAgoIso(10) }, ], }; const result = cleanCompletedJobs(jobs, 30, NOW); assert.equal(result.removed, 2); assert.deepEqual(result.jobs.completed.map(j => j.jobId), ['recent']); }); test('keeps a job exactly at the cutoff (>=, not >)', () => { const jobs = { completed: [ { jobId: 'on-cutoff', endTime: daysAgoIso(30) }, ], }; const result = cleanCompletedJobs(jobs, 30, NOW); assert.equal(result.removed, 0); assert.equal(result.jobs.completed.length, 1); }); test('falls back through endTime -> startTime -> created', () => { const jobs = { completed: [ { jobId: 'byStart', startTime: daysAgoIso(60) }, { jobId: 'byCreated', created: daysAgoIso(5) }, ], }; const result = cleanCompletedJobs(jobs, 30, NOW); assert.equal(result.removed, 1); assert.equal(result.jobs.completed[0].jobId, 'byCreated'); }); test('keeps jobs with no timestamp at all (safety default)', () => { const jobs = { completed: [ { jobId: 'noDate' }, { jobId: 'old', endTime: daysAgoIso(100) }, ], }; const result = cleanCompletedJobs(jobs, 30, NOW); assert.equal(result.removed, 1); assert.deepEqual(result.jobs.completed.map(j => j.jobId), ['noDate']); }); test('handles missing completed array without throwing', () => { const jobs = {}; const result = cleanCompletedJobs(jobs, 30, NOW); assert.equal(result.removed, 0); assert.equal(result.jobs, jobs); }); test('defaults to COMPLETED_RETENTION_DAYS when retentionDays is omitted', () => { const jobs = { completed: [ { jobId: 'stale', endTime: daysAgoIso(COMPLETED_RETENTION_DAYS + 5) }, { jobId: 'ok', endTime: daysAgoIso(1) }, ], }; const result = cleanCompletedJobs(jobs, undefined, NOW); assert.equal(result.retentionDays, COMPLETED_RETENTION_DAYS); assert.deepEqual(result.jobs.completed.map(j => j.jobId), ['ok']); }); }); describe('msToTime', () => { test('formats sub-minute durations as "S.Ms"', () => { assert.equal(msToTime(5300), '5.3s'); }); test('formats sub-hour durations as "Mm S.Ms"', () => { assert.equal(msToTime(90500), '1m 30.5s'); }); test('formats hour-plus durations as "Hh Mm S.Ms"', () => { // 1h 2m 3.4s assert.equal(msToTime(3600000 + 2 * 60000 + 3400), '1h 2m 3.4s'); }); test('handles zero', () => { assert.equal(msToTime(0), '0.0s'); }); });