@studio-foundation/api 0.3.0-beta.1 → 0.3.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -4
- package/ARCHITECTURE.md +0 -52
- package/src/.gitkeep +0 -0
- package/src/api.ts +0 -8
- package/src/bootstrap.ts +0 -259
- package/src/event-bus.ts +0 -64
- package/src/index.ts +0 -58
- package/src/integration-runtime.ts +0 -180
- package/src/integration-store.ts +0 -125
- package/src/integrations/linear/failure-handler.ts +0 -93
- package/src/integrations/linear/webhook-handler.ts +0 -156
- package/src/integrations/registry.ts +0 -12
- package/src/integrations/types.ts +0 -37
- package/src/launcher.ts +0 -214
- package/src/logger.ts +0 -50
- package/src/plans.ts +0 -43
- package/src/routes/agents.ts +0 -131
- package/src/routes/config.ts +0 -154
- package/src/routes/contracts.ts +0 -205
- package/src/routes/pipelines.ts +0 -146
- package/src/routes/projects.ts +0 -237
- package/src/routes/runs.ts +0 -468
- package/src/routes/skills.ts +0 -136
- package/src/routes/tools.ts +0 -222
- package/src/routes/users.ts +0 -169
- package/src/routes/validate.ts +0 -196
- package/src/routes/webhooks.ts +0 -120
- package/src/server.ts +0 -155
- package/src/spawners/http-api-spawner.ts +0 -96
- package/src/user-store-pg.ts +0 -138
- package/src/user-store.ts +0 -125
- package/src/utils/repo-resolver.ts +0 -3
- package/src/webhook-dispatcher.ts +0 -142
- package/src/webhook-store.ts +0 -141
- package/tests/agents.test.ts +0 -164
- package/tests/cancel.test.ts +0 -120
- package/tests/config.test.ts +0 -196
- package/tests/contracts.test.ts +0 -302
- package/tests/event-bus.test.ts +0 -53
- package/tests/http-api-spawner.test.ts +0 -158
- package/tests/integration-runtime.test.ts +0 -199
- package/tests/integration-store.test.ts +0 -66
- package/tests/integrations/linear/failure-handler.test.ts +0 -113
- package/tests/integrations/linear/webhook-handler.test.ts +0 -191
- package/tests/launcher.test.ts +0 -380
- package/tests/linear-webhook.test.ts +0 -390
- package/tests/pipelines.test.ts +0 -166
- package/tests/projects.test.ts +0 -283
- package/tests/runs.test.ts +0 -379
- package/tests/server.test.ts +0 -208
- package/tests/skills.test.ts +0 -149
- package/tests/sse.test.ts +0 -129
- package/tests/tools.test.ts +0 -233
- package/tests/user-store.test.ts +0 -93
- package/tests/users.test.ts +0 -189
- package/tests/utils/repo-resolver.test.ts +0 -105
- package/tests/validate.test.ts +0 -233
- package/tests/webhook-dispatcher.test.ts +0 -214
- package/tests/webhook-store.test.ts +0 -98
- package/tests/webhooks.test.ts +0 -176
- package/tsconfig.json +0 -24
- package/vitest.config.ts +0 -7
package/tests/agents.test.ts
DELETED
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
-
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
3
|
-
import { resolve } from 'node:path';
|
|
4
|
-
import { buildServer } from '../src/server.js';
|
|
5
|
-
import { InMemoryRunStore } from '@studio-foundation/engine';
|
|
6
|
-
import type { IntegrationRuntime } from '../src/integration-runtime.js';
|
|
7
|
-
import type { IntegrationStore } from '../src/integration-store.js';
|
|
8
|
-
|
|
9
|
-
const TMP = resolve('/tmp', `.studio-agents-test-${Date.now()}`);
|
|
10
|
-
const AGENTS_DIR = resolve(TMP, 'agents');
|
|
11
|
-
|
|
12
|
-
const nullIntegrationRuntime = { registerRoutes: () => {} } as unknown as IntegrationRuntime;
|
|
13
|
-
const nullIntegrationStore = {} as unknown as IntegrationStore;
|
|
14
|
-
|
|
15
|
-
function makeServer() {
|
|
16
|
-
return buildServer({
|
|
17
|
-
store: new InMemoryRunStore(),
|
|
18
|
-
launcher: { launch: async () => ({ run_id: 'x' }), cancel: async () => {} },
|
|
19
|
-
configsDir: TMP,
|
|
20
|
-
projectName: 'test-project',
|
|
21
|
-
apiConfig: {},
|
|
22
|
-
studioVersion: '0.0.0-test',
|
|
23
|
-
maskedConfig: { providers: [] },
|
|
24
|
-
integrationRuntime: nullIntegrationRuntime,
|
|
25
|
-
integrationStore: nullIntegrationStore,
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
beforeAll(() => {
|
|
30
|
-
mkdirSync(AGENTS_DIR, { recursive: true });
|
|
31
|
-
writeFileSync(
|
|
32
|
-
resolve(AGENTS_DIR, 'analyst.agent.yaml'),
|
|
33
|
-
'name: analyst\nprovider: anthropic\nmodel: claude-sonnet-4-20250514\n'
|
|
34
|
-
);
|
|
35
|
-
writeFileSync(
|
|
36
|
-
resolve(AGENTS_DIR, 'coder.agent.yaml'),
|
|
37
|
-
'name: coder\nprovider: anthropic\nmodel: claude-sonnet-4-20250514\ntemperature: 0.2\n'
|
|
38
|
-
);
|
|
39
|
-
writeFileSync(resolve(AGENTS_DIR, 'ignored.yaml'), ''); // must be ignored
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
afterAll(() => {
|
|
43
|
-
rmSync(TMP, { recursive: true, force: true });
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
describe('GET /api/agents', () => {
|
|
47
|
-
it('returns only *.agent.yaml files as agent names', async () => {
|
|
48
|
-
const server = makeServer();
|
|
49
|
-
const res = await server.inject({ method: 'GET', url: '/api/agents' });
|
|
50
|
-
expect(res.statusCode).toBe(200);
|
|
51
|
-
const { agents } = res.json() as { agents: string[] };
|
|
52
|
-
expect(agents).toContain('analyst');
|
|
53
|
-
expect(agents).toContain('coder');
|
|
54
|
-
expect(agents).not.toContain('ignored');
|
|
55
|
-
expect(agents).not.toContain('analyst.agent.yaml');
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('returns empty array when agents dir is missing', async () => {
|
|
59
|
-
const server = buildServer({
|
|
60
|
-
store: new InMemoryRunStore(),
|
|
61
|
-
launcher: { launch: async () => ({ run_id: 'x' }), cancel: async () => {} },
|
|
62
|
-
configsDir: resolve('/tmp', `.studio-no-agents-${Date.now()}`),
|
|
63
|
-
projectName: 'test-project',
|
|
64
|
-
apiConfig: {},
|
|
65
|
-
studioVersion: '0.0.0-test',
|
|
66
|
-
maskedConfig: { providers: [] },
|
|
67
|
-
integrationRuntime: nullIntegrationRuntime,
|
|
68
|
-
integrationStore: nullIntegrationStore,
|
|
69
|
-
});
|
|
70
|
-
const res = await server.inject({ method: 'GET', url: '/api/agents' });
|
|
71
|
-
expect(res.statusCode).toBe(200);
|
|
72
|
-
expect((res.json() as { agents: string[] }).agents).toEqual([]);
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
describe('GET /api/agents/:name', () => {
|
|
77
|
-
it('returns parsed agent content as JSON', async () => {
|
|
78
|
-
const server = makeServer();
|
|
79
|
-
const res = await server.inject({ method: 'GET', url: '/api/agents/analyst' });
|
|
80
|
-
expect(res.statusCode).toBe(200);
|
|
81
|
-
const body = res.json() as { name: string; provider: string; model: string };
|
|
82
|
-
expect(body.name).toBe('analyst');
|
|
83
|
-
expect(body.provider).toBe('anthropic');
|
|
84
|
-
expect(body.model).toBe('claude-sonnet-4-20250514');
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it('returns nested fields from YAML', async () => {
|
|
88
|
-
const server = makeServer();
|
|
89
|
-
const res = await server.inject({ method: 'GET', url: '/api/agents/coder' });
|
|
90
|
-
expect(res.statusCode).toBe(200);
|
|
91
|
-
const body = res.json() as { temperature: number };
|
|
92
|
-
expect(body.temperature).toBe(0.2);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('returns 404 for unknown agent', async () => {
|
|
96
|
-
const server = makeServer();
|
|
97
|
-
const res = await server.inject({ method: 'GET', url: '/api/agents/nonexistent' });
|
|
98
|
-
expect(res.statusCode).toBe(404);
|
|
99
|
-
expect((res.json() as { error: string }).error).toBe('Agent not found');
|
|
100
|
-
});
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
describe('PUT /api/agents/:name', () => {
|
|
104
|
-
it('creates a new agent file', async () => {
|
|
105
|
-
const server = makeServer();
|
|
106
|
-
const res = await server.inject({
|
|
107
|
-
method: 'PUT',
|
|
108
|
-
url: '/api/agents/new-agent',
|
|
109
|
-
payload: { name: 'new-agent', provider: 'anthropic', model: 'claude-haiku-4-5-20251001' },
|
|
110
|
-
});
|
|
111
|
-
expect(res.statusCode).toBe(200);
|
|
112
|
-
// Verify it can be read back
|
|
113
|
-
const getRes = await server.inject({ method: 'GET', url: '/api/agents/new-agent' });
|
|
114
|
-
expect(getRes.statusCode).toBe(200);
|
|
115
|
-
expect((getRes.json() as { model: string }).model).toBe('claude-haiku-4-5-20251001');
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it('updates an existing agent', async () => {
|
|
119
|
-
const server = makeServer();
|
|
120
|
-
const res = await server.inject({
|
|
121
|
-
method: 'PUT',
|
|
122
|
-
url: '/api/agents/analyst',
|
|
123
|
-
payload: { name: 'analyst', provider: 'anthropic', model: 'claude-opus-4-6' },
|
|
124
|
-
});
|
|
125
|
-
expect(res.statusCode).toBe(200);
|
|
126
|
-
// Verify model updated
|
|
127
|
-
const getRes = await server.inject({ method: 'GET', url: '/api/agents/analyst' });
|
|
128
|
-
expect((getRes.json() as { model: string }).model).toBe('claude-opus-4-6');
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it('returns 400 when name field is missing', async () => {
|
|
132
|
-
const server = makeServer();
|
|
133
|
-
const res = await server.inject({
|
|
134
|
-
method: 'PUT',
|
|
135
|
-
url: '/api/agents/foo',
|
|
136
|
-
payload: { provider: 'anthropic' },
|
|
137
|
-
});
|
|
138
|
-
expect(res.statusCode).toBe(400);
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
describe('DELETE /api/agents/:name', () => {
|
|
143
|
-
it('deletes an agent and returns 204', async () => {
|
|
144
|
-
const server = makeServer();
|
|
145
|
-
// Create it first via PUT
|
|
146
|
-
await server.inject({
|
|
147
|
-
method: 'PUT',
|
|
148
|
-
url: '/api/agents/to-delete',
|
|
149
|
-
payload: { name: 'to-delete', provider: 'anthropic', model: 'claude-haiku-4-5-20251001' },
|
|
150
|
-
});
|
|
151
|
-
const res = await server.inject({ method: 'DELETE', url: '/api/agents/to-delete' });
|
|
152
|
-
expect(res.statusCode).toBe(204);
|
|
153
|
-
// Verify it's gone
|
|
154
|
-
const getRes = await server.inject({ method: 'GET', url: '/api/agents/to-delete' });
|
|
155
|
-
expect(getRes.statusCode).toBe(404);
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it('returns 404 for nonexistent agent', async () => {
|
|
159
|
-
const server = makeServer();
|
|
160
|
-
const res = await server.inject({ method: 'DELETE', url: '/api/agents/nonexistent' });
|
|
161
|
-
expect(res.statusCode).toBe(404);
|
|
162
|
-
expect((res.json() as { error: string }).error).toBe('Agent not found');
|
|
163
|
-
});
|
|
164
|
-
});
|
package/tests/cancel.test.ts
DELETED
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { buildServer } from '../src/server.js';
|
|
3
|
-
import { InMemoryRunStore } from '@studio-foundation/engine';
|
|
4
|
-
import type { RunLauncher } from '../src/launcher.js';
|
|
5
|
-
import type { PipelineRun } from '@studio-foundation/contracts';
|
|
6
|
-
import type { IntegrationRuntime } from '../src/integration-runtime.js';
|
|
7
|
-
import type { IntegrationStore } from '../src/integration-store.js';
|
|
8
|
-
|
|
9
|
-
function makeRun(overrides: Partial<PipelineRun> = {}): PipelineRun {
|
|
10
|
-
return {
|
|
11
|
-
id: 'run-abc',
|
|
12
|
-
pipeline_name: 'test-pipeline',
|
|
13
|
-
status: 'running',
|
|
14
|
-
started_at: '2026-01-01T10:00:00Z',
|
|
15
|
-
stages: [],
|
|
16
|
-
...overrides,
|
|
17
|
-
} as PipelineRun;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const nullIntegrationRuntime = { registerRoutes: () => {} } as unknown as IntegrationRuntime;
|
|
21
|
-
const nullIntegrationStore = {} as unknown as IntegrationStore;
|
|
22
|
-
|
|
23
|
-
function makeServer(store = new InMemoryRunStore(), launcher?: Partial<RunLauncher>) {
|
|
24
|
-
return buildServer({
|
|
25
|
-
store,
|
|
26
|
-
launcher: {
|
|
27
|
-
launch: vi.fn().mockResolvedValue({ run_id: 'new-run' }),
|
|
28
|
-
cancel: vi.fn().mockResolvedValue(undefined),
|
|
29
|
-
subscribe: vi.fn().mockReturnValue(() => {}),
|
|
30
|
-
...launcher,
|
|
31
|
-
} as RunLauncher,
|
|
32
|
-
configsDir: '/tmp/.studio',
|
|
33
|
-
projectName: 'test',
|
|
34
|
-
apiConfig: {},
|
|
35
|
-
integrationRuntime: nullIntegrationRuntime,
|
|
36
|
-
integrationStore: nullIntegrationStore,
|
|
37
|
-
} as any);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
describe('POST /api/runs/:id/cancel', () => {
|
|
41
|
-
it('returns 200 with run_id when run is running', async () => {
|
|
42
|
-
const store = new InMemoryRunStore();
|
|
43
|
-
const cancelFn = vi.fn().mockResolvedValue(undefined);
|
|
44
|
-
store.savePipelineRun(makeRun({ id: 'run-1', status: 'running' }));
|
|
45
|
-
const server = makeServer(store, { cancel: cancelFn });
|
|
46
|
-
|
|
47
|
-
const res = await server.inject({ method: 'POST', url: '/api/runs/run-1/cancel' });
|
|
48
|
-
|
|
49
|
-
expect(res.statusCode).toBe(200);
|
|
50
|
-
expect(res.json()).toEqual({ run_id: 'run-1' });
|
|
51
|
-
expect(cancelFn).toHaveBeenCalledWith('run-1');
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('returns 404 when run does not exist', async () => {
|
|
55
|
-
const server = makeServer();
|
|
56
|
-
|
|
57
|
-
const res = await server.inject({ method: 'POST', url: '/api/runs/nonexistent/cancel' });
|
|
58
|
-
|
|
59
|
-
expect(res.statusCode).toBe(404);
|
|
60
|
-
expect(res.json().error).toMatch(/not found/i);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('returns 409 when run is already success', async () => {
|
|
64
|
-
const store = new InMemoryRunStore();
|
|
65
|
-
store.savePipelineRun(makeRun({ id: 'run-done', status: 'success' }));
|
|
66
|
-
const server = makeServer(store);
|
|
67
|
-
|
|
68
|
-
const res = await server.inject({ method: 'POST', url: '/api/runs/run-done/cancel' });
|
|
69
|
-
|
|
70
|
-
expect(res.statusCode).toBe(409);
|
|
71
|
-
expect(res.json().error).toMatch(/not cancellable/i);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('returns 409 for failed and cancelled statuses', async () => {
|
|
75
|
-
for (const status of ['failed', 'cancelled'] as const) {
|
|
76
|
-
const store = new InMemoryRunStore();
|
|
77
|
-
store.savePipelineRun(makeRun({ id: 'run-x', status }));
|
|
78
|
-
const server = makeServer(store);
|
|
79
|
-
|
|
80
|
-
const res = await server.inject({ method: 'POST', url: '/api/runs/run-x/cancel' });
|
|
81
|
-
|
|
82
|
-
expect(res.statusCode).toBe(409);
|
|
83
|
-
}
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
describe('DELETE /api/runs/:id', () => {
|
|
88
|
-
it('returns 200 with run_id when run is running', async () => {
|
|
89
|
-
const store = new InMemoryRunStore();
|
|
90
|
-
const cancelFn = vi.fn().mockResolvedValue(undefined);
|
|
91
|
-
store.savePipelineRun(makeRun({ id: 'run-del-1', status: 'running' }));
|
|
92
|
-
const server = makeServer(store, { cancel: cancelFn });
|
|
93
|
-
|
|
94
|
-
const res = await server.inject({ method: 'DELETE', url: '/api/runs/run-del-1' });
|
|
95
|
-
|
|
96
|
-
expect(res.statusCode).toBe(200);
|
|
97
|
-
expect(res.json()).toEqual({ run_id: 'run-del-1' });
|
|
98
|
-
expect(cancelFn).toHaveBeenCalledWith('run-del-1');
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it('returns 404 when run does not exist', async () => {
|
|
102
|
-
const server = makeServer();
|
|
103
|
-
|
|
104
|
-
const res = await server.inject({ method: 'DELETE', url: '/api/runs/nonexistent' });
|
|
105
|
-
|
|
106
|
-
expect(res.statusCode).toBe(404);
|
|
107
|
-
expect(res.json().error).toMatch(/not found/i);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it('returns 409 when run is already terminal', async () => {
|
|
111
|
-
const store = new InMemoryRunStore();
|
|
112
|
-
store.savePipelineRun(makeRun({ id: 'run-done', status: 'success' }));
|
|
113
|
-
const server = makeServer(store);
|
|
114
|
-
|
|
115
|
-
const res = await server.inject({ method: 'DELETE', url: '/api/runs/run-done' });
|
|
116
|
-
|
|
117
|
-
expect(res.statusCode).toBe(409);
|
|
118
|
-
expect(res.json().error).toMatch(/not cancellable/i);
|
|
119
|
-
});
|
|
120
|
-
});
|
package/tests/config.test.ts
DELETED
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
|
|
2
|
-
import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
|
|
3
|
-
import { resolve } from 'node:path';
|
|
4
|
-
import { buildServer } from '../src/server.js';
|
|
5
|
-
import { InMemoryRunStore } from '@studio-foundation/engine';
|
|
6
|
-
import type { IntegrationRuntime } from '../src/integration-runtime.js';
|
|
7
|
-
import type { IntegrationStore } from '../src/integration-store.js';
|
|
8
|
-
|
|
9
|
-
const BASE_CONFIG_YAML = `providers:
|
|
10
|
-
anthropic:
|
|
11
|
-
apiKey: \${ANTHROPIC_API_KEY}
|
|
12
|
-
openai:
|
|
13
|
-
apiKey: \${OPENAI_API_KEY}
|
|
14
|
-
defaults:
|
|
15
|
-
provider: anthropic
|
|
16
|
-
model: claude-sonnet-4-20250514
|
|
17
|
-
`;
|
|
18
|
-
|
|
19
|
-
// Each describe block uses its own dir to avoid cross-test mutation
|
|
20
|
-
const GET_TMP = resolve('/tmp', `.studio-config-get-test-${Date.now()}`);
|
|
21
|
-
const PATCH_TMP = resolve('/tmp', `.studio-config-patch-test-${Date.now()}`);
|
|
22
|
-
const POST_TMP = resolve('/tmp', `.studio-config-post-test-${Date.now()}`);
|
|
23
|
-
|
|
24
|
-
const nullIntegrationRuntime = { registerRoutes: () => {} } as unknown as IntegrationRuntime;
|
|
25
|
-
const nullIntegrationStore = {} as unknown as IntegrationStore;
|
|
26
|
-
|
|
27
|
-
function makeServer(configsDir: string) {
|
|
28
|
-
return buildServer({
|
|
29
|
-
store: new InMemoryRunStore(),
|
|
30
|
-
launcher: { launch: async () => ({ run_id: 'x' }), cancel: async () => {} },
|
|
31
|
-
configsDir,
|
|
32
|
-
projectName: 'test-project',
|
|
33
|
-
apiConfig: {},
|
|
34
|
-
studioVersion: '0.0.0-test',
|
|
35
|
-
maskedConfig: { providers: [] },
|
|
36
|
-
integrationRuntime: nullIntegrationRuntime,
|
|
37
|
-
integrationStore: nullIntegrationStore,
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function writeConfig(dir: string, content = BASE_CONFIG_YAML) {
|
|
42
|
-
mkdirSync(dir, { recursive: true });
|
|
43
|
-
writeFileSync(resolve(dir, 'config.yaml'), content);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
afterAll(() => {
|
|
47
|
-
for (const dir of [GET_TMP, PATCH_TMP, POST_TMP]) {
|
|
48
|
-
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
|
|
49
|
-
}
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
describe('GET /api/config', () => {
|
|
53
|
-
beforeEach(() => writeConfig(GET_TMP));
|
|
54
|
-
|
|
55
|
-
it('returns providers with API keys masked as ***', async () => {
|
|
56
|
-
const server = makeServer(GET_TMP);
|
|
57
|
-
const res = await server.inject({ method: 'GET', url: '/api/config' });
|
|
58
|
-
expect(res.statusCode).toBe(200);
|
|
59
|
-
const body = res.json() as { providers: Record<string, { apiKey: string }> };
|
|
60
|
-
expect(body.providers['anthropic'].apiKey).toBe('***');
|
|
61
|
-
expect(body.providers['openai'].apiKey).toBe('***');
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('returns defaults from config', async () => {
|
|
65
|
-
const server = makeServer(GET_TMP);
|
|
66
|
-
const res = await server.inject({ method: 'GET', url: '/api/config' });
|
|
67
|
-
const body = res.json() as { defaults: { provider: string; model: string } };
|
|
68
|
-
expect(body.defaults.provider).toBe('anthropic');
|
|
69
|
-
expect(body.defaults.model).toBe('claude-sonnet-4-20250514');
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('never exposes raw API key values in the response', async () => {
|
|
73
|
-
const server = makeServer(GET_TMP);
|
|
74
|
-
const res = await server.inject({ method: 'GET', url: '/api/config' });
|
|
75
|
-
// The env var reference itself must not appear — only *** is returned
|
|
76
|
-
expect(res.payload).not.toContain('ANTHROPIC_API_KEY');
|
|
77
|
-
expect(res.payload).not.toContain('OPENAI_API_KEY');
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('returns empty providers when config.yaml does not exist', async () => {
|
|
81
|
-
const emptyDir = resolve('/tmp', `.studio-no-config-${Date.now()}`);
|
|
82
|
-
const server = makeServer(emptyDir);
|
|
83
|
-
const res = await server.inject({ method: 'GET', url: '/api/config' });
|
|
84
|
-
expect(res.statusCode).toBe(200);
|
|
85
|
-
const body = res.json() as { providers: Record<string, unknown> };
|
|
86
|
-
expect(body.providers).toEqual({});
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
describe('PATCH /api/config', () => {
|
|
91
|
-
beforeEach(() => writeConfig(PATCH_TMP));
|
|
92
|
-
|
|
93
|
-
it('updates defaults and returns masked config', async () => {
|
|
94
|
-
const server = makeServer(PATCH_TMP);
|
|
95
|
-
const res = await server.inject({
|
|
96
|
-
method: 'PATCH',
|
|
97
|
-
url: '/api/config',
|
|
98
|
-
payload: { defaults: { provider: 'openai', model: 'gpt-4o' } },
|
|
99
|
-
});
|
|
100
|
-
expect(res.statusCode).toBe(200);
|
|
101
|
-
const body = res.json() as { defaults: { provider: string; model: string } };
|
|
102
|
-
expect(body.defaults.provider).toBe('openai');
|
|
103
|
-
expect(body.defaults.model).toBe('gpt-4o');
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('preserves providers when patching only defaults', async () => {
|
|
107
|
-
const server = makeServer(PATCH_TMP);
|
|
108
|
-
const res = await server.inject({
|
|
109
|
-
method: 'PATCH',
|
|
110
|
-
url: '/api/config',
|
|
111
|
-
payload: { defaults: { provider: 'openai', model: 'gpt-4o' } },
|
|
112
|
-
});
|
|
113
|
-
const body = res.json() as { providers: Record<string, { apiKey: string }> };
|
|
114
|
-
expect(body.providers['anthropic'].apiKey).toBe('***');
|
|
115
|
-
expect(body.providers['openai'].apiKey).toBe('***');
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it('preserves unpatched fields within defaults', async () => {
|
|
119
|
-
const server = makeServer(PATCH_TMP);
|
|
120
|
-
const res = await server.inject({
|
|
121
|
-
method: 'PATCH',
|
|
122
|
-
url: '/api/config',
|
|
123
|
-
payload: { defaults: { model: 'claude-opus-4-6' } },
|
|
124
|
-
});
|
|
125
|
-
expect(res.statusCode).toBe(200);
|
|
126
|
-
const body = res.json() as { defaults: { provider: string; model: string } };
|
|
127
|
-
// model updated
|
|
128
|
-
expect(body.defaults.model).toBe('claude-opus-4-6');
|
|
129
|
-
// provider preserved
|
|
130
|
-
expect(body.defaults.provider).toBe('anthropic');
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it('persists changes so a subsequent GET reflects them', async () => {
|
|
134
|
-
const server = makeServer(PATCH_TMP);
|
|
135
|
-
await server.inject({
|
|
136
|
-
method: 'PATCH',
|
|
137
|
-
url: '/api/config',
|
|
138
|
-
payload: { defaults: { provider: 'openai', model: 'gpt-4o' } },
|
|
139
|
-
});
|
|
140
|
-
const getRes = await server.inject({ method: 'GET', url: '/api/config' });
|
|
141
|
-
const body = getRes.json() as { defaults: { provider: string } };
|
|
142
|
-
expect(body.defaults.provider).toBe('openai');
|
|
143
|
-
});
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
describe('POST /api/config/providers', () => {
|
|
147
|
-
beforeEach(() => writeConfig(POST_TMP));
|
|
148
|
-
|
|
149
|
-
it('adds a provider with apiKey stored as env var reference', async () => {
|
|
150
|
-
const server = makeServer(POST_TMP);
|
|
151
|
-
const res = await server.inject({
|
|
152
|
-
method: 'POST',
|
|
153
|
-
url: '/api/config/providers',
|
|
154
|
-
payload: { provider: 'anthropic', apiKeyEnvVar: 'MY_ANTHROPIC_KEY' },
|
|
155
|
-
});
|
|
156
|
-
expect(res.statusCode).toBe(200);
|
|
157
|
-
const body = res.json() as { providers: Record<string, { apiKey: string }> };
|
|
158
|
-
expect(body.providers['anthropic'].apiKey).toBe('***');
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it('never stores the env var name in plain text in the response', async () => {
|
|
162
|
-
const server = makeServer(POST_TMP);
|
|
163
|
-
const res = await server.inject({
|
|
164
|
-
method: 'POST',
|
|
165
|
-
url: '/api/config/providers',
|
|
166
|
-
payload: { provider: 'anthropic', apiKeyEnvVar: 'MY_ANTHROPIC_KEY' },
|
|
167
|
-
});
|
|
168
|
-
expect(res.payload).not.toContain('MY_ANTHROPIC_KEY');
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it('updates an existing provider', async () => {
|
|
172
|
-
const server = makeServer(POST_TMP);
|
|
173
|
-
const res = await server.inject({
|
|
174
|
-
method: 'POST',
|
|
175
|
-
url: '/api/config/providers',
|
|
176
|
-
payload: { provider: 'openai', apiKeyEnvVar: 'OPENAI_NEW_KEY' },
|
|
177
|
-
});
|
|
178
|
-
expect(res.statusCode).toBe(200);
|
|
179
|
-
// GET should show openai still masked
|
|
180
|
-
const getRes = await server.inject({ method: 'GET', url: '/api/config' });
|
|
181
|
-
const body = getRes.json() as { providers: Record<string, { apiKey: string }> };
|
|
182
|
-
expect(body.providers['openai'].apiKey).toBe('***');
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it('returns 400 for unknown provider', async () => {
|
|
186
|
-
const server = makeServer(POST_TMP);
|
|
187
|
-
const res = await server.inject({
|
|
188
|
-
method: 'POST',
|
|
189
|
-
url: '/api/config/providers',
|
|
190
|
-
payload: { provider: 'unknown-llm', apiKeyEnvVar: 'SOME_KEY' },
|
|
191
|
-
});
|
|
192
|
-
expect(res.statusCode).toBe(400);
|
|
193
|
-
const body = res.json() as { error: string };
|
|
194
|
-
expect(body.error).toBeTruthy();
|
|
195
|
-
});
|
|
196
|
-
});
|