Import the client, server, skills, tests, and build scripts as the starting point for the rebuild, then trim the surfaces this project does not ship: Electron desktop packaging, the marketing website, and the ESP32 firmware tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows. Rename the package and CLI entry points to hermes-ui, drop the prepare hook so npm ci no longer triggers a build, and drop the removed tsconfig and nodemon references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs so the documented package map and hard rules match this repository. reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
520 lines
17 KiB
TypeScript
520 lines
17 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
describe('user auth tables and middleware', () => {
|
|
let db: any = null
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules()
|
|
vi.stubEnv('AUTH_JWT_SECRET', 'test-secret')
|
|
const { DatabaseSync } = await import('node:sqlite')
|
|
db = new DatabaseSync(':memory:')
|
|
vi.doMock('../../packages/server/src/db/index', () => ({
|
|
getDb: () => db,
|
|
getStoragePath: () => ':memory:',
|
|
}))
|
|
})
|
|
|
|
afterEach(() => {
|
|
db?.close()
|
|
db = null
|
|
vi.doUnmock('../../packages/server/src/db/index')
|
|
vi.doUnmock('../../packages/server/src/services/hermes/hermes-profile')
|
|
vi.unstubAllEnvs()
|
|
vi.resetModules()
|
|
})
|
|
|
|
async function initUsers() {
|
|
const schemas = await import('../../packages/server/src/db/hermes/schemas')
|
|
schemas.initAllHermesTables()
|
|
return {
|
|
schemas,
|
|
users: await import('../../packages/server/src/db/hermes/users-store'),
|
|
auth: await import('../../packages/server/src/middleware/user-auth'),
|
|
}
|
|
}
|
|
|
|
function makeCtx(user: any, profile: string) {
|
|
return {
|
|
state: { user },
|
|
query: { profile },
|
|
request: { body: {} },
|
|
get: vi.fn((name: string) => name.toLowerCase() === 'x-hermes-profile' ? '' : ''),
|
|
status: 200,
|
|
body: null,
|
|
} as any
|
|
}
|
|
|
|
function jwtPayload(token: string): any {
|
|
return JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf-8'))
|
|
}
|
|
|
|
it('uses the default user JWT lifetime when no override is configured', async () => {
|
|
const { auth } = await initUsers()
|
|
vi.setSystemTime(new Date('2026-06-30T00:00:00Z'))
|
|
|
|
const token = await auth.issueUserJwt({ id: 1, username: 'admin', role: 'super_admin' })
|
|
const payload = jwtPayload(token)
|
|
|
|
expect(payload.exp - payload.iat).toBe(60 * 60 * 24 * 30)
|
|
})
|
|
|
|
it('allows configuring the user JWT lifetime with HERMES_WEB_UI_AUTH_JWT_EXPIRES_IN', async () => {
|
|
vi.stubEnv('HERMES_WEB_UI_AUTH_JWT_EXPIRES_IN', '12h')
|
|
const { auth } = await initUsers()
|
|
vi.setSystemTime(new Date('2026-06-30T00:00:00Z'))
|
|
|
|
const token = await auth.issueUserJwt({ id: 1, username: 'admin', role: 'super_admin' })
|
|
const payload = jwtPayload(token)
|
|
|
|
expect(payload.exp - payload.iat).toBe(12 * 60 * 60)
|
|
})
|
|
|
|
it('falls back to the default user JWT lifetime for invalid overrides', async () => {
|
|
vi.stubEnv('HERMES_WEB_UI_AUTH_JWT_EXPIRES_IN', 'forever')
|
|
const { auth } = await initUsers()
|
|
|
|
expect(auth.getUserJwtExpiresSeconds()).toBe(60 * 60 * 24 * 30)
|
|
})
|
|
|
|
it.each([
|
|
['30s', 30],
|
|
['90', 90],
|
|
['15m', 15 * 60],
|
|
['2 hours', 2 * 60 * 60],
|
|
['7d', 7 * 24 * 60 * 60],
|
|
])('parses user JWT lifetime override %s', async (value, seconds) => {
|
|
const { auth } = await initUsers()
|
|
|
|
expect(auth.parseJwtExpirySeconds(value)).toBe(seconds)
|
|
})
|
|
|
|
it('creates the default super admin without profile bindings', async () => {
|
|
const { schemas, users } = await initUsers()
|
|
|
|
const created = users.bootstrapDefaultSuperAdmin('admin', '123456')
|
|
expect(created?.id).toBe(1)
|
|
|
|
const row = db.prepare(`SELECT * FROM ${schemas.USERS_TABLE} WHERE id = ?`).get(1) as any
|
|
expect(row.username).toBe('admin')
|
|
expect(row.role).toBe('super_admin')
|
|
expect(row.status).toBe('active')
|
|
expect(row.password_hash).not.toBe('123456')
|
|
expect(users.verifyPassword('123456', row.password_hash)).toBe(true)
|
|
|
|
const profileCount = db.prepare(`SELECT COUNT(*) as count FROM ${schemas.USER_PROFILES_TABLE} WHERE user_id = ?`).get(1) as any
|
|
expect(profileCount.count).toBe(0)
|
|
})
|
|
|
|
it('allows super admin to access profiles without explicit binding', async () => {
|
|
const { users, auth } = await initUsers()
|
|
const created = users.bootstrapDefaultSuperAdmin('admin', '123456')
|
|
expect(created?.role).toBe('super_admin')
|
|
|
|
const ctx = makeCtx({ id: created?.id, username: 'admin', role: 'super_admin' }, 'research')
|
|
const next = vi.fn(async () => {})
|
|
|
|
await auth.resolveUserProfile(ctx, next)
|
|
|
|
expect(ctx.state.profile).toEqual({ name: 'research' })
|
|
expect(next).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('requires regular admins to be associated with the requested profile', async () => {
|
|
const { schemas, users, auth } = await initUsers()
|
|
const now = Date.now()
|
|
db.prepare(
|
|
`INSERT INTO ${schemas.USERS_TABLE} (username, password_hash, role, status, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)`
|
|
).run('ops', users.hashPassword('secret'), 'admin', 'active', now, now)
|
|
const admin = users.findUserByUsername('ops')
|
|
expect(admin?.id).toBe(1)
|
|
|
|
const deniedCtx = makeCtx({ id: admin!.id, username: 'ops', role: 'admin' }, 'research')
|
|
await auth.resolveUserProfile(deniedCtx, vi.fn(async () => {}))
|
|
expect(deniedCtx.status).toBe(403)
|
|
|
|
db.prepare(
|
|
`INSERT INTO ${schemas.USER_PROFILES_TABLE} (user_id, profile_name, is_default, created_at)
|
|
VALUES (?, ?, 1, ?)`
|
|
).run(admin!.id, 'research', now)
|
|
|
|
const allowedCtx = makeCtx({ id: admin!.id, username: 'ops', role: 'admin' }, 'research')
|
|
const next = vi.fn(async () => {})
|
|
await auth.resolveUserProfile(allowedCtx, next)
|
|
|
|
expect(allowedCtx.state.profile).toEqual({ name: 'research' })
|
|
expect(next).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('does not infer a profile when the frontend does not send one', async () => {
|
|
const { auth } = await initUsers()
|
|
const ctx = makeCtx({ id: 1, username: 'admin', role: 'super_admin' }, '')
|
|
const next = vi.fn(async () => {})
|
|
|
|
await auth.resolveUserProfile(ctx, next)
|
|
|
|
expect(ctx.state.profile).toBeUndefined()
|
|
expect(next).toHaveBeenCalledOnce()
|
|
|
|
await auth.requireUserProfile(ctx, vi.fn(async () => {}))
|
|
expect(ctx.status).toBe(400)
|
|
expect(ctx.body).toEqual({ error: 'Profile is required' })
|
|
})
|
|
|
|
it.each([
|
|
'/api/devices/peer-connections',
|
|
'/api/devices/peer-connections/conn-1/terminals',
|
|
'/api/devices/peer-connections/conn-1/exec',
|
|
])('rejects server token for local MCP device endpoint %s', async (path) => {
|
|
vi.stubEnv('AUTH_TOKEN', 'server-token')
|
|
const { auth } = await initUsers()
|
|
const ctx = {
|
|
path,
|
|
headers: { authorization: 'Bearer server-token' },
|
|
query: {},
|
|
ip: '127.0.0.1',
|
|
request: { ip: '127.0.0.1', body: {} },
|
|
req: { socket: { remoteAddress: '127.0.0.1' } },
|
|
state: {},
|
|
status: 200,
|
|
body: null,
|
|
} as any
|
|
const next = vi.fn(async () => {})
|
|
|
|
await auth.requireUserJwt(ctx, next)
|
|
|
|
expect(next).not.toHaveBeenCalled()
|
|
expect(ctx.status).toBe(401)
|
|
expect(ctx.body).toEqual({ error: 'Unauthorized' })
|
|
expect(ctx.state.serverTokenAuth).toBeUndefined()
|
|
})
|
|
|
|
it.each([
|
|
'/api/hermes/media/apikey-image-generate',
|
|
'/api/hermes/media/grok-image-to-video',
|
|
])('still allows server token for local media agent endpoint %s', async (path) => {
|
|
vi.stubEnv('AUTH_TOKEN', 'server-token')
|
|
const { auth } = await initUsers()
|
|
const ctx = {
|
|
path,
|
|
headers: { authorization: 'Bearer server-token' },
|
|
query: {},
|
|
ip: '127.0.0.1',
|
|
request: { ip: '127.0.0.1', body: {} },
|
|
req: { socket: { remoteAddress: '127.0.0.1' } },
|
|
state: {},
|
|
status: 200,
|
|
body: null,
|
|
} as any
|
|
const next = vi.fn(async () => {})
|
|
|
|
await auth.requireUserJwt(ctx, next)
|
|
|
|
expect(next).toHaveBeenCalledOnce()
|
|
expect(ctx.state.serverTokenAuth).toBe(true)
|
|
})
|
|
|
|
it.each([
|
|
'/api/hermes/media/apikey-image-generate',
|
|
'/api/hermes/media/grok-image-to-video',
|
|
'/api/devices',
|
|
'/api/devices/scan',
|
|
'/api/devices/device-1/connect',
|
|
'/api/devices/peer-connections',
|
|
'/api/devices/peer-connections/conn-1/disconnect',
|
|
'/api/devices/peer-connections/conn-1/terminal',
|
|
'/api/devices/peer-connections/conn-1/terminals',
|
|
'/api/devices/peer-connections/conn-1/terminal/term-1/read',
|
|
'/api/devices/peer-connections/conn-1/terminal/term-1/input',
|
|
'/api/devices/peer-connections/conn-1/terminal/term-1/resize',
|
|
'/api/devices/peer-connections/conn-1/terminal/term-1/close',
|
|
'/api/devices/peer-connections/conn-1/exec',
|
|
'/api/devices/peer-connections/conn-1/download',
|
|
'/api/devices/peer-connections/conn-1/upload',
|
|
])('rejects server token for local-only endpoint %s from non-loopback clients', async (path) => {
|
|
vi.stubEnv('AUTH_TOKEN', 'server-token')
|
|
const { auth } = await initUsers()
|
|
const ctx = {
|
|
path,
|
|
headers: { authorization: 'Bearer server-token' },
|
|
query: {},
|
|
ip: '192.168.1.50',
|
|
request: { ip: '192.168.1.50', body: {} },
|
|
req: { socket: { remoteAddress: '192.168.1.50' } },
|
|
state: {},
|
|
status: 200,
|
|
body: null,
|
|
} as any
|
|
const next = vi.fn(async () => {})
|
|
|
|
await auth.requireUserJwt(ctx, next)
|
|
|
|
expect(next).not.toHaveBeenCalled()
|
|
expect(ctx.status).toBe(401)
|
|
expect(ctx.body).toEqual({ error: 'Unauthorized' })
|
|
})
|
|
|
|
it('requires super admin privileges after a valid user token reaches MCP device routes', async () => {
|
|
const { auth } = await initUsers()
|
|
const next = vi.fn(async () => {})
|
|
const adminCtx = {
|
|
state: { user: { id: 1, username: 'ops', role: 'admin' } },
|
|
status: 200,
|
|
body: null,
|
|
} as any
|
|
const superCtx = {
|
|
state: { user: { id: 2, username: 'root', role: 'super_admin' } },
|
|
status: 200,
|
|
body: null,
|
|
} as any
|
|
|
|
await auth.requireSuperAdmin(adminCtx, vi.fn(async () => {}))
|
|
await auth.requireSuperAdmin(superCtx, next)
|
|
|
|
expect(adminCtx.status).toBe(403)
|
|
expect(adminCtx.body).toEqual({ error: 'Super administrator privileges are required' })
|
|
expect(next).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('ignores stale profile headers for the aggregate available-models endpoint', async () => {
|
|
const { auth } = await initUsers()
|
|
const ctx = {
|
|
path: '/api/hermes/available-models',
|
|
state: { user: { id: 1, username: 'ops', role: 'admin' } },
|
|
query: {},
|
|
request: { body: {} },
|
|
get: vi.fn((name: string) => name.toLowerCase() === 'x-hermes-profile' ? 'private' : ''),
|
|
status: 200,
|
|
body: null,
|
|
} as any
|
|
const next = vi.fn(async () => {})
|
|
|
|
await auth.resolveUserProfile(ctx, next)
|
|
|
|
expect(ctx.state.profile).toBeUndefined()
|
|
expect(next).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('does not create the default super admin until first valid bootstrap login', async () => {
|
|
const { schemas, users } = await initUsers()
|
|
|
|
expect(users.countUsers()).toBe(0)
|
|
expect(users.bootstrapDefaultSuperAdmin('admin', 'bad-password')).toBeNull()
|
|
expect(users.countUsers()).toBe(0)
|
|
|
|
const created = users.bootstrapDefaultSuperAdmin('admin', '123456')
|
|
expect(created?.role).toBe('super_admin')
|
|
expect(users.countUsers()).toBe(1)
|
|
|
|
const userCount = db.prepare(`SELECT COUNT(*) as count FROM ${schemas.USERS_TABLE}`).get() as any
|
|
expect(userCount.count).toBe(1)
|
|
})
|
|
|
|
it('signs and verifies user JWTs', async () => {
|
|
const { auth } = await initUsers()
|
|
const token = auth.signUserJwt({ id: 1, username: 'admin', role: 'super_admin' }, 'secret', 1000)
|
|
|
|
const payload = auth.verifyUserJwt(token, 'secret', 1000)
|
|
expect(payload?.sub).toBe('1')
|
|
expect(payload?.username).toBe('admin')
|
|
expect(payload?.role).toBe('super_admin')
|
|
|
|
expect(auth.verifyUserJwt(token, 'wrong', 1000)).toBeNull()
|
|
})
|
|
|
|
it('signs model run JWTs with the same payload shape and a one hour expiry', async () => {
|
|
const { auth } = await initUsers()
|
|
const token = auth.signUserJwt(
|
|
{ id: 1, username: 'admin', role: 'super_admin' },
|
|
'secret',
|
|
1000,
|
|
auth.MODEL_RUN_EXPIRES_SECONDS,
|
|
)
|
|
|
|
const payload = auth.verifyUserJwt(token, 'secret', 1000)
|
|
expect(payload).toMatchObject({
|
|
sub: '1',
|
|
username: 'admin',
|
|
role: 'super_admin',
|
|
type: 'access',
|
|
aud: 'hermes-web-ui',
|
|
iat: 1,
|
|
exp: 3601,
|
|
})
|
|
expect(auth.verifyUserJwt(token, 'secret', 1000 + 60 * 60 * 1000)).toBeNull()
|
|
})
|
|
|
|
it('authenticates JWTs passed as query tokens for download and websocket URLs', async () => {
|
|
const { users, auth } = await initUsers()
|
|
const user = users.bootstrapDefaultSuperAdmin('admin', '123456')!
|
|
const token = auth.signUserJwt(user, 'test-secret')
|
|
const ctx = {
|
|
path: '/api/hermes/download',
|
|
headers: {},
|
|
query: { token },
|
|
state: {},
|
|
request: { body: {} },
|
|
status: 200,
|
|
body: null,
|
|
} as any
|
|
const next = vi.fn(async () => {})
|
|
|
|
await auth.requireUserJwt(ctx, next)
|
|
|
|
expect(ctx.state.user).toEqual({ id: user.id, username: 'admin', role: 'super_admin' })
|
|
expect(next).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('lets SPA and static asset paths pass through without a JWT', async () => {
|
|
const { auth } = await initUsers()
|
|
const ctx = {
|
|
path: '/',
|
|
headers: {},
|
|
query: {},
|
|
state: {},
|
|
request: { body: {} },
|
|
status: 200,
|
|
body: null,
|
|
} as any
|
|
const next = vi.fn(async () => {})
|
|
|
|
await auth.requireUserJwt(ctx, next)
|
|
|
|
expect(next).toHaveBeenCalledOnce()
|
|
expect(ctx.status).toBe(200)
|
|
expect(ctx.body).toBeNull()
|
|
})
|
|
|
|
it('still requires a JWT for protected API paths', async () => {
|
|
const { auth } = await initUsers()
|
|
const ctx = {
|
|
path: '/api/hermes/sessions',
|
|
headers: {},
|
|
query: {},
|
|
state: {},
|
|
request: { body: {} },
|
|
status: 200,
|
|
body: null,
|
|
} as any
|
|
const next = vi.fn(async () => {})
|
|
|
|
await auth.requireUserJwt(ctx, next)
|
|
|
|
expect(next).not.toHaveBeenCalled()
|
|
expect(ctx.status).toBe(401)
|
|
expect(ctx.body).toEqual({ error: 'Unauthorized' })
|
|
})
|
|
|
|
it('bootstraps the default super admin through password login and returns a user JWT', async () => {
|
|
await initUsers()
|
|
const ctrl = await import('../../packages/server/src/controllers/auth')
|
|
const ctx = {
|
|
request: { body: { username: 'admin', password: '123456' } },
|
|
headers: {},
|
|
ip: '127.0.0.1',
|
|
status: 200,
|
|
body: null,
|
|
} as any
|
|
|
|
await ctrl.login(ctx)
|
|
|
|
expect(ctx.status).toBe(200)
|
|
expect(ctx.body.token).toMatch(/^[^.]+\.[^.]+\.[^.]+$/)
|
|
})
|
|
|
|
it('marks only admin with password 123456 as requiring a credential change', async () => {
|
|
const { users } = await initUsers()
|
|
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
|
|
const ctrl = await import('../../packages/server/src/controllers/auth')
|
|
|
|
const defaultCtx = {
|
|
state: { user: { id: admin.id, username: 'admin', role: 'super_admin' } },
|
|
status: 200,
|
|
body: null,
|
|
} as any
|
|
await ctrl.currentUser(defaultCtx)
|
|
expect(defaultCtx.body.user.requiresCredentialChange).toBe(true)
|
|
|
|
users.updateUserPassword(admin.id, 'stronger-password')
|
|
const passwordChangedCtx = {
|
|
state: { user: { id: admin.id, username: 'admin', role: 'super_admin' } },
|
|
status: 200,
|
|
body: null,
|
|
} as any
|
|
await ctrl.currentUser(passwordChangedCtx)
|
|
expect(passwordChangedCtx.body.user.requiresCredentialChange).toBe(false)
|
|
|
|
users.updateUserPassword(admin.id, '123456')
|
|
users.updateUsername(admin.id, 'owner')
|
|
const usernameChangedCtx = {
|
|
state: { user: { id: admin.id, username: 'owner', role: 'super_admin' } },
|
|
status: 200,
|
|
body: null,
|
|
} as any
|
|
await ctrl.currentUser(usernameChangedCtx)
|
|
expect(usernameChangedCtx.body.user.requiresCredentialChange).toBe(false)
|
|
})
|
|
|
|
it('lets super admins create regular admins with profile bindings', async () => {
|
|
const { users } = await initUsers()
|
|
vi.doMock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
|
listProfileNamesFromDisk: () => ['default', 'research'],
|
|
}))
|
|
const ctrl = await import('../../packages/server/src/controllers/auth')
|
|
const ctx = {
|
|
state: { user: { id: 1, username: 'admin', role: 'super_admin' } },
|
|
request: {
|
|
body: {
|
|
username: 'ops',
|
|
password: 'secret1',
|
|
role: 'admin',
|
|
status: 'active',
|
|
profiles: ['research'],
|
|
},
|
|
},
|
|
status: 200,
|
|
body: null,
|
|
} as any
|
|
|
|
await ctrl.createManagedUser(ctx)
|
|
|
|
expect(ctx.status).toBe(201)
|
|
const created = users.findUserByUsername('ops')
|
|
expect(created?.role).toBe('admin')
|
|
expect(users.listUserProfiles(created!.id).map(profile => profile.profile_name)).toEqual(['research'])
|
|
})
|
|
|
|
it('does not allow disabling the last active super admin', async () => {
|
|
const { users } = await initUsers()
|
|
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
|
|
vi.doMock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
|
listProfileNamesFromDisk: () => ['default'],
|
|
}))
|
|
const ctrl = await import('../../packages/server/src/controllers/auth')
|
|
const ctx = {
|
|
state: { user: { id: admin.id, username: 'admin', role: 'super_admin' } },
|
|
params: { id: String(admin.id) },
|
|
request: { body: { status: 'disabled' } },
|
|
status: 200,
|
|
body: null,
|
|
} as any
|
|
|
|
await ctrl.updateManagedUser(ctx)
|
|
|
|
expect(ctx.status).toBe(400)
|
|
expect(ctx.body).toEqual({ error: 'You cannot disable your own account' })
|
|
})
|
|
|
|
it('requires super admin for super-admin-only middleware', async () => {
|
|
const { auth } = await initUsers()
|
|
const adminCtx = makeCtx({ id: 2, username: 'ops', role: 'admin' }, 'default')
|
|
await auth.requireSuperAdmin(adminCtx, vi.fn(async () => {}))
|
|
expect(adminCtx.status).toBe(403)
|
|
|
|
const superCtx = makeCtx({ id: 1, username: 'admin', role: 'super_admin' }, 'default')
|
|
const next = vi.fn(async () => {})
|
|
await auth.requireSuperAdmin(superCtx, next)
|
|
expect(next).toHaveBeenCalledOnce()
|
|
})
|
|
})
|