@remit/storage-service 0.0.7 → 0.0.9
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/package.json +5 -1
- package/src/backends/filesystem.ts +203 -0
- package/src/backends/s3.test.ts +144 -6
- package/src/backends/s3.ts +214 -0
- package/src/index.ts +26 -0
- package/src/outbox-sweep.test.ts +196 -0
- package/src/outbox-sweep.ts +103 -0
- package/src/outbox-upload-url.test.ts +290 -0
- package/src/outbox-upload-url.ts +78 -0
- package/src/s3.ts +17 -1
- package/src/signed-path.ts +86 -0
- package/src/storage.test.ts +136 -0
- package/src/storage.ts +261 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/storage-service",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
"./s3": {
|
|
17
17
|
"types": "./src/s3.ts",
|
|
18
18
|
"default": "./src/s3.ts"
|
|
19
|
+
},
|
|
20
|
+
"./signed-path": {
|
|
21
|
+
"types": "./src/signed-path.ts",
|
|
22
|
+
"default": "./src/signed-path.ts"
|
|
19
23
|
}
|
|
20
24
|
},
|
|
21
25
|
"scripts": {
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
mkdir,
|
|
5
5
|
readdir,
|
|
6
6
|
readFile,
|
|
7
|
+
rm,
|
|
7
8
|
stat,
|
|
8
9
|
unlink,
|
|
9
10
|
writeFile,
|
|
@@ -13,7 +14,13 @@ import { PassThrough, Readable } from "node:stream";
|
|
|
13
14
|
import { pipeline } from "node:stream/promises";
|
|
14
15
|
import { createGunzip, createGzip, gunzipSync, gzipSync } from "node:zlib";
|
|
15
16
|
import { ContentEncoding, StorageType } from "@remit/domain-enums";
|
|
17
|
+
import {
|
|
18
|
+
UPLOAD_ROUTE_PREFIX,
|
|
19
|
+
UPLOAD_URL_SIGNING_LABEL,
|
|
20
|
+
} from "../outbox-upload-url.js";
|
|
21
|
+
import { signStoragePath } from "../signed-path.js";
|
|
16
22
|
import type {
|
|
23
|
+
OutboxAttachmentListItem,
|
|
17
24
|
ParsedBody,
|
|
18
25
|
StorageReference,
|
|
19
26
|
StorageService,
|
|
@@ -26,6 +33,8 @@ import {
|
|
|
26
33
|
buildExtractedSkippedKey,
|
|
27
34
|
buildExtractedTextKey,
|
|
28
35
|
buildMessageBodyKey,
|
|
36
|
+
buildOutboxAttachmentKey,
|
|
37
|
+
buildOutboxAttachmentPrefix,
|
|
29
38
|
buildParsedBodyKey,
|
|
30
39
|
computeChecksum,
|
|
31
40
|
isStorageNotFoundError,
|
|
@@ -37,8 +46,20 @@ interface StoreParams {
|
|
|
37
46
|
compress?: boolean;
|
|
38
47
|
}
|
|
39
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Where a minted upload URL points, and what signs it. Absent on a process that
|
|
51
|
+
* only reads storage; a mint then fails loud rather than handing out a URL
|
|
52
|
+
* nothing can verify.
|
|
53
|
+
*/
|
|
54
|
+
export interface FilesystemUploadUrlConfig {
|
|
55
|
+
/** Public origin of the deployment serving the upload route. */
|
|
56
|
+
origin: string;
|
|
57
|
+
signingSecret: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
40
60
|
export const createFilesystemStorageService = (
|
|
41
61
|
basePath: string,
|
|
62
|
+
uploadUrls?: FilesystemUploadUrlConfig,
|
|
42
63
|
): StorageService => {
|
|
43
64
|
const storeInternal = async (
|
|
44
65
|
params: StoreParams,
|
|
@@ -252,6 +273,181 @@ export const createFilesystemStorageService = (
|
|
|
252
273
|
}));
|
|
253
274
|
};
|
|
254
275
|
|
|
276
|
+
const storeOutboxAttachment: StorageService["storeOutboxAttachment"] = (
|
|
277
|
+
params,
|
|
278
|
+
) => {
|
|
279
|
+
const {
|
|
280
|
+
accountConfigId,
|
|
281
|
+
accountId,
|
|
282
|
+
outboxMessageId,
|
|
283
|
+
outboxAttachmentId,
|
|
284
|
+
content,
|
|
285
|
+
} = params;
|
|
286
|
+
return storeInternal({
|
|
287
|
+
key: buildOutboxAttachmentKey(
|
|
288
|
+
accountConfigId,
|
|
289
|
+
accountId,
|
|
290
|
+
outboxMessageId,
|
|
291
|
+
outboxAttachmentId,
|
|
292
|
+
),
|
|
293
|
+
content,
|
|
294
|
+
compress: false,
|
|
295
|
+
});
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const listOutboxAttachments: StorageService["listOutboxAttachments"] = async (
|
|
299
|
+
accountConfigId,
|
|
300
|
+
accountId,
|
|
301
|
+
outboxMessageId,
|
|
302
|
+
) => {
|
|
303
|
+
const prefix = buildOutboxAttachmentPrefix(
|
|
304
|
+
accountConfigId,
|
|
305
|
+
accountId,
|
|
306
|
+
outboxMessageId,
|
|
307
|
+
);
|
|
308
|
+
const entries = await readdir(join(basePath, prefix)).catch(
|
|
309
|
+
(error: unknown) => {
|
|
310
|
+
if (isStorageNotFoundError(error)) return [];
|
|
311
|
+
throw error;
|
|
312
|
+
},
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
const items: OutboxAttachmentListItem[] = [];
|
|
316
|
+
for (const name of entries) {
|
|
317
|
+
const { size } = await stat(join(basePath, prefix, name));
|
|
318
|
+
items.push({
|
|
319
|
+
outboxAttachmentId: name,
|
|
320
|
+
key: `${prefix}${name}`,
|
|
321
|
+
sizeBytes: size,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
return items;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const listOutboxDraftsWithAttachments: StorageService["listOutboxDraftsWithAttachments"] =
|
|
328
|
+
async (accountConfigId, accountId) => {
|
|
329
|
+
// An account that has never held an attachment has no directory, and
|
|
330
|
+
// that is genuinely nothing to do. A storage root that is not there at
|
|
331
|
+
// all is a different thing entirely — a process that cannot see the
|
|
332
|
+
// volume it is meant to be collecting from — and it must not read as an
|
|
333
|
+
// empty answer. Unmounted, the sweep would report every account swept
|
|
334
|
+
// and collect nothing, forever.
|
|
335
|
+
const rootExists = await stat(basePath)
|
|
336
|
+
.then(() => true)
|
|
337
|
+
.catch((error: unknown) => {
|
|
338
|
+
if (isStorageNotFoundError(error)) return false;
|
|
339
|
+
throw error;
|
|
340
|
+
});
|
|
341
|
+
if (!rootExists) {
|
|
342
|
+
throw new Error(
|
|
343
|
+
`storage root ${basePath} does not exist; this process cannot see the storage it was asked to read`,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const root = join(
|
|
348
|
+
basePath,
|
|
349
|
+
`accounts/${accountConfigId}/${accountId}/outbox`,
|
|
350
|
+
);
|
|
351
|
+
const entries = await readdir(root, { withFileTypes: true }).catch(
|
|
352
|
+
(error: unknown) => {
|
|
353
|
+
if (isStorageNotFoundError(error)) return [];
|
|
354
|
+
throw error;
|
|
355
|
+
},
|
|
356
|
+
);
|
|
357
|
+
return entries
|
|
358
|
+
.filter((entry) => entry.isDirectory())
|
|
359
|
+
.map((entry) => entry.name);
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const deleteOutboxAttachment: StorageService["deleteOutboxAttachment"] =
|
|
363
|
+
async (accountConfigId, accountId, outboxMessageId, outboxAttachmentId) => {
|
|
364
|
+
const prefix = buildOutboxAttachmentPrefix(
|
|
365
|
+
accountConfigId,
|
|
366
|
+
accountId,
|
|
367
|
+
outboxMessageId,
|
|
368
|
+
);
|
|
369
|
+
const entries = await readdir(join(basePath, prefix)).catch(
|
|
370
|
+
(error: unknown) => {
|
|
371
|
+
if (isStorageNotFoundError(error)) return [];
|
|
372
|
+
throw error;
|
|
373
|
+
},
|
|
374
|
+
);
|
|
375
|
+
for (const name of entries) {
|
|
376
|
+
if (name !== outboxAttachmentId) continue;
|
|
377
|
+
await rm(join(basePath, prefix, name), { force: true });
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const statOutboxAttachment: StorageService["statOutboxAttachment"] = async (
|
|
382
|
+
accountConfigId,
|
|
383
|
+
accountId,
|
|
384
|
+
outboxMessageId,
|
|
385
|
+
outboxAttachmentId,
|
|
386
|
+
) => {
|
|
387
|
+
const fullPath = join(
|
|
388
|
+
basePath,
|
|
389
|
+
buildOutboxAttachmentKey(
|
|
390
|
+
accountConfigId,
|
|
391
|
+
accountId,
|
|
392
|
+
outboxMessageId,
|
|
393
|
+
outboxAttachmentId,
|
|
394
|
+
),
|
|
395
|
+
);
|
|
396
|
+
const stats = await stat(fullPath).catch((error: unknown) => {
|
|
397
|
+
if (isStorageNotFoundError(error)) return null;
|
|
398
|
+
throw error;
|
|
399
|
+
});
|
|
400
|
+
return stats ? { sizeBytes: stats.size } : null;
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
// This backend has no presigned anything, so the URL addresses the
|
|
404
|
+
// deployment's own upload route. Its authority is the same HMAC the read side
|
|
405
|
+
// uses on /content, under the write label, covering the storage key, the
|
|
406
|
+
// expiry and the exact byte count — a URL for one attachment, for a while,
|
|
407
|
+
// for one size.
|
|
408
|
+
const createOutboxAttachmentUploadUrl: StorageService["createOutboxAttachmentUploadUrl"] =
|
|
409
|
+
async (params) => {
|
|
410
|
+
if (!uploadUrls) {
|
|
411
|
+
throw new Error(
|
|
412
|
+
"the filesystem storage backend cannot mint an upload URL without an origin and a signing secret",
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
const key = buildOutboxAttachmentKey(
|
|
416
|
+
params.accountConfigId,
|
|
417
|
+
params.accountId,
|
|
418
|
+
params.outboxMessageId,
|
|
419
|
+
params.outboxAttachmentId,
|
|
420
|
+
);
|
|
421
|
+
const sig = signStoragePath(
|
|
422
|
+
uploadUrls.signingSecret,
|
|
423
|
+
UPLOAD_URL_SIGNING_LABEL,
|
|
424
|
+
key,
|
|
425
|
+
[params.expiresAt, params.sizeBytes],
|
|
426
|
+
);
|
|
427
|
+
const origin = uploadUrls.origin.replace(/\/+$/, "");
|
|
428
|
+
const query = new URLSearchParams({
|
|
429
|
+
exp: String(params.expiresAt),
|
|
430
|
+
max: String(params.sizeBytes),
|
|
431
|
+
sig,
|
|
432
|
+
});
|
|
433
|
+
return {
|
|
434
|
+
uploadUrl: `${origin}${UPLOAD_ROUTE_PREFIX}${key}?${query.toString()}`,
|
|
435
|
+
};
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const deleteOutboxAttachments: StorageService["deleteOutboxAttachments"] =
|
|
439
|
+
async (accountConfigId, accountId, outboxMessageId) => {
|
|
440
|
+
const dirPath = join(
|
|
441
|
+
basePath,
|
|
442
|
+
buildOutboxAttachmentPrefix(
|
|
443
|
+
accountConfigId,
|
|
444
|
+
accountId,
|
|
445
|
+
outboxMessageId,
|
|
446
|
+
),
|
|
447
|
+
);
|
|
448
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
449
|
+
};
|
|
450
|
+
|
|
255
451
|
const storeDeduplicated: StorageService["storeDeduplicated"] = (params) => {
|
|
256
452
|
const { accountConfigId, accountId, content } = params;
|
|
257
453
|
const checksumSha256 = computeChecksum(content);
|
|
@@ -338,6 +534,13 @@ export const createFilesystemStorageService = (
|
|
|
338
534
|
storeBodyPart,
|
|
339
535
|
bodyPartExists,
|
|
340
536
|
retrieveBodyPart,
|
|
537
|
+
storeOutboxAttachment,
|
|
538
|
+
listOutboxAttachments,
|
|
539
|
+
listOutboxDraftsWithAttachments,
|
|
540
|
+
deleteOutboxAttachments,
|
|
541
|
+
deleteOutboxAttachment,
|
|
542
|
+
statOutboxAttachment,
|
|
543
|
+
createOutboxAttachmentUploadUrl,
|
|
341
544
|
storeDeduplicated,
|
|
342
545
|
storeParsedBody,
|
|
343
546
|
retrieveParsedBody,
|
package/src/backends/s3.test.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { Readable } from "node:stream";
|
|
|
3
3
|
import { describe, test } from "node:test";
|
|
4
4
|
import { gunzipSync } from "node:zlib";
|
|
5
5
|
import {
|
|
6
|
+
DeleteObjectCommand,
|
|
7
|
+
DeleteObjectsCommand,
|
|
6
8
|
GetObjectCommand,
|
|
7
9
|
HeadObjectCommand,
|
|
8
10
|
ListObjectsV2Command,
|
|
@@ -22,16 +24,34 @@ interface CapturedCommand {
|
|
|
22
24
|
const createFakeS3Client = (): {
|
|
23
25
|
client: S3Client;
|
|
24
26
|
commands: CapturedCommand[];
|
|
25
|
-
stored: Map<string, { body: Buffer; contentEncoding?: string }>;
|
|
27
|
+
stored: Map<string, { body: Buffer; etag: string; contentEncoding?: string }>;
|
|
26
28
|
} => {
|
|
27
29
|
const commands: CapturedCommand[] = [];
|
|
28
|
-
const stored = new Map<
|
|
30
|
+
const stored = new Map<
|
|
31
|
+
string,
|
|
32
|
+
{ body: Buffer; etag: string; contentEncoding?: string }
|
|
33
|
+
>();
|
|
34
|
+
let etagCounter = 0;
|
|
29
35
|
|
|
30
36
|
const send = async (command: unknown): Promise<unknown> => {
|
|
31
37
|
if (command instanceof PutObjectCommand) {
|
|
32
38
|
const input = command.input as unknown as Record<string, unknown>;
|
|
33
39
|
commands.push({ name: "PutObjectCommand", input });
|
|
34
40
|
|
|
41
|
+
// S3's conditional write, which is what the ledger's compare-and-set
|
|
42
|
+
// rides on: If-None-Match refuses an overwrite, If-Match refuses a
|
|
43
|
+
// stale one. Both answer 412.
|
|
44
|
+
const existing = stored.get(String(input.Key));
|
|
45
|
+
const precondition =
|
|
46
|
+
(input.IfNoneMatch === "*" && existing !== undefined) ||
|
|
47
|
+
(typeof input.IfMatch === "string" && input.IfMatch !== existing?.etag);
|
|
48
|
+
if (precondition) {
|
|
49
|
+
throw Object.assign(new Error("PreconditionFailed"), {
|
|
50
|
+
name: "PreconditionFailed",
|
|
51
|
+
$metadata: { httpStatusCode: 412 },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
35
55
|
if (input.ChecksumSHA256 !== undefined) {
|
|
36
56
|
throw Object.assign(
|
|
37
57
|
new Error(
|
|
@@ -46,8 +66,10 @@ const createFakeS3Client = (): {
|
|
|
46
66
|
if (!Buffer.isBuffer(body)) {
|
|
47
67
|
throw new Error("expected Body to be a Buffer");
|
|
48
68
|
}
|
|
69
|
+
etagCounter += 1;
|
|
49
70
|
stored.set(key, {
|
|
50
71
|
body,
|
|
72
|
+
etag: `"etag-${etagCounter}"`,
|
|
51
73
|
contentEncoding:
|
|
52
74
|
typeof input.ContentEncoding === "string"
|
|
53
75
|
? input.ContentEncoding
|
|
@@ -70,26 +92,65 @@ const createFakeS3Client = (): {
|
|
|
70
92
|
transformToByteArray: async () => new Uint8Array(entry.body),
|
|
71
93
|
},
|
|
72
94
|
ContentEncoding: entry.contentEncoding,
|
|
95
|
+
ETag: entry.etag,
|
|
73
96
|
};
|
|
74
97
|
}
|
|
75
98
|
|
|
76
99
|
if (command instanceof HeadObjectCommand) {
|
|
77
100
|
const input = command.input as unknown as Record<string, unknown>;
|
|
78
101
|
const key = String(input.Key);
|
|
79
|
-
|
|
102
|
+
const entry = stored.get(key);
|
|
103
|
+
if (!entry) {
|
|
80
104
|
throw Object.assign(new Error(`not found: ${key}`), {
|
|
81
105
|
name: "NotFound",
|
|
82
106
|
});
|
|
83
107
|
}
|
|
108
|
+
return { ContentLength: entry.body.length };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (command instanceof DeleteObjectCommand) {
|
|
112
|
+
const input = command.input as unknown as Record<string, unknown>;
|
|
113
|
+
commands.push({ name: "DeleteObjectCommand", input });
|
|
114
|
+
stored.delete(String(input.Key));
|
|
115
|
+
return {};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (command instanceof DeleteObjectsCommand) {
|
|
119
|
+
const input = command.input as unknown as Record<string, unknown>;
|
|
120
|
+
commands.push({ name: "DeleteObjectsCommand", input });
|
|
121
|
+
const del = input.Delete as { Objects: { Key: string }[] };
|
|
122
|
+
for (const { Key } of del.Objects) stored.delete(Key);
|
|
84
123
|
return {};
|
|
85
124
|
}
|
|
86
125
|
|
|
87
126
|
if (command instanceof ListObjectsV2Command) {
|
|
88
127
|
const input = command.input as unknown as Record<string, unknown>;
|
|
89
128
|
const prefix = String(input.Prefix ?? "");
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
.
|
|
129
|
+
const maxKeys = Number(input.MaxKeys ?? Number.POSITIVE_INFINITY);
|
|
130
|
+
const matching = [...stored.entries()].filter(([key]) =>
|
|
131
|
+
key.startsWith(prefix),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// A delimited listing answers with directory-like prefixes instead of
|
|
135
|
+
// the objects beneath them, the way listOutboxDraftsWithAttachments
|
|
136
|
+
// asks for them.
|
|
137
|
+
if (typeof input.Delimiter === "string") {
|
|
138
|
+
const delimiter = input.Delimiter;
|
|
139
|
+
const prefixes = new Set<string>();
|
|
140
|
+
for (const [key] of matching) {
|
|
141
|
+
const rest = key.slice(prefix.length);
|
|
142
|
+
const at = rest.indexOf(delimiter);
|
|
143
|
+
if (at >= 0) prefixes.add(prefix + rest.slice(0, at + 1));
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
CommonPrefixes: [...prefixes].map((Prefix) => ({ Prefix })),
|
|
147
|
+
IsTruncated: false,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const Contents = matching
|
|
152
|
+
.slice(0, maxKeys)
|
|
153
|
+
.map(([Key, entry]) => ({ Key, Size: entry.body.length }));
|
|
93
154
|
return { Contents, IsTruncated: false };
|
|
94
155
|
}
|
|
95
156
|
|
|
@@ -461,3 +522,80 @@ describe("createS3StorageService", () => {
|
|
|
461
522
|
}
|
|
462
523
|
});
|
|
463
524
|
});
|
|
525
|
+
|
|
526
|
+
describe("createS3StorageService: outbox attachments", () => {
|
|
527
|
+
test("an uploaded object lists at the size the bucket holds", async () => {
|
|
528
|
+
const { client } = createFakeS3Client();
|
|
529
|
+
const storage = createS3StorageService(client, "bucket");
|
|
530
|
+
|
|
531
|
+
await storage.storeOutboxAttachment({
|
|
532
|
+
accountConfigId: "cfg1",
|
|
533
|
+
accountId: "acc1",
|
|
534
|
+
outboxMessageId: "draft1",
|
|
535
|
+
outboxAttachmentId: "att1",
|
|
536
|
+
content: Buffer.alloc(777),
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
const [entry] = await storage.listOutboxAttachments(
|
|
540
|
+
"cfg1",
|
|
541
|
+
"acc1",
|
|
542
|
+
"draft1",
|
|
543
|
+
);
|
|
544
|
+
assert.strictEqual(entry.outboxAttachmentId, "att1");
|
|
545
|
+
// Uncompressed, so the size in the bucket is the size the cap counts.
|
|
546
|
+
assert.strictEqual(entry.sizeBytes, 777);
|
|
547
|
+
assert.strictEqual(
|
|
548
|
+
await storage
|
|
549
|
+
.statOutboxAttachment("cfg1", "acc1", "draft1", "att1")
|
|
550
|
+
.then((found) => found?.sizeBytes),
|
|
551
|
+
777,
|
|
552
|
+
);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test("statOutboxAttachment answers null for an object that was never uploaded", async () => {
|
|
556
|
+
const { client } = createFakeS3Client();
|
|
557
|
+
const storage = createS3StorageService(client, "bucket");
|
|
558
|
+
|
|
559
|
+
assert.strictEqual(
|
|
560
|
+
await storage.statOutboxAttachment("cfg1", "acc1", "draft1", "nothing"),
|
|
561
|
+
null,
|
|
562
|
+
);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
test("deleting a draft's attachments empties the prefix", async () => {
|
|
566
|
+
const { client } = createFakeS3Client();
|
|
567
|
+
const storage = createS3StorageService(client, "bucket");
|
|
568
|
+
await storage.storeOutboxAttachment({
|
|
569
|
+
accountConfigId: "cfg1",
|
|
570
|
+
accountId: "acc1",
|
|
571
|
+
outboxMessageId: "draft1",
|
|
572
|
+
outboxAttachmentId: "att2",
|
|
573
|
+
content: Buffer.alloc(8),
|
|
574
|
+
});
|
|
575
|
+
await storage.deleteOutboxAttachments("cfg1", "acc1", "draft1");
|
|
576
|
+
|
|
577
|
+
assert.deepStrictEqual(
|
|
578
|
+
await storage.listOutboxAttachments("cfg1", "acc1", "draft1"),
|
|
579
|
+
[],
|
|
580
|
+
);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test("listOutboxDraftsWithAttachments finds prefixes whose row may be long gone", async () => {
|
|
584
|
+
const { client } = createFakeS3Client();
|
|
585
|
+
const storage = createS3StorageService(client, "bucket");
|
|
586
|
+
for (const draft of ["draft1", "draft2"]) {
|
|
587
|
+
await storage.storeOutboxAttachment({
|
|
588
|
+
accountConfigId: "cfg1",
|
|
589
|
+
accountId: "acc1",
|
|
590
|
+
outboxMessageId: draft,
|
|
591
|
+
outboxAttachmentId: "att1",
|
|
592
|
+
content: Buffer.alloc(4),
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
assert.deepStrictEqual(
|
|
597
|
+
(await storage.listOutboxDraftsWithAttachments("cfg1", "acc1")).sort(),
|
|
598
|
+
["draft1", "draft2"],
|
|
599
|
+
);
|
|
600
|
+
});
|
|
601
|
+
});
|