collabcentral/test/helpers.test.js
Joseph B. McQueen 44568044e8 Phase R1: split per-user data into gitignored authorized.json
Every add/remove of a favorite group or authorized user was rewriting
config.json — the same file that carries structural bot metadata and
was committed to git. This split ends the git-noise and lets ops
deploy fresh installs without a pre-populated user list.

Split
- config.json (committed) stays structural: server, per-bot labels,
  integration + service-account ids, languages.
- config/authorized.json (gitignored) is the new mutable source of
  truth: { admins: [personId...], bot: { <appName>: { <personId>:
  { id, displayName, email, avatar, groups: [...] } } } }.
- Seeded authorized.json with the current admins list and all
  authorized users (3 on novi, 4 on techupdates) so this commit is
  a pure move — no data lost, no downtime.

Helpers (lib/helpers.js)
- New getAuthorizedEntry(authorized, app, id) as the single lookup
  point every consumer goes through, so nullability is uniform.
- isAuthorized() gains an authorized-doc arg (pure signature stays
  testable): fails closed when the doc is missing / partially
  loaded, so a broken deploy grants no access.
- isAdmin() now reads authorized.admins instead of config.admins.

Runtime (index.js)
- loadAuthorized() with an ENOENT fallback to { admins: [], bot: {} }
  so a fresh deploy can bootstrap via the admin page instead of
  requiring a hand-crafted authorized.json.
- All 8 previous config.webex.bot[app].authorized sites (favorites
  read/add/remove, admin list/add/delete, isAuthorized) now go
  through the authorized doc.
- Every mutation writes to config/authorized.json instead of
  config/config.json.

Latent-bug fixup (uncovered while smoke-testing this refactor)
- The /user/:scope/:action fallthroughs used res.status(4xx)
  without .send(...), so unknown scopes / unauthorized callers got
  a hung request instead of a response. Added ".send(...)" bodies
  so the response actually completes.

Docs + tests
- README updated: new "Authorized users" step in "Adding a new bot",
  updated file-layout section, docker mount list adds
  authorized.json.
- Test suite expanded from 48 → 53 with a new getAuthorizedEntry
  group and the existing isAuthorized/isAdmin cases reshaped for
  the new signatures.

Smoke tested the auth matrix end-to-end (admin + non-admin + signed-
out across /info, /admin/users, /user/groups/list): every path
returns the expected code and body.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 21:06:44 -04:00

394 lines
14 KiB
JavaScript

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');
});
});