@podosoft/podokit 1.3.0 → 1.4.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/dist/add.d.ts +10 -1
- package/dist/add.js +20 -7
- package/dist/api-paths.d.ts +34 -0
- package/dist/api-paths.js +103 -0
- package/dist/deploy-compose-profile.d.ts +4 -0
- package/dist/deploy-compose-profile.js +13 -2
- package/dist/deploy-compose-render.d.ts +1 -1
- package/dist/deploy-compose-render.js +23 -7
- package/dist/deploy-compose.js +2 -1
- package/dist/deploy-profile.d.ts +3 -0
- package/dist/deploy-profile.js +12 -4
- package/dist/deploy-render.js +54 -8
- package/dist/deploy.js +21 -1
- package/dist/dev.d.ts +14 -0
- package/dist/dev.js +152 -10
- package/dist/index.js +15 -2
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +4 -2
- package/dist/lockfile.d.ts +3 -0
- package/dist/lockfile.js +9 -0
- package/dist/module-api-paths.d.ts +52 -0
- package/dist/module-api-paths.js +189 -0
- package/dist/remove.d.ts +3 -0
- package/dist/remove.js +12 -3
- package/dist/templates/fullstack/AGENTS.md +17 -2
- package/dist/templates/fullstack/README.md +19 -5
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -0
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +20 -1
- package/dist/templates/fullstack/apps/api/src/app.ts +11 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +3 -0
- package/dist/templates/fullstack/apps/api/src/config/redis.connection.spec.ts +48 -2
- package/dist/templates/fullstack/apps/api/src/config/redis.connection.ts +45 -0
- package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +46 -0
- package/dist/templates/fullstack/apps/api/src/config/server-options.ts +48 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +1 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
- package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +196 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +181 -2
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +8 -1
- package/dist/templates/fullstack/apps/api/src/database/database.ts +29 -10
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +188 -0
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.ts +151 -0
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +28 -4
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +10 -2
- package/dist/templates/fullstack/apps/api/src/main.ts +2 -1
- package/dist/templates/fullstack/apps/web/components.json +3 -1
- package/dist/templates/fullstack/apps/web/package.json +1 -1
- package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/index.ts +7 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/textarea.svelte +22 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +276 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +194 -9
- package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +15 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-headers.ts +12 -0
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
- package/dist/templates/fullstack/dot-claude/skills/podokit-elysia-endpoint/SKILL.md +5 -0
- package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +7 -1
- package/dist/templates/fullstack/dot-devcontainer/devcontainer.json +1 -0
- package/dist/templates/fullstack/{dot-env.docker → dot-env.docker.example} +7 -4
- package/dist/templates/fullstack/dot-env.example +10 -0
- package/dist/templates/fullstack/dot-gitignore +1 -0
- package/dist/templates/fullstack/dot-podokit/dev.json +2 -1
- package/dist/templates/fullstack/infra/k3s/README.md +3 -1
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +27 -9
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/asset-paths.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +125 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +107 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +10 -10
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
- package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.spec.ts +26 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +17 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +8 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.spec.ts +114 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.ts +50 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +4 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +11 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance-command.ts +5 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance.ts +14 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.spec.ts +136 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.ts +40 -0
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -2
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +121 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +108 -10
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +3 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +31 -3
- package/dist/templates/modules/bullmq/module.manifest.json +5 -1
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +2 -4
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.spec.ts +224 -0
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +124 -35
- package/dist/templates/modules/events-redis/module.manifest.json +4 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.routes.ts +136 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.spec.ts +636 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.ts +673 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +10 -4
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +37 -8
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-config.ts +99 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/migrations/1792100000000-InitFileUploadSessions.ts +49 -0
- package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.test.ts +456 -0
- package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.ts +356 -0
- package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +147 -0
- package/dist/templates/modules/file-upload/module.manifest.json +34 -6
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +1 -1
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +23 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +30 -20
- package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +20 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +79 -12
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +16 -1
- package/dist/templates/modules/object-storage-local/module.manifest.json +11 -2
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.spec.ts +115 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.ts +171 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +222 -9
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +55 -19
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.spec.ts +71 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.ts +98 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +6 -3
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts +7 -2
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +187 -44
- package/dist/templates/modules/object-storage-s3/module.manifest.json +10 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +11 -22
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +113 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +122 -0
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +24 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +7 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +14 -1
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +9 -12
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +16 -4
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +233 -5
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +134 -32
- package/dist/templates/modules/redis/module.manifest.json +4 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +11 -4
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +33 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +7 -0
- package/dist/templates/todo/AGENTS.md +9 -1
- package/dist/templates/todo/README.md +18 -5
- package/dist/update.d.ts +5 -0
- package/dist/update.js +35 -0
- package/package.json +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +0 -13
- package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +0 -5
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { SQL } from "bun";
|
|
3
|
+
import { Database as SqliteDatabase } from "bun:sqlite";
|
|
4
|
+
import type { QueryRunner } from "typeorm";
|
|
5
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
6
|
+
import {
|
|
7
|
+
MAX_MULTIPART_PARTS,
|
|
8
|
+
MultipartUploadError,
|
|
9
|
+
objectBodyChunks,
|
|
10
|
+
type CompletedPart,
|
|
11
|
+
type ObjectBody,
|
|
12
|
+
type ObjectInfo,
|
|
13
|
+
type ObjectStore,
|
|
14
|
+
} from "@podosoft/podokit-runtime";
|
|
15
|
+
import { Elysia } from "elysia";
|
|
16
|
+
import type { RequestIdentity } from "../core/services";
|
|
17
|
+
import { revertLatestSqliteMigration, runSqliteMigrations } from "../database/sqlite-migrator";
|
|
18
|
+
import { InitFileUploadSessions1792100000000 } from "../migrations/1792100000000-InitFileUploadSessions";
|
|
19
|
+
import { ChunkedUploads, COMPLETE_LEASE_MS } from "./chunked-uploads";
|
|
20
|
+
import { allowedOrigins, chunkedUploadRoutes } from "./chunked-uploads.routes";
|
|
21
|
+
import { filesRoutes } from "./files.module";
|
|
22
|
+
import { DEFAULT_UPLOAD_CONFIG, expectedPartSize, partLayout, uploadConfig, type UploadConfig } from "./upload-config";
|
|
23
|
+
|
|
24
|
+
const MIB = 1024 * 1024;
|
|
25
|
+
const PART = 5 * MIB;
|
|
26
|
+
|
|
27
|
+
async function bytes(body: ObjectBody): Promise<Uint8Array> {
|
|
28
|
+
const chunks: Uint8Array[] = [];
|
|
29
|
+
for await (const chunk of objectBodyChunks(body)) chunks.push(chunk);
|
|
30
|
+
return Buffer.concat(chunks);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function md5(value: Uint8Array): string {
|
|
34
|
+
return `"${new Bun.CryptoHasher("md5").update(value).digest("hex")}"`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface MemoryStore extends ObjectStore {
|
|
38
|
+
objects: Map<string, Uint8Array>;
|
|
39
|
+
uploads: Map<string, { key: string; parts: Map<number, Uint8Array> }>;
|
|
40
|
+
calls: { complete: number; abort: number };
|
|
41
|
+
failAbort: boolean;
|
|
42
|
+
/** Held completes resolve only when released. */
|
|
43
|
+
holdComplete?: Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function memoryStore(): MemoryStore {
|
|
47
|
+
const objects = new Map<string, Uint8Array>();
|
|
48
|
+
const uploads = new Map<string, { key: string; parts: Map<number, Uint8Array> }>();
|
|
49
|
+
const info = (key: string): ObjectInfo | null => {
|
|
50
|
+
const value = objects.get(key);
|
|
51
|
+
return value ? { key, size: value.byteLength, etag: md5(value), lastModified: new Date(0) } : null;
|
|
52
|
+
};
|
|
53
|
+
const upload = (key: string, uploadId: string): { key: string; parts: Map<number, Uint8Array> } => {
|
|
54
|
+
const found = uploads.get(uploadId);
|
|
55
|
+
if (!found || found.key !== key) throw new MultipartUploadError("UPLOAD_NOT_FOUND", "no such upload");
|
|
56
|
+
return found;
|
|
57
|
+
};
|
|
58
|
+
const store: MemoryStore = {
|
|
59
|
+
objects,
|
|
60
|
+
uploads,
|
|
61
|
+
calls: { complete: 0, abort: 0 },
|
|
62
|
+
failAbort: false,
|
|
63
|
+
put: async (key, body) => {
|
|
64
|
+
const value = await bytes(body);
|
|
65
|
+
objects.set(key, value);
|
|
66
|
+
return { key, size: value.byteLength };
|
|
67
|
+
},
|
|
68
|
+
get: async () => null,
|
|
69
|
+
stat: async (key) => info(key),
|
|
70
|
+
list: async () => ({ keys: [] }),
|
|
71
|
+
exists: async (key) => objects.has(key),
|
|
72
|
+
delete: async (key) => {
|
|
73
|
+
objects.delete(key);
|
|
74
|
+
},
|
|
75
|
+
getDownloadUrl: async (key) => `https://storage.example.com/${key}`,
|
|
76
|
+
createMultipartUpload: async (key) => {
|
|
77
|
+
const uploadId = crypto.randomUUID();
|
|
78
|
+
uploads.set(uploadId, { key, parts: new Map() });
|
|
79
|
+
return { uploadId };
|
|
80
|
+
},
|
|
81
|
+
uploadPart: async (key, uploadId, partNumber, body) => {
|
|
82
|
+
const target = upload(key, uploadId);
|
|
83
|
+
const value = await bytes(body);
|
|
84
|
+
target.parts.set(partNumber, value);
|
|
85
|
+
return { etag: md5(value), size: value.byteLength };
|
|
86
|
+
},
|
|
87
|
+
completeMultipartUpload: async (key, uploadId, parts: readonly CompletedPart[]) => {
|
|
88
|
+
store.calls.complete += 1;
|
|
89
|
+
await store.holdComplete;
|
|
90
|
+
const target = upload(key, uploadId);
|
|
91
|
+
const ordered = parts.map(({ partNumber, etag }) => {
|
|
92
|
+
const value = target.parts.get(partNumber);
|
|
93
|
+
if (!value || md5(value) !== etag) throw new MultipartUploadError("INVALID_PART", `part ${partNumber}`);
|
|
94
|
+
return value;
|
|
95
|
+
});
|
|
96
|
+
uploads.delete(uploadId);
|
|
97
|
+
objects.set(key, Buffer.concat(ordered));
|
|
98
|
+
return info(key) as ObjectInfo;
|
|
99
|
+
},
|
|
100
|
+
abortMultipartUpload: async (_key, uploadId) => {
|
|
101
|
+
store.calls.abort += 1;
|
|
102
|
+
if (store.failAbort) throw new Error("storage is unavailable");
|
|
103
|
+
uploads.delete(uploadId);
|
|
104
|
+
},
|
|
105
|
+
close: () => undefined,
|
|
106
|
+
};
|
|
107
|
+
return store;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let open: SQL | undefined;
|
|
111
|
+
|
|
112
|
+
afterEach(async () => {
|
|
113
|
+
await open?.close();
|
|
114
|
+
open = undefined;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
async function database(): Promise<SQL> {
|
|
118
|
+
const sql = new SQL(":memory:");
|
|
119
|
+
open = sql;
|
|
120
|
+
await sql`PRAGMA foreign_keys = ON`;
|
|
121
|
+
const runner = {
|
|
122
|
+
connection: { options: { type: "better-sqlite3" } },
|
|
123
|
+
query: (query: string): Promise<unknown> => sql.unsafe(query),
|
|
124
|
+
} as unknown as QueryRunner;
|
|
125
|
+
await new InitFileUploadSessions1792100000000().up(runner);
|
|
126
|
+
return sql;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface Harness {
|
|
130
|
+
sql: SQL;
|
|
131
|
+
store: MemoryStore;
|
|
132
|
+
clock: { now: number };
|
|
133
|
+
uploads: ChunkedUploads;
|
|
134
|
+
replica: () => ChunkedUploads;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const CONFIG: UploadConfig = { ...DEFAULT_UPLOAD_CONFIG, partBytes: PART, sessionTtlSeconds: 600 };
|
|
138
|
+
|
|
139
|
+
async function harness(config: UploadConfig = CONFIG): Promise<Harness> {
|
|
140
|
+
const sql = await database();
|
|
141
|
+
const store = memoryStore();
|
|
142
|
+
const clock = { now: Date.parse("2026-01-01T00:00:00.000Z") };
|
|
143
|
+
const replica = (): ChunkedUploads =>
|
|
144
|
+
new ChunkedUploads({ sql, dialect: "sqlite", config, storage: () => store, now: () => clock.now });
|
|
145
|
+
return { sql, store, clock, uploads: replica(), replica };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function content(size: number, seed = 7): Uint8Array {
|
|
149
|
+
const value = new Uint8Array(size);
|
|
150
|
+
for (let index = 0; index < size; index++) value[index] = (index * 31 + seed) % 251;
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function streamOf(value: Uint8Array): ReadableStream<Uint8Array> {
|
|
155
|
+
return new ReadableStream({
|
|
156
|
+
start(controller): void {
|
|
157
|
+
controller.enqueue(value);
|
|
158
|
+
controller.close();
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function slicePart(file: Uint8Array, partSize: number, partNumber: number): Uint8Array {
|
|
164
|
+
return file.subarray((partNumber - 1) * partSize, partNumber * partSize);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function code(operation: Promise<unknown>): Promise<string> {
|
|
168
|
+
try {
|
|
169
|
+
await operation;
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (error instanceof AppException) return `${error.statusCode} ${error.code}`;
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
throw new Error("expected the operation to fail");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function sendAll(
|
|
178
|
+
uploads: ChunkedUploads,
|
|
179
|
+
owner: string | undefined,
|
|
180
|
+
id: string,
|
|
181
|
+
file: Uint8Array,
|
|
182
|
+
partSize: number,
|
|
183
|
+
partCount: number,
|
|
184
|
+
): Promise<void> {
|
|
185
|
+
for (let number = 1; number <= partCount; number++) {
|
|
186
|
+
const part = slicePart(file, partSize, number);
|
|
187
|
+
await uploads.putPart(owner, id, number, part.byteLength, streamOf(part));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
describe("part layout", () => {
|
|
192
|
+
it("uses the configured part size with a smaller last part", () => {
|
|
193
|
+
const layout = partLayout(12 * MIB + 3, 8 * MIB);
|
|
194
|
+
expect(layout).toEqual({ partSize: 8 * MIB, partCount: 2 });
|
|
195
|
+
expect(expectedPartSize(12 * MIB + 3, layout, 1)).toBe(8 * MIB);
|
|
196
|
+
expect(expectedPartSize(12 * MIB + 3, layout, 2)).toBe(4 * MIB + 3);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("keeps a small file in one part and exact multiples without a remainder", () => {
|
|
200
|
+
expect(partLayout(10, 8 * MIB)).toEqual({ partSize: 8 * MIB, partCount: 1 });
|
|
201
|
+
expect(expectedPartSize(10, partLayout(10, 8 * MIB), 1)).toBe(10);
|
|
202
|
+
const exact = partLayout(16 * MIB, 8 * MIB);
|
|
203
|
+
expect(exact.partCount).toBe(2);
|
|
204
|
+
expect(expectedPartSize(16 * MIB, exact, 2)).toBe(8 * MIB);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("grows the part size in whole MiB to stay within the part limit", () => {
|
|
208
|
+
const size = 100 * 1024 * MIB;
|
|
209
|
+
const layout = partLayout(size, 8 * MIB);
|
|
210
|
+
expect(layout.partSize % MIB).toBe(0);
|
|
211
|
+
expect(layout.partSize).toBeGreaterThan(8 * MIB);
|
|
212
|
+
expect(layout.partCount).toBeLessThanOrEqual(MAX_MULTIPART_PARTS);
|
|
213
|
+
expect(partLayout(size, layout.partSize - MIB).partSize).toBe(layout.partSize);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("upload configuration", () => {
|
|
218
|
+
it("reads defaults and overrides", () => {
|
|
219
|
+
expect(uploadConfig({}, 128 * MIB)).toEqual(DEFAULT_UPLOAD_CONFIG);
|
|
220
|
+
expect(uploadConfig({
|
|
221
|
+
FILE_UPLOAD_MAX_BYTES: "1048576",
|
|
222
|
+
FILE_UPLOAD_PART_BYTES: String(16 * MIB),
|
|
223
|
+
FILE_UPLOAD_SESSION_TTL: "3600",
|
|
224
|
+
FILE_UPLOAD_SWEEP_INTERVAL: "0",
|
|
225
|
+
FILE_UPLOAD_RATE_LIMIT_MAX: "50",
|
|
226
|
+
}, 128 * MIB)).toEqual({
|
|
227
|
+
maxBytes: MIB,
|
|
228
|
+
partBytes: 16 * MIB,
|
|
229
|
+
sessionTtlSeconds: 3600,
|
|
230
|
+
sweepIntervalSeconds: 0,
|
|
231
|
+
rateLimitMax: 50,
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("rejects parts below the storage minimum and invalid numbers", () => {
|
|
236
|
+
expect(() => uploadConfig({ FILE_UPLOAD_PART_BYTES: String(4 * MIB) }, 128 * MIB)).toThrow("FILE_UPLOAD_PART_BYTES");
|
|
237
|
+
expect(() => uploadConfig({ FILE_UPLOAD_MAX_BYTES: "1.5" }, 128 * MIB)).toThrow("FILE_UPLOAD_MAX_BYTES");
|
|
238
|
+
expect(() => uploadConfig({ FILE_UPLOAD_SESSION_TTL: "-1" }, 128 * MIB)).toThrow("FILE_UPLOAD_SESSION_TTL");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("refuses a part size the API body limit cannot accept", () => {
|
|
242
|
+
expect(() => uploadConfig({ FILE_UPLOAD_PART_BYTES: String(64 * MIB) }, 32 * MIB)).toThrow("API_MAX_REQUEST_BODY_SIZE");
|
|
243
|
+
// 1 TiB in at most 10,000 parts needs parts over 100 MiB.
|
|
244
|
+
expect(() => uploadConfig({ FILE_UPLOAD_MAX_BYTES: String(1024 * 1024 * MIB) }, 100 * MIB)).toThrow("body limit");
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("chunked uploads", () => {
|
|
249
|
+
it("stores a file sent in parts, with a retried part, and resumes from the status", async () => {
|
|
250
|
+
const { uploads, store } = await harness();
|
|
251
|
+
const file = content(2 * PART + 1234);
|
|
252
|
+
const created = await uploads.create("user_1", { filename: "report.bin", size: file.byteLength, contentType: "application/pdf" });
|
|
253
|
+
expect(created).toMatchObject({ partSize: PART, partCount: 3, expiresAt: "2026-01-01T00:10:00.000Z" });
|
|
254
|
+
|
|
255
|
+
await uploads.putPart("user_1", created.id, 3, 1234, streamOf(slicePart(file, PART, 3)));
|
|
256
|
+
await uploads.putPart("user_1", created.id, 1, PART, streamOf(slicePart(file, PART, 1)));
|
|
257
|
+
// A retry of the same part replaces it.
|
|
258
|
+
await uploads.putPart("user_1", created.id, 1, PART, streamOf(slicePart(file, PART, 1)));
|
|
259
|
+
const status = await uploads.status("user_1", created.id);
|
|
260
|
+
expect(status).toMatchObject({ status: "open", size: file.byteLength, receivedParts: [1, 3], filename: "report.bin" });
|
|
261
|
+
expect(status.key).toBeUndefined();
|
|
262
|
+
|
|
263
|
+
expect(await code(uploads.complete("user_1", created.id))).toBe("409 UPLOAD_INCOMPLETE");
|
|
264
|
+
await uploads.putPart("user_1", created.id, 2, PART, streamOf(slicePart(file, PART, 2)));
|
|
265
|
+
const done = await uploads.complete("user_1", created.id);
|
|
266
|
+
expect(done).toMatchObject({ status: "completed", size: file.byteLength, contentType: "application/pdf" });
|
|
267
|
+
expect(done.key).toMatch(new RegExp(`^uploads/user_1/${created.id}-report\\.bin$`));
|
|
268
|
+
expect(done.url).toBe(`https://storage.example.com/${done.key}`);
|
|
269
|
+
expect(Buffer.compare(store.objects.get(done.key) ?? new Uint8Array(), file)).toBe(0);
|
|
270
|
+
expect((await uploads.status("user_1", created.id)).key).toBe(done.key);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("rejects sizes over the limit and parts of the wrong size", async () => {
|
|
274
|
+
const { uploads } = await harness({ ...CONFIG, maxBytes: 3 * PART });
|
|
275
|
+
expect(await code(uploads.create("user_1", { filename: "big", size: 3 * PART + 1 }))).toBe("413 UPLOAD_TOO_LARGE");
|
|
276
|
+
const { id } = await uploads.create("user_1", { filename: "a", size: PART + 10 });
|
|
277
|
+
const part = content(PART);
|
|
278
|
+
expect(await code(uploads.putPart("user_1", id, 1, PART - 1, streamOf(part)))).toBe("400 UPLOAD_PART_SIZE_MISMATCH");
|
|
279
|
+
expect(await code(uploads.putPart("user_1", id, 2, PART, streamOf(part)))).toBe("400 UPLOAD_PART_SIZE_MISMATCH");
|
|
280
|
+
expect(await code(uploads.putPart("user_1", id, 1, null, streamOf(part)))).toBe("411 UPLOAD_LENGTH_REQUIRED");
|
|
281
|
+
expect(await code(uploads.putPart("user_1", id, 3, 10, streamOf(part)))).toBe("400 UPLOAD_PART_OUT_OF_RANGE");
|
|
282
|
+
// A body that ends before its declared length is refused and not recorded.
|
|
283
|
+
expect(await code(uploads.putPart("user_1", id, 1, PART, streamOf(part.subarray(0, 100))))).toBe("400 UPLOAD_PART_SIZE_MISMATCH");
|
|
284
|
+
expect((await uploads.status("user_1", id)).receivedParts).toEqual([]);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("hides uploads from everyone but their owner", async () => {
|
|
288
|
+
const { uploads } = await harness();
|
|
289
|
+
const { id } = await uploads.create("user_1", { filename: "a", size: 10 });
|
|
290
|
+
const part = content(10);
|
|
291
|
+
expect(await code(uploads.status("user_2", id))).toBe("404 UPLOAD_NOT_FOUND");
|
|
292
|
+
expect(await code(uploads.status(undefined, id))).toBe("404 UPLOAD_NOT_FOUND");
|
|
293
|
+
expect(await code(uploads.putPart("user_2", id, 1, 10, streamOf(part)))).toBe("404 UPLOAD_NOT_FOUND");
|
|
294
|
+
expect(await code(uploads.complete("user_2", id))).toBe("404 UPLOAD_NOT_FOUND");
|
|
295
|
+
expect(await code(uploads.abort("user_2", id))).toBe("404 UPLOAD_NOT_FOUND");
|
|
296
|
+
expect(await code(uploads.status("user_1", "not-a-uuid"))).toBe("404 UPLOAD_NOT_FOUND");
|
|
297
|
+
expect((await uploads.status("user_1", id)).status).toBe("open");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("lets exactly one of two concurrent completes assemble the object", async () => {
|
|
301
|
+
const { uploads, store, replica } = await harness();
|
|
302
|
+
const file = content(PART + 5);
|
|
303
|
+
const created = await uploads.create("user_1", { filename: "a", size: file.byteLength });
|
|
304
|
+
await sendAll(uploads, "user_1", created.id, file, PART, created.partCount);
|
|
305
|
+
|
|
306
|
+
let release = (): void => undefined;
|
|
307
|
+
store.holdComplete = new Promise((resolve) => { release = resolve; });
|
|
308
|
+
const first = uploads.complete("user_1", created.id);
|
|
309
|
+
await Bun.sleep(5);
|
|
310
|
+
expect(await code(replica().complete("user_1", created.id))).toBe("409 UPLOAD_COMPLETING");
|
|
311
|
+
expect(await code(replica().putPart("user_1", created.id, 2, 5, streamOf(content(5))))).toBe("409 UPLOAD_COMPLETING");
|
|
312
|
+
expect(await code(replica().abort("user_1", created.id))).toBe("409 UPLOAD_COMPLETING");
|
|
313
|
+
release();
|
|
314
|
+
const done = await first;
|
|
315
|
+
expect(store.calls.complete).toBe(1);
|
|
316
|
+
|
|
317
|
+
// Completing again returns the stored result without a second assembly.
|
|
318
|
+
expect(await replica().complete("user_1", created.id)).toEqual(done);
|
|
319
|
+
expect(store.calls.complete).toBe(1);
|
|
320
|
+
expect(await code(uploads.abort("user_1", created.id))).toBe("409 UPLOAD_COMPLETED");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("claims completion with a conditional update when both requests saw it open", async () => {
|
|
324
|
+
const { uploads, store, replica } = await harness();
|
|
325
|
+
const created = await uploads.create("user_1", { filename: "a", size: 10 });
|
|
326
|
+
await uploads.putPart("user_1", created.id, 1, 10, streamOf(content(10)));
|
|
327
|
+
|
|
328
|
+
let release = (): void => undefined;
|
|
329
|
+
store.holdComplete = new Promise((resolve) => { release = resolve; });
|
|
330
|
+
const attempts = [uploads.complete("user_1", created.id), replica().complete("user_1", created.id)]
|
|
331
|
+
.map((attempt) => attempt.then(
|
|
332
|
+
(result) => result.status,
|
|
333
|
+
(error: unknown) => error instanceof AppException ? error.code : "unexpected",
|
|
334
|
+
));
|
|
335
|
+
await Bun.sleep(5);
|
|
336
|
+
release();
|
|
337
|
+
expect((await Promise.all(attempts)).sort()).toEqual(["UPLOAD_COMPLETING", "completed"]);
|
|
338
|
+
expect(store.calls.complete).toBe(1);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("reopens the upload when storage rejects a changed part", async () => {
|
|
342
|
+
const { uploads, store } = await harness();
|
|
343
|
+
const created = await uploads.create("user_1", { filename: "a", size: 10 });
|
|
344
|
+
await uploads.putPart("user_1", created.id, 1, 10, streamOf(content(10)));
|
|
345
|
+
for (const upload of store.uploads.values()) upload.parts.set(1, content(10, 99));
|
|
346
|
+
|
|
347
|
+
expect(await code(uploads.complete("user_1", created.id))).toBe("409 UPLOAD_INVALID_PART");
|
|
348
|
+
expect((await uploads.status("user_1", created.id)).status).toBe("open");
|
|
349
|
+
await uploads.putPart("user_1", created.id, 1, 10, streamOf(content(10)));
|
|
350
|
+
expect((await uploads.complete("user_1", created.id)).size).toBe(10);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("aborts on request and refuses later parts", async () => {
|
|
354
|
+
const { uploads, store } = await harness();
|
|
355
|
+
const created = await uploads.create("user_1", { filename: "a", size: 10 });
|
|
356
|
+
await uploads.abort("user_1", created.id);
|
|
357
|
+
expect(store.uploads.size).toBe(0);
|
|
358
|
+
expect((await uploads.status("user_1", created.id)).status).toBe("aborted");
|
|
359
|
+
expect(await code(uploads.putPart("user_1", created.id, 1, 10, streamOf(content(10))))).toBe("410 UPLOAD_ABORTED");
|
|
360
|
+
expect(await code(uploads.complete("user_1", created.id))).toBe("410 UPLOAD_ABORTED");
|
|
361
|
+
await uploads.abort("user_1", created.id);
|
|
362
|
+
expect(store.calls.abort).toBe(1);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("marks an upload storage no longer knows as gone", async () => {
|
|
366
|
+
const { uploads, store } = await harness();
|
|
367
|
+
const created = await uploads.create("user_1", { filename: "a", size: 10 });
|
|
368
|
+
store.uploads.clear();
|
|
369
|
+
expect(await code(uploads.putPart("user_1", created.id, 1, 10, streamOf(content(10))))).toBe("404 UPLOAD_NOT_FOUND");
|
|
370
|
+
expect((await uploads.status("user_1", created.id)).status).toBe("aborted");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("expires idle uploads and lets only one sweeping replica abort each", async () => {
|
|
374
|
+
const { uploads, store, clock, replica } = await harness();
|
|
375
|
+
const ids: string[] = [];
|
|
376
|
+
for (let index = 0; index < 3; index++) {
|
|
377
|
+
ids.push((await uploads.create("user_1", { filename: `f${index}`, size: 10 })).id);
|
|
378
|
+
}
|
|
379
|
+
const active = await uploads.create("user_1", { filename: "active", size: PART + 1 });
|
|
380
|
+
clock.now += 500_000;
|
|
381
|
+
// Storing a part restarts the idle clock.
|
|
382
|
+
await uploads.putPart("user_1", active.id, 1, PART, streamOf(content(PART)));
|
|
383
|
+
clock.now += 200_000;
|
|
384
|
+
|
|
385
|
+
const first = ids[0] ?? "";
|
|
386
|
+
expect(await code(uploads.putPart("user_1", first, 1, 10, streamOf(content(10))))).toBe("410 UPLOAD_EXPIRED");
|
|
387
|
+
const results = await Promise.all([replica().sweep(), replica().sweep()]);
|
|
388
|
+
expect(results.reduce((sum, result) => sum + result.expired, 0)).toBe(3);
|
|
389
|
+
expect(store.calls.abort).toBe(3);
|
|
390
|
+
expect(store.uploads.size).toBe(1);
|
|
391
|
+
expect((await uploads.status("user_1", first)).status).toBe("aborted");
|
|
392
|
+
expect((await uploads.status("user_1", active.id)).status).toBe("open");
|
|
393
|
+
expect((await uploads.sweep()).expired).toBe(0);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("retries a failed storage abort and deletes finished records after retention", async () => {
|
|
397
|
+
const { uploads, store, clock } = await harness();
|
|
398
|
+
const created = await uploads.create("user_1", { filename: "a", size: 10 });
|
|
399
|
+
store.failAbort = true;
|
|
400
|
+
await uploads.abort("user_1", created.id);
|
|
401
|
+
expect(store.uploads.size).toBe(1);
|
|
402
|
+
|
|
403
|
+
store.failAbort = false;
|
|
404
|
+
// Retried once a sweep interval has passed since the failed attempt.
|
|
405
|
+
clock.now += 60_000;
|
|
406
|
+
expect((await uploads.sweep()).released).toBe(0);
|
|
407
|
+
clock.now += 241_000;
|
|
408
|
+
expect(await uploads.sweep()).toMatchObject({ released: 1, deleted: 0 });
|
|
409
|
+
expect(store.uploads.size).toBe(0);
|
|
410
|
+
|
|
411
|
+
clock.now += 600_000;
|
|
412
|
+
expect((await uploads.sweep()).deleted).toBe(1);
|
|
413
|
+
expect(await code(uploads.status("user_1", created.id))).toBe("404 UPLOAD_NOT_FOUND");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("settles uploads a stopped replica left completing", async () => {
|
|
417
|
+
const { uploads, store, clock, sql } = await harness();
|
|
418
|
+
const stored = await uploads.create("user_1", { filename: "stored", size: 10 });
|
|
419
|
+
const lost = await uploads.create("user_1", { filename: "lost", size: 10 });
|
|
420
|
+
for (const { id } of [stored, lost]) {
|
|
421
|
+
await uploads.putPart("user_1", id, 1, 10, streamOf(content(10)));
|
|
422
|
+
}
|
|
423
|
+
await sql`UPDATE "file_upload_sessions" SET "status" = 'completing'`;
|
|
424
|
+
const storedKey = (await sql<Array<{ objectKey: string }>>`
|
|
425
|
+
SELECT "objectKey" FROM "file_upload_sessions" WHERE "id" = ${stored.id}
|
|
426
|
+
`)[0]?.objectKey ?? "";
|
|
427
|
+
// The stopped replica's storage complete went through for this one.
|
|
428
|
+
store.objects.set(storedKey, content(10));
|
|
429
|
+
for (const [uploadId, upload] of store.uploads) {
|
|
430
|
+
if (upload.key === storedKey) store.uploads.delete(uploadId);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
expect((await uploads.sweep()).recovered).toBe(0);
|
|
434
|
+
clock.now += COMPLETE_LEASE_MS + 1;
|
|
435
|
+
expect((await uploads.sweep()).recovered).toBe(2);
|
|
436
|
+
expect((await uploads.status("user_1", stored.id)).status).toBe("completed");
|
|
437
|
+
expect((await uploads.status("user_1", lost.id)).status).toBe("aborted");
|
|
438
|
+
expect(store.uploads.size).toBe(0);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("stops the sweep timer on close", async () => {
|
|
442
|
+
const { uploads } = await harness({ ...CONFIG, sweepIntervalSeconds: 1 });
|
|
443
|
+
uploads.start();
|
|
444
|
+
await uploads.close();
|
|
445
|
+
uploads.start();
|
|
446
|
+
await uploads.close();
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
describe("chunked upload routes", () => {
|
|
451
|
+
const WEB = "http://localhost:5001";
|
|
452
|
+
|
|
453
|
+
function caller(userId: string | undefined): RequestIdentity {
|
|
454
|
+
return {
|
|
455
|
+
userId: async () => userId,
|
|
456
|
+
isAdmin: async () => false,
|
|
457
|
+
roles: async () => [],
|
|
458
|
+
activeOrganizationId: async () => undefined,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
interface Handler {
|
|
463
|
+
handle(request: Request): Promise<Response>;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
interface Reply {
|
|
467
|
+
status: number;
|
|
468
|
+
body: Record<string, unknown>;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function app(): Promise<{ handle: (request: Request) => Promise<Reply>; store: MemoryStore }> {
|
|
472
|
+
const { uploads, store } = await harness();
|
|
473
|
+
const identities = new Map([["alice", caller("user_1")], ["bob", caller("user_2")]]);
|
|
474
|
+
const routes = (user: string): Handler => new Elysia()
|
|
475
|
+
.onError(({ error, set }) => {
|
|
476
|
+
if (!(error instanceof AppException)) return undefined;
|
|
477
|
+
set.status = error.statusCode;
|
|
478
|
+
return { code: error.code };
|
|
479
|
+
})
|
|
480
|
+
.use(filesRoutes(store, identities.get(user) ?? caller(undefined)))
|
|
481
|
+
.use(chunkedUploadRoutes(uploads, identities.get(user) ?? caller(undefined), allowedOrigins(`${WEB}/, http://example.com`)));
|
|
482
|
+
const apps = new Map([...identities.keys()].map((user) => [user, routes(user)]));
|
|
483
|
+
return {
|
|
484
|
+
store,
|
|
485
|
+
handle: async (request) => {
|
|
486
|
+
const user = request.headers.get("x-test-user") ?? "alice";
|
|
487
|
+
const response = await (apps.get(user) ?? routes(user)).handle(request);
|
|
488
|
+
const text = await response.text();
|
|
489
|
+
return { status: response.status, body: text ? JSON.parse(text) as Record<string, unknown> : {} };
|
|
490
|
+
},
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function json(path: string, body: unknown, headers: Record<string, string> = {}): Request {
|
|
495
|
+
return new Request(`http://localhost${path}`, {
|
|
496
|
+
method: "POST",
|
|
497
|
+
headers: { "content-type": "application/json", origin: WEB, ...headers },
|
|
498
|
+
body: JSON.stringify(body),
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function part(path: string, value: Uint8Array, headers: Record<string, string> = {}): Request {
|
|
503
|
+
return new Request(`http://localhost${path}`, {
|
|
504
|
+
method: "PUT",
|
|
505
|
+
headers: {
|
|
506
|
+
"content-type": "application/octet-stream",
|
|
507
|
+
"content-length": String(value.byteLength),
|
|
508
|
+
origin: WEB,
|
|
509
|
+
...headers,
|
|
510
|
+
},
|
|
511
|
+
// A copy owns a plain ArrayBuffer, which the BodyInit type requires.
|
|
512
|
+
body: value.slice(),
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
it("runs an upload over HTTP", async () => {
|
|
517
|
+
const { handle, store } = await app();
|
|
518
|
+
const file = content(PART + 3);
|
|
519
|
+
const created = await handle(json("/files/uploads", { filename: "a.bin", size: file.byteLength }));
|
|
520
|
+
expect(created.status).toBe(201);
|
|
521
|
+
const id = String(created.body.id);
|
|
522
|
+
expect(created.body).toMatchObject({ partSize: PART, partCount: 2 });
|
|
523
|
+
|
|
524
|
+
expect(await handle(part(`/files/uploads/${id}/parts/1`, slicePart(file, PART, 1)))).toEqual({
|
|
525
|
+
status: 200,
|
|
526
|
+
body: { partNumber: 1, size: PART },
|
|
527
|
+
});
|
|
528
|
+
expect((await handle(part(`/files/uploads/${id}/parts/2`, slicePart(file, PART, 2)))).status).toBe(200);
|
|
529
|
+
expect((await handle(new Request(`http://localhost/files/uploads/${id}`))).body.receivedParts).toEqual([1, 2]);
|
|
530
|
+
const done = await handle(json(`/files/uploads/${id}/complete`, {}));
|
|
531
|
+
expect(done).toMatchObject({ status: 200, body: { status: "completed", size: file.byteLength } });
|
|
532
|
+
expect(store.objects.get(String(done.body.key))?.byteLength).toBe(file.byteLength);
|
|
533
|
+
|
|
534
|
+
// The presign route still matches encoded keys next to the upload routes.
|
|
535
|
+
const presign = await handle(new Request(`http://localhost/files/${encodeURIComponent(String(done.body.key))}/url`));
|
|
536
|
+
expect(presign).toEqual({ status: 200, body: { url: `https://storage.example.com/${String(done.body.key)}` } });
|
|
537
|
+
|
|
538
|
+
const removed = await handle(new Request(`http://localhost/files/uploads/${id}`, { method: "DELETE", headers: { origin: WEB } }));
|
|
539
|
+
expect(removed).toEqual({ status: 409, body: { code: "UPLOAD_COMPLETED" } });
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("answers another user with 404 and cancels on DELETE", async () => {
|
|
543
|
+
const { handle } = await app();
|
|
544
|
+
const created = await handle(json("/files/uploads", { filename: "a", size: 10 }));
|
|
545
|
+
const id = String(created.body.id);
|
|
546
|
+
const bob = { "x-test-user": "bob" };
|
|
547
|
+
expect((await handle(new Request(`http://localhost/files/uploads/${id}`, { headers: bob }))).status).toBe(404);
|
|
548
|
+
expect((await handle(part(`/files/uploads/${id}/parts/1`, content(10), bob))).body).toEqual({ code: "UPLOAD_NOT_FOUND" });
|
|
549
|
+
expect((await handle(new Request(`http://localhost/files/uploads/${id}`, { method: "DELETE", headers: bob }))).status).toBe(404);
|
|
550
|
+
|
|
551
|
+
expect((await handle(new Request(`http://localhost/files/uploads/${id}`, { method: "DELETE" }))).status).toBe(204);
|
|
552
|
+
expect((await handle(new Request(`http://localhost/files/uploads/${id}`))).body.status).toBe("aborted");
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it("requires JSON or binary content types so cross-origin calls are preflighted", async () => {
|
|
556
|
+
const { handle } = await app();
|
|
557
|
+
const form = new Request("http://localhost/files/uploads", {
|
|
558
|
+
method: "POST",
|
|
559
|
+
headers: { "content-type": "text/plain", origin: WEB },
|
|
560
|
+
body: JSON.stringify({ filename: "a", size: 10 }),
|
|
561
|
+
});
|
|
562
|
+
expect(await handle(form)).toEqual({ status: 415, body: { code: "UNSUPPORTED_MEDIA_TYPE" } });
|
|
563
|
+
const urlencoded = new Request("http://localhost/files/uploads", {
|
|
564
|
+
method: "POST",
|
|
565
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
566
|
+
body: "filename=a&size=10",
|
|
567
|
+
});
|
|
568
|
+
expect((await handle(urlencoded)).status).toBe(415);
|
|
569
|
+
|
|
570
|
+
const id = String((await handle(json("/files/uploads", { filename: "a", size: 10 }))).body.id);
|
|
571
|
+
expect(await handle(part(`/files/uploads/${id}/parts/1`, content(10), { "content-type": "text/plain" }))).toEqual({
|
|
572
|
+
status: 415,
|
|
573
|
+
body: { code: "UNSUPPORTED_MEDIA_TYPE" },
|
|
574
|
+
});
|
|
575
|
+
const complete = new Request(`http://localhost/files/uploads/${id}/complete`, { method: "POST" });
|
|
576
|
+
expect(await handle(complete)).toEqual({ status: 415, body: { code: "UNSUPPORTED_MEDIA_TYPE" } });
|
|
577
|
+
expect((await handle(new Request(`http://localhost/files/uploads/${id}`))).body.receivedParts).toEqual([]);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it("rejects a browser origin outside CORS_ORIGIN", async () => {
|
|
581
|
+
const { handle } = await app();
|
|
582
|
+
const foreign = { origin: "https://attacker.example.net" };
|
|
583
|
+
expect(await handle(json("/files/uploads", { filename: "a", size: 10 }, foreign))).toEqual({
|
|
584
|
+
status: 403,
|
|
585
|
+
body: { code: "ORIGIN_NOT_ALLOWED" },
|
|
586
|
+
});
|
|
587
|
+
expect((await handle(json("/files/uploads", { filename: "a", size: 10 }, { origin: "http://example.com" }))).status).toBe(201);
|
|
588
|
+
const id = String((await handle(json("/files/uploads", { filename: "a", size: 10 }))).body.id);
|
|
589
|
+
expect((await handle(part(`/files/uploads/${id}/parts/1`, content(10), foreign))).status).toBe(403);
|
|
590
|
+
expect((await handle(new Request(`http://localhost/files/uploads/${id}`, { method: "DELETE", headers: foreign }))).status).toBe(403);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it("refuses a part without a valid Content-Length", async () => {
|
|
594
|
+
const { handle } = await app();
|
|
595
|
+
const id = String((await handle(json("/files/uploads", { filename: "a", size: 10 }))).body.id);
|
|
596
|
+
expect((await handle(part(`/files/uploads/${id}/parts/1`, content(10), { "content-length": "9" }))).body)
|
|
597
|
+
.toEqual({ code: "UPLOAD_PART_SIZE_MISMATCH" });
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
describe("upload session migration", () => {
|
|
602
|
+
function runner(type: "postgres" | "better-sqlite3", queries: string[]): QueryRunner {
|
|
603
|
+
return {
|
|
604
|
+
connection: { options: { type } },
|
|
605
|
+
query: (sql: string): Promise<unknown[]> => {
|
|
606
|
+
queries.push(sql);
|
|
607
|
+
return Promise.resolve([]);
|
|
608
|
+
},
|
|
609
|
+
} as unknown as QueryRunner;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
it("uses uuid and timestamptz columns on PostgreSQL", async () => {
|
|
613
|
+
const queries: string[] = [];
|
|
614
|
+
await new InitFileUploadSessions1792100000000().up(runner("postgres", queries));
|
|
615
|
+
expect(queries[0]).toContain('"id" uuid NOT NULL');
|
|
616
|
+
expect(queries[0]).toContain('"expiresAt" TIMESTAMP WITH TIME ZONE NOT NULL');
|
|
617
|
+
expect(queries[2]).toContain("ON DELETE CASCADE");
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it("migrates up and down on SQLite", async () => {
|
|
621
|
+
const sqlite = new SqliteDatabase(":memory:");
|
|
622
|
+
const migrations = [{ timestamp: 1792100000000, migration: new InitFileUploadSessions1792100000000() }];
|
|
623
|
+
const tables = (): string[] => sqlite
|
|
624
|
+
.query<{ name: string }, []>(`SELECT "name" FROM "sqlite_master" WHERE "type" = 'table' AND "name" LIKE 'file_upload%' ORDER BY "name"`)
|
|
625
|
+
.all()
|
|
626
|
+
.map((row) => row.name);
|
|
627
|
+
try {
|
|
628
|
+
await runSqliteMigrations(sqlite, migrations);
|
|
629
|
+
expect(tables()).toEqual(["file_upload_parts", "file_upload_sessions"]);
|
|
630
|
+
expect(await revertLatestSqliteMigration(sqlite, migrations)).toBe(true);
|
|
631
|
+
expect(tables()).toEqual([]);
|
|
632
|
+
} finally {
|
|
633
|
+
sqlite.close();
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
});
|