@kb-labs/gateway-app 0.2.0 → 0.4.0
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/dist/index.d.ts +2 -0
- package/dist/index.js +2454 -0
- package/dist/index.js.map +1 -0
- package/package.json +14 -11
- package/.kb/database/kb.sqlite-shm +0 -0
- package/.kb/database/kb.sqlite-wal +0 -0
- package/src/__tests__/auth-routes.test.ts +0 -279
- package/src/__tests__/execute-routes.test.ts +0 -408
- package/src/__tests__/execution-registry.test.ts +0 -218
- package/src/__tests__/health.test.ts +0 -215
- package/src/__tests__/live-gateway.e2e.test.ts +0 -648
- package/src/__tests__/llm-gateway.test.ts +0 -361
- package/src/__tests__/observability-collector.test.ts +0 -59
- package/src/__tests__/platform-api.test.ts +0 -317
- package/src/__tests__/registry.test.ts +0 -546
- package/src/__tests__/retry-executor.test.ts +0 -244
- package/src/__tests__/server.integration.test.ts +0 -417
- package/src/__tests__/subscription-registry.test.ts +0 -308
- package/src/__tests__/telemetry-ingest.test.ts +0 -309
- package/src/__tests__/tokens.test.ts +0 -83
- package/src/__tests__/ws-client-connect.e2e.test.ts +0 -381
- package/src/__tests__/ws-handshake.e2e.test.ts +0 -288
- package/src/auth/middleware.ts +0 -50
- package/src/auth/routes.ts +0 -57
- package/src/auth/tokens.ts +0 -41
- package/src/bootstrap.ts +0 -98
- package/src/clients/subscription-registry.ts +0 -137
- package/src/clients/ws-handler.ts +0 -196
- package/src/config.ts +0 -20
- package/src/docs/routes.ts +0 -70
- package/src/execute/errors.ts +0 -21
- package/src/execute/execution-registry.ts +0 -84
- package/src/execute/retry-executor.ts +0 -159
- package/src/execute/routes.ts +0 -239
- package/src/hosts/dispatcher.ts +0 -2
- package/src/hosts/registry.ts +0 -305
- package/src/hosts/ws-handler.ts +0 -445
- package/src/index.ts +0 -7
- package/src/llm/routes.ts +0 -343
- package/src/manifest.ts +0 -21
- package/src/observability/collector.ts +0 -346
- package/src/platform/routes.ts +0 -195
- package/src/server.ts +0 -447
- package/src/telemetry/routes.ts +0 -89
- package/src/ws/gateway-ws.ts +0 -73
- package/tsconfig.build.json +0 -15
- package/tsconfig.json +0 -10
- package/tsup.config.ts +0 -8
- package/vitest.config.ts +0 -23
|
@@ -1,279 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Integration tests for auth routes (CC4 — Auth Flow).
|
|
3
|
-
* Spins up a real Fastify instance with mocked AuthService.
|
|
4
|
-
*
|
|
5
|
-
* Covers:
|
|
6
|
-
* POST /auth/register — happy path, bad body (400)
|
|
7
|
-
* POST /auth/token — happy path, bad creds (401), bad body (400)
|
|
8
|
-
* POST /auth/refresh — happy path, expired token (401), bad body (400)
|
|
9
|
-
*/
|
|
10
|
-
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
|
|
11
|
-
import Fastify, { type FastifyInstance } from 'fastify';
|
|
12
|
-
import type { ICache } from '@kb-labs/core-platform';
|
|
13
|
-
import type { JwtConfig } from '@kb-labs/gateway-auth';
|
|
14
|
-
import { createAuthMiddleware } from '../auth/middleware.js';
|
|
15
|
-
import { registerAuthRoutes } from '../auth/routes.js';
|
|
16
|
-
|
|
17
|
-
// ── Minimal stub cache ────────────────────────────────────────────────────────
|
|
18
|
-
|
|
19
|
-
function makeCache(): ICache {
|
|
20
|
-
const store = new Map<string, unknown>();
|
|
21
|
-
return {
|
|
22
|
-
async get<T>(k: string) { return (store.get(k) as T) ?? null; },
|
|
23
|
-
async set(k: string, v: unknown) { store.set(k, v); },
|
|
24
|
-
async delete(k: string) { store.delete(k); },
|
|
25
|
-
async clear() { store.clear(); },
|
|
26
|
-
} as unknown as ICache;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// ── Mocked AuthService ────────────────────────────────────────────────────────
|
|
30
|
-
|
|
31
|
-
function makeAuthService() {
|
|
32
|
-
return {
|
|
33
|
-
register: vi.fn(),
|
|
34
|
-
issueTokens: vi.fn(),
|
|
35
|
-
refreshTokens: vi.fn(),
|
|
36
|
-
verify: vi.fn(),
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const testJwtConfig: JwtConfig = { secret: 'test-secret' };
|
|
41
|
-
|
|
42
|
-
let app: FastifyInstance;
|
|
43
|
-
let authService: ReturnType<typeof makeAuthService>;
|
|
44
|
-
|
|
45
|
-
beforeAll(async () => {
|
|
46
|
-
authService = makeAuthService();
|
|
47
|
-
app = Fastify({ logger: false });
|
|
48
|
-
|
|
49
|
-
const cache = makeCache();
|
|
50
|
-
app.addHook('preHandler', createAuthMiddleware(cache, testJwtConfig));
|
|
51
|
-
|
|
52
|
-
// We inject the mocked authService via cast — same interface
|
|
53
|
-
registerAuthRoutes(app, authService as never);
|
|
54
|
-
|
|
55
|
-
await app.ready();
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
afterAll(async () => {
|
|
59
|
-
await app.close();
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
// ── POST /auth/register ───────────────────────────────────────────────────────
|
|
63
|
-
|
|
64
|
-
describe('POST /auth/register', () => {
|
|
65
|
-
it('returns 201 with clientId, clientSecret, hostId on success', async () => {
|
|
66
|
-
authService.register.mockResolvedValue({
|
|
67
|
-
clientId: 'client-abc',
|
|
68
|
-
clientSecret: 'secret-xyz',
|
|
69
|
-
hostId: 'host-001',
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
const res = await app.inject({
|
|
73
|
-
method: 'POST',
|
|
74
|
-
url: '/auth/register',
|
|
75
|
-
payload: { name: 'My Agent', namespaceId: 'ns-test' },
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
expect(res.statusCode).toBe(201);
|
|
79
|
-
const body = res.json() as { clientId: string; clientSecret: string; hostId: string };
|
|
80
|
-
expect(body.clientId).toBe('client-abc');
|
|
81
|
-
expect(body.clientSecret).toBe('secret-xyz');
|
|
82
|
-
expect(body.hostId).toBe('host-001');
|
|
83
|
-
expect(authService.register).toHaveBeenCalledWith(
|
|
84
|
-
expect.objectContaining({ name: 'My Agent', namespaceId: 'ns-test' }),
|
|
85
|
-
);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('returns 400 when name is missing', async () => {
|
|
89
|
-
const res = await app.inject({
|
|
90
|
-
method: 'POST',
|
|
91
|
-
url: '/auth/register',
|
|
92
|
-
payload: { namespaceId: 'ns-test' },
|
|
93
|
-
});
|
|
94
|
-
expect(res.statusCode).toBe(400);
|
|
95
|
-
const body = res.json() as { error: string };
|
|
96
|
-
expect(body.error).toBe('Bad Request');
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it('returns 400 when namespaceId is missing', async () => {
|
|
100
|
-
const res = await app.inject({
|
|
101
|
-
method: 'POST',
|
|
102
|
-
url: '/auth/register',
|
|
103
|
-
payload: { name: 'agent' },
|
|
104
|
-
});
|
|
105
|
-
expect(res.statusCode).toBe(400);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it('returns 400 when body is empty', async () => {
|
|
109
|
-
const res = await app.inject({
|
|
110
|
-
method: 'POST',
|
|
111
|
-
url: '/auth/register',
|
|
112
|
-
payload: {},
|
|
113
|
-
});
|
|
114
|
-
expect(res.statusCode).toBe(400);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it('forwards capabilities array to authService', async () => {
|
|
118
|
-
authService.register.mockResolvedValue({
|
|
119
|
-
clientId: 'c-2',
|
|
120
|
-
clientSecret: 's-2',
|
|
121
|
-
hostId: 'h-2',
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
await app.inject({
|
|
125
|
-
method: 'POST',
|
|
126
|
-
url: '/auth/register',
|
|
127
|
-
payload: { name: 'Cap Agent', namespaceId: 'ns', capabilities: ['read', 'write'] },
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
expect(authService.register).toHaveBeenCalledWith(
|
|
131
|
-
expect.objectContaining({ capabilities: ['read', 'write'] }),
|
|
132
|
-
);
|
|
133
|
-
});
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
// ── POST /auth/token ──────────────────────────────────────────────────────────
|
|
137
|
-
|
|
138
|
-
describe('POST /auth/token', () => {
|
|
139
|
-
it('returns 200 with token pair on valid credentials', async () => {
|
|
140
|
-
authService.issueTokens.mockResolvedValue({
|
|
141
|
-
accessToken: 'access.jwt.token',
|
|
142
|
-
refreshToken: 'refresh.jwt.token',
|
|
143
|
-
expiresIn: 3600,
|
|
144
|
-
tokenType: 'Bearer',
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
const res = await app.inject({
|
|
148
|
-
method: 'POST',
|
|
149
|
-
url: '/auth/token',
|
|
150
|
-
payload: { clientId: 'client-abc', clientSecret: 'secret-xyz' },
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
expect(res.statusCode).toBe(200);
|
|
154
|
-
const body = res.json() as { accessToken: string; tokenType: string };
|
|
155
|
-
expect(body.accessToken).toBe('access.jwt.token');
|
|
156
|
-
expect(body.tokenType).toBe('Bearer');
|
|
157
|
-
expect(authService.issueTokens).toHaveBeenCalledWith('client-abc', 'secret-xyz');
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it('returns 401 when credentials are invalid (issueTokens returns null)', async () => {
|
|
161
|
-
authService.issueTokens.mockResolvedValue(null);
|
|
162
|
-
|
|
163
|
-
const res = await app.inject({
|
|
164
|
-
method: 'POST',
|
|
165
|
-
url: '/auth/token',
|
|
166
|
-
payload: { clientId: 'bad', clientSecret: 'wrong' },
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
expect(res.statusCode).toBe(401);
|
|
170
|
-
const body = res.json() as { error: string; message: string };
|
|
171
|
-
expect(body.error).toBe('Unauthorized');
|
|
172
|
-
expect(body.message).toContain('Invalid credentials');
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it('returns 400 when clientId is missing', async () => {
|
|
176
|
-
const res = await app.inject({
|
|
177
|
-
method: 'POST',
|
|
178
|
-
url: '/auth/token',
|
|
179
|
-
payload: { clientSecret: 'xyz' },
|
|
180
|
-
});
|
|
181
|
-
expect(res.statusCode).toBe(400);
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
it('returns 400 when clientSecret is missing', async () => {
|
|
185
|
-
const res = await app.inject({
|
|
186
|
-
method: 'POST',
|
|
187
|
-
url: '/auth/token',
|
|
188
|
-
payload: { clientId: 'abc' },
|
|
189
|
-
});
|
|
190
|
-
expect(res.statusCode).toBe(400);
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
it('returns 400 when body is empty', async () => {
|
|
194
|
-
const res = await app.inject({
|
|
195
|
-
method: 'POST',
|
|
196
|
-
url: '/auth/token',
|
|
197
|
-
payload: {},
|
|
198
|
-
});
|
|
199
|
-
expect(res.statusCode).toBe(400);
|
|
200
|
-
});
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
// ── POST /auth/refresh ────────────────────────────────────────────────────────
|
|
204
|
-
|
|
205
|
-
describe('POST /auth/refresh', () => {
|
|
206
|
-
it('returns 200 with new token pair on valid refresh token', async () => {
|
|
207
|
-
authService.refreshTokens.mockResolvedValue({
|
|
208
|
-
accessToken: 'new.access.token',
|
|
209
|
-
refreshToken: 'new.refresh.token',
|
|
210
|
-
expiresIn: 3600,
|
|
211
|
-
tokenType: 'Bearer',
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
const res = await app.inject({
|
|
215
|
-
method: 'POST',
|
|
216
|
-
url: '/auth/refresh',
|
|
217
|
-
payload: { refreshToken: 'valid.refresh.token' },
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
expect(res.statusCode).toBe(200);
|
|
221
|
-
const body = res.json() as { accessToken: string; refreshToken: string };
|
|
222
|
-
expect(body.accessToken).toBe('new.access.token');
|
|
223
|
-
expect(body.refreshToken).toBe('new.refresh.token');
|
|
224
|
-
expect(authService.refreshTokens).toHaveBeenCalledWith('valid.refresh.token');
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
it('returns 401 when refresh token is invalid (refreshTokens returns null)', async () => {
|
|
228
|
-
authService.refreshTokens.mockResolvedValue(null);
|
|
229
|
-
|
|
230
|
-
const res = await app.inject({
|
|
231
|
-
method: 'POST',
|
|
232
|
-
url: '/auth/refresh',
|
|
233
|
-
payload: { refreshToken: 'expired.or.invalid' },
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
expect(res.statusCode).toBe(401);
|
|
237
|
-
const body = res.json() as { error: string; message: string };
|
|
238
|
-
expect(body.error).toBe('Unauthorized');
|
|
239
|
-
expect(body.message).toContain('Invalid or expired');
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
it('returns 400 when refreshToken field is missing', async () => {
|
|
243
|
-
const res = await app.inject({
|
|
244
|
-
method: 'POST',
|
|
245
|
-
url: '/auth/refresh',
|
|
246
|
-
payload: {},
|
|
247
|
-
});
|
|
248
|
-
expect(res.statusCode).toBe(400);
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
it('returns 400 when body contains wrong field', async () => {
|
|
252
|
-
const res = await app.inject({
|
|
253
|
-
method: 'POST',
|
|
254
|
-
url: '/auth/refresh',
|
|
255
|
-
payload: { token: 'wrong-field-name' },
|
|
256
|
-
});
|
|
257
|
-
expect(res.statusCode).toBe(400);
|
|
258
|
-
});
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
// ── Auth middleware: public routes skip auth check ────────────────────────────
|
|
262
|
-
|
|
263
|
-
describe('Auth middleware — public routes', () => {
|
|
264
|
-
it('/auth/register is accessible without Authorization header', async () => {
|
|
265
|
-
authService.register.mockResolvedValue({
|
|
266
|
-
clientId: 'c', clientSecret: 's', hostId: 'h',
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
const res = await app.inject({
|
|
270
|
-
method: 'POST',
|
|
271
|
-
url: '/auth/register',
|
|
272
|
-
payload: { name: 'x', namespaceId: 'y' },
|
|
273
|
-
// No Authorization header
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
// Should reach the route handler, not be blocked by middleware
|
|
277
|
-
expect(res.statusCode).not.toBe(401);
|
|
278
|
-
});
|
|
279
|
-
});
|
|
@@ -1,408 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Integration tests for execute routes (CC1/CC2/CC3/CC5).
|
|
3
|
-
*
|
|
4
|
-
* POST /api/v1/execute — ndjson streaming, 400/401/503, cancellation flow
|
|
5
|
-
* POST /api/v1/execute/:id/cancel — 200/404/403/409
|
|
6
|
-
*
|
|
7
|
-
* Uses real Fastify instance with mocked globalDispatcher.
|
|
8
|
-
* ndjson response is collected line-by-line.
|
|
9
|
-
*/
|
|
10
|
-
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
11
|
-
import Fastify, { type FastifyInstance } from 'fastify';
|
|
12
|
-
import type { ILogger } from '@kb-labs/core-platform';
|
|
13
|
-
|
|
14
|
-
// ── Mock heavy deps before importing routes ───────────────────────────────────
|
|
15
|
-
// Use vi.hoisted() so variables are available when vi.mock() factory runs
|
|
16
|
-
// (vi.mock calls are hoisted to the top of the file, before variable declarations)
|
|
17
|
-
|
|
18
|
-
const { mockDispatcher, mockBroadcast } = vi.hoisted(() => {
|
|
19
|
-
const mockDispatcher = {
|
|
20
|
-
firstHost: vi.fn(),
|
|
21
|
-
firstHostWithCapability: vi.fn(),
|
|
22
|
-
call: vi.fn(),
|
|
23
|
-
};
|
|
24
|
-
const mockBroadcast = vi.fn();
|
|
25
|
-
return { mockDispatcher, mockBroadcast };
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
vi.mock('../hosts/dispatcher.js', () => ({
|
|
29
|
-
globalDispatcher: mockDispatcher,
|
|
30
|
-
HostCallDispatcher: vi.fn(),
|
|
31
|
-
}));
|
|
32
|
-
|
|
33
|
-
vi.mock('../clients/subscription-registry.js', () => ({
|
|
34
|
-
subscriptionRegistry: {
|
|
35
|
-
broadcast: mockBroadcast,
|
|
36
|
-
subscribe: vi.fn(),
|
|
37
|
-
unsubscribe: vi.fn(),
|
|
38
|
-
},
|
|
39
|
-
SubscriptionRegistry: vi.fn(),
|
|
40
|
-
}));
|
|
41
|
-
|
|
42
|
-
import { registerExecuteRoutes } from '../execute/routes.js';
|
|
43
|
-
import { executionRegistry } from '../execute/execution-registry.js';
|
|
44
|
-
|
|
45
|
-
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
46
|
-
|
|
47
|
-
const noopLogger: ILogger = {
|
|
48
|
-
info: vi.fn(),
|
|
49
|
-
warn: vi.fn(),
|
|
50
|
-
error: vi.fn(),
|
|
51
|
-
debug: vi.fn(),
|
|
52
|
-
child: vi.fn(() => noopLogger),
|
|
53
|
-
} as unknown as ILogger;
|
|
54
|
-
|
|
55
|
-
function makeAuthContext(namespaceId = 'ns-test') {
|
|
56
|
-
return {
|
|
57
|
-
type: 'machine' as const,
|
|
58
|
-
userId: 'host-001',
|
|
59
|
-
namespaceId,
|
|
60
|
-
tier: 'free' as const,
|
|
61
|
-
permissions: ['host:connect'],
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** Parse ndjson response body into array of parsed objects */
|
|
66
|
-
function parseNdjson(body: string): unknown[] {
|
|
67
|
-
return body
|
|
68
|
-
.split('\n')
|
|
69
|
-
.filter((line) => line.trim() !== '')
|
|
70
|
-
.map((line) => JSON.parse(line) as unknown);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
let app: FastifyInstance;
|
|
74
|
-
|
|
75
|
-
beforeAll(async () => {
|
|
76
|
-
app = Fastify({ logger: false });
|
|
77
|
-
|
|
78
|
-
// Inject authContext via preHandler
|
|
79
|
-
app.addHook('preHandler', async (request) => {
|
|
80
|
-
(request as { authContext?: unknown }).authContext = makeAuthContext();
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
registerExecuteRoutes(app, noopLogger);
|
|
84
|
-
await app.ready();
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
afterAll(async () => {
|
|
88
|
-
await app.close();
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
beforeEach(() => {
|
|
92
|
-
vi.clearAllMocks();
|
|
93
|
-
// By default, a host exists
|
|
94
|
-
mockDispatcher.firstHost.mockReturnValue('host-001');
|
|
95
|
-
mockDispatcher.firstHostWithCapability.mockReturnValue('host-001');
|
|
96
|
-
// By default, dispatch resolves with a result
|
|
97
|
-
mockDispatcher.call.mockResolvedValue({ output: 'test-result' });
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
// ── POST /api/v1/execute — happy path ─────────────────────────────────────────
|
|
101
|
-
|
|
102
|
-
describe('POST /api/v1/execute — success', () => {
|
|
103
|
-
it('returns 200 with ndjson content-type', async () => {
|
|
104
|
-
const res = await app.inject({
|
|
105
|
-
method: 'POST',
|
|
106
|
-
url: '/api/v1/execute',
|
|
107
|
-
payload: { pluginId: 'my-plugin', handlerRef: 'handlers/main.js', input: { foo: 'bar' } },
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
expect(res.statusCode).toBe(200);
|
|
111
|
-
expect(res.headers['content-type']).toContain('application/x-ndjson');
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it('response body contains execution:done as last event', async () => {
|
|
115
|
-
const res = await app.inject({
|
|
116
|
-
method: 'POST',
|
|
117
|
-
url: '/api/v1/execute',
|
|
118
|
-
payload: { pluginId: 'my-plugin', handlerRef: 'handlers/main.js', input: {} },
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
const events = parseNdjson(res.body);
|
|
122
|
-
const doneEvent = events.find((e) => (e as { type: string }).type === 'execution:done');
|
|
123
|
-
expect(doneEvent).toBeDefined();
|
|
124
|
-
expect((doneEvent as { exitCode: number }).exitCode).toBe(0);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it('X-Execution-Id header is present in response', async () => {
|
|
128
|
-
const res = await app.inject({
|
|
129
|
-
method: 'POST',
|
|
130
|
-
url: '/api/v1/execute',
|
|
131
|
-
payload: { pluginId: 'p', handlerRef: 'h', input: null },
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
expect(res.headers['x-execution-id']).toBeTruthy();
|
|
135
|
-
expect(typeof res.headers['x-execution-id']).toBe('string');
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it('dispatches to correct namespace and host', async () => {
|
|
139
|
-
await app.inject({
|
|
140
|
-
method: 'POST',
|
|
141
|
-
url: '/api/v1/execute',
|
|
142
|
-
payload: { pluginId: 'test-plugin', handlerRef: 'handler.js', input: { x: 1 } },
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
expect(mockDispatcher.firstHostWithCapability).toHaveBeenCalledWith('ns-test', 'execution');
|
|
146
|
-
expect(mockDispatcher.call).toHaveBeenCalledWith(
|
|
147
|
-
'ns-test',
|
|
148
|
-
'host-001',
|
|
149
|
-
'execution',
|
|
150
|
-
'execute',
|
|
151
|
-
expect.arrayContaining([
|
|
152
|
-
expect.objectContaining({ pluginId: 'test-plugin', handlerRef: 'handler.js' }),
|
|
153
|
-
]),
|
|
154
|
-
);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it('broadcasts execution:done event to WS subscribers', async () => {
|
|
158
|
-
await app.inject({
|
|
159
|
-
method: 'POST',
|
|
160
|
-
url: '/api/v1/execute',
|
|
161
|
-
payload: { pluginId: 'p', handlerRef: 'h', input: null },
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
// broadcast is called at least once (for execution:done)
|
|
165
|
-
const calls = mockBroadcast.mock.calls as [string, { type: string }][];
|
|
166
|
-
const doneCall = calls.find(([, event]) => event.type === 'execution:done');
|
|
167
|
-
expect(doneCall).toBeDefined();
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it('execution is removed from registry after completion', async () => {
|
|
171
|
-
const before = executionRegistry.size;
|
|
172
|
-
await app.inject({
|
|
173
|
-
method: 'POST',
|
|
174
|
-
url: '/api/v1/execute',
|
|
175
|
-
payload: { pluginId: 'p', handlerRef: 'h', input: null },
|
|
176
|
-
});
|
|
177
|
-
// After response, execution should have been removed
|
|
178
|
-
expect(executionRegistry.size).toBe(before);
|
|
179
|
-
});
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
// ── POST /api/v1/execute — error cases ───────────────────────────────────────
|
|
183
|
-
|
|
184
|
-
describe('POST /api/v1/execute — error cases', () => {
|
|
185
|
-
it('returns 400 when pluginId is missing', async () => {
|
|
186
|
-
const res = await app.inject({
|
|
187
|
-
method: 'POST',
|
|
188
|
-
url: '/api/v1/execute',
|
|
189
|
-
payload: { handlerRef: 'h', input: {} },
|
|
190
|
-
});
|
|
191
|
-
expect(res.statusCode).toBe(400);
|
|
192
|
-
const body = res.json() as { error: string };
|
|
193
|
-
expect(body.error).toBe('Bad Request');
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it('returns 400 when handlerRef is missing', async () => {
|
|
197
|
-
const res = await app.inject({
|
|
198
|
-
method: 'POST',
|
|
199
|
-
url: '/api/v1/execute',
|
|
200
|
-
payload: { pluginId: 'p', input: {} },
|
|
201
|
-
});
|
|
202
|
-
expect(res.statusCode).toBe(400);
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
it('returns 503 when no host is connected for namespace', async () => {
|
|
206
|
-
mockDispatcher.firstHostWithCapability.mockReturnValue(null);
|
|
207
|
-
|
|
208
|
-
const res = await app.inject({
|
|
209
|
-
method: 'POST',
|
|
210
|
-
url: '/api/v1/execute',
|
|
211
|
-
payload: { pluginId: 'p', handlerRef: 'h', input: null },
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
expect(res.statusCode).toBe(503);
|
|
215
|
-
const body = res.json() as { error: string; namespaceId: string };
|
|
216
|
-
expect(body.error).toBe('No execution host connected');
|
|
217
|
-
expect(body.namespaceId).toBe('ns-test');
|
|
218
|
-
expect(noopLogger.warn).toHaveBeenCalledWith(
|
|
219
|
-
'No execution host connected for namespace',
|
|
220
|
-
expect.objectContaining({
|
|
221
|
-
diagnosticEvent: 'gateway.execution.dispatch',
|
|
222
|
-
reasonCode: 'execution_host_unavailable',
|
|
223
|
-
serviceId: 'gateway',
|
|
224
|
-
evidence: expect.objectContaining({
|
|
225
|
-
namespaceId: 'ns-test',
|
|
226
|
-
}),
|
|
227
|
-
}),
|
|
228
|
-
);
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it('on dispatch failure: streams execution:error + execution:done(exitCode=1)', async () => {
|
|
232
|
-
mockDispatcher.call.mockRejectedValue(new Error('ECONNREFUSED connection refused'));
|
|
233
|
-
|
|
234
|
-
const res = await app.inject({
|
|
235
|
-
method: 'POST',
|
|
236
|
-
url: '/api/v1/execute',
|
|
237
|
-
payload: { pluginId: 'p', handlerRef: 'h', input: null },
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
expect(res.statusCode).toBe(200); // headers already sent
|
|
241
|
-
const events = parseNdjson(res.body);
|
|
242
|
-
|
|
243
|
-
const errorEvent = events.find((e) => (e as { type: string }).type === 'execution:error');
|
|
244
|
-
expect(errorEvent).toBeDefined();
|
|
245
|
-
expect((errorEvent as { code: string }).code).toBe('EXECUTION_FAILED');
|
|
246
|
-
expect(noopLogger.error).toHaveBeenCalledWith(
|
|
247
|
-
'Gateway execution dispatch failed',
|
|
248
|
-
expect.any(Error),
|
|
249
|
-
expect.objectContaining({
|
|
250
|
-
diagnosticEvent: 'gateway.execution.dispatch',
|
|
251
|
-
reasonCode: 'execution_dispatch_failed',
|
|
252
|
-
serviceId: 'gateway',
|
|
253
|
-
}),
|
|
254
|
-
);
|
|
255
|
-
|
|
256
|
-
const doneEvent = events.find((e) => (e as { type: string }).type === 'execution:done');
|
|
257
|
-
expect((doneEvent as { exitCode: number }).exitCode).toBe(1);
|
|
258
|
-
});
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
// ── POST /api/v1/execute/:id/cancel ──────────────────────────────────────────
|
|
262
|
-
|
|
263
|
-
describe('POST /api/v1/execute/:id/cancel', () => {
|
|
264
|
-
it('returns 200 when execution is found and cancelled', async () => {
|
|
265
|
-
// Register a real execution so cancel can find it
|
|
266
|
-
const signal = executionRegistry.register({
|
|
267
|
-
executionId: 'exec-cancel-test',
|
|
268
|
-
requestId: 'req-1',
|
|
269
|
-
namespaceId: 'ns-test',
|
|
270
|
-
hostId: 'host-001',
|
|
271
|
-
pluginId: 'p',
|
|
272
|
-
handlerRef: 'h',
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
const res = await app.inject({
|
|
276
|
-
method: 'POST',
|
|
277
|
-
url: '/api/v1/execute/exec-cancel-test/cancel',
|
|
278
|
-
payload: { reason: 'user' },
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
expect(res.statusCode).toBe(200);
|
|
282
|
-
const body = res.json() as { executionId: string; status: string };
|
|
283
|
-
expect(body.status).toBe('cancelled');
|
|
284
|
-
expect(body.executionId).toBe('exec-cancel-test');
|
|
285
|
-
expect(signal.aborted).toBe(true);
|
|
286
|
-
|
|
287
|
-
executionRegistry.remove('exec-cancel-test');
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
it('returns 404 when execution does not exist', async () => {
|
|
291
|
-
const res = await app.inject({
|
|
292
|
-
method: 'POST',
|
|
293
|
-
url: '/api/v1/execute/nonexistent-id/cancel',
|
|
294
|
-
payload: {},
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
expect(res.statusCode).toBe(404);
|
|
298
|
-
const body = res.json() as { error: string };
|
|
299
|
-
expect(body.error).toContain('not found');
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
it('returns 403 when execution belongs to different namespace', async () => {
|
|
303
|
-
// Register an execution in a different namespace
|
|
304
|
-
executionRegistry.register({
|
|
305
|
-
executionId: 'exec-other-ns',
|
|
306
|
-
requestId: 'req-x',
|
|
307
|
-
namespaceId: 'ns-other', // different namespace
|
|
308
|
-
hostId: 'host-002',
|
|
309
|
-
pluginId: 'p',
|
|
310
|
-
handlerRef: 'h',
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
const res = await app.inject({
|
|
314
|
-
method: 'POST',
|
|
315
|
-
url: '/api/v1/execute/exec-other-ns/cancel',
|
|
316
|
-
payload: {},
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
expect(res.statusCode).toBe(403);
|
|
320
|
-
const body = res.json() as { error: string };
|
|
321
|
-
expect(body.error).toContain('Forbidden');
|
|
322
|
-
|
|
323
|
-
executionRegistry.remove('exec-other-ns');
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
it('returns 409 when execution is already cancelled', async () => {
|
|
327
|
-
executionRegistry.register({
|
|
328
|
-
executionId: 'exec-already-cancelled',
|
|
329
|
-
requestId: 'req-2',
|
|
330
|
-
namespaceId: 'ns-test',
|
|
331
|
-
hostId: 'host-001',
|
|
332
|
-
pluginId: 'p',
|
|
333
|
-
handlerRef: 'h',
|
|
334
|
-
});
|
|
335
|
-
// Cancel it first
|
|
336
|
-
executionRegistry.cancel('exec-already-cancelled', 'user');
|
|
337
|
-
|
|
338
|
-
const res = await app.inject({
|
|
339
|
-
method: 'POST',
|
|
340
|
-
url: '/api/v1/execute/exec-already-cancelled/cancel',
|
|
341
|
-
payload: {},
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
expect(res.statusCode).toBe(409);
|
|
345
|
-
const body = res.json() as { status: string };
|
|
346
|
-
expect(body.status).toBe('already_cancelled');
|
|
347
|
-
|
|
348
|
-
executionRegistry.remove('exec-already-cancelled');
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
it('defaults reason to "user" when not provided', async () => {
|
|
352
|
-
executionRegistry.register({
|
|
353
|
-
executionId: 'exec-default-reason',
|
|
354
|
-
requestId: 'req-3',
|
|
355
|
-
namespaceId: 'ns-test',
|
|
356
|
-
hostId: 'host-001',
|
|
357
|
-
pluginId: 'p',
|
|
358
|
-
handlerRef: 'h',
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
const res = await app.inject({
|
|
362
|
-
method: 'POST',
|
|
363
|
-
url: '/api/v1/execute/exec-default-reason/cancel',
|
|
364
|
-
payload: {},
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
expect(res.statusCode).toBe(200);
|
|
368
|
-
const body = res.json() as { reason: string };
|
|
369
|
-
expect(body.reason).toBe('user');
|
|
370
|
-
|
|
371
|
-
executionRegistry.remove('exec-default-reason');
|
|
372
|
-
});
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
// ── 401 without auth context ──────────────────────────────────────────────────
|
|
376
|
-
|
|
377
|
-
describe('POST /api/v1/execute — auth guard', () => {
|
|
378
|
-
let appNoAuth: FastifyInstance;
|
|
379
|
-
|
|
380
|
-
beforeAll(async () => {
|
|
381
|
-
appNoAuth = Fastify({ logger: false });
|
|
382
|
-
// No preHandler — authContext stays undefined
|
|
383
|
-
registerExecuteRoutes(appNoAuth, noopLogger);
|
|
384
|
-
await appNoAuth.ready();
|
|
385
|
-
});
|
|
386
|
-
|
|
387
|
-
afterAll(async () => {
|
|
388
|
-
await appNoAuth.close();
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
it('returns 401 when authContext is absent', async () => {
|
|
392
|
-
const res = await appNoAuth.inject({
|
|
393
|
-
method: 'POST',
|
|
394
|
-
url: '/api/v1/execute',
|
|
395
|
-
payload: { pluginId: 'p', handlerRef: 'h', input: null },
|
|
396
|
-
});
|
|
397
|
-
expect(res.statusCode).toBe(401);
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
it('cancel returns 401 when authContext is absent', async () => {
|
|
401
|
-
const res = await appNoAuth.inject({
|
|
402
|
-
method: 'POST',
|
|
403
|
-
url: '/api/v1/execute/some-id/cancel',
|
|
404
|
-
payload: {},
|
|
405
|
-
});
|
|
406
|
-
expect(res.statusCode).toBe(401);
|
|
407
|
-
});
|
|
408
|
-
});
|