@kb-labs/gateway-app 0.2.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/.kb/database/kb.sqlite-shm +0 -0
- package/.kb/database/kb.sqlite-wal +0 -0
- package/package.json +49 -0
- package/src/__tests__/auth-routes.test.ts +279 -0
- package/src/__tests__/execute-routes.test.ts +408 -0
- package/src/__tests__/execution-registry.test.ts +218 -0
- package/src/__tests__/health.test.ts +215 -0
- package/src/__tests__/live-gateway.e2e.test.ts +648 -0
- package/src/__tests__/llm-gateway.test.ts +361 -0
- package/src/__tests__/observability-collector.test.ts +59 -0
- package/src/__tests__/platform-api.test.ts +317 -0
- package/src/__tests__/registry.test.ts +546 -0
- package/src/__tests__/retry-executor.test.ts +244 -0
- package/src/__tests__/server.integration.test.ts +417 -0
- package/src/__tests__/subscription-registry.test.ts +308 -0
- package/src/__tests__/telemetry-ingest.test.ts +309 -0
- package/src/__tests__/tokens.test.ts +83 -0
- package/src/__tests__/ws-client-connect.e2e.test.ts +381 -0
- package/src/__tests__/ws-handshake.e2e.test.ts +288 -0
- package/src/auth/middleware.ts +50 -0
- package/src/auth/routes.ts +57 -0
- package/src/auth/tokens.ts +41 -0
- package/src/bootstrap.ts +98 -0
- package/src/clients/subscription-registry.ts +137 -0
- package/src/clients/ws-handler.ts +196 -0
- package/src/config.ts +20 -0
- package/src/docs/routes.ts +70 -0
- package/src/execute/errors.ts +21 -0
- package/src/execute/execution-registry.ts +84 -0
- package/src/execute/retry-executor.ts +159 -0
- package/src/execute/routes.ts +239 -0
- package/src/hosts/dispatcher.ts +2 -0
- package/src/hosts/registry.ts +305 -0
- package/src/hosts/ws-handler.ts +445 -0
- package/src/index.ts +7 -0
- package/src/llm/routes.ts +343 -0
- package/src/manifest.ts +21 -0
- package/src/observability/collector.ts +346 -0
- package/src/platform/routes.ts +195 -0
- package/src/server.ts +447 -0
- package/src/telemetry/routes.ts +89 -0
- package/src/ws/gateway-ws.ts +73 -0
- package/tsconfig.build.json +15 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +8 -0
- package/vitest.config.ts +23 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for SubscriptionRegistry (CC5 — Multi-Client Pub/Sub).
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
5
|
+
import { SubscriptionRegistry } from '../clients/subscription-registry.js';
|
|
6
|
+
import type { ExecutionEventMessage } from '@kb-labs/gateway-contracts';
|
|
7
|
+
|
|
8
|
+
// Minimal WebSocket mock with configurable readyState
|
|
9
|
+
function makeSocket(readyState = 1 /* OPEN */) {
|
|
10
|
+
return {
|
|
11
|
+
readyState,
|
|
12
|
+
OPEN: 1,
|
|
13
|
+
send: vi.fn(),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let registry: SubscriptionRegistry;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
registry = new SubscriptionRegistry();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// ── subscribe / unsubscribe ────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
describe('SubscriptionRegistry — subscribe / unsubscribe', () => {
|
|
26
|
+
it('subscribe adds to both indexes', () => {
|
|
27
|
+
registry.subscribe('conn-1', 'exec-1');
|
|
28
|
+
expect(registry.getSubscribers('exec-1').has('conn-1')).toBe(true);
|
|
29
|
+
expect(registry.getSubscriptions('conn-1').has('exec-1')).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('multiple connections can subscribe to same execution', () => {
|
|
33
|
+
registry.subscribe('conn-1', 'exec-1');
|
|
34
|
+
registry.subscribe('conn-2', 'exec-1');
|
|
35
|
+
const subs = registry.getSubscribers('exec-1');
|
|
36
|
+
expect(subs.has('conn-1')).toBe(true);
|
|
37
|
+
expect(subs.has('conn-2')).toBe(true);
|
|
38
|
+
expect(subs.size).toBe(2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('one connection can subscribe to multiple executions', () => {
|
|
42
|
+
registry.subscribe('conn-1', 'exec-1');
|
|
43
|
+
registry.subscribe('conn-1', 'exec-2');
|
|
44
|
+
const subs = registry.getSubscriptions('conn-1');
|
|
45
|
+
expect(subs.has('exec-1')).toBe(true);
|
|
46
|
+
expect(subs.has('exec-2')).toBe(true);
|
|
47
|
+
expect(subs.size).toBe(2);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('subscribe is idempotent', () => {
|
|
51
|
+
registry.subscribe('conn-1', 'exec-1');
|
|
52
|
+
registry.subscribe('conn-1', 'exec-1');
|
|
53
|
+
expect(registry.getSubscribers('exec-1').size).toBe(1);
|
|
54
|
+
expect(registry.getSubscriptions('conn-1').size).toBe(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('unsubscribe removes from both indexes', () => {
|
|
58
|
+
registry.subscribe('conn-1', 'exec-1');
|
|
59
|
+
registry.unsubscribe('conn-1', 'exec-1');
|
|
60
|
+
expect(registry.getSubscribers('exec-1').has('conn-1')).toBe(false);
|
|
61
|
+
expect(registry.getSubscriptions('conn-1').has('exec-1')).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('unsubscribe GCs empty sets', () => {
|
|
65
|
+
registry.subscribe('conn-1', 'exec-1');
|
|
66
|
+
registry.unsubscribe('conn-1', 'exec-1');
|
|
67
|
+
// getSubscribers returns an empty set (not the internal one), but size should be 0
|
|
68
|
+
expect(registry.getSubscribers('exec-1').size).toBe(0);
|
|
69
|
+
expect(registry.getSubscriptions('conn-1').size).toBe(0);
|
|
70
|
+
// connectionCount and subscriptionCount reflect no data
|
|
71
|
+
expect(registry.connectionCount).toBe(0);
|
|
72
|
+
expect(registry.subscriptionCount).toBe(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('unsubscribe only removes one connection, other subscribers remain', () => {
|
|
76
|
+
registry.subscribe('conn-1', 'exec-1');
|
|
77
|
+
registry.subscribe('conn-2', 'exec-1');
|
|
78
|
+
registry.unsubscribe('conn-1', 'exec-1');
|
|
79
|
+
const subs = registry.getSubscribers('exec-1');
|
|
80
|
+
expect(subs.has('conn-1')).toBe(false);
|
|
81
|
+
expect(subs.has('conn-2')).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('unsubscribe on non-existent connection/execution does not throw', () => {
|
|
85
|
+
expect(() => registry.unsubscribe('ghost', 'exec-1')).not.toThrow();
|
|
86
|
+
expect(() => registry.unsubscribe('conn-1', 'ghost')).not.toThrow();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── registerSocket / removeSocket ─────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
describe('SubscriptionRegistry — registerSocket / removeSocket', () => {
|
|
93
|
+
it('registerSocket stores socket for broadcast', () => {
|
|
94
|
+
const socket = makeSocket();
|
|
95
|
+
registry.registerSocket('conn-1', socket as never);
|
|
96
|
+
registry.subscribe('conn-1', 'exec-1');
|
|
97
|
+
|
|
98
|
+
const event: ExecutionEventMessage = {
|
|
99
|
+
type: 'execution:output',
|
|
100
|
+
requestId: 'req-1',
|
|
101
|
+
executionId: 'exec-1',
|
|
102
|
+
stream: 'stdout',
|
|
103
|
+
data: 'hello',
|
|
104
|
+
timestamp: Date.now(),
|
|
105
|
+
};
|
|
106
|
+
registry.broadcast('exec-1', event);
|
|
107
|
+
expect(socket.send).toHaveBeenCalledTimes(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('removeSocket prevents broadcast after removal', () => {
|
|
111
|
+
const socket = makeSocket();
|
|
112
|
+
registry.registerSocket('conn-1', socket as never);
|
|
113
|
+
registry.subscribe('conn-1', 'exec-1');
|
|
114
|
+
registry.removeSocket('conn-1');
|
|
115
|
+
|
|
116
|
+
const event: ExecutionEventMessage = {
|
|
117
|
+
type: 'execution:output',
|
|
118
|
+
requestId: 'req-1',
|
|
119
|
+
executionId: 'exec-1',
|
|
120
|
+
stream: 'stdout',
|
|
121
|
+
data: 'hello',
|
|
122
|
+
timestamp: Date.now(),
|
|
123
|
+
};
|
|
124
|
+
registry.broadcast('exec-1', event);
|
|
125
|
+
expect(socket.send).not.toHaveBeenCalled();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ── broadcast ─────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
describe('SubscriptionRegistry — broadcast', () => {
|
|
132
|
+
const makeEvent = (executionId: string): ExecutionEventMessage => ({
|
|
133
|
+
type: 'execution:output',
|
|
134
|
+
requestId: 'req-broadcast',
|
|
135
|
+
executionId,
|
|
136
|
+
stream: 'stdout',
|
|
137
|
+
data: 'output data',
|
|
138
|
+
timestamp: Date.now(),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('broadcasts to all open subscribers', () => {
|
|
142
|
+
const s1 = makeSocket();
|
|
143
|
+
const s2 = makeSocket();
|
|
144
|
+
registry.registerSocket('conn-1', s1 as never);
|
|
145
|
+
registry.registerSocket('conn-2', s2 as never);
|
|
146
|
+
registry.subscribe('conn-1', 'exec-1');
|
|
147
|
+
registry.subscribe('conn-2', 'exec-1');
|
|
148
|
+
|
|
149
|
+
registry.broadcast('exec-1', makeEvent('exec-1'));
|
|
150
|
+
expect(s1.send).toHaveBeenCalledTimes(1);
|
|
151
|
+
expect(s2.send).toHaveBeenCalledTimes(1);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('does not broadcast to closed sockets', () => {
|
|
155
|
+
const open = makeSocket(1); // OPEN
|
|
156
|
+
const closed = makeSocket(3); // CLOSED
|
|
157
|
+
registry.registerSocket('conn-open', open as never);
|
|
158
|
+
registry.registerSocket('conn-closed', closed as never);
|
|
159
|
+
registry.subscribe('conn-open', 'exec-1');
|
|
160
|
+
registry.subscribe('conn-closed', 'exec-1');
|
|
161
|
+
|
|
162
|
+
registry.broadcast('exec-1', makeEvent('exec-1'));
|
|
163
|
+
expect(open.send).toHaveBeenCalledTimes(1);
|
|
164
|
+
expect(closed.send).not.toHaveBeenCalled();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('does not broadcast to connections without a registered socket', () => {
|
|
168
|
+
registry.subscribe('conn-no-socket', 'exec-1');
|
|
169
|
+
// Should not throw
|
|
170
|
+
expect(() => registry.broadcast('exec-1', makeEvent('exec-1'))).not.toThrow();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('broadcast sends JSON-serialised event', () => {
|
|
174
|
+
const socket = makeSocket();
|
|
175
|
+
registry.registerSocket('conn-1', socket as never);
|
|
176
|
+
registry.subscribe('conn-1', 'exec-1');
|
|
177
|
+
|
|
178
|
+
const event = makeEvent('exec-1');
|
|
179
|
+
registry.broadcast('exec-1', event);
|
|
180
|
+
|
|
181
|
+
const sent = JSON.parse((socket.send as ReturnType<typeof vi.fn>).mock.calls[0]![0] as string) as unknown;
|
|
182
|
+
expect(sent).toEqual(event);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('broadcast to execution with no subscribers is a no-op', () => {
|
|
186
|
+
// Should not throw
|
|
187
|
+
expect(() => registry.broadcast('no-subscribers', makeEvent('no-subscribers'))).not.toThrow();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('broadcast only reaches subscribers of the target execution', () => {
|
|
191
|
+
const s1 = makeSocket();
|
|
192
|
+
const s2 = makeSocket();
|
|
193
|
+
registry.registerSocket('conn-1', s1 as never);
|
|
194
|
+
registry.registerSocket('conn-2', s2 as never);
|
|
195
|
+
registry.subscribe('conn-1', 'exec-1');
|
|
196
|
+
registry.subscribe('conn-2', 'exec-2'); // different execution
|
|
197
|
+
|
|
198
|
+
registry.broadcast('exec-1', makeEvent('exec-1'));
|
|
199
|
+
expect(s1.send).toHaveBeenCalledTimes(1);
|
|
200
|
+
expect(s2.send).not.toHaveBeenCalled(); // unrelated subscriber
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ── removeConnection ──────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
describe('SubscriptionRegistry — removeConnection', () => {
|
|
207
|
+
it('returns orphaned executionIds (those with zero subscribers after removal)', () => {
|
|
208
|
+
registry.subscribe('conn-1', 'exec-orphan');
|
|
209
|
+
const orphaned = registry.removeConnection('conn-1');
|
|
210
|
+
expect(orphaned).toContain('exec-orphan');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('does not return executionId still subscribed by another connection', () => {
|
|
214
|
+
registry.subscribe('conn-1', 'exec-shared');
|
|
215
|
+
registry.subscribe('conn-2', 'exec-shared');
|
|
216
|
+
const orphaned = registry.removeConnection('conn-1');
|
|
217
|
+
expect(orphaned).not.toContain('exec-shared');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('returns empty array if connection had no subscriptions', () => {
|
|
221
|
+
const orphaned = registry.removeConnection('ghost-conn');
|
|
222
|
+
expect(orphaned).toEqual([]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('cleans up all indexes for removed connection', () => {
|
|
226
|
+
const socket = makeSocket();
|
|
227
|
+
registry.registerSocket('conn-1', socket as never);
|
|
228
|
+
registry.subscribe('conn-1', 'exec-1');
|
|
229
|
+
registry.subscribe('conn-1', 'exec-2');
|
|
230
|
+
|
|
231
|
+
registry.removeConnection('conn-1');
|
|
232
|
+
|
|
233
|
+
expect(registry.connectionCount).toBe(0);
|
|
234
|
+
expect(registry.getSubscriptions('conn-1').size).toBe(0);
|
|
235
|
+
expect(registry.getSubscribers('exec-1').size).toBe(0);
|
|
236
|
+
expect(registry.getSubscribers('exec-2').size).toBe(0);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('removes socket during removeConnection', () => {
|
|
240
|
+
const socket = makeSocket();
|
|
241
|
+
registry.registerSocket('conn-1', socket as never);
|
|
242
|
+
registry.subscribe('conn-1', 'exec-1');
|
|
243
|
+
registry.removeConnection('conn-1');
|
|
244
|
+
|
|
245
|
+
// Broadcast should not reach the removed socket
|
|
246
|
+
const event: ExecutionEventMessage = {
|
|
247
|
+
type: 'execution:output',
|
|
248
|
+
requestId: 'req-late',
|
|
249
|
+
executionId: 'exec-1',
|
|
250
|
+
stream: 'stdout',
|
|
251
|
+
data: 'late',
|
|
252
|
+
timestamp: Date.now(),
|
|
253
|
+
};
|
|
254
|
+
// exec-1 was orphaned and removed, but even if called directly it should not throw
|
|
255
|
+
registry.broadcast('exec-1', event);
|
|
256
|
+
expect(socket.send).not.toHaveBeenCalled();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('returns orphaned list with multiple executions', () => {
|
|
260
|
+
registry.subscribe('conn-1', 'exec-a');
|
|
261
|
+
registry.subscribe('conn-1', 'exec-b');
|
|
262
|
+
// conn-2 also subscribes to exec-b → exec-b won't be orphaned
|
|
263
|
+
registry.subscribe('conn-2', 'exec-b');
|
|
264
|
+
|
|
265
|
+
const orphaned = registry.removeConnection('conn-1');
|
|
266
|
+
expect(orphaned).toContain('exec-a');
|
|
267
|
+
expect(orphaned).not.toContain('exec-b');
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ── connectionCount / subscriptionCount ───────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
describe('SubscriptionRegistry — metrics', () => {
|
|
274
|
+
it('connectionCount tracks unique connections with subscriptions', () => {
|
|
275
|
+
expect(registry.connectionCount).toBe(0);
|
|
276
|
+
registry.subscribe('conn-1', 'exec-1');
|
|
277
|
+
expect(registry.connectionCount).toBe(1);
|
|
278
|
+
registry.subscribe('conn-2', 'exec-1');
|
|
279
|
+
expect(registry.connectionCount).toBe(2);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('subscriptionCount is the sum of all (connection, execution) pairs', () => {
|
|
283
|
+
expect(registry.subscriptionCount).toBe(0);
|
|
284
|
+
registry.subscribe('conn-1', 'exec-1');
|
|
285
|
+
expect(registry.subscriptionCount).toBe(1);
|
|
286
|
+
registry.subscribe('conn-1', 'exec-2');
|
|
287
|
+
expect(registry.subscriptionCount).toBe(2);
|
|
288
|
+
registry.subscribe('conn-2', 'exec-1');
|
|
289
|
+
expect(registry.subscriptionCount).toBe(3);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('counts decrease after unsubscribe', () => {
|
|
293
|
+
registry.subscribe('conn-1', 'exec-1');
|
|
294
|
+
registry.subscribe('conn-1', 'exec-2');
|
|
295
|
+
registry.unsubscribe('conn-1', 'exec-1');
|
|
296
|
+
expect(registry.subscriptionCount).toBe(1);
|
|
297
|
+
expect(registry.connectionCount).toBe(1);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('counts reset to 0 after all connections removed', () => {
|
|
301
|
+
registry.subscribe('conn-1', 'exec-1');
|
|
302
|
+
registry.subscribe('conn-2', 'exec-1');
|
|
303
|
+
registry.removeConnection('conn-1');
|
|
304
|
+
registry.removeConnection('conn-2');
|
|
305
|
+
expect(registry.connectionCount).toBe(0);
|
|
306
|
+
expect(registry.subscriptionCount).toBe(0);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for Telemetry Ingestion endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* POST /telemetry/v1/ingest
|
|
6
|
+
* - 401 without auth
|
|
7
|
+
* - 400 with invalid body
|
|
8
|
+
* - 400 with empty events array
|
|
9
|
+
* - 503 when analytics not configured
|
|
10
|
+
* - 200 single event ingest
|
|
11
|
+
* - 200 batch ingest (multiple events)
|
|
12
|
+
* - 200 with default timestamp when omitted
|
|
13
|
+
* - 422 when all events fail
|
|
14
|
+
* - Partial success (some accepted, some rejected)
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
17
|
+
import Fastify, { type FastifyInstance } from 'fastify';
|
|
18
|
+
import type { ICache, ILogger, IAnalytics } from '@kb-labs/core-platform';
|
|
19
|
+
import type { JwtConfig } from '@kb-labs/gateway-auth';
|
|
20
|
+
import { createAuthMiddleware } from '../auth/middleware.js';
|
|
21
|
+
import { registerTelemetryRoutes } from '../telemetry/routes.js';
|
|
22
|
+
|
|
23
|
+
// ── Mocks ─────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
const mockAnalytics: IAnalytics = {
|
|
26
|
+
track: vi.fn(),
|
|
27
|
+
identify: vi.fn(),
|
|
28
|
+
flush: vi.fn(),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
let analyticsEnabled = true;
|
|
32
|
+
|
|
33
|
+
vi.mock('@kb-labs/core-runtime', () => ({
|
|
34
|
+
platform: {
|
|
35
|
+
get analytics() { return analyticsEnabled ? mockAnalytics : undefined; },
|
|
36
|
+
},
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
function makeCache(): ICache {
|
|
40
|
+
const store = new Map<string, unknown>();
|
|
41
|
+
return {
|
|
42
|
+
async get<T>(k: string) { return (store.get(k) as T) ?? null; },
|
|
43
|
+
async set(k: string, v: unknown) { store.set(k, v); },
|
|
44
|
+
async delete(k: string) { store.delete(k); },
|
|
45
|
+
async clear() { store.clear(); },
|
|
46
|
+
} as unknown as ICache;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const noopLogger: ILogger = {
|
|
50
|
+
info: vi.fn(),
|
|
51
|
+
warn: vi.fn(),
|
|
52
|
+
error: vi.fn(),
|
|
53
|
+
debug: vi.fn(),
|
|
54
|
+
child: vi.fn(() => noopLogger),
|
|
55
|
+
} as unknown as ILogger;
|
|
56
|
+
|
|
57
|
+
const stubJwtConfig: JwtConfig = { secret: 'test-secret' };
|
|
58
|
+
const TEST_TOKEN = 'test-telemetry-token';
|
|
59
|
+
const TEST_AUTH_HEADER = `Bearer ${TEST_TOKEN}`;
|
|
60
|
+
|
|
61
|
+
// ── Test app builder ──────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
async function buildApp(): Promise<FastifyInstance> {
|
|
64
|
+
const cache = makeCache();
|
|
65
|
+
await cache.set(`host:token:${TEST_TOKEN}`, {
|
|
66
|
+
hostId: 'host-test',
|
|
67
|
+
namespaceId: 'ns-test',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const app = Fastify({ logger: false });
|
|
71
|
+
await app.register(async function scope(s) {
|
|
72
|
+
s.addHook('onRequest', createAuthMiddleware(cache, stubJwtConfig));
|
|
73
|
+
registerTelemetryRoutes(s as any, noopLogger);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await app.ready();
|
|
77
|
+
return app;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
function makeIngestPayload(overrides: Record<string, unknown> = {}) {
|
|
83
|
+
return {
|
|
84
|
+
events: [
|
|
85
|
+
{
|
|
86
|
+
source: 'my-product',
|
|
87
|
+
type: 'user.signup',
|
|
88
|
+
payload: { plan: 'pro' },
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
...overrides,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Tests ─────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
describe('POST /telemetry/v1/ingest', () => {
|
|
98
|
+
let app: FastifyInstance;
|
|
99
|
+
|
|
100
|
+
beforeEach(async () => {
|
|
101
|
+
vi.clearAllMocks();
|
|
102
|
+
analyticsEnabled = true;
|
|
103
|
+
app = await buildApp();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
afterEach(async () => {
|
|
107
|
+
await app.close();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ── Auth ──────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
it('returns 401 without auth token', async () => {
|
|
113
|
+
const res = await app.inject({
|
|
114
|
+
method: 'POST',
|
|
115
|
+
url: '/telemetry/v1/ingest',
|
|
116
|
+
payload: makeIngestPayload(),
|
|
117
|
+
});
|
|
118
|
+
expect(res.statusCode).toBe(401);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ── Validation ────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
it('returns 400 with empty body', async () => {
|
|
124
|
+
const res = await app.inject({
|
|
125
|
+
method: 'POST',
|
|
126
|
+
url: '/telemetry/v1/ingest',
|
|
127
|
+
headers: { authorization: TEST_AUTH_HEADER },
|
|
128
|
+
payload: {},
|
|
129
|
+
});
|
|
130
|
+
expect(res.statusCode).toBe(400);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('returns 400 with empty events array', async () => {
|
|
134
|
+
const res = await app.inject({
|
|
135
|
+
method: 'POST',
|
|
136
|
+
url: '/telemetry/v1/ingest',
|
|
137
|
+
headers: { authorization: TEST_AUTH_HEADER },
|
|
138
|
+
payload: { events: [] },
|
|
139
|
+
});
|
|
140
|
+
expect(res.statusCode).toBe(400);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('returns 400 when event missing required source field', async () => {
|
|
144
|
+
const res = await app.inject({
|
|
145
|
+
method: 'POST',
|
|
146
|
+
url: '/telemetry/v1/ingest',
|
|
147
|
+
headers: { authorization: TEST_AUTH_HEADER },
|
|
148
|
+
payload: { events: [{ type: 'test' }] }, // missing source
|
|
149
|
+
});
|
|
150
|
+
expect(res.statusCode).toBe(400);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ── Analytics unavailable ─────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
it('returns 503 when analytics adapter not configured', async () => {
|
|
156
|
+
analyticsEnabled = false;
|
|
157
|
+
const res = await app.inject({
|
|
158
|
+
method: 'POST',
|
|
159
|
+
url: '/telemetry/v1/ingest',
|
|
160
|
+
headers: { authorization: TEST_AUTH_HEADER },
|
|
161
|
+
payload: makeIngestPayload(),
|
|
162
|
+
});
|
|
163
|
+
expect(res.statusCode).toBe(503);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ── Happy path ────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
it('ingests single event and calls analytics.track()', async () => {
|
|
169
|
+
(mockAnalytics.track as any).mockResolvedValue(undefined);
|
|
170
|
+
|
|
171
|
+
const res = await app.inject({
|
|
172
|
+
method: 'POST',
|
|
173
|
+
url: '/telemetry/v1/ingest',
|
|
174
|
+
headers: { authorization: TEST_AUTH_HEADER },
|
|
175
|
+
payload: makeIngestPayload(),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(res.statusCode).toBe(200);
|
|
179
|
+
const body = res.json();
|
|
180
|
+
expect(body.accepted).toBe(1);
|
|
181
|
+
expect(body.rejected).toBe(0);
|
|
182
|
+
|
|
183
|
+
expect(mockAnalytics.track).toHaveBeenCalledTimes(1);
|
|
184
|
+
expect(mockAnalytics.track).toHaveBeenCalledWith(
|
|
185
|
+
'user.signup',
|
|
186
|
+
expect.objectContaining({
|
|
187
|
+
_source: 'my-product',
|
|
188
|
+
_tenantId: 'ns-test',
|
|
189
|
+
plan: 'pro',
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('ingests batch of multiple events', async () => {
|
|
195
|
+
(mockAnalytics.track as any).mockResolvedValue(undefined);
|
|
196
|
+
|
|
197
|
+
const res = await app.inject({
|
|
198
|
+
method: 'POST',
|
|
199
|
+
url: '/telemetry/v1/ingest',
|
|
200
|
+
headers: { authorization: TEST_AUTH_HEADER },
|
|
201
|
+
payload: {
|
|
202
|
+
events: [
|
|
203
|
+
{ source: 'api', type: 'request', payload: { path: '/a' } },
|
|
204
|
+
{ source: 'api', type: 'request', payload: { path: '/b' } },
|
|
205
|
+
{ source: 'api', type: 'error', payload: { code: 500 } },
|
|
206
|
+
],
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
expect(res.statusCode).toBe(200);
|
|
211
|
+
const body = res.json();
|
|
212
|
+
expect(body.accepted).toBe(3);
|
|
213
|
+
expect(body.rejected).toBe(0);
|
|
214
|
+
expect(mockAnalytics.track).toHaveBeenCalledTimes(3);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('uses current timestamp when event.timestamp is omitted', async () => {
|
|
218
|
+
(mockAnalytics.track as any).mockResolvedValue(undefined);
|
|
219
|
+
|
|
220
|
+
await app.inject({
|
|
221
|
+
method: 'POST',
|
|
222
|
+
url: '/telemetry/v1/ingest',
|
|
223
|
+
headers: { authorization: TEST_AUTH_HEADER },
|
|
224
|
+
payload: makeIngestPayload(),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const trackCall = (mockAnalytics.track as any).mock.calls[0];
|
|
228
|
+
expect(trackCall[1]._ts).toBeDefined();
|
|
229
|
+
// Should be a valid ISO string
|
|
230
|
+
expect(new Date(trackCall[1]._ts).getTime()).toBeGreaterThan(0);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('passes tags as flat properties', async () => {
|
|
234
|
+
(mockAnalytics.track as any).mockResolvedValue(undefined);
|
|
235
|
+
|
|
236
|
+
await app.inject({
|
|
237
|
+
method: 'POST',
|
|
238
|
+
url: '/telemetry/v1/ingest',
|
|
239
|
+
headers: { authorization: TEST_AUTH_HEADER },
|
|
240
|
+
payload: {
|
|
241
|
+
events: [
|
|
242
|
+
{
|
|
243
|
+
source: 'my-app',
|
|
244
|
+
type: 'deploy',
|
|
245
|
+
tags: { env: 'prod', region: 'eu' },
|
|
246
|
+
payload: { version: '1.2.3' },
|
|
247
|
+
},
|
|
248
|
+
],
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
expect(mockAnalytics.track).toHaveBeenCalledWith(
|
|
253
|
+
'deploy',
|
|
254
|
+
expect.objectContaining({
|
|
255
|
+
env: 'prod',
|
|
256
|
+
region: 'eu',
|
|
257
|
+
version: '1.2.3',
|
|
258
|
+
}),
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// ── Error handling ────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
it('returns 422 when all events fail', async () => {
|
|
265
|
+
(mockAnalytics.track as any).mockRejectedValue(new Error('DB write failed'));
|
|
266
|
+
|
|
267
|
+
const res = await app.inject({
|
|
268
|
+
method: 'POST',
|
|
269
|
+
url: '/telemetry/v1/ingest',
|
|
270
|
+
headers: { authorization: TEST_AUTH_HEADER },
|
|
271
|
+
payload: makeIngestPayload(),
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
expect(res.statusCode).toBe(422);
|
|
275
|
+
const body = res.json();
|
|
276
|
+
expect(body.accepted).toBe(0);
|
|
277
|
+
expect(body.rejected).toBe(1);
|
|
278
|
+
expect(body.errors).toHaveLength(1);
|
|
279
|
+
expect(body.errors[0].message).toContain('DB write failed');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('handles partial failure (some events succeed, some fail)', async () => {
|
|
283
|
+
let callCount = 0;
|
|
284
|
+
(mockAnalytics.track as any).mockImplementation(async () => {
|
|
285
|
+
callCount++;
|
|
286
|
+
if (callCount === 2) {throw new Error('Failed event 2');}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const res = await app.inject({
|
|
290
|
+
method: 'POST',
|
|
291
|
+
url: '/telemetry/v1/ingest',
|
|
292
|
+
headers: { authorization: TEST_AUTH_HEADER },
|
|
293
|
+
payload: {
|
|
294
|
+
events: [
|
|
295
|
+
{ source: 'app', type: 'ok1' },
|
|
296
|
+
{ source: 'app', type: 'fail' },
|
|
297
|
+
{ source: 'app', type: 'ok2' },
|
|
298
|
+
],
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
expect(res.statusCode).toBe(200); // partial success = 200
|
|
303
|
+
const body = res.json();
|
|
304
|
+
expect(body.accepted).toBe(2);
|
|
305
|
+
expect(body.rejected).toBe(1);
|
|
306
|
+
expect(body.errors).toHaveLength(1);
|
|
307
|
+
expect(body.errors[0].index).toBe(1);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { resolveToken, extractBearerToken } from '../auth/tokens.js';
|
|
3
|
+
import type { ICache } from '@kb-labs/core-platform';
|
|
4
|
+
import type { JwtConfig } from '@kb-labs/gateway-auth';
|
|
5
|
+
|
|
6
|
+
function makeCache(entries: Record<string, unknown> = {}): ICache {
|
|
7
|
+
return {
|
|
8
|
+
get: vi.fn(async (key: string) => entries[key] ?? null),
|
|
9
|
+
set: vi.fn(),
|
|
10
|
+
delete: vi.fn(),
|
|
11
|
+
clear: vi.fn(),
|
|
12
|
+
} as unknown as ICache;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('extractBearerToken', () => {
|
|
16
|
+
it('extracts token from Bearer header', () => {
|
|
17
|
+
expect(extractBearerToken('Bearer abc-123')).toBe('abc-123');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('is case-insensitive', () => {
|
|
21
|
+
expect(extractBearerToken('bearer abc-123')).toBe('abc-123');
|
|
22
|
+
expect(extractBearerToken('BEARER abc-123')).toBe('abc-123');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('returns null for missing header', () => {
|
|
26
|
+
expect(extractBearerToken(undefined)).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns null for non-Bearer scheme', () => {
|
|
30
|
+
expect(extractBearerToken('Basic dXNlcjpwYXNz')).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns null for empty string', () => {
|
|
34
|
+
expect(extractBearerToken('')).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('handles token with special characters', () => {
|
|
38
|
+
expect(extractBearerToken('Bearer 8d006616-9c5e-466f-a72f-1c6a6dc20a60')).toBe(
|
|
39
|
+
'8d006616-9c5e-466f-a72f-1c6a6dc20a60',
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const stubJwtConfig: JwtConfig = { secret: 'test-secret' };
|
|
45
|
+
|
|
46
|
+
describe('resolveToken', () => {
|
|
47
|
+
it('resolves machine token from cache', async () => {
|
|
48
|
+
const token = 'machine-token-uuid';
|
|
49
|
+
const cache = makeCache({
|
|
50
|
+
[`host:token:${token}`]: { hostId: 'host-1', namespaceId: 'ns-1' },
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const ctx = await resolveToken(token, cache, stubJwtConfig);
|
|
54
|
+
expect(ctx).not.toBeNull();
|
|
55
|
+
expect(ctx!.type).toBe('machine');
|
|
56
|
+
expect(ctx!.userId).toBe('host-1');
|
|
57
|
+
expect(ctx!.namespaceId).toBe('ns-1');
|
|
58
|
+
expect(ctx!.permissions).toContain('host:connect');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('returns null for unknown token (no CLI fallback)', async () => {
|
|
62
|
+
const cache = makeCache(); // no entries
|
|
63
|
+
const ctx = await resolveToken('some-unknown-token', cache, stubJwtConfig);
|
|
64
|
+
expect(ctx).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('machine token resolves correctly, unknown token returns null', async () => {
|
|
68
|
+
const token = 'machine-uuid';
|
|
69
|
+
const cache = makeCache({ [`host:token:${token}`]: { hostId: 'h-1', namespaceId: 'ns-a' } });
|
|
70
|
+
const ctx = await resolveToken(token, cache, stubJwtConfig);
|
|
71
|
+
expect(ctx!.type).toBe('machine');
|
|
72
|
+
|
|
73
|
+
const unknown = await resolveToken('other-token', cache, stubJwtConfig);
|
|
74
|
+
expect(unknown).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('checks correct cache key for machine token', async () => {
|
|
78
|
+
const token = 'test-token';
|
|
79
|
+
const cache = makeCache();
|
|
80
|
+
await resolveToken(token, cache, stubJwtConfig);
|
|
81
|
+
expect(cache.get).toHaveBeenCalledWith(`host:token:${token}`);
|
|
82
|
+
});
|
|
83
|
+
});
|