147 lines
4.9 KiB
TypeScript
147 lines
4.9 KiB
TypeScript
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises'
|
|
import { tmpdir } from 'os'
|
|
import { join } from 'path'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import YAML from 'js-yaml'
|
|
|
|
const originalHermesHome = process.env.HERMES_HOME
|
|
const tempHomes: string[] = []
|
|
let hermesHome = ''
|
|
|
|
async function loadHelpers() {
|
|
vi.resetModules()
|
|
process.env.HERMES_HOME = hermesHome
|
|
return import('../../packages/server/src/services/config-helpers')
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
hermesHome = await mkdtemp(join(tmpdir(), 'hermes-config-helpers-'))
|
|
tempHomes.push(hermesHome)
|
|
await mkdir(hermesHome, { recursive: true })
|
|
})
|
|
|
|
afterEach(async () => {
|
|
vi.resetModules()
|
|
if (originalHermesHome === undefined) delete process.env.HERMES_HOME
|
|
else process.env.HERMES_HOME = originalHermesHome
|
|
await Promise.all(tempHomes.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
|
hermesHome = ''
|
|
})
|
|
|
|
describe('config-helpers locked file updates', () => {
|
|
it('merges concurrent config.yaml updates by re-reading under the file lock', async () => {
|
|
await writeFile(join(hermesHome, 'config.yaml'), 'model:\n default: old\n', 'utf-8')
|
|
const { updateConfigYaml } = await loadHelpers()
|
|
|
|
await Promise.all([
|
|
updateConfigYaml(async (cfg) => {
|
|
await new Promise(resolve => setTimeout(resolve, 25))
|
|
cfg.model.default = 'glm-5.1'
|
|
return cfg
|
|
}),
|
|
updateConfigYaml((cfg) => {
|
|
cfg.platforms = cfg.platforms || {}
|
|
cfg.platforms.api_server = { extra: { port: 8648 } }
|
|
return cfg
|
|
}),
|
|
])
|
|
|
|
const config = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any
|
|
expect(config.model.default).toBe('glm-5.1')
|
|
expect(config.platforms.api_server.extra.port).toBe(8648)
|
|
await expect(readFile(join(hermesHome, 'config.yaml.bak'), 'utf-8')).resolves.toContain('model:')
|
|
})
|
|
|
|
it('serializes concurrent .env updates without losing keys', async () => {
|
|
await writeFile(join(hermesHome, '.env'), 'OPENROUTER_API_KEY=keep\n', 'utf-8')
|
|
const { saveEnvValue } = await loadHelpers()
|
|
|
|
await Promise.all([
|
|
saveEnvValue('DEEPSEEK_API_KEY', 'deepseek'),
|
|
saveEnvValue('MOONSHOT_API_KEY', 'moonshot'),
|
|
])
|
|
|
|
const env = await readFile(join(hermesHome, '.env'), 'utf-8')
|
|
expect(env).toContain('OPENROUTER_API_KEY=keep')
|
|
expect(env).toContain('DEEPSEEK_API_KEY=deepseek')
|
|
expect(env).toContain('MOONSHOT_API_KEY=moonshot')
|
|
})
|
|
|
|
it('rejects invalid .env keys instead of writing keyless lines', async () => {
|
|
const envPath = join(hermesHome, '.env')
|
|
await writeFile(envPath, 'OPENROUTER_API_KEY=keep\n', 'utf-8')
|
|
const { saveEnvValue } = await loadHelpers()
|
|
|
|
await expect(saveEnvValue('', 'leaked-value')).rejects.toThrow('Invalid .env key')
|
|
await expect(saveEnvValue('=BROKEN', 'leaked-value')).rejects.toThrow('Invalid .env key')
|
|
|
|
const env = await readFile(envPath, 'utf-8')
|
|
expect(env).toBe('OPENROUTER_API_KEY=keep\n')
|
|
expect(env).not.toContain('=leaked-value')
|
|
})
|
|
|
|
it('skips writing config.yaml when an updater returns write false', async () => {
|
|
const configPath = join(hermesHome, 'config.yaml')
|
|
await writeFile(configPath, 'model:\n default: old\n', 'utf-8')
|
|
const before = await readFile(configPath, 'utf-8')
|
|
const { updateConfigYaml } = await loadHelpers()
|
|
|
|
const result = await updateConfigYaml((cfg) => ({ data: cfg, result: 'unchanged', write: false }))
|
|
|
|
expect(result).toBe('unchanged')
|
|
await expect(readFile(configPath, 'utf-8')).resolves.toBe(before)
|
|
await expect(readFile(`${configPath}.bak`, 'utf-8')).rejects.toMatchObject({ code: 'ENOENT' })
|
|
})
|
|
|
|
it('strips api_server config before gateway restart', async () => {
|
|
const { stripLegacyApiServerGatewayConfig } = await loadHelpers()
|
|
const result = stripLegacyApiServerGatewayConfig({
|
|
model: { default: 'glm-5.1' },
|
|
platforms: {
|
|
api_server: {
|
|
enabled: true,
|
|
key: '',
|
|
cors_origins: '*',
|
|
extra: {
|
|
port: 8642,
|
|
host: '127.0.0.1',
|
|
},
|
|
},
|
|
feishu: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
})
|
|
|
|
expect(result.changed).toBe(true)
|
|
expect(result.config).toEqual({
|
|
model: { default: 'glm-5.1' },
|
|
platforms: {
|
|
feishu: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
})
|
|
})
|
|
|
|
it('removes custom api_server fields as well', async () => {
|
|
const { stripLegacyApiServerGatewayConfig } = await loadHelpers()
|
|
const result = stripLegacyApiServerGatewayConfig({
|
|
platforms: {
|
|
api_server: {
|
|
key: 'custom-key',
|
|
cors_origins: 'https://example.com',
|
|
extra: {
|
|
port: 8642,
|
|
host: '127.0.0.1',
|
|
mode: 'custom',
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
expect(result.changed).toBe(true)
|
|
expect(result.config).toEqual({})
|
|
})
|
|
})
|