@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.
- package/dist/console/gateway.d.ts +3 -1
- package/dist/console/gateway.d.ts.map +1 -1
- package/dist/console/gateway.js +218 -41
- package/dist/console/gateway.js.map +1 -1
- package/dist/console/index.d.ts +1 -0
- package/dist/console/index.d.ts.map +1 -1
- package/dist/console/index.js +1 -0
- package/dist/console/index.js.map +1 -1
- package/dist/console/routes.d.ts +3 -97
- package/dist/console/routes.d.ts.map +1 -1
- package/dist/console/routes.js +507 -80
- package/dist/console/routes.js.map +1 -1
- package/dist/console/schemas.d.ts +29 -0
- package/dist/console/schemas.d.ts.map +1 -1
- package/dist/console/schemas.js +22 -0
- package/dist/console/schemas.js.map +1 -1
- package/dist/console/types.d.ts +175 -0
- package/dist/console/types.d.ts.map +1 -0
- package/dist/console/types.js +2 -0
- package/dist/console/types.js.map +1 -0
- package/dist/create-server.d.ts +17 -34
- package/dist/create-server.d.ts.map +1 -1
- package/dist/create-server.js +26 -26
- package/dist/create-server.js.map +1 -1
- package/dist/proxy/connection-manager.d.ts +3 -3
- package/dist/proxy/connection-manager.d.ts.map +1 -1
- package/dist/proxy/routes.d.ts +4 -4
- package/dist/proxy/routes.d.ts.map +1 -1
- package/dist/proxy/routes.js +1 -1
- package/dist/routes.d.ts +33 -9
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +153 -70
- package/dist/routes.js.map +1 -1
- package/package.json +21 -7
- package/src/__tests__/blob-routes.test.ts +424 -0
- package/src/__tests__/console-gateway-live-routes.test.ts +54 -3
- package/src/__tests__/console-routes.test.ts +161 -7
- package/src/__tests__/console-ui.test.ts +114 -0
- package/src/__tests__/create-server.test.ts +233 -10
- package/src/__tests__/pull-chunk-storage.test.ts +6 -2
- package/src/__tests__/realtime-bridge.test.ts +6 -2
- package/src/__tests__/sync-rate-limit-routing.test.ts +6 -2
- package/src/console/gateway.ts +277 -53
- package/src/console/index.ts +1 -0
- package/src/console/routes.ts +654 -198
- package/src/console/schemas.ts +29 -0
- package/src/console/types.ts +185 -0
- package/src/create-server.ts +56 -53
- package/src/proxy/connection-manager.ts +3 -3
- package/src/proxy/routes.ts +4 -4
- 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.
|
|
3
|
+
"version": "0.0.6-100",
|
|
4
4
|
"description": "Hono adapter for the Syncular server with OpenAPI support",
|
|
5
|
-
"license": "
|
|
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.
|
|
52
|
-
"@syncular/core": "0.0.
|
|
53
|
-
"@syncular/server": "0.0.
|
|
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.
|
|
61
|
-
"@syncular/server-dialect-postgres": "0.0.
|
|
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')).
|
|
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('
|
|
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');
|