Files
hermes-ui/tests/server/copilot-auth-controller.test.ts
T
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
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.
2026-10-16 10:34:35 +08:00

186 lines
7.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('os', async () => {
const actual = await vi.importActual<typeof import('os')>('os')
return { ...actual, homedir: () => '/fake/home' }
})
const { mockReadFile, mockWriteFile, mockMkdir, mockSaveEnvValue, mockReadConfigYaml, mockWriteConfigYaml, mockUpdateConfigYaml, mockResolveWithSource, mockInvalidate, mockReadAppConfig, mockWriteAppConfig } = vi.hoisted(() => ({
mockReadFile: vi.fn(),
mockWriteFile: vi.fn().mockResolvedValue(undefined),
mockMkdir: vi.fn().mockResolvedValue(undefined),
mockSaveEnvValue: vi.fn().mockResolvedValue(undefined),
mockReadConfigYaml: vi.fn(),
mockWriteConfigYaml: vi.fn().mockResolvedValue(undefined),
mockUpdateConfigYaml: vi.fn(),
mockResolveWithSource: vi.fn(),
mockInvalidate: vi.fn(),
mockReadAppConfig: vi.fn(),
mockWriteAppConfig: vi.fn().mockResolvedValue({ copilotEnabled: true }),
}))
vi.mock('fs/promises', () => ({
readFile: mockReadFile,
writeFile: mockWriteFile,
mkdir: mockMkdir,
}))
vi.mock('../../packages/server/src/services/config-helpers', () => ({
saveEnvValue: mockSaveEnvValue,
readConfigYaml: mockReadConfigYaml,
writeConfigYaml: mockWriteConfigYaml,
updateConfigYaml: mockUpdateConfigYaml,
}))
vi.mock('../../packages/server/src/services/hermes/copilot-models', () => ({
resolveCopilotOAuthTokenWithSource: mockResolveWithSource,
invalidateAllCaches: mockInvalidate,
}))
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
getActiveEnvPath: () => '/fake/home/.hermes/.env',
}))
vi.mock('../../packages/server/src/services/app-config', () => ({
readAppConfig: mockReadAppConfig,
writeAppConfig: mockWriteAppConfig,
}))
vi.mock('../../packages/server/src/services/logger', () => ({
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn() },
}))
import * as ctrl from '../../packages/server/src/controllers/hermes/copilot-auth'
function makeCtx(): any {
return { params: {}, request: { body: {} }, body: undefined, status: 200 }
}
beforeEach(() => {
vi.clearAllMocks()
mockReadFile.mockResolvedValue('')
mockReadConfigYaml.mockResolvedValue({})
mockUpdateConfigYaml.mockImplementation(async (updater: any) => {
const cfg = await mockReadConfigYaml()
const updated = await updater(cfg)
if (updated && typeof updated === 'object' && Object.hasOwn(updated, 'data')) {
if (updated.write === false) return updated.result
await mockWriteConfigYaml(updated.data)
return updated.result
}
await mockWriteConfigYaml(updated)
return undefined
})
})
afterEach(() => {
delete process.env.COPILOT_GITHUB_TOKEN
})
describe('copilot-auth controller — checkToken', () => {
it('reports has_token=false / source=null / enabled=false when nothing resolves', async () => {
mockResolveWithSource.mockResolvedValue({ token: '', source: null })
mockReadAppConfig.mockResolvedValue({})
const ctx = makeCtx()
await ctrl.checkToken(ctx)
expect(ctx.body).toEqual({ has_token: false, source: null, enabled: false })
expect(mockInvalidate).toHaveBeenCalled()
})
it('reports source and enabled flag', async () => {
mockResolveWithSource.mockResolvedValue({ token: 'gho_xxx', source: 'env' })
mockReadAppConfig.mockResolvedValue({ copilotEnabled: true })
const ctx = makeCtx()
await ctrl.checkToken(ctx)
expect(ctx.body).toEqual({ has_token: true, source: 'env', enabled: true })
})
})
describe('copilot-auth controller — enable', () => {
it('persists copilotEnabled=true and invalidates cache', async () => {
const ctx = makeCtx()
await ctrl.enable(ctx)
expect(mockWriteAppConfig).toHaveBeenCalledWith({ copilotEnabled: true })
expect(mockInvalidate).toHaveBeenCalled()
expect(ctx.body).toEqual({ ok: true })
})
})
describe('copilot-auth controller — disable', () => {
it('clears ~/.hermes/.env when token source is env', async () => {
mockResolveWithSource.mockResolvedValue({ token: 'gho_xxx', source: 'env' })
process.env.COPILOT_GITHUB_TOKEN = 'gho_xxx'
const ctx = makeCtx()
await ctrl.disable(ctx)
expect(mockSaveEnvValue).toHaveBeenCalledWith('COPILOT_GITHUB_TOKEN', '')
expect(process.env.COPILOT_GITHUB_TOKEN).toBeUndefined()
expect(mockWriteAppConfig).toHaveBeenCalledWith({ copilotEnabled: false })
expect(ctx.body).toEqual({ ok: true, cleared_env: true, cleared_default: false })
})
it('does NOT touch .env when token source is gh-cli (preserves gh CLI session)', async () => {
mockResolveWithSource.mockResolvedValue({ token: 'gho_xxx', source: 'gh-cli' })
const ctx = makeCtx()
await ctrl.disable(ctx)
expect(mockSaveEnvValue).not.toHaveBeenCalled()
expect(mockWriteAppConfig).toHaveBeenCalledWith({ copilotEnabled: false })
expect(ctx.body).toEqual({ ok: true, cleared_env: false, cleared_default: false })
})
it('does NOT touch .env when token source is apps-json (preserves VS Code Copilot)', async () => {
mockResolveWithSource.mockResolvedValue({ token: 'gho_xxx', source: 'apps-json' })
const ctx = makeCtx()
await ctrl.disable(ctx)
expect(mockSaveEnvValue).not.toHaveBeenCalled()
expect(mockWriteAppConfig).toHaveBeenCalledWith({ copilotEnabled: false })
expect(ctx.body).toEqual({ ok: true, cleared_env: false, cleared_default: false })
})
it('still flips enabled=false even when no token is resolvable', async () => {
mockResolveWithSource.mockResolvedValue({ token: '', source: null })
const ctx = makeCtx()
await ctrl.disable(ctx)
expect(mockSaveEnvValue).not.toHaveBeenCalled()
expect(mockWriteAppConfig).toHaveBeenCalledWith({ copilotEnabled: false })
})
it('clears default model when it belongs to copilot', async () => {
mockResolveWithSource.mockResolvedValue({ token: '', source: null })
mockReadConfigYaml.mockResolvedValue({ model: { default: 'gpt-4o', provider: 'copilot' } })
const ctx = makeCtx()
await ctrl.disable(ctx)
expect(mockWriteConfigYaml).toHaveBeenCalledWith(expect.objectContaining({ model: {} }))
expect(ctx.body).toEqual(expect.objectContaining({ cleared_default: true }))
})
it('does NOT touch default model when it belongs to a different provider', async () => {
mockResolveWithSource.mockResolvedValue({ token: '', source: null })
mockReadConfigYaml.mockResolvedValue({ model: { default: 'glm-4', provider: 'zhipu' } })
const ctx = makeCtx()
await ctrl.disable(ctx)
expect(mockWriteConfigYaml).not.toHaveBeenCalled()
expect(ctx.body).toEqual(expect.objectContaining({ cleared_default: false }))
})
it('returns 500 and does NOT flip enabled flag when writeConfigYaml fails', async () => {
mockResolveWithSource.mockResolvedValue({ token: 'gho_xxx', source: 'env' })
mockReadConfigYaml.mockResolvedValue({ model: { default: 'gpt-4o', provider: 'copilot' } })
mockWriteConfigYaml.mockRejectedValueOnce(new Error('disk full'))
const ctx = makeCtx()
await ctrl.disable(ctx)
expect(ctx.status).toBe(500)
expect(mockSaveEnvValue).not.toHaveBeenCalled()
expect(mockWriteAppConfig).not.toHaveBeenCalled()
})
it('does not write process.env on persistToken / disable cleanup is defensive only', async () => {
// disable 不依赖 process.env 被写入;只清理之前可能由外部 export 的覆盖。
mockResolveWithSource.mockResolvedValue({ token: '', source: null })
process.env.COPILOT_GITHUB_TOKEN = 'leftover-from-shell'
const ctx = makeCtx()
await ctrl.disable(ctx)
// source=null → 不动 .env,也不清 process.env(因为不是 web-ui 自己的状态)
expect(process.env.COPILOT_GITHUB_TOKEN).toBe('leftover-from-shell')
})
})