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:
parent
bd6bfedb70
commit
44568044e8
6 changed files with 190 additions and 358 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -9,6 +9,7 @@ config/token.json
|
|||
config/jobs.json
|
||||
config/jobs copy.json
|
||||
config/userPrefs.json
|
||||
config/authorized.json
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
|
||||
|
|
|
|||
46
README.md
46
README.md
|
|
@ -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
|
||||
`/CollabCentral/alerts/*` routes.
|
||||
3. **Bot metadata + authorized users** — edit `config/config.json` and add
|
||||
an entry under `webex.bot`:
|
||||
3. **Bot metadata** — edit `config/config.json` and add a structural entry
|
||||
under `webex.bot`:
|
||||
```json
|
||||
"alerts": {
|
||||
"label": "Ops Alerts",
|
||||
"authorized": {
|
||||
"<webexPersonId>": {
|
||||
"id": "<webexPersonId>",
|
||||
"displayName": "…",
|
||||
"email": "…",
|
||||
"avatar": "…",
|
||||
"groups": [ { "name": "…", "alias": "…", "id": "<webexGroupId>" } ]
|
||||
"alerts": { "label": "Ops Alerts" }
|
||||
```
|
||||
4. **Authorized users** — add per-user data to `config/authorized.json`
|
||||
(gitignored; created automatically at first admin action if it doesn't
|
||||
exist):
|
||||
```json
|
||||
{
|
||||
"admins": ["<webexPersonId>"],
|
||||
"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
|
||||
served by the per-bot static mount).
|
||||
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`,
|
||||
`token.json`, `languages.json`, and (if you want to preserve state)
|
||||
`jobs.json` and `userPrefs.json`.
|
||||
`token.json`, `languages.json`, `authorized.json`, and (if you want to
|
||||
preserve state) `jobs.json` and `userPrefs.json`.
|
||||
|
||||
## File layout
|
||||
|
||||
|
|
@ -119,8 +130,8 @@ Committed to the repo:
|
|||
and the send queue.
|
||||
- `html/` — frontend (sendMessage / monitorJobs / jobDetail pages, per-bot
|
||||
icons, shared JS libraries).
|
||||
- `config/config.json` — server settings, per-bot labels, and the
|
||||
authorized-user table for each bot.
|
||||
- `config/config.json` — structural: server settings, per-bot labels,
|
||||
integration ids. No per-user data lives here anymore.
|
||||
- `config/languages.json` — supported translation languages.
|
||||
- `config/botTokens.example.json` — template for `botTokens.json`.
|
||||
- `.env.example` — template for `.env`.
|
||||
|
|
@ -133,6 +144,9 @@ Runtime state (gitignored, not committed):
|
|||
- `config/botTokens.json` — per-bot access tokens.
|
||||
- `config/token.json` — service-account OAuth tokens (rotated by the
|
||||
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/userPrefs.json` — per-user preferences (language, etc.).
|
||||
- `uploads/` — CSVs of recipient IDs and any attached images.
|
||||
|
|
|
|||
|
|
@ -3,273 +3,14 @@
|
|||
"name": "CollabCentral",
|
||||
"port": "1451"
|
||||
},
|
||||
"admins": [
|
||||
"Y2lzY29zcGFyazovL3VzL1BFT1BMRS80NzAyNDlhNC1kNjFjLTQzNmMtYTE1My1kOGUzZTExMmI4MDU"
|
||||
],
|
||||
"languages": [],
|
||||
"webex": {
|
||||
"bot": {
|
||||
"novi": {
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
"label": "Novi Communicator"
|
||||
},
|
||||
"techupdates": {
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
"label": "Technology Updates"
|
||||
}
|
||||
},
|
||||
"integration": {
|
||||
|
|
@ -280,4 +21,4 @@
|
|||
"appName": "CollabCentral"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
111
index.js
111
index.js
|
|
@ -18,9 +18,42 @@ import * as helpers from './lib/helpers.js';
|
|||
const queue = new PQueue({ concurrency: 10 });
|
||||
|
||||
//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 jobs = JSON.parse(fs.readFileSync('./config/jobs.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
|
||||
// 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.action == "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") {
|
||||
logger("apiEndpoint(" + req.params.app + ")", "GET /user/groups/find");
|
||||
getGroupsFromCache()
|
||||
|
|
@ -507,21 +541,21 @@ app.get('/CollabCentral/:app/user/:scope/:action', (req, res) => {
|
|||
res.status(502).send('Failed to load groups from Webex.');
|
||||
});
|
||||
}
|
||||
} else { res.status(404) }
|
||||
} else { res.status(401) }
|
||||
} else { res.status(404).send('Not found.'); }
|
||||
} else { res.status(401).send('Unauthorized.'); }
|
||||
})
|
||||
|
||||
// Add / remove a favorite group for the calling user. Favorites live inside
|
||||
// config.json under `webex.bot[app].authorized[personId].groups` so they
|
||||
// survive restarts and are picklable in the compose page's Favorite Groups
|
||||
// selector. Both endpoints validate that:
|
||||
// authorized.json under `bot[app][personId].groups` so they survive restarts
|
||||
// and are picklable in the compose page's Favorite Groups selector. Both
|
||||
// endpoints validate that:
|
||||
// - the caller is authorized for :app
|
||||
// - :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
|
||||
// arbitrary strings into the config.
|
||||
// Because index.js is single-process and node is single-threaded, concurrent
|
||||
// requests here can't corrupt config.json — every read/mutate/write executes
|
||||
// atomically within one handler.
|
||||
// requests here can't corrupt authorized.json — every read/mutate/write
|
||||
// executes atomically within one handler.
|
||||
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.');
|
||||
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.');
|
||||
|
||||
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; })) {
|
||||
return res.status(200).send(favorites);
|
||||
}
|
||||
favorites.push({ name: group.displayName, id: group.id });
|
||||
config.webex.bot[appName].authorized[personId].groups = favorites;
|
||||
entry.groups = favorites;
|
||||
try {
|
||||
saveConfig(config, './config/config.json');
|
||||
saveConfig(authorized, './config/authorized.json');
|
||||
} catch (err) {
|
||||
logger('apiEndpoint(' + appName + ')',
|
||||
'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;
|
||||
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 removed = favorites.find(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);
|
||||
|
||||
config.webex.bot[appName].authorized[personId].groups = favorites;
|
||||
entry.groups = favorites;
|
||||
try {
|
||||
saveConfig(config, './config/config.json');
|
||||
saveConfig(authorized, './config/authorized.json');
|
||||
} catch (err) {
|
||||
logger('apiEndpoint(' + appName + ')',
|
||||
'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 authority lives in a top-level config.admins array (personIds).
|
||||
// Every admin route rejects with 403 for non-admin callers — 403 rather than
|
||||
// 401 so the client can tell an admin-only route apart from a signed-out
|
||||
// state (which would 401). Admin actions are cross-bot in concept but the
|
||||
// routes are still :app-scoped because the resource being edited is per-bot
|
||||
// (authorized users on THAT bot) and it keeps the OAuth session model
|
||||
// unchanged (admin uses the same session cookie as any other page).
|
||||
// Admin authority lives in authorized.admins (personIds). Every admin route
|
||||
// rejects with 403 for non-admin callers — 403 rather than 401 so the client
|
||||
// can tell an admin-only route apart from a signed-out state (which would
|
||||
// 401). Admin actions are cross-bot in concept but the routes are still
|
||||
// :app-scoped because the resource being edited is per-bot (authorized users
|
||||
// on THAT bot) and it keeps the OAuth session model unchanged (admin uses
|
||||
// the same session cookie as any other page).
|
||||
//
|
||||
// requireAdmin returns null on success or an Express-response-sending
|
||||
// function on failure, so each handler stays a straight-line function.
|
||||
|
|
@ -617,8 +657,8 @@ function adminUserRow(entry) {
|
|||
}
|
||||
|
||||
function adminUsersList(appName) {
|
||||
var authorized = (config.webex.bot[appName] && config.webex.bot[appName].authorized) || {};
|
||||
return Object.keys(authorized).map(function (id) { return adminUserRow(authorized[id]); });
|
||||
var bucket = (authorized.bot && authorized.bot[appName]) || {};
|
||||
return Object.keys(bucket).map(function (id) { return adminUserRow(bucket[id]); });
|
||||
}
|
||||
|
||||
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.');
|
||||
|
||||
var authorized = config.webex.bot[appName].authorized = config.webex.bot[appName].authorized || {};
|
||||
if (!authorized[person.id]) {
|
||||
authorized[person.id] = {
|
||||
if (!authorized.bot[appName]) authorized.bot[appName] = {};
|
||||
var bucket = authorized.bot[appName];
|
||||
if (!bucket[person.id]) {
|
||||
bucket[person.id] = {
|
||||
id: person.id,
|
||||
displayName: person.displayName,
|
||||
email: person.email,
|
||||
|
|
@ -653,7 +694,7 @@ app.post('/CollabCentral/:app/admin/users', async function (req, res) {
|
|||
groups: []
|
||||
};
|
||||
try {
|
||||
saveConfig(config, './config/config.json');
|
||||
saveConfig(authorized, './config/authorized.json');
|
||||
} catch (err) {
|
||||
logger('apiEndpoint(' + appName + ')',
|
||||
'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 + '>');
|
||||
}
|
||||
res.status(200).send({
|
||||
user: adminUserRow(authorized[person.id]),
|
||||
user: adminUserRow(bucket[person.id]),
|
||||
users: adminUsersList(appName)
|
||||
});
|
||||
});
|
||||
|
|
@ -672,14 +713,14 @@ app.delete('/CollabCentral/:app/admin/users/:id', function (req, res) {
|
|||
if (requireAdmin(req, res)) return;
|
||||
var appName = req.params.app;
|
||||
var targetId = req.params.id;
|
||||
var authorized = (config.webex.bot[appName] && config.webex.bot[appName].authorized) || {};
|
||||
if (!authorized[targetId]) {
|
||||
var bucket = (authorized.bot && authorized.bot[appName]) || {};
|
||||
if (!bucket[targetId]) {
|
||||
return res.status(200).send({ removed: false, users: adminUsersList(appName) });
|
||||
}
|
||||
var name = authorized[targetId].displayName || targetId;
|
||||
delete authorized[targetId];
|
||||
var name = bucket[targetId].displayName || targetId;
|
||||
delete bucket[targetId];
|
||||
try {
|
||||
saveConfig(config, './config/config.json');
|
||||
saveConfig(authorized, './config/authorized.json');
|
||||
} catch (err) {
|
||||
logger('apiEndpoint(' + appName + ')',
|
||||
'admin/users delete save failed: ' + (err && err.message || err));
|
||||
|
|
@ -1617,11 +1658,11 @@ async function cleanCompletedJobs(jobs) {
|
|||
|
||||
function 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) {
|
||||
return helpers.isAdmin(config, personId);
|
||||
return helpers.isAdmin(authorized, personId);
|
||||
}
|
||||
|
||||
function msToTime(duration) {
|
||||
|
|
|
|||
|
|
@ -50,22 +50,33 @@ export function getBotConfig(config, botTokens, appName) {
|
|||
return cfg;
|
||||
}
|
||||
|
||||
// True iff `personId` is listed under the bot's `authorized` map AND the bot
|
||||
// is enabled. Missing personId or missing bot config yields false.
|
||||
export function isAuthorized(config, botTokens, appName, personId) {
|
||||
var botCfg = getBotConfig(config, botTokens, appName);
|
||||
if (!botCfg) return false;
|
||||
if (!personId) return false;
|
||||
return !!(botCfg.authorized && botCfg.authorized[personId]);
|
||||
// Returns the "per-user record" for (appName, personId) if one exists in the
|
||||
// authorized doc, otherwise null. Kept here as a single lookup point so
|
||||
// callers never reach into authorized.bot[...] directly and every consumer
|
||||
// (isAuthorized, favorites, admin routes) shares one nullability contract.
|
||||
export function getAuthorizedEntry(authorized, appName, personId) {
|
||||
if (!authorized || !authorized.bot || !appName || !personId) return null;
|
||||
var bucket = authorized.bot[appName];
|
||||
return (bucket && bucket[personId]) || null;
|
||||
}
|
||||
|
||||
// True iff `personId` appears in the top-level config.admins array. Admin
|
||||
// authority is intentionally cross-bot: a single 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(config, personId) {
|
||||
// True iff `personId` is authorized on the bot AND the bot is enabled. The
|
||||
// three-argument shape (config, tokens, authorized, app, id) lets the same
|
||||
// pure function serve both server code and unit tests without mocking file
|
||||
// I/O. Missing config, tokens, or authorized yields false so a partially-
|
||||
// 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;
|
||||
var admins = config && config.admins;
|
||||
var admins = authorized && authorized.admins;
|
||||
if (!Array.isArray(admins)) return false;
|
||||
return admins.indexOf(personId) !== -1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
getBotToken,
|
||||
isBotEnabled,
|
||||
getBotConfig,
|
||||
getAuthorizedEntry,
|
||||
isAuthorized,
|
||||
isAdmin,
|
||||
getOAuthRedirectUri,
|
||||
|
|
@ -15,30 +16,19 @@ import {
|
|||
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.
|
||||
// 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',
|
||||
authorized: {
|
||||
'personA': { id: 'personA' },
|
||||
'personB': { id: 'personB' },
|
||||
},
|
||||
},
|
||||
techupdates: {
|
||||
label: 'Tech Updates',
|
||||
authorized: {
|
||||
'personA': { id: 'personA' },
|
||||
},
|
||||
},
|
||||
orphan: {
|
||||
label: 'Configured but no authorized users',
|
||||
},
|
||||
novi: { label: 'Novi Communicator' },
|
||||
techupdates: { label: 'Tech Updates' },
|
||||
orphan: { label: 'Configured but no authorized users' },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -48,6 +38,13 @@ function makeState() {
|
|||
orphan: { token: 'orphan-token', enabled: true },
|
||||
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', () => {
|
||||
const { config, botTokens } = makeState();
|
||||
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, 'novi', 'personA'), true);
|
||||
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, 'novi', 'stranger'), false);
|
||||
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, 'techupdates', 'personA'), false);
|
||||
assert.equal(isAuthorized(config, botTokens, authorized, 'techupdates', 'personA'), false);
|
||||
});
|
||||
|
||||
test('false when personId is falsy', () => {
|
||||
assert.equal(isAuthorized(config, botTokens, 'novi', undefined), false);
|
||||
assert.equal(isAuthorized(config, botTokens, 'novi', ''), false);
|
||||
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 block', () => {
|
||||
assert.equal(isAuthorized(config, botTokens, 'orphan', 'personA'), 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 config.admins', () => {
|
||||
assert.equal(isAdmin({ admins: ['personA', 'personB'] }, 'personA'), true);
|
||||
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 config.admins', () => {
|
||||
assert.equal(isAdmin({ admins: ['personA'] }, 'stranger'), false);
|
||||
test('false when the person is not in authorized.admins', () => {
|
||||
assert.equal(isAdmin({ admins: ['personA'], bot: {} }, 'stranger'), false);
|
||||
});
|
||||
|
||||
test('false when personId is missing', () => {
|
||||
|
|
@ -210,7 +234,7 @@ describe('isAdmin', () => {
|
|||
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(null, 'personA'), false);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue