@andrian.yablonskyy/thub-coordinator 1.0.38 → 1.0.40

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.
Files changed (64) hide show
  1. package/bin/thub-admin.js +26 -5
  2. package/config.json +5 -6
  3. package/package.json +2 -2
  4. package/public/js/actions.js +58 -0
  5. package/public/js/log-viewer.js +188 -56
  6. package/public/js/settings.js +66 -0
  7. package/scripts/install-default-config.js +1 -1
  8. package/scripts/install-target.js +2 -2
  9. package/src/api/admin.js +1 -1
  10. package/src/api/agent.js +28 -22
  11. package/src/api/resource.js +11 -14
  12. package/src/api/sse.js +13 -10
  13. package/src/config.js +33 -10
  14. package/src/db/migrations/022_add_sessions.sql +10 -0
  15. package/src/db/migrations/023_drop_artifacts.sql +4 -0
  16. package/src/db/migrations/024_add_settings.sql +10 -0
  17. package/src/db/migrations/025_add_job_artifacts.sql +4 -0
  18. package/src/dev/virtual.js +10 -6
  19. package/src/login-guard.js +111 -0
  20. package/src/rate-limit.js +137 -0
  21. package/src/security.js +104 -0
  22. package/src/server.js +53 -23
  23. package/src/services/admin-users.js +32 -19
  24. package/src/services/cleanup.js +2 -2
  25. package/src/services/heartbeat.js +23 -3
  26. package/src/services/job-artifacts.js +91 -0
  27. package/src/services/jobs.js +17 -9
  28. package/src/services/logs.js +12 -14
  29. package/src/services/registry.js +34 -0
  30. package/src/services/retention.js +5 -14
  31. package/src/services/session-store.js +127 -0
  32. package/src/services/settings.js +345 -0
  33. package/src/web/routes.js +230 -21
  34. package/test/admin-users.test.js +16 -16
  35. package/test/agent-visibility.test.js +85 -0
  36. package/test/cleanup.test.js +1 -5
  37. package/test/groups-tab.test.js +97 -0
  38. package/test/job-artifacts.test.js +115 -0
  39. package/test/login-guard.test.js +139 -0
  40. package/test/logs.test.js +150 -0
  41. package/test/rate-limit.test.js +106 -0
  42. package/test/scheduler.test.js +10 -12
  43. package/test/security.test.js +107 -0
  44. package/test/session.test.js +132 -0
  45. package/test/settings.test.js +206 -0
  46. package/views/admin/settings.pug +133 -0
  47. package/views/groups/list.pug +1 -1
  48. package/views/help/_agent-cli.pug +2 -2
  49. package/views/help/_agent-setup.pug +4 -1
  50. package/views/help/_client-setup.pug +1 -1
  51. package/views/help/_coordinator.pug +18 -7
  52. package/views/help/_docker.pug +1 -1
  53. package/views/help/_env.pug +2 -1
  54. package/views/help/_git.pug +3 -2
  55. package/views/help/_overview.pug +2 -2
  56. package/views/help/_troubleshooting.pug +4 -0
  57. package/views/jobs/list.pug +5 -5
  58. package/views/jobs/show.pug +33 -7
  59. package/views/layout.pug +4 -1
  60. package/views/mixins/client-config.pug +65 -3
  61. package/views/mixins/list-controls.pug +1 -1
  62. package/views/mixins/log-viewer.pug +14 -0
  63. package/views/mixins/resource.pug +1 -8
  64. package/src/services/artifacts.js +0 -149
package/src/web/routes.js CHANGED
@@ -16,6 +16,7 @@
16
16
  const fs = require('node:fs'),
17
17
  os = require('node:os'),
18
18
  path = require('node:path'),
19
+ { once } = require('node:events'),
19
20
  express = require('express'),
20
21
  multer = require('multer'),
21
22
  { requireAdminSession, requireAdminRole } = require('../auth'),
@@ -26,11 +27,19 @@ const fs = require('node:fs'),
26
27
  } = require('@andrian.yablonskyy/thub-common'),
27
28
  { retentionCutoff, JOB_RETENTION } = require('../services/cleanup'),
28
29
  listPrefs = require('../services/list-prefs'),
29
- search = require('../services/search');
30
-
31
- // Resources page sort keys (list-prefs.js) -> comparators. Empty values
32
- // (never heartbeated, no version reported) sort last in both directions.
33
- const RESOURCE_SORT_VALUE = {
30
+ search = require('../services/search'),
31
+ { createLoginGuard } = require('../login-guard'),
32
+ { formatBytes } = require('../services/job-artifacts');
33
+
34
+ // When this process started: the settings page tells a restart happened by it changing.
35
+ const STARTED_AT = new Date().toISOString(),
36
+ // Job log viewer: lines per page it loads (and the most one request may ask for).
37
+ LOG_PAGE = 1000,
38
+ LOG_PAGE_MAX = 5000,
39
+
40
+ // Resources page sort keys (list-prefs.js) -> comparators. Empty values
41
+ // (never heartbeated, no version reported) sort last in both directions.
42
+ RESOURCE_SORT_VALUE = {
34
43
  name: (r) => r.name,
35
44
  type: (r) => r.type,
36
45
  version: (r) => r.client_version,
@@ -65,7 +74,8 @@ const RESOURCE_SORT_VALUE = {
65
74
  // views hitting the same kind of SSE stream the Agent uses (§6.4), just
66
75
  // authenticated by session cookie instead of a bearer token.
67
76
  function createWebRouter({ services, config }){
68
- const router = express.Router();
77
+ const router = express.Router(),
78
+ loginGuard = createLoginGuard();
69
79
 
70
80
  // Shown as a toast (layout.pug): `danger` stays until closed, `warning`
71
81
  // closes after 30 s, `info`/`success` after 10 s — unless `sticky`.
@@ -165,6 +175,8 @@ function createWebRouter({ services, config }){
165
175
  res.locals.isNewer = isNewer;
166
176
  res.locals.nextCronRun = nextCronRun;
167
177
  res.locals.formatDateTime = formatDateTime;
178
+ // 1233 -> "1.2 KB" (job artifacts).
179
+ res.locals.fmtBytes = formatBytes;
168
180
  res.locals.currentPath = req.originalUrl;
169
181
  // A live-update re-fetch (server.js, req.thubPassive) renders the page
170
182
  // only for its data: flash messages stay for the next real page view.
@@ -203,19 +215,59 @@ function createWebRouter({ services, config }){
203
215
  next();
204
216
  });
205
217
 
218
+ // A Secure-only session cookie (server.js cookieSecure, §12) is never
219
+ // sent back over plain HTTP, so signing in would just loop back here.
220
+ // Say why instead — usually a proxy not passing X-Forwarded-Proto.
221
+ function httpsProblem(req){
222
+ if (req.session.cookie.secure !== true || req.secure){
223
+ return null;
224
+ }
225
+ return {
226
+ type: 'danger',
227
+ sticky: true,
228
+ text: `Signing in needs HTTPS: this Coordinator's session cookie is sent only over HTTPS (publicUrl is ${config.publicUrl}), ` +
229
+ 'but this request reached it as plain HTTP. Open the dashboard through that https:// address; behind a reverse proxy, ' +
230
+ 'make it send "X-Forwarded-Proto: https" and check trustProxy. To allow plain HTTP, set session.secureCookie to false.'
231
+ };
232
+ }
233
+
206
234
  router.get('/login', (req, res) => {
207
235
  if (req.session.user){
208
236
  return res.redirect('/');
209
237
  }
210
- res.render('login', { title: 'Sign in' });
238
+ const problem = httpsProblem(req);
239
+ res.render('login', { title: 'Sign in', ...(problem ? { messages: [problem] } : {}) });
211
240
  });
212
241
 
213
- router.post('/login', (req, res) => {
242
+ // Backoff after failed attempts, per IP and per username, checked before
243
+ // any hashing; a cap on password checks running at once (login-guard.js).
244
+ // The answer to a wrong password and to an unknown user is the same, and
245
+ // takes as long (admin-users.js).
246
+ function throttled(res, { reason, retryAfterSec }, what){
247
+ res.set('Retry-After', String(retryAfterSec));
248
+ const text = reason === 'busy'
249
+ ? 'The Coordinator is busy checking other sign-ins — try again in a moment.'
250
+ : `Too many failed ${what} — try again in ${retryAfterSec} s.`;
251
+ return { status: reason === 'busy' ? 503 : 429, text };
252
+ }
253
+
254
+ router.post('/login', async (req, res) => {
255
+ const problem = httpsProblem(req);
256
+ if (problem){
257
+ return res.status(400).render('login', { title: 'Sign in', messages: [problem] });
258
+ }
214
259
  const { username, password } = req.body,
215
- user = services.adminUsers.verify(username, password);
260
+ allowed = loginGuard.check(req.ip, username);
261
+ if (!allowed.ok){
262
+ const { status, text } = throttled(res, allowed, 'sign-ins');
263
+ return res.status(status).render('login', { title: 'Sign in', messages: [{ type: 'danger', text }] });
264
+ }
265
+ const user = await loginGuard.run(() => services.adminUsers.verify(username, password));
216
266
  if (!user){
267
+ loginGuard.failure(req.ip, username);
217
268
  return res.status(401).render('login', { title: 'Sign in', messages: [{ type: 'danger', text: 'Invalid credentials' }] });
218
269
  }
270
+ loginGuard.success(req.ip, username);
219
271
  req.session.user = user;
220
272
  req.session.cookie.maxAge = user.sessionTimeoutMin * 60 * 1000;
221
273
  res.redirect('/');
@@ -307,17 +359,29 @@ function createWebRouter({ services, config }){
307
359
  // profile form since it needs the *current* password re-entered, and a
308
360
  // mismatch between newPassword/confirmPassword is a form-level check
309
361
  // that has nothing to do with the other fields.
310
- router.post('/profile/password', (req, res) => {
311
- const { currentPassword, newPassword, confirmPassword } = req.body;
362
+ // The current password is checked like a sign-in: the same backoff, so a
363
+ // stolen session can't be used to guess it.
364
+ router.post('/profile/password', async (req, res) => {
365
+ const { currentPassword, newPassword, confirmPassword } = req.body,
366
+ { username } = req.session.user;
312
367
  if (newPassword !== confirmPassword){
313
368
  flash(req, 'danger', 'New password and confirmation do not match.');
314
369
  return res.redirect('/profile');
315
370
  }
371
+ const allowed = loginGuard.check(req.ip, username);
372
+ if (!allowed.ok){
373
+ flash(req, 'danger', throttled(res, allowed, 'password checks').text);
374
+ return res.redirect('/profile');
375
+ }
316
376
  try {
317
- services.adminUsers.changePassword(req.session.user.id, currentPassword, newPassword);
377
+ await loginGuard.run(() => services.adminUsers.changePassword(req.session.user.id, currentPassword, newPassword));
378
+ loginGuard.success(req.ip, username);
318
379
  flash(req, 'success', 'Password changed.');
319
380
  }
320
381
  catch (err){
382
+ if (err.status === 401){
383
+ loginGuard.failure(req.ip, username);
384
+ }
321
385
  flash(req, 'danger', err.message);
322
386
  }
323
387
  res.redirect('/profile');
@@ -563,6 +627,24 @@ function createWebRouter({ services, config }){
563
627
  res.redirect(returnTo(req, '/resources'));
564
628
  });
565
629
 
630
+ // Groups tab (resource card): the Client's group membership, as a config
631
+ // revision the Client writes to its file (registry.setClientGroups).
632
+ router.post('/resources/:id/groups', requireAdminRole, (req, res) => {
633
+ try {
634
+ const ids = [].concat(req.body.groups || []).filter((g) => typeof g === 'string' && g),
635
+ r = services.registry.setClientGroups(req.params.id, ids, {
636
+ by: req.session.user.username,
637
+ knownGroupIds: services.groups.list().map((g) => g.id)
638
+ });
639
+ flash(req, 'success', `Groups of ${r.name} saved: ${r.group_ids.length ? `${r.group_ids.length} group(s)` : 'none'} — in effect now.\n` +
640
+ 'The Client writes them to its config file on its next heartbeat, then restarts once it has no job running.');
641
+ }
642
+ catch (err){
643
+ flash(req, 'danger', err.message);
644
+ }
645
+ res.redirect(returnTo(req, '/resources'));
646
+ });
647
+
566
648
  // Export (resource card, admins — the file holds the join key): the
567
649
  // Client's config file as JSON, secrets excluded.
568
650
  router.get('/resources/:id/config/export', requireAdminRole, (req, res) => {
@@ -786,7 +868,7 @@ function createWebRouter({ services, config }){
786
868
  });
787
869
 
788
870
  // "Clean up database" (§9): everything before the given time — finished
789
- // jobs with their logs/artifacts, and older history — then VACUUM. The
871
+ // jobs with their logs, and older history — then VACUUM. The
790
872
  // time is dd/mm/yyyy HH:MM:SS in the user's own time zone.
791
873
  router.post('/jobs/clean-history', requireAdminRole, (req, res) => {
792
874
  const tz = req.session.user.timezone || 'UTC',
@@ -818,8 +900,7 @@ function createWebRouter({ services, config }){
818
900
  return res.status(404).send('Not found');
819
901
  }
820
902
  job.resource = job.resource_id ? services.registry.get(job.resource_id) : null;
821
- const artifacts = services.artifacts.listForJob(job.id).map((a) => ({ ...a, url: services.artifacts.signedUrl(a) })),
822
- jobActive = ACTIVE_JOB_STATES.has(job.state);
903
+ const jobActive = ACTIVE_JOB_STATES.has(job.state);
823
904
  res.render('jobs/show', {
824
905
  title: job.id,
825
906
  active: 'jobs',
@@ -829,7 +910,6 @@ function createWebRouter({ services, config }){
829
910
  ? services.registry.waitingReason(job.spec.target.type, job.spec.target.labels || [], job.spec.target.group,
830
911
  job.spec.target.client)
831
912
  : null,
832
- artifacts,
833
913
  jobActive,
834
914
  canCancel: req.session.user.role === 'admin' && jobActive
835
915
  });
@@ -847,15 +927,53 @@ function createWebRouter({ services, config }){
847
927
  res.redirect(returnTo(req, `/jobs/${req.params.id}`));
848
928
  });
849
929
 
850
- // Plain, one-shot fetch — used by the log viewer for a job that's
851
- // already finished, so it doesn't open a live connection (§SSE) for
852
- // output that will never change again.
930
+ // The log viewer's pages (public/js/log-viewer.js): the last `limit`
931
+ // lines first, then earlier ones (`before` = the oldest seq it has) as the
932
+ // reader scrolls up. `total` is how many lines the job has in all.
853
933
  router.get('/jobs/:id/logs', (req, res) => {
854
- res.json({ lines: services.logs.listSince(req.params.id, 0) });
934
+ const before = Number.parseInt(req.query.before, 10),
935
+ limit = Math.min(Math.max(Number.parseInt(req.query.limit, 10) || LOG_PAGE, 1), LOG_PAGE_MAX),
936
+ page = services.logs.pageBefore(req.params.id, before > 0 ? before : undefined, limit);
937
+ res.json({ ...page, total: services.logs.count(req.params.id) });
938
+ });
939
+
940
+ // RAW (log viewer): the whole log as plain text, for a new tab, a
941
+ // download or grep. Streamed in pages, waiting for the socket to drain,
942
+ // so a huge log never sits in memory. Log lines live as long as their job
943
+ // (§9), so this is always the complete log.
944
+ router.get('/jobs/:id/log.txt', async (req, res, next) => {
945
+ try {
946
+ const job = services.jobs.get(req.params.id);
947
+ if (!job){
948
+ return res.status(404).type('text/plain').send('Unknown job');
949
+ }
950
+ res.set({
951
+ 'Content-Type': 'text/plain; charset=utf-8',
952
+ // The log is whatever the job printed: never let a browser sniff it
953
+ // into HTML.
954
+ 'X-Content-Type-Options': 'nosniff',
955
+ 'Content-Disposition': `inline; filename="${job.id}.log"`,
956
+ 'Cache-Control': 'no-store'
957
+ });
958
+ for (let seq = 0, page; (page = services.logs.listSince(job.id, seq, LOG_PAGE_MAX)).length;){
959
+ const chunk = page.map((l) => `[${l.ts}] [${l.stream}] ${l.line}\n`).join('');
960
+ seq = page.at(-1).seq;
961
+ if (!res.write(chunk)){
962
+ await once(res, 'drain');
963
+ }
964
+ if (res.destroyed){
965
+ return;
966
+ }
967
+ }
968
+ res.end();
969
+ }
970
+ catch (err){
971
+ next(err);
972
+ }
855
973
  });
856
974
 
857
975
  router.get('/jobs/:id/stream', (req, res) => {
858
- attachJobStream(req, res, { jobId: req.params.id, services, config });
976
+ attachJobStream(req, res, { jobId: req.params.id, services });
859
977
  });
860
978
 
861
979
  // Resource groups (§13.1) — their own page since they're a distinct
@@ -927,6 +1045,97 @@ function createWebRouter({ services, config }){
927
1045
  });
928
1046
  }
929
1047
 
1048
+ // Coordinator settings (services/settings.js, README §13.2): stored in
1049
+ // the database over the config file. "Restart now" only under systemd,
1050
+ // whose Restart=always brings the process back (INVOCATION_ID is set for
1051
+ // every unit it starts); run by hand, exiting would just stop it.
1052
+ const canRestart = Boolean(process.env.INVOCATION_ID);
1053
+
1054
+ router.get('/admin/settings', requireAdminRole, (req, res) => {
1055
+ const items = services.settings.list(),
1056
+ groups = [...new Set(items.map((s) => s.group))].map((name) => ({ name, items: items.filter((s) => s.group === name) }));
1057
+ res.render('admin/settings', {
1058
+ title: 'Settings',
1059
+ active: 'settings',
1060
+ groups,
1061
+ pendingRestart: items.filter((s) => s.pendingRestart),
1062
+ canRestart,
1063
+ startedAt: STARTED_AT,
1064
+ restarting: req.query.restarting || null,
1065
+ readOnly: {
1066
+ listen: config.listen,
1067
+ dataDir: config.dataDir,
1068
+ configPath: config.configPath || '(built-in defaults)'
1069
+ }
1070
+ });
1071
+ });
1072
+
1073
+ router.post('/admin/settings', requireAdminRole, (req, res) => {
1074
+ const posted = req.body.s || {},
1075
+ changes = {},
1076
+ reset = [].concat(req.body.reset || []);
1077
+ for (const s of services.settings.SETTINGS){
1078
+ if (s.type === 'secret'){
1079
+ // Empty means "unchanged": the current secret is never put in the page.
1080
+ if (req.body.clear?.[s.key] === '1'){
1081
+ changes[s.key] = '';
1082
+ }
1083
+ else if (posted[s.key]){
1084
+ changes[s.key] = posted[s.key];
1085
+ }
1086
+ }
1087
+ else if (s.key in posted){
1088
+ // A checkbox posts a hidden "false" and, when checked, "true" after it.
1089
+ changes[s.key] = [].concat(posted[s.key]).at(-1);
1090
+ }
1091
+ }
1092
+ try {
1093
+ const result = services.settings.save({ changes, reset }, {
1094
+ by: req.session.user.username,
1095
+ // Don't let an admin lock everyone out: an HTTPS-only cookie
1096
+ // can't come back over the plain-HTTP connection they're using.
1097
+ guard: (after) => {
1098
+ const secureCookie = after['session.secureCookie'],
1099
+ httpsOnly = secureCookie === true || (secureCookie === 'auto' && /^https:\/\//i.test(after.publicUrl || ''));
1100
+ return httpsOnly && !req.secure
1101
+ ? 'Secure session cookie: you\'re connected over plain HTTP, so with this setting nobody could sign in this way ' +
1102
+ 'after the restart. Open the dashboard over https:// first (behind a proxy: it must send X-Forwarded-Proto: https), ' +
1103
+ 'or set it to false.'
1104
+ : null;
1105
+ }
1106
+ });
1107
+ if (!result.changed.length){
1108
+ flash(req, 'info', 'Nothing changed.');
1109
+ }
1110
+ else {
1111
+ const label = (k) => services.settings.SETTINGS.find((s) => s.key === k).label;
1112
+ flash(req, result.restart.length ? 'warning' : 'success', [
1113
+ result.now.length ? `Saved and applied: ${result.now.map(label).join(', ')}.` : '',
1114
+ result.restart.length ? `Saved, applies after a restart: ${result.restart.map(label).join(', ')}.` : ''
1115
+ ].filter(Boolean).join('\n'));
1116
+ }
1117
+ }
1118
+ catch (err){
1119
+ flash(req, 'danger', err.message);
1120
+ }
1121
+ res.redirect('/admin/settings');
1122
+ });
1123
+
1124
+ router.post('/admin/settings/restart', requireAdminRole, (req, res) => {
1125
+ if (!canRestart){
1126
+ flash(req, 'danger', 'This Coordinator isn\'t running under systemd, so it can\'t restart itself — restart it by hand.');
1127
+ return res.redirect('/admin/settings');
1128
+ }
1129
+ res.redirect(`/admin/settings?restarting=${encodeURIComponent(STARTED_AT)}`);
1130
+ // After the redirect is out; systemd (Restart=always) starts it again.
1131
+ res.on('finish', () => setTimeout(() => process.kill(process.pid, 'SIGTERM'), 500));
1132
+ });
1133
+
1134
+ // Polled by the settings page while restarting (public/js/settings.js).
1135
+ router.get('/admin/settings/status', requireAdminRole, (req, res) => {
1136
+ res.set('Cache-Control', 'no-store').json({ startedAt: STARTED_AT });
1137
+ });
1138
+
930
1139
  router.get('/admin/agents', requireAdminRole, (req, res) => renderAgents(req, res));
931
1140
 
932
1141
  router.post('/admin/agents', requireAdminRole, (req, res) => {
@@ -23,11 +23,11 @@ function buildService(){
23
23
  return createAdminUsersService(db);
24
24
  }
25
25
 
26
- test('a newly created user gets profile defaults (UTC, auto theme, 60min timeout)', () => {
26
+ test('a newly created user gets profile defaults (UTC, auto theme, 60min timeout)', async () => {
27
27
  const adminUsers = buildService();
28
28
  adminUsers.create({ username: 'alice', password: 'hunter2', role: 'admin' });
29
29
 
30
- const user = adminUsers.verify('alice', 'hunter2');
30
+ const user = await adminUsers.verify('alice', 'hunter2');
31
31
  assert.equal(user.timezone, 'UTC');
32
32
  assert.equal(user.theme, 'auto');
33
33
  assert.equal(user.sessionTimeoutMin, 60);
@@ -80,48 +80,48 @@ test('a session timeout outside the offered presets is rejected (400)', () => {
80
80
  assert.throws(() => adminUsers.updateProfile(id, { sessionTimeoutMin: 7 }), (err) => err.status === 400);
81
81
  });
82
82
 
83
- test('resetPassword creates the user as admin when the username does not exist yet', () => {
83
+ test('resetPassword creates the user as admin when the username does not exist yet', async () => {
84
84
  const adminUsers = buildService();
85
85
 
86
86
  adminUsers.resetPassword({ username: 'admin', password: 'newpass' });
87
- const user = adminUsers.verify('admin', 'newpass');
87
+ const user = await adminUsers.verify('admin', 'newpass');
88
88
  assert.ok(user);
89
89
  assert.equal(user.role, 'admin');
90
90
  });
91
91
 
92
- test('resetPassword on an existing user changes only the password, not the role', () => {
92
+ test('resetPassword on an existing user changes only the password, not the role', async () => {
93
93
  const adminUsers = buildService();
94
94
  adminUsers.create({ username: 'carol', password: 'oldpass', role: 'viewer' });
95
95
 
96
96
  adminUsers.resetPassword({ username: 'carol', password: 'newpass' });
97
97
 
98
- assert.equal(adminUsers.verify('carol', 'oldpass'), null);
99
- const user = adminUsers.verify('carol', 'newpass');
98
+ assert.equal(await adminUsers.verify('carol', 'oldpass'), null);
99
+ const user = await adminUsers.verify('carol', 'newpass');
100
100
  assert.ok(user);
101
101
  assert.equal(user.role, 'viewer'); // not silently promoted to admin
102
102
  });
103
103
 
104
- test('changePassword succeeds with the correct current password', () => {
104
+ test('changePassword succeeds with the correct current password', async () => {
105
105
  const adminUsers = buildService(),
106
106
  { id } = adminUsers.create({ username: 'dave', password: 'oldpass', role: 'admin' });
107
107
 
108
- adminUsers.changePassword(id, 'oldpass', 'newpass');
108
+ await adminUsers.changePassword(id, 'oldpass', 'newpass');
109
109
 
110
- assert.equal(adminUsers.verify('dave', 'oldpass'), null);
111
- assert.ok(adminUsers.verify('dave', 'newpass'));
110
+ assert.equal(await adminUsers.verify('dave', 'oldpass'), null);
111
+ assert.ok(await adminUsers.verify('dave', 'newpass'));
112
112
  });
113
113
 
114
- test('changePassword is rejected (401) with the wrong current password, leaving it unchanged', () => {
114
+ test('changePassword is rejected (401) with the wrong current password, leaving it unchanged', async () => {
115
115
  const adminUsers = buildService(),
116
116
  { id } = adminUsers.create({ username: 'dave', password: 'oldpass', role: 'admin' });
117
117
 
118
- assert.throws(() => adminUsers.changePassword(id, 'wrongpass', 'newpass'), (err) => err.status === 401);
119
- assert.ok(adminUsers.verify('dave', 'oldpass'));
118
+ await assert.rejects(adminUsers.changePassword(id, 'wrongpass', 'newpass'), (err) => err.status === 401);
119
+ assert.ok(await adminUsers.verify('dave', 'oldpass'));
120
120
  });
121
121
 
122
- test('changePassword rejects an empty new password (400)', () => {
122
+ test('changePassword rejects an empty new password (400)', async () => {
123
123
  const adminUsers = buildService(),
124
124
  { id } = adminUsers.create({ username: 'dave', password: 'oldpass', role: 'admin' });
125
125
 
126
- assert.throws(() => adminUsers.changePassword(id, 'oldpass', ''), (err) => err.status === 400);
126
+ await assert.rejects(adminUsers.changePassword(id, 'oldpass', ''), (err) => err.status === 400);
127
127
  });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * @file packages/coordinator/test/agent-visibility.test.js
3
+ * @description Tests: which jobs an agent token may see — a cli token only its own, a ci token all
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
+ fs = require('node:fs'),
19
+ os = require('node:os'),
20
+ path = require('node:path'),
21
+ { loadConfig } = require('../src/config'),
22
+ { buildServices, createApp } = require('../src/server');
23
+
24
+ test('a cli agent sees only its own jobs (others look like unknown ids); a ci agent sees all', async (t) => {
25
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-vis-')),
26
+ file = path.join(dir, 'coordinator.json');
27
+ fs.writeFileSync(file, JSON.stringify({ dataDir: path.join(dir, 'data'), updates: { checkIntervalMin: 0 } }));
28
+ const config = loadConfig(file),
29
+ services = buildServices(config),
30
+ server = createApp(config, services).listen(0, '127.0.0.1');
31
+ t.after(() => new Promise((r) => {
32
+ server.closeAllConnections?.();
33
+ server.close(r);
34
+ }));
35
+ await new Promise((r) => server.on('listening', r));
36
+ services.registry.registerAuto({ clientId: 'c1', name: 'lab-sw-01', type: 'sw', labels: [] });
37
+
38
+ const base = `http://127.0.0.1:${server.address().port}/api/v1`,
39
+ alice = services.agents.create({ name: 'alice', kind: 'cli' }).token,
40
+ bob = services.agents.create({ name: 'bob', kind: 'cli' }).token,
41
+ ci = services.agents.create({ name: 'pipeline', kind: 'ci' }).token,
42
+ call = async (token, method, url, body) => {
43
+ const res = await fetch(`${base}${url}`, {
44
+ method,
45
+ headers: { authorization: `Bearer ${token}`, ...(body ? { 'content-type': 'application/json' } : {}) },
46
+ body: body ? JSON.stringify(body) : undefined
47
+ });
48
+ return { status: res.status, body: res.headers.get('content-type')?.includes('json') ? await res.json() : await res.text() };
49
+ },
50
+ submit = async (token) => (await call(token, 'POST', '/jobs', { target: { type: 'sw' }, command: './t.sh' })).body.jobId,
51
+ aliceJob = await submit(alice),
52
+ bobJob = await submit(bob),
53
+ ciJob = await submit(ci);
54
+
55
+ // Alice: her own job, every way of reading it.
56
+ assert.equal((await call(alice, 'GET', `/jobs/${aliceJob}`)).status, 200);
57
+ assert.equal((await call(alice, 'GET', `/jobs/${aliceJob}/logs`)).status, 200);
58
+ assert.equal((await call(alice, 'GET', `/jobs/${aliceJob}/artifacts`)).status, 200);
59
+
60
+ // Anyone else's job (a colleague's, a pipeline's): exactly like an id that doesn't exist.
61
+ const unknown = await call(alice, 'GET', '/jobs/M-99999');
62
+ for (const id of [bobJob, ciJob]){
63
+ for (const url of [`/jobs/${id}`, `/jobs/${id}/logs`, `/jobs/${id}/logs/stream`, `/jobs/${id}/artifacts`]){
64
+ const r = await call(alice, 'GET', url);
65
+ assert.equal(r.status, 404, url);
66
+ assert.deepEqual(r.body, unknown.body, url);
67
+ }
68
+ assert.equal((await call(alice, 'POST', `/jobs/${id}/cancel`)).status, 404);
69
+ }
70
+ assert.equal(services.jobs.get(bobJob).state, 'QUEUED'); // untouched
71
+
72
+ // Her list is her own jobs, --mine or not.
73
+ const ids = async (token, q = '') => (await call(token, 'GET', `/jobs${q}`)).body.jobs.map((j) => j.id).sort();
74
+ assert.deepEqual(await ids(alice), [aliceJob]);
75
+ assert.deepEqual(await ids(alice, '?mine=true'), [aliceJob]);
76
+
77
+ // A ci agent reads every job (and lists them all, or its own with --mine)…
78
+ assert.equal((await call(ci, 'GET', `/jobs/${aliceJob}`)).status, 200);
79
+ assert.equal((await call(ci, 'GET', `/jobs/${bobJob}/logs`)).status, 200);
80
+ assert.deepEqual(await ids(ci), [aliceJob, bobJob, ciJob].sort());
81
+ assert.deepEqual(await ids(ci, '?mine=1'), [ciJob]);
82
+ // …but still cancels only its own.
83
+ assert.equal((await call(ci, 'POST', `/jobs/${aliceJob}/cancel`)).status, 403);
84
+ assert.equal((await call(alice, 'POST', `/jobs/${aliceJob}/cancel`)).status, 200);
85
+ });
@@ -23,7 +23,6 @@ const test = require('node:test'),
23
23
  { createEventsService } = require('../src/services/events'),
24
24
  { createRegistryService } = require('../src/services/registry'),
25
25
  { createAgentsService } = require('../src/services/agents'),
26
- { createArtifactsService } = require('../src/services/artifacts'),
27
26
  { createJobsService } = require('../src/services/jobs'),
28
27
  { createCleanupService, retentionCutoff } = require('../src/services/cleanup'),
29
28
  { JOB_STATES } = require('@andrian.yablonskyy/thub-common');
@@ -35,8 +34,6 @@ function setup(jobRetention = 'forever'){
35
34
  config = {
36
35
  dataDir,
37
36
  dbPath: path.join(dataDir, 'thub.db'),
38
- artifactsDir: path.join(dataDir, 'artifacts'),
39
- artifacts: { linkTtlHours: 1 },
40
37
  sessionSecret: 's',
41
38
  publicUrl: 'http://x',
42
39
  scheduler: { maxQueuedPerAgent: 1000 },
@@ -48,8 +45,7 @@ function setup(jobRetention = 'forever'){
48
45
  events = createEventsService(db),
49
46
  registry = createRegistryService(db, { bus, events }),
50
47
  { agent } = createAgentsService(db, { events }).create({ name: 'ci', kind: 'ci' }),
51
- artifacts = createArtifactsService(db, { config }),
52
- jobs = createJobsService(db, { bus, events, registry, artifacts, config }),
48
+ jobs = createJobsService(db, { bus, events, registry, config }),
53
49
  cleanup = createCleanupService(db, { jobs, config, now: () => NOW });
54
50
  registry.registerAuto({ clientId: 'c', name: 'lab', type: 'sw', labels: [] });
55
51
 
@@ -0,0 +1,97 @@
1
+ /**
2
+ * @file packages/coordinator/test/groups-tab.test.js
3
+ * @description Tests: a Client's group membership set from the resource card's Groups tab
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
+ fs = require('node:fs'),
19
+ os = require('node:os'),
20
+ path = require('node:path'),
21
+ { loadConfig } = require('../src/config'),
22
+ { buildServices, createApp } = require('../src/server');
23
+
24
+ async function start(t){
25
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-grp-')),
26
+ file = path.join(dir, 'coordinator.json');
27
+ fs.writeFileSync(file, JSON.stringify({ dataDir: path.join(dir, 'data'), updates: { checkIntervalMin: 0 }, rateLimit: { loginPerMinute: 0 } }));
28
+ const config = loadConfig(file),
29
+ services = buildServices(config),
30
+ server = createApp(config, services).listen(0, '127.0.0.1');
31
+ t.after(() => new Promise((r) => {
32
+ server.closeAllConnections?.();
33
+ server.close(r);
34
+ }));
35
+ await new Promise((r) => server.on('listening', r));
36
+ const base = `http://127.0.0.1:${server.address().port}`;
37
+ services.adminUsers.create({ username: 'admin', password: 'pw', role: 'admin' });
38
+ services.adminUsers.create({ username: 'view', password: 'pw', role: 'viewer' });
39
+ const login = async (u) => (await fetch(`${base}/login`, {
40
+ method: 'POST', body: new URLSearchParams({ username: u, password: 'pw' }), redirect: 'manual'
41
+ })).headers.get('set-cookie').split(';')[0];
42
+ return { base, services, admin: await login('admin'), viewer: await login('view') };
43
+ }
44
+
45
+ // A Client recent enough to report its config file (what Import needs too).
46
+ const register = (services, name, { configFile = { name, type: 'sw' } } = {}) => services.registry.registerAuto({
47
+ clientId: `id-${name}`, name, type: 'sw', labels: [], config: {}, ...(configFile ? { configFile } : {})
48
+ }).resourceId;
49
+
50
+ test('Groups tab: in effect at once, sent to the Client as its next config revision', async (t) => {
51
+ const { base, services, admin, viewer } = await start(t),
52
+ nightly = services.groups.create({ name: 'nightly' }),
53
+ pr = services.groups.create({ name: 'pr-pool', comment: 'pull requests' }),
54
+ id = register(services, 'lab-sw-01'),
55
+ save = (cookie, groups) => fetch(`${base}/resources/${id}/groups`, {
56
+ method: 'POST', headers: { cookie }, body: new URLSearchParams([...groups.map((g) => ['groups', g]), ['returnTo', '/resources']]), redirect: 'manual'
57
+ }),
58
+ page = async () => (await fetch(`${base}/resources`, { headers: { cookie: admin } })).text();
59
+
60
+ assert.equal((await save(viewer, [nightly.id])).status, 403);
61
+ assert.equal((await save(admin, [nightly.id, pr.id])).status, 302);
62
+ const r = services.registry.get(id);
63
+ assert.deepEqual(r.group_ids.sort(), [nightly.id, pr.id].sort()); // scheduling follows now
64
+ const pending = services.registry.pendingConfig(id);
65
+ assert.equal(pending.revision, 1);
66
+ assert.deepEqual(pending.file.groups.sort(), [nightly.id, pr.id].sort()); // the Client writes it to its file
67
+ assert.match(await page(), /Groups of lab-sw-01 saved: 2 group\(s\) — in effect now/);
68
+
69
+ // The tab, with every group as a checkbox; no Groups line on Details any more.
70
+ const html = await page();
71
+ assert.match(html, new RegExp(`name="groups" value="${pr.id}" checked`));
72
+ assert.match(html, /data-config-tab="groups"/);
73
+ assert.match(html, /not written to the Client yet/);
74
+ assert.doesNotMatch(html, /<dt class="col-sm-4">Groups<\/dt>/);
75
+
76
+ // None at all: allowed.
77
+ await save(admin, []);
78
+ assert.deepEqual(services.registry.get(id).group_ids, []);
79
+ });
80
+
81
+ test('Groups tab: keeps other pending changes; refuses unknown groups and Clients too old for it', async (t) => {
82
+ const { services } = await start(t),
83
+ g = services.groups.create({ name: 'g' }),
84
+ id = register(services, 'lab-sw-02');
85
+
86
+ // An Import not applied yet: its fields go along with the groups.
87
+ services.registry.importClientConfig(id, { labels: ['board:x'] }, { by: 'admin' });
88
+ services.registry.setClientGroups(id, [g.id], { by: 'admin', knownGroupIds: [g.id] });
89
+ assert.deepEqual(services.registry.pendingConfig(id).file, { labels: ['board:x'], groups: [g.id] });
90
+ // A device save meanwhile doesn't drop them either.
91
+ services.registry.setClientConfig(id, {}, { by: 'admin' });
92
+ assert.deepEqual(services.registry.pendingConfig(id).file.groups, [g.id]);
93
+
94
+ assert.throws(() => services.registry.setClientGroups(id, ['no-such-group'], { knownGroupIds: [g.id] }), /Unknown group/);
95
+ const old = register(services, 'lab-old', { configFile: null });
96
+ assert.throws(() => services.registry.setClientGroups(old, [g.id], { knownGroupIds: [g.id] }), /too old to take its groups/);
97
+ });