collabcentral/test/helpers.test.js
Joseph B. McQueen a793c9f39b Add admin UI for managing per-bot authorized users + retract cancelled preview DMs
Two features stitched together because they touch the same building-
job data path.

--- 1. Retract preview DM on cancel -----------------------------------

The /jobs/edit flow DMs a preview of the composed message to the
sender's own Webex space. Until now that DM lingered even if the
sender then hit Cancel or Discard.

- /jobs/edit now stores the returned message id as
  jobs.building[key].previewMessageId.
- /jobs/cancel captures that id before deleting the building entry,
  saves the cancel first, then fires a best-effort
  DELETE /v1/messages/<id> against Webex.
- New deleteWebexMessage(messageId, appName) helper wraps the DELETE.
  Uses the bot token (bots own their messages) and never throws — a
  Webex hiccup logs but doesn't fail the cancel that already
  succeeded on our side. Called fire-and-forget so the HTTP response
  isn't blocked on a slow Webex round trip.

--- 2. Admin: manage authorized users -------------------------------

Admin authority lives in a new top-level config.admins array of
personIds (seeded with Joe's id). Admin actions are cross-bot in
concept but the routes are :app-scoped because the resource being
edited is per-bot and it lets admin reuse the existing OAuth session
without a separate auth surface.

Helpers
- lib/helpers.js: new pure isAdmin(config, personId) that fails
  closed when admins is missing, not-an-array, or config is null.
- test/helpers.test.js: 5 new assertions covering the happy path,
  the "not in list" case, missing personId, non-array admins, and
  missing config. Total suite is now 48 assertions across 11 groups.

Server-side (index.js)
- New findPersonByEmail(email) helper hits Webex /v1/people?email=
  using the service account token, returns
  { id, displayName, email, avatar } or null.
- /info now returns isAdmin so the client can decide whether to
  render the admin dropdown.
- New requireAdmin(req, res) gate returns 401 for signed-out and
  403 for signed-in-but-not-admin (distinct codes so the frontend
  can render distinct panels).
- GET  /CollabCentral/:app/admin/users            → list users
- POST /CollabCentral/:app/admin/users            → lookup + add
- DELETE /CollabCentral/:app/admin/users/:id      → remove
- Shared adminUserRow / adminUsersList shape so every response is an
  authoritative snapshot the client can render without merging.
- DELETE of an unknown id is idempotent — returns 200 removed:false
  without rewriting config.json.

Frontend
- New html/admin.html + html/admin.js on the shared layout. Panels
  swap between not-signed-in / not-admin / admin. Add-user form
  takes an email; user list renders as rows with Webex avatar
  (fallback initials), name, email, favorite-group count, and a
  Remove button that confirm()s before firing DELETE.
- html/js/app.js: renderUserChip() replaces the plain-text top-right
  user label with a proper button + dropdown menu when the caller
  is an admin. Menu is keyboard-friendly (Escape to close), closes
  on outside-click, and currently exposes one item ("Admin" →
  admin.html). Non-admins get the plain-text label unchanged, so
  the existing pages are visually identical for them.
- html/css/app.css: new .appHeader__userBtn / .appHeader__userMenu
  dropdown, .inlineFieldRow for the email-plus-button pattern, and
  .userRow* rules for the admin user list.

Config
- Add config.admins array seeded with Joe McQueen's personId.
- config.json also picks up an in-app state change from the running
  instance (an "AV Team" favorite removed from Joe's techupdates
  authorized entry via the favorites UI). Rolling that into this
  commit so the file stops drifting from origin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 20:47:39 -04:00

370 lines
13 KiB
JavaScript

import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import {
buildingKey,
jobsForApp,
getBotToken,
isBotEnabled,
getBotConfig,
isAuthorized,
isAdmin,
getOAuthRedirectUri,
buildAuthUrl,
cleanCompletedJobs,
msToTime,
COMPLETED_RETENTION_DAYS,
} from '../lib/helpers.js';
// A minimal, self-contained config/tokens pair used across the auth tests so
// each test doesn't have to reconstruct one. Keeping novi enabled + techupdates
// disabled + noman unknown covers the three interesting bot states.
function makeState() {
return {
config: {
webex: {
bot: {
novi: {
label: 'Novi Communicator',
authorized: {
'personA': { id: 'personA' },
'personB': { id: 'personB' },
},
},
techupdates: {
label: 'Tech Updates',
authorized: {
'personA': { id: 'personA' },
},
},
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',
},
};
}
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('isAuthorized', () => {
const { config, botTokens } = makeState();
test('true when the person is listed under the bot and the bot is enabled', () => {
assert.equal(isAuthorized(config, botTokens, 'novi', 'personA'), true);
});
test('false when the person is not listed under this bot', () => {
assert.equal(isAuthorized(config, botTokens, 'novi', 'stranger'), false);
});
test('false when the bot is disabled, even for a listed person', () => {
assert.equal(isAuthorized(config, botTokens, 'techupdates', 'personA'), false);
});
test('false when personId is falsy', () => {
assert.equal(isAuthorized(config, botTokens, 'novi', undefined), false);
assert.equal(isAuthorized(config, botTokens, 'novi', ''), false);
});
test('false when the bot config has no authorized block', () => {
assert.equal(isAuthorized(config, botTokens, 'orphan', 'personA'), false);
});
});
describe('isAdmin', () => {
test('true when the person appears in config.admins', () => {
assert.equal(isAdmin({ admins: ['personA', 'personB'] }, 'personA'), true);
});
test('false when the person is not in config.admins', () => {
assert.equal(isAdmin({ admins: ['personA'] }, '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 config 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');
});
});