@syncular/server-hono 0.0.4-26 → 0.0.6-100

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 (51) hide show
  1. package/dist/console/gateway.d.ts +3 -1
  2. package/dist/console/gateway.d.ts.map +1 -1
  3. package/dist/console/gateway.js +218 -41
  4. package/dist/console/gateway.js.map +1 -1
  5. package/dist/console/index.d.ts +1 -0
  6. package/dist/console/index.d.ts.map +1 -1
  7. package/dist/console/index.js +1 -0
  8. package/dist/console/index.js.map +1 -1
  9. package/dist/console/routes.d.ts +3 -97
  10. package/dist/console/routes.d.ts.map +1 -1
  11. package/dist/console/routes.js +507 -80
  12. package/dist/console/routes.js.map +1 -1
  13. package/dist/console/schemas.d.ts +29 -0
  14. package/dist/console/schemas.d.ts.map +1 -1
  15. package/dist/console/schemas.js +22 -0
  16. package/dist/console/schemas.js.map +1 -1
  17. package/dist/console/types.d.ts +175 -0
  18. package/dist/console/types.d.ts.map +1 -0
  19. package/dist/console/types.js +2 -0
  20. package/dist/console/types.js.map +1 -0
  21. package/dist/create-server.d.ts +17 -34
  22. package/dist/create-server.d.ts.map +1 -1
  23. package/dist/create-server.js +26 -26
  24. package/dist/create-server.js.map +1 -1
  25. package/dist/proxy/connection-manager.d.ts +3 -3
  26. package/dist/proxy/connection-manager.d.ts.map +1 -1
  27. package/dist/proxy/routes.d.ts +4 -4
  28. package/dist/proxy/routes.d.ts.map +1 -1
  29. package/dist/proxy/routes.js +1 -1
  30. package/dist/routes.d.ts +33 -9
  31. package/dist/routes.d.ts.map +1 -1
  32. package/dist/routes.js +153 -70
  33. package/dist/routes.js.map +1 -1
  34. package/package.json +21 -7
  35. package/src/__tests__/blob-routes.test.ts +424 -0
  36. package/src/__tests__/console-gateway-live-routes.test.ts +54 -3
  37. package/src/__tests__/console-routes.test.ts +161 -7
  38. package/src/__tests__/console-ui.test.ts +114 -0
  39. package/src/__tests__/create-server.test.ts +233 -10
  40. package/src/__tests__/pull-chunk-storage.test.ts +6 -2
  41. package/src/__tests__/realtime-bridge.test.ts +6 -2
  42. package/src/__tests__/sync-rate-limit-routing.test.ts +6 -2
  43. package/src/console/gateway.ts +277 -53
  44. package/src/console/index.ts +1 -0
  45. package/src/console/routes.ts +654 -198
  46. package/src/console/schemas.ts +29 -0
  47. package/src/console/types.ts +185 -0
  48. package/src/create-server.ts +56 -53
  49. package/src/proxy/connection-manager.ts +3 -3
  50. package/src/proxy/routes.ts +4 -4
  51. package/src/routes.ts +225 -96
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@syncular/server-hono",
3
- "version": "0.0.4-26",
3
+ "version": "0.0.6-100",
4
4
  "description": "Hono adapter for the Syncular server with OpenAPI support",
5
- "license": "MIT",
5
+ "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
7
7
  "homepage": "https://syncular.dev",
8
8
  "repository": {
@@ -34,6 +34,20 @@
34
34
  "types": "./dist/index.d.ts",
35
35
  "default": "./dist/index.js"
36
36
  }
37
+ },
38
+ "./blobs": {
39
+ "bun": "./src/blobs.ts",
40
+ "import": {
41
+ "types": "./dist/blobs.d.ts",
42
+ "default": "./dist/blobs.js"
43
+ }
44
+ },
45
+ "./create-server": {
46
+ "bun": "./src/create-server.ts",
47
+ "import": {
48
+ "types": "./dist/create-server.d.ts",
49
+ "default": "./dist/create-server.js"
50
+ }
37
51
  }
38
52
  },
39
53
  "scripts": {
@@ -48,17 +62,17 @@
48
62
  "@hono/standard-validator": "^0.2.2",
49
63
  "@standard-community/standard-json": "^0.3.5",
50
64
  "@standard-community/standard-openapi": "^0.2.9",
51
- "@syncular/console": "0.0.4-26",
52
- "@syncular/core": "0.0.4-26",
53
- "@syncular/server": "0.0.4-26",
65
+ "@syncular/console": "0.0.6-100",
66
+ "@syncular/core": "0.0.6-100",
67
+ "@syncular/server": "0.0.6-100",
54
68
  "@types/json-schema": "^7.0.15",
55
69
  "hono-openapi": "^1.2.0",
56
70
  "openapi-types": "^12.1.3"
57
71
  },
58
72
  "devDependencies": {
59
73
  "@syncular/config": "0.0.0",
60
- "@syncular/dialect-pglite": "0.0.4-26",
61
- "@syncular/server-dialect-postgres": "0.0.4-26",
74
+ "@syncular/dialect-pglite": "0.0.6-100",
75
+ "@syncular/server-dialect-postgres": "0.0.6-100",
62
76
  "kysely": "*",
63
77
  "zod": "*"
64
78
  },
@@ -0,0 +1,424 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import type { BlobStorageAdapter } from '@syncular/core';
3
+ import { createDatabase } from '@syncular/core';
4
+ import {
5
+ type BlobTokenSigner,
6
+ createBlobManager,
7
+ createDatabaseBlobStorageAdapter,
8
+ createHmacTokenSigner,
9
+ ensureBlobStorageSchemaSqlite,
10
+ type SyncBlobDb,
11
+ } from '@syncular/server';
12
+ import { Hono } from 'hono';
13
+ import type { Kysely } from 'kysely';
14
+ import { createBunSqliteDialect } from '../../../dialect-bun-sqlite/src';
15
+ import { createBlobRoutes } from '../blobs';
16
+
17
+ interface UploadInitResponse {
18
+ exists: boolean;
19
+ uploadUrl?: string;
20
+ uploadMethod?: 'PUT' | 'POST';
21
+ }
22
+
23
+ interface UrlResponse {
24
+ url: string;
25
+ }
26
+
27
+ interface CompleteResponse {
28
+ ok: boolean;
29
+ error?: string;
30
+ }
31
+
32
+ const ACTOR_HEADER = 'x-user-id';
33
+ const ACTOR_ID = 'user-1';
34
+ const INVALID_HASH = 'invalid-hash';
35
+
36
+ function toHex(bytes: Uint8Array): string {
37
+ return Array.from(bytes)
38
+ .map((value) => value.toString(16).padStart(2, '0'))
39
+ .join('');
40
+ }
41
+
42
+ async function createHash(bytes: Uint8Array): Promise<string> {
43
+ const digest = await crypto.subtle.digest('SHA-256', bytes);
44
+ return `sha256:${toHex(new Uint8Array(digest))}`;
45
+ }
46
+
47
+ async function signBlobToken(args: {
48
+ signer: BlobTokenSigner;
49
+ hash: string;
50
+ action: 'upload' | 'download';
51
+ }): Promise<string> {
52
+ return args.signer.sign(
53
+ {
54
+ hash: args.hash,
55
+ action: args.action,
56
+ expiresAt: Date.now() + 60_000,
57
+ },
58
+ 60
59
+ );
60
+ }
61
+
62
+ function createDefaultAdapter(
63
+ db: Kysely<SyncBlobDb>,
64
+ tokenSigner: BlobTokenSigner
65
+ ): BlobStorageAdapter {
66
+ return createDatabaseBlobStorageAdapter({
67
+ db,
68
+ baseUrl: 'http://localhost/sync',
69
+ tokenSigner,
70
+ });
71
+ }
72
+
73
+ function createFallbackAdapter(
74
+ db: Kysely<SyncBlobDb>,
75
+ tokenSigner: BlobTokenSigner
76
+ ): BlobStorageAdapter {
77
+ const adapter = createDefaultAdapter(db, tokenSigner);
78
+ return {
79
+ name: 'database-fallback',
80
+ signUpload: adapter.signUpload,
81
+ signDownload: adapter.signDownload,
82
+ exists: adapter.exists,
83
+ delete: adapter.delete,
84
+ getMetadata: adapter.getMetadata,
85
+ };
86
+ }
87
+
88
+ function buildApp(args: {
89
+ db: Kysely<SyncBlobDb>;
90
+ tokenSigner: BlobTokenSigner;
91
+ adapter: BlobStorageAdapter;
92
+ authenticate?: (
93
+ c: Parameters<typeof createBlobRoutes>[0]['authenticate']
94
+ ) => ReturnType<Parameters<typeof createBlobRoutes>[0]['authenticate']>;
95
+ canAccessBlob?: Parameters<typeof createBlobRoutes>[0]['canAccessBlob'];
96
+ }): Hono {
97
+ const blobManager = createBlobManager({
98
+ db: args.db,
99
+ adapter: args.adapter,
100
+ });
101
+
102
+ const app = new Hono();
103
+ app.route(
104
+ '/sync',
105
+ createBlobRoutes({
106
+ blobManager,
107
+ authenticate: async (c) => {
108
+ if (args.authenticate) {
109
+ return args.authenticate(c);
110
+ }
111
+ const actorId = c.req.header(ACTOR_HEADER);
112
+ return actorId ? { actorId } : null;
113
+ },
114
+ tokenSigner: args.tokenSigner,
115
+ db: args.db,
116
+ canAccessBlob: args.canAccessBlob,
117
+ })
118
+ );
119
+ return app;
120
+ }
121
+
122
+ async function initiateUpload(args: {
123
+ app: Hono;
124
+ hash: string;
125
+ size: number;
126
+ mimeType?: string;
127
+ }): Promise<UploadInitResponse> {
128
+ const response = await args.app.request(
129
+ 'http://localhost/sync/blobs/upload',
130
+ {
131
+ method: 'POST',
132
+ headers: {
133
+ 'content-type': 'application/json',
134
+ [ACTOR_HEADER]: ACTOR_ID,
135
+ },
136
+ body: JSON.stringify({
137
+ hash: args.hash,
138
+ size: args.size,
139
+ mimeType: args.mimeType ?? 'application/octet-stream',
140
+ }),
141
+ }
142
+ );
143
+
144
+ expect(response.status).toBe(200);
145
+ return (await response.json()) as UploadInitResponse;
146
+ }
147
+
148
+ describe('createBlobRoutes', () => {
149
+ let db: Kysely<SyncBlobDb>;
150
+ let tokenSigner: BlobTokenSigner;
151
+
152
+ beforeEach(async () => {
153
+ db = createDatabase<SyncBlobDb>({
154
+ dialect: createBunSqliteDialect({ path: ':memory:' }),
155
+ family: 'sqlite',
156
+ });
157
+ await ensureBlobStorageSchemaSqlite(db);
158
+ tokenSigner = createHmacTokenSigner('blob-route-test-secret');
159
+ });
160
+
161
+ afterEach(async () => {
162
+ await db.destroy();
163
+ });
164
+
165
+ it('rejects unauthenticated upload initiation', async () => {
166
+ const app = buildApp({
167
+ db,
168
+ tokenSigner,
169
+ adapter: createDefaultAdapter(db, tokenSigner),
170
+ authenticate: async () => null,
171
+ });
172
+
173
+ const hash = await createHash(new Uint8Array([1, 2, 3]));
174
+ const response = await app.request('http://localhost/sync/blobs/upload', {
175
+ method: 'POST',
176
+ headers: { 'content-type': 'application/json' },
177
+ body: JSON.stringify({
178
+ hash,
179
+ size: 3,
180
+ mimeType: 'application/octet-stream',
181
+ }),
182
+ });
183
+
184
+ expect(response.status).toBe(401);
185
+ expect(await response.json()).toEqual({ error: 'UNAUTHENTICATED' });
186
+ });
187
+
188
+ it('rejects invalid direct-upload tokens', async () => {
189
+ const app = buildApp({
190
+ db,
191
+ tokenSigner,
192
+ adapter: createDefaultAdapter(db, tokenSigner),
193
+ });
194
+
195
+ const response = await app.request(
196
+ `http://localhost/sync/blobs/${encodeURIComponent(`sha256:${'a'.repeat(64)}`)}/upload?token=invalid-token`,
197
+ {
198
+ method: 'PUT',
199
+ body: new Uint8Array([1, 2, 3]),
200
+ }
201
+ );
202
+
203
+ expect(response.status).toBe(401);
204
+ expect(await response.json()).toEqual({ error: 'INVALID_TOKEN' });
205
+ });
206
+
207
+ it('rejects direct upload when body size does not match metadata', async () => {
208
+ const app = buildApp({
209
+ db,
210
+ tokenSigner,
211
+ adapter: createDefaultAdapter(db, tokenSigner),
212
+ });
213
+
214
+ const content = new Uint8Array([1, 2, 3, 4]);
215
+ const hash = await createHash(content);
216
+ const init = await initiateUpload({
217
+ app,
218
+ hash,
219
+ size: content.length,
220
+ });
221
+
222
+ const firstUpload = await app.request(init.uploadUrl!, {
223
+ method: init.uploadMethod ?? 'PUT',
224
+ body: content,
225
+ });
226
+ expect(firstUpload.status).toBe(200);
227
+
228
+ const complete = await app.request(
229
+ `http://localhost/sync/blobs/${encodeURIComponent(hash)}/complete`,
230
+ {
231
+ method: 'POST',
232
+ headers: { [ACTOR_HEADER]: ACTOR_ID },
233
+ }
234
+ );
235
+ expect(complete.status).toBe(200);
236
+
237
+ const token = await signBlobToken({
238
+ signer: tokenSigner,
239
+ hash,
240
+ action: 'upload',
241
+ });
242
+
243
+ const response = await app.request(
244
+ `http://localhost/sync/blobs/${encodeURIComponent(hash)}/upload?token=${encodeURIComponent(token)}`,
245
+ {
246
+ method: 'PUT',
247
+ body: new Uint8Array([1, 2, 3]),
248
+ }
249
+ );
250
+
251
+ expect(response.status).toBe(400);
252
+ const payload = (await response.json()) as { error: string };
253
+ expect(payload.error).toBe('SIZE_MISMATCH');
254
+ });
255
+
256
+ it('rejects direct upload when body hash does not match route hash', async () => {
257
+ const app = buildApp({
258
+ db,
259
+ tokenSigner,
260
+ adapter: createDefaultAdapter(db, tokenSigner),
261
+ });
262
+
263
+ const expected = new Uint8Array([1, 2, 3, 4]);
264
+ const hash = await createHash(expected);
265
+ await initiateUpload({
266
+ app,
267
+ hash,
268
+ size: expected.length,
269
+ });
270
+
271
+ const token = await signBlobToken({
272
+ signer: tokenSigner,
273
+ hash,
274
+ action: 'upload',
275
+ });
276
+
277
+ const response = await app.request(
278
+ `http://localhost/sync/blobs/${encodeURIComponent(hash)}/upload?token=${encodeURIComponent(token)}`,
279
+ {
280
+ method: 'PUT',
281
+ body: new Uint8Array([9, 9, 9, 9]),
282
+ }
283
+ );
284
+
285
+ expect(response.status).toBe(400);
286
+ const payload = (await response.json()) as { error: string };
287
+ expect(payload.error).toBe('HASH_MISMATCH');
288
+ });
289
+
290
+ it('returns 404 for invalid hash format and 403 for forbidden actor access', async () => {
291
+ const app = buildApp({
292
+ db,
293
+ tokenSigner,
294
+ adapter: createDefaultAdapter(db, tokenSigner),
295
+ canAccessBlob: async () => false,
296
+ });
297
+
298
+ const invalidHashResponse = await app.request(
299
+ `http://localhost/sync/blobs/${INVALID_HASH}/url`,
300
+ {
301
+ headers: { [ACTOR_HEADER]: ACTOR_ID },
302
+ }
303
+ );
304
+ expect(invalidHashResponse.status).toBe(404);
305
+ expect(await invalidHashResponse.json()).toEqual({ error: 'NOT_FOUND' });
306
+
307
+ const validHash = `sha256:${'b'.repeat(64)}`;
308
+ const forbiddenResponse = await app.request(
309
+ `http://localhost/sync/blobs/${encodeURIComponent(validHash)}/url`,
310
+ {
311
+ headers: { [ACTOR_HEADER]: ACTOR_ID },
312
+ }
313
+ );
314
+ expect(forbiddenResponse.status).toBe(403);
315
+ expect(await forbiddenResponse.json()).toEqual({ error: 'FORBIDDEN' });
316
+ });
317
+
318
+ it('uploads and downloads blobs through adapter put/get branches', async () => {
319
+ const app = buildApp({
320
+ db,
321
+ tokenSigner,
322
+ adapter: createDefaultAdapter(db, tokenSigner),
323
+ });
324
+
325
+ const content = new TextEncoder().encode('adapter-route-content');
326
+ const hash = await createHash(content);
327
+ const init = await initiateUpload({
328
+ app,
329
+ hash,
330
+ size: content.length,
331
+ mimeType: 'text/plain',
332
+ });
333
+
334
+ expect(init.exists).toBe(false);
335
+ expect(typeof init.uploadUrl).toBe('string');
336
+
337
+ const uploadResponse = await app.request(init.uploadUrl!, {
338
+ method: init.uploadMethod ?? 'PUT',
339
+ headers: { 'content-type': 'text/plain' },
340
+ body: content,
341
+ });
342
+ expect(uploadResponse.status).toBe(200);
343
+
344
+ const completeResponse = await app.request(
345
+ `http://localhost/sync/blobs/${encodeURIComponent(hash)}/complete`,
346
+ {
347
+ method: 'POST',
348
+ headers: { [ACTOR_HEADER]: ACTOR_ID },
349
+ }
350
+ );
351
+ expect(completeResponse.status).toBe(200);
352
+ expect((await completeResponse.json()) as CompleteResponse).toEqual({
353
+ ok: true,
354
+ metadata: expect.anything(),
355
+ });
356
+
357
+ const downloadUrlResponse = await app.request(
358
+ `http://localhost/sync/blobs/${encodeURIComponent(hash)}/url`,
359
+ {
360
+ headers: { [ACTOR_HEADER]: ACTOR_ID },
361
+ }
362
+ );
363
+ expect(downloadUrlResponse.status).toBe(200);
364
+ const { url } = (await downloadUrlResponse.json()) as UrlResponse;
365
+
366
+ const downloadResponse = await app.request(url);
367
+ expect(downloadResponse.status).toBe(200);
368
+ expect(new Uint8Array(await downloadResponse.arrayBuffer())).toEqual(
369
+ content
370
+ );
371
+ });
372
+
373
+ it('uploads and downloads blobs through DB fallback branches when adapter lacks put/get', async () => {
374
+ const app = buildApp({
375
+ db,
376
+ tokenSigner,
377
+ adapter: createFallbackAdapter(db, tokenSigner),
378
+ });
379
+
380
+ const content = new TextEncoder().encode('database-fallback-content');
381
+ const hash = await createHash(content);
382
+ const init = await initiateUpload({
383
+ app,
384
+ hash,
385
+ size: content.length,
386
+ mimeType: 'text/plain',
387
+ });
388
+
389
+ const uploadResponse = await app.request(init.uploadUrl!, {
390
+ method: init.uploadMethod ?? 'PUT',
391
+ headers: { 'content-type': 'text/plain' },
392
+ body: content,
393
+ });
394
+ expect(uploadResponse.status).toBe(200);
395
+
396
+ const completeResponse = await app.request(
397
+ `http://localhost/sync/blobs/${encodeURIComponent(hash)}/complete`,
398
+ {
399
+ method: 'POST',
400
+ headers: { [ACTOR_HEADER]: ACTOR_ID },
401
+ }
402
+ );
403
+ expect(completeResponse.status).toBe(200);
404
+ expect((await completeResponse.json()) as CompleteResponse).toEqual({
405
+ ok: true,
406
+ metadata: expect.anything(),
407
+ });
408
+
409
+ const urlResponse = await app.request(
410
+ `http://localhost/sync/blobs/${encodeURIComponent(hash)}/url`,
411
+ {
412
+ headers: { [ACTOR_HEADER]: ACTOR_ID },
413
+ }
414
+ );
415
+ expect(urlResponse.status).toBe(200);
416
+ const payload = (await urlResponse.json()) as UrlResponse;
417
+
418
+ const downloadResponse = await app.request(payload.url);
419
+ expect(downloadResponse.status).toBe(200);
420
+ expect(new Uint8Array(await downloadResponse.arrayBuffer())).toEqual(
421
+ content
422
+ );
423
+ });
424
+ });
@@ -11,14 +11,20 @@ function isRecord(value: unknown): value is Record<string, unknown> {
11
11
 
12
12
  class MockDownstreamSocket {
13
13
  url: string;
14
+ onopen: ((event: Event) => void) | null = null;
14
15
  onmessage: ((event: MessageEvent) => void) | null = null;
15
16
  onerror: ((event: Event) => void) | null = null;
16
17
  closeCalls = 0;
18
+ sent: string[] = [];
17
19
 
18
20
  constructor(url: string) {
19
21
  this.url = url;
20
22
  }
21
23
 
24
+ emitOpen() {
25
+ this.onopen?.(new Event('open'));
26
+ }
27
+
22
28
  emitJson(payload: Record<string, unknown>) {
23
29
  this.onmessage?.(
24
30
  new MessageEvent('message', { data: JSON.stringify(payload) })
@@ -32,6 +38,10 @@ class MockDownstreamSocket {
32
38
  close() {
33
39
  this.closeCalls += 1;
34
40
  }
41
+
42
+ send(data: string) {
43
+ this.sent.push(data);
44
+ }
35
45
  }
36
46
 
37
47
  function createGatewayLiveHarness() {
@@ -151,10 +161,19 @@ describe('createConsoleGatewayRoutes live fan-in', () => {
151
161
 
152
162
  const alphaUrl = new URL(alphaSocket.url);
153
163
  expect(alphaUrl.pathname).toBe('/api/alpha/console/events/live');
154
- expect(alphaUrl.searchParams.get('token')).toBe(CONSOLE_TOKEN);
164
+ expect(alphaUrl.searchParams.get('token')).toBeNull();
155
165
  expect(alphaUrl.searchParams.get('partitionId')).toBe('tenant-a');
156
166
  expect(alphaUrl.searchParams.get('replayLimit')).toBe('42');
157
167
 
168
+ alphaSocket.emitOpen();
169
+ betaSocket.emitOpen();
170
+ expect(alphaSocket.sent[0]).toBe(
171
+ JSON.stringify({ type: 'auth', token: CONSOLE_TOKEN })
172
+ );
173
+ expect(betaSocket.sent[0]).toBe(
174
+ JSON.stringify({ type: 'auth', token: CONSOLE_TOKEN })
175
+ );
176
+
158
177
  const connectedEvent = upstream.messages.find(
159
178
  (message) => message.type === 'connected'
160
179
  );
@@ -191,19 +210,51 @@ describe('createConsoleGatewayRoutes live fan-in', () => {
191
210
  );
192
211
  });
193
212
 
194
- it('closes the upstream socket when auth is missing', async () => {
213
+ it('accepts auth over first websocket message when headers are missing', async () => {
195
214
  const { app, downstreamSockets, getEvents } = createGatewayLiveHarness();
196
215
 
197
216
  const response = await app.request('http://localhost/console/events/live');
198
217
  expect(response.status).toBe(200);
199
218
 
200
219
  const events = getEvents();
201
- if (!events?.onOpen) {
220
+ if (!events?.onOpen || !events.onMessage) {
202
221
  throw new Error('Expected websocket onOpen handler to be captured.');
203
222
  }
204
223
 
205
224
  const upstream = createUpstreamSocketHarness();
206
225
  events.onOpen(new Event('open'), upstream.ws);
226
+ expect(downstreamSockets).toHaveLength(0);
227
+
228
+ await events.onMessage(
229
+ new MessageEvent('message', {
230
+ data: JSON.stringify({ type: 'auth', token: CONSOLE_TOKEN }),
231
+ }),
232
+ upstream.ws
233
+ );
234
+
235
+ expect(upstream.messages[0]?.type).toBe('connected');
236
+ expect(downstreamSockets).toHaveLength(2);
237
+ });
238
+
239
+ it('closes the upstream socket when websocket auth token is invalid', async () => {
240
+ const { app, downstreamSockets, getEvents } = createGatewayLiveHarness();
241
+
242
+ const response = await app.request('http://localhost/console/events/live');
243
+ expect(response.status).toBe(200);
244
+
245
+ const events = getEvents();
246
+ if (!events?.onOpen || !events.onMessage) {
247
+ throw new Error('Expected websocket handlers to be captured.');
248
+ }
249
+
250
+ const upstream = createUpstreamSocketHarness();
251
+ events.onOpen(new Event('open'), upstream.ws);
252
+ await events.onMessage(
253
+ new MessageEvent('message', {
254
+ data: JSON.stringify({ type: 'auth', token: 'bad-token' }),
255
+ }),
256
+ upstream.ws
257
+ );
207
258
 
208
259
  const errorEvent = upstream.messages[0];
209
260
  expect(errorEvent?.type).toBe('error');