@syncular/server-hono 0.0.6-95 → 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/README.md +5 -23
- package/dist/admin-page.d.ts +14 -0
- package/dist/admin-page.js +217 -0
- package/dist/admin.d.ts +39 -0
- package/dist/admin.js +142 -0
- package/dist/index.d.ts +16 -14
- package/dist/index.js +134 -23
- package/package.json +19 -53
- package/src/admin-page.ts +217 -0
- package/src/admin.ts +193 -0
- package/src/index.ts +175 -31
- package/dist/api-key-auth.d.ts +0 -49
- package/dist/api-key-auth.d.ts.map +0 -1
- package/dist/api-key-auth.js +0 -110
- package/dist/api-key-auth.js.map +0 -1
- package/dist/blobs.d.ts +0 -69
- package/dist/blobs.d.ts.map +0 -1
- package/dist/blobs.js +0 -383
- package/dist/blobs.js.map +0 -1
- package/dist/console/gateway.d.ts +0 -33
- package/dist/console/gateway.d.ts.map +0 -1
- package/dist/console/gateway.js +0 -2534
- package/dist/console/gateway.js.map +0 -1
- package/dist/console/index.d.ts +0 -11
- package/dist/console/index.d.ts.map +0 -1
- package/dist/console/index.js +0 -11
- package/dist/console/index.js.map +0 -1
- package/dist/console/routes.d.ts +0 -33
- package/dist/console/routes.d.ts.map +0 -1
- package/dist/console/routes.js +0 -2890
- package/dist/console/routes.js.map +0 -1
- package/dist/console/schemas.d.ts +0 -490
- package/dist/console/schemas.d.ts.map +0 -1
- package/dist/console/schemas.js +0 -303
- package/dist/console/schemas.js.map +0 -1
- package/dist/console/types.d.ts +0 -142
- package/dist/console/types.d.ts.map +0 -1
- package/dist/console/types.js +0 -2
- package/dist/console/types.js.map +0 -1
- package/dist/console/ui.d.ts +0 -38
- package/dist/console/ui.d.ts.map +0 -1
- package/dist/console/ui.js +0 -43
- package/dist/console/ui.js.map +0 -1
- package/dist/create-server.d.ts +0 -68
- package/dist/create-server.d.ts.map +0 -1
- package/dist/create-server.js +0 -109
- package/dist/create-server.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/openapi.d.ts +0 -45
- package/dist/openapi.d.ts.map +0 -1
- package/dist/openapi.js +0 -59
- package/dist/openapi.js.map +0 -1
- package/dist/proxy/connection-manager.d.ts +0 -78
- package/dist/proxy/connection-manager.d.ts.map +0 -1
- package/dist/proxy/connection-manager.js +0 -251
- package/dist/proxy/connection-manager.js.map +0 -1
- package/dist/proxy/index.d.ts +0 -8
- package/dist/proxy/index.d.ts.map +0 -1
- package/dist/proxy/index.js +0 -8
- package/dist/proxy/index.js.map +0 -1
- package/dist/proxy/routes.d.ts +0 -74
- package/dist/proxy/routes.d.ts.map +0 -1
- package/dist/proxy/routes.js +0 -147
- package/dist/proxy/routes.js.map +0 -1
- package/dist/rate-limit.d.ts +0 -101
- package/dist/rate-limit.d.ts.map +0 -1
- package/dist/rate-limit.js +0 -186
- package/dist/rate-limit.js.map +0 -1
- package/dist/routes.d.ts +0 -145
- package/dist/routes.d.ts.map +0 -1
- package/dist/routes.js +0 -1471
- package/dist/routes.js.map +0 -1
- package/dist/ws.d.ts +0 -235
- package/dist/ws.d.ts.map +0 -1
- package/dist/ws.js +0 -614
- package/dist/ws.js.map +0 -1
- package/src/__tests__/blob-routes.test.ts +0 -424
- package/src/__tests__/console-gateway-live-routes.test.ts +0 -297
- package/src/__tests__/console-gateway-routes.test.ts +0 -1364
- package/src/__tests__/console-routes.test.ts +0 -1543
- package/src/__tests__/console-ui.test.ts +0 -114
- package/src/__tests__/create-server.test.ts +0 -342
- package/src/__tests__/pull-chunk-storage.test.ts +0 -576
- package/src/__tests__/rate-limit.test.ts +0 -78
- package/src/__tests__/realtime-bridge.test.ts +0 -135
- package/src/__tests__/sync-rate-limit-routing.test.ts +0 -185
- package/src/__tests__/ws-connection-manager.test.ts +0 -176
- package/src/api-key-auth.ts +0 -179
- package/src/blobs.ts +0 -534
- package/src/console/gateway.ts +0 -3603
- package/src/console/index.ts +0 -11
- package/src/console/routes.ts +0 -3748
- package/src/console/schemas.ts +0 -434
- package/src/console/types.ts +0 -151
- package/src/console/ui.ts +0 -100
- package/src/create-server.ts +0 -206
- package/src/openapi.ts +0 -74
- package/src/proxy/connection-manager.ts +0 -340
- package/src/proxy/index.ts +0 -8
- package/src/proxy/routes.ts +0 -223
- package/src/rate-limit.ts +0 -321
- package/src/routes.ts +0 -2007
- package/src/ws.ts +0 -802
|
@@ -1,424 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,297 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'bun:test';
|
|
2
|
-
import { Hono } from 'hono';
|
|
3
|
-
import { defineWebSocketHelper, WSContext, type WSEvents } from 'hono/ws';
|
|
4
|
-
import { createConsoleGatewayRoutes } from '../console';
|
|
5
|
-
|
|
6
|
-
const CONSOLE_TOKEN = 'gateway-token';
|
|
7
|
-
|
|
8
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
9
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
class MockDownstreamSocket {
|
|
13
|
-
url: string;
|
|
14
|
-
onopen: ((event: Event) => void) | null = null;
|
|
15
|
-
onmessage: ((event: MessageEvent) => void) | null = null;
|
|
16
|
-
onerror: ((event: Event) => void) | null = null;
|
|
17
|
-
closeCalls = 0;
|
|
18
|
-
sent: string[] = [];
|
|
19
|
-
|
|
20
|
-
constructor(url: string) {
|
|
21
|
-
this.url = url;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
emitOpen() {
|
|
25
|
-
this.onopen?.(new Event('open'));
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
emitJson(payload: Record<string, unknown>) {
|
|
29
|
-
this.onmessage?.(
|
|
30
|
-
new MessageEvent('message', { data: JSON.stringify(payload) })
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
emitError() {
|
|
35
|
-
this.onerror?.(new Event('error'));
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
close() {
|
|
39
|
-
this.closeCalls += 1;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
send(data: string) {
|
|
43
|
-
this.sent.push(data);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function createGatewayLiveHarness() {
|
|
48
|
-
const downstreamSockets: MockDownstreamSocket[] = [];
|
|
49
|
-
let capturedEvents: WSEvents | null = null;
|
|
50
|
-
|
|
51
|
-
const app = new Hono();
|
|
52
|
-
const upgradeWebSocket = defineWebSocketHelper(async (_c, events) => {
|
|
53
|
-
capturedEvents = events;
|
|
54
|
-
return new Response(null, { status: 200 });
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
app.route(
|
|
58
|
-
'/console',
|
|
59
|
-
createConsoleGatewayRoutes({
|
|
60
|
-
instances: [
|
|
61
|
-
{
|
|
62
|
-
instanceId: 'alpha',
|
|
63
|
-
label: 'Alpha',
|
|
64
|
-
baseUrl: 'https://alpha.example.test/api/alpha',
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
instanceId: 'beta',
|
|
68
|
-
label: 'Beta',
|
|
69
|
-
baseUrl: 'https://beta.example.test/api/beta',
|
|
70
|
-
},
|
|
71
|
-
],
|
|
72
|
-
authenticate: async (c) => {
|
|
73
|
-
const authHeader = c.req.header('Authorization');
|
|
74
|
-
if (authHeader === `Bearer ${CONSOLE_TOKEN}`) {
|
|
75
|
-
return { consoleUserId: 'gateway-user' };
|
|
76
|
-
}
|
|
77
|
-
return null;
|
|
78
|
-
},
|
|
79
|
-
websocket: {
|
|
80
|
-
enabled: true,
|
|
81
|
-
upgradeWebSocket,
|
|
82
|
-
heartbeatIntervalMs: 60000,
|
|
83
|
-
createWebSocket: (url) => {
|
|
84
|
-
const socket = new MockDownstreamSocket(url);
|
|
85
|
-
downstreamSockets.push(socket);
|
|
86
|
-
return socket;
|
|
87
|
-
},
|
|
88
|
-
},
|
|
89
|
-
})
|
|
90
|
-
);
|
|
91
|
-
|
|
92
|
-
return {
|
|
93
|
-
app,
|
|
94
|
-
downstreamSockets,
|
|
95
|
-
getEvents: () => capturedEvents,
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function createUpstreamSocketHarness() {
|
|
100
|
-
const messages: Array<Record<string, unknown>> = [];
|
|
101
|
-
const closes: Array<{ code?: number; reason?: string }> = [];
|
|
102
|
-
|
|
103
|
-
const ws = new WSContext({
|
|
104
|
-
readyState: 1,
|
|
105
|
-
send(data) {
|
|
106
|
-
if (typeof data !== 'string') {
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const parsed = JSON.parse(data);
|
|
111
|
-
if (isRecord(parsed)) {
|
|
112
|
-
messages.push(parsed);
|
|
113
|
-
}
|
|
114
|
-
},
|
|
115
|
-
close(code, reason) {
|
|
116
|
-
closes.push({ code, reason });
|
|
117
|
-
},
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
return {
|
|
121
|
-
ws,
|
|
122
|
-
messages,
|
|
123
|
-
closes,
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
describe('createConsoleGatewayRoutes live fan-in', () => {
|
|
128
|
-
it('fans in downstream websocket events and emits instance degradation markers', async () => {
|
|
129
|
-
const { app, downstreamSockets, getEvents } = createGatewayLiveHarness();
|
|
130
|
-
|
|
131
|
-
const response = await app.request(
|
|
132
|
-
'http://localhost/console/events/live?instanceIds=alpha,beta&partitionId=tenant-a&replayLimit=42',
|
|
133
|
-
{
|
|
134
|
-
headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
|
|
135
|
-
}
|
|
136
|
-
);
|
|
137
|
-
expect(response.status).toBe(200);
|
|
138
|
-
|
|
139
|
-
const events = getEvents();
|
|
140
|
-
if (!events?.onOpen || !events.onError) {
|
|
141
|
-
throw new Error('Expected websocket lifecycle handlers to be captured.');
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const upstream = createUpstreamSocketHarness();
|
|
145
|
-
events.onOpen(new Event('open'), upstream.ws);
|
|
146
|
-
|
|
147
|
-
expect(downstreamSockets).toHaveLength(2);
|
|
148
|
-
|
|
149
|
-
const alphaSocket = downstreamSockets.find((socket) =>
|
|
150
|
-
socket.url.includes('alpha.example.test')
|
|
151
|
-
);
|
|
152
|
-
const betaSocket = downstreamSockets.find((socket) =>
|
|
153
|
-
socket.url.includes('beta.example.test')
|
|
154
|
-
);
|
|
155
|
-
|
|
156
|
-
if (!alphaSocket || !betaSocket) {
|
|
157
|
-
throw new Error(
|
|
158
|
-
'Expected both alpha and beta downstream websocket links.'
|
|
159
|
-
);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const alphaUrl = new URL(alphaSocket.url);
|
|
163
|
-
expect(alphaUrl.pathname).toBe('/api/alpha/console/events/live');
|
|
164
|
-
expect(alphaUrl.searchParams.get('token')).toBeNull();
|
|
165
|
-
expect(alphaUrl.searchParams.get('partitionId')).toBe('tenant-a');
|
|
166
|
-
expect(alphaUrl.searchParams.get('replayLimit')).toBe('42');
|
|
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
|
-
|
|
177
|
-
const connectedEvent = upstream.messages.find(
|
|
178
|
-
(message) => message.type === 'connected'
|
|
179
|
-
);
|
|
180
|
-
expect(connectedEvent?.instanceCount).toBe(2);
|
|
181
|
-
|
|
182
|
-
alphaSocket.emitJson({
|
|
183
|
-
type: 'push',
|
|
184
|
-
timestamp: '2026-02-17T11:00:00.000Z',
|
|
185
|
-
data: {
|
|
186
|
-
partitionId: 'tenant-a',
|
|
187
|
-
requestId: 'alpha-req-1',
|
|
188
|
-
},
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
const pushEvent = upstream.messages.find(
|
|
192
|
-
(message) => message.type === 'push'
|
|
193
|
-
);
|
|
194
|
-
expect(pushEvent?.instanceId).toBe('alpha');
|
|
195
|
-
if (!isRecord(pushEvent?.data)) {
|
|
196
|
-
throw new Error('Expected push event to include object data payload.');
|
|
197
|
-
}
|
|
198
|
-
expect(pushEvent.data.instanceId).toBe('alpha');
|
|
199
|
-
expect(pushEvent.data.partitionId).toBe('tenant-a');
|
|
200
|
-
|
|
201
|
-
betaSocket.emitError();
|
|
202
|
-
const degradedEvent = upstream.messages.find(
|
|
203
|
-
(message) => message.type === 'instance_error'
|
|
204
|
-
);
|
|
205
|
-
expect(degradedEvent?.instanceId).toBe('beta');
|
|
206
|
-
|
|
207
|
-
events.onError(new Event('error'), upstream.ws);
|
|
208
|
-
expect(downstreamSockets.every((socket) => socket.closeCalls === 1)).toBe(
|
|
209
|
-
true
|
|
210
|
-
);
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
it('accepts auth over first websocket message when headers are missing', async () => {
|
|
214
|
-
const { app, downstreamSockets, getEvents } = createGatewayLiveHarness();
|
|
215
|
-
|
|
216
|
-
const response = await app.request('http://localhost/console/events/live');
|
|
217
|
-
expect(response.status).toBe(200);
|
|
218
|
-
|
|
219
|
-
const events = getEvents();
|
|
220
|
-
if (!events?.onOpen || !events.onMessage) {
|
|
221
|
-
throw new Error('Expected websocket onOpen handler to be captured.');
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
const upstream = createUpstreamSocketHarness();
|
|
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
|
-
);
|
|
258
|
-
|
|
259
|
-
const errorEvent = upstream.messages[0];
|
|
260
|
-
expect(errorEvent?.type).toBe('error');
|
|
261
|
-
expect(errorEvent?.message).toBe('UNAUTHENTICATED');
|
|
262
|
-
expect(upstream.closes).toEqual([
|
|
263
|
-
{ code: 4001, reason: 'Unauthenticated' },
|
|
264
|
-
]);
|
|
265
|
-
expect(downstreamSockets).toHaveLength(0);
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
it('closes the upstream socket when no instances match the filter', async () => {
|
|
269
|
-
const { app, downstreamSockets, getEvents } = createGatewayLiveHarness();
|
|
270
|
-
|
|
271
|
-
const response = await app.request(
|
|
272
|
-
'http://localhost/console/events/live?instanceId=missing',
|
|
273
|
-
{
|
|
274
|
-
headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
|
|
275
|
-
}
|
|
276
|
-
);
|
|
277
|
-
expect(response.status).toBe(200);
|
|
278
|
-
|
|
279
|
-
const events = getEvents();
|
|
280
|
-
if (!events?.onOpen) {
|
|
281
|
-
throw new Error('Expected websocket onOpen handler to be captured.');
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
const upstream = createUpstreamSocketHarness();
|
|
285
|
-
events.onOpen(new Event('open'), upstream.ws);
|
|
286
|
-
|
|
287
|
-
const errorEvent = upstream.messages[0];
|
|
288
|
-
expect(errorEvent?.type).toBe('error');
|
|
289
|
-
expect(errorEvent?.message).toBe(
|
|
290
|
-
'No enabled instances matched the provided instance filter.'
|
|
291
|
-
);
|
|
292
|
-
expect(upstream.closes).toEqual([
|
|
293
|
-
{ code: 4004, reason: 'No instances selected' },
|
|
294
|
-
]);
|
|
295
|
-
expect(downstreamSockets).toHaveLength(0);
|
|
296
|
-
});
|
|
297
|
-
});
|