@studio-foundation/api 0.3.0-beta.1
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/ARCHITECTURE.md +52 -0
- package/LICENSE +663 -0
- package/README.md +161 -0
- package/dist/api.d.ts +8 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +6 -0
- package/dist/api.js.map +1 -0
- package/dist/bootstrap.d.ts +66 -0
- package/dist/bootstrap.d.ts.map +1 -0
- package/dist/bootstrap.js +193 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/event-bus.d.ts +16 -0
- package/dist/event-bus.d.ts.map +1 -0
- package/dist/event-bus.js +37 -0
- package/dist/event-bus.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +52 -0
- package/dist/index.js.map +1 -0
- package/dist/integration-runtime.d.ts +22 -0
- package/dist/integration-runtime.d.ts.map +1 -0
- package/dist/integration-runtime.js +150 -0
- package/dist/integration-runtime.js.map +1 -0
- package/dist/integration-store.d.ts +26 -0
- package/dist/integration-store.d.ts.map +1 -0
- package/dist/integration-store.js +80 -0
- package/dist/integration-store.js.map +1 -0
- package/dist/integrations/linear/failure-handler.d.ts +5 -0
- package/dist/integrations/linear/failure-handler.d.ts.map +1 -0
- package/dist/integrations/linear/failure-handler.js +73 -0
- package/dist/integrations/linear/failure-handler.js.map +1 -0
- package/dist/integrations/linear/webhook-handler.d.ts +6 -0
- package/dist/integrations/linear/webhook-handler.d.ts.map +1 -0
- package/dist/integrations/linear/webhook-handler.js +131 -0
- package/dist/integrations/linear/webhook-handler.js.map +1 -0
- package/dist/integrations/registry.d.ts +4 -0
- package/dist/integrations/registry.d.ts.map +1 -0
- package/dist/integrations/registry.js +9 -0
- package/dist/integrations/registry.js.map +1 -0
- package/dist/integrations/types.d.ts +33 -0
- package/dist/integrations/types.d.ts.map +1 -0
- package/dist/integrations/types.js +2 -0
- package/dist/integrations/types.js.map +1 -0
- package/dist/launcher.d.ts +45 -0
- package/dist/launcher.d.ts.map +1 -0
- package/dist/launcher.js +149 -0
- package/dist/launcher.js.map +1 -0
- package/dist/logger.d.ts +7 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +37 -0
- package/dist/logger.js.map +1 -0
- package/dist/plans.d.ts +13 -0
- package/dist/plans.d.ts.map +1 -0
- package/dist/plans.js +33 -0
- package/dist/plans.js.map +1 -0
- package/dist/routes/agents.d.ts +6 -0
- package/dist/routes/agents.d.ts.map +1 -0
- package/dist/routes/agents.js +118 -0
- package/dist/routes/agents.js.map +1 -0
- package/dist/routes/config.d.ts +6 -0
- package/dist/routes/config.d.ts.map +1 -0
- package/dist/routes/config.js +129 -0
- package/dist/routes/config.js.map +1 -0
- package/dist/routes/contracts.d.ts +6 -0
- package/dist/routes/contracts.d.ts.map +1 -0
- package/dist/routes/contracts.js +188 -0
- package/dist/routes/contracts.js.map +1 -0
- package/dist/routes/pipelines.d.ts +6 -0
- package/dist/routes/pipelines.d.ts.map +1 -0
- package/dist/routes/pipelines.js +133 -0
- package/dist/routes/pipelines.js.map +1 -0
- package/dist/routes/projects.d.ts +6 -0
- package/dist/routes/projects.d.ts.map +1 -0
- package/dist/routes/projects.js +220 -0
- package/dist/routes/projects.js.map +1 -0
- package/dist/routes/runs.d.ts +6 -0
- package/dist/routes/runs.d.ts.map +1 -0
- package/dist/routes/runs.js +427 -0
- package/dist/routes/runs.js.map +1 -0
- package/dist/routes/skills.d.ts +6 -0
- package/dist/routes/skills.d.ts.map +1 -0
- package/dist/routes/skills.js +123 -0
- package/dist/routes/skills.js.map +1 -0
- package/dist/routes/tools.d.ts +6 -0
- package/dist/routes/tools.d.ts.map +1 -0
- package/dist/routes/tools.js +193 -0
- package/dist/routes/tools.js.map +1 -0
- package/dist/routes/users.d.ts +6 -0
- package/dist/routes/users.d.ts.map +1 -0
- package/dist/routes/users.js +152 -0
- package/dist/routes/users.js.map +1 -0
- package/dist/routes/validate.d.ts +6 -0
- package/dist/routes/validate.d.ts.map +1 -0
- package/dist/routes/validate.js +171 -0
- package/dist/routes/validate.js.map +1 -0
- package/dist/routes/webhooks.d.ts +8 -0
- package/dist/routes/webhooks.d.ts.map +1 -0
- package/dist/routes/webhooks.js +102 -0
- package/dist/routes/webhooks.js.map +1 -0
- package/dist/server.d.ts +46 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +101 -0
- package/dist/server.js.map +1 -0
- package/dist/spawners/http-api-spawner.d.ts +10 -0
- package/dist/spawners/http-api-spawner.d.ts.map +1 -0
- package/dist/spawners/http-api-spawner.js +88 -0
- package/dist/spawners/http-api-spawner.js.map +1 -0
- package/dist/user-store-pg.d.ts +21 -0
- package/dist/user-store-pg.d.ts.map +1 -0
- package/dist/user-store-pg.js +97 -0
- package/dist/user-store-pg.js.map +1 -0
- package/dist/user-store.d.ts +29 -0
- package/dist/user-store.d.ts.map +1 -0
- package/dist/user-store.js +97 -0
- package/dist/user-store.js.map +1 -0
- package/dist/utils/repo-resolver.d.ts +3 -0
- package/dist/utils/repo-resolver.d.ts.map +1 -0
- package/dist/utils/repo-resolver.js +3 -0
- package/dist/utils/repo-resolver.js.map +1 -0
- package/dist/webhook-dispatcher.d.ts +13 -0
- package/dist/webhook-dispatcher.d.ts.map +1 -0
- package/dist/webhook-dispatcher.js +108 -0
- package/dist/webhook-dispatcher.js.map +1 -0
- package/dist/webhook-store.d.ts +31 -0
- package/dist/webhook-store.d.ts.map +1 -0
- package/dist/webhook-store.js +91 -0
- package/dist/webhook-store.js.map +1 -0
- package/package.json +59 -0
- package/src/.gitkeep +0 -0
- package/src/api.ts +8 -0
- package/src/bootstrap.ts +259 -0
- package/src/event-bus.ts +64 -0
- package/src/index.ts +58 -0
- package/src/integration-runtime.ts +180 -0
- package/src/integration-store.ts +125 -0
- package/src/integrations/linear/failure-handler.ts +93 -0
- package/src/integrations/linear/webhook-handler.ts +156 -0
- package/src/integrations/registry.ts +12 -0
- package/src/integrations/types.ts +37 -0
- package/src/launcher.ts +214 -0
- package/src/logger.ts +50 -0
- package/src/plans.ts +43 -0
- package/src/routes/agents.ts +131 -0
- package/src/routes/config.ts +154 -0
- package/src/routes/contracts.ts +205 -0
- package/src/routes/pipelines.ts +146 -0
- package/src/routes/projects.ts +237 -0
- package/src/routes/runs.ts +468 -0
- package/src/routes/skills.ts +136 -0
- package/src/routes/tools.ts +222 -0
- package/src/routes/users.ts +169 -0
- package/src/routes/validate.ts +196 -0
- package/src/routes/webhooks.ts +120 -0
- package/src/server.ts +155 -0
- package/src/spawners/http-api-spawner.ts +96 -0
- package/src/user-store-pg.ts +138 -0
- package/src/user-store.ts +125 -0
- package/src/utils/repo-resolver.ts +3 -0
- package/src/webhook-dispatcher.ts +142 -0
- package/src/webhook-store.ts +141 -0
- package/tests/agents.test.ts +164 -0
- package/tests/cancel.test.ts +120 -0
- package/tests/config.test.ts +196 -0
- package/tests/contracts.test.ts +302 -0
- package/tests/event-bus.test.ts +53 -0
- package/tests/http-api-spawner.test.ts +158 -0
- package/tests/integration-runtime.test.ts +199 -0
- package/tests/integration-store.test.ts +66 -0
- package/tests/integrations/linear/failure-handler.test.ts +113 -0
- package/tests/integrations/linear/webhook-handler.test.ts +191 -0
- package/tests/launcher.test.ts +380 -0
- package/tests/linear-webhook.test.ts +390 -0
- package/tests/pipelines.test.ts +166 -0
- package/tests/projects.test.ts +283 -0
- package/tests/runs.test.ts +379 -0
- package/tests/server.test.ts +208 -0
- package/tests/skills.test.ts +149 -0
- package/tests/sse.test.ts +129 -0
- package/tests/tools.test.ts +233 -0
- package/tests/user-store.test.ts +93 -0
- package/tests/users.test.ts +189 -0
- package/tests/utils/repo-resolver.test.ts +105 -0
- package/tests/validate.test.ts +233 -0
- package/tests/webhook-dispatcher.test.ts +214 -0
- package/tests/webhook-store.test.ts +98 -0
- package/tests/webhooks.test.ts +176 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,233 @@
|
|
|
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-api-tools-test-${Date.now()}`);
|
|
10
|
+
const TOOLS_DIR = resolve(TMP, 'tools');
|
|
11
|
+
|
|
12
|
+
const CUSTOM_TOOL_YAML = `name: my-custom-tool
|
|
13
|
+
description: A custom tool for testing
|
|
14
|
+
version: 1
|
|
15
|
+
commands:
|
|
16
|
+
- name: my-custom-tool-do_something
|
|
17
|
+
description: Does something useful
|
|
18
|
+
parameters: {}
|
|
19
|
+
execute:
|
|
20
|
+
type: shell
|
|
21
|
+
command: echo hello
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
const nullIntegrationRuntime = { registerRoutes: () => {} } as unknown as IntegrationRuntime;
|
|
25
|
+
const nullIntegrationStore = {} as unknown as IntegrationStore;
|
|
26
|
+
|
|
27
|
+
function makeServer() {
|
|
28
|
+
return buildServer({
|
|
29
|
+
store: new InMemoryRunStore(),
|
|
30
|
+
launcher: { launch: async () => ({ run_id: 'x' }), cancel: async () => {} },
|
|
31
|
+
configsDir: TMP,
|
|
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
|
+
beforeAll(() => {
|
|
42
|
+
mkdirSync(TOOLS_DIR, { recursive: true });
|
|
43
|
+
writeFileSync(resolve(TOOLS_DIR, 'my-custom-tool.tool.yaml'), CUSTOM_TOOL_YAML);
|
|
44
|
+
writeFileSync(resolve(TOOLS_DIR, 'ignored.yaml'), ''); // must be ignored
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterAll(() => {
|
|
48
|
+
rmSync(TMP, { recursive: true, force: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('GET /api/tools', () => {
|
|
52
|
+
it('returns builtin tools with is_builtin: true', async () => {
|
|
53
|
+
const server = makeServer();
|
|
54
|
+
const res = await server.inject({ method: 'GET', url: '/api/tools' });
|
|
55
|
+
expect(res.statusCode).toBe(200);
|
|
56
|
+
const { tools } = res.json() as { tools: { name: string; is_builtin: boolean }[] };
|
|
57
|
+
const builtins = tools.filter(t => t.is_builtin);
|
|
58
|
+
expect(builtins.map(t => t.name)).toContain('shell');
|
|
59
|
+
expect(builtins.map(t => t.name)).toContain('repo-manager');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns custom tools with is_builtin: false', async () => {
|
|
63
|
+
const server = makeServer();
|
|
64
|
+
const res = await server.inject({ method: 'GET', url: '/api/tools' });
|
|
65
|
+
expect(res.statusCode).toBe(200);
|
|
66
|
+
const { tools } = res.json() as { tools: { name: string; is_builtin: boolean }[] };
|
|
67
|
+
const custom = tools.find(t => t.name === 'my-custom-tool');
|
|
68
|
+
expect(custom).toBeDefined();
|
|
69
|
+
expect(custom!.is_builtin).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('does not include non-.tool.yaml files', async () => {
|
|
73
|
+
const server = makeServer();
|
|
74
|
+
const res = await server.inject({ method: 'GET', url: '/api/tools' });
|
|
75
|
+
const { tools } = res.json() as { tools: { name: string }[] };
|
|
76
|
+
expect(tools.map(t => t.name)).not.toContain('ignored');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('returns builtins even when tools dir is missing', async () => {
|
|
80
|
+
const emptyServer = buildServer({
|
|
81
|
+
store: new InMemoryRunStore(),
|
|
82
|
+
launcher: { launch: async () => ({ run_id: 'x' }), cancel: async () => {} },
|
|
83
|
+
configsDir: resolve('/tmp', `.studio-api-no-tools-${Date.now()}`),
|
|
84
|
+
projectName: 'empty',
|
|
85
|
+
apiConfig: {},
|
|
86
|
+
studioVersion: '0.0.0-test',
|
|
87
|
+
maskedConfig: { providers: [] },
|
|
88
|
+
integrationRuntime: nullIntegrationRuntime,
|
|
89
|
+
integrationStore: nullIntegrationStore,
|
|
90
|
+
});
|
|
91
|
+
const res = await emptyServer.inject({ method: 'GET', url: '/api/tools' });
|
|
92
|
+
expect(res.statusCode).toBe(200);
|
|
93
|
+
const { tools } = res.json() as { tools: { name: string; is_builtin: boolean }[] };
|
|
94
|
+
expect(tools.some(t => t.is_builtin)).toBe(true);
|
|
95
|
+
expect(tools.every(t => t.name !== 'my-custom-tool')).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('GET /api/tools/:name', () => {
|
|
100
|
+
it('returns parsed YAML for a custom tool', async () => {
|
|
101
|
+
const server = makeServer();
|
|
102
|
+
const res = await server.inject({ method: 'GET', url: '/api/tools/my-custom-tool' });
|
|
103
|
+
expect(res.statusCode).toBe(200);
|
|
104
|
+
const body = res.json() as { name: string; version: number; is_builtin: boolean };
|
|
105
|
+
expect(body.name).toBe('my-custom-tool');
|
|
106
|
+
expect(body.version).toBe(1);
|
|
107
|
+
expect(body.is_builtin).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('returns builtin definition for a known builtin', async () => {
|
|
111
|
+
const server = makeServer();
|
|
112
|
+
const res = await server.inject({ method: 'GET', url: '/api/tools/shell' });
|
|
113
|
+
expect(res.statusCode).toBe(200);
|
|
114
|
+
const body = res.json() as { name: string; is_builtin: boolean; prompt_snippet?: string };
|
|
115
|
+
expect(body.name).toBe('shell');
|
|
116
|
+
expect(body.is_builtin).toBe(true);
|
|
117
|
+
expect(body.prompt_snippet).toBeTruthy();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('returns 404 for unknown tool', async () => {
|
|
121
|
+
const server = makeServer();
|
|
122
|
+
const res = await server.inject({ method: 'GET', url: '/api/tools/nonexistent' });
|
|
123
|
+
expect(res.statusCode).toBe(404);
|
|
124
|
+
expect((res.json() as { error: string }).error).toBeTruthy();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('PUT /api/tools/:name', () => {
|
|
129
|
+
it('creates a new custom tool from YAML body', async () => {
|
|
130
|
+
const server = makeServer();
|
|
131
|
+
const yamlBody = `name: new-tool\ndescription: test\nversion: 1\ncommands: []\n`;
|
|
132
|
+
const res = await server.inject({
|
|
133
|
+
method: 'PUT',
|
|
134
|
+
url: '/api/tools/new-tool',
|
|
135
|
+
headers: { 'content-type': 'text/plain' },
|
|
136
|
+
payload: yamlBody,
|
|
137
|
+
});
|
|
138
|
+
expect(res.statusCode).toBe(200);
|
|
139
|
+
expect((res.json() as { name: string }).name).toBe('new-tool');
|
|
140
|
+
const getRes = await server.inject({ method: 'GET', url: '/api/tools/new-tool' });
|
|
141
|
+
expect(getRes.statusCode).toBe(200);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('creates a new custom tool from JSON body', async () => {
|
|
145
|
+
const server = makeServer();
|
|
146
|
+
const res = await server.inject({
|
|
147
|
+
method: 'PUT',
|
|
148
|
+
url: '/api/tools/json-tool',
|
|
149
|
+
headers: { 'content-type': 'application/json' },
|
|
150
|
+
payload: { name: 'json-tool', version: 1, commands: [] },
|
|
151
|
+
});
|
|
152
|
+
expect(res.statusCode).toBe(200);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('returns 400 for invalid YAML body', async () => {
|
|
156
|
+
const server = makeServer();
|
|
157
|
+
const res = await server.inject({
|
|
158
|
+
method: 'PUT',
|
|
159
|
+
url: '/api/tools/bad-tool',
|
|
160
|
+
headers: { 'content-type': 'text/plain' },
|
|
161
|
+
payload: 'key: [unclosed\n - item',
|
|
162
|
+
});
|
|
163
|
+
expect(res.statusCode).toBe(400);
|
|
164
|
+
expect((res.json() as { error: string }).error).toBeTruthy();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('DELETE /api/tools/:name', () => {
|
|
169
|
+
it('deletes an existing custom tool', async () => {
|
|
170
|
+
const server = makeServer();
|
|
171
|
+
await server.inject({
|
|
172
|
+
method: 'PUT',
|
|
173
|
+
url: '/api/tools/to-delete',
|
|
174
|
+
headers: { 'content-type': 'text/plain' },
|
|
175
|
+
payload: 'name: to-delete\nversion: 1\ncommands: []\n',
|
|
176
|
+
});
|
|
177
|
+
const res = await server.inject({ method: 'DELETE', url: '/api/tools/to-delete' });
|
|
178
|
+
expect(res.statusCode).toBe(200);
|
|
179
|
+
const getRes = await server.inject({ method: 'GET', url: '/api/tools/to-delete' });
|
|
180
|
+
expect(getRes.statusCode).toBe(404);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('returns 403 when trying to delete a builtin tool', async () => {
|
|
184
|
+
const server = makeServer();
|
|
185
|
+
const res = await server.inject({ method: 'DELETE', url: '/api/tools/shell' });
|
|
186
|
+
expect(res.statusCode).toBe(403);
|
|
187
|
+
expect((res.json() as { error: string }).error).toMatch(/builtin/i);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('returns 404 for nonexistent custom tool', async () => {
|
|
191
|
+
const server = makeServer();
|
|
192
|
+
const res = await server.inject({ method: 'DELETE', url: '/api/tools/ghost-tool' });
|
|
193
|
+
expect(res.statusCode).toBe(404);
|
|
194
|
+
expect((res.json() as { error: string }).error).toBeTruthy();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('POST /api/tools/install', () => {
|
|
199
|
+
it('installs a bundled tool by name', async () => {
|
|
200
|
+
const server = makeServer();
|
|
201
|
+
const res = await server.inject({
|
|
202
|
+
method: 'POST',
|
|
203
|
+
url: '/api/tools/install',
|
|
204
|
+
payload: { name: 'shell' },
|
|
205
|
+
});
|
|
206
|
+
expect(res.statusCode).toBe(200);
|
|
207
|
+
expect((res.json() as { installed: string }).installed).toBe('shell');
|
|
208
|
+
const getRes = await server.inject({ method: 'GET', url: '/api/tools/shell' });
|
|
209
|
+
expect(getRes.statusCode).toBe(200);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('returns 409 if tool is already installed', async () => {
|
|
213
|
+
const server = makeServer();
|
|
214
|
+
await server.inject({ method: 'POST', url: '/api/tools/install', payload: { name: 'git' } });
|
|
215
|
+
const res = await server.inject({
|
|
216
|
+
method: 'POST',
|
|
217
|
+
url: '/api/tools/install',
|
|
218
|
+
payload: { name: 'git' },
|
|
219
|
+
});
|
|
220
|
+
expect(res.statusCode).toBe(409);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('returns 404 for unknown tool name', async () => {
|
|
224
|
+
const server = makeServer();
|
|
225
|
+
const res = await server.inject({
|
|
226
|
+
method: 'POST',
|
|
227
|
+
url: '/api/tools/install',
|
|
228
|
+
payload: { name: 'nonexistent-tool' },
|
|
229
|
+
});
|
|
230
|
+
expect(res.statusCode).toBe(404);
|
|
231
|
+
expect((res.json() as { error: string }).error).toBeTruthy();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { mkdirSync } from 'node:fs';
|
|
4
|
+
import { UserStore, type User } from '../src/user-store.js';
|
|
5
|
+
|
|
6
|
+
function makeUser(overrides: Partial<User> = {}): User {
|
|
7
|
+
return {
|
|
8
|
+
id: 'user-1',
|
|
9
|
+
email: 'alice@example.com',
|
|
10
|
+
plan: 'free',
|
|
11
|
+
api_key: 'sk-test-abc123',
|
|
12
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
13
|
+
...overrides,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('UserStore', () => {
|
|
18
|
+
let store: UserStore;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
const dbDir = resolve('/tmp', `.studio-user-store-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
22
|
+
mkdirSync(dbDir, { recursive: true });
|
|
23
|
+
store = new UserStore(resolve(dbDir, 'runs.db'));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
store.close();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// CRUD
|
|
31
|
+
test('saves and retrieves a user by id', () => {
|
|
32
|
+
const user = makeUser();
|
|
33
|
+
store.saveUser(user);
|
|
34
|
+
expect(store.getUserById('user-1')).toEqual(user);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('retrieves a user by api_key', () => {
|
|
38
|
+
const user = makeUser();
|
|
39
|
+
store.saveUser(user);
|
|
40
|
+
expect(store.getUserByApiKey('sk-test-abc123')).toEqual(user);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('returns null for unknown id', () => {
|
|
44
|
+
expect(store.getUserById('nonexistent')).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('returns null for unknown api_key', () => {
|
|
48
|
+
expect(store.getUserByApiKey('sk-unknown')).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('lists all users', () => {
|
|
52
|
+
store.saveUser(makeUser({ id: 'u1', email: 'a@a.com', api_key: 'key-1' }));
|
|
53
|
+
store.saveUser(makeUser({ id: 'u2', email: 'b@b.com', api_key: 'key-2' }));
|
|
54
|
+
expect(store.listUsers()).toHaveLength(2);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('deletes a user', () => {
|
|
58
|
+
store.saveUser(makeUser());
|
|
59
|
+
store.deleteUser('user-1');
|
|
60
|
+
expect(store.getUserById('user-1')).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Usage tracking
|
|
64
|
+
test('getDailyUsage returns zeros for new entry', () => {
|
|
65
|
+
const usage = store.getDailyUsage('user-1', '2026-01-01');
|
|
66
|
+
expect(usage.runs_count).toBe(0);
|
|
67
|
+
expect(usage.tokens_used).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('incrementRuns increases runs_count by 1', () => {
|
|
71
|
+
store.incrementRuns('user-1', '2026-01-01');
|
|
72
|
+
store.incrementRuns('user-1', '2026-01-01');
|
|
73
|
+
expect(store.getDailyUsage('user-1', '2026-01-01').runs_count).toBe(2);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('incrementTokens increases tokens_used', () => {
|
|
77
|
+
store.incrementTokens('user-1', '2026-01-01', 1000);
|
|
78
|
+
store.incrementTokens('user-1', '2026-01-01', 500);
|
|
79
|
+
expect(store.getDailyUsage('user-1', '2026-01-01').tokens_used).toBe(1500);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('usage is scoped by date — different dates are independent', () => {
|
|
83
|
+
store.incrementRuns('user-1', '2026-01-01');
|
|
84
|
+
store.incrementRuns('user-1', '2026-01-02');
|
|
85
|
+
expect(store.getDailyUsage('user-1', '2026-01-01').runs_count).toBe(1);
|
|
86
|
+
expect(store.getDailyUsage('user-1', '2026-01-02').runs_count).toBe(1);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('usage is scoped by user — different users are independent', () => {
|
|
90
|
+
store.incrementRuns('user-1', '2026-01-01');
|
|
91
|
+
expect(store.getDailyUsage('user-2', '2026-01-01').runs_count).toBe(0);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// api/tests/users.test.ts
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
import { mkdirSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { buildServer } from '../src/server.js';
|
|
6
|
+
import { InMemoryRunStore } from '@studio-foundation/engine';
|
|
7
|
+
import type { RunLauncher } from '../src/launcher.js';
|
|
8
|
+
import type { IntegrationRuntime } from '../src/integration-runtime.js';
|
|
9
|
+
import type { IntegrationStore } from '../src/integration-store.js';
|
|
10
|
+
import { UserStore } from '../src/user-store.js';
|
|
11
|
+
import { DEFAULT_PLANS } from '../src/plans.js';
|
|
12
|
+
|
|
13
|
+
function makeMockLauncher(): RunLauncher {
|
|
14
|
+
return { launch: async () => ({ run_id: 'mock-run-id' }), cancel: async () => {}, subscribe: () => () => {} };
|
|
15
|
+
}
|
|
16
|
+
const nullIntegrationRuntime = { registerRoutes: () => {} } as unknown as IntegrationRuntime;
|
|
17
|
+
const nullIntegrationStore = {} as unknown as IntegrationStore;
|
|
18
|
+
|
|
19
|
+
function makeTempUserStore() {
|
|
20
|
+
const dir = resolve('/tmp', `.studio-users-route-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
21
|
+
mkdirSync(dir, { recursive: true });
|
|
22
|
+
return new UserStore(resolve(dir, 'runs.db'));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function buildTestServer(userStore: UserStore, apiKey?: string) {
|
|
26
|
+
return buildServer({
|
|
27
|
+
store: new InMemoryRunStore(),
|
|
28
|
+
launcher: makeMockLauncher(),
|
|
29
|
+
configsDir: '/tmp/.studio',
|
|
30
|
+
projectName: 'test',
|
|
31
|
+
apiConfig: apiKey ? { key: apiKey } : {},
|
|
32
|
+
integrationRuntime: nullIntegrationRuntime,
|
|
33
|
+
integrationStore: nullIntegrationStore,
|
|
34
|
+
userStore,
|
|
35
|
+
plans: DEFAULT_PLANS,
|
|
36
|
+
hasUsers: userStore.listUsers().length > 0,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('POST /api/users', () => {
|
|
41
|
+
let userStore: UserStore;
|
|
42
|
+
|
|
43
|
+
beforeEach(() => { userStore = makeTempUserStore(); });
|
|
44
|
+
afterEach(() => { userStore.close(); });
|
|
45
|
+
|
|
46
|
+
it('creates a user and returns api_key', async () => {
|
|
47
|
+
const server = buildTestServer(userStore, 'admin-key');
|
|
48
|
+
const res = await server.inject({
|
|
49
|
+
method: 'POST',
|
|
50
|
+
url: '/api/users',
|
|
51
|
+
headers: { Authorization: 'Bearer admin-key' },
|
|
52
|
+
payload: { email: 'alice@example.com', plan: 'pro' },
|
|
53
|
+
});
|
|
54
|
+
expect(res.statusCode).toBe(201);
|
|
55
|
+
const body = res.json();
|
|
56
|
+
expect(body.email).toBe('alice@example.com');
|
|
57
|
+
expect(body.plan).toBe('pro');
|
|
58
|
+
expect(body.api_key).toBeTruthy();
|
|
59
|
+
expect(body.id).toBeTruthy();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('rejects duplicate email with 409', async () => {
|
|
63
|
+
const server = buildTestServer(userStore, 'admin-key');
|
|
64
|
+
await server.inject({
|
|
65
|
+
method: 'POST',
|
|
66
|
+
url: '/api/users',
|
|
67
|
+
headers: { Authorization: 'Bearer admin-key' },
|
|
68
|
+
payload: { email: 'alice@example.com' },
|
|
69
|
+
});
|
|
70
|
+
const res = await server.inject({
|
|
71
|
+
method: 'POST',
|
|
72
|
+
url: '/api/users',
|
|
73
|
+
headers: { Authorization: 'Bearer admin-key' },
|
|
74
|
+
payload: { email: 'alice@example.com' },
|
|
75
|
+
});
|
|
76
|
+
expect(res.statusCode).toBe(409);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('GET /api/users', () => {
|
|
81
|
+
let userStore: UserStore;
|
|
82
|
+
|
|
83
|
+
beforeEach(() => { userStore = makeTempUserStore(); });
|
|
84
|
+
afterEach(() => { userStore.close(); });
|
|
85
|
+
|
|
86
|
+
it('lists users without exposing api_key', async () => {
|
|
87
|
+
userStore.saveUser({ id: 'u1', email: 'alice@example.com', plan: 'free', api_key: 'secret-key', created_at: '2026-01-01T00:00:00.000Z' });
|
|
88
|
+
const server = buildTestServer(userStore);
|
|
89
|
+
const res = await server.inject({
|
|
90
|
+
method: 'GET',
|
|
91
|
+
url: '/api/users',
|
|
92
|
+
headers: { Authorization: 'Bearer secret-key' },
|
|
93
|
+
});
|
|
94
|
+
expect(res.statusCode).toBe(200);
|
|
95
|
+
const users = res.json();
|
|
96
|
+
expect(users).toHaveLength(1);
|
|
97
|
+
expect(users[0].email).toBe('alice@example.com');
|
|
98
|
+
expect(users[0].api_key).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('GET /api/users/:id', () => {
|
|
103
|
+
let userStore: UserStore;
|
|
104
|
+
|
|
105
|
+
beforeEach(() => { userStore = makeTempUserStore(); });
|
|
106
|
+
afterEach(() => { userStore.close(); });
|
|
107
|
+
|
|
108
|
+
it('returns user with today_usage', async () => {
|
|
109
|
+
userStore.saveUser({ id: 'u1', email: 'alice@example.com', plan: 'pro', api_key: 'user-key', created_at: '2026-01-01T00:00:00.000Z' });
|
|
110
|
+
const server = buildTestServer(userStore);
|
|
111
|
+
const res = await server.inject({
|
|
112
|
+
method: 'GET',
|
|
113
|
+
url: '/api/users/u1',
|
|
114
|
+
headers: { Authorization: 'Bearer user-key' },
|
|
115
|
+
});
|
|
116
|
+
expect(res.statusCode).toBe(200);
|
|
117
|
+
const body = res.json();
|
|
118
|
+
expect(body.email).toBe('alice@example.com');
|
|
119
|
+
expect(body.today_usage).toEqual({ runs_count: 0, tokens_used: 0 });
|
|
120
|
+
expect(body.api_key).toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('returns 404 for unknown id', async () => {
|
|
124
|
+
userStore.saveUser({ id: 'u1', email: 'alice@example.com', plan: 'pro', api_key: 'user-key', created_at: '2026-01-01T00:00:00.000Z' });
|
|
125
|
+
const server = buildTestServer(userStore);
|
|
126
|
+
const res = await server.inject({
|
|
127
|
+
method: 'GET',
|
|
128
|
+
url: '/api/users/nonexistent',
|
|
129
|
+
headers: { Authorization: 'Bearer user-key' },
|
|
130
|
+
});
|
|
131
|
+
expect(res.statusCode).toBe(404);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('DELETE /api/users/:id', () => {
|
|
136
|
+
let userStore: UserStore;
|
|
137
|
+
|
|
138
|
+
beforeEach(() => { userStore = makeTempUserStore(); });
|
|
139
|
+
afterEach(() => { userStore.close(); });
|
|
140
|
+
|
|
141
|
+
it('deletes a user and returns 204', async () => {
|
|
142
|
+
userStore.saveUser({ id: 'u1', email: 'alice@example.com', plan: 'free', api_key: 'user-key', created_at: '2026-01-01T00:00:00.000Z' });
|
|
143
|
+
const server = buildTestServer(userStore);
|
|
144
|
+
const res = await server.inject({
|
|
145
|
+
method: 'DELETE',
|
|
146
|
+
url: '/api/users/u1',
|
|
147
|
+
headers: { Authorization: 'Bearer user-key' },
|
|
148
|
+
});
|
|
149
|
+
expect(res.statusCode).toBe(204);
|
|
150
|
+
expect(userStore.getUserById('u1')).toBeNull();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('returns 404 for unknown id', async () => {
|
|
154
|
+
userStore.saveUser({ id: 'u1', email: 'alice@example.com', plan: 'free', api_key: 'user-key', created_at: '2026-01-01T00:00:00.000Z' });
|
|
155
|
+
const server = buildTestServer(userStore);
|
|
156
|
+
const res = await server.inject({
|
|
157
|
+
method: 'DELETE',
|
|
158
|
+
url: '/api/users/nonexistent',
|
|
159
|
+
headers: { Authorization: 'Bearer user-key' },
|
|
160
|
+
});
|
|
161
|
+
expect(res.statusCode).toBe(404);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('GET /api/users/me', () => {
|
|
166
|
+
let userStore: UserStore;
|
|
167
|
+
|
|
168
|
+
beforeEach(() => { userStore = makeTempUserStore(); });
|
|
169
|
+
afterEach(() => { userStore.close(); });
|
|
170
|
+
|
|
171
|
+
it('returns current user info', async () => {
|
|
172
|
+
userStore.saveUser({ id: 'u1', email: 'me@example.com', plan: 'pro', api_key: 'my-key', created_at: '2026-01-01T00:00:00.000Z' });
|
|
173
|
+
const server = buildTestServer(userStore);
|
|
174
|
+
const res = await server.inject({
|
|
175
|
+
method: 'GET',
|
|
176
|
+
url: '/api/users/me',
|
|
177
|
+
headers: { Authorization: 'Bearer my-key' },
|
|
178
|
+
});
|
|
179
|
+
expect(res.statusCode).toBe(200);
|
|
180
|
+
expect(res.json().email).toBe('me@example.com');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('returns 401 when not authenticated', async () => {
|
|
184
|
+
userStore.saveUser({ id: 'u1', email: 'me@example.com', plan: 'pro', api_key: 'my-key', created_at: '2026-01-01T00:00:00.000Z' });
|
|
185
|
+
const server = buildTestServer(userStore);
|
|
186
|
+
const res = await server.inject({ method: 'GET', url: '/api/users/me' });
|
|
187
|
+
expect(res.statusCode).toBe(401);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
|
|
4
|
+
// Mock node:child_process so tests don't actually run git
|
|
5
|
+
vi.mock('node:child_process', async (importOriginal) => {
|
|
6
|
+
const actual = await importOriginal<typeof import('node:child_process')>();
|
|
7
|
+
return { ...actual, execSync: vi.fn() };
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
// Mock node:fs/promises — mkdir only
|
|
11
|
+
vi.mock('node:fs/promises', () => ({
|
|
12
|
+
mkdir: vi.fn().mockResolvedValue(undefined),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
import { resolveRepoPath, cloneRepo } from '../../src/utils/repo-resolver.js';
|
|
16
|
+
import { execSync } from 'node:child_process';
|
|
17
|
+
|
|
18
|
+
const mockExecSync = vi.mocked(execSync);
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
vi.unstubAllEnvs();
|
|
22
|
+
mockExecSync.mockClear();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('resolveRepoPath', () => {
|
|
26
|
+
it('returns resolved repoPathOverride when provided', async () => {
|
|
27
|
+
const result = await resolveRepoPath({ repoPathOverride: 'my-project', pipelineName: 'p' });
|
|
28
|
+
expect(result).toMatch(/my-project$/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns "." when no repoPathOverride and no repoUrl', async () => {
|
|
32
|
+
const result = await resolveRepoPath({ pipelineName: 'feature-builder' });
|
|
33
|
+
expect(result).toBe('.');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('throws when repoUrl is set but no rawProjectsDir and no env var', async () => {
|
|
37
|
+
vi.stubEnv('STUDIO_PROJECTS_DIR', '');
|
|
38
|
+
await expect(
|
|
39
|
+
resolveRepoPath({ repoUrl: 'https://github.com/user/repo', pipelineName: 'p' })
|
|
40
|
+
).rejects.toThrow('STUDIO_PROJECTS_DIR is not set');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('clones when repoUrl is provided with rawProjectsDir', async () => {
|
|
44
|
+
const result = await resolveRepoPath({
|
|
45
|
+
repoUrl: 'https://github.com/user/repo',
|
|
46
|
+
rawProjectsDir: '/tmp/projects',
|
|
47
|
+
pipelineName: 'my-pipeline',
|
|
48
|
+
});
|
|
49
|
+
expect(mockExecSync).toHaveBeenCalledOnce();
|
|
50
|
+
const cmd = mockExecSync.mock.calls[0]![0] as string;
|
|
51
|
+
expect(cmd).toContain('git clone');
|
|
52
|
+
expect(cmd).toContain('https://github.com/user/repo');
|
|
53
|
+
expect(result).toContain('/tmp/projects/my-pipeline-');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('clones when repoUrl is provided via STUDIO_PROJECTS_DIR env var', async () => {
|
|
57
|
+
vi.stubEnv('STUDIO_PROJECTS_DIR', '/tmp/envprojects');
|
|
58
|
+
const result = await resolveRepoPath({
|
|
59
|
+
repoUrl: 'https://github.com/user/repo',
|
|
60
|
+
pipelineName: 'test-pipe',
|
|
61
|
+
});
|
|
62
|
+
expect(mockExecSync).toHaveBeenCalledOnce();
|
|
63
|
+
expect(result).toContain('/tmp/envprojects/test-pipe-');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('expands ~ in rawProjectsDir', async () => {
|
|
67
|
+
const result = await resolveRepoPath({
|
|
68
|
+
repoUrl: 'https://github.com/user/repo',
|
|
69
|
+
rawProjectsDir: '~/projects',
|
|
70
|
+
pipelineName: 'p',
|
|
71
|
+
});
|
|
72
|
+
expect(result).toContain(homedir() + '/projects/p-');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('repoPathOverride takes precedence over repoUrl', async () => {
|
|
76
|
+
const result = await resolveRepoPath({
|
|
77
|
+
repoPathOverride: '/explicit/path',
|
|
78
|
+
repoUrl: 'https://github.com/user/repo',
|
|
79
|
+
rawProjectsDir: '/tmp/p',
|
|
80
|
+
pipelineName: 'p',
|
|
81
|
+
});
|
|
82
|
+
expect(result).toBe('/explicit/path');
|
|
83
|
+
expect(mockExecSync).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('passes branch arg when branch is set', async () => {
|
|
87
|
+
await resolveRepoPath({
|
|
88
|
+
repoUrl: 'https://github.com/user/repo',
|
|
89
|
+
rawProjectsDir: '/tmp/projects',
|
|
90
|
+
pipelineName: 'p',
|
|
91
|
+
branch: 'main',
|
|
92
|
+
});
|
|
93
|
+
const cmd = mockExecSync.mock.calls[0]![0] as string;
|
|
94
|
+
expect(cmd).toContain('--branch main');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('cloneRepo', () => {
|
|
99
|
+
it('throws a descriptive error when git clone fails', async () => {
|
|
100
|
+
mockExecSync.mockImplementationOnce(() => { throw new Error('repository not found'); });
|
|
101
|
+
await expect(
|
|
102
|
+
cloneRepo('https://github.com/bad/repo', '/tmp/p', 'pipeline')
|
|
103
|
+
).rejects.toThrow('Failed to clone https://github.com/bad/repo');
|
|
104
|
+
});
|
|
105
|
+
});
|