@powerhousedao/switchboard 6.0.0-dev.22 → 6.0.0-dev.221
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/Auth.md +45 -27
- package/CHANGELOG.md +1721 -5
- package/README.md +13 -12
- package/dist/index.d.mts +1 -0
- package/dist/index.mjs +134 -0
- package/dist/index.mjs.map +1 -0
- package/dist/install-packages.d.mts +1 -0
- package/dist/install-packages.mjs +31 -0
- package/dist/install-packages.mjs.map +1 -0
- package/dist/migrate.d.mts +1 -0
- package/dist/migrate.mjs +55 -0
- package/dist/migrate.mjs.map +1 -0
- package/dist/server-UGYERfMo.mjs +762 -0
- package/dist/server-UGYERfMo.mjs.map +1 -0
- package/dist/server.d.mts +113 -0
- package/dist/server.d.mts.map +1 -0
- package/dist/server.mjs +4 -0
- package/dist/utils-DFl0ezBT.mjs +44 -0
- package/dist/utils-DFl0ezBT.mjs.map +1 -0
- package/dist/utils.d.mts +9 -0
- package/dist/utils.d.mts.map +1 -0
- package/dist/utils.mjs +2 -0
- package/package.json +57 -39
- package/test/attachments/auth.test.ts +219 -0
- package/test/attachments/index.test.ts +119 -0
- package/test/attachments/routes-integration.test.ts +103 -0
- package/test/attachments/routes.test.ts +864 -0
- package/test/metrics.test.ts +202 -0
- package/test/pglite-dialect.test.ts +40 -0
- package/test/pglite-version.test.ts +37 -0
- package/tsconfig.json +12 -3
- package/tsdown.config.ts +16 -0
- package/vitest.config.ts +11 -0
- package/Dockerfile +0 -86
- package/dist/src/clients/redis.d.ts +0 -5
- package/dist/src/clients/redis.d.ts.map +0 -1
- package/dist/src/clients/redis.js +0 -48
- package/dist/src/clients/redis.js.map +0 -1
- package/dist/src/config.d.ts +0 -12
- package/dist/src/config.d.ts.map +0 -1
- package/dist/src/config.js +0 -33
- package/dist/src/config.js.map +0 -1
- package/dist/src/connect-crypto.d.ts +0 -41
- package/dist/src/connect-crypto.d.ts.map +0 -1
- package/dist/src/connect-crypto.js +0 -127
- package/dist/src/connect-crypto.js.map +0 -1
- package/dist/src/feature-flags.d.ts +0 -2
- package/dist/src/feature-flags.d.ts.map +0 -1
- package/dist/src/feature-flags.js +0 -9
- package/dist/src/feature-flags.js.map +0 -1
- package/dist/src/index.d.ts +0 -3
- package/dist/src/index.d.ts.map +0 -1
- package/dist/src/index.js +0 -21
- package/dist/src/index.js.map +0 -1
- package/dist/src/install-packages.d.ts +0 -2
- package/dist/src/install-packages.d.ts.map +0 -1
- package/dist/src/install-packages.js +0 -36
- package/dist/src/install-packages.js.map +0 -1
- package/dist/src/migrate.d.ts +0 -3
- package/dist/src/migrate.d.ts.map +0 -1
- package/dist/src/migrate.js +0 -65
- package/dist/src/migrate.js.map +0 -1
- package/dist/src/profiler.d.ts +0 -4
- package/dist/src/profiler.d.ts.map +0 -1
- package/dist/src/profiler.js +0 -17
- package/dist/src/profiler.js.map +0 -1
- package/dist/src/server.d.ts +0 -6
- package/dist/src/server.d.ts.map +0 -1
- package/dist/src/server.js +0 -304
- package/dist/src/server.js.map +0 -1
- package/dist/src/types.d.ts +0 -64
- package/dist/src/types.d.ts.map +0 -1
- package/dist/src/types.js +0 -2
- package/dist/src/types.js.map +0 -1
- package/dist/src/utils.d.ts +0 -6
- package/dist/src/utils.d.ts.map +0 -1
- package/dist/src/utils.js +0 -92
- package/dist/src/utils.js.map +0 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/entrypoint.sh +0 -17
|
@@ -0,0 +1,864 @@
|
|
|
1
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
2
|
+
import {
|
|
3
|
+
AttachmentBuilder,
|
|
4
|
+
type AttachmentBuildResult,
|
|
5
|
+
} from "@powerhousedao/reactor-attachments";
|
|
6
|
+
import { Kysely } from "kysely";
|
|
7
|
+
import { PGliteDialect } from "kysely-pglite-dialect";
|
|
8
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
9
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { Readable, Writable } from "node:stream";
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
14
|
+
import {
|
|
15
|
+
buildContentDisposition,
|
|
16
|
+
makeDeleteReservationHandler,
|
|
17
|
+
makeDownloadHandler,
|
|
18
|
+
makeGetReservationHandler,
|
|
19
|
+
makeReserveHandler,
|
|
20
|
+
makeStatHandler,
|
|
21
|
+
makeUploadHandler,
|
|
22
|
+
parseReserveOptions,
|
|
23
|
+
quoteFilename,
|
|
24
|
+
} from "../../src/attachments/routes.js";
|
|
25
|
+
|
|
26
|
+
type CapturedRes = ServerResponse & {
|
|
27
|
+
_headers: Record<string, string>;
|
|
28
|
+
_body: Buffer;
|
|
29
|
+
_done: Promise<void>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function makeReq(opts: {
|
|
33
|
+
method: string;
|
|
34
|
+
url?: string;
|
|
35
|
+
body?: Buffer | string;
|
|
36
|
+
params?: Record<string, string>;
|
|
37
|
+
}): IncomingMessage {
|
|
38
|
+
const buf =
|
|
39
|
+
typeof opts.body === "string"
|
|
40
|
+
? Buffer.from(opts.body, "utf8")
|
|
41
|
+
: (opts.body ?? Buffer.alloc(0));
|
|
42
|
+
const req = Readable.from(buf.length === 0 ? [] : [buf]) as Readable & {
|
|
43
|
+
method: string;
|
|
44
|
+
url?: string;
|
|
45
|
+
headers: Record<string, string>;
|
|
46
|
+
params?: Record<string, string>;
|
|
47
|
+
};
|
|
48
|
+
req.method = opts.method;
|
|
49
|
+
req.url = opts.url ?? "/";
|
|
50
|
+
req.headers = {};
|
|
51
|
+
if (opts.params) req.params = opts.params;
|
|
52
|
+
return req as unknown as IncomingMessage;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makeRes(): CapturedRes {
|
|
56
|
+
const chunks: Buffer[] = [];
|
|
57
|
+
const headers: Record<string, string> = {};
|
|
58
|
+
const writable = new Writable({
|
|
59
|
+
write(chunk: string | Buffer, _encoding, callback) {
|
|
60
|
+
chunks.push(
|
|
61
|
+
typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk,
|
|
62
|
+
);
|
|
63
|
+
callback();
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
const done = new Promise<void>((resolve) => {
|
|
67
|
+
writable.once("finish", resolve);
|
|
68
|
+
});
|
|
69
|
+
Object.assign(writable, {
|
|
70
|
+
statusCode: 200,
|
|
71
|
+
_headers: headers,
|
|
72
|
+
setHeader(name: string, value: string | number | readonly string[]) {
|
|
73
|
+
headers[name.toLowerCase()] = String(value);
|
|
74
|
+
},
|
|
75
|
+
getHeader(name: string) {
|
|
76
|
+
return headers[name.toLowerCase()];
|
|
77
|
+
},
|
|
78
|
+
_done: done,
|
|
79
|
+
});
|
|
80
|
+
Object.defineProperty(writable, "_body", {
|
|
81
|
+
get(): Buffer {
|
|
82
|
+
return Buffer.concat(chunks);
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
return writable as unknown as CapturedRes;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function waitFor(res: CapturedRes): Promise<void> {
|
|
89
|
+
await res._done;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe("attachment routes", () => {
|
|
93
|
+
let attachments: AttachmentBuildResult;
|
|
94
|
+
let storagePath: string;
|
|
95
|
+
let kysely: Kysely<unknown>;
|
|
96
|
+
let cleanup: () => Promise<void>;
|
|
97
|
+
|
|
98
|
+
beforeEach(async () => {
|
|
99
|
+
const pglite = new PGlite();
|
|
100
|
+
kysely = new Kysely<unknown>({ dialect: new PGliteDialect(pglite) });
|
|
101
|
+
storagePath = await mkdtemp(join(tmpdir(), "switchboard-attach-"));
|
|
102
|
+
attachments = await new AttachmentBuilder(kysely, storagePath).build();
|
|
103
|
+
cleanup = async () => {
|
|
104
|
+
await kysely.destroy();
|
|
105
|
+
await rm(storagePath, { recursive: true, force: true });
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
afterEach(async () => {
|
|
110
|
+
await cleanup();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("POST reserve returns 201 with reservationId for valid body", async () => {
|
|
114
|
+
const handler = makeReserveHandler(attachments);
|
|
115
|
+
const req = makeReq({
|
|
116
|
+
method: "POST",
|
|
117
|
+
body: JSON.stringify({ mimeType: "text/plain", fileName: "hello.txt" }),
|
|
118
|
+
});
|
|
119
|
+
const res = makeRes();
|
|
120
|
+
await handler(req, res);
|
|
121
|
+
await waitFor(res);
|
|
122
|
+
expect(res.statusCode).toBe(201);
|
|
123
|
+
const body = JSON.parse(res._body.toString("utf8")) as {
|
|
124
|
+
reservationId: string;
|
|
125
|
+
};
|
|
126
|
+
expect(body.reservationId).toMatch(/.+/);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("POST reserve returns 400 for missing fields", async () => {
|
|
130
|
+
const handler = makeReserveHandler(attachments);
|
|
131
|
+
const req = makeReq({
|
|
132
|
+
method: "POST",
|
|
133
|
+
body: JSON.stringify({ mimeType: "text/plain" }),
|
|
134
|
+
});
|
|
135
|
+
const res = makeRes();
|
|
136
|
+
await handler(req, res);
|
|
137
|
+
await waitFor(res);
|
|
138
|
+
expect(res.statusCode).toBe(400);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("PUT upload returns 404 for unknown reservation", async () => {
|
|
142
|
+
const handler = makeUploadHandler(attachments);
|
|
143
|
+
const req = makeReq({
|
|
144
|
+
method: "PUT",
|
|
145
|
+
params: { reservationId: "00000000-0000-0000-0000-000000000000" },
|
|
146
|
+
body: "hello",
|
|
147
|
+
});
|
|
148
|
+
const res = makeRes();
|
|
149
|
+
await handler(req, res);
|
|
150
|
+
await waitFor(res);
|
|
151
|
+
expect(res.statusCode).toBe(404);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("full reserve -> upload -> download cycle round-trips bytes", async () => {
|
|
155
|
+
// Reserve
|
|
156
|
+
const reserveHandler = makeReserveHandler(attachments);
|
|
157
|
+
const reserveReq = makeReq({
|
|
158
|
+
method: "POST",
|
|
159
|
+
body: JSON.stringify({
|
|
160
|
+
mimeType: "text/plain",
|
|
161
|
+
fileName: "hello.txt",
|
|
162
|
+
extension: "txt",
|
|
163
|
+
}),
|
|
164
|
+
});
|
|
165
|
+
const reserveRes = makeRes();
|
|
166
|
+
await reserveHandler(reserveReq, reserveRes);
|
|
167
|
+
await waitFor(reserveRes);
|
|
168
|
+
expect(reserveRes.statusCode).toBe(201);
|
|
169
|
+
const { reservationId } = JSON.parse(reserveRes._body.toString("utf8")) as {
|
|
170
|
+
reservationId: string;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// Upload
|
|
174
|
+
const uploadHandler = makeUploadHandler(attachments);
|
|
175
|
+
const payload = "hello world";
|
|
176
|
+
const uploadReq = makeReq({
|
|
177
|
+
method: "PUT",
|
|
178
|
+
params: { reservationId },
|
|
179
|
+
body: payload,
|
|
180
|
+
});
|
|
181
|
+
const uploadRes = makeRes();
|
|
182
|
+
await uploadHandler(uploadReq, uploadRes);
|
|
183
|
+
await waitFor(uploadRes);
|
|
184
|
+
expect(uploadRes.statusCode).toBe(200);
|
|
185
|
+
const upload = JSON.parse(uploadRes._body.toString("utf8")) as {
|
|
186
|
+
hash: string;
|
|
187
|
+
ref: string;
|
|
188
|
+
header: { mimeType: string; fileName: string; sizeBytes: number };
|
|
189
|
+
};
|
|
190
|
+
expect(upload.hash).toMatch(/^[a-f0-9]{64}$/);
|
|
191
|
+
expect(upload.ref).toBe(`attachment://v1:${upload.hash}`);
|
|
192
|
+
expect(upload.header.sizeBytes).toBe(payload.length);
|
|
193
|
+
|
|
194
|
+
// Reservation should be soft-deleted: get() rejects, and the row must
|
|
195
|
+
// still be present in the DB with deleted_at_utc populated. Asserting
|
|
196
|
+
// both halves directly here guards against a future regression where
|
|
197
|
+
// upload accidentally hard-deletes the row.
|
|
198
|
+
await expect(attachments.reservations.get(reservationId)).rejects.toThrow();
|
|
199
|
+
const row = await (
|
|
200
|
+
kysely as unknown as Kysely<{
|
|
201
|
+
attachment_reservation: {
|
|
202
|
+
reservation_id: string;
|
|
203
|
+
deleted_at_utc: string | null;
|
|
204
|
+
};
|
|
205
|
+
}>
|
|
206
|
+
)
|
|
207
|
+
.withSchema("attachments")
|
|
208
|
+
.selectFrom("attachment_reservation")
|
|
209
|
+
.selectAll()
|
|
210
|
+
.where("reservation_id", "=", reservationId)
|
|
211
|
+
.executeTakeFirst();
|
|
212
|
+
expect(row).toBeDefined();
|
|
213
|
+
expect(row!.deleted_at_utc).not.toBeNull();
|
|
214
|
+
|
|
215
|
+
// Download
|
|
216
|
+
const downloadHandler = makeDownloadHandler(attachments);
|
|
217
|
+
const downloadReq = makeReq({
|
|
218
|
+
method: "GET",
|
|
219
|
+
params: { hash: upload.hash },
|
|
220
|
+
});
|
|
221
|
+
const downloadRes = makeRes();
|
|
222
|
+
await downloadHandler(downloadReq, downloadRes);
|
|
223
|
+
await waitFor(downloadRes);
|
|
224
|
+
expect(downloadRes.statusCode).toBe(200);
|
|
225
|
+
expect(downloadRes._body.toString("utf8")).toBe(payload);
|
|
226
|
+
expect(downloadRes.getHeader("content-type")).toBe("text/plain");
|
|
227
|
+
expect(downloadRes.getHeader("content-length")).toBe(
|
|
228
|
+
String(payload.length),
|
|
229
|
+
);
|
|
230
|
+
expect(downloadRes.getHeader("content-disposition")).toContain("hello.txt");
|
|
231
|
+
const meta = JSON.parse(
|
|
232
|
+
downloadRes.getHeader("attachment-metadata") as string,
|
|
233
|
+
) as { fileName: string; mimeType: string; sizeBytes: number };
|
|
234
|
+
expect(meta.fileName).toBe("hello.txt");
|
|
235
|
+
expect(meta.mimeType).toBe("text/plain");
|
|
236
|
+
expect(meta.sizeBytes).toBe(payload.length);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("GET download Attachment-Metadata includes server-sourced timestamps", async () => {
|
|
240
|
+
const reserveHandler = makeReserveHandler(attachments);
|
|
241
|
+
const reserveReq = makeReq({
|
|
242
|
+
method: "POST",
|
|
243
|
+
body: JSON.stringify({ mimeType: "text/plain", fileName: "ts.txt" }),
|
|
244
|
+
});
|
|
245
|
+
const reserveRes = makeRes();
|
|
246
|
+
await reserveHandler(reserveReq, reserveRes);
|
|
247
|
+
await waitFor(reserveRes);
|
|
248
|
+
const { reservationId } = JSON.parse(reserveRes._body.toString("utf8")) as {
|
|
249
|
+
reservationId: string;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const uploadHandler = makeUploadHandler(attachments);
|
|
253
|
+
const uploadReq = makeReq({
|
|
254
|
+
method: "PUT",
|
|
255
|
+
params: { reservationId },
|
|
256
|
+
body: "tsdata",
|
|
257
|
+
});
|
|
258
|
+
const uploadRes = makeRes();
|
|
259
|
+
await uploadHandler(uploadReq, uploadRes);
|
|
260
|
+
await waitFor(uploadRes);
|
|
261
|
+
const upload = JSON.parse(uploadRes._body.toString("utf8")) as {
|
|
262
|
+
hash: string;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const downloadHandler = makeDownloadHandler(attachments);
|
|
266
|
+
const downloadReq = makeReq({
|
|
267
|
+
method: "GET",
|
|
268
|
+
params: { hash: upload.hash },
|
|
269
|
+
});
|
|
270
|
+
const downloadRes = makeRes();
|
|
271
|
+
await downloadHandler(downloadReq, downloadRes);
|
|
272
|
+
await waitFor(downloadRes);
|
|
273
|
+
|
|
274
|
+
const meta = JSON.parse(
|
|
275
|
+
downloadRes.getHeader("attachment-metadata") as string,
|
|
276
|
+
) as { createdAtUtc: string; lastAccessedAtUtc: string };
|
|
277
|
+
expect(typeof meta.createdAtUtc).toBe("string");
|
|
278
|
+
expect(typeof meta.lastAccessedAtUtc).toBe("string");
|
|
279
|
+
expect(new Date(meta.createdAtUtc).toString()).not.toBe("Invalid Date");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("HEAD stat returns 200 with Attachment-Metadata and zero-byte body", async () => {
|
|
283
|
+
const reserveHandler = makeReserveHandler(attachments);
|
|
284
|
+
const reserveReq = makeReq({
|
|
285
|
+
method: "POST",
|
|
286
|
+
body: JSON.stringify({ mimeType: "text/plain", fileName: "head.txt" }),
|
|
287
|
+
});
|
|
288
|
+
const reserveRes = makeRes();
|
|
289
|
+
await reserveHandler(reserveReq, reserveRes);
|
|
290
|
+
await waitFor(reserveRes);
|
|
291
|
+
const { reservationId } = JSON.parse(reserveRes._body.toString("utf8")) as {
|
|
292
|
+
reservationId: string;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const uploadHandler = makeUploadHandler(attachments);
|
|
296
|
+
const uploadReq = makeReq({
|
|
297
|
+
method: "PUT",
|
|
298
|
+
params: { reservationId },
|
|
299
|
+
body: "headdata",
|
|
300
|
+
});
|
|
301
|
+
const uploadRes = makeRes();
|
|
302
|
+
await uploadHandler(uploadReq, uploadRes);
|
|
303
|
+
await waitFor(uploadRes);
|
|
304
|
+
const upload = JSON.parse(uploadRes._body.toString("utf8")) as {
|
|
305
|
+
hash: string;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const statHandler = makeStatHandler(attachments);
|
|
309
|
+
const statReq = makeReq({
|
|
310
|
+
method: "HEAD",
|
|
311
|
+
params: { hash: upload.hash },
|
|
312
|
+
});
|
|
313
|
+
const statRes = makeRes();
|
|
314
|
+
await statHandler(statReq, statRes);
|
|
315
|
+
await waitFor(statRes);
|
|
316
|
+
|
|
317
|
+
expect(statRes.statusCode).toBe(200);
|
|
318
|
+
// Mock-level invariant: the handler must not write a body. Wire-level
|
|
319
|
+
// body suppression for HEAD is enforced by Node's http module and is
|
|
320
|
+
// covered by the HEAD-over-real-server test below.
|
|
321
|
+
expect(statRes._body.length).toBe(0);
|
|
322
|
+
expect(statRes.getHeader("content-length")).toBe(String("headdata".length));
|
|
323
|
+
const meta = JSON.parse(
|
|
324
|
+
statRes.getHeader("attachment-metadata") as string,
|
|
325
|
+
) as {
|
|
326
|
+
mimeType: string;
|
|
327
|
+
fileName: string;
|
|
328
|
+
sizeBytes: number;
|
|
329
|
+
extension: string | null;
|
|
330
|
+
createdAtUtc: string;
|
|
331
|
+
lastAccessedAtUtc: string;
|
|
332
|
+
};
|
|
333
|
+
expect(meta.mimeType).toBe("text/plain");
|
|
334
|
+
expect(meta.fileName).toBe("head.txt");
|
|
335
|
+
expect(meta.sizeBytes).toBe("headdata".length);
|
|
336
|
+
expect(typeof meta.createdAtUtc).toBe("string");
|
|
337
|
+
expect(typeof meta.lastAccessedAtUtc).toBe("string");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("HEAD over a real http.Server returns headers with a 0-byte wire body", async () => {
|
|
341
|
+
const reserveHandler = makeReserveHandler(attachments);
|
|
342
|
+
const reserveReq = makeReq({
|
|
343
|
+
method: "POST",
|
|
344
|
+
body: JSON.stringify({ mimeType: "text/plain", fileName: "wire.txt" }),
|
|
345
|
+
});
|
|
346
|
+
const reserveRes = makeRes();
|
|
347
|
+
await reserveHandler(reserveReq, reserveRes);
|
|
348
|
+
await waitFor(reserveRes);
|
|
349
|
+
const { reservationId } = JSON.parse(reserveRes._body.toString("utf8")) as {
|
|
350
|
+
reservationId: string;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const uploadHandler = makeUploadHandler(attachments);
|
|
354
|
+
const uploadReq = makeReq({
|
|
355
|
+
method: "PUT",
|
|
356
|
+
params: { reservationId },
|
|
357
|
+
body: "wire-body-payload",
|
|
358
|
+
});
|
|
359
|
+
const uploadRes = makeRes();
|
|
360
|
+
await uploadHandler(uploadReq, uploadRes);
|
|
361
|
+
await waitFor(uploadRes);
|
|
362
|
+
const upload = JSON.parse(uploadRes._body.toString("utf8")) as {
|
|
363
|
+
hash: string;
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const { createServer } = await import("node:http");
|
|
367
|
+
const statHandler = makeStatHandler(attachments);
|
|
368
|
+
const server = createServer((req, res) => {
|
|
369
|
+
const m = /^\/attachments\/([^/]+)$/.exec(req.url ?? "/");
|
|
370
|
+
if (!m) {
|
|
371
|
+
res.statusCode = 404;
|
|
372
|
+
res.end();
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
(req as IncomingMessage & { params?: Record<string, string> }).params = {
|
|
376
|
+
hash: m[1],
|
|
377
|
+
};
|
|
378
|
+
void statHandler(req, res);
|
|
379
|
+
});
|
|
380
|
+
await new Promise<void>((resolve) => server.listen(0, resolve));
|
|
381
|
+
const port = (server.address() as { port: number }).port;
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
const response = await fetch(
|
|
385
|
+
`http://127.0.0.1:${port}/attachments/${upload.hash}`,
|
|
386
|
+
{
|
|
387
|
+
method: "HEAD",
|
|
388
|
+
},
|
|
389
|
+
);
|
|
390
|
+
expect(response.status).toBe(200);
|
|
391
|
+
expect(response.headers.get("content-length")).toBe(
|
|
392
|
+
String("wire-body-payload".length),
|
|
393
|
+
);
|
|
394
|
+
// Node's http module suppresses the body for HEAD requests at the
|
|
395
|
+
// protocol level — assert that no bytes arrived on the wire.
|
|
396
|
+
const buf = await response.arrayBuffer();
|
|
397
|
+
expect(buf.byteLength).toBe(0);
|
|
398
|
+
} finally {
|
|
399
|
+
await new Promise<void>((resolve, reject) =>
|
|
400
|
+
server.close((err) => (err ? reject(err) : resolve())),
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("HEAD stat returns 404 for unknown hash", async () => {
|
|
406
|
+
const handler = makeStatHandler(attachments);
|
|
407
|
+
const req = makeReq({ method: "HEAD", params: { hash: "a".repeat(64) } });
|
|
408
|
+
const res = makeRes();
|
|
409
|
+
await handler(req, res);
|
|
410
|
+
await waitFor(res);
|
|
411
|
+
expect(res.statusCode).toBe(404);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("HEAD stat returns 400 for malformed hash", async () => {
|
|
415
|
+
const handler = makeStatHandler(attachments);
|
|
416
|
+
const req = makeReq({ method: "HEAD", params: { hash: "not-a-hash" } });
|
|
417
|
+
const res = makeRes();
|
|
418
|
+
await handler(req, res);
|
|
419
|
+
await waitFor(res);
|
|
420
|
+
expect(res.statusCode).toBe(400);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("GET reservation returns 200 with reservation JSON for active reservation", async () => {
|
|
424
|
+
const reserveHandler = makeReserveHandler(attachments);
|
|
425
|
+
const reserveReq = makeReq({
|
|
426
|
+
method: "POST",
|
|
427
|
+
body: JSON.stringify({ mimeType: "text/plain", fileName: "r.txt" }),
|
|
428
|
+
});
|
|
429
|
+
const reserveRes = makeRes();
|
|
430
|
+
await reserveHandler(reserveReq, reserveRes);
|
|
431
|
+
await waitFor(reserveRes);
|
|
432
|
+
const { reservationId } = JSON.parse(reserveRes._body.toString("utf8")) as {
|
|
433
|
+
reservationId: string;
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
const handler = makeGetReservationHandler(attachments);
|
|
437
|
+
const req = makeReq({ method: "GET", params: { reservationId } });
|
|
438
|
+
const res = makeRes();
|
|
439
|
+
await handler(req, res);
|
|
440
|
+
await waitFor(res);
|
|
441
|
+
|
|
442
|
+
expect(res.statusCode).toBe(200);
|
|
443
|
+
const json = JSON.parse(res._body.toString("utf8")) as {
|
|
444
|
+
reservationId: string;
|
|
445
|
+
mimeType: string;
|
|
446
|
+
fileName: string;
|
|
447
|
+
};
|
|
448
|
+
expect(json.reservationId).toBe(reservationId);
|
|
449
|
+
expect(json.mimeType).toBe("text/plain");
|
|
450
|
+
expect(json.fileName).toBe("r.txt");
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("GET reservation returns 404 for unknown id", async () => {
|
|
454
|
+
const handler = makeGetReservationHandler(attachments);
|
|
455
|
+
const req = makeReq({
|
|
456
|
+
method: "GET",
|
|
457
|
+
params: { reservationId: "00000000-0000-0000-0000-000000000000" },
|
|
458
|
+
});
|
|
459
|
+
const res = makeRes();
|
|
460
|
+
await handler(req, res);
|
|
461
|
+
await waitFor(res);
|
|
462
|
+
expect(res.statusCode).toBe(404);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("GET reservation returns 404 for soft-deleted id", async () => {
|
|
466
|
+
const reserveHandler = makeReserveHandler(attachments);
|
|
467
|
+
const reserveReq = makeReq({
|
|
468
|
+
method: "POST",
|
|
469
|
+
body: JSON.stringify({ mimeType: "text/plain", fileName: "r.txt" }),
|
|
470
|
+
});
|
|
471
|
+
const reserveRes = makeRes();
|
|
472
|
+
await reserveHandler(reserveReq, reserveRes);
|
|
473
|
+
await waitFor(reserveRes);
|
|
474
|
+
const { reservationId } = JSON.parse(reserveRes._body.toString("utf8")) as {
|
|
475
|
+
reservationId: string;
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
await attachments.reservations.delete(reservationId);
|
|
479
|
+
|
|
480
|
+
const handler = makeGetReservationHandler(attachments);
|
|
481
|
+
const req = makeReq({ method: "GET", params: { reservationId } });
|
|
482
|
+
const res = makeRes();
|
|
483
|
+
await handler(req, res);
|
|
484
|
+
await waitFor(res);
|
|
485
|
+
expect(res.statusCode).toBe(404);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("DELETE reservation returns 204 and is idempotent", async () => {
|
|
489
|
+
const reserveHandler = makeReserveHandler(attachments);
|
|
490
|
+
const reserveReq = makeReq({
|
|
491
|
+
method: "POST",
|
|
492
|
+
body: JSON.stringify({ mimeType: "text/plain", fileName: "r.txt" }),
|
|
493
|
+
});
|
|
494
|
+
const reserveRes = makeRes();
|
|
495
|
+
await reserveHandler(reserveReq, reserveRes);
|
|
496
|
+
await waitFor(reserveRes);
|
|
497
|
+
const { reservationId } = JSON.parse(reserveRes._body.toString("utf8")) as {
|
|
498
|
+
reservationId: string;
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const handler = makeDeleteReservationHandler(attachments);
|
|
502
|
+
|
|
503
|
+
const req1 = makeReq({ method: "DELETE", params: { reservationId } });
|
|
504
|
+
const res1 = makeRes();
|
|
505
|
+
await handler(req1, res1);
|
|
506
|
+
await waitFor(res1);
|
|
507
|
+
expect(res1.statusCode).toBe(204);
|
|
508
|
+
|
|
509
|
+
const req2 = makeReq({ method: "DELETE", params: { reservationId } });
|
|
510
|
+
const res2 = makeRes();
|
|
511
|
+
await handler(req2, res2);
|
|
512
|
+
await waitFor(res2);
|
|
513
|
+
expect(res2.statusCode).toBe(204);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("GET download returns 404 for unknown hash", async () => {
|
|
517
|
+
const handler = makeDownloadHandler(attachments);
|
|
518
|
+
const req = makeReq({
|
|
519
|
+
method: "GET",
|
|
520
|
+
params: { hash: "a".repeat(64) },
|
|
521
|
+
});
|
|
522
|
+
const res = makeRes();
|
|
523
|
+
await handler(req, res);
|
|
524
|
+
await waitFor(res);
|
|
525
|
+
expect(res.statusCode).toBe(404);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("GET download returns 400 for malformed hash", async () => {
|
|
529
|
+
const handler = makeDownloadHandler(attachments);
|
|
530
|
+
const req = makeReq({
|
|
531
|
+
method: "GET",
|
|
532
|
+
params: { hash: "not-a-hash" },
|
|
533
|
+
});
|
|
534
|
+
const res = makeRes();
|
|
535
|
+
await handler(req, res);
|
|
536
|
+
await waitFor(res);
|
|
537
|
+
expect(res.statusCode).toBe(400);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("GET download accepts an uppercase hash and canonicalises to lowercase", async () => {
|
|
541
|
+
// Reserve, upload, then download using the uppercased hash. The route
|
|
542
|
+
// must accept either case and look up the canonical (lowercase) entry.
|
|
543
|
+
const reserveHandler = makeReserveHandler(attachments);
|
|
544
|
+
const reserveReq = makeReq({
|
|
545
|
+
method: "POST",
|
|
546
|
+
body: JSON.stringify({ mimeType: "text/plain", fileName: "u.txt" }),
|
|
547
|
+
});
|
|
548
|
+
const reserveRes = makeRes();
|
|
549
|
+
await reserveHandler(reserveReq, reserveRes);
|
|
550
|
+
await waitFor(reserveRes);
|
|
551
|
+
const { reservationId } = JSON.parse(reserveRes._body.toString("utf8")) as {
|
|
552
|
+
reservationId: string;
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
const uploadHandler = makeUploadHandler(attachments);
|
|
556
|
+
const uploadReq = makeReq({
|
|
557
|
+
method: "PUT",
|
|
558
|
+
params: { reservationId },
|
|
559
|
+
body: "case-test",
|
|
560
|
+
});
|
|
561
|
+
const uploadRes = makeRes();
|
|
562
|
+
await uploadHandler(uploadReq, uploadRes);
|
|
563
|
+
await waitFor(uploadRes);
|
|
564
|
+
const upload = JSON.parse(uploadRes._body.toString("utf8")) as {
|
|
565
|
+
hash: string;
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
const handler = makeDownloadHandler(attachments);
|
|
569
|
+
const req = makeReq({
|
|
570
|
+
method: "GET",
|
|
571
|
+
params: { hash: upload.hash.toUpperCase() },
|
|
572
|
+
});
|
|
573
|
+
const res = makeRes();
|
|
574
|
+
await handler(req, res);
|
|
575
|
+
await waitFor(res);
|
|
576
|
+
expect(res.statusCode).toBe(200);
|
|
577
|
+
expect(res._body.toString("utf8")).toBe("case-test");
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it("HEAD stat accepts an uppercase hash", async () => {
|
|
581
|
+
const reserveHandler = makeReserveHandler(attachments);
|
|
582
|
+
const reserveReq = makeReq({
|
|
583
|
+
method: "POST",
|
|
584
|
+
body: JSON.stringify({ mimeType: "text/plain", fileName: "uh.txt" }),
|
|
585
|
+
});
|
|
586
|
+
const reserveRes = makeRes();
|
|
587
|
+
await reserveHandler(reserveReq, reserveRes);
|
|
588
|
+
await waitFor(reserveRes);
|
|
589
|
+
const { reservationId } = JSON.parse(reserveRes._body.toString("utf8")) as {
|
|
590
|
+
reservationId: string;
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
const uploadHandler = makeUploadHandler(attachments);
|
|
594
|
+
const uploadReq = makeReq({
|
|
595
|
+
method: "PUT",
|
|
596
|
+
params: { reservationId },
|
|
597
|
+
body: "head-case",
|
|
598
|
+
});
|
|
599
|
+
const uploadRes = makeRes();
|
|
600
|
+
await uploadHandler(uploadReq, uploadRes);
|
|
601
|
+
await waitFor(uploadRes);
|
|
602
|
+
const upload = JSON.parse(uploadRes._body.toString("utf8")) as {
|
|
603
|
+
hash: string;
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
const statHandler = makeStatHandler(attachments);
|
|
607
|
+
const statReq = makeReq({
|
|
608
|
+
method: "HEAD",
|
|
609
|
+
params: { hash: upload.hash.toUpperCase() },
|
|
610
|
+
});
|
|
611
|
+
const statRes = makeRes();
|
|
612
|
+
await statHandler(statReq, statRes);
|
|
613
|
+
await waitFor(statRes);
|
|
614
|
+
expect(statRes.statusCode).toBe(200);
|
|
615
|
+
expect(statRes.getHeader("content-length")).toBe(
|
|
616
|
+
String("head-case".length),
|
|
617
|
+
);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it("PUT upload returns opaque 500 when reservation lookup throws an unmapped error", async () => {
|
|
621
|
+
const secret = "INTERNAL_DB_PATH=/var/secret/db.sock";
|
|
622
|
+
const originalGet = attachments.reservations.get.bind(
|
|
623
|
+
attachments.reservations,
|
|
624
|
+
);
|
|
625
|
+
attachments.reservations.get = () => {
|
|
626
|
+
throw new Error(secret);
|
|
627
|
+
};
|
|
628
|
+
try {
|
|
629
|
+
const handler = makeUploadHandler(attachments);
|
|
630
|
+
const req = makeReq({
|
|
631
|
+
method: "PUT",
|
|
632
|
+
params: { reservationId: "00000000-0000-0000-0000-000000000000" },
|
|
633
|
+
body: "hello",
|
|
634
|
+
});
|
|
635
|
+
const res = makeRes();
|
|
636
|
+
await handler(req, res);
|
|
637
|
+
await waitFor(res);
|
|
638
|
+
expect(res.statusCode).toBe(500);
|
|
639
|
+
const bodyText = res._body.toString("utf8");
|
|
640
|
+
expect(JSON.parse(bodyText)).toEqual({ error: "Internal error" });
|
|
641
|
+
expect(bodyText).not.toContain(secret);
|
|
642
|
+
} finally {
|
|
643
|
+
attachments.reservations.get = originalGet;
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it("GET download returns opaque 500 when store throws an unmapped error", async () => {
|
|
648
|
+
const secret = "INTERNAL_FS_PATH=/var/secret/blobs";
|
|
649
|
+
const originalGet = attachments.store.get.bind(attachments.store);
|
|
650
|
+
attachments.store.get = () => {
|
|
651
|
+
throw new Error(secret);
|
|
652
|
+
};
|
|
653
|
+
try {
|
|
654
|
+
const handler = makeDownloadHandler(attachments);
|
|
655
|
+
const req = makeReq({
|
|
656
|
+
method: "GET",
|
|
657
|
+
params: { hash: "a".repeat(64) },
|
|
658
|
+
});
|
|
659
|
+
const res = makeRes();
|
|
660
|
+
await handler(req, res);
|
|
661
|
+
await waitFor(res);
|
|
662
|
+
expect(res.statusCode).toBe(500);
|
|
663
|
+
const bodyText = res._body.toString("utf8");
|
|
664
|
+
expect(JSON.parse(bodyText)).toEqual({ error: "Internal error" });
|
|
665
|
+
expect(bodyText).not.toContain(secret);
|
|
666
|
+
} finally {
|
|
667
|
+
attachments.store.get = originalGet;
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("identical uploads dedupe to the same hash", async () => {
|
|
672
|
+
const reserveHandler = makeReserveHandler(attachments);
|
|
673
|
+
const uploadHandler = makeUploadHandler(attachments);
|
|
674
|
+
|
|
675
|
+
const doRoundTrip = async (): Promise<string> => {
|
|
676
|
+
const r1 = makeReq({
|
|
677
|
+
method: "POST",
|
|
678
|
+
body: JSON.stringify({ mimeType: "text/plain", fileName: "x.txt" }),
|
|
679
|
+
});
|
|
680
|
+
const r1res = makeRes();
|
|
681
|
+
await reserveHandler(r1, r1res);
|
|
682
|
+
await waitFor(r1res);
|
|
683
|
+
const { reservationId } = JSON.parse(r1res._body.toString()) as {
|
|
684
|
+
reservationId: string;
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
const u1 = makeReq({
|
|
688
|
+
method: "PUT",
|
|
689
|
+
params: { reservationId },
|
|
690
|
+
body: "same content",
|
|
691
|
+
});
|
|
692
|
+
const u1res = makeRes();
|
|
693
|
+
await uploadHandler(u1, u1res);
|
|
694
|
+
await waitFor(u1res);
|
|
695
|
+
const { hash } = JSON.parse(u1res._body.toString()) as { hash: string };
|
|
696
|
+
return hash;
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
const h1 = await doRoundTrip();
|
|
700
|
+
const h2 = await doRoundTrip();
|
|
701
|
+
expect(h1).toBe(h2);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
describe("validation and header encoding", () => {
|
|
705
|
+
it("parseReserveOptions rejects fileName with CR/LF", () => {
|
|
706
|
+
expect(
|
|
707
|
+
parseReserveOptions({
|
|
708
|
+
mimeType: "text/plain",
|
|
709
|
+
fileName: "evil\r\nX-Inj: foo",
|
|
710
|
+
}),
|
|
711
|
+
).toBeNull();
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it("parseReserveOptions rejects fileName with NUL", () => {
|
|
715
|
+
expect(
|
|
716
|
+
parseReserveOptions({
|
|
717
|
+
mimeType: "text/plain",
|
|
718
|
+
fileName: "a\x00b",
|
|
719
|
+
}),
|
|
720
|
+
).toBeNull();
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
it("parseReserveOptions rejects oversized fileName", () => {
|
|
724
|
+
expect(
|
|
725
|
+
parseReserveOptions({
|
|
726
|
+
mimeType: "text/plain",
|
|
727
|
+
fileName: "x".repeat(256),
|
|
728
|
+
}),
|
|
729
|
+
).toBeNull();
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
it("parseReserveOptions rejects empty fileName", () => {
|
|
733
|
+
expect(
|
|
734
|
+
parseReserveOptions({
|
|
735
|
+
mimeType: "text/plain",
|
|
736
|
+
fileName: "",
|
|
737
|
+
}),
|
|
738
|
+
).toBeNull();
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it("parseReserveOptions rejects malformed mimeType", () => {
|
|
742
|
+
for (const mimeType of ["", "plain", "text/plain\r\nX: y", "text/"]) {
|
|
743
|
+
expect(
|
|
744
|
+
parseReserveOptions({ mimeType, fileName: "ok.txt" }),
|
|
745
|
+
).toBeNull();
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it("parseReserveOptions accepts non-ASCII fileName", () => {
|
|
750
|
+
const opts = parseReserveOptions({
|
|
751
|
+
mimeType: "application/pdf",
|
|
752
|
+
fileName: "résumé.pdf",
|
|
753
|
+
});
|
|
754
|
+
expect(opts).toEqual({
|
|
755
|
+
mimeType: "application/pdf",
|
|
756
|
+
fileName: "résumé.pdf",
|
|
757
|
+
extension: null,
|
|
758
|
+
});
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it("parseReserveOptions accepts mimeType with parameters", () => {
|
|
762
|
+
const opts = parseReserveOptions({
|
|
763
|
+
mimeType: "text/plain; charset=utf-8",
|
|
764
|
+
fileName: "ok.txt",
|
|
765
|
+
});
|
|
766
|
+
expect(opts?.mimeType).toBe("text/plain; charset=utf-8");
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it("quoteFilename escapes backslash and double-quote", () => {
|
|
770
|
+
expect(quoteFilename(`a"b\\c`)).toBe(`"a\\"b\\\\c"`);
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
it("buildContentDisposition emits ASCII fallback and RFC 5987 form for non-ASCII", () => {
|
|
774
|
+
const value = buildContentDisposition("résumé.pdf");
|
|
775
|
+
expect(value).toMatch(
|
|
776
|
+
/^attachment; filename="[^"]*\.pdf"; filename\*=UTF-8''/,
|
|
777
|
+
);
|
|
778
|
+
expect(value).toContain("filename*=UTF-8''r%C3%A9sum%C3%A9.pdf");
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
it("buildContentDisposition produces a header Node accepts even for CR/LF/NUL input", () => {
|
|
782
|
+
const res = makeRes();
|
|
783
|
+
for (const fileName of [
|
|
784
|
+
"evil\r\nX-Inj: foo",
|
|
785
|
+
"a\x00b.txt",
|
|
786
|
+
"name\twith\ttabs",
|
|
787
|
+
]) {
|
|
788
|
+
expect(() =>
|
|
789
|
+
res.setHeader(
|
|
790
|
+
"Content-Disposition",
|
|
791
|
+
buildContentDisposition(fileName),
|
|
792
|
+
),
|
|
793
|
+
).not.toThrow();
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it("buildContentDisposition encodes RFC-5987-reserved chars in the encoded form", () => {
|
|
798
|
+
const value = buildContentDisposition("a'b(c)*!.txt");
|
|
799
|
+
expect(value).toContain("filename*=UTF-8''a%27b%28c%29%2A%21.txt");
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
it("POST reserve with CRLF in fileName returns 400 and persists no row", async () => {
|
|
803
|
+
const handler = makeReserveHandler(attachments);
|
|
804
|
+
const req = makeReq({
|
|
805
|
+
method: "POST",
|
|
806
|
+
body: JSON.stringify({
|
|
807
|
+
mimeType: "text/plain",
|
|
808
|
+
fileName: "evil\r\nX-Inj: foo",
|
|
809
|
+
}),
|
|
810
|
+
});
|
|
811
|
+
const res = makeRes();
|
|
812
|
+
await handler(req, res);
|
|
813
|
+
await waitFor(res);
|
|
814
|
+
expect(res.statusCode).toBe(400);
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
it("download with non-ASCII fileName produces RFC 6266 Content-Disposition", async () => {
|
|
818
|
+
const reserveHandler = makeReserveHandler(attachments);
|
|
819
|
+
const uploadHandler = makeUploadHandler(attachments);
|
|
820
|
+
const downloadHandler = makeDownloadHandler(attachments);
|
|
821
|
+
|
|
822
|
+
const reserveReq = makeReq({
|
|
823
|
+
method: "POST",
|
|
824
|
+
body: JSON.stringify({
|
|
825
|
+
mimeType: "application/pdf",
|
|
826
|
+
fileName: "résumé.pdf",
|
|
827
|
+
}),
|
|
828
|
+
});
|
|
829
|
+
const reserveRes = makeRes();
|
|
830
|
+
await reserveHandler(reserveReq, reserveRes);
|
|
831
|
+
await waitFor(reserveRes);
|
|
832
|
+
expect(reserveRes.statusCode).toBe(201);
|
|
833
|
+
const { reservationId } = JSON.parse(
|
|
834
|
+
reserveRes._body.toString("utf8"),
|
|
835
|
+
) as { reservationId: string };
|
|
836
|
+
|
|
837
|
+
const uploadReq = makeReq({
|
|
838
|
+
method: "PUT",
|
|
839
|
+
params: { reservationId },
|
|
840
|
+
body: "pdf-bytes",
|
|
841
|
+
});
|
|
842
|
+
const uploadRes = makeRes();
|
|
843
|
+
await uploadHandler(uploadReq, uploadRes);
|
|
844
|
+
await waitFor(uploadRes);
|
|
845
|
+
expect(uploadRes.statusCode).toBe(200);
|
|
846
|
+
const { hash } = JSON.parse(uploadRes._body.toString("utf8")) as {
|
|
847
|
+
hash: string;
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
const downloadReq = makeReq({ method: "GET", params: { hash } });
|
|
851
|
+
const downloadRes = makeRes();
|
|
852
|
+
await downloadHandler(downloadReq, downloadRes);
|
|
853
|
+
await waitFor(downloadRes);
|
|
854
|
+
expect(downloadRes.statusCode).toBe(200);
|
|
855
|
+
const cd = downloadRes.getHeader("content-disposition") as string;
|
|
856
|
+
expect(cd).toContain("filename*=UTF-8''r%C3%A9sum%C3%A9.pdf");
|
|
857
|
+
expect(cd).toMatch(/filename="[^"]*\.pdf"/);
|
|
858
|
+
const meta = JSON.parse(
|
|
859
|
+
downloadRes.getHeader("attachment-metadata") as string,
|
|
860
|
+
) as { fileName: string };
|
|
861
|
+
expect(meta.fileName).toBe("résumé.pdf");
|
|
862
|
+
});
|
|
863
|
+
});
|
|
864
|
+
});
|