@kb-labs/gateway-app 0.2.0 → 0.3.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.
Files changed (49) hide show
  1. package/dist/index.d.ts +2 -0
  2. package/dist/index.js +2454 -0
  3. package/dist/index.js.map +1 -0
  4. package/package.json +7 -4
  5. package/.kb/database/kb.sqlite-shm +0 -0
  6. package/.kb/database/kb.sqlite-wal +0 -0
  7. package/src/__tests__/auth-routes.test.ts +0 -279
  8. package/src/__tests__/execute-routes.test.ts +0 -408
  9. package/src/__tests__/execution-registry.test.ts +0 -218
  10. package/src/__tests__/health.test.ts +0 -215
  11. package/src/__tests__/live-gateway.e2e.test.ts +0 -648
  12. package/src/__tests__/llm-gateway.test.ts +0 -361
  13. package/src/__tests__/observability-collector.test.ts +0 -59
  14. package/src/__tests__/platform-api.test.ts +0 -317
  15. package/src/__tests__/registry.test.ts +0 -546
  16. package/src/__tests__/retry-executor.test.ts +0 -244
  17. package/src/__tests__/server.integration.test.ts +0 -417
  18. package/src/__tests__/subscription-registry.test.ts +0 -308
  19. package/src/__tests__/telemetry-ingest.test.ts +0 -309
  20. package/src/__tests__/tokens.test.ts +0 -83
  21. package/src/__tests__/ws-client-connect.e2e.test.ts +0 -381
  22. package/src/__tests__/ws-handshake.e2e.test.ts +0 -288
  23. package/src/auth/middleware.ts +0 -50
  24. package/src/auth/routes.ts +0 -57
  25. package/src/auth/tokens.ts +0 -41
  26. package/src/bootstrap.ts +0 -98
  27. package/src/clients/subscription-registry.ts +0 -137
  28. package/src/clients/ws-handler.ts +0 -196
  29. package/src/config.ts +0 -20
  30. package/src/docs/routes.ts +0 -70
  31. package/src/execute/errors.ts +0 -21
  32. package/src/execute/execution-registry.ts +0 -84
  33. package/src/execute/retry-executor.ts +0 -159
  34. package/src/execute/routes.ts +0 -239
  35. package/src/hosts/dispatcher.ts +0 -2
  36. package/src/hosts/registry.ts +0 -305
  37. package/src/hosts/ws-handler.ts +0 -445
  38. package/src/index.ts +0 -7
  39. package/src/llm/routes.ts +0 -343
  40. package/src/manifest.ts +0 -21
  41. package/src/observability/collector.ts +0 -346
  42. package/src/platform/routes.ts +0 -195
  43. package/src/server.ts +0 -447
  44. package/src/telemetry/routes.ts +0 -89
  45. package/src/ws/gateway-ws.ts +0 -73
  46. package/tsconfig.build.json +0 -15
  47. package/tsconfig.json +0 -10
  48. package/tsup.config.ts +0 -8
  49. 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
- });