mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-05-25 13:30:14 +00:00
f61a1d9454
* docs release 0.6.0 changelog * fix auth startup and profile model defaults
339 lines
12 KiB
TypeScript
339 lines
12 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
|
|
}
|
|
|
|
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('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('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()
|
|
})
|
|
})
|