// @vitest-environment node import http from 'node:http' import type { AddressInfo } from 'node:net' import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { parseConfig } from './config.ts' import { createHub } from './server.ts' import { Audit } from './audit.ts' import { LoginLimiter, LOCK_MS, lockedMessage } from './ratelimit.ts' import { ROUTES, routeFor } from './routes.ts' import { run } from './users-cli.ts' import { Users, addUser, can, hashPassword, parseUsersFile, readUsersFile, requires, setDisabled, setPassword, setRole, verifyPassword, writeUsersFile, type Role, } from './users.ts' let dir: string let servers: http.Server[] = [] beforeEach(() => { dir = mkdtempSync(path.join(tmpdir(), 'hub-sec-')) }) afterEach(() => { for (const s of servers) s.close() servers = [] rmSync(dir, { recursive: true, force: true }) }) const env = (over: Record = {}) => ({ HUB_SERVERS: 'a=http://127.0.0.1:1', A_TOKEN: 'token-a', A_ADMIN_TOKEN: 'admin-secret-a', HUB_DATA: dir, HUB_STATIC: dir, ...over, }) async function start(over: Record = {}): Promise { const hub = createHub(parseConfig(env(over))) servers.push(hub) await new Promise((resolve) => hub.listen(0, '127.0.0.1', resolve)) return `http://127.0.0.1:${(hub.address() as AddressInfo).port}` } async function call(base: string, apiPath: string, params: Record = {}, token?: string, headers: Record = {}) { const headerSet: Record = { ...headers } if (token) headerSet.authorization = `Bearer ${token}` const isLogin = apiPath === 'user/login' const res = await fetch(`${base}/api/${apiPath}${isLogin ? '' : `?${new URLSearchParams(params)}`}`, { method: isLogin ? 'POST' : 'GET', headers: isLogin ? { ...headerSet, 'content-type': 'application/x-www-form-urlencoded' } : headerSet, body: isLogin ? new URLSearchParams(params).toString() : undefined, }) return { code: res.status, body: (await res.json()) as Record } } const login = (base: string, user: string, pass: string, headers: Record = {}) => call(base, 'user/login', { user, pass }, undefined, headers) function seed(...rows: [string, Role, string][]): void { let users = readUsersFile(dir) ?? [] for (const [name, role, pass] of rows) users = addUser(users, name, role, pass) writeUsersFile(dir, users) } describe('roles and capabilities', () => { it('ranks the roles and answers capabilities', () => { expect(requires('admin', 'operator')).toBe(true) expect(requires('viewer', 'operator')).toBe(false) expect(requires('apps-admin', 'operator')).toBe(true) expect(can('viewer', 'read')).toBe(true) expect(can('viewer', 'operate')).toBe(false) expect(can('operator', 'operate')).toBe(true) expect(can('operator', 'apps.write')).toBe(false) expect(can('apps-admin', 'apps.write')).toBe(true) expect(can('apps-admin', 'audit.read')).toBe(false) for (const c of ['read', 'apps.write', 'operate', 'audit.read', 'admin'] as const) expect(can('admin', c)).toBe(true) }) }) describe('passwords and the users file', () => { it('hashes with scrypt and verifies', async () => { const hash = hashPassword('correct horse') expect(hash).toMatch(/^scrypt\$16384\$8\$1\$[A-Za-z0-9+/=]+\$[A-Za-z0-9+/=]+$/) expect(hash.split('$')[5].length).toBeGreaterThan(80) expect(await verifyPassword('correct horse', hash)).toBe(true) expect(await verifyPassword('wrong', hash)).toBe(false) expect(await verifyPassword('x', 'not a hash')).toBe(false) expect(hashPassword('correct horse')).not.toBe(hash) }) it('parses and rejects bad files', () => { const hash = hashPassword('longenough') const ok = parseUsersFile(JSON.stringify({ users: [{ name: 'Alice', role: 'admin', hash, disabled: true }] })) expect(ok).toEqual([{ name: 'Alice', role: 'admin', hash, disabled: true }]) expect(() => parseUsersFile('nope')).toThrow(/JSON/) expect(() => parseUsersFile('{}')).toThrow(/users/) expect(() => parseUsersFile(JSON.stringify({ users: [{ name: 'a', role: 'root', hash }] }))).toThrow(/role/) expect(() => parseUsersFile(JSON.stringify({ users: [{ name: 'a', role: 'admin', hash: 'x' }] }))).toThrow(/hash/) expect(() => parseUsersFile(JSON.stringify({ users: [{ name: 'a', role: 'admin', hash }, { name: 'A', role: 'viewer', hash }] })), ).toThrow(/twice/) }) it('edits users (the functions the CLI calls)', async () => { let users = addUser([], 'bob', 'viewer', 'password1') expect(() => addUser(users, 'BOB', 'viewer', 'password1')).toThrow(/exists/) expect(() => addUser(users, 'x', 'root', 'password1')).toThrow(/role/) expect(() => addUser(users, 'x', 'viewer', 'short')).toThrow(/8/) users = setRole(users, 'Bob', 'operator') expect(users[0].role).toBe('operator') const before = users[0].hash users = setPassword(users, 'bob', 'password2') expect(users[0].hash).not.toBe(before) expect(await verifyPassword('password2', users[0].hash)).toBe(true) users = setDisabled(users, 'bob', true) expect(users[0].disabled).toBe(true) users = setDisabled(users, 'bob', false) expect('disabled' in users[0]).toBe(false) expect(() => setRole(users, 'nobody', 'viewer')).toThrow(/No user/) }) it('writes atomically, leaving no temp file', () => { writeUsersFile(dir, addUser([], 'bob', 'viewer', 'password1')) const file = path.join(dir, 'users.json') expect(readUsersFile(dir)?.[0].name).toBe('bob') expect(existsSync(`${file}.${process.pid}.tmp`)).toBe(false) if (process.platform !== 'win32') expect(statSync(file).mode & 0o777).toBe(0o600) }) it('runs CLI commands', async () => { const pw = async () => 'password1' expect(await run(['list'], dir, pw)).toEqual(['No users.']) await run(['add', 'alice', 'apps-admin'], dir, pw) await run(['role', 'alice', 'operator'], dir, pw) await run(['disable', 'alice'], dir, pw) expect(await run(['list'], dir, pw)).toEqual(['alice\toperator\tdisabled']) await run(['enable', 'alice'], dir, pw) await run(['passwd', 'alice'], dir, async () => 'password2') expect(await verifyPassword('password2', readUsersFile(dir)![0].hash)).toBe(true) await expect(run(['bogus'], dir, pw)).rejects.toThrow(/Usage/) await expect(run(['add', 'bob'], dir, pw)).rejects.toThrow(/role/) }) }) describe('bootstrap', () => { it('uses the environment user as admin when there is no file, and does not write it', async () => { const users = Users.load(dir, { user: 'Root', password: 'secret' }) expect(existsSync(path.join(dir, 'users.json'))).toBe(false) expect(users.notice).toMatch(/HUB_USER/) expect((await users.authenticate('root', 'secret'))?.role).toBe('admin') expect(await users.authenticate('root', 'nope')).toBeNull() }) it('ignores the environment user when the file exists', async () => { seed(['alice', 'viewer', 'password1']) const users = Users.load(dir, { user: 'root', password: 'secret' }) expect(users.notice).toMatch(/ignored/) expect(await users.authenticate('root', 'secret')).toBeNull() expect((await users.authenticate('ALICE', 'password1'))?.role).toBe('viewer') }) it('refuses to start with neither', () => { expect(() => Users.load(dir, null)).toThrow(/No users/) expect(() => createHub(parseConfig(env()))).toThrow(/No users/) }) it('config reads no file and takes the optional variables', () => { const c = parseConfig(env({ HUB_TRUST_PROXY: '1' })) expect(c.bootstrap).toBeNull() expect(c.dataDir).toBe(dir) expect(c.trustProxy).toBe(true) expect(c.servers[0].adminToken).toBe('admin-secret-a') expect(parseConfig({ ...env(), HUB_DATA: '' }).dataDir).toBe('/data') expect(parseConfig(env()).trustProxy).toBe(false) }) }) describe('rate limit', () => { it('locks after 5 failures, expires, and a success resets the user', () => { let now = 1_000_000 const limiter = new LoginLimiter(() => now) for (let i = 0; i < 4; i++) limiter.failure('1.1.1.1', 'Bob') expect(limiter.lockedFor('1.1.1.1', 'bob')).toBe(0) limiter.success('bob') for (let i = 0; i < 4; i++) limiter.failure('2.2.2.2', 'bob') expect(limiter.lockedFor('2.2.2.2', 'bob')).toBe(0) limiter.failure('2.2.2.2', 'bob') expect(limiter.lockedFor('3.3.3.3', 'BOB')).toBe(LOCK_MS) expect(limiter.lockedFor('2.2.2.2', 'other')).toBe(LOCK_MS) now += LOCK_MS + 1 expect(limiter.lockedFor('2.2.2.2', 'bob')).toBe(0) expect(lockedMessage(LOCK_MS)).toBe('Too many failed logins. Try again in 15 minutes.') expect(lockedMessage(1000)).toBe('Too many failed logins. Try again in 1 minute.') }) it('forgets failures older than the window', () => { let now = 0 const limiter = new LoginLimiter(() => now) for (let i = 0; i < 4; i++) limiter.failure('ip', 'u') now += 16 * 60_000 limiter.failure('ip', 'u') expect(limiter.lockedFor('ip', 'u')).toBe(0) }) it('locks the hub login with 429 and the message, even for the right password', async () => { seed(['alice', 'admin', 'password1']) const base = await start() for (let i = 0; i < 5; i++) expect((await login(base, 'alice', 'bad')).body.status).toBe('error') const locked = await login(base, 'alice', 'password1') expect(locked.code).toBe(429) expect(locked.body).toEqual({ status: 'error', errorMessage: 'Too many failed logins. Try again in 15 minutes.' }) }) it('counts X-Forwarded-For only when the proxy is trusted', async () => { seed(['alice', 'admin', 'password1']) // Untrusted: the header is ignored, so five attempts from one socket lock that socket. const plain = await start() for (let i = 0; i < 5; i++) await login(plain, `u${i}`, 'bad', { 'x-forwarded-for': `9.9.9.${i}` }) expect((await login(plain, 'alice', 'password1', { 'x-forwarded-for': '8.8.8.8' })).code).toBe(429) // Trusted: each forwarded address has its own count. const trusted = await start({ HUB_TRUST_PROXY: '1' }) for (let i = 0; i < 5; i++) await login(trusted, `u${i}`, 'bad', { 'x-forwarded-for': `9.9.9.${i}` }) expect((await login(trusted, 'alice', 'password1', { 'x-forwarded-for': '8.8.8.8, 10.0.0.1' })).code).toBe(200) }) }) describe('audit', () => { it('appends JSON lines, lists newest first and filters with before', () => { let now = Date.parse('2026-01-01T00:00:00Z') const audit = new Audit(dir, { now: () => now }) for (const action of ['one', 'two', 'three']) { audit.log({ user: 'u', ip: 'i', action, outcome: 'ok' }) now += 1000 } const lines = readFileSync(path.join(dir, 'audit.log'), 'utf8').trim().split('\n') expect(lines).toHaveLength(3) expect(JSON.parse(lines[0])).toMatchObject({ user: 'u', ip: 'i', action: 'one', outcome: 'ok' }) expect(audit.list().map((e) => e.action)).toEqual(['three', 'two', 'one']) expect(audit.list(1).map((e) => e.action)).toEqual(['three']) expect(audit.list(200, '2026-01-01T00:00:02.000Z').map((e) => e.action)).toEqual(['two', 'one']) }) it('rotates once the file is too big and keeps one old file', () => { const audit = new Audit(dir, { maxBytes: 600 }) for (let i = 0; i < 12; i++) audit.log({ user: 'u', ip: 'i', action: `a${i}`, outcome: 'ok' }) expect(existsSync(path.join(dir, 'audit.log.1'))).toBe(true) expect(existsSync(path.join(dir, 'audit.log.2'))).toBe(false) expect(statSync(path.join(dir, 'audit.log')).size).toBeLessThanOrEqual(600) expect(audit.list(1)[0].action).toBe('a11') }) it('falls back to memory when the folder is not writable', () => { const file = path.join(dir, 'blocker') writeFileSync(file, 'x') const err = vi.spyOn(console, 'error').mockImplementation(() => {}) const audit = new Audit(path.join(file, 'sub')) audit.log({ user: 'u', ip: 'i', action: 'x', outcome: 'ok' }) audit.log({ user: 'u', ip: 'i', action: 'y', outcome: 'ok' }) expect(err).toHaveBeenCalledTimes(1) err.mockRestore() expect(audit.inMemory).toBe(true) expect(audit.list().map((e) => e.action)).toEqual(['y', 'x']) }) it('records login, logout and refusals without passwords or tokens, and is for audit.read only', async () => { seed(['alice', 'admin', 'password1'], ['vic', 'viewer', 'password2']) const base = await start() await login(base, 'alice', 'wrong-password-xyz') const adminLogin = await login(base, 'alice', 'password1') const admin = adminLogin.body.token as string expect(adminLogin.body.hubRole).toBe('admin') const viewer = (await login(base, 'vic', 'password2')).body.token as string expect((await call(base, 'admin/users/delete', { user: 'x', node: 'a' }, admin)).code).toBe(403) expect((await call(base, 'hub/audit/list', {}, viewer)).code).toBe(403) await call(base, 'user/logout', {}, viewer) const list = await call(base, 'hub/audit/list', { limit: '50' }, admin) expect(list.body.status).toBe('ok') const entries = list.body.response.entries as Record[] const actions = entries.map((e) => `${e.action}:${e.outcome}`) expect(actions).toEqual(expect.arrayContaining(['login:failed', 'login:ok', 'refused-write:refused', 'denied-read:refused', 'logout:ok'])) const refused = entries.find((e) => e.action === 'refused-write') expect(refused).toMatchObject({ user: 'alice', role: 'admin', target: 'admin/users/delete', server: 'a' }) const ts = entries.map((e) => e.ts) expect([...ts].sort().reverse()).toEqual(ts) const raw = readFileSync(path.join(dir, 'audit.log'), 'utf8') for (const secret of ['wrong-password-xyz', 'password1', 'password2', admin, viewer, 'token-a', 'admin-secret-a']) { expect(raw).not.toContain(secret) } }) it('logs a locked attempt', async () => { seed(['alice', 'admin', 'password1']) const base = await start() for (let i = 0; i < 6; i++) await login(base, 'alice', 'bad') const lines = readFileSync(path.join(dir, 'audit.log'), 'utf8').trim().split('\n').map((l) => JSON.parse(l)) expect(lines.filter((l) => l.outcome === 'failed')).toHaveLength(5) expect(lines[5]).toMatchObject({ action: 'login', outcome: 'refused', detail: 'locked' }) }) }) describe('routes table', () => { it('lists every old read endpoint as a read and hub/audit/list as audit.read', () => { for (const p of [ 'dashboard/stats/get', 'dashboard/stats/getTop', 'dashboard/metrics/json', 'apps/list', 'logs/query', 'settings/get', 'user/checkForUpdate', 'admin/cluster/state', ]) expect(ROUTES[p]).toEqual({ kind: 'read', capability: 'read' }) expect(routeFor('hub/audit/list')).toEqual({ kind: 'read', capability: 'audit.read' }) expect(routeFor('logs/delete')).toBeUndefined() expect(routeFor('constructor')).toBeUndefined() expect(routeFor('hub/nothing')).toBeUndefined() }) }) describe('sessions by role', () => { it('puts hubRole in the body and in info', async () => { seed(['vic', 'viewer', 'password2'], ['op', 'operator', 'password3']) const base = await start() const a = await login(base, 'vic', 'password2') expect(a.body.hubRole).toBe('viewer') expect(a.body.info.hubRole).toBe('viewer') expect(a.body.info.permissions.Dashboard.canView).toBe(true) const b = await call(base, 'user/session/get', {}, (await login(base, 'op', 'password3')).body.token) expect(b.body.hubRole).toBe('operator') expect(b.body.info.hubRole).toBe('operator') }) it('drops the sessions of a disabled user and refuses a new login', async () => { seed(['alice', 'admin', 'password1'], ['vic', 'viewer', 'password2']) const base = await start() const vic = (await login(base, 'vic', 'password2')).body.token as string const alice = (await login(base, 'alice', 'password1')).body.token as string expect((await call(base, 'user/session/get', {}, vic)).body.status).toBe('ok') // The file changes: vic is disabled, alice is untouched. const users = setDisabled(readUsersFile(dir)!, 'vic', true) writeUsersFile(dir, users) const future = new Date(Date.now() + 5000) const { utimesSync } = await import('node:fs') utimesSync(path.join(dir, 'users.json'), future, future) expect((await call(base, 'user/session/get', {}, vic)).body.status).toBe('invalid-token') expect((await call(base, 'user/session/get', {}, alice)).body.status).toBe('ok') expect((await login(base, 'vic', 'password2')).body.status).toBe('error') }) it('drops sessions when the role or password changes', async () => { seed(['alice', 'admin', 'password1']) const base = await start() const t = (await login(base, 'alice', 'password1')).body.token as string writeUsersFile(dir, setRole(readUsersFile(dir)!, 'alice', 'viewer')) const { utimesSync } = await import('node:fs') const future = new Date(Date.now() + 5000) utimesSync(path.join(dir, 'users.json'), future, future) expect((await call(base, 'user/session/get', {}, t)).body.status).toBe('invalid-token') const again = await login(base, 'ALICE', 'password1') expect(again.body.hubRole).toBe('viewer') }) })