@andrian.yablonskyy/thub-coordinator 1.0.39 → 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 (45) hide show
  1. package/bin/thub-admin.js +23 -0
  2. package/config.json +4 -0
  3. package/package.json +2 -2
  4. package/public/js/actions.js +58 -0
  5. package/public/js/settings.js +66 -0
  6. package/src/api/agent.js +27 -15
  7. package/src/api/resource.js +5 -3
  8. package/src/config.js +17 -1
  9. package/src/db/migrations/024_add_settings.sql +10 -0
  10. package/src/db/migrations/025_add_job_artifacts.sql +4 -0
  11. package/src/dev/virtual.js +9 -5
  12. package/src/login-guard.js +111 -0
  13. package/src/rate-limit.js +137 -0
  14. package/src/security.js +104 -0
  15. package/src/server.js +17 -3
  16. package/src/services/admin-users.js +32 -19
  17. package/src/services/heartbeat.js +23 -3
  18. package/src/services/job-artifacts.js +91 -0
  19. package/src/services/jobs.js +13 -2
  20. package/src/services/registry.js +34 -0
  21. package/src/services/settings.js +345 -0
  22. package/src/web/routes.js +157 -10
  23. package/test/admin-users.test.js +16 -16
  24. package/test/agent-visibility.test.js +85 -0
  25. package/test/groups-tab.test.js +97 -0
  26. package/test/job-artifacts.test.js +115 -0
  27. package/test/login-guard.test.js +139 -0
  28. package/test/logs.test.js +1 -1
  29. package/test/rate-limit.test.js +106 -0
  30. package/test/security.test.js +107 -0
  31. package/test/settings.test.js +206 -0
  32. package/views/admin/settings.pug +133 -0
  33. package/views/groups/list.pug +1 -1
  34. package/views/help/_agent-cli.pug +1 -1
  35. package/views/help/_agent-setup.pug +4 -1
  36. package/views/help/_client-setup.pug +1 -1
  37. package/views/help/_coordinator.pug +9 -1
  38. package/views/help/_env.pug +1 -0
  39. package/views/help/_troubleshooting.pug +3 -1
  40. package/views/jobs/list.pug +3 -3
  41. package/views/jobs/show.pug +36 -0
  42. package/views/layout.pug +4 -1
  43. package/views/mixins/client-config.pug +65 -3
  44. package/views/mixins/list-controls.pug +1 -1
  45. package/views/mixins/resource.pug +1 -8
@@ -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
+ });
@@ -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
+ });
@@ -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
+ });
package/test/logs.test.js CHANGED
@@ -113,7 +113,7 @@ test('RAW: the whole log as plain text, for as long as the job exists', async (t
113
113
  test('artifacts are gone: old Clients\' uploads are discarded, old Agents get none, the store is removed', async (t) => {
114
114
  const { base, services, job } = await setup(t, 3),
115
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' });
116
+ { token: agentToken } = services.agents.create({ name: 'old-agent', kind: 'ci' });
117
117
  services.db.prepare('UPDATE jobs SET resource_id = ? WHERE id = ?').run(resourceId, job.id);
118
118
 
119
119
  // An old Client still uploads its results after the job: accepted (so the
@@ -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
+ });