profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

main default branch 105 files Expires Sep 13, 2026, 9:06 AM
config.test.ts 4,965 bytes
1 import { mkdtemp, readFile, rm } from 'node:fs/promises'
2 import { tmpdir } from 'node:os'
3 import path from 'node:path'
4 import type { FastifyInstance } from 'fastify'
5 import { afterEach, beforeEach, describe, expect, it } from 'vitest'
6 import type { ConfigStatus, ConfigUpdateResponse } from '../../shared/types'
7 import { buildApp } from '../app'
8 import { createLlmMock } from '../providers/llmMock'
9 import { createSttMock } from '../providers/sttMock'
10 import { SessionStore } from '../store/sessionStore'
11 import { isTrustedOrigin } from './config'
12
13 describe('config routes', () => {
14 let storeDir: string
15 let envDir: string
16 let envPath: string
17 let env: NodeJS.ProcessEnv
18 let app: FastifyInstance
19
20 function build(demoLocked = false): FastifyInstance {
21 return buildApp({
22 store: new SessionStore(storeDir),
23 llm: createLlmMock(),
24 stt: createSttMock(),
25 demoLocked,
26 envPath,
27 env,
28 })
29 }
30
31 beforeEach(async () => {
32 storeDir = await mkdtemp(path.join(tmpdir(), 'voicetask-store-'))
33 envDir = await mkdtemp(path.join(tmpdir(), 'voicetask-cfg-'))
34 envPath = path.join(envDir, '.env')
35 env = {}
36 app = build()
37 })
38
39 afterEach(async () => {
40 await app.close()
41 await rm(storeDir, { recursive: true, force: true })
42 await rm(envDir, { recursive: true, force: true })
43 })
44
45 it('reports an unconfigured server without leaking key values', async () => {
46 env.ANTHROPIC_API_KEY = 'sk-ant-value-abcd'
47 const res = await app.inject({ method: 'GET', url: '/api/config' })
48 expect(res.statusCode).toBe(200)
49 const body = res.json<ConfigStatus>()
50 expect(body.mode).toBe('unconfigured')
51 expect(body.anthropicKeySet).toBe(true)
52 expect(body.openaiKeySet).toBe(false)
53 expect(res.payload).not.toContain('sk-ant-value-abcd')
54 expect(body.anthropicKeyHint).toBe('...abcd')
55 })
56
57 it('saves both keys and switches the server to real providers', async () => {
58 const res = await app.inject({
59 method: 'POST',
60 url: '/api/config',
61 payload: { mode: 'real', anthropicKey: 'sk-ant-1234abcd', openaiKey: 'sk-1234wxyz' },
62 })
63 expect(res.statusCode).toBe(200)
64 const body = res.json<ConfigUpdateResponse>()
65 expect(body.status.mode).toBe('real')
66 expect(body.restartRequired).toBe(false)
67 expect(env.ANTHROPIC_API_KEY).toBe('sk-ant-1234abcd')
68 expect(await readFile(envPath, 'utf8')).toContain('OPENAI_API_KEY=sk-1234wxyz')
69 })
70
71 it('switches to the offline demo without any key', async () => {
72 const res = await app.inject({ method: 'POST', url: '/api/config', payload: { mode: 'demo' } })
73 expect(res.statusCode).toBe(200)
74 expect(res.json<ConfigUpdateResponse>().status.mode).toBe('demo')
75 expect(env.MOCK_PROVIDERS).toBe('1')
76 })
77
78 it('rejects real mode when a key is still missing', async () => {
79 const res = await app.inject({
80 method: 'POST',
81 url: '/api/config',
82 payload: { mode: 'real', anthropicKey: 'sk-ant-1234abcd' },
83 })
84 expect(res.statusCode).toBe(400)
85 expect(res.json<{ error: string }>().error).toContain('OpenAI')
86 expect(env.ANTHROPIC_API_KEY).toBeUndefined()
87 })
88
89 it('rejects a key that contains spaces', async () => {
90 const res = await app.inject({
91 method: 'POST',
92 url: '/api/config',
93 payload: { mode: 'real', anthropicKey: 'sk-ant abcd', openaiKey: 'sk-1234wxyz' },
94 })
95 expect(res.statusCode).toBe(400)
96 })
97
98 it('keeps a demo-started server on mocks and asks for a restart', async () => {
99 await app.close()
100 app = build(true)
101 env.MOCK_PROVIDERS = '1'
102
103 const res = await app.inject({
104 method: 'POST',
105 url: '/api/config',
106 payload: { mode: 'real', anthropicKey: 'sk-ant-1234abcd', openaiKey: 'sk-1234wxyz' },
107 })
108 expect(res.statusCode).toBe(200)
109 const body = res.json<ConfigUpdateResponse>()
110 expect(body.restartRequired).toBe(true)
111 expect(body.status.mode).toBe('demo')
112 expect(env.ANTHROPIC_API_KEY).toBeUndefined()
113 })
114
115 it('refuses writes from another origin', async () => {
116 const res = await app.inject({
117 method: 'POST',
118 url: '/api/config',
119 headers: { origin: 'https://example.com', host: 'localhost:3001' },
120 payload: { mode: 'demo' },
121 })
122 expect(res.statusCode).toBe(403)
123 expect(env.MOCK_PROVIDERS).toBeUndefined()
124 })
125
126 it('accepts local and same-host origins only', () => {
127 expect(isTrustedOrigin(undefined, 'localhost:3001')).toBe(true)
128 expect(isTrustedOrigin('http://localhost:5173', 'localhost:3001')).toBe(true)
129 expect(isTrustedOrigin('http://127.0.0.1:3001', 'localhost:3001')).toBe(true)
130 expect(isTrustedOrigin('http://192.168.1.4:3001', '192.168.1.4:3001')).toBe(true)
131 expect(isTrustedOrigin('http://192.168.1.4:3001', 'localhost:3001')).toBe(false)
132 expect(isTrustedOrigin('https://example.com', 'localhost:3001')).toBe(false)
133 expect(isTrustedOrigin('not-a-url', 'localhost:3001')).toBe(false)
134 })
135 })
136