@gilhrpenner/convex-files-control 0.1.1 → 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 +243 -207
- package/dist/client/http.d.ts +4 -4
- package/dist/client/http.d.ts.map +1 -1
- package/dist/client/http.js +39 -10
- package/dist/client/http.js.map +1 -1
- package/dist/client/index.d.ts +66 -11
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +136 -41
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +2 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/download.d.ts +2 -0
- package/dist/component/download.d.ts.map +1 -1
- package/dist/component/download.js +33 -12
- package/dist/component/download.js.map +1 -1
- package/dist/component/schema.d.ts +3 -1
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +1 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/react/index.d.ts +2 -2
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +10 -6
- package/dist/react/index.js.map +1 -1
- package/package.json +4 -3
- package/src/__tests__/client-extra.test.ts +157 -4
- package/src/__tests__/client.test.ts +572 -46
- package/src/__tests__/download-core.test.ts +70 -0
- package/src/__tests__/entrypoints.test.ts +13 -0
- package/src/__tests__/http.test.ts +34 -0
- package/src/__tests__/react.test.ts +10 -16
- package/src/__tests__/shared.test.ts +0 -5
- package/src/__tests__/transfer.test.ts +103 -0
- package/src/client/http.ts +51 -10
- package/src/client/index.ts +242 -51
- package/src/component/_generated/component.ts +2 -0
- package/src/component/download.ts +35 -14
- package/src/component/schema.ts +1 -0
- package/src/react/index.ts +11 -10
|
@@ -99,9 +99,17 @@ function makeCtx(runMutation?: unknown, runQuery?: unknown) {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
describe("registerRoutes", () => {
|
|
102
|
+
// Helper to create a mock checkUploadRequest hook that returns accessKeys
|
|
103
|
+
function mockCheckUploadRequest(accessKeys: string[] = ["test-user"]) {
|
|
104
|
+
return vi.fn(async () => ({ accessKeys }));
|
|
105
|
+
}
|
|
106
|
+
|
|
102
107
|
test("registers CORS preflight routes", async () => {
|
|
103
108
|
const router = createRouter();
|
|
104
|
-
registerRoutes(router, component, {
|
|
109
|
+
registerRoutes(router, component, {
|
|
110
|
+
enableUploadRoute: true,
|
|
111
|
+
checkUploadRequest: mockCheckUploadRequest(),
|
|
112
|
+
});
|
|
105
113
|
|
|
106
114
|
const uploadOptions = getRoute(router, "/files/upload", "OPTIONS");
|
|
107
115
|
const downloadOptions = getRoute(router, "/files/download", "OPTIONS");
|
|
@@ -120,9 +128,9 @@ describe("registerRoutes", () => {
|
|
|
120
128
|
expect(downloadResponse.status).toBe(204);
|
|
121
129
|
});
|
|
122
130
|
|
|
123
|
-
test("download route
|
|
131
|
+
test("download route handles without accessKeyQueryParam", async () => {
|
|
124
132
|
const router = createRouter();
|
|
125
|
-
registerRoutes(router, component
|
|
133
|
+
registerRoutes(router, component);
|
|
126
134
|
const downloadRoute = getRoute(router, "/files/download", "GET");
|
|
127
135
|
const handler = getHandler(downloadRoute.handler);
|
|
128
136
|
|
|
@@ -137,13 +145,42 @@ describe("registerRoutes", () => {
|
|
|
137
145
|
await handler(
|
|
138
146
|
ctx,
|
|
139
147
|
buildDownloadRequest(
|
|
140
|
-
"https://example.com/files/download?token=token
|
|
148
|
+
"https://example.com/files/download?token=token",
|
|
141
149
|
),
|
|
142
150
|
);
|
|
143
151
|
|
|
144
152
|
expect(runMutation).toHaveBeenCalledWith(
|
|
145
153
|
component.download.consumeDownloadGrantForUrl,
|
|
146
|
-
{ downloadToken: "token", accessKey:
|
|
154
|
+
{ downloadToken: "token", accessKey: undefined, password: undefined },
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("download route uses accessKey from checkDownloadRequest hook", async () => {
|
|
159
|
+
const router = createRouter();
|
|
160
|
+
const checkDownloadRequest = vi.fn(async () => ({ accessKey: "from-hook" }));
|
|
161
|
+
registerRoutes(router, component, { checkDownloadRequest });
|
|
162
|
+
const downloadRoute = getRoute(router, "/files/download", "GET");
|
|
163
|
+
const handler = getHandler(downloadRoute.handler);
|
|
164
|
+
|
|
165
|
+
const runMutation = vi.fn(async () => ({
|
|
166
|
+
status: "ok",
|
|
167
|
+
downloadUrl: "https://file.example.com",
|
|
168
|
+
}));
|
|
169
|
+
const ctx = makeCtx(runMutation);
|
|
170
|
+
|
|
171
|
+
vi.stubGlobal("fetch", vi.fn(async () => new Response("file", { status: 200 })));
|
|
172
|
+
|
|
173
|
+
await handler(
|
|
174
|
+
ctx,
|
|
175
|
+
buildDownloadRequest("https://example.com/files/download?token=token"),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
expect(runMutation).toHaveBeenCalledWith(
|
|
179
|
+
component.download.consumeDownloadGrantForUrl,
|
|
180
|
+
expect.objectContaining({
|
|
181
|
+
downloadToken: "token",
|
|
182
|
+
accessKey: "from-hook",
|
|
183
|
+
}),
|
|
147
184
|
);
|
|
148
185
|
});
|
|
149
186
|
|
|
@@ -188,8 +225,9 @@ describe("registerRoutes", () => {
|
|
|
188
225
|
});
|
|
189
226
|
|
|
190
227
|
test("upload route validates input", async () => {
|
|
228
|
+
const checkUploadRequest = mockCheckUploadRequest(["a"]);
|
|
191
229
|
const router = createRouter();
|
|
192
|
-
registerRoutes(router, component, { enableUploadRoute: true });
|
|
230
|
+
registerRoutes(router, component, { enableUploadRoute: true, checkUploadRequest });
|
|
193
231
|
const uploadRoute = getRoute(router, "/files/upload", "POST");
|
|
194
232
|
const handler = getHandler(uploadRoute.handler);
|
|
195
233
|
|
|
@@ -214,41 +252,12 @@ describe("registerRoutes", () => {
|
|
|
214
252
|
);
|
|
215
253
|
expect(missingContentTypeResponse.status).toBe(415);
|
|
216
254
|
|
|
217
|
-
const missingFileRequest = buildUploadRequest({
|
|
218
|
-
[uploadFormFields.accessKeys]: JSON.stringify(["a"]),
|
|
219
|
-
});
|
|
255
|
+
const missingFileRequest = buildUploadRequest({});
|
|
220
256
|
const missingFileResponse = await handler(makeCtx(), missingFileRequest);
|
|
221
257
|
expect(missingFileResponse.status).toBe(400);
|
|
222
258
|
|
|
223
|
-
const missingAccessKeysRequest = buildUploadRequest({
|
|
224
|
-
[uploadFormFields.file]: new File(["file"], "filename", { type: "text/plain" }),
|
|
225
|
-
});
|
|
226
|
-
const missingAccessKeysResponse = await handler(
|
|
227
|
-
makeCtx(),
|
|
228
|
-
missingAccessKeysRequest,
|
|
229
|
-
);
|
|
230
|
-
expect(missingAccessKeysResponse.status).toBe(400);
|
|
231
|
-
|
|
232
|
-
const invalidAccessKeysRequest = buildUploadRequest({
|
|
233
|
-
[uploadFormFields.file]: new File(["file"], "filename", { type: "text/plain" }),
|
|
234
|
-
[uploadFormFields.accessKeys]: "not json",
|
|
235
|
-
});
|
|
236
|
-
const invalidAccessKeysResponse = await handler(
|
|
237
|
-
makeCtx(),
|
|
238
|
-
invalidAccessKeysRequest,
|
|
239
|
-
);
|
|
240
|
-
expect(invalidAccessKeysResponse.status).toBe(400);
|
|
241
|
-
|
|
242
|
-
const invalidArrayRequest = buildUploadRequest({
|
|
243
|
-
[uploadFormFields.file]: new File(["file"], "filename", { type: "text/plain" }),
|
|
244
|
-
[uploadFormFields.accessKeys]: JSON.stringify([1]),
|
|
245
|
-
});
|
|
246
|
-
const invalidArrayResponse = await handler(makeCtx(), invalidArrayRequest);
|
|
247
|
-
expect(invalidArrayResponse.status).toBe(400);
|
|
248
|
-
|
|
249
259
|
const invalidTypeRequest = buildUploadRequest({
|
|
250
260
|
[uploadFormFields.file]: new File(["file"], "filename", { type: "text/plain" }),
|
|
251
|
-
[uploadFormFields.accessKeys]: JSON.stringify(["a"]),
|
|
252
261
|
[uploadFormFields.expiresAt]: new File(["nope"], "filename"),
|
|
253
262
|
});
|
|
254
263
|
const invalidTypeResponse = await handler(makeCtx(), invalidTypeRequest);
|
|
@@ -256,7 +265,6 @@ describe("registerRoutes", () => {
|
|
|
256
265
|
|
|
257
266
|
const invalidNumberRequest = buildUploadRequest({
|
|
258
267
|
[uploadFormFields.file]: new File(["file"], "filename", { type: "text/plain" }),
|
|
259
|
-
[uploadFormFields.accessKeys]: JSON.stringify(["a"]),
|
|
260
268
|
[uploadFormFields.expiresAt]: "nope",
|
|
261
269
|
});
|
|
262
270
|
const invalidNumberResponse = await handler(makeCtx(), invalidNumberRequest);
|
|
@@ -264,8 +272,9 @@ describe("registerRoutes", () => {
|
|
|
264
272
|
});
|
|
265
273
|
|
|
266
274
|
test("upload route handles uploads", async () => {
|
|
275
|
+
const checkUploadRequest = mockCheckUploadRequest(["a", "b"]);
|
|
267
276
|
const router = createRouter();
|
|
268
|
-
registerRoutes(router, component, { enableUploadRoute: true });
|
|
277
|
+
registerRoutes(router, component, { enableUploadRoute: true, checkUploadRequest });
|
|
269
278
|
const uploadRoute = getRoute(router, "/files/upload", "POST");
|
|
270
279
|
const handler = getHandler(uploadRoute.handler);
|
|
271
280
|
|
|
@@ -314,7 +323,6 @@ describe("registerRoutes", () => {
|
|
|
314
323
|
|
|
315
324
|
const request = buildUploadRequest({
|
|
316
325
|
[uploadFormFields.file]: new File(["file"], "filename", { type: "text/plain" }),
|
|
317
|
-
[uploadFormFields.accessKeys]: JSON.stringify(["a", "b"]),
|
|
318
326
|
[uploadFormFields.expiresAt]: "123",
|
|
319
327
|
});
|
|
320
328
|
|
|
@@ -332,11 +340,18 @@ describe("registerRoutes", () => {
|
|
|
332
340
|
storageId: "storage",
|
|
333
341
|
accessKeys: ["a", "b"],
|
|
334
342
|
expiresAt: 123,
|
|
343
|
+
metadata: {
|
|
344
|
+
size: 4,
|
|
345
|
+
sha256: expect.any(String),
|
|
346
|
+
contentType: "text/plain",
|
|
347
|
+
},
|
|
335
348
|
});
|
|
336
349
|
|
|
350
|
+
// Reset hook for null expiresAt test
|
|
351
|
+
checkUploadRequest.mockResolvedValue({ accessKeys: ["a"] });
|
|
352
|
+
|
|
337
353
|
const nullRequest = buildUploadRequest({
|
|
338
354
|
[uploadFormFields.file]: new File(["file"], "filename", { type: "text/plain" }),
|
|
339
|
-
[uploadFormFields.accessKeys]: JSON.stringify(["a"]),
|
|
340
355
|
[uploadFormFields.expiresAt]: "null",
|
|
341
356
|
});
|
|
342
357
|
|
|
@@ -346,11 +361,15 @@ describe("registerRoutes", () => {
|
|
|
346
361
|
storageId: "storage",
|
|
347
362
|
accessKeys: ["a"],
|
|
348
363
|
expiresAt: undefined,
|
|
364
|
+
metadata: {
|
|
365
|
+
size: 4,
|
|
366
|
+
sha256: expect.any(String),
|
|
367
|
+
contentType: "text/plain",
|
|
368
|
+
},
|
|
349
369
|
});
|
|
350
370
|
|
|
351
371
|
const binaryRequest = buildUploadRequest({
|
|
352
372
|
[uploadFormFields.file]: new File(["file"], "filename"),
|
|
353
|
-
[uploadFormFields.accessKeys]: JSON.stringify(["a"]),
|
|
354
373
|
});
|
|
355
374
|
|
|
356
375
|
await handler(ctx, binaryRequest);
|
|
@@ -362,9 +381,489 @@ describe("registerRoutes", () => {
|
|
|
362
381
|
);
|
|
363
382
|
});
|
|
364
383
|
|
|
384
|
+
test("upload route uses btoa fallback when Buffer is unavailable", async () => {
|
|
385
|
+
const checkUploadRequest = mockCheckUploadRequest(["a"]);
|
|
386
|
+
const router = createRouter();
|
|
387
|
+
registerRoutes(router, component, { enableUploadRoute: true, checkUploadRequest });
|
|
388
|
+
const uploadRoute = getRoute(router, "/files/upload", "POST");
|
|
389
|
+
const handler = getHandler(uploadRoute.handler);
|
|
390
|
+
|
|
391
|
+
const uploadUrl = "https://upload.example.com";
|
|
392
|
+
const runMutation = vi.fn(async (ref) => {
|
|
393
|
+
if (ref === component.upload.generateUploadUrl) {
|
|
394
|
+
return {
|
|
395
|
+
uploadUrl,
|
|
396
|
+
uploadToken: "token",
|
|
397
|
+
uploadTokenExpiresAt: Date.now(),
|
|
398
|
+
storageProvider: "convex",
|
|
399
|
+
storageId: null,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
if (ref === component.upload.finalizeUpload) {
|
|
403
|
+
return {
|
|
404
|
+
storageId: "storage",
|
|
405
|
+
storageProvider: "convex",
|
|
406
|
+
expiresAt: null,
|
|
407
|
+
metadata: null,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
throw new Error("Unexpected mutation");
|
|
411
|
+
});
|
|
412
|
+
const ctx = makeCtx(runMutation);
|
|
413
|
+
|
|
414
|
+
vi.stubGlobal("Buffer", undefined as any);
|
|
415
|
+
const btoaSpy = vi.fn((value: string) => `encoded:${value.length}`);
|
|
416
|
+
vi.stubGlobal("btoa", btoaSpy);
|
|
417
|
+
class FakeResponse {
|
|
418
|
+
body: unknown;
|
|
419
|
+
status: number;
|
|
420
|
+
statusText: string;
|
|
421
|
+
headers: Headers;
|
|
422
|
+
ok: boolean;
|
|
423
|
+
|
|
424
|
+
constructor(body: unknown, init?: ResponseInit) {
|
|
425
|
+
this.body = body;
|
|
426
|
+
this.status = init?.status ?? 200;
|
|
427
|
+
this.statusText = init?.statusText ?? "";
|
|
428
|
+
this.headers = new Headers(init?.headers);
|
|
429
|
+
this.ok = this.status >= 200 && this.status < 300;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async json() {
|
|
433
|
+
return this.body ? JSON.parse(String(this.body)) : null;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
vi.stubGlobal("Response", FakeResponse as any);
|
|
437
|
+
class FakeTextDecoder {
|
|
438
|
+
decode() {
|
|
439
|
+
throw new Error("latin1 unsupported");
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
vi.stubGlobal("TextDecoder", FakeTextDecoder as any);
|
|
443
|
+
vi.stubGlobal("crypto", {
|
|
444
|
+
subtle: {
|
|
445
|
+
digest: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
expect(typeof Buffer).toBe("undefined");
|
|
449
|
+
|
|
450
|
+
vi.stubGlobal(
|
|
451
|
+
"fetch",
|
|
452
|
+
vi.fn(async (url) => {
|
|
453
|
+
if (url === uploadUrl) {
|
|
454
|
+
return {
|
|
455
|
+
ok: true,
|
|
456
|
+
json: async () => ({ storageId: "storage" }),
|
|
457
|
+
} as Response;
|
|
458
|
+
}
|
|
459
|
+
return { ok: false, status: 404 } as Response;
|
|
460
|
+
}),
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
const request = buildUploadRequest({
|
|
464
|
+
[uploadFormFields.file]: new File(["file"], "filename", {
|
|
465
|
+
type: "text/plain",
|
|
466
|
+
}),
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const response = await handler(ctx, request);
|
|
470
|
+
expect(response.status).toBe(200);
|
|
471
|
+
expect(btoaSpy).toHaveBeenCalled();
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("upload route skips TextDecoder when unavailable", async () => {
|
|
475
|
+
const checkUploadRequest = mockCheckUploadRequest(["a"]);
|
|
476
|
+
const router = createRouter();
|
|
477
|
+
registerRoutes(router, component, { enableUploadRoute: true, checkUploadRequest });
|
|
478
|
+
const uploadRoute = getRoute(router, "/files/upload", "POST");
|
|
479
|
+
const handler = getHandler(uploadRoute.handler);
|
|
480
|
+
|
|
481
|
+
const uploadUrl = "https://upload.example.com";
|
|
482
|
+
const runMutation = vi.fn(async (ref) => {
|
|
483
|
+
if (ref === component.upload.generateUploadUrl) {
|
|
484
|
+
return {
|
|
485
|
+
uploadUrl,
|
|
486
|
+
uploadToken: "token",
|
|
487
|
+
uploadTokenExpiresAt: Date.now(),
|
|
488
|
+
storageProvider: "convex",
|
|
489
|
+
storageId: null,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
if (ref === component.upload.finalizeUpload) {
|
|
493
|
+
return {
|
|
494
|
+
storageId: "storage",
|
|
495
|
+
storageProvider: "convex",
|
|
496
|
+
expiresAt: null,
|
|
497
|
+
metadata: null,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
throw new Error("Unexpected mutation");
|
|
501
|
+
});
|
|
502
|
+
const ctx = makeCtx(runMutation);
|
|
503
|
+
|
|
504
|
+
vi.stubGlobal("Buffer", undefined as any);
|
|
505
|
+
const btoaSpy = vi.fn(() => "encoded");
|
|
506
|
+
vi.stubGlobal("btoa", btoaSpy);
|
|
507
|
+
vi.stubGlobal("TextDecoder", undefined as any);
|
|
508
|
+
class FakeResponse {
|
|
509
|
+
body: unknown;
|
|
510
|
+
status: number;
|
|
511
|
+
statusText: string;
|
|
512
|
+
headers: Headers;
|
|
513
|
+
ok: boolean;
|
|
514
|
+
|
|
515
|
+
constructor(body: unknown, init?: ResponseInit) {
|
|
516
|
+
this.body = body;
|
|
517
|
+
this.status = init?.status ?? 200;
|
|
518
|
+
this.statusText = init?.statusText ?? "";
|
|
519
|
+
this.headers = new Headers(init?.headers);
|
|
520
|
+
this.ok = this.status >= 200 && this.status < 300;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async json() {
|
|
524
|
+
return this.body ? JSON.parse(String(this.body)) : null;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
vi.stubGlobal("Response", FakeResponse as any);
|
|
528
|
+
vi.stubGlobal("crypto", {
|
|
529
|
+
subtle: {
|
|
530
|
+
digest: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
vi.stubGlobal(
|
|
535
|
+
"fetch",
|
|
536
|
+
vi.fn(async (url) => {
|
|
537
|
+
if (url === uploadUrl) {
|
|
538
|
+
return {
|
|
539
|
+
ok: true,
|
|
540
|
+
json: async () => ({ storageId: "storage" }),
|
|
541
|
+
} as Response;
|
|
542
|
+
}
|
|
543
|
+
return { ok: false, status: 404 } as Response;
|
|
544
|
+
}),
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
const request = buildUploadRequest({
|
|
548
|
+
[uploadFormFields.file]: new File(["file"], "filename", {
|
|
549
|
+
type: "text/plain",
|
|
550
|
+
}),
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
const response = await handler(ctx, request);
|
|
554
|
+
expect(response.status).toBe(200);
|
|
555
|
+
expect(btoaSpy).toHaveBeenCalled();
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
test("upload route uses TextDecoder when available", async () => {
|
|
559
|
+
const checkUploadRequest = mockCheckUploadRequest(["a"]);
|
|
560
|
+
const router = createRouter();
|
|
561
|
+
registerRoutes(router, component, { enableUploadRoute: true, checkUploadRequest });
|
|
562
|
+
const uploadRoute = getRoute(router, "/files/upload", "POST");
|
|
563
|
+
const handler = getHandler(uploadRoute.handler);
|
|
564
|
+
|
|
565
|
+
const uploadUrl = "https://upload.example.com";
|
|
566
|
+
const runMutation = vi.fn(async (ref) => {
|
|
567
|
+
if (ref === component.upload.generateUploadUrl) {
|
|
568
|
+
return {
|
|
569
|
+
uploadUrl,
|
|
570
|
+
uploadToken: "token",
|
|
571
|
+
uploadTokenExpiresAt: Date.now(),
|
|
572
|
+
storageProvider: "convex",
|
|
573
|
+
storageId: null,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
if (ref === component.upload.finalizeUpload) {
|
|
577
|
+
return {
|
|
578
|
+
storageId: "storage",
|
|
579
|
+
storageProvider: "convex",
|
|
580
|
+
expiresAt: null,
|
|
581
|
+
metadata: null,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
throw new Error("Unexpected mutation");
|
|
585
|
+
});
|
|
586
|
+
const ctx = makeCtx(runMutation);
|
|
587
|
+
|
|
588
|
+
vi.stubGlobal("Buffer", undefined as any);
|
|
589
|
+
const btoaSpy = vi.fn(() => "encoded");
|
|
590
|
+
vi.stubGlobal("btoa", btoaSpy);
|
|
591
|
+
class FakeResponse {
|
|
592
|
+
body: unknown;
|
|
593
|
+
status: number;
|
|
594
|
+
statusText: string;
|
|
595
|
+
headers: Headers;
|
|
596
|
+
ok: boolean;
|
|
597
|
+
|
|
598
|
+
constructor(body: unknown, init?: ResponseInit) {
|
|
599
|
+
this.body = body;
|
|
600
|
+
this.status = init?.status ?? 200;
|
|
601
|
+
this.statusText = init?.statusText ?? "";
|
|
602
|
+
this.headers = new Headers(init?.headers);
|
|
603
|
+
this.ok = this.status >= 200 && this.status < 300;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async json() {
|
|
607
|
+
return this.body ? JSON.parse(String(this.body)) : null;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
vi.stubGlobal("Response", FakeResponse as any);
|
|
611
|
+
class FakeTextDecoder {
|
|
612
|
+
decode() {
|
|
613
|
+
return "decoded";
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
vi.stubGlobal("TextDecoder", FakeTextDecoder as any);
|
|
617
|
+
vi.stubGlobal("crypto", {
|
|
618
|
+
subtle: {
|
|
619
|
+
digest: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
|
|
620
|
+
},
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
vi.stubGlobal(
|
|
624
|
+
"fetch",
|
|
625
|
+
vi.fn(async (url) => {
|
|
626
|
+
if (url === uploadUrl) {
|
|
627
|
+
return {
|
|
628
|
+
ok: true,
|
|
629
|
+
json: async () => ({ storageId: "storage" }),
|
|
630
|
+
} as Response;
|
|
631
|
+
}
|
|
632
|
+
return { ok: false, status: 404 } as Response;
|
|
633
|
+
}),
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
const request = buildUploadRequest({
|
|
637
|
+
[uploadFormFields.file]: new File(["file"], "filename", {
|
|
638
|
+
type: "text/plain",
|
|
639
|
+
}),
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
const response = await handler(ctx, request);
|
|
643
|
+
expect(response.status).toBe(200);
|
|
644
|
+
expect(btoaSpy).toHaveBeenCalledWith("decoded");
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
test("upload route throws when base64 encoding is unavailable", async () => {
|
|
648
|
+
const checkUploadRequest = mockCheckUploadRequest(["a"]);
|
|
649
|
+
const router = createRouter();
|
|
650
|
+
registerRoutes(router, component, { enableUploadRoute: true, checkUploadRequest });
|
|
651
|
+
const uploadRoute = getRoute(router, "/files/upload", "POST");
|
|
652
|
+
const handler = getHandler(uploadRoute.handler);
|
|
653
|
+
|
|
654
|
+
const uploadUrl = "https://upload.example.com";
|
|
655
|
+
const runMutation = vi.fn(async (ref) => {
|
|
656
|
+
if (ref === component.upload.generateUploadUrl) {
|
|
657
|
+
return {
|
|
658
|
+
uploadUrl,
|
|
659
|
+
uploadToken: "token",
|
|
660
|
+
uploadTokenExpiresAt: Date.now(),
|
|
661
|
+
storageProvider: "convex",
|
|
662
|
+
storageId: null,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
if (ref === component.upload.finalizeUpload) {
|
|
666
|
+
return {
|
|
667
|
+
storageId: "storage",
|
|
668
|
+
storageProvider: "convex",
|
|
669
|
+
expiresAt: null,
|
|
670
|
+
metadata: null,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
throw new Error("Unexpected mutation");
|
|
674
|
+
});
|
|
675
|
+
const ctx = makeCtx(runMutation);
|
|
676
|
+
|
|
677
|
+
vi.stubGlobal("Buffer", undefined as any);
|
|
678
|
+
vi.stubGlobal("btoa", undefined as any);
|
|
679
|
+
class FakeResponse {
|
|
680
|
+
body: unknown;
|
|
681
|
+
status: number;
|
|
682
|
+
statusText: string;
|
|
683
|
+
headers: Headers;
|
|
684
|
+
ok: boolean;
|
|
685
|
+
|
|
686
|
+
constructor(body: unknown, init?: ResponseInit) {
|
|
687
|
+
this.body = body;
|
|
688
|
+
this.status = init?.status ?? 200;
|
|
689
|
+
this.statusText = init?.statusText ?? "";
|
|
690
|
+
this.headers = new Headers(init?.headers);
|
|
691
|
+
this.ok = this.status >= 200 && this.status < 300;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
vi.stubGlobal("Response", FakeResponse as any);
|
|
695
|
+
vi.stubGlobal("crypto", {
|
|
696
|
+
subtle: {
|
|
697
|
+
digest: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
|
|
698
|
+
},
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
vi.stubGlobal(
|
|
702
|
+
"fetch",
|
|
703
|
+
vi.fn(async (url) => {
|
|
704
|
+
if (url === uploadUrl) {
|
|
705
|
+
return {
|
|
706
|
+
ok: true,
|
|
707
|
+
json: async () => ({ storageId: "storage" }),
|
|
708
|
+
} as Response;
|
|
709
|
+
}
|
|
710
|
+
return { ok: false, status: 404 } as Response;
|
|
711
|
+
}),
|
|
712
|
+
);
|
|
713
|
+
|
|
714
|
+
const request = buildUploadRequest({
|
|
715
|
+
[uploadFormFields.file]: new File(["file"], "filename", {
|
|
716
|
+
type: "text/plain",
|
|
717
|
+
}),
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
await expect(handler(ctx, request)).rejects.toThrow(
|
|
721
|
+
"Base64 encoding is not available in this environment.",
|
|
722
|
+
);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
test("upload route calls onUploadComplete hook", async () => {
|
|
726
|
+
const checkUploadRequest = mockCheckUploadRequest(["a"]);
|
|
727
|
+
const onUploadComplete = vi.fn(async () => undefined);
|
|
728
|
+
const router = createRouter();
|
|
729
|
+
registerRoutes(router, component, {
|
|
730
|
+
enableUploadRoute: true,
|
|
731
|
+
checkUploadRequest,
|
|
732
|
+
onUploadComplete,
|
|
733
|
+
});
|
|
734
|
+
const uploadRoute = getRoute(router, "/files/upload", "POST");
|
|
735
|
+
const handler = getHandler(uploadRoute.handler);
|
|
736
|
+
|
|
737
|
+
const uploadUrl = "https://upload.example.com";
|
|
738
|
+
const uploadToken = "upload-token";
|
|
739
|
+
const finalizeResult = {
|
|
740
|
+
storageId: "storage",
|
|
741
|
+
storageProvider: "convex",
|
|
742
|
+
expiresAt: null,
|
|
743
|
+
metadata: {
|
|
744
|
+
storageId: "storage",
|
|
745
|
+
size: 4,
|
|
746
|
+
sha256: "hash",
|
|
747
|
+
contentType: "text/plain",
|
|
748
|
+
},
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
const runMutation = vi.fn(async (ref, _args) => {
|
|
752
|
+
if (ref === component.upload.generateUploadUrl) {
|
|
753
|
+
return {
|
|
754
|
+
uploadUrl,
|
|
755
|
+
uploadToken,
|
|
756
|
+
uploadTokenExpiresAt: Date.now(),
|
|
757
|
+
storageProvider: "convex",
|
|
758
|
+
storageId: null,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
if (ref === component.upload.finalizeUpload) {
|
|
762
|
+
return finalizeResult;
|
|
763
|
+
}
|
|
764
|
+
throw new Error("Unexpected mutation");
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
const ctx = makeCtx(runMutation);
|
|
768
|
+
|
|
769
|
+
const fetchMock = vi.fn(async (url) => {
|
|
770
|
+
if (url === uploadUrl) {
|
|
771
|
+
return new Response(JSON.stringify({ storageId: "storage" }), {
|
|
772
|
+
status: 200,
|
|
773
|
+
headers: { "Content-Type": "application/json" },
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
return new Response(null, { status: 404 });
|
|
777
|
+
});
|
|
778
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
779
|
+
|
|
780
|
+
const request = buildUploadRequest({
|
|
781
|
+
[uploadFormFields.file]: new File(["file"], "filename", { type: "text/plain" }),
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
await handler(ctx, request);
|
|
785
|
+
|
|
786
|
+
expect(onUploadComplete).toHaveBeenCalledTimes(1);
|
|
787
|
+
const hookArgs = onUploadComplete.mock.calls[0]?.[1];
|
|
788
|
+
expect(hookArgs).toMatchObject({
|
|
789
|
+
provider: "convex",
|
|
790
|
+
accessKeys: ["a"],
|
|
791
|
+
expiresAt: null,
|
|
792
|
+
result: finalizeResult,
|
|
793
|
+
});
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
test("upload route returns hook response with CORS headers", async () => {
|
|
797
|
+
const checkUploadRequest = mockCheckUploadRequest(["a"]);
|
|
798
|
+
const onUploadComplete = vi.fn(async () =>
|
|
799
|
+
new Response("hooked", {
|
|
800
|
+
status: 202,
|
|
801
|
+
headers: { "X-Hook": "true" },
|
|
802
|
+
}),
|
|
803
|
+
);
|
|
804
|
+
const router = createRouter();
|
|
805
|
+
registerRoutes(router, component, {
|
|
806
|
+
enableUploadRoute: true,
|
|
807
|
+
checkUploadRequest,
|
|
808
|
+
onUploadComplete,
|
|
809
|
+
});
|
|
810
|
+
const uploadRoute = getRoute(router, "/files/upload", "POST");
|
|
811
|
+
const handler = getHandler(uploadRoute.handler);
|
|
812
|
+
|
|
813
|
+
const uploadUrl = "https://upload.example.com";
|
|
814
|
+
const uploadToken = "upload-token";
|
|
815
|
+
const runMutation = vi.fn(async (ref) => {
|
|
816
|
+
if (ref === component.upload.generateUploadUrl) {
|
|
817
|
+
return {
|
|
818
|
+
uploadUrl,
|
|
819
|
+
uploadToken,
|
|
820
|
+
uploadTokenExpiresAt: Date.now(),
|
|
821
|
+
storageProvider: "convex",
|
|
822
|
+
storageId: null,
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
if (ref === component.upload.finalizeUpload) {
|
|
826
|
+
return {
|
|
827
|
+
storageId: "storage",
|
|
828
|
+
storageProvider: "convex",
|
|
829
|
+
expiresAt: null,
|
|
830
|
+
metadata: null,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
throw new Error("Unexpected mutation");
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
const ctx = makeCtx(runMutation);
|
|
837
|
+
vi.stubGlobal(
|
|
838
|
+
"fetch",
|
|
839
|
+
vi.fn(async (url) => {
|
|
840
|
+
if (url === uploadUrl) {
|
|
841
|
+
return new Response(JSON.stringify({ storageId: "storage" }), {
|
|
842
|
+
status: 200,
|
|
843
|
+
headers: { "Content-Type": "application/json" },
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
return new Response(null, { status: 404 });
|
|
847
|
+
}),
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
const request = buildUploadRequest({
|
|
851
|
+
[uploadFormFields.file]: new File(["file"], "filename", { type: "text/plain" }),
|
|
852
|
+
});
|
|
853
|
+
request.headers.set("Origin", "https://origin.example");
|
|
854
|
+
|
|
855
|
+
const response = await handler(ctx, request);
|
|
856
|
+
expect(response.status).toBe(202);
|
|
857
|
+
expect(response.headers.get("X-Hook")).toBe("true");
|
|
858
|
+
expect(response.headers.get("Access-Control-Allow-Origin")).toBe(
|
|
859
|
+
"https://origin.example",
|
|
860
|
+
);
|
|
861
|
+
});
|
|
862
|
+
|
|
365
863
|
test("upload route handles upstream failures", async () => {
|
|
864
|
+
const checkUploadRequest = mockCheckUploadRequest(["a"]);
|
|
366
865
|
const router = createRouter();
|
|
367
|
-
registerRoutes(router, component, { enableUploadRoute: true });
|
|
866
|
+
registerRoutes(router, component, { enableUploadRoute: true, checkUploadRequest });
|
|
368
867
|
const uploadRoute = getRoute(router, "/files/upload", "POST");
|
|
369
868
|
const handler = getHandler(uploadRoute.handler);
|
|
370
869
|
|
|
@@ -388,7 +887,6 @@ describe("registerRoutes", () => {
|
|
|
388
887
|
|
|
389
888
|
const request = buildUploadRequest({
|
|
390
889
|
[uploadFormFields.file]: new File(["file"], "filename", { type: "text/plain" }),
|
|
391
|
-
[uploadFormFields.accessKeys]: JSON.stringify(["a"]),
|
|
392
890
|
});
|
|
393
891
|
|
|
394
892
|
const failedUpload = await handler(ctx, request);
|
|
@@ -451,7 +949,7 @@ describe("registerRoutes", () => {
|
|
|
451
949
|
const response = await handler(
|
|
452
950
|
ctx,
|
|
453
951
|
buildDownloadRequest(
|
|
454
|
-
"https://example.com/files/download?token=token
|
|
952
|
+
"https://example.com/files/download?token=token",
|
|
455
953
|
),
|
|
456
954
|
);
|
|
457
955
|
expect(response.status).toBe(entry.code);
|
|
@@ -533,6 +1031,35 @@ describe("registerRoutes", () => {
|
|
|
533
1031
|
"attachment; filename=\"download\"",
|
|
534
1032
|
);
|
|
535
1033
|
});
|
|
1034
|
+
|
|
1035
|
+
test("download route omits Content-Type when upstream is missing it", async () => {
|
|
1036
|
+
const router = createRouter();
|
|
1037
|
+
registerRoutes(router, component);
|
|
1038
|
+
const downloadRoute = getRoute(router, "/files/download", "GET");
|
|
1039
|
+
const handler = getHandler(downloadRoute.handler);
|
|
1040
|
+
|
|
1041
|
+
const runMutation = vi.fn(async () => ({
|
|
1042
|
+
status: "ok",
|
|
1043
|
+
downloadUrl: "https://file.example.com",
|
|
1044
|
+
}));
|
|
1045
|
+
const ctx = makeCtx(runMutation);
|
|
1046
|
+
|
|
1047
|
+
vi.stubGlobal(
|
|
1048
|
+
"fetch",
|
|
1049
|
+
vi.fn(async () => ({
|
|
1050
|
+
ok: true,
|
|
1051
|
+
body: "file",
|
|
1052
|
+
headers: new Headers(),
|
|
1053
|
+
}) as any),
|
|
1054
|
+
);
|
|
1055
|
+
|
|
1056
|
+
const response = await handler(
|
|
1057
|
+
ctx,
|
|
1058
|
+
buildDownloadRequest("https://example.com/files/download?token=token"),
|
|
1059
|
+
);
|
|
1060
|
+
|
|
1061
|
+
expect(response.status).toBe(200);
|
|
1062
|
+
});
|
|
536
1063
|
});
|
|
537
1064
|
|
|
538
1065
|
describe("helpers", () => {
|
|
@@ -540,11 +1067,10 @@ describe("helpers", () => {
|
|
|
540
1067
|
const url = buildDownloadUrl({
|
|
541
1068
|
baseUrl: "https://example.com/",
|
|
542
1069
|
downloadToken: "token",
|
|
543
|
-
accessKey: "key",
|
|
544
1070
|
filename: "file.txt",
|
|
545
1071
|
});
|
|
546
1072
|
expect(url).toBe(
|
|
547
|
-
"https://example.com/files/download?token=token&
|
|
1073
|
+
"https://example.com/files/download?token=token&filename=file.txt",
|
|
548
1074
|
);
|
|
549
1075
|
});
|
|
550
1076
|
|