@andrian.yablonskyy/thub-coordinator 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +157 -0
- package/bin/thub-admin.js +190 -0
- package/config.json +30 -0
- package/eslint.config.js +115 -0
- package/package.json +32 -0
- package/public/css/thub.css +6 -0
- package/public/js/copy-to-clipboard.js +36 -0
- package/public/js/log-viewer.js +76 -0
- package/public/js/profile-theme.js +28 -0
- package/public/js/theme.js +51 -0
- package/public/js/tooltips.js +18 -0
- package/src/api/admin.js +112 -0
- package/src/api/agent.js +110 -0
- package/src/api/resource.js +211 -0
- package/src/api/sse.js +96 -0
- package/src/auth.js +94 -0
- package/src/config.js +110 -0
- package/src/db/index.js +61 -0
- package/src/db/migrations/001_init.sql +104 -0
- package/src/db/migrations/003_split_job_id_counters.sql +5 -0
- package/src/db/migrations/004_add_client_id.sql +8 -0
- package/src/db/migrations/005_add_groups.sql +14 -0
- package/src/db/migrations/006_events_allow_group.sql +20 -0
- package/src/db/migrations/007_add_user_profile.sql +10 -0
- package/src/server.js +179 -0
- package/src/services/admin-users.js +174 -0
- package/src/services/agents.js +56 -0
- package/src/services/artifacts.js +149 -0
- package/src/services/bus.js +23 -0
- package/src/services/commands.js +43 -0
- package/src/services/events.js +37 -0
- package/src/services/groups.js +79 -0
- package/src/services/heartbeat.js +56 -0
- package/src/services/jobs.js +355 -0
- package/src/services/logs.js +70 -0
- package/src/services/registry.js +347 -0
- package/src/services/retention.js +44 -0
- package/src/services/scheduler.js +101 -0
- package/src/services/tokens.js +28 -0
- package/src/web/routes.js +343 -0
- package/test/admin-users.test.js +127 -0
- package/test/scheduler.test.js +375 -0
- package/views/admin/agents.pug +53 -0
- package/views/groups/list.pug +94 -0
- package/views/index.pug +37 -0
- package/views/jobs/list.pug +45 -0
- package/views/jobs/show.pug +42 -0
- package/views/layout.pug +73 -0
- package/views/login.pug +16 -0
- package/views/mixins/log-viewer.pug +14 -0
- package/views/mixins/status-badge.pug +7 -0
- package/views/profile.pug +77 -0
- package/views/resources/list.pug +65 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/src/web/routes.js
|
|
3
|
+
* @description Dashboard (Pug) routes: overview, resources, groups, jobs, agents, profile pages (README §10)
|
|
4
|
+
*
|
|
5
|
+
* @author Andrian Yablonskyy
|
|
6
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
7
|
+
*
|
|
8
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
9
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
10
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
11
|
+
* from AdSystem.PRO.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const fs = require('node:fs'),
|
|
17
|
+
os = require('node:os'),
|
|
18
|
+
path = require('node:path'),
|
|
19
|
+
express = require('express'),
|
|
20
|
+
multer = require('multer'),
|
|
21
|
+
{ requireAdminSession, requireAdminRole } = require('../auth'),
|
|
22
|
+
{ attachJobStream } = require('../api/sse'),
|
|
23
|
+
{ RESOURCE_STATES, JOB_STATES, ACTIVE_JOB_STATES } = require('@andrian.yablonskyy/thub-common');
|
|
24
|
+
|
|
25
|
+
// §10.1: avatar uploads are small, single images — a hard size cap and an
|
|
26
|
+
// allow-list of image mimetypes, same spirit as the join-key/token checks
|
|
27
|
+
// elsewhere (reject outright rather than trying to sanitize).
|
|
28
|
+
const AVATAR_MAX_BYTES = 2 * 1024 * 1024,
|
|
29
|
+
AVATAR_EXT_BY_MIMETYPE = {
|
|
30
|
+
'image/png': 'png',
|
|
31
|
+
'image/jpeg': 'jpg',
|
|
32
|
+
'image/gif': 'gif',
|
|
33
|
+
'image/webp': 'webp'
|
|
34
|
+
},
|
|
35
|
+
avatarUpload = multer({ dest: os.tmpdir(), limits: { fileSize: AVATAR_MAX_BYTES } });
|
|
36
|
+
|
|
37
|
+
// §10 Web dashboard: server-rendered Pug + Bootstrap 5.3, with the live
|
|
38
|
+
// views hitting the same kind of SSE stream the Agent uses (§6.4), just
|
|
39
|
+
// authenticated by session cookie instead of a bearer token.
|
|
40
|
+
function createWebRouter({ services, config }){
|
|
41
|
+
const router = express.Router();
|
|
42
|
+
|
|
43
|
+
function flash(req, type, text){
|
|
44
|
+
req.session.flash = req.session.flash || [];
|
|
45
|
+
req.session.flash.push({ type, text });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
router.use((req, res, next) => {
|
|
49
|
+
const user = req.session?.user || null;
|
|
50
|
+
res.locals.user = user;
|
|
51
|
+
res.locals.messages = req.session?.flash || [];
|
|
52
|
+
if (req.session){
|
|
53
|
+
req.session.flash = [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Every dashboard timestamp is rendered here on the server (Pug), so
|
|
57
|
+
// without this every user would see the *server's* local time — not
|
|
58
|
+
// their own (§10.1). Falls back to UTC for the (rare) pre-login page.
|
|
59
|
+
const tz = user?.timezone || 'UTC';
|
|
60
|
+
res.locals.fmtDate = (iso, fallback = '—') =>
|
|
61
|
+
iso ? new Date(iso).toLocaleString('en-US', { timeZone: tz }) : fallback;
|
|
62
|
+
|
|
63
|
+
// Idle timeout (§10.1): re-applied on every request (not just login)
|
|
64
|
+
// so a mid-session profile change takes effect immediately, and so
|
|
65
|
+
// `rolling: true` (server.js) actually extends by *this* user's chosen
|
|
66
|
+
// duration each time, not whatever the session happened to start with.
|
|
67
|
+
if (req.session && user){
|
|
68
|
+
req.session.cookie.maxAge = user.sessionTimeoutMin * 60 * 1000;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
next();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
router.get('/login', (req, res) => {
|
|
75
|
+
if (req.session.user){
|
|
76
|
+
return res.redirect('/');
|
|
77
|
+
}
|
|
78
|
+
res.render('login', { title: 'Sign in' });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
router.post('/login', (req, res) => {
|
|
82
|
+
const { username, password } = req.body,
|
|
83
|
+
user = services.adminUsers.verify(username, password);
|
|
84
|
+
if (!user){
|
|
85
|
+
return res.status(401).render('login', { title: 'Sign in', error: 'Invalid credentials' });
|
|
86
|
+
}
|
|
87
|
+
req.session.user = user;
|
|
88
|
+
req.session.cookie.maxAge = user.sessionTimeoutMin * 60 * 1000;
|
|
89
|
+
res.redirect('/');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
router.post('/logout', (req, res) => {
|
|
93
|
+
req.session.destroy(() => res.redirect('/login'));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
router.use(requireAdminSession);
|
|
97
|
+
|
|
98
|
+
// §10.1: every logged-in user (admin or viewer) manages their own
|
|
99
|
+
// profile — display name, avatar, timezone, theme and idle session
|
|
100
|
+
// timeout. Not gated by requireAdminRole: this only ever touches the
|
|
101
|
+
// caller's own account (req.session.user.id), never another user's.
|
|
102
|
+
router.get('/profile', (req, res) => {
|
|
103
|
+
res.render('profile', {
|
|
104
|
+
title: 'Profile',
|
|
105
|
+
timezones: Intl.supportedValuesOf('timeZone'),
|
|
106
|
+
sessionTimeoutOptions: services.adminUsers.SESSION_TIMEOUT_OPTIONS_MIN
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
router.post('/profile', (req, res) => {
|
|
111
|
+
avatarUpload.single('avatar')(req, res, (uploadErr) => {
|
|
112
|
+
if (uploadErr){
|
|
113
|
+
flash(
|
|
114
|
+
req,
|
|
115
|
+
'danger',
|
|
116
|
+
uploadErr.code === 'LIMIT_FILE_SIZE'
|
|
117
|
+
? `Avatar image must be under ${AVATAR_MAX_BYTES / (1024 * 1024)}MB.`
|
|
118
|
+
: uploadErr.message
|
|
119
|
+
);
|
|
120
|
+
return res.redirect('/profile');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const userId = req.session.user.id,
|
|
124
|
+
fields = {
|
|
125
|
+
username: req.body.username,
|
|
126
|
+
firstName: req.body.firstName || null,
|
|
127
|
+
lastName: req.body.lastName || null,
|
|
128
|
+
timezone: req.body.timezone,
|
|
129
|
+
sessionTimeoutMin: Number(req.body.sessionTimeoutMin)
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
if (req.file){
|
|
133
|
+
const ext = AVATAR_EXT_BY_MIMETYPE[req.file.mimetype];
|
|
134
|
+
if (!ext){
|
|
135
|
+
fs.rmSync(req.file.path, { force: true });
|
|
136
|
+
flash(req, 'danger', `Unsupported image type "${req.file.mimetype}" — use PNG, JPEG, GIF or WebP.`);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
// Clear any previous avatar under a different extension so
|
|
140
|
+
// switching PNG -> JPEG doesn't leave the old file behind.
|
|
141
|
+
for (const e of Object.values(AVATAR_EXT_BY_MIMETYPE)){
|
|
142
|
+
fs.rmSync(path.join(config.avatarsDir, `${userId}.${e}`), { force: true });
|
|
143
|
+
}
|
|
144
|
+
fs.renameSync(req.file.path, path.join(config.avatarsDir, `${userId}.${ext}`));
|
|
145
|
+
fields.avatarPath = `/avatars/${userId}.${ext}`;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
req.session.user = services.adminUsers.updateProfile(userId, fields);
|
|
151
|
+
flash(req, 'success', 'Profile updated.');
|
|
152
|
+
}
|
|
153
|
+
catch (err){
|
|
154
|
+
flash(req, 'danger', err.message);
|
|
155
|
+
}
|
|
156
|
+
res.redirect('/profile');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Quick theme toggle (navbar button, §10.1) — a separate, minimal
|
|
161
|
+
// endpoint so switching theme doesn't require resubmitting the whole
|
|
162
|
+
// profile form. Persists per-account so it's the same on every device,
|
|
163
|
+
// not just the browser that clicked it (public/js/theme.js).
|
|
164
|
+
router.post('/profile/theme', (req, res) => {
|
|
165
|
+
try {
|
|
166
|
+
req.session.user = services.adminUsers.updateProfile(req.session.user.id, { theme: req.body.theme });
|
|
167
|
+
res.json({ ok: true, theme: req.session.user.theme });
|
|
168
|
+
}
|
|
169
|
+
catch (err){
|
|
170
|
+
res.status(err.status || 400).json({ ok: false, error: err.message });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Self-service password change (§10.1) — separate from the rest of the
|
|
175
|
+
// profile form since it needs the *current* password re-entered, and a
|
|
176
|
+
// mismatch between newPassword/confirmPassword is a form-level check
|
|
177
|
+
// that has nothing to do with the other fields.
|
|
178
|
+
router.post('/profile/password', (req, res) => {
|
|
179
|
+
const { currentPassword, newPassword, confirmPassword } = req.body;
|
|
180
|
+
if (newPassword !== confirmPassword){
|
|
181
|
+
flash(req, 'danger', 'New password and confirmation do not match.');
|
|
182
|
+
return res.redirect('/profile');
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
services.adminUsers.changePassword(req.session.user.id, currentPassword, newPassword);
|
|
186
|
+
flash(req, 'success', 'Password changed.');
|
|
187
|
+
}
|
|
188
|
+
catch (err){
|
|
189
|
+
flash(req, 'danger', err.message);
|
|
190
|
+
}
|
|
191
|
+
res.redirect('/profile');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
router.get('/', (req, res) => {
|
|
195
|
+
const resources = services.registry.list(),
|
|
196
|
+
queueLength = services.jobs.list({ state: JOB_STATES.QUEUED, limit: 1000 }).length,
|
|
197
|
+
dayAgo = new Date(Date.now() - 86400 * 1000).toISOString(),
|
|
198
|
+
jobsLast24h = services.jobs
|
|
199
|
+
.list({ limit: 1000 })
|
|
200
|
+
.filter((j) => j.created_at >= dayAgo).length,
|
|
201
|
+
onlineCount = resources.filter((r) => r.status !== RESOURCE_STATES.OUT_OF_SERVICE && r.status !== RESOURCE_STATES.REGISTERED).length;
|
|
202
|
+
res.render('index', { title: 'Overview', active: 'overview', resources, queueLength, jobsLast24h, onlineCount });
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
router.get('/resources', (req, res) => {
|
|
206
|
+
const groupsById = Object.fromEntries(services.groups.list().map((g) => [g.id, g]));
|
|
207
|
+
res.render('resources/list', {
|
|
208
|
+
title: 'Resources',
|
|
209
|
+
active: 'resources',
|
|
210
|
+
resources: services.registry.list(),
|
|
211
|
+
groupsById
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
router.post('/resources/:id/maintenance', requireAdminRole, (req, res) => {
|
|
216
|
+
services.registry.setMaintenance(req.params.id, req.body.enabled === '1');
|
|
217
|
+
res.redirect('/resources');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
router.post('/resources/:id/rotate-token', requireAdminRole, (req, res) => {
|
|
221
|
+
const { generateToken, hashToken } = require('../services/tokens');
|
|
222
|
+
const token = generateToken('res');
|
|
223
|
+
services.db.prepare('UPDATE resources SET token_hash = ? WHERE id = ?').run(hashToken(token), req.params.id);
|
|
224
|
+
services.events.record('resource', req.params.id, 'resource.token_rotated', {});
|
|
225
|
+
flash(req, 'warning', `New resource token (copy it now): ${token}`);
|
|
226
|
+
res.redirect('/resources');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
router.get('/jobs', (req, res) => {
|
|
230
|
+
const filters = { state: req.query.state || undefined, source: req.query.source || undefined },
|
|
231
|
+
jobs = services.jobs.list({ ...filters, limit: 200 }).map((j) => ({
|
|
232
|
+
...j,
|
|
233
|
+
resource: j.resource_id ? services.registry.get(j.resource_id) : null
|
|
234
|
+
}));
|
|
235
|
+
res.render('jobs/list', {
|
|
236
|
+
title: 'Jobs',
|
|
237
|
+
active: 'jobs',
|
|
238
|
+
jobs,
|
|
239
|
+
filters,
|
|
240
|
+
canManage: req.session.user.role === 'admin'
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// "Reset" the queue: cancel everything active. "Clean": delete finished
|
|
245
|
+
// job history. Two distinct, deliberately separate destructive actions
|
|
246
|
+
// (§13.1) — reset stops in-flight work, clean clears past work.
|
|
247
|
+
router.post('/jobs/reset-queue', requireAdminRole, (req, res) => {
|
|
248
|
+
const canceled = services.jobs.resetQueue();
|
|
249
|
+
flash(req, 'warning', `Canceled ${canceled} active job(s).`);
|
|
250
|
+
res.redirect('/jobs');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
router.post('/jobs/clean-history', requireAdminRole, (req, res) => {
|
|
254
|
+
const deleted = services.jobs.cleanHistory();
|
|
255
|
+
flash(req, 'warning', `Deleted ${deleted} finished job(s) and their logs/artifacts.`);
|
|
256
|
+
res.redirect('/jobs');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
router.get('/jobs/:id', (req, res) => {
|
|
260
|
+
const job = services.jobs.get(req.params.id);
|
|
261
|
+
if (!job){
|
|
262
|
+
return res.status(404).send('Not found');
|
|
263
|
+
}
|
|
264
|
+
job.resource = job.resource_id ? services.registry.get(job.resource_id) : null;
|
|
265
|
+
const artifacts = services.artifacts.listForJob(job.id).map((a) => ({ ...a, url: services.artifacts.signedUrl(a) })),
|
|
266
|
+
jobActive = ACTIVE_JOB_STATES.has(job.state);
|
|
267
|
+
res.render('jobs/show', {
|
|
268
|
+
title: job.id,
|
|
269
|
+
active: 'jobs',
|
|
270
|
+
job,
|
|
271
|
+
artifacts,
|
|
272
|
+
jobActive,
|
|
273
|
+
canCancel: req.session.user.role === 'admin' && jobActive
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
router.post('/jobs/:id/cancel', requireAdminRole, (req, res) => {
|
|
278
|
+
services.jobs.cancel(req.params.id, { isAdmin: true });
|
|
279
|
+
res.redirect(`/jobs/${req.params.id}`);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Plain, one-shot fetch — used by the log viewer for a job that's
|
|
283
|
+
// already finished, so it doesn't open a live connection (§SSE) for
|
|
284
|
+
// output that will never change again.
|
|
285
|
+
router.get('/jobs/:id/logs', (req, res) => {
|
|
286
|
+
res.json({ lines: services.logs.listSince(req.params.id, 0) });
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
router.get('/jobs/:id/stream', (req, res) => {
|
|
290
|
+
attachJobStream(req, res, { jobId: req.params.id, services, config });
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Resource groups (§13.1) — their own page since they're a distinct
|
|
294
|
+
// concept from both Resources (which declare membership in their own
|
|
295
|
+
// Client config) and Agents. Viewable by anyone logged in; only admins
|
|
296
|
+
// can create/rename/delete.
|
|
297
|
+
router.get('/groups', (req, res) => {
|
|
298
|
+
const groups = services.groups.list().map((g) => ({
|
|
299
|
+
...g,
|
|
300
|
+
resourceCount: services.registry.list().filter((r) => r.group_ids.includes(g.id)).length
|
|
301
|
+
}));
|
|
302
|
+
res.render('groups/list', {
|
|
303
|
+
title: 'Groups',
|
|
304
|
+
active: 'groups',
|
|
305
|
+
groups,
|
|
306
|
+
canManage: req.session.user.role === 'admin'
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
router.post('/groups', requireAdminRole, (req, res) => {
|
|
311
|
+
services.groups.create({ name: req.body.name, comment: req.body.comment });
|
|
312
|
+
res.redirect('/groups');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
router.post('/groups/:id', requireAdminRole, (req, res) => {
|
|
316
|
+
services.groups.update(req.params.id, { name: req.body.name, comment: req.body.comment });
|
|
317
|
+
res.redirect('/groups');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
router.post('/groups/:id/delete', requireAdminRole, (req, res) => {
|
|
321
|
+
services.groups.remove(req.params.id);
|
|
322
|
+
flash(req, 'warning', 'Group deleted; any resource that listed it just stopped matching on it.');
|
|
323
|
+
res.redirect('/groups');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
router.get('/admin/agents', requireAdminRole, (req, res) => {
|
|
327
|
+
res.render('admin/agents', { title: 'Agents', active: 'agents', agents: services.agents.list() });
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
router.post('/admin/agents', requireAdminRole, (req, res) => {
|
|
331
|
+
const { agent, token } = services.agents.create({ name: req.body.name, kind: req.body.kind });
|
|
332
|
+
res.render('admin/agents', { title: 'Agents', active: 'agents', agents: services.agents.list(), newToken: token });
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
router.post('/admin/agents/:id/revoke', requireAdminRole, (req, res) => {
|
|
336
|
+
services.agents.revoke(req.params.id);
|
|
337
|
+
res.redirect('/admin/agents');
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
return router;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
module.exports = { createWebRouter };
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/test/admin-users.test.js
|
|
3
|
+
* @description Tests: admin user profile defaults, partial updates, and username/theme/timeout validation
|
|
4
|
+
*
|
|
5
|
+
* @author Andrian Yablonskyy
|
|
6
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
7
|
+
*
|
|
8
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
9
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
10
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
11
|
+
* from AdSystem.PRO.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const test = require('node:test'),
|
|
17
|
+
assert = require('node:assert/strict'),
|
|
18
|
+
{ openDb } = require('../src/db'),
|
|
19
|
+
{ createAdminUsersService } = require('../src/services/admin-users');
|
|
20
|
+
|
|
21
|
+
function buildService(){
|
|
22
|
+
const db = openDb(':memory:');
|
|
23
|
+
return createAdminUsersService(db);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test('a newly created user gets profile defaults (UTC, auto theme, 60min timeout)', () => {
|
|
27
|
+
const adminUsers = buildService();
|
|
28
|
+
adminUsers.create({ username: 'alice', password: 'hunter2', role: 'admin' });
|
|
29
|
+
|
|
30
|
+
const user = adminUsers.verify('alice', 'hunter2');
|
|
31
|
+
assert.equal(user.timezone, 'UTC');
|
|
32
|
+
assert.equal(user.theme, 'auto');
|
|
33
|
+
assert.equal(user.sessionTimeoutMin, 60);
|
|
34
|
+
assert.equal(user.firstName, null);
|
|
35
|
+
assert.equal(user.avatarPath, null);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('updateProfile only touches the fields passed, leaving the rest as-is', () => {
|
|
39
|
+
const adminUsers = buildService(),
|
|
40
|
+
{ id } = adminUsers.create({ username: 'bob', password: 'hunter2', role: 'viewer' });
|
|
41
|
+
|
|
42
|
+
adminUsers.updateProfile(id, { firstName: 'Bob', lastName: 'Builder', timezone: 'America/New_York' });
|
|
43
|
+
const afterFirst = adminUsers.getById(id);
|
|
44
|
+
assert.equal(afterFirst.firstName, 'Bob');
|
|
45
|
+
assert.equal(afterFirst.timezone, 'America/New_York');
|
|
46
|
+
assert.equal(afterFirst.username, 'bob'); // untouched
|
|
47
|
+
|
|
48
|
+
adminUsers.updateProfile(id, { theme: 'dark' });
|
|
49
|
+
const afterSecond = adminUsers.getById(id);
|
|
50
|
+
assert.equal(afterSecond.theme, 'dark');
|
|
51
|
+
assert.equal(afterSecond.firstName, 'Bob'); // still there — partial update didn't clobber it
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('renaming a user to a username already held by someone else is rejected (409)', () => {
|
|
55
|
+
const adminUsers = buildService();
|
|
56
|
+
adminUsers.create({ username: 'alice', password: 'x', role: 'admin' });
|
|
57
|
+
const { id: bobId } = adminUsers.create({ username: 'bob', password: 'x', role: 'viewer' });
|
|
58
|
+
|
|
59
|
+
assert.throws(() => adminUsers.updateProfile(bobId, { username: 'alice' }), (err) => err.status === 409);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('renaming a user to their own current username is not a conflict', () => {
|
|
63
|
+
const adminUsers = buildService(),
|
|
64
|
+
{ id } = adminUsers.create({ username: 'alice', password: 'x', role: 'admin' });
|
|
65
|
+
|
|
66
|
+
assert.doesNotThrow(() => adminUsers.updateProfile(id, { username: 'alice' }));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('an invalid theme is rejected (400)', () => {
|
|
70
|
+
const adminUsers = buildService(),
|
|
71
|
+
{ id } = adminUsers.create({ username: 'alice', password: 'x', role: 'admin' });
|
|
72
|
+
|
|
73
|
+
assert.throws(() => adminUsers.updateProfile(id, { theme: 'rainbow' }), (err) => err.status === 400);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('a session timeout outside the offered presets is rejected (400)', () => {
|
|
77
|
+
const adminUsers = buildService(),
|
|
78
|
+
{ id } = adminUsers.create({ username: 'alice', password: 'x', role: 'admin' });
|
|
79
|
+
|
|
80
|
+
assert.throws(() => adminUsers.updateProfile(id, { sessionTimeoutMin: 7 }), (err) => err.status === 400);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('resetPassword creates the user as admin when the username does not exist yet', () => {
|
|
84
|
+
const adminUsers = buildService();
|
|
85
|
+
|
|
86
|
+
adminUsers.resetPassword({ username: 'admin', password: 'newpass' });
|
|
87
|
+
const user = adminUsers.verify('admin', 'newpass');
|
|
88
|
+
assert.ok(user);
|
|
89
|
+
assert.equal(user.role, 'admin');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('resetPassword on an existing user changes only the password, not the role', () => {
|
|
93
|
+
const adminUsers = buildService();
|
|
94
|
+
adminUsers.create({ username: 'carol', password: 'oldpass', role: 'viewer' });
|
|
95
|
+
|
|
96
|
+
adminUsers.resetPassword({ username: 'carol', password: 'newpass' });
|
|
97
|
+
|
|
98
|
+
assert.equal(adminUsers.verify('carol', 'oldpass'), null);
|
|
99
|
+
const user = adminUsers.verify('carol', 'newpass');
|
|
100
|
+
assert.ok(user);
|
|
101
|
+
assert.equal(user.role, 'viewer'); // not silently promoted to admin
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('changePassword succeeds with the correct current password', () => {
|
|
105
|
+
const adminUsers = buildService(),
|
|
106
|
+
{ id } = adminUsers.create({ username: 'dave', password: 'oldpass', role: 'admin' });
|
|
107
|
+
|
|
108
|
+
adminUsers.changePassword(id, 'oldpass', 'newpass');
|
|
109
|
+
|
|
110
|
+
assert.equal(adminUsers.verify('dave', 'oldpass'), null);
|
|
111
|
+
assert.ok(adminUsers.verify('dave', 'newpass'));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('changePassword is rejected (401) with the wrong current password, leaving it unchanged', () => {
|
|
115
|
+
const adminUsers = buildService(),
|
|
116
|
+
{ id } = adminUsers.create({ username: 'dave', password: 'oldpass', role: 'admin' });
|
|
117
|
+
|
|
118
|
+
assert.throws(() => adminUsers.changePassword(id, 'wrongpass', 'newpass'), (err) => err.status === 401);
|
|
119
|
+
assert.ok(adminUsers.verify('dave', 'oldpass'));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('changePassword rejects an empty new password (400)', () => {
|
|
123
|
+
const adminUsers = buildService(),
|
|
124
|
+
{ id } = adminUsers.create({ username: 'dave', password: 'oldpass', role: 'admin' });
|
|
125
|
+
|
|
126
|
+
assert.throws(() => adminUsers.changePassword(id, 'oldpass', ''), (err) => err.status === 400);
|
|
127
|
+
});
|