Files
hermes-ui/tests/server/provider-delete-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

342 lines
13 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import YAML from 'js-yaml'
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
restartGateway: vi.fn().mockResolvedValue(undefined),
}))
let hermesHome = ''
async function loadProvidersController() {
vi.resetModules()
process.env.HERMES_HOME = hermesHome
return import('../../packages/server/src/controllers/hermes/providers')
}
function makeCtx(poolKey: string, overrides: Record<string, any> = {}) {
return {
params: { poolKey: encodeURIComponent(poolKey) },
request: { body: {} },
status: 200,
body: undefined as unknown,
...overrides,
}
}
function readAuth(profileDir = hermesHome) {
return JSON.parse(readFileSync(join(profileDir, 'auth.json'), 'utf-8'))
}
function readYaml(filePath: string) {
return YAML.load(readFileSync(filePath, 'utf-8')) as any
}
describe('providers controller delete', () => {
beforeEach(() => {
hermesHome = mkdtempSync(join(tmpdir(), 'hwui-provider-delete-'))
mkdirSync(hermesHome, { recursive: true })
writeFileSync(join(hermesHome, 'config.yaml'), 'model:\n provider: openai-codex\n default: gpt-5.5\n')
})
afterEach(() => {
delete process.env.HERMES_HOME
vi.doUnmock('../../packages/server/src/controllers/hermes/providers')
vi.clearAllMocks()
if (hermesHome) rmSync(hermesHome, { recursive: true, force: true })
hermesHome = ''
})
it('removes built-in API-key provider credentials from env and auth pool', async () => {
writeFileSync(join(hermesHome, '.env'), [
['DEEPSEEK_API_KEY', 'deepseek-placeholder'].join('='),
['DEEPSEEK_BASE_URL', 'https://deepseek-proxy.invalid/v1'].join('='),
['OPENROUTER_API_KEY', 'openrouter-placeholder'].join('='),
['OPENROUTER_BASE_URL', 'https://openrouter-proxy.invalid/v1'].join('='),
'',
].join('\n'))
writeFileSync(join(hermesHome, 'auth.json'), JSON.stringify({
providers: {
deepseek: { access_token: 'legacy-token' },
openrouter: { access_token: 'keep-token' },
},
credential_pool: {
deepseek: [{ label: 'DEEPSEEK_API_KEY', source: 'env:DEEPSEEK_API_KEY' }],
openrouter: [{ label: 'OPENROUTER_API_KEY', source: 'env:OPENROUTER_API_KEY' }],
},
}, null, 2))
const { remove } = await loadProvidersController()
const ctx = makeCtx('deepseek')
await remove(ctx)
expect(ctx.body).toEqual({ success: true })
const envAfter = readFileSync(join(hermesHome, '.env'), 'utf-8')
expect(envAfter).not.toContain('DEEPSEEK_API_KEY')
expect(envAfter).not.toContain('DEEPSEEK_BASE_URL')
expect(envAfter).toContain(['OPENROUTER_API_KEY', 'openrouter-placeholder'].join('='))
expect(envAfter).toContain(['OPENROUTER_BASE_URL', 'https://openrouter-proxy.invalid/v1'].join('='))
const authAfter = readAuth()
expect(authAfter.providers).not.toHaveProperty('deepseek')
expect(authAfter.credential_pool).not.toHaveProperty('deepseek')
expect(authAfter.providers.openrouter).toEqual({ access_token: 'keep-token' })
expect(authAfter.credential_pool.openrouter).toEqual([
{ label: 'OPENROUTER_API_KEY', source: 'env:OPENROUTER_API_KEY' },
])
})
it('does not remove unrelated base URL env for a provider without a base URL env mapping', async () => {
writeFileSync(join(hermesHome, '.env'), [
['XAI_BASE_URL', 'https://xai-proxy.invalid/v1'].join('='),
['DEEPSEEK_BASE_URL', 'https://deepseek-proxy.invalid/v1'].join('='),
'',
].join('\n'))
const { remove } = await loadProvidersController()
const ctx = makeCtx('xai-oauth')
await remove(ctx)
expect(ctx.body).toEqual({ success: true })
const envAfter = readFileSync(join(hermesHome, '.env'), 'utf-8')
expect(envAfter).toContain(['XAI_BASE_URL', 'https://xai-proxy.invalid/v1'].join('='))
expect(envAfter).toContain(['DEEPSEEK_BASE_URL', 'https://deepseek-proxy.invalid/v1'].join('='))
})
it('removes custom provider config and any matching stored auth entry', async () => {
writeFileSync(join(hermesHome, 'config.yaml'), [
'model:',
' provider: openai-codex',
' default: gpt-5.5',
'custom_providers:',
' - name: deepseek-proxy',
' base_url: https://example.invalid/v1',
' api_key: placeholder',
' model: deepseek-chat',
' - name: keep-provider',
' base_url: https://keep.invalid/v1',
' api_key: placeholder',
' model: keep-model',
'',
].join('\n'))
writeFileSync(join(hermesHome, 'auth.json'), JSON.stringify({
credential_pool: {
'custom:deepseek-proxy': [{ label: 'custom' }],
'custom:keep-provider': [{ label: 'keep' }],
},
}, null, 2))
const { remove } = await loadProvidersController()
const ctx = makeCtx('custom:deepseek-proxy')
await remove(ctx)
expect(ctx.body).toEqual({ success: true })
const configAfter = readFileSync(join(hermesHome, 'config.yaml'), 'utf-8')
expect(configAfter).not.toContain('deepseek-proxy')
expect(configAfter).toContain('keep-provider')
const authAfter = readAuth()
expect(authAfter.credential_pool).not.toHaveProperty('custom:deepseek-proxy')
expect(authAfter.credential_pool['custom:keep-provider']).toEqual([{ label: 'keep' }])
})
it('removes v12 providers dict entries when requested by source', async () => {
writeFileSync(join(hermesHome, 'config.yaml'), [
'model:',
' provider: custom:dict-provider',
' default: dict-model',
'providers:',
' dict-provider:',
' api: https://dict.invalid/v1',
' api_key: dict-key',
' default_model: dict-model',
' keep-provider:',
' api: https://keep.invalid/v1',
' default_model: keep-model',
'',
].join('\n'))
writeFileSync(join(hermesHome, 'auth.json'), JSON.stringify({
credential_pool: {
'custom:dict-provider': [{ label: 'dict' }],
'custom:keep-provider': [{ label: 'keep' }],
},
}, null, 2))
const { remove } = await loadProvidersController()
const ctx = makeCtx('custom:dict-provider', {
query: { source: 'providers', providerKey: 'dict-provider' },
})
await remove(ctx)
expect(ctx.body).toEqual({ success: true })
const configAfter = readYaml(join(hermesHome, 'config.yaml'))
expect(configAfter.providers).not.toHaveProperty('dict-provider')
expect(configAfter.providers).toHaveProperty('keep-provider')
expect(configAfter.model).toEqual({
provider: 'custom:keep-provider',
default: 'keep-model',
})
const authAfter = readAuth()
expect(authAfter.credential_pool).not.toHaveProperty('custom:dict-provider')
expect(authAfter.credential_pool['custom:keep-provider']).toEqual([{ label: 'keep' }])
})
it('uses provider source to delete one side when legacy and dict providers share a name', async () => {
writeFileSync(join(hermesHome, 'config.yaml'), [
'model:',
' provider: custom:shared',
' default: legacy-model',
'custom_providers:',
' - name: shared',
' base_url: https://legacy.invalid/v1',
' api_key: legacy-key',
' model: legacy-model',
'providers:',
' shared:',
' api: https://dict.invalid/v1',
' api_key: dict-key',
' default_model: dict-model',
'',
].join('\n'))
const { remove } = await loadProvidersController()
const ctx = makeCtx('custom:shared', {
query: { source: 'providers', providerKey: 'shared' },
})
await remove(ctx)
expect(ctx.body).toEqual({ success: true })
const configAfter = readYaml(join(hermesHome, 'config.yaml'))
expect(configAfter.providers).not.toHaveProperty('shared')
expect(configAfter.custom_providers).toHaveLength(1)
expect(configAfter.custom_providers[0].name).toBe('shared')
})
it('keeps OAuth-style provider deletion clearing stored auth entries', async () => {
writeFileSync(join(hermesHome, 'auth.json'), JSON.stringify({
providers: {
'openai-codex': { account_id: 'remove-me' },
copilot: { account_id: 'keep-me' },
},
credential_pool: {
'openai-codex': [{ label: 'remove-me' }],
copilot: [{ label: 'keep-me' }],
},
}, null, 2))
const { remove } = await loadProvidersController()
const ctx = makeCtx('openai-codex')
await remove(ctx)
expect(ctx.body).toEqual({ success: true })
const authAfter = readAuth()
expect(authAfter.providers).not.toHaveProperty('openai-codex')
expect(authAfter.credential_pool).not.toHaveProperty('openai-codex')
expect(authAfter.providers.copilot).toEqual({ account_id: 'keep-me' })
expect(authAfter.credential_pool.copilot).toEqual([{ label: 'keep-me' }])
})
it('does not create auth.json when deleting a provider without stored auth credentials', async () => {
writeFileSync(join(hermesHome, '.env'), [['DEEPSEEK_API_KEY', 'deepseek-placeholder'].join('='), ''].join('\n'))
const { remove } = await loadProvidersController()
const ctx = makeCtx('deepseek')
await remove(ctx)
expect(ctx.body).toEqual({ success: true })
expect(existsSync(join(hermesHome, 'auth.json'))).toBe(false)
})
it('deletes provider state from the request-scoped profile only', async () => {
const researchDir = join(hermesHome, 'profiles', 'research')
mkdirSync(researchDir, { recursive: true })
writeFileSync(join(hermesHome, 'config.yaml'), [
'model:',
' provider: deepseek',
' default: keep-default-model',
'',
].join('\n'))
writeFileSync(join(hermesHome, '.env'), [
['DEEPSEEK_API_KEY', 'keep-default-key'].join('='),
['OPENROUTER_API_KEY', 'keep-default-openrouter'].join('='),
'',
].join('\n'))
writeFileSync(join(hermesHome, 'auth.json'), JSON.stringify({
providers: {
deepseek: { access_token: 'keep-default-token' },
},
credential_pool: {
deepseek: [{ label: 'keep-default' }],
},
}, null, 2))
writeFileSync(join(researchDir, 'config.yaml'), [
'model:',
' provider: deepseek',
' default: research-model',
'custom_providers:',
' - name: keep-provider',
' base_url: https://keep.invalid/v1',
' api_key: placeholder',
' model: keep-model',
'',
].join('\n'))
writeFileSync(join(researchDir, '.env'), [
['DEEPSEEK_API_KEY', 'remove-research-key'].join('='),
['OPENROUTER_API_KEY', 'keep-research-openrouter'].join('='),
'',
].join('\n'))
writeFileSync(join(researchDir, 'auth.json'), JSON.stringify({
providers: {
deepseek: { access_token: 'remove-research-token' },
openrouter: { access_token: 'keep-research-token' },
},
credential_pool: {
deepseek: [{ label: 'remove-research' }],
openrouter: [{ label: 'keep-research' }],
},
}, null, 2))
const { remove } = await loadProvidersController()
const ctx = makeCtx('deepseek', { state: { profile: { name: 'research' } } })
await remove(ctx)
expect(ctx.body).toEqual({ success: true })
const defaultEnvAfter = readFileSync(join(hermesHome, '.env'), 'utf-8')
expect(defaultEnvAfter).toContain(['DEEPSEEK_API_KEY', 'keep-default-key'].join('='))
expect(defaultEnvAfter).toContain(['OPENROUTER_API_KEY', 'keep-default-openrouter'].join('='))
expect(readFileSync(join(hermesHome, 'config.yaml'), 'utf-8')).toContain('keep-default-model')
expect(readAuth()).toEqual({
providers: {
deepseek: { access_token: 'keep-default-token' },
},
credential_pool: {
deepseek: [{ label: 'keep-default' }],
},
})
const researchEnvAfter = readFileSync(join(researchDir, '.env'), 'utf-8')
expect(researchEnvAfter).not.toContain('DEEPSEEK_API_KEY')
expect(researchEnvAfter).toContain(['OPENROUTER_API_KEY', 'keep-research-openrouter'].join('='))
const researchConfigAfter = readFileSync(join(researchDir, 'config.yaml'), 'utf-8')
expect(researchConfigAfter).toContain('keep-provider')
expect(researchConfigAfter).toContain('keep-model')
const researchAuthAfter = readAuth(researchDir)
expect(researchAuthAfter.providers).not.toHaveProperty('deepseek')
expect(researchAuthAfter.credential_pool).not.toHaveProperty('deepseek')
expect(researchAuthAfter.providers.openrouter).toEqual({ access_token: 'keep-research-token' })
expect(researchAuthAfter.credential_pool.openrouter).toEqual([{ label: 'keep-research' }])
})
})