@andrian.yablonskyy/thub-coordinator 1.0.37 → 1.0.39

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 (66) hide show
  1. package/bin/thub-admin.js +3 -5
  2. package/config.json +1 -6
  3. package/package.json +2 -2
  4. package/public/css/thub.css +129 -0
  5. package/public/js/config-import.js +37 -33
  6. package/public/js/copy-to-clipboard.js +21 -18
  7. package/public/js/help.js +93 -0
  8. package/public/js/list-search.js +51 -0
  9. package/public/js/live.js +287 -0
  10. package/public/js/log-viewer.js +289 -45
  11. package/public/js/remove-resource.js +5 -3
  12. package/public/js/resource-card.js +14 -13
  13. package/public/js/tooltips.js +3 -1
  14. package/scripts/install-default-config.js +1 -1
  15. package/scripts/install-target.js +2 -2
  16. package/src/api/admin.js +1 -1
  17. package/src/api/agent.js +4 -10
  18. package/src/api/resource.js +6 -11
  19. package/src/api/sse.js +13 -10
  20. package/src/config.js +16 -9
  21. package/src/db/migrations/022_add_sessions.sql +10 -0
  22. package/src/db/migrations/023_drop_artifacts.sql +4 -0
  23. package/src/dev/virtual.js +1 -1
  24. package/src/server.js +55 -21
  25. package/src/services/cleanup.js +2 -2
  26. package/src/services/jobs.js +33 -19
  27. package/src/services/list-prefs.js +4 -0
  28. package/src/services/live.js +124 -0
  29. package/src/services/logs.js +12 -14
  30. package/src/services/retention.js +5 -14
  31. package/src/services/search.js +59 -0
  32. package/src/services/session-store.js +127 -0
  33. package/src/web/routes.js +205 -34
  34. package/test/cleanup.test.js +1 -5
  35. package/test/list-prefs.test.js +6 -0
  36. package/test/live.test.js +153 -0
  37. package/test/logs.test.js +150 -0
  38. package/test/scheduler.test.js +10 -12
  39. package/test/search.test.js +97 -0
  40. package/test/session.test.js +132 -0
  41. package/test/views.test.js +30 -0
  42. package/views/admin/agents.pug +28 -10
  43. package/views/groups/list.pug +14 -1
  44. package/views/help/_agent-cli.pug +103 -0
  45. package/views/help/_agent-setup.pug +72 -0
  46. package/views/help/_ci.pug +44 -0
  47. package/views/help/_client-machines.pug +87 -0
  48. package/views/help/_client-setup.pug +122 -0
  49. package/views/help/_coordinator.pug +137 -0
  50. package/views/help/_docker.pug +124 -0
  51. package/views/help/_env.pug +127 -0
  52. package/views/help/_git.pug +130 -0
  53. package/views/help/_mixins.pug +24 -0
  54. package/views/help/_overview.pug +118 -0
  55. package/views/help/_quick-start.pug +37 -0
  56. package/views/help/_troubleshooting.pug +44 -0
  57. package/views/help/index.pug +67 -0
  58. package/views/index.pug +3 -2
  59. package/views/jobs/list.pug +8 -5
  60. package/views/jobs/show.pug +0 -10
  61. package/views/layout.pug +24 -1
  62. package/views/mixins/list-controls.pug +36 -0
  63. package/views/mixins/log-viewer.pug +39 -3
  64. package/views/mixins/resource.pug +1 -1
  65. package/views/resources/list.pug +7 -4
  66. package/src/services/artifacts.js +0 -149
@@ -0,0 +1,150 @@
1
+ /**
2
+ * @file packages/coordinator/test/logs.test.js
3
+ * @description Tests: job log paging for the dashboard viewer, the full SSE replay, and the raw log (RAW button)
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
+ // A Coordinator on a random port, an admin session, and one job with `n`
25
+ // log lines ("line 1".."line n").
26
+ async function setup(t, n){
27
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-logs-')),
28
+ file = path.join(dir, 'coordinator.json');
29
+ fs.writeFileSync(file, JSON.stringify({
30
+ dataDir: path.join(dir, 'data'), sessionSecret: 'test-secret-test-secret', updates: { checkIntervalMin: 0 }
31
+ }));
32
+ const config = loadConfig(file),
33
+ services = buildServices(config),
34
+ server = createApp(config, services).listen(0, '127.0.0.1');
35
+ t.after(() => new Promise((r) => {
36
+ server.closeAllConnections?.();
37
+ server.close(r);
38
+ }));
39
+ await new Promise((r) => server.on('listening', r));
40
+ const base = `http://127.0.0.1:${server.address().port}`;
41
+
42
+ services.adminUsers.create({ username: 'admin', password: 'pw', role: 'admin' });
43
+ const login = await fetch(`${base}/login`, {
44
+ method: 'POST', body: new URLSearchParams({ username: 'admin', password: 'pw' }), redirect: 'manual'
45
+ }),
46
+ cookie = login.headers.get('set-cookie').split(';')[0],
47
+ { agent } = services.agents.create({ name: 'ci', kind: 'ci' });
48
+ services.registry.registerAuto({ clientId: 'c1', name: 'lab-sw-01', type: 'sw', labels: [] });
49
+ const job = services.jobs.create({ agentId: agent.id, source: 'ci', spec: { target: { type: 'sw' }, command: './t.sh' } });
50
+ for (let i = 0; i < n; i += 500){
51
+ services.logs.appendBatch(job.id, Array.from({ length: Math.min(500, n - i) }, (_, k) => ({ stream: 'runner', line: `line ${i + k + 1}` })));
52
+ }
53
+ const finish = () => services.db.prepare('UPDATE jobs SET state = ?, finished_at = ? WHERE id = ?').run('PASSED', new Date().toISOString(), job.id);
54
+ return { base, cookie, services, job, finish, get: (p) => fetch(`${base}${p}`, { headers: { cookie } }) };
55
+ }
56
+
57
+ test('log pages: the end first, then earlier pages until the start', async (t) => {
58
+ const { get, job } = await setup(t, 2500),
59
+ last = await (await get(`/jobs/${job.id}/logs?limit=1000`)).json();
60
+ assert.equal(last.total, 2500);
61
+ assert.equal(last.hasMore, true);
62
+ assert.equal(last.lines.length, 1000);
63
+ assert.equal(last.lines[0].line, 'line 1501');
64
+ assert.equal(last.lines.at(-1).line, 'line 2500');
65
+
66
+ const earlier = await (await get(`/jobs/${job.id}/logs?limit=1000&before=${last.lines[0].seq}`)).json();
67
+ assert.deepEqual([earlier.lines[0].line, earlier.lines.at(-1).line, earlier.hasMore], ['line 501', 'line 1500', true]);
68
+
69
+ const first = await (await get(`/jobs/${job.id}/logs?limit=1000&before=${earlier.lines[0].seq}`)).json();
70
+ assert.deepEqual([first.lines.length, first.lines[0].line, first.hasMore], [500, 'line 1', false]);
71
+
72
+ // Limits are clamped: never more than 5000 a request.
73
+ assert.equal((await (await get(`/jobs/${job.id}/logs?limit=999999`)).json()).lines.length, 2500);
74
+ });
75
+
76
+ test('log stream replays every line, not just the first 500', async (t) => {
77
+ const { get, job, finish } = await setup(t, 1234);
78
+ finish();
79
+ const text = await (await get(`/jobs/${job.id}/stream`)).text(),
80
+ lines = [...text.matchAll(/^event: log$/gm)].length;
81
+ assert.equal(lines, 1234);
82
+ assert.match(text, /"line":"line 1234"/);
83
+ assert.match(text, /event: end/);
84
+
85
+ // Resuming after a seq replays only what follows it.
86
+ const resumed = await (await get(`/jobs/${job.id}/stream?after=1200`)).text();
87
+ assert.equal([...resumed.matchAll(/^event: log$/gm)].length, 34);
88
+ });
89
+
90
+ test('RAW: the whole log as plain text, for as long as the job exists', async (t) => {
91
+ const { get, job, finish, services } = await setup(t, 6001),
92
+ res = await get(`/jobs/${job.id}/log.txt`),
93
+ text = await res.text();
94
+ assert.equal(res.status, 200);
95
+ assert.match(res.headers.get('content-type'), /^text\/plain; charset=utf-8/);
96
+ assert.equal(res.headers.get('x-content-type-options'), 'nosniff');
97
+ assert.match(res.headers.get('content-disposition'), new RegExp(`inline; filename="${job.id}.log"`));
98
+ const rows = text.trimEnd().split('\n');
99
+ assert.equal(rows.length, 6001); // more than one 5000-line page
100
+ assert.match(rows[0], /^\[\d{4}-\d\d-\d\dT[^\]]+\] \[runner\] line 1$/);
101
+ assert.match(rows.at(-1), /\] \[runner\] line 6001$/);
102
+
103
+ // Still complete once the job has finished — no retention of its own
104
+ // (log lines go with the job), and no artifact to fall back to.
105
+ finish();
106
+ assert.equal((await (await get(`/jobs/${job.id}/log.txt`)).text()).trimEnd().split('\n').length, 6001);
107
+ services.db.prepare('DELETE FROM job_logs WHERE job_id = ?').run(job.id);
108
+ assert.equal(await (await get(`/jobs/${job.id}/log.txt`)).text(), '');
109
+
110
+ assert.equal((await get('/jobs/M-99999/log.txt')).status, 404);
111
+ });
112
+
113
+ test('artifacts are gone: old Clients\' uploads are discarded, old Agents get none, the store is removed', async (t) => {
114
+ const { base, services, job } = await setup(t, 3),
115
+ { resourceId, resourceToken } = services.registry.registerAuto({ clientId: 'c9', name: 'lab-hw-09', type: 'hw', labels: [] }),
116
+ { token: agentToken } = services.agents.create({ name: 'old-agent', kind: 'cli' });
117
+ services.db.prepare('UPDATE jobs SET resource_id = ? WHERE id = ?').run(resourceId, job.id);
118
+
119
+ // An old Client still uploads its results after the job: accepted (so the
120
+ // job doesn't end in ERROR), stored nowhere.
121
+ const form = new FormData();
122
+ form.append('files', new Blob(['<testsuite tests="1"/>']), 'results.xml');
123
+ const upload = await fetch(`${base}/api/v1/jobs/${job.id}/artifacts`, {
124
+ method: 'POST', headers: { authorization: `Bearer ${resourceToken}` }, body: form
125
+ });
126
+ assert.equal(upload.status, 201);
127
+ assert.deepEqual(await upload.json(), { artifacts: [] });
128
+ assert.equal(fs.existsSync(path.join(path.dirname(services.db.name), 'artifacts')), false);
129
+
130
+ // An old Agent's `thub status` still asks: none.
131
+ const listed = await fetch(`${base}/api/v1/jobs/${job.id}/artifacts`, { headers: { authorization: `Bearer ${agentToken}` } });
132
+ assert.deepEqual(await listed.json(), { artifacts: [] });
133
+
134
+ // No table, no download route.
135
+ assert.equal(services.db.prepare('SELECT COUNT(*) AS n FROM sqlite_master WHERE name = ?').get('artifacts').n, 0);
136
+ assert.equal((await fetch(`${base}/artifacts/download/x.y.z`, { redirect: 'manual' })).status, 302); // just the login redirect
137
+ });
138
+
139
+ test('upgrade: a leftover <dataDir>/artifacts is deleted on startup', (t) => {
140
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-legacy-')),
141
+ file = path.join(dir, 'coordinator.json'),
142
+ legacy = path.join(dir, 'data', 'artifacts', 'M-00001');
143
+ fs.mkdirSync(legacy, { recursive: true });
144
+ fs.writeFileSync(path.join(legacy, 'console.log'), 'old');
145
+ fs.writeFileSync(file, JSON.stringify({ dataDir: path.join(dir, 'data'), updates: { checkIntervalMin: 0 } }));
146
+ const log = t.mock.method(console, 'log', () => {});
147
+ buildServices(loadConfig(file));
148
+ assert.equal(fs.existsSync(path.join(dir, 'data', 'artifacts')), false);
149
+ assert.match(log.mock.calls[0].arguments[0], /no longer stores job artifacts/);
150
+ });
@@ -25,8 +25,8 @@ const test = require('node:test'),
25
25
  { createRegistryService } = require('../src/services/registry'),
26
26
  { createAgentsService } = require('../src/services/agents'),
27
27
  { createGroupsService } = require('../src/services/groups'),
28
- { createArtifactsService } = require('../src/services/artifacts'),
29
28
  { createJobsService } = require('../src/services/jobs'),
29
+ { createLogsService } = require('../src/services/logs'),
30
30
  { createScheduler } = require('../src/services/scheduler'),
31
31
  { createHeartbeatMonitor } = require('../src/services/heartbeat'),
32
32
  { JOB_STATES, RESOURCE_STATES } = require('@andrian.yablonskyy/thub-common');
@@ -42,22 +42,19 @@ function buildTestServices(overrides = {}){
42
42
  registry = createRegistryService(db, { bus, events }),
43
43
  agents = createAgentsService(db, { events }),
44
44
  groups = createGroupsService(db, { events, registry }),
45
- artifactsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-test-artifacts-')),
46
45
  config = {
47
46
  scheduler: { assignAckTimeoutSec: 15, requeueOnLost: true, maxQueuedPerAgent: 20, tickIntervalSec: 3600 },
48
47
  jobs: { defaultTimeoutSec: 1800, maxTimeoutSec: 14400 },
49
48
  heartbeat: { intervalSec: 10, missedLimit: 3, sweepIntervalSec: 3600 },
50
- artifactsDir,
51
49
  sessionSecret: 'test-secret',
52
50
  publicUrl: 'http://localhost:8080',
53
- artifacts: { linkTtlHours: 1 },
54
51
  ...overrides
55
52
  },
56
- artifacts = createArtifactsService(db, { config }),
57
- jobs = createJobsService(db, { bus, events, registry, artifacts, config }),
53
+ logs = createLogsService(db, { bus }),
54
+ jobs = createJobsService(db, { bus, events, registry, config }),
58
55
  scheduler = createScheduler(db, { bus, events, registry, config }),
59
56
  heartbeatMonitor = createHeartbeatMonitor(db, { bus, events, registry, jobs, config });
60
- return { db, bus, registry, agents, groups, artifacts, jobs, scheduler, heartbeatMonitor, artifactsDir };
57
+ return { db, bus, registry, agents, groups, logs, jobs, scheduler, heartbeatMonitor };
61
58
  }
62
59
 
63
60
  function registerResource(registry, { name, type, labels = [], groups = [], clientId } = {}){
@@ -206,15 +203,16 @@ test('admin resetQueue cancels every active job and leaves finished ones alone',
206
203
  assert.equal(jobs.get(finished.id).state, JOB_STATES.PASSED); // untouched
207
204
  });
208
205
 
209
- test('admin cleanHistory deletes finished jobs, their artifacts on disk, and leaves active jobs alone', () => {
210
- const { registry, agents, artifacts, jobs, artifactsDir } = buildTestServices(),
206
+ test('admin cleanHistory deletes finished jobs with their log lines, and leaves active jobs alone', () => {
207
+ const { db, registry, agents, logs, jobs } = buildTestServices(),
211
208
  { agent } = agents.create({ name: 'ci', kind: 'ci' });
212
209
  registerResource(registry, { name: 'lab-sw-01', type: 'sw', labels: [] });
213
210
 
214
211
  const finished = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
215
212
  jobs.setState(finished.id, JOB_STATES.PASSED);
216
- artifacts.storeGenerated(finished.id, 'console.log', Buffer.from('hello'), 'text/plain');
217
- assert.ok(fs.existsSync(path.join(artifactsDir, finished.id)));
213
+ logs.appendBatch(finished.id, [{ stream: 'runner', line: 'hello' }]);
214
+ const logLines = (id) => db.prepare('SELECT COUNT(*) AS n FROM job_logs WHERE job_id = ?').get(id).n;
215
+ assert.equal(logLines(finished.id), 1);
218
216
 
219
217
  const active = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() }),
220
218
 
@@ -222,7 +220,7 @@ test('admin cleanHistory deletes finished jobs, their artifacts on disk, and lea
222
220
 
223
221
  assert.equal(deleted, 1);
224
222
  assert.equal(jobs.get(finished.id), undefined);
225
- assert.equal(fs.existsSync(path.join(artifactsDir, finished.id)), false);
223
+ assert.equal(logLines(finished.id), 0);
226
224
  assert.ok(jobs.get(active.id)); // active job untouched
227
225
  });
228
226
 
@@ -0,0 +1,97 @@
1
+ /**
2
+ * @file packages/coordinator/test/search.test.js
3
+ * @description Tests: dashboard list search (?q=) — query parsing, in-memory matching and the Jobs page's SQL search
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
+ { EventEmitter } = require('node:events'),
19
+ { openDb } = require('../src/db'),
20
+ { createEventsService } = require('../src/services/events'),
21
+ { createRegistryService } = require('../src/services/registry'),
22
+ { createAgentsService } = require('../src/services/agents'),
23
+ { createJobsService } = require('../src/services/jobs'),
24
+ { parseTerms, normalizeQuery, matches, likeClause, MAX_QUERY } = require('../src/services/search');
25
+
26
+ test('parseTerms splits into lower-case, unique words, capped', () => {
27
+ assert.deepEqual(parseTerms(' Lab-HW nucleo lab-hw '), ['lab-hw', 'nucleo']);
28
+ assert.deepEqual(parseTerms(''), []);
29
+ assert.deepEqual(parseTerms(undefined), []);
30
+ assert.deepEqual(parseTerms(['a']), []); // ?q=a&q=b arrives as an array: ignored
31
+ assert.equal(parseTerms(Array.from({ length: 30 }, (_, i) => `w${i}`).join(' ')).length, 10);
32
+ assert.equal(normalizeQuery(` ${'x'.repeat(500)}`).length, MAX_QUERY - 2);
33
+ });
34
+
35
+ test('matches needs every word somewhere in the fields', () => {
36
+ const fields = ['lab-hw-01', 'IDLE', ['board:nucleo-f401re', 'uart'], null, [['10.0.0.7']]];
37
+ assert.ok(matches(fields, []));
38
+ assert.ok(matches(fields, parseTerms('NUCLEO idle')));
39
+ assert.ok(matches(fields, parseTerms('10.0.0')));
40
+ assert.ok(!matches(fields, parseTerms('nucleo busy')));
41
+ });
42
+
43
+ test('likeClause ANDs the words, ORs the columns and matches % and _ literally', () => {
44
+ const { sql, params } = likeClause(['a', 'b'], ['x', '50%_off']);
45
+ assert.equal(sql, '(COALESCE(a, \'\') LIKE ? ESCAPE \'\\\' OR COALESCE(b, \'\') LIKE ? ESCAPE \'\\\') AND ' +
46
+ '(COALESCE(a, \'\') LIKE ? ESCAPE \'\\\' OR COALESCE(b, \'\') LIKE ? ESCAPE \'\\\')');
47
+ assert.deepEqual(params, ['%x%', '%x%', '%50\\%\\_off%', '%50\\%\\_off%']);
48
+ assert.deepEqual(likeClause(['a'], []), { sql: '', params: [] });
49
+ });
50
+
51
+ test('jobs.page searches ids, users, resources, agents and spec fields — never --env values', () => {
52
+ const db = openDb(':memory:'),
53
+ bus = new EventEmitter(),
54
+ events = createEventsService(db),
55
+ registry = createRegistryService(db, { bus, events }),
56
+ agents = createAgentsService(db, { events }),
57
+ ci = agents.create({ name: 'ci-firmware', kind: 'ci' }).agent,
58
+ dev = agents.create({ name: 'alice-laptop', kind: 'cli' }).agent,
59
+ config = { scheduler: { maxQueuedPerAgent: 100 }, jobs: { defaultTimeoutSec: 60, maxTimeoutSec: 60 } },
60
+ jobs = createJobsService(db, { bus, events, registry, artifacts: {}, config });
61
+ registry.registerAuto({ clientId: 'c', name: 'lab-sw-01', type: 'sw', labels: [] });
62
+
63
+ const a = jobs.create({
64
+ agentId: ci.id,
65
+ source: 'ci',
66
+ spec: {
67
+ target: { type: 'sw' },
68
+ command: './ci/smoke.sh',
69
+ git: { url: 'git@github.com:yourorg/firmware-tests.git', ref: 'release/1.4' },
70
+ meta: { ciJobId: '9165432107' },
71
+ env: { API_TOKEN: 'hunter2-secret' }
72
+ }
73
+ }).id,
74
+ m = jobs.create({
75
+ agentId: dev.id,
76
+ source: 'cli',
77
+ spec: { target: { type: 'sw' }, command: 'make test', user: 'Alice Smith', suite: 'regression_50%' }
78
+ }).id,
79
+ ids = (q, extra = {}) => jobs.page({ q, size: 'all', ...extra }).rows.map((j) => j.id).sort();
80
+
81
+ assert.deepEqual(ids(''), [a, m].sort());
82
+ assert.deepEqual(ids(a.toLowerCase()), [a]); // job id, any case
83
+ assert.deepEqual(ids('alice'), [m]); // user and agent name
84
+ assert.deepEqual(ids('ci-firmware'), [a]); // agent name
85
+ assert.deepEqual(ids('firmware-tests release/1.4'), [a]); // git url + ref, both words
86
+ assert.deepEqual(ids('9165432107'), [a]); // meta
87
+ assert.deepEqual(ids('smoke alice'), []); // words must all match the same job
88
+ assert.deepEqual(ids('regression_50%'), [m]); // % and _ are literal…
89
+ assert.deepEqual(ids('regression_5%'), []); // …not wildcards
90
+ assert.deepEqual(ids('queued', { source: 'cli' }), [m]); // combines with filters
91
+ assert.equal(jobs.page({ q: 'make', size: 'all' }).pagination.total, 1);
92
+
93
+ // --env values are secrets (README §7.2): searching them must find nothing,
94
+ // or the search box would be an oracle for them.
95
+ assert.deepEqual(ids('hunter2'), []);
96
+ assert.deepEqual(ids('API_TOKEN'), []);
97
+ });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * @file packages/coordinator/test/session.test.js
3
+ * @description Tests: dashboard sessions — the SQLite session store, sessions surviving a restart, and the Secure cookie flag
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
+ { promisify } = require('node:util'),
22
+ { openDb } = require('../src/db'),
23
+ { SqliteSessionStore } = require('../src/services/session-store'),
24
+ { loadConfig } = require('../src/config'),
25
+ { buildServices, createApp, cookieSecure } = require('../src/server');
26
+
27
+ test('session store: get/set/touch/destroy, expired sessions dropped and pruned', async () => {
28
+ let now = Date.parse('2026-09-30T12:00:00Z');
29
+ const store = new SqliteSessionStore(openDb(':memory:'), { pruneIntervalMs: 0, now: () => now }),
30
+ get = promisify(store.get.bind(store)),
31
+ set = promisify(store.set.bind(store)),
32
+ touch = promisify(store.touch.bind(store)),
33
+ destroy = promisify(store.destroy.bind(store)),
34
+ length = promisify(store.length.bind(store)),
35
+ sess = (minutes) => ({ cookie: { expires: new Date(now + minutes * 60_000).toISOString() }, user: { id: 'u1' } });
36
+
37
+ await set('a', sess(60));
38
+ await set('b', sess(5));
39
+ assert.deepEqual((await get('a')).user, { id: 'u1' });
40
+ assert.equal(await get('nope'), null);
41
+ assert.equal(await length(), 2);
42
+
43
+ now += 10 * 60_000; // b's 5 minutes are up
44
+ assert.equal(await get('b'), null); // expired: gone on read
45
+ await touch('a', sess(60)); // activity moves a's expiry out
46
+ now += 55 * 60_000;
47
+ assert.ok(await get('a'));
48
+
49
+ await set('c', sess(1));
50
+ now += 2 * 60_000;
51
+ assert.equal(store.prune(), 1); // c, never read again, swept
52
+ assert.equal(await length(), 1);
53
+
54
+ await destroy('a'); // logout
55
+ assert.equal(await get('a'), null);
56
+ });
57
+
58
+ test('cookieSecure: Secure for an https publicUrl, per-request otherwise, or as configured', () => {
59
+ assert.equal(cookieSecure({ publicUrl: 'https://thub.example.com', session: { secureCookie: 'auto' } }), true);
60
+ assert.equal(cookieSecure({ publicUrl: 'http://localhost:8080', session: { secureCookie: 'auto' } }), 'auto');
61
+ assert.equal(cookieSecure({ publicUrl: 'https://thub.example.com', session: { secureCookie: false } }), false);
62
+ assert.equal(cookieSecure({ publicUrl: 'http://lab', session: { secureCookie: true } }), true);
63
+ });
64
+
65
+ // --- over HTTP ------------------------------------------------------------
66
+
67
+ function writeConfig(dir, extra = {}){
68
+ const file = path.join(dir, 'coordinator.json');
69
+ fs.writeFileSync(file, JSON.stringify({
70
+ dataDir: path.join(dir, 'data'), sessionSecret: 'test-secret-test-secret', updates: { checkIntervalMin: 0 }, ...extra
71
+ }));
72
+ return file;
73
+ }
74
+
75
+ async function start(t, file){
76
+ const config = loadConfig(file),
77
+ services = buildServices(config),
78
+ server = createApp(config, services).listen(0, '127.0.0.1');
79
+ await new Promise((r) => server.on('listening', r));
80
+ const stop = () => new Promise((r) => {
81
+ server.closeAllConnections?.();
82
+ server.close(r);
83
+ });
84
+ t.after(stop);
85
+ return { base: `http://127.0.0.1:${server.address().port}`, services, stop };
86
+ }
87
+
88
+ const login = (base, headers = {}) => fetch(`${base}/login`, {
89
+ method: 'POST', headers, body: new URLSearchParams({ username: 'admin', password: 'pw' }), redirect: 'manual'
90
+ });
91
+
92
+ test('a signed-in session survives a Coordinator restart', async (t) => {
93
+ const file = writeConfig(fs.mkdtempSync(path.join(os.tmpdir(), 'thub-sess-'))),
94
+ first = await start(t, file);
95
+ first.services.adminUsers.create({ username: 'admin', password: 'pw', role: 'admin' });
96
+ const cookie = (await login(first.base)).headers.get('set-cookie').split(';')[0];
97
+ assert.equal((await fetch(`${first.base}/jobs`, { headers: { cookie }, redirect: 'manual' })).status, 200);
98
+ await first.stop();
99
+
100
+ const second = await start(t, file); // same database, new process state
101
+ assert.equal((await fetch(`${second.base}/jobs`, { headers: { cookie }, redirect: 'manual' })).status, 200);
102
+
103
+ await fetch(`${second.base}/logout`, { method: 'POST', headers: { cookie }, redirect: 'manual' });
104
+ assert.equal((await fetch(`${second.base}/jobs`, { headers: { cookie }, redirect: 'manual' })).status, 302);
105
+ });
106
+
107
+ test('https publicUrl: the session cookie is Secure; plain HTTP gets an explanation, not a login loop', async (t) => {
108
+ const file = writeConfig(fs.mkdtempSync(path.join(os.tmpdir(), 'thub-sess-')), { publicUrl: 'https://thub.example.com' }),
109
+ { base, services } = await start(t, file);
110
+ services.adminUsers.create({ username: 'admin', password: 'pw', role: 'admin' });
111
+
112
+ // Behind the (loopback, trusted) reverse proxy terminating TLS.
113
+ const viaProxy = await login(base, { 'X-Forwarded-Proto': 'https' }),
114
+ setCookie = viaProxy.headers.get('set-cookie');
115
+ assert.equal(viaProxy.status, 302);
116
+ assert.match(setCookie, /; Secure/);
117
+ assert.match(setCookie, /; HttpOnly/);
118
+ assert.match(setCookie, /; SameSite=Lax/);
119
+
120
+ // The proxy forgot X-Forwarded-Proto: no cookie could come back — say so.
121
+ const plain = await login(base);
122
+ assert.equal(plain.status, 400);
123
+ assert.equal(plain.headers.get('set-cookie'), null);
124
+ assert.match(await plain.text(), /Signing in needs HTTPS/);
125
+ assert.match(await (await fetch(`${base}/login`)).text(), /X-Forwarded-Proto: https/);
126
+ });
127
+
128
+ test('config: session.secureCookie must be "auto", true or false', () => {
129
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-sess-'));
130
+ assert.equal(loadConfig(writeConfig(dir)).session.secureCookie, 'auto');
131
+ assert.throws(() => loadConfig(writeConfig(dir, { session: { secureCookie: 'yes' } })), /session\.secureCookie must be/);
132
+ });
@@ -33,3 +33,33 @@ test('every view compiles', () => {
33
33
  assert.doesNotThrow(() => pug.compileFile(file), `${path.relative(VIEWS, file)}`);
34
34
  }
35
35
  });
36
+
37
+ // The help page is mostly static text split over partials sharing mixins
38
+ // (views/help/_mixins.pug): compiling each file alone doesn't catch a missing
39
+ // mixin or a broken include, so render the whole page.
40
+ test('help page renders every section with this Coordinator\'s URL', () => {
41
+ const html = pug.renderFile(path.join(VIEWS, 'help', 'index.pug'), {
42
+ user: { username: 'viewer', role: 'viewer', theme: 'auto' },
43
+ updates: {},
44
+ messages: [],
45
+ currentPath: '/help',
46
+ active: 'help',
47
+ title: 'Help',
48
+ coordinatorVersion: '1.2.3',
49
+ commonVersion: '1.0.0',
50
+ fmtDate: () => '',
51
+ coordinatorUrl: 'https://thub.example.test'
52
+ });
53
+ for (const id of ['overview', 'use-cases', 'quick-start', 'coordinator', 'agent-setup', 'client-setup',
54
+ 'client-machines', 'docker', 'git', 'agent-cli', 'env', 'ci', 'troubleshooting']){
55
+ assert.match(html, new RegExp(`<section[^>]* id="${id}"`), `section #${id}`);
56
+ assert.match(html, new RegExp(`href="#${id}"`), `TOC entry for #${id}`);
57
+ }
58
+ assert.match(html, /thub config set url {3}https:\/\/thub\.example\.test/);
59
+ assert.match(html, /<a class="nav-link d-flex align-items-center active" href="\/help" aria-current="page"/);
60
+ // Troubleshooting: commands and parameters are <code>, placeholders escaped.
61
+ assert.match(html, /<code>journalctl -u thub-client@&lt;instance&gt; -f<\/code>/);
62
+ assert.match(html, /<span>The job is rejected with <code>422<\/code><\/span>/);
63
+ // Code placeholders are escaped, never parsed as tags.
64
+ assert.doesNotMatch(html, /<(url|jobId|groupId|resourceId|work|ref)>/);
65
+ });
@@ -1,12 +1,17 @@
1
1
  extends ../layout
2
2
  include ../mixins/updates
3
+ include ../mixins/list-controls
3
4
 
4
5
  block content
5
6
  .d-flex.justify-content-between.align-items-center.mb-3
6
7
  h1.h4.mb-0 Agents (CI / developers)
7
8
  button.btn.btn-primary.btn-sm(type="button" data-bs-toggle="modal" data-bs-target="#addAgentModal") Register agent
8
- .mb-3
9
- +updatesToolbar('agent', 'agents', '/admin/agents')
9
+ .d-flex.flex-wrap.align-items-center.gap-2.mb-3
10
+ +updatesToolbar('agent', 'agents', list.pageUrl())
11
+ .d-flex.flex-wrap.align-items-center.gap-2.mb-3
12
+ +searchForm(q, 'Search agents: name, kind, version, status', list.urlFor({ q: '' }))
13
+ .ms-auto
14
+ +pageSizePicker(list)
10
15
 
11
16
  if newToken
12
17
  .alert.alert-warning
@@ -14,23 +19,26 @@ block content
14
19
  |
15
20
  code= newToken
16
21
 
22
+ div(data-live="list-toolbar")
23
+ +listToolbar(list, 'agents')
24
+
17
25
  table.table.table-hover.align-middle
18
26
  thead
19
27
  tr
20
- th Name
21
- th Kind
22
- th Version
23
- th Created
24
- th Last used
25
- th Status
28
+ +sortHeader(list, 'name', 'Name')
29
+ +sortHeader(list, 'kind', 'Kind')
30
+ +sortHeader(list, 'version', 'Version', 'desc')
31
+ +sortHeader(list, 'created', 'Created', 'desc')
32
+ +sortHeader(list, 'used', 'Last used', 'desc')
33
+ +sortHeader(list, 'status', 'Status')
26
34
  th
27
- tbody
35
+ tbody(data-live="rows")
28
36
  each a in agents
29
37
  tr(class=a.revoked_at ? 'opacity-50' : '')
30
38
  td= a.name
31
39
  td.text-uppercase= a.kind
32
40
  //- A revoked token can't pick an update up: no button.
33
- td: +versionWithUpdate(a.version, updates.latest.agent, a.update_to, `/admin/agents/${a.id}/update`, '/admin/agents', !a.revoked_at)
41
+ td: +versionWithUpdate(a.version, updates.latest.agent, a.update_to, `/admin/agents/${a.id}/update`, list.pageUrl(), !a.revoked_at)
34
42
  td= fmtDate(a.created_at)
35
43
  td= fmtDate(a.last_used_at, 'never')
36
44
  td= a.revoked_at ? 'revoked' : 'active'
@@ -50,12 +58,14 @@ block content
50
58
  data-confirm-title="Revoke agent token?"
51
59
  data-confirm-ok="Revoke"
52
60
  )
61
+ input(type="hidden" name="returnTo" value=list.pageUrl())
53
62
  button.btn.btn-sm.btn-outline-danger(type="submit") Revoke
54
63
 
55
64
  .modal(tabindex="-1" id=`editAgentModal-${a.id}` aria-labelledby=`editAgentTitle-${a.id}`)
56
65
  .modal-dialog
57
66
  .modal-content
58
67
  form(method="post" action=`/admin/agents/${a.id}/rename`)
68
+ input(type="hidden" name="returnTo" value=list.pageUrl())
59
69
  .modal-header
60
70
  h5.modal-title(id=`editAgentTitle-${a.id}`) Edit agent
61
71
  button.btn-close(type="button" data-bs-dismiss="modal" aria-label="Close")
@@ -66,6 +76,14 @@ block content
66
76
  .modal-footer
67
77
  button.btn.btn-secondary(type="button" data-bs-dismiss="modal") Cancel
68
78
  button.btn.btn-primary(type="submit") Save
79
+ else
80
+ tr
81
+ td.text-body-secondary(colspan="7")
82
+ if q
83
+ | No agent matches “#{q}”.
84
+ a(href=list.urlFor({ q: '' })) Show all agents
85
+ else
86
+ | No agents yet.
69
87
 
70
88
  .modal(tabindex="-1" id="addAgentModal")
71
89
  .modal-dialog
@@ -1,4 +1,5 @@
1
1
  extends ../layout
2
+ include ../mixins/list-controls
2
3
 
3
4
  block content
4
5
  .d-flex.justify-content-between.align-items-center.mb-3
@@ -6,10 +7,18 @@ block content
6
7
  if canManage
7
8
  button.btn.btn-primary.btn-sm(type="button" data-bs-toggle="modal" data-bs-target="#addGroupModal") Add group
8
9
 
9
- p.text-body-secondary.small.
10
+ p.text-body-secondary.small: i.
10
11
  A job can be constrained to run only on resources in a group with `thub run --group &lt;id&gt;` (§13.1).
11
12
  A resource joins a group by listing its id under `groups` in the Client's own config — membership isn't set here.
12
13
 
14
+ if totalCount
15
+ .d-flex.flex-wrap.align-items-center.gap-2.mb-2
16
+ +searchForm(q, 'Search groups: name, id, comment', '/groups')
17
+ if q
18
+ span.small.text-body-secondary
19
+ | #{groups.length} of #{totalCount} groups
20
+ +searchNote(q)
21
+
13
22
  if groups.length
14
23
  table.table.table-hover.align-middle
15
24
  thead
@@ -77,6 +86,10 @@ block content
77
86
  i.bi.bi-clipboard
78
87
  .modal-footer
79
88
  button.btn.btn-primary(type="submit") Save
89
+ else if q
90
+ p.text-body-secondary
91
+ | No group matches “#{q}”.
92
+ a(href="/groups") Show all groups
80
93
  else
81
94
  p.text-body-secondary No groups yet.
82
95