@remit/backend 0.0.59 → 0.0.60

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.
@@ -0,0 +1,763 @@
1
+ /**
2
+ * Issue #679, phase 1: reserving room for a file, uploading it, and confirming
3
+ * it landed.
4
+ *
5
+ * Driven through the real handlers, the real OutboxAttachmentService, a real
6
+ * storage backend and the real error funnel, so what is asserted is the status
7
+ * code and body the browser receives. The upload leg goes through the real
8
+ * receiver against a URL the storage backend actually minted — nothing here
9
+ * hand-builds a signature.
10
+ */
11
+
12
+ import assert from "node:assert/strict";
13
+ import { mkdtemp, rm } from "node:fs/promises";
14
+ import { join } from "node:path";
15
+ import { Readable } from "node:stream";
16
+ import { afterEach, describe, it } from "node:test";
17
+ import type {
18
+ CreateOutboxMessageInput,
19
+ IOutboxAttachmentRepository,
20
+ IOutboxMessageRepository,
21
+ OutboxAttachmentItem,
22
+ OutboxMessageItem,
23
+ UpdateOutboxMessageInput,
24
+ } from "@remit/data-ports";
25
+ import { holdsRoom } from "@remit/data-ports";
26
+ import { ForbiddenError, NotFoundError } from "@remit/data-ports/errors";
27
+ import {
28
+ OutboxAttachmentRejectionReason,
29
+ OutboxMessageStatus,
30
+ } from "@remit/domain-enums";
31
+ import {
32
+ OUTBOX_ATTACHMENT_MAX_TOTAL_BYTES,
33
+ OutboxAttachmentService,
34
+ } from "@remit/mailbox-service";
35
+ import {
36
+ type StorageService,
37
+ UPLOAD_ROUTE_PREFIX,
38
+ } from "@remit/storage-service";
39
+ import { createFilesystemStorageService } from "@remit/storage-service/filesystem";
40
+ import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
41
+ import type { Context } from "openapi-backend";
42
+ import { receiveUpload } from "../../dev-server/upload-handler.js";
43
+ import { deriveAccountConfigId } from "../auth.js";
44
+ import { handleError } from "../error.js";
45
+ import { formatResponse } from "../response.js";
46
+ import {
47
+ _resetForTest,
48
+ type RemitClient,
49
+ setClient,
50
+ } from "../service/data-client.js";
51
+ import {
52
+ completeOutboxAttachment,
53
+ mintOutboxAttachment,
54
+ } from "./outbox-attachment.js";
55
+
56
+ const SUB = "cognito-sub-679";
57
+ const ACCOUNT_CONFIG_ID = deriveAccountConfigId(SUB);
58
+ const OTHER_ACCOUNT_CONFIG_ID = deriveAccountConfigId("cognito-sub-stranger");
59
+ const ACCOUNT_ID = "acc-679";
60
+ const DRAFT_ID = "a1b2c3d4e5f6g7h8i9j0k1l2m";
61
+ const ORIGIN = "https://mail.example.test";
62
+ const SECRET = "a-signing-secret-of-at-least-32-characters";
63
+
64
+ const createOutboxRepository = (
65
+ rows: Map<string, OutboxMessageItem>,
66
+ ): IOutboxMessageRepository =>
67
+ ({
68
+ get: async (
69
+ accountConfigId: string,
70
+ outboxMessageId: string,
71
+ mode?: "read" | "act",
72
+ ) => {
73
+ const row = rows.get(outboxMessageId);
74
+ if (!row) throw new NotFoundError(`No outbox message ${outboxMessageId}`);
75
+ if (row.accountConfigId !== accountConfigId) {
76
+ // Mirrors the real repository: an action verb denies, a read feigns
77
+ // absence.
78
+ if (mode === "act") {
79
+ throw new ForbiddenError(
80
+ `Outbox message ${outboxMessageId} not in account config`,
81
+ );
82
+ }
83
+ throw new NotFoundError(`No outbox message ${outboxMessageId}`);
84
+ }
85
+ return row;
86
+ },
87
+ create: async (input: CreateOutboxMessageInput) =>
88
+ input as OutboxMessageItem,
89
+ update: async (
90
+ _accountConfigId: string,
91
+ outboxMessageId: string,
92
+ input: UpdateOutboxMessageInput,
93
+ ) => ({ ...rows.get(outboxMessageId), ...input }) as OutboxMessageItem,
94
+ }) as unknown as IOutboxMessageRepository;
95
+
96
+ interface Installed {
97
+ storage: StorageService;
98
+ basePath: string;
99
+ rows: Map<string, OutboxAttachmentItem>;
100
+ }
101
+
102
+ /** The row store the cap and the sweep both read. */
103
+ const createAttachmentRepository = (
104
+ rows: Map<string, OutboxAttachmentItem>,
105
+ ): IOutboxAttachmentRepository => {
106
+ const forDraft = (accountConfigId: string, outboxMessageId: string) =>
107
+ [...rows.values()].filter(
108
+ (row) =>
109
+ row.accountConfigId === accountConfigId &&
110
+ row.outboxMessageId === outboxMessageId,
111
+ );
112
+
113
+ return {
114
+ reserve: async (input, cap) => {
115
+ const live = forDraft(
116
+ input.accountConfigId,
117
+ input.outboxMessageId,
118
+ ).filter((row) => holdsRoom(row, cap.nowSeconds));
119
+ const usedBytes = live.reduce((total, row) => total + row.sizeBytes, 0);
120
+ if (live.length >= cap.maxCount) {
121
+ return { outcome: "OverCountCap", usedBytes };
122
+ }
123
+ if (usedBytes + input.sizeBytes > cap.maxTotalBytes) {
124
+ return { outcome: "OverByteCap", usedBytes };
125
+ }
126
+ const item = {
127
+ ...input,
128
+ state: "Pending",
129
+ createdAt: 0,
130
+ updatedAt: 0,
131
+ } as OutboxAttachmentItem;
132
+ rows.set(item.outboxAttachmentId, item);
133
+ return { outcome: "Reserved", item };
134
+ },
135
+ get: async (accountConfigId, outboxAttachmentId) => {
136
+ const row = rows.get(outboxAttachmentId);
137
+ if (!row || row.accountConfigId !== accountConfigId) {
138
+ throw new NotFoundError("gone");
139
+ }
140
+ return row;
141
+ },
142
+ listByOutboxMessage: async (accountConfigId, outboxMessageId) =>
143
+ forDraft(accountConfigId, outboxMessageId),
144
+ markStored: async (_accountConfigId, outboxAttachmentId, sizeBytes) => {
145
+ const row = rows.get(outboxAttachmentId);
146
+ if (!row || row.state !== "Pending") return null;
147
+ const next = {
148
+ ...row,
149
+ state: "Stored",
150
+ sizeBytes,
151
+ reservationExpiresAt: 0,
152
+ } as OutboxAttachmentItem;
153
+ rows.set(outboxAttachmentId, next);
154
+ return next;
155
+ },
156
+ deleteLapsedReservations: async (
157
+ accountConfigId: string,
158
+ outboxMessageId: string,
159
+ nowSeconds: number,
160
+ ) => {
161
+ const gone: string[] = [];
162
+ for (const row of [...rows.values()]) {
163
+ if (
164
+ row.accountConfigId === accountConfigId &&
165
+ row.outboxMessageId === outboxMessageId &&
166
+ row.state === "Pending" &&
167
+ row.reservationExpiresAt < nowSeconds
168
+ ) {
169
+ rows.delete(row.outboxAttachmentId);
170
+ gone.push(row.outboxAttachmentId);
171
+ }
172
+ }
173
+ return gone;
174
+ },
175
+ deleteMany: async (_accountConfigId, ids) => {
176
+ for (const id of ids) rows.delete(id);
177
+ },
178
+ deleteByOutboxMessage: async (accountConfigId, outboxMessageId) => {
179
+ for (const row of forDraft(accountConfigId, outboxMessageId)) {
180
+ rows.delete(row.outboxAttachmentId);
181
+ }
182
+ },
183
+ };
184
+ };
185
+
186
+ const temporaryRoots: string[] = [];
187
+
188
+ const install = async (
189
+ overrides: Partial<OutboxMessageItem> = {},
190
+ accountConfigId = ACCOUNT_CONFIG_ID,
191
+ ): Promise<Installed> => {
192
+ const basePath = await mkdtemp(join(process.cwd(), ".tmp-attachments-"));
193
+ temporaryRoots.push(basePath);
194
+
195
+ const rows = new Map<string, OutboxMessageItem>([
196
+ [
197
+ DRAFT_ID,
198
+ {
199
+ outboxMessageId: DRAFT_ID,
200
+ accountId: ACCOUNT_ID,
201
+ accountConfigId,
202
+ status: OutboxMessageStatus.draft,
203
+ ...overrides,
204
+ } as OutboxMessageItem,
205
+ ],
206
+ ]);
207
+ const outboxMessage = createOutboxRepository(rows);
208
+ const storage = createFilesystemStorageService(basePath, {
209
+ origin: ORIGIN,
210
+ signingSecret: SECRET,
211
+ });
212
+
213
+ const attachmentRows = new Map<string, OutboxAttachmentItem>();
214
+ setClient({
215
+ outboxMessage,
216
+ storage,
217
+ outboxAttachment: new OutboxAttachmentService({
218
+ outboxMessageService: outboxMessage,
219
+ outboxAttachmentService: createAttachmentRepository(attachmentRows),
220
+ storage,
221
+ }),
222
+ } as unknown as RemitClient);
223
+
224
+ return { storage, basePath, rows: attachmentRows };
225
+ };
226
+
227
+ const authorizedEvent = (): APIGatewayProxyEvent =>
228
+ ({
229
+ requestContext: { authorizer: { claims: { sub: SUB } } },
230
+ }) as unknown as APIGatewayProxyEvent;
231
+
232
+ const mintContext = (
233
+ body: Record<string, unknown>,
234
+ outboxMessageId = DRAFT_ID,
235
+ ): Context =>
236
+ ({
237
+ request: { params: { outboxMessageId }, requestBody: body },
238
+ }) as unknown as Context;
239
+
240
+ const completeContext = (
241
+ outboxAttachmentId: string,
242
+ outboxMessageId = DRAFT_ID,
243
+ ): Context =>
244
+ ({
245
+ request: { params: { outboxMessageId, outboxAttachmentId } },
246
+ }) as unknown as Context;
247
+
248
+ /** The response the browser would receive, error funnel included. */
249
+ const respond = async (
250
+ run: () => Promise<unknown>,
251
+ ): Promise<APIGatewayProxyResult> => {
252
+ const outcome = await run().then(
253
+ (body) => ({ ok: true as const, body }),
254
+ (error: unknown) => ({ ok: false as const, error }),
255
+ );
256
+ if (!outcome.ok) return handleError(outcome.error);
257
+ return formatResponse(outcome.body as Record<string, unknown>);
258
+ };
259
+
260
+ const parse = (response: APIGatewayProxyResult): Record<string, unknown> =>
261
+ JSON.parse(response.body) as Record<string, unknown>;
262
+
263
+ const mint = (
264
+ body: Record<string, unknown>,
265
+ outboxMessageId = DRAFT_ID,
266
+ ): Promise<APIGatewayProxyResult> =>
267
+ respond(() =>
268
+ mintOutboxAttachment(mintContext(body, outboxMessageId), authorizedEvent()),
269
+ );
270
+
271
+ const complete = (
272
+ outboxAttachmentId: string,
273
+ outboxMessageId = DRAFT_ID,
274
+ ): Promise<APIGatewayProxyResult> =>
275
+ respond(() =>
276
+ completeOutboxAttachment(
277
+ completeContext(outboxAttachmentId, outboxMessageId),
278
+ authorizedEvent(),
279
+ ),
280
+ );
281
+
282
+ /** PUT bytes to a minted URL through the real receiver. */
283
+ const putTo = (
284
+ storage: StorageService,
285
+ uploadUrl: string,
286
+ content: Buffer,
287
+ nowSeconds = Math.floor(Date.now() / 1000),
288
+ ) => {
289
+ const url = new URL(uploadUrl);
290
+ return receiveUpload(storage, {
291
+ storageKey: url.pathname.slice(UPLOAD_ROUTE_PREFIX.length),
292
+ exp: url.searchParams.get("exp") ?? undefined,
293
+ max: url.searchParams.get("max") ?? undefined,
294
+ sig: url.searchParams.get("sig") ?? undefined,
295
+ body: Readable.from(content),
296
+ nowSeconds,
297
+ secret: SECRET,
298
+ findLiveReservation: async () => true,
299
+ });
300
+ };
301
+
302
+ const mintedBody = (
303
+ response: APIGatewayProxyResult,
304
+ ): { outboxAttachmentId: string; uploadUrl: string } => {
305
+ assert.equal(response.statusCode, 200);
306
+ const body = parse(response);
307
+ return {
308
+ outboxAttachmentId: String(body.outboxAttachmentId),
309
+ uploadUrl: String(body.uploadUrl),
310
+ };
311
+ };
312
+
313
+ afterEach(async () => {
314
+ _resetForTest();
315
+ for (const root of temporaryRoots.splice(0)) {
316
+ await rm(root, { recursive: true, force: true });
317
+ }
318
+ });
319
+
320
+ describe("reserving room on a draft (#679)", () => {
321
+ it("answers with an upload URL scoped to one attachment on this draft", async () => {
322
+ await install();
323
+
324
+ const response = await mint({
325
+ filename: "invoice.pdf",
326
+ contentType: "application/pdf",
327
+ sizeBytes: 2048,
328
+ });
329
+
330
+ assert.equal(response.statusCode, 200);
331
+ const body = parse(response);
332
+ assert.equal(body.outboxMessageId, DRAFT_ID);
333
+ assert.equal(body.filename, "invoice.pdf");
334
+ assert.equal(body.contentType, "application/pdf");
335
+ assert.equal(body.sizeBytes, 2048);
336
+ assert.ok(Number(body.uploadExpiresAt) > Math.floor(Date.now() / 1000));
337
+
338
+ const url = new URL(String(body.uploadUrl));
339
+ assert.equal(url.origin, ORIGIN);
340
+ assert.ok(url.pathname.includes(String(body.outboxAttachmentId)));
341
+ assert.ok(url.pathname.includes(DRAFT_ID));
342
+ assert.equal(url.searchParams.get("max"), "2048");
343
+ assert.ok((url.searchParams.get("sig") ?? "").length > 0);
344
+ });
345
+
346
+ it("sanitizes the filename it will put on the message", async () => {
347
+ await install();
348
+
349
+ const response = await mint({
350
+ filename: "../../../etc/pa\u202Edwssap",
351
+ contentType: "definitely not a media type",
352
+ sizeBytes: 10,
353
+ });
354
+
355
+ assert.equal(response.statusCode, 200);
356
+ const body = parse(response);
357
+ assert.equal(body.filename, "padwssap");
358
+ assert.equal(body.contentType, "application/octet-stream");
359
+ });
360
+
361
+ it("refuses a filename that is nothing but separators", async () => {
362
+ await install();
363
+
364
+ const response = await mint({
365
+ filename: "../..",
366
+ contentType: "text/plain",
367
+ sizeBytes: 10,
368
+ });
369
+
370
+ assert.equal(response.statusCode, 400);
371
+ assert.equal(
372
+ parse(response).reason,
373
+ OutboxAttachmentRejectionReason.UnusableFilename,
374
+ );
375
+ });
376
+
377
+ it("refuses a declared size over the cap with 413, before any bytes move", async () => {
378
+ await install();
379
+
380
+ const response = await mint({
381
+ filename: "huge.bin",
382
+ contentType: "application/octet-stream",
383
+ sizeBytes: OUTBOX_ATTACHMENT_MAX_TOTAL_BYTES + 1,
384
+ });
385
+
386
+ assert.equal(response.statusCode, 413);
387
+ const body = parse(response);
388
+ assert.equal(body.reason, OutboxAttachmentRejectionReason.FileTooLarge);
389
+ assert.equal(body.limitBytes, OUTBOX_ATTACHMENT_MAX_TOTAL_BYTES);
390
+ assert.match(String(body.message), /huge\.bin/);
391
+ });
392
+
393
+ it("counts a reservation nobody has uploaded against yet", async () => {
394
+ await install();
395
+
396
+ const first = await mint({
397
+ filename: "half.bin",
398
+ contentType: "application/octet-stream",
399
+ sizeBytes: OUTBOX_ATTACHMENT_MAX_TOTAL_BYTES - 1024,
400
+ });
401
+ assert.equal(first.statusCode, 200);
402
+
403
+ const second = await mint({
404
+ filename: "one-too-many.bin",
405
+ contentType: "application/octet-stream",
406
+ sizeBytes: 2048,
407
+ });
408
+
409
+ assert.equal(second.statusCode, 413);
410
+ const body = parse(second);
411
+ assert.equal(body.reason, OutboxAttachmentRejectionReason.MessageTooLarge);
412
+ assert.equal(body.usedBytes, OUTBOX_ATTACHMENT_MAX_TOTAL_BYTES - 1024);
413
+ });
414
+
415
+ it("refuses an empty file", async () => {
416
+ await install();
417
+
418
+ const response = await mint({
419
+ filename: "empty.txt",
420
+ contentType: "text/plain",
421
+ sizeBytes: 0,
422
+ });
423
+
424
+ assert.equal(response.statusCode, 400);
425
+ assert.equal(
426
+ parse(response).reason,
427
+ OutboxAttachmentRejectionReason.EmptyFile,
428
+ );
429
+ });
430
+
431
+ it("denies a mint against someone else's draft, and reserves nothing", async () => {
432
+ const { storage } = await install({}, OTHER_ACCOUNT_CONFIG_ID);
433
+
434
+ const response = await mint({
435
+ filename: "trespass.txt",
436
+ contentType: "text/plain",
437
+ sizeBytes: 10,
438
+ });
439
+
440
+ assert.equal(response.statusCode, 403);
441
+ assert.deepEqual(
442
+ await storage.listOutboxAttachments(
443
+ OTHER_ACCOUNT_CONFIG_ID,
444
+ ACCOUNT_ID,
445
+ DRAFT_ID,
446
+ ),
447
+ [],
448
+ );
449
+ });
450
+
451
+ it("answers 404 for a draft that does not exist", async () => {
452
+ await install();
453
+
454
+ const response = await mint(
455
+ { filename: "orphan.txt", contentType: "text/plain", sizeBytes: 10 },
456
+ "z9y8x7w6v5u4t3s2r1q0p9o8n",
457
+ );
458
+
459
+ assert.equal(response.statusCode, 404);
460
+ });
461
+
462
+ it("refuses to reserve on a message that has already left draft", async () => {
463
+ await install({ status: OutboxMessageStatus.sent });
464
+
465
+ const response = await mint({
466
+ filename: "late.txt",
467
+ contentType: "text/plain",
468
+ sizeBytes: 10,
469
+ });
470
+
471
+ assert.equal(response.statusCode, 409);
472
+ assert.match(
473
+ String(parse(response).message),
474
+ /no longer take an attachment/,
475
+ );
476
+ });
477
+ });
478
+
479
+ describe("uploading to a minted URL (#679)", () => {
480
+ it("stores the bytes, and the completion answers with the size storage holds", async () => {
481
+ const { storage } = await install();
482
+ const content = Buffer.from("%PDF-1.7 a small invoice");
483
+ const { outboxAttachmentId, uploadUrl } = mintedBody(
484
+ await mint({
485
+ filename: "invoice.pdf",
486
+ contentType: "application/pdf",
487
+ sizeBytes: content.length,
488
+ }),
489
+ );
490
+
491
+ const upload = await putTo(storage, uploadUrl, content);
492
+ assert.equal(upload.status, 204);
493
+
494
+ const response = await complete(outboxAttachmentId);
495
+ assert.equal(response.statusCode, 200);
496
+ const body = parse(response);
497
+ assert.equal(body.outboxAttachmentId, outboxAttachmentId);
498
+ assert.equal(body.sizeBytes, content.length);
499
+
500
+ const stat = await storage.statOutboxAttachment(
501
+ ACCOUNT_CONFIG_ID,
502
+ ACCOUNT_ID,
503
+ DRAFT_ID,
504
+ outboxAttachmentId,
505
+ );
506
+ assert.equal(stat?.sizeBytes, content.length);
507
+ });
508
+
509
+ it("refuses a forged signature and writes nothing", async () => {
510
+ const { storage } = await install();
511
+ const { outboxAttachmentId, uploadUrl } = mintedBody(
512
+ await mint({
513
+ filename: "forged.bin",
514
+ contentType: "application/octet-stream",
515
+ sizeBytes: 4,
516
+ }),
517
+ );
518
+
519
+ const tampered = new URL(uploadUrl);
520
+ tampered.searchParams.set("sig", "not-the-signature");
521
+
522
+ const upload = await putTo(storage, tampered.toString(), Buffer.alloc(4));
523
+ assert.equal(upload.status, 403);
524
+ assert.equal(
525
+ await storage.statOutboxAttachment(
526
+ ACCOUNT_CONFIG_ID,
527
+ ACCOUNT_ID,
528
+ DRAFT_ID,
529
+ outboxAttachmentId,
530
+ ),
531
+ null,
532
+ );
533
+ });
534
+
535
+ it("refuses a URL whose size was raised after it was minted", async () => {
536
+ const { storage } = await install();
537
+ const { uploadUrl } = mintedBody(
538
+ await mint({
539
+ filename: "grown.bin",
540
+ contentType: "application/octet-stream",
541
+ sizeBytes: 4,
542
+ }),
543
+ );
544
+
545
+ // The byte count is part of the signed message, so raising it invalidates
546
+ // the URL rather than raising the allowance.
547
+ const tampered = new URL(uploadUrl);
548
+ tampered.searchParams.set("max", "40000");
549
+
550
+ const upload = await putTo(
551
+ storage,
552
+ tampered.toString(),
553
+ Buffer.alloc(4000),
554
+ );
555
+ assert.equal(upload.status, 403);
556
+ });
557
+
558
+ it("refuses a URL pointed at a different attachment", async () => {
559
+ const { storage } = await install();
560
+ const first = mintedBody(
561
+ await mint({
562
+ filename: "mine.bin",
563
+ contentType: "application/octet-stream",
564
+ sizeBytes: 4,
565
+ }),
566
+ );
567
+ const second = mintedBody(
568
+ await mint({
569
+ filename: "theirs.bin",
570
+ contentType: "application/octet-stream",
571
+ sizeBytes: 4,
572
+ }),
573
+ );
574
+
575
+ const swapped = new URL(first.uploadUrl);
576
+ swapped.pathname = new URL(second.uploadUrl).pathname;
577
+
578
+ const upload = await putTo(storage, swapped.toString(), Buffer.alloc(4));
579
+ assert.equal(upload.status, 403);
580
+ assert.equal(
581
+ await storage.statOutboxAttachment(
582
+ ACCOUNT_CONFIG_ID,
583
+ ACCOUNT_ID,
584
+ DRAFT_ID,
585
+ second.outboxAttachmentId,
586
+ ),
587
+ null,
588
+ );
589
+ });
590
+
591
+ it("refuses an expired URL", async () => {
592
+ const { storage } = await install();
593
+ const { outboxAttachmentId, uploadUrl } = mintedBody(
594
+ await mint({
595
+ filename: "stale.bin",
596
+ contentType: "application/octet-stream",
597
+ sizeBytes: 4,
598
+ }),
599
+ );
600
+
601
+ const upload = await putTo(
602
+ storage,
603
+ uploadUrl,
604
+ Buffer.alloc(4),
605
+ Math.floor(Date.now() / 1000) + 100_000,
606
+ );
607
+ assert.equal(upload.status, 403);
608
+ assert.equal(upload.reason, "expired");
609
+ assert.equal(
610
+ await storage.statOutboxAttachment(
611
+ ACCOUNT_CONFIG_ID,
612
+ ACCOUNT_ID,
613
+ DRAFT_ID,
614
+ outboxAttachmentId,
615
+ ),
616
+ null,
617
+ );
618
+ });
619
+
620
+ it("cuts off a body larger than was reserved, and stores none of it", async () => {
621
+ const { storage } = await install();
622
+ const { outboxAttachmentId, uploadUrl } = mintedBody(
623
+ await mint({
624
+ filename: "sneaky.bin",
625
+ contentType: "application/octet-stream",
626
+ sizeBytes: 1024,
627
+ }),
628
+ );
629
+
630
+ const upload = await putTo(storage, uploadUrl, Buffer.alloc(64 * 1024));
631
+ assert.equal(upload.status, 413);
632
+ assert.equal(
633
+ await storage.statOutboxAttachment(
634
+ ACCOUNT_CONFIG_ID,
635
+ ACCOUNT_ID,
636
+ DRAFT_ID,
637
+ outboxAttachmentId,
638
+ ),
639
+ null,
640
+ );
641
+ });
642
+
643
+ it("refuses a body shorter than was reserved", async () => {
644
+ const { storage } = await install();
645
+ const { uploadUrl } = mintedBody(
646
+ await mint({
647
+ filename: "short.bin",
648
+ contentType: "application/octet-stream",
649
+ sizeBytes: 1024,
650
+ }),
651
+ );
652
+
653
+ const upload = await putTo(storage, uploadUrl, Buffer.alloc(16));
654
+ assert.equal(upload.status, 400);
655
+ assert.equal(upload.reason, "size-mismatch");
656
+ });
657
+ });
658
+
659
+ describe("completing an attachment (#679)", () => {
660
+ it("refuses to complete when nothing was ever uploaded", async () => {
661
+ await install();
662
+ const { outboxAttachmentId } = mintedBody(
663
+ await mint({
664
+ filename: "never-sent.bin",
665
+ contentType: "application/octet-stream",
666
+ sizeBytes: 8,
667
+ }),
668
+ );
669
+
670
+ const response = await complete(outboxAttachmentId);
671
+
672
+ assert.equal(response.statusCode, 400);
673
+ assert.equal(
674
+ parse(response).reason,
675
+ OutboxAttachmentRejectionReason.UploadMissing,
676
+ );
677
+ });
678
+
679
+ it("refuses to complete an attachment that was never reserved", async () => {
680
+ await install();
681
+
682
+ const response = await complete("never-minted");
683
+
684
+ assert.equal(response.statusCode, 400);
685
+ assert.equal(
686
+ parse(response).reason,
687
+ OutboxAttachmentRejectionReason.ReservationExpired,
688
+ );
689
+ });
690
+
691
+ it("refuses, and removes, an object that is not the size reserved for it", async () => {
692
+ const { storage } = await install();
693
+ const { outboxAttachmentId } = mintedBody(
694
+ await mint({
695
+ filename: "mismatched.bin",
696
+ contentType: "application/octet-stream",
697
+ sizeBytes: 4096,
698
+ }),
699
+ );
700
+
701
+ // Write past the upload route, the way a hosted deployment's block storage
702
+ // could end up holding something other than what was announced.
703
+ await storage.storeOutboxAttachment({
704
+ accountConfigId: ACCOUNT_CONFIG_ID,
705
+ accountId: ACCOUNT_ID,
706
+ outboxMessageId: DRAFT_ID,
707
+ outboxAttachmentId,
708
+ content: Buffer.alloc(9),
709
+ });
710
+
711
+ const response = await complete(outboxAttachmentId);
712
+
713
+ assert.equal(response.statusCode, 400);
714
+ assert.equal(
715
+ parse(response).reason,
716
+ OutboxAttachmentRejectionReason.SizeMismatch,
717
+ );
718
+ assert.equal(
719
+ await storage.statOutboxAttachment(
720
+ ACCOUNT_CONFIG_ID,
721
+ ACCOUNT_ID,
722
+ DRAFT_ID,
723
+ outboxAttachmentId,
724
+ ),
725
+ null,
726
+ );
727
+ });
728
+ });
729
+
730
+ describe("the mint route through the whole request pipeline", () => {
731
+ it("reaches its handler with the real built spec in front of it", async () => {
732
+ await install();
733
+ const { api } = await import("../index.js");
734
+
735
+ const body = JSON.stringify({
736
+ filename: "routed.txt",
737
+ contentType: "text/plain",
738
+ sizeBytes: 18,
739
+ });
740
+ const event = {
741
+ httpMethod: "POST",
742
+ path: `/outbox/${DRAFT_ID}/attachments`,
743
+ body,
744
+ headers: { "content-type": "application/json" },
745
+ requestContext: { authorizer: { claims: { sub: SUB } } },
746
+ } as unknown as APIGatewayProxyEvent;
747
+
748
+ const result = (await api.handleRequest(
749
+ {
750
+ method: "POST",
751
+ path: event.path,
752
+ query: {},
753
+ body,
754
+ headers: { "content-type": "application/json" },
755
+ },
756
+ event,
757
+ {} as never,
758
+ )) as APIGatewayProxyResult;
759
+
760
+ assert.equal(result.statusCode, 200);
761
+ assert.equal(parse(result).filename, "routed.txt");
762
+ });
763
+ });