@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
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @file packages/coordinator/test/job-artifacts.test.js
3
+ * @description Tests: artifacts a job reports (metadata only) — validation, storage with the result, the job page and the Agent API
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
+ { sanitizeArtifacts, normalizeTimestamp, formatBytes, MAX_ARTIFACTS } = require('../src/services/job-artifacts'),
22
+ { loadConfig } = require('../src/config'),
23
+ { buildServices, createApp } = require('../src/server');
24
+
25
+ test('formatBytes: B, KB, MB, GB …, 1024-based', () => {
26
+ assert.equal(formatBytes(0), '0 B');
27
+ assert.equal(formatBytes(1023), '1023 B');
28
+ assert.equal(formatBytes(1233), '1.2 KB');
29
+ assert.equal(formatBytes(15 * 1024), '15 KB');
30
+ assert.equal(formatBytes(5.5 * 1024 ** 2), '5.5 MB');
31
+ assert.equal(formatBytes(3 * 1024 ** 3), '3.0 GB');
32
+ assert.equal(formatBytes(2 * 1024 ** 4), '2.0 TB');
33
+ assert.equal(formatBytes(-1), '—');
34
+ });
35
+
36
+ test('timestamps: Unix seconds, Unix milliseconds or ISO dates', () => {
37
+ assert.equal(normalizeTimestamp(1790000000), '2026-09-21T14:13:20.000Z');
38
+ assert.equal(normalizeTimestamp(1790000000123), '2026-09-21T14:13:20.123Z');
39
+ assert.equal(normalizeTimestamp('1790000000'), '2026-09-21T14:13:20.000Z');
40
+ assert.equal(normalizeTimestamp('2026-09-21T14:13:20Z'), '2026-09-21T14:13:20.000Z');
41
+ assert.equal(normalizeTimestamp(1234), '1970-01-01T00:20:34.000Z'); // small numbers are seconds
42
+ assert.equal(normalizeTimestamp('soon'), null);
43
+ assert.equal(normalizeTimestamp(undefined), null);
44
+ });
45
+
46
+ test('sanitizeArtifacts keeps valid entries, only http(s) links, and drops the rest', () => {
47
+ const { artifacts, dropped } = sanitizeArtifacts([
48
+ { name: 'app.bin', size: 1233, link: 'https://artifactory.example.com/fw/app.bin', timestamp: 1790000000 },
49
+ { name: ' report.html ', link: 'http://files.lab/r.html' }, // size/timestamp optional
50
+ { name: 'evil', size: 1, link: 'javascript:alert(1)' },
51
+ { name: 'data', link: 'data:text/html,<script>x</script>' },
52
+ { name: '', link: 'https://x.example/a' },
53
+ { name: 'no-link' },
54
+ { name: 'neg', size: -5, link: 'https://x.example/n' }, // bad size: kept, size unknown
55
+ 'not an object'
56
+ ]);
57
+ assert.equal(dropped, 5);
58
+ assert.deepEqual(artifacts, [
59
+ { name: 'app.bin', size: 1233, link: 'https://artifactory.example.com/fw/app.bin', timestamp: '2026-09-21T14:13:20.000Z' },
60
+ { name: 'report.html', size: null, link: 'http://files.lab/r.html', timestamp: null },
61
+ { name: 'neg', size: null, link: 'https://x.example/n', timestamp: null }
62
+ ]);
63
+ const many = sanitizeArtifacts(Array.from({ length: MAX_ARTIFACTS + 3 }, (_, i) => ({ name: `f${i}`, link: `https://x.example/${i}` })));
64
+ assert.equal(many.artifacts.length, MAX_ARTIFACTS);
65
+ assert.equal(many.dropped, 3);
66
+ assert.deepEqual(sanitizeArtifacts(undefined), { artifacts: [], dropped: 0 });
67
+ });
68
+
69
+ test('a finished job\'s reported artifacts: stored with the result, on the job page and via the Agent API', async (t) => {
70
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-art-')),
71
+ file = path.join(dir, 'coordinator.json');
72
+ fs.writeFileSync(file, JSON.stringify({ dataDir: path.join(dir, 'data'), updates: { checkIntervalMin: 0 }, rateLimit: { loginPerMinute: 0 } }));
73
+ const config = loadConfig(file),
74
+ services = buildServices(config),
75
+ server = createApp(config, services).listen(0, '127.0.0.1');
76
+ t.after(() => new Promise((r) => {
77
+ server.closeAllConnections?.();
78
+ server.close(r);
79
+ }));
80
+ await new Promise((r) => server.on('listening', r));
81
+ const base = `http://127.0.0.1:${server.address().port}`,
82
+ { resourceId, resourceToken } = services.registry.registerAuto({ clientId: 'c1', name: 'lab-sw-01', type: 'sw', labels: [] }),
83
+ { agent, token } = services.agents.create({ name: 'ci', kind: 'ci' }),
84
+ job = services.jobs.create({ agentId: agent.id, source: 'ci', spec: { target: { type: 'sw' }, command: './t.sh' } }),
85
+ asClient = (url, body) => fetch(`${base}/api/v1${url}`, {
86
+ method: 'POST', headers: { authorization: `Bearer ${resourceToken}`, 'content-type': 'application/json' }, body: JSON.stringify(body)
87
+ });
88
+ services.db.prepare('UPDATE jobs SET resource_id = ?, state = ? WHERE id = ?').run(resourceId, 'RUNNING', job.id);
89
+
90
+ const result = await asClient(`/jobs/${job.id}/result`, {
91
+ state: 'PASSED', exitCode: 0, summary: { total: 1, passed: 1, failed: 0, skipped: 0 },
92
+ artifacts: [
93
+ { name: 'app.bin', size: 1233, link: 'https://artifactory.example.com/fw/app.bin', timestamp: 1790000000 },
94
+ { name: 'x', link: 'javascript:alert(1)' }
95
+ ]
96
+ });
97
+ assert.equal(result.status, 200);
98
+ assert.deepEqual(services.jobs.get(job.id).artifacts.map((a) => a.name), ['app.bin']);
99
+
100
+ // Agent API: in the job, and on its own (with `url` for older Agents).
101
+ const agentGet = (url) => fetch(`${base}/api/v1${url}`, { headers: { authorization: `Bearer ${token}` } }).then((r) => r.json());
102
+ assert.equal((await agentGet(`/jobs/${job.id}`)).artifacts[0].link, 'https://artifactory.example.com/fw/app.bin');
103
+ assert.equal((await agentGet(`/jobs/${job.id}/artifacts`)).artifacts[0].url, 'https://artifactory.example.com/fw/app.bin');
104
+
105
+ // The job page.
106
+ services.adminUsers.create({ username: 'admin', password: 'pw', role: 'admin' });
107
+ const cookie = (await fetch(`${base}/login`, {
108
+ method: 'POST', body: new URLSearchParams({ username: 'admin', password: 'pw' }), redirect: 'manual'
109
+ })).headers.get('set-cookie').split(';')[0],
110
+ html = await (await fetch(`${base}/jobs/${job.id}`, { headers: { cookie } })).text();
111
+ assert.match(html, /<a href="https:\/\/artifactory\.example\.com\/fw\/app\.bin" target="_blank" rel="noopener noreferrer">app\.bin<\/a>/);
112
+ assert.match(html, />1\.2 KB</);
113
+ assert.match(html, /21\/09\/2026 14:13:20/); // the admin's time zone (UTC)
114
+ assert.doesNotMatch(html, /javascript:alert/);
115
+ });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * @file packages/coordinator/test/login-guard.test.js
3
+ * @description Tests: sign-in protection — backoff per IP and per username, async password checks, no username enumeration by timing
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
+ { createLoginGuard } = require('../src/login-guard'),
22
+ { openDb } = require('../src/db'),
23
+ { createAdminUsersService } = require('../src/services/admin-users'),
24
+ { loadConfig } = require('../src/config'),
25
+ { buildServices, createApp } = require('../src/server');
26
+
27
+ test('backoff: per IP from the 5th failure, doubling up to 15 min; a success clears it', () => {
28
+ let t = 0;
29
+ const guard = createLoginGuard({ now: () => t });
30
+ for (let i = 0; i < 4; i++){
31
+ guard.failure('10.0.0.1', `u${i}`); // different names: only the IP counts up
32
+ assert.equal(guard.check('10.0.0.1', 'any').ok, true);
33
+ }
34
+ guard.failure('10.0.0.1', 'u4'); // 5th
35
+ assert.deepEqual(guard.check('10.0.0.1', 'any'), { ok: false, reason: 'backoff', retryAfterSec: 1 });
36
+ assert.equal(guard.check('10.0.0.2', 'any').ok, true); // another address isn't affected
37
+ t += 1000;
38
+ guard.failure('10.0.0.1', 'u5'); // 6th: 2 s
39
+ assert.equal(guard.check('10.0.0.1', 'any').retryAfterSec, 2);
40
+ for (let i = 0; i < 20; i++){
41
+ guard.failure('10.0.0.1', `v${i}`);
42
+ }
43
+ assert.equal(guard.check('10.0.0.1', 'any').retryAfterSec, 15 * 60); // capped
44
+ guard.success('10.0.0.1', 'admin');
45
+ assert.equal(guard.check('10.0.0.1', 'any').ok, true);
46
+ });
47
+
48
+ test('backoff: per username from the 10th failure, from any address, up to 5 min; forgotten after an hour', () => {
49
+ let t = 0;
50
+ const guard = createLoginGuard({ now: () => t });
51
+ for (let i = 0; i < 9; i++){
52
+ guard.failure(`10.0.1.${i}`, 'Admin'); // one guess per address
53
+ }
54
+ assert.equal(guard.check('10.0.9.9', 'admin').ok, true);
55
+ guard.failure('10.0.1.9', ' ADMIN '); // 10th, any spelling
56
+ assert.equal(guard.check('10.0.9.9', 'admin').ok, false); // even from a fresh address
57
+ for (let i = 0; i < 30; i++){
58
+ guard.failure(`10.0.2.${i}`, 'admin');
59
+ }
60
+ assert.equal(guard.check('10.0.9.9', 'admin').retryAfterSec, 5 * 60); // capped, its owner isn't locked out for long
61
+ t += 61 * 60 * 1000;
62
+ assert.equal(guard.check('10.0.9.9', 'admin').ok, true);
63
+ });
64
+
65
+ test('a limit on password checks running at once', async () => {
66
+ const guard = createLoginGuard({ maxConcurrent: 1 });
67
+ let release;
68
+ const running = guard.run(() => new Promise((r) => (release = r)));
69
+ assert.deepEqual(guard.check('ip', 'u'), { ok: false, reason: 'busy', retryAfterSec: 1 });
70
+ release();
71
+ await running;
72
+ assert.equal(guard.check('ip', 'u').ok, true);
73
+ });
74
+
75
+ test('password checks don\'t block the event loop, and an unknown user takes as long as a wrong password', async () => {
76
+ const users = createAdminUsersService(openDb(':memory:'));
77
+ users.create({ username: 'alice', password: 'correct horse', role: 'admin' });
78
+
79
+ // Timers keep firing while four checks run (scryptSync would stall them all).
80
+ let last = performance.now(),
81
+ worstGap = 0;
82
+ const ticker = setInterval(() => {
83
+ const now = performance.now();
84
+ worstGap = Math.max(worstGap, now - last);
85
+ last = now;
86
+ }, 5);
87
+ await Promise.all([1, 2, 3, 4].map(() => users.verify('alice', 'wrong')));
88
+ clearInterval(ticker);
89
+ assert.ok(worstGap < 60, `event loop stalled for ${worstGap.toFixed(0)} ms`);
90
+
91
+ const time = async (name) => {
92
+ const start = performance.now();
93
+ for (let i = 0; i < 4; i++){
94
+ assert.equal(await users.verify(name, 'wrong'), null);
95
+ }
96
+ return performance.now() - start;
97
+ },
98
+ known = await time('alice'),
99
+ unknown = await time('nobody');
100
+ assert.ok(unknown > known * 0.5 && unknown < known * 2, `unknown user ${unknown.toFixed(0)} ms vs wrong password ${known.toFixed(0)} ms`);
101
+ assert.ok((await users.verify('alice', 'correct horse')).id);
102
+ });
103
+
104
+ test('sign-in page: a locked-out guesser gets 429 with Retry-After, before any password is checked', async (t) => {
105
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-guard-')),
106
+ file = path.join(dir, 'coordinator.json');
107
+ // The per-IP sign-in budget off, to see the backoff on its own.
108
+ fs.writeFileSync(file, JSON.stringify({ dataDir: path.join(dir, 'data'), updates: { checkIntervalMin: 0 }, rateLimit: { loginPerMinute: 0 } }));
109
+ const config = loadConfig(file),
110
+ services = buildServices(config),
111
+ server = createApp(config, services).listen(0, '127.0.0.1');
112
+ t.after(() => new Promise((r) => {
113
+ server.closeAllConnections?.();
114
+ server.close(r);
115
+ }));
116
+ await new Promise((r) => server.on('listening', r));
117
+ services.adminUsers.create({ username: 'admin', password: 'right', role: 'admin' });
118
+ const base = `http://127.0.0.1:${server.address().port}`,
119
+ // The test reaches the Coordinator over loopback, which trustProxy
120
+ // trusts: X-Forwarded-For stands in for different client addresses.
121
+ login = (ip, username, password) => fetch(`${base}/login`, {
122
+ method: 'POST', headers: { 'X-Forwarded-For': ip }, body: new URLSearchParams({ username, password }), redirect: 'manual'
123
+ }),
124
+ verify = t.mock.method(services.adminUsers, 'verify');
125
+
126
+ for (let i = 0; i < 5; i++){
127
+ assert.equal((await login('203.0.113.7', `guess${i}`, 'x')).status, 401);
128
+ }
129
+ const locked = await login('203.0.113.7', 'admin', 'right'); // even the right password waits
130
+ assert.equal(locked.status, 429);
131
+ assert.equal(locked.headers.get('retry-after'), '1');
132
+ assert.match(await locked.text(), /Too many failed sign-ins — try again in 1 s/);
133
+ assert.equal(verify.mock.callCount(), 5); // the 6th never reached a password check
134
+
135
+ assert.equal((await login('198.51.100.1', 'admin', 'right')).status, 302); // the owner, elsewhere, signs in
136
+ await new Promise((r) => setTimeout(r, 1100));
137
+ assert.equal((await login('203.0.113.7', 'admin', 'right')).status, 302); // after the wait, and a success clears it
138
+ assert.equal((await login('203.0.113.7', 'nobody', 'x')).status, 401); // counting starts over
139
+ });
@@ -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: 'ci' });
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
+ });
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @file packages/coordinator/test/rate-limit.test.js
3
+ * @description Tests: request rate limiting — token buckets per credential (IP without one), the sign-in limit, configuration
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
+ { createBuckets } = require('../src/rate-limit'),
22
+ { loadConfig } = require('../src/config'),
23
+ { buildServices, createApp } = require('../src/server');
24
+
25
+ test('token bucket: a burst up to the limit, then refills at limit per minute', () => {
26
+ const b = createBuckets(),
27
+ t0 = 1_000_000;
28
+ for (let i = 0; i < 5; i++){
29
+ assert.equal(b.take('k', 5, t0).ok, true);
30
+ }
31
+ const refused = b.take('k', 5, t0);
32
+ assert.equal(refused.ok, false);
33
+ assert.equal(refused.retryAfterSec, 12); // one token per 60/5 s
34
+ assert.equal(b.take('k', 5, t0 + 12_000).ok, true); // refilled one
35
+ assert.equal(b.take('k', 5, t0 + 12_000).ok, false);
36
+ assert.equal(b.take('other', 5, t0).ok, true); // its own bucket
37
+ b.prune(() => 5, t0 + 120_000); // full again = forgotten
38
+ assert.equal(b.size(), 0);
39
+ });
40
+
41
+ async function start(t, rateLimit){
42
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-rl-')),
43
+ file = path.join(dir, 'coordinator.json');
44
+ fs.writeFileSync(file, JSON.stringify({
45
+ dataDir: path.join(dir, 'data'), sessionSecret: 'test-secret-test-secret', updates: { checkIntervalMin: 0 }, rateLimit
46
+ }));
47
+ const config = loadConfig(file),
48
+ services = buildServices(config),
49
+ server = createApp(config, services).listen(0, '127.0.0.1');
50
+ t.after(() => new Promise((r) => {
51
+ server.closeAllConnections?.();
52
+ server.close(r);
53
+ }));
54
+ await new Promise((r) => server.on('listening', r));
55
+ return { base: `http://127.0.0.1:${server.address().port}`, services, config };
56
+ }
57
+
58
+ test('per credential: each token has its own budget, even from one IP; unknown tokens count by IP', async (t) => {
59
+ const { base, services } = await start(t, { requestsPerMinute: 3, loginPerMinute: 0 }),
60
+ tokenA = services.agents.create({ name: 'a', kind: 'ci' }).token,
61
+ tokenB = services.agents.create({ name: 'b', kind: 'ci' }).token,
62
+ get = (token) => fetch(`${base}/api/v1/resources`, { headers: token ? { authorization: `Bearer ${token}` } : {} });
63
+
64
+ for (let i = 0; i < 3; i++){
65
+ assert.equal((await get(tokenA)).status, 200);
66
+ }
67
+ const refused = await get(tokenA);
68
+ assert.equal(refused.status, 429);
69
+ assert.equal(refused.headers.get('retry-after'), '20');
70
+ assert.equal(refused.headers.get('ratelimit-limit'), '3');
71
+ assert.match((await refused.json()).error, /Too many requests: try again in 20 s/);
72
+ assert.equal((await get(tokenB)).status, 200); // a Client behind the same NAT isn't affected
73
+
74
+ // Random tokens all land in the one IP bucket — no dodging the limit.
75
+ for (let i = 0; i < 3; i++){
76
+ assert.equal((await get(`agt_fake${i}`)).status, 401);
77
+ }
78
+ assert.equal((await get('agt_fake9')).status, 429);
79
+ });
80
+
81
+ test('sign-in attempts have their own small limit; 0 turns limits off; a dashboard change applies at once', async (t) => {
82
+ const { base, services, config } = await start(t, { requestsPerMinute: 500, loginPerMinute: 3 }),
83
+ login = () => fetch(`${base}/login`, { method: 'POST', body: new URLSearchParams({ username: 'x', password: 'y' }), redirect: 'manual' });
84
+ for (let i = 0; i < 3; i++){
85
+ assert.equal((await login()).status, 401);
86
+ }
87
+ const refused = await login();
88
+ assert.equal(refused.status, 429);
89
+ assert.match(await refused.text(), /Too many sign-in attempts/);
90
+ assert.equal((await fetch(`${base}/login`)).status, 200); // viewing the page isn't an attempt
91
+
92
+ services.settings.save({ changes: { 'rateLimit.loginPerMinute': '0', 'rateLimit.requestsPerMinute': '0' } });
93
+ assert.equal(config.rateLimit.loginPerMinute, 0);
94
+ assert.equal((await login()).status, 401); // no longer limited
95
+ assert.equal((await fetch(`${base}/login`)).headers.get('ratelimit-limit'), null);
96
+ });
97
+
98
+ test('config: rateLimit values must be whole numbers, 0 or more', () => {
99
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-rl-')),
100
+ file = path.join(dir, 'coordinator.json'),
101
+ write = (rateLimit) => fs.writeFileSync(file, JSON.stringify({ dataDir: path.join(dir, 'data'), rateLimit }));
102
+ write(undefined);
103
+ assert.deepEqual(loadConfig(file).rateLimit, { requestsPerMinute: 500, loginPerMinute: 10 });
104
+ write({ requestsPerMinute: -1 });
105
+ assert.throws(() => loadConfig(file), /rateLimit\.requestsPerMinute must be a whole number/);
106
+ });
@@ -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