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>
This commit is contained in:
Joseph B. McQueen 2026-07-01 21:06:44 -04:00
parent bd6bfedb70
commit 44568044e8
6 changed files with 190 additions and 358 deletions

1
.gitignore vendored
View file

@ -9,6 +9,7 @@ config/token.json
config/jobs.json config/jobs.json
config/jobs copy.json config/jobs copy.json
config/userPrefs.json config/userPrefs.json
config/authorized.json
uploads/* uploads/*
!uploads/.gitkeep !uploads/.gitkeep

View file

@ -66,23 +66,34 @@ Say you want to add a bot called `alerts`.
``` ```
Setting `enabled: false` will keep the bot listed but return 404 for all Setting `enabled: false` will keep the bot listed but return 404 for all
`/CollabCentral/alerts/*` routes. `/CollabCentral/alerts/*` routes.
3. **Bot metadata + authorized users** — edit `config/config.json` and add 3. **Bot metadata** — edit `config/config.json` and add a structural entry
an entry under `webex.bot`: under `webex.bot`:
```json ```json
"alerts": { "alerts": { "label": "Ops Alerts" }
"label": "Ops Alerts", ```
"authorized": { 4. **Authorized users** — add per-user data to `config/authorized.json`
"<webexPersonId>": { (gitignored; created automatically at first admin action if it doesn't
"id": "<webexPersonId>", exist):
"displayName": "…", ```json
"email": "…", {
"avatar": "…", "admins": ["<webexPersonId>"],
"groups": [ { "name": "…", "alias": "…", "id": "<webexGroupId>" } ] "bot": {
"alerts": {
"<webexPersonId>": {
"id": "<webexPersonId>",
"displayName": "…",
"email": "…",
"avatar": "…",
"groups": [ { "name": "…", "alias": "…", "id": "<webexGroupId>" } ]
}
} }
} }
} }
``` ```
Only person IDs listed under `authorized` can log into that bot's UI. Only person IDs listed under `bot.<appName>` can sign into that bot's UI.
Anyone listed under `admins` (any personId) can manage authorized users
across every bot via the in-app Admin page — much easier than hand-
editing this file, but the file is the source of truth.
4. **Icons** — drop `alerts.png` and `alerts.ico` into `html/` (they're 4. **Icons** — drop `alerts.png` and `alerts.ico` into `html/` (they're
served by the per-bot static mount). served by the per-bot static mount).
5. **OAuth redirect URI** — in the Webex integration used for OAuth, register 5. **OAuth redirect URI** — in the Webex integration used for OAuth, register
@ -108,8 +119,8 @@ docker run -d \
``` ```
The `config/` mount should contain at minimum `config.json`, `botTokens.json`, The `config/` mount should contain at minimum `config.json`, `botTokens.json`,
`token.json`, `languages.json`, and (if you want to preserve state) `token.json`, `languages.json`, `authorized.json`, and (if you want to
`jobs.json` and `userPrefs.json`. preserve state) `jobs.json` and `userPrefs.json`.
## File layout ## File layout
@ -119,8 +130,8 @@ Committed to the repo:
and the send queue. and the send queue.
- `html/` — frontend (sendMessage / monitorJobs / jobDetail pages, per-bot - `html/` — frontend (sendMessage / monitorJobs / jobDetail pages, per-bot
icons, shared JS libraries). icons, shared JS libraries).
- `config/config.json` — server settings, per-bot labels, and the - `config/config.json` — structural: server settings, per-bot labels,
authorized-user table for each bot. integration ids. No per-user data lives here anymore.
- `config/languages.json` — supported translation languages. - `config/languages.json` — supported translation languages.
- `config/botTokens.example.json` — template for `botTokens.json`. - `config/botTokens.example.json` — template for `botTokens.json`.
- `.env.example` — template for `.env`. - `.env.example` — template for `.env`.
@ -133,6 +144,9 @@ Runtime state (gitignored, not committed):
- `config/botTokens.json` — per-bot access tokens. - `config/botTokens.json` — per-bot access tokens.
- `config/token.json` — service-account OAuth tokens (rotated by the - `config/token.json` — service-account OAuth tokens (rotated by the
refresh cron). refresh cron).
- `config/authorized.json` — admins list + per-bot authorized users
(with their favorite groups). Edited at runtime by the admin page
and the favorites picker on the compose page.
- `config/jobs.json` — building / running / scheduled / completed jobs. - `config/jobs.json` — building / running / scheduled / completed jobs.
- `config/userPrefs.json` — per-user preferences (language, etc.). - `config/userPrefs.json` — per-user preferences (language, etc.).
- `uploads/` — CSVs of recipient IDs and any attached images. - `uploads/` — CSVs of recipient IDs and any attached images.

View file

@ -3,273 +3,14 @@
"name": "CollabCentral", "name": "CollabCentral",
"port": "1451" "port": "1451"
}, },
"admins": [
"Y2lzY29zcGFyazovL3VzL1BFT1BMRS80NzAyNDlhNC1kNjFjLTQzNmMtYTE1My1kOGUzZTExMmI4MDU"
],
"languages": [], "languages": [],
"webex": { "webex": {
"bot": { "bot": {
"novi": { "novi": {
"label": "Novi Communicator", "label": "Novi Communicator"
"authorized": {
"Y2lzY29zcGFyazovL3VzL1BFT1BMRS80NzAyNDlhNC1kNjFjLTQzNmMtYTE1My1kOGUzZTExMmI4MDU": {
"id": "Y2lzY29zcGFyazovL3VzL1BFT1BMRS80NzAyNDlhNC1kNjFjLTQzNmMtYTE1My1kOGUzZTExMmI4MDU",
"displayName": "Joe McQueen",
"email": "mcqueenj@ae.com",
"avatar": "https://avatar-prod-us-east-2.webexcontent.com/Avtr~V1~db9cdc31-8f8e-40b4-916c-8d9e9c92e70d/V1~667e3673b56f4ddc39139c73da9140dd631eb821a756524ead12e37e3a61afc7~9c5b6e767cff46e3af35a003cea38f25~1600",
"groups": [
{
"name": "Group: Audio Visual",
"alias": "AV Team",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZTQ3NmY3MjktZWViYi00MDI3LWFlMTctNzc1YWI1ZDgzYmNhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: South Side Campus",
"alias": "SSW Campus",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvYjAxOWM5MzgtNTZhNi00NTQxLTk0NjgtYjFmOWEwM2I5NTNlOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: IT Warrendale",
"alias": "Warrendale",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvMjkwN2RlZGUtZjJmNi00OTdjLTkwMjItZTRmOGIzNzM3MTFhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: New York Design Office Personnel",
"alias": "NY Design Center",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvNjUzOGQzYmYtY2UzOS00Y2ZlLWFmZWUtNGViYTM0OGQ3Y2FjOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: 34th Street Office Personnel",
"alias": "34th Street",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZGVmOTA0MDMtMTBlZS00YzRlLWIzYWItYzNjZDlkODhmMTQ1OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: San Francisco Personnel",
"alias": "San Francisco Office",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvNjJjYTcxYTktYWYzYy00YjQ4LWIxZjUtMjc1NDEwNGYwMWJlOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Ottawa Campus",
"alias": "Ottawa Campus",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZWIyYWI1ZDgtYzk2Ny00MmUwLTg0OWQtZGE5OTYzMDdkNDhhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Hazleton Campus",
"alias": "Hazleton Campus",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvMDc3NTI1NGEtZTIxMS00ZTQ1LTgxNjktNjYxYTAzOGYxNTE3OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Canada Distribution Center",
"alias": "Canada DC",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZmUwMDk1MjUtMzEyZC00YzMxLTgwMzItNWEyODIwODRiYTVjOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Mexico Office Personnel",
"alias": "Mexico Office",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvNjhmYTJiZGUtNjI2My00NWEwLTljMDMtNjMyMGI0NmVlODE0OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Hong Kong Office",
"alias": "Hong Kong Office",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvMjdlM2QzZGEtZjI4Ny00NDg3LWI3NzUtM2JmZTA3MzNhNzZhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Hazleton Mgmt",
"alias": "Hazleton Mgmt",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvYTEzODI3OTgtOWY5Zi00YmY1LTlkMmQtODNhZTk2OGY0MjU3OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Ottawa Campus Mgmt",
"alias": "Ottawa Campus Mgmt",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZDYwNzFhYmMtYzNjZC00ZDdkLWFiYTktOGY0NDRiZTViODQ3OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: MDC DC Mgmt",
"alias": "Canada DC Mgmt",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvOTk2ZWRmNzYtNjdlYS00MjdkLWEzNGEtMTBjNjJhYmRkMDM5OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
}
]
},
"Y2lzY29zcGFyazovL3VzL1BFT1BMRS84MWUzMzExMi1jYWZlLTQ0NWYtOGE5OS01MDI3Mzg4ZDQwNGM": {
"id": "Y2lzY29zcGFyazovL3VzL1BFT1BMRS84MWUzMzExMi1jYWZlLTQ0NWYtOGE5OS01MDI3Mzg4ZDQwNGM",
"displayName": "Megan Reinecker",
"email": "reineckerm@ae.com",
"avatar": "https://avatar-prod-us-east-2.webexcontent.com/Avtr~V1~db9cdc31-8f8e-40b4-916c-8d9e9c92e70d/V1~81e33112-cafe-445f-8a99-5027388d404c~c5234572b50e49ff884c45b3ea0e3fea~1600",
"groups": [
{
"name": "Group: South Side Campus",
"alias": "SSW Campus",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvYjAxOWM5MzgtNTZhNi00NTQxLTk0NjgtYjFmOWEwM2I5NTNlOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: IT Warrendale",
"alias": "Warrendale",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvMjkwN2RlZGUtZjJmNi00OTdjLTkwMjItZTRmOGIzNzM3MTFhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: New York Design Office Personnel",
"alias": "NY Design Center",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvNjUzOGQzYmYtY2UzOS00Y2ZlLWFmZWUtNGViYTM0OGQ3Y2FjOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: 34th Street Office Personnel",
"alias": "34th Street",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZGVmOTA0MDMtMTBlZS00YzRlLWIzYWItYzNjZDlkODhmMTQ1OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: San Francisco Personnel",
"alias": "San Francisco Office",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvNjJjYTcxYTktYWYzYy00YjQ4LWIxZjUtMjc1NDEwNGYwMWJlOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Ottawa Campus",
"alias": "Ottawa Campus",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZWIyYWI1ZDgtYzk2Ny00MmUwLTg0OWQtZGE5OTYzMDdkNDhhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Hazleton Campus",
"alias": "Hazleton Campus",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvMDc3NTI1NGEtZTIxMS00ZTQ1LTgxNjktNjYxYTAzOGYxNTE3OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Canada Distribution Center",
"alias": "Canada DC",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZmUwMDk1MjUtMzEyZC00YzMxLTgwMzItNWEyODIwODRiYTVjOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Mexico Office Personnel",
"alias": "Mexico Office",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvNjhmYTJiZGUtNjI2My00NWEwLTljMDMtNjMyMGI0NmVlODE0OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Hong Kong Office",
"alias": "Hong Kong Office",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvMjdlM2QzZGEtZjI4Ny00NDg3LWI3NzUtM2JmZTA3MzNhNzZhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Hazleton Mgmt",
"alias": "Hazleton Mgmt",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvYTEzODI3OTgtOWY5Zi00YmY1LTlkMmQtODNhZTk2OGY0MjU3OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Ottawa Campus Mgmt",
"alias": "Ottawa Campus Mgmt",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZDYwNzFhYmMtYzNjZC00ZDdkLWFiYTktOGY0NDRiZTViODQ3OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: MDC DC Mgmt",
"alias": "Canada DC Mgmt",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvOTk2ZWRmNzYtNjdlYS00MjdkLWEzNGEtMTBjNjJhYmRkMDM5OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
}
]
},
"Y2lzY29zcGFyazovL3VzL1BFT1BMRS80NmZkYWEzYy03YTNkLTRjY2QtYWU4Ny0xYzIzNmIyNGE0Zjg": {
"id": "Y2lzY29zcGFyazovL3VzL1BFT1BMRS80NmZkYWEzYy03YTNkLTRjY2QtYWU4Ny0xYzIzNmIyNGE0Zjg",
"displayName": "Jayna Catalina",
"email": "catalinaj@ae.com",
"avatar": "https://avatar-prod-us-east-2.webexcontent.com/Avtr~V1~db9cdc31-8f8e-40b4-916c-8d9e9c92e70d/V1~cf803b9485e536eece6e2f2e541f8ab8691867a0765656a0ebb28ba4b74d7bca~8afff234e4f045ad9fa92af6f8d082c0~1600",
"groups": [
{
"name": "Group: South Side Campus",
"alias": "SSW Campus",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvYjAxOWM5MzgtNTZhNi00NTQxLTk0NjgtYjFmOWEwM2I5NTNlOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: IT Warrendale",
"alias": "Warrendale",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvMjkwN2RlZGUtZjJmNi00OTdjLTkwMjItZTRmOGIzNzM3MTFhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: New York Design Office Personnel",
"alias": "NY Design Center",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvNjUzOGQzYmYtY2UzOS00Y2ZlLWFmZWUtNGViYTM0OGQ3Y2FjOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: 34th Street Office Personnel",
"alias": "34th Street",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZGVmOTA0MDMtMTBlZS00YzRlLWIzYWItYzNjZDlkODhmMTQ1OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: San Francisco Personnel",
"alias": "San Francisco Office",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvNjJjYTcxYTktYWYzYy00YjQ4LWIxZjUtMjc1NDEwNGYwMWJlOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Ottawa Campus",
"alias": "Ottawa Campus",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZWIyYWI1ZDgtYzk2Ny00MmUwLTg0OWQtZGE5OTYzMDdkNDhhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Hazleton Campus",
"alias": "Hazleton Campus",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvMDc3NTI1NGEtZTIxMS00ZTQ1LTgxNjktNjYxYTAzOGYxNTE3OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Canada Distribution Center",
"alias": "Canada DC",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZmUwMDk1MjUtMzEyZC00YzMxLTgwMzItNWEyODIwODRiYTVjOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Mexico Office Personnel",
"alias": "Mexico Office",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvNjhmYTJiZGUtNjI2My00NWEwLTljMDMtNjMyMGI0NmVlODE0OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Hong Kong Office",
"alias": "Hong Kong Office",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvMjdlM2QzZGEtZjI4Ny00NDg3LWI3NzUtM2JmZTA3MzNhNzZhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Hazleton Mgmt",
"alias": "Hazleton Mgmt",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvYTEzODI3OTgtOWY5Zi00YmY1LTlkMmQtODNhZTk2OGY0MjU3OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Ottawa Campus Mgmt",
"alias": "Ottawa Campus Mgmt",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZDYwNzFhYmMtYzNjZC00ZDdkLWFiYTktOGY0NDRiZTViODQ3OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: MDC DC Mgmt",
"alias": "Canada DC Mgmt",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvOTk2ZWRmNzYtNjdlYS00MjdkLWEzNGEtMTBjNjJhYmRkMDM5OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
}
]
}
}
}, },
"techupdates": { "techupdates": {
"label": "Technology Updates", "label": "Technology Updates"
"authorized": {
"Y2lzY29zcGFyazovL3VzL1BFT1BMRS80NzAyNDlhNC1kNjFjLTQzNmMtYTE1My1kOGUzZTExMmI4MDU": {
"id": "Y2lzY29zcGFyazovL3VzL1BFT1BMRS80NzAyNDlhNC1kNjFjLTQzNmMtYTE1My1kOGUzZTExMmI4MDU",
"displayName": "Joe McQueen",
"email": "mcqueenj@ae.com",
"avatar": "https://avatar-prod-us-east-2.webexcontent.com/Avtr~V1~db9cdc31-8f8e-40b4-916c-8d9e9c92e70d/V1~667e3673b56f4ddc39139c73da9140dd631eb821a756524ead12e37e3a61afc7~9c5b6e767cff46e3af35a003cea38f25~1600",
"groups": []
},
"Y2lzY29zcGFyazovL3VzL1BFT1BMRS83NmZkODZjNi1iNGEyLTRmYWItODE2OC00NGY0N2FjMmYxYzA": {
"id": "Y2lzY29zcGFyazovL3VzL1BFT1BMRS83NmZkODZjNi1iNGEyLTRmYWItODE2OC00NGY0N2FjMmYxYzA",
"displayName": "Samantha Matthews",
"email": "MatthewsS@AE.com",
"avatar": "https://avatar-prod-us-east-2.webexcontent.com/Avtr~V1~db9cdc31-8f8e-40b4-916c-8d9e9c92e70d/V1~2c5040ed2e348bc8fff87e924a089f603d31014677e744b988f03824d9bf2ce7~20b84d9e14044642bb28e21abd421b41~1600",
"groups": []
},
"Y2lzY29zcGFyazovL3VzL1BFT1BMRS9hNTBjNDgxMC01MjQ5LTQ3ZjQtOGMwZi05NmFhY2NkYTA4NGQ": {
"id": "Y2lzY29zcGFyazovL3VzL1BFT1BMRS9hNTBjNDgxMC01MjQ5LTQ3ZjQtOGMwZi05NmFhY2NkYTA4NGQ",
"displayName": "Rachel Grattan",
"email": "grattanr@ae.com",
"avatar": "https://avatar-prod-us-east-2.webexcontent.com/Avtr~V1~db9cdc31-8f8e-40b4-916c-8d9e9c92e70d/V1~b0a8973dc9d4e6b69519ee3a32c7955b27faac48ae7b3741d25a6db269313846~f50bb3274e3e427a802bfd27c3454016~1600",
"groups": []
},
"Y2lzY29zcGFyazovL3VzL1BFT1BMRS9iOTY4NzY5Ni1kNjc5LTRmMjUtYWYzZS0wZTQzMWQ5NWU1ZTA": {
"id": "Y2lzY29zcGFyazovL3VzL1BFT1BMRS9iOTY4NzY5Ni1kNjc5LTRmMjUtYWYzZS0wZTQzMWQ5NWU1ZTA",
"displayName": "Chloe Piccola",
"email": "piccolac@ae.com",
"avatar": "https://avatar-prod-us-east-2.webexcontent.com/Avtr~V1~db9cdc31-8f8e-40b4-916c-8d9e9c92e70d/V1~5cbf8b8daff9d364e6004f7d6b3b3f6b7bc48f3770b7c91d17b0399f47701680~9b386ca401d3460aab619f0afd5a5151~1600",
"groups": []
}
}
} }
}, },
"integration": { "integration": {
@ -280,4 +21,4 @@
"appName": "CollabCentral" "appName": "CollabCentral"
} }
} }
} }

111
index.js
View file

@ -18,9 +18,42 @@ import * as helpers from './lib/helpers.js';
const queue = new PQueue({ concurrency: 10 }); const queue = new PQueue({ concurrency: 10 });
//Load the config files from storage. //Load the config files from storage.
// config.json = committed, structural (server port, bot labels, integration ids).
// authorized.json = gitignored, mutable per-user data:
// { admins: [personId...], bot: { <appName>: { <personId>: { ...profile, groups: [...] } } } }
// jobs.json = gitignored, runtime queue state.
// userPrefs.json = gitignored, per-recipient preferences (language, ...).
var config = JSON.parse(fs.readFileSync('./config/config.json')); var config = JSON.parse(fs.readFileSync('./config/config.json'));
var jobs = JSON.parse(fs.readFileSync('./config/jobs.json')); var jobs = JSON.parse(fs.readFileSync('./config/jobs.json'));
var userPrefs = JSON.parse(fs.readFileSync('./config/userPrefs.json')); var userPrefs = JSON.parse(fs.readFileSync('./config/userPrefs.json'));
var authorized = loadAuthorized();
function loadAuthorized() {
try {
var doc = JSON.parse(fs.readFileSync('./config/authorized.json'));
doc.admins = Array.isArray(doc.admins) ? doc.admins : [];
doc.bot = (doc.bot && typeof doc.bot === 'object') ? doc.bot : {};
return doc;
} catch (err) {
// Missing file (fresh deploy) is fine — we start empty and the admin
// page can populate. Any other parse error is fatal because otherwise
// the whole authorization layer silently permits nothing.
if (err && err.code === 'ENOENT') {
console.log('loadAuthorized: authorized.json not found, starting empty.');
return { admins: [], bot: {} };
}
throw err;
}
}
// Returns the mutable per-user record for (appName, personId), creating the
// bot bucket if this is the first write. Never returns null when personId is
// truthy — callers use this to add favorites / users without null-checking.
// Read-only callers should prefer helpers.getAuthorizedEntry(authorized, ...).
function getOrCreateAuthorizedEntry(appName, personId) {
if (!authorized.bot[appName]) authorized.bot[appName] = {};
return authorized.bot[appName][personId] || null;
}
// Defensive init so code below can safely `.push`, `.filter`, and index into // Defensive init so code below can safely `.push`, `.filter`, and index into
// these regardless of what the on-disk jobs.json happens to contain. // these regardless of what the on-disk jobs.json happens to contain.
@ -496,7 +529,8 @@ app.get('/CollabCentral/:app/user/:scope/:action', (req, res) => {
if (req.params.scope == "groups") { if (req.params.scope == "groups") {
if (req.params.action == "list") { if (req.params.action == "list") {
logger("apiEndpoint(" + req.params.app + ")", "GET /user/groups/list"); logger("apiEndpoint(" + req.params.app + ")", "GET /user/groups/list");
res.status(200).send(config.webex.bot[req.params.app].authorized[req.cookies.id].groups) var listEntry = helpers.getAuthorizedEntry(authorized, req.params.app, req.cookies.id);
res.status(200).send((listEntry && listEntry.groups) || []);
} else if (req.params.action == "find") { } else if (req.params.action == "find") {
logger("apiEndpoint(" + req.params.app + ")", "GET /user/groups/find"); logger("apiEndpoint(" + req.params.app + ")", "GET /user/groups/find");
getGroupsFromCache() getGroupsFromCache()
@ -507,21 +541,21 @@ app.get('/CollabCentral/:app/user/:scope/:action', (req, res) => {
res.status(502).send('Failed to load groups from Webex.'); res.status(502).send('Failed to load groups from Webex.');
}); });
} }
} else { res.status(404) } } else { res.status(404).send('Not found.'); }
} else { res.status(401) } } else { res.status(401).send('Unauthorized.'); }
}) })
// Add / remove a favorite group for the calling user. Favorites live inside // Add / remove a favorite group for the calling user. Favorites live inside
// config.json under `webex.bot[app].authorized[personId].groups` so they // authorized.json under `bot[app][personId].groups` so they survive restarts
// survive restarts and are picklable in the compose page's Favorite Groups // and are picklable in the compose page's Favorite Groups selector. Both
// selector. Both endpoints validate that: // endpoints validate that:
// - the caller is authorized for :app // - the caller is authorized for :app
// - :id looks like a Webex SCIM group id we know about (present in the // - :id looks like a Webex SCIM group id we know about (present in the
// cached org-wide group list) — this stops a bad actor from stuffing // cached org-wide group list) — this stops a bad actor from stuffing
// arbitrary strings into the config. // arbitrary strings into the config.
// Because index.js is single-process and node is single-threaded, concurrent // Because index.js is single-process and node is single-threaded, concurrent
// requests here can't corrupt config.json — every read/mutate/write executes // requests here can't corrupt authorized.json — every read/mutate/write
// atomically within one handler. // executes atomically within one handler.
app.post('/CollabCentral/:app/user/groups/add', async function (req, res) { app.post('/CollabCentral/:app/user/groups/add', async function (req, res) {
if (!isAuthorized(req.params.app, req.cookies.id)) return res.status(401).send('Unauthorized.'); if (!isAuthorized(req.params.app, req.cookies.id)) return res.status(401).send('Unauthorized.');
var appName = req.params.app; var appName = req.params.app;
@ -540,14 +574,18 @@ app.post('/CollabCentral/:app/user/groups/add', async function (req, res) {
} }
if (!group) return res.status(404).send('Unknown group id.'); if (!group) return res.status(404).send('Unknown group id.');
var favorites = config.webex.bot[appName].authorized[personId].groups || []; var entry = helpers.getAuthorizedEntry(authorized, appName, personId);
// isAuthorized guarantees the entry exists but be defensive; a race
// between an admin removal and this request could otherwise crash.
if (!entry) return res.status(401).send('Unauthorized.');
var favorites = Array.isArray(entry.groups) ? entry.groups : [];
if (favorites.find(function (g) { return g.id === groupId; })) { if (favorites.find(function (g) { return g.id === groupId; })) {
return res.status(200).send(favorites); return res.status(200).send(favorites);
} }
favorites.push({ name: group.displayName, id: group.id }); favorites.push({ name: group.displayName, id: group.id });
config.webex.bot[appName].authorized[personId].groups = favorites; entry.groups = favorites;
try { try {
saveConfig(config, './config/config.json'); saveConfig(authorized, './config/authorized.json');
} catch (err) { } catch (err) {
logger('apiEndpoint(' + appName + ')', logger('apiEndpoint(' + appName + ')',
'groups/add save failed: ' + (err && err.message || err)); 'groups/add save failed: ' + (err && err.message || err));
@ -565,15 +603,17 @@ app.post('/CollabCentral/:app/user/groups/remove', function (req, res) {
var groupId = req.body && req.body.id; var groupId = req.body && req.body.id;
if (!groupId) return res.status(400).send('Missing group id.'); if (!groupId) return res.status(400).send('Missing group id.');
var favorites = config.webex.bot[appName].authorized[personId].groups || []; var entry = helpers.getAuthorizedEntry(authorized, appName, personId);
if (!entry) return res.status(401).send('Unauthorized.');
var favorites = Array.isArray(entry.groups) ? entry.groups : [];
var before = favorites.length; var before = favorites.length;
var removed = favorites.find(function (g) { return g.id === groupId; }); var removed = favorites.find(function (g) { return g.id === groupId; });
favorites = favorites.filter(function (g) { return g.id !== groupId; }); favorites = favorites.filter(function (g) { return g.id !== groupId; });
if (favorites.length === before) return res.status(200).send(favorites); if (favorites.length === before) return res.status(200).send(favorites);
config.webex.bot[appName].authorized[personId].groups = favorites; entry.groups = favorites;
try { try {
saveConfig(config, './config/config.json'); saveConfig(authorized, './config/authorized.json');
} catch (err) { } catch (err) {
logger('apiEndpoint(' + appName + ')', logger('apiEndpoint(' + appName + ')',
'groups/remove save failed: ' + (err && err.message || err)); 'groups/remove save failed: ' + (err && err.message || err));
@ -586,13 +626,13 @@ app.post('/CollabCentral/:app/user/groups/remove', function (req, res) {
// ---- Admin: manage the authorized-user list for a bot --------------------- // ---- Admin: manage the authorized-user list for a bot ---------------------
// //
// Admin authority lives in a top-level config.admins array (personIds). // Admin authority lives in authorized.admins (personIds). Every admin route
// Every admin route rejects with 403 for non-admin callers — 403 rather than // rejects with 403 for non-admin callers — 403 rather than 401 so the client
// 401 so the client can tell an admin-only route apart from a signed-out // can tell an admin-only route apart from a signed-out state (which would
// state (which would 401). Admin actions are cross-bot in concept but the // 401). Admin actions are cross-bot in concept but the routes are still
// routes are still :app-scoped because the resource being edited is per-bot // :app-scoped because the resource being edited is per-bot (authorized users
// (authorized users on THAT bot) and it keeps the OAuth session model // on THAT bot) and it keeps the OAuth session model unchanged (admin uses
// unchanged (admin uses the same session cookie as any other page). // the same session cookie as any other page).
// //
// requireAdmin returns null on success or an Express-response-sending // requireAdmin returns null on success or an Express-response-sending
// function on failure, so each handler stays a straight-line function. // function on failure, so each handler stays a straight-line function.
@ -617,8 +657,8 @@ function adminUserRow(entry) {
} }
function adminUsersList(appName) { function adminUsersList(appName) {
var authorized = (config.webex.bot[appName] && config.webex.bot[appName].authorized) || {}; var bucket = (authorized.bot && authorized.bot[appName]) || {};
return Object.keys(authorized).map(function (id) { return adminUserRow(authorized[id]); }); return Object.keys(bucket).map(function (id) { return adminUserRow(bucket[id]); });
} }
app.get('/CollabCentral/:app/admin/users', function (req, res) { app.get('/CollabCentral/:app/admin/users', function (req, res) {
@ -643,9 +683,10 @@ app.post('/CollabCentral/:app/admin/users', async function (req, res) {
} }
if (!person) return res.status(404).send('No Webex user found for that email.'); if (!person) return res.status(404).send('No Webex user found for that email.');
var authorized = config.webex.bot[appName].authorized = config.webex.bot[appName].authorized || {}; if (!authorized.bot[appName]) authorized.bot[appName] = {};
if (!authorized[person.id]) { var bucket = authorized.bot[appName];
authorized[person.id] = { if (!bucket[person.id]) {
bucket[person.id] = {
id: person.id, id: person.id,
displayName: person.displayName, displayName: person.displayName,
email: person.email, email: person.email,
@ -653,7 +694,7 @@ app.post('/CollabCentral/:app/admin/users', async function (req, res) {
groups: [] groups: []
}; };
try { try {
saveConfig(config, './config/config.json'); saveConfig(authorized, './config/authorized.json');
} catch (err) { } catch (err) {
logger('apiEndpoint(' + appName + ')', logger('apiEndpoint(' + appName + ')',
'admin/users save failed: ' + (err && err.message || err)); 'admin/users save failed: ' + (err && err.message || err));
@ -663,7 +704,7 @@ app.post('/CollabCentral/:app/admin/users', async function (req, res) {
'admin/users + "' + person.displayName + '" <' + person.email + '>'); 'admin/users + "' + person.displayName + '" <' + person.email + '>');
} }
res.status(200).send({ res.status(200).send({
user: adminUserRow(authorized[person.id]), user: adminUserRow(bucket[person.id]),
users: adminUsersList(appName) users: adminUsersList(appName)
}); });
}); });
@ -672,14 +713,14 @@ app.delete('/CollabCentral/:app/admin/users/:id', function (req, res) {
if (requireAdmin(req, res)) return; if (requireAdmin(req, res)) return;
var appName = req.params.app; var appName = req.params.app;
var targetId = req.params.id; var targetId = req.params.id;
var authorized = (config.webex.bot[appName] && config.webex.bot[appName].authorized) || {}; var bucket = (authorized.bot && authorized.bot[appName]) || {};
if (!authorized[targetId]) { if (!bucket[targetId]) {
return res.status(200).send({ removed: false, users: adminUsersList(appName) }); return res.status(200).send({ removed: false, users: adminUsersList(appName) });
} }
var name = authorized[targetId].displayName || targetId; var name = bucket[targetId].displayName || targetId;
delete authorized[targetId]; delete bucket[targetId];
try { try {
saveConfig(config, './config/config.json'); saveConfig(authorized, './config/authorized.json');
} catch (err) { } catch (err) {
logger('apiEndpoint(' + appName + ')', logger('apiEndpoint(' + appName + ')',
'admin/users delete save failed: ' + (err && err.message || err)); 'admin/users delete save failed: ' + (err && err.message || err));
@ -1617,11 +1658,11 @@ async function cleanCompletedJobs(jobs) {
function isAuthorized(appName, personId) { function isAuthorized(appName, personId) {
logger("isAuthorized", appName + " " + personId) logger("isAuthorized", appName + " " + personId)
return helpers.isAuthorized(config, botTokens, appName, personId); return helpers.isAuthorized(config, botTokens, authorized, appName, personId);
} }
function isAdmin(personId) { function isAdmin(personId) {
return helpers.isAdmin(config, personId); return helpers.isAdmin(authorized, personId);
} }
function msToTime(duration) { function msToTime(duration) {

View file

@ -50,22 +50,33 @@ export function getBotConfig(config, botTokens, appName) {
return cfg; return cfg;
} }
// True iff `personId` is listed under the bot's `authorized` map AND the bot // Returns the "per-user record" for (appName, personId) if one exists in the
// is enabled. Missing personId or missing bot config yields false. // authorized doc, otherwise null. Kept here as a single lookup point so
export function isAuthorized(config, botTokens, appName, personId) { // callers never reach into authorized.bot[...] directly and every consumer
var botCfg = getBotConfig(config, botTokens, appName); // (isAuthorized, favorites, admin routes) shares one nullability contract.
if (!botCfg) return false; export function getAuthorizedEntry(authorized, appName, personId) {
if (!personId) return false; if (!authorized || !authorized.bot || !appName || !personId) return null;
return !!(botCfg.authorized && botCfg.authorized[personId]); var bucket = authorized.bot[appName];
return (bucket && bucket[personId]) || null;
} }
// True iff `personId` appears in the top-level config.admins array. Admin // True iff `personId` is authorized on the bot AND the bot is enabled. The
// authority is intentionally cross-bot: a single admin manages authorization // three-argument shape (config, tokens, authorized, app, id) lets the same
// for every bot the process serves. Missing personId or missing admins list // pure function serve both server code and unit tests without mocking file
// yields false so an unconfigured deployment fails closed. // I/O. Missing config, tokens, or authorized yields false so a partially-
export function isAdmin(config, personId) { // loaded process fails closed rather than granting access.
export function isAuthorized(config, botTokens, authorized, appName, personId) {
if (!getBotConfig(config, botTokens, appName)) return false;
return getAuthorizedEntry(authorized, appName, personId) !== null;
}
// True iff `personId` appears in authorized.admins. Admin authority is
// intentionally cross-bot: one admin manages authorization for every bot
// the process serves. Missing personId or missing admins list yields false
// so an unconfigured deployment fails closed.
export function isAdmin(authorized, personId) {
if (!personId) return false; if (!personId) return false;
var admins = config && config.admins; var admins = authorized && authorized.admins;
if (!Array.isArray(admins)) return false; if (!Array.isArray(admins)) return false;
return admins.indexOf(personId) !== -1; return admins.indexOf(personId) !== -1;
} }

View file

@ -6,6 +6,7 @@ import {
getBotToken, getBotToken,
isBotEnabled, isBotEnabled,
getBotConfig, getBotConfig,
getAuthorizedEntry,
isAuthorized, isAuthorized,
isAdmin, isAdmin,
getOAuthRedirectUri, getOAuthRedirectUri,
@ -15,30 +16,19 @@ import {
COMPLETED_RETENTION_DAYS, COMPLETED_RETENTION_DAYS,
} from '../lib/helpers.js'; } from '../lib/helpers.js';
// A minimal, self-contained config/tokens pair used across the auth tests so // A minimal, self-contained state triple used across the auth tests so each
// each test doesn't have to reconstruct one. Keeping novi enabled + techupdates // test doesn't have to reconstruct one. Keeping novi enabled + techupdates
// disabled + noman unknown covers the three interesting bot states. // 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() { function makeState() {
return { return {
config: { config: {
webex: { webex: {
bot: { bot: {
novi: { novi: { label: 'Novi Communicator' },
label: 'Novi Communicator', techupdates: { label: 'Tech Updates' },
authorized: { orphan: { label: 'Configured but no authorized users' },
'personA': { id: 'personA' },
'personB': { id: 'personB' },
},
},
techupdates: {
label: 'Tech Updates',
authorized: {
'personA': { id: 'personA' },
},
},
orphan: {
label: 'Configured but no authorized users',
},
}, },
}, },
}, },
@ -48,6 +38,13 @@ function makeState() {
orphan: { token: 'orphan-token', enabled: true }, orphan: { token: 'orphan-token', enabled: true },
flatstring: 'flat-token', flatstring: 'flat-token',
}, },
authorized: {
admins: [],
bot: {
novi: { 'personA': { id: 'personA' }, 'personB': { id: 'personB' } },
techupdates: { 'personA': { id: 'personA' } },
},
},
}; };
} }
@ -165,38 +162,65 @@ describe('getBotConfig', () => {
}); });
}); });
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', () => { describe('isAuthorized', () => {
const { config, botTokens } = makeState(); const { config, botTokens, authorized } = makeState();
test('true when the person is listed under the bot and the bot is enabled', () => { test('true when the person is listed under the bot and the bot is enabled', () => {
assert.equal(isAuthorized(config, botTokens, 'novi', 'personA'), true); assert.equal(isAuthorized(config, botTokens, authorized, 'novi', 'personA'), true);
}); });
test('false when the person is not listed under this bot', () => { test('false when the person is not listed under this bot', () => {
assert.equal(isAuthorized(config, botTokens, 'novi', 'stranger'), false); assert.equal(isAuthorized(config, botTokens, authorized, 'novi', 'stranger'), false);
}); });
test('false when the bot is disabled, even for a listed person', () => { test('false when the bot is disabled, even for a listed person', () => {
assert.equal(isAuthorized(config, botTokens, 'techupdates', 'personA'), false); assert.equal(isAuthorized(config, botTokens, authorized, 'techupdates', 'personA'), false);
}); });
test('false when personId is falsy', () => { test('false when personId is falsy', () => {
assert.equal(isAuthorized(config, botTokens, 'novi', undefined), false); assert.equal(isAuthorized(config, botTokens, authorized, 'novi', undefined), false);
assert.equal(isAuthorized(config, botTokens, 'novi', ''), false); assert.equal(isAuthorized(config, botTokens, authorized, 'novi', ''), false);
}); });
test('false when the bot config has no authorized block', () => { test('false when the bot config has no authorized bucket', () => {
assert.equal(isAuthorized(config, botTokens, 'orphan', 'personA'), false); 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', () => { describe('isAdmin', () => {
test('true when the person appears in config.admins', () => { test('true when the person appears in authorized.admins', () => {
assert.equal(isAdmin({ admins: ['personA', 'personB'] }, 'personA'), true); assert.equal(isAdmin({ admins: ['personA', 'personB'], bot: {} }, 'personA'), true);
}); });
test('false when the person is not in config.admins', () => { test('false when the person is not in authorized.admins', () => {
assert.equal(isAdmin({ admins: ['personA'] }, 'stranger'), false); assert.equal(isAdmin({ admins: ['personA'], bot: {} }, 'stranger'), false);
}); });
test('false when personId is missing', () => { test('false when personId is missing', () => {
@ -210,7 +234,7 @@ describe('isAdmin', () => {
assert.equal(isAdmin({ admins: 'personA' }, 'personA'), false); assert.equal(isAdmin({ admins: 'personA' }, 'personA'), false);
}); });
test('false on missing config entirely', () => { test('false on missing authorized doc entirely', () => {
assert.equal(isAdmin(undefined, 'personA'), false); assert.equal(isAdmin(undefined, 'personA'), false);
assert.equal(isAdmin(null, 'personA'), false); assert.equal(isAdmin(null, 'personA'), false);
}); });