@remit/storage-service 0.0.12 → 0.0.14

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.
@@ -1,447 +0,0 @@
1
- import assert from "node:assert";
2
- import { Readable } from "node:stream";
3
- import { describe, test } from "node:test";
4
- import { gunzipSync } from "node:zlib";
5
- import {
6
- DeleteObjectCommand,
7
- DeleteObjectsCommand,
8
- GetObjectCommand,
9
- HeadObjectCommand,
10
- ListObjectsV2Command,
11
- PutObjectCommand,
12
- S3Client,
13
- } from "@aws-sdk/client-s3";
14
- import { ContentEncoding, StorageType } from "@remit/domain-enums";
15
- import { mockClient } from "aws-sdk-client-mock";
16
- import { computeChecksum } from "../storage.js";
17
- import { createS3StorageService } from "./s3.js";
18
-
19
- interface CapturedCommand {
20
- name: string;
21
- input: Record<string, unknown>;
22
- }
23
-
24
- const createFakeS3Client = (): {
25
- client: S3Client;
26
- commands: CapturedCommand[];
27
- stored: Map<string, { body: Buffer; etag: string; contentEncoding?: string }>;
28
- } => {
29
- const commands: CapturedCommand[] = [];
30
- const stored = new Map<
31
- string,
32
- { body: Buffer; etag: string; contentEncoding?: string }
33
- >();
34
- let etagCounter = 0;
35
-
36
- const send = async (command: unknown): Promise<unknown> => {
37
- if (command instanceof PutObjectCommand) {
38
- const input = command.input as unknown as Record<string, unknown>;
39
- commands.push({ name: "PutObjectCommand", input });
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
-
55
- if (input.ChecksumSHA256 !== undefined) {
56
- throw Object.assign(
57
- new Error(
58
- "BadDigest: The SHA256 you specified did not match the calculated checksum.",
59
- ),
60
- { name: "BadDigest" },
61
- );
62
- }
63
-
64
- const key = String(input.Key);
65
- const body = input.Body;
66
- if (!Buffer.isBuffer(body)) {
67
- throw new Error("expected Body to be a Buffer");
68
- }
69
- etagCounter += 1;
70
- stored.set(key, {
71
- body,
72
- etag: `"etag-${etagCounter}"`,
73
- contentEncoding:
74
- typeof input.ContentEncoding === "string"
75
- ? input.ContentEncoding
76
- : undefined,
77
- });
78
- return {};
79
- }
80
-
81
- if (command instanceof GetObjectCommand) {
82
- const input = command.input as unknown as Record<string, unknown>;
83
- const key = String(input.Key);
84
- const entry = stored.get(key);
85
- if (!entry) {
86
- throw Object.assign(new Error(`not found: ${key}`), {
87
- name: "NoSuchKey",
88
- });
89
- }
90
- return {
91
- Body: {
92
- transformToByteArray: async () => new Uint8Array(entry.body),
93
- },
94
- ContentEncoding: entry.contentEncoding,
95
- ETag: entry.etag,
96
- };
97
- }
98
-
99
- if (command instanceof HeadObjectCommand) {
100
- const input = command.input as unknown as Record<string, unknown>;
101
- const key = String(input.Key);
102
- const entry = stored.get(key);
103
- if (!entry) {
104
- throw Object.assign(new Error(`not found: ${key}`), {
105
- name: "NotFound",
106
- });
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);
123
- return {};
124
- }
125
-
126
- if (command instanceof ListObjectsV2Command) {
127
- const input = command.input as unknown as Record<string, unknown>;
128
- const prefix = String(input.Prefix ?? "");
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 }));
154
- return { Contents, IsTruncated: false };
155
- }
156
-
157
- throw new Error(`unexpected command: ${String(command)}`);
158
- };
159
-
160
- return {
161
- client: { send } as unknown as S3Client,
162
- commands,
163
- stored,
164
- };
165
- };
166
-
167
- describe("createS3StorageService", () => {
168
- test("storeMessageBody sends gzipped body keyed under accounts/{configId}/{accountId}/...", async () => {
169
- const { client, commands, stored } = createFakeS3Client();
170
- const storage = createS3StorageService(client, "bucket");
171
- const content = Buffer.from("Hello, world! This is a message body.");
172
-
173
- const ref = await storage.storeMessageBody({
174
- accountConfigId: "cfg1",
175
- accountId: "acc123",
176
- messageId: "msg456",
177
- content,
178
- });
179
-
180
- assert.strictEqual(commands.length, 1);
181
- const put = commands[0];
182
- assert.strictEqual(put.name, "PutObjectCommand");
183
- assert.strictEqual(put.input.ChecksumSHA256, undefined);
184
- assert.strictEqual(put.input.ContentEncoding, "gzip");
185
-
186
- const entry = stored.get("accounts/cfg1/acc123/messages/msg456/body.eml");
187
- assert.ok(entry, "object should be stored");
188
- assert.deepStrictEqual(gunzipSync(entry.body), content);
189
-
190
- assert.strictEqual(ref.storageType, StorageType.S3);
191
- assert.strictEqual(ref.contentEncoding, ContentEncoding.Gzip);
192
- assert.strictEqual(ref.checksumSha256, computeChecksum(content));
193
- assert.strictEqual(ref.sizeBytes, entry.body.length);
194
- });
195
-
196
- test("storeMessageBodyStream streams a gzipped body to S3 with the pre-gzip checksum", async () => {
197
- // lib-storage's Upload needs a real S3Client surface (config, request
198
- // handler), so mock the client rather than hand-rolling a `send` stub.
199
- const s3Mock = mockClient(S3Client);
200
- const captured: Buffer[] = [];
201
- s3Mock.on(PutObjectCommand).callsFake((input) => {
202
- const body = input.Body;
203
- captured.push(Buffer.isBuffer(body) ? body : Buffer.from(body));
204
- assert.strictEqual(input.ContentEncoding, "gzip");
205
- assert.strictEqual(input.ContentType, "message/rfc822");
206
- return {};
207
- });
208
-
209
- const storage = createS3StorageService(
210
- new S3Client({ region: "us-east-1" }),
211
- "bucket",
212
- );
213
- const content = Buffer.from("streamed message body bytes");
214
-
215
- const ref = await storage.storeMessageBodyStream({
216
- accountConfigId: "cfg1",
217
- accountId: "acc123",
218
- messageId: "msg-stream",
219
- content: Readable.from(content),
220
- });
221
-
222
- // One PutObject, gzipped, decoding back to the original bytes — proves the
223
- // stream was uploaded, not silently dropped.
224
- assert.strictEqual(captured.length, 1);
225
- assert.deepStrictEqual(gunzipSync(captured[0]), content);
226
-
227
- assert.strictEqual(ref.storageType, StorageType.S3);
228
- assert.strictEqual(ref.contentEncoding, ContentEncoding.Gzip);
229
- // Checksum is over the logical pre-gzip content, matching storeMessageBody.
230
- assert.strictEqual(ref.checksumSha256, computeChecksum(content));
231
- assert.strictEqual(ref.sizeBytes, captured[0].length);
232
-
233
- s3Mock.restore();
234
- });
235
-
236
- test("storeDeduplicated places object at content-hash key based on pre-gzip checksum", async () => {
237
- const { client, stored } = createFakeS3Client();
238
- const storage = createS3StorageService(client, "bucket");
239
- const content = Buffer.from("dedupable content");
240
- const hash = computeChecksum(content);
241
-
242
- const ref = await storage.storeDeduplicated({
243
- accountConfigId: "cfg1",
244
- accountId: "acc123",
245
- content,
246
- });
247
-
248
- const expectedKey = `accounts/cfg1/acc123/dedup/${hash.slice(0, 2)}/${hash}`;
249
- assert.strictEqual(ref.storageKey, expectedKey);
250
- assert.strictEqual(ref.checksumSha256, hash);
251
-
252
- const entry = stored.get(expectedKey);
253
- assert.ok(entry, "dedup object should be stored");
254
- assert.deepStrictEqual(gunzipSync(entry.body), content);
255
- });
256
-
257
- test("round-trips content through retrieve with gzip decoding", async () => {
258
- const { client } = createFakeS3Client();
259
- const storage = createS3StorageService(client, "bucket");
260
- const content = Buffer.from("round trip payload");
261
-
262
- const ref = await storage.storeMessageBody({
263
- accountConfigId: "cfg1",
264
- accountId: "acc123",
265
- messageId: "msg789",
266
- content,
267
- });
268
-
269
- const retrieved = await storage.retrieve(ref.uri);
270
- assert.deepStrictEqual(retrieved, content);
271
- });
272
-
273
- test("storeParsedBody writes gzipped JSON keyed under accounts/{configId}/{accountId}/...", async () => {
274
- const { client, commands, stored } = createFakeS3Client();
275
- const storage = createS3StorageService(client, "bucket");
276
- const parsed = {
277
- text: "hi",
278
- html: "<p>hi</p>",
279
- attachments: [
280
- {
281
- filename: "a.txt",
282
- contentType: "text/plain",
283
- contentDisposition: "attachment",
284
- contentId: null,
285
- size: 12,
286
- },
287
- ],
288
- };
289
-
290
- const ref = await storage.storeParsedBody({
291
- accountConfigId: "cfg-x",
292
- accountId: "acc-x",
293
- messageId: "msg-x",
294
- parsed,
295
- });
296
-
297
- assert.strictEqual(commands.length, 1);
298
- const put = commands[0];
299
- assert.strictEqual(put.input.ChecksumSHA256, undefined);
300
- assert.strictEqual(put.input.ContentEncoding, "gzip");
301
- assert.strictEqual(put.input.ContentType, "application/json");
302
- assert.strictEqual(
303
- put.input.Key,
304
- "accounts/cfg-x/acc-x/messages/msg-x/parsed.json.gz",
305
- );
306
-
307
- const entry = stored.get(
308
- "accounts/cfg-x/acc-x/messages/msg-x/parsed.json.gz",
309
- );
310
- assert.ok(entry, "parsed body should be stored");
311
- assert.deepStrictEqual(
312
- JSON.parse(gunzipSync(entry.body).toString("utf8")),
313
- parsed,
314
- );
315
- assert.strictEqual(ref.storageType, StorageType.S3);
316
- });
317
-
318
- test("retrieveParsedBody returns parsed JSON on hit and null on miss", async () => {
319
- const { client } = createFakeS3Client();
320
- const storage = createS3StorageService(client, "bucket");
321
- const parsed = {
322
- text: "hello",
323
- html: null,
324
- attachments: [],
325
- };
326
-
327
- await storage.storeParsedBody({
328
- accountConfigId: "cfg-r",
329
- accountId: "acc-r",
330
- messageId: "msg-r",
331
- parsed,
332
- });
333
-
334
- const hit = await storage.retrieveParsedBody("cfg-r", "acc-r", "msg-r");
335
- assert.deepStrictEqual(hit, parsed);
336
-
337
- const miss = await storage.retrieveParsedBody("cfg-r", "acc-r", "nope");
338
- assert.strictEqual(miss, null);
339
- });
340
-
341
- test("retrieveBodyPart round-trips gzipped content and returns null on miss", async () => {
342
- const { client } = createFakeS3Client();
343
- const storage = createS3StorageService(client, "bucket");
344
- const content = Buffer.from("attachment bytes");
345
-
346
- await storage.storeBodyPart({
347
- accountConfigId: "cfg-p",
348
- accountId: "acc-p",
349
- messageId: "msg-p",
350
- partPath: "1.2",
351
- content,
352
- });
353
-
354
- const retrieved = await storage.retrieveBodyPart(
355
- "cfg-p",
356
- "acc-p",
357
- "msg-p",
358
- "1.2",
359
- );
360
- assert.deepStrictEqual(retrieved, content);
361
-
362
- const miss = await storage.retrieveBodyPart(
363
- "cfg-p",
364
- "acc-p",
365
- "msg-p",
366
- "nope",
367
- );
368
- assert.strictEqual(miss, null);
369
- });
370
- });
371
-
372
- describe("createS3StorageService: outbox attachments", () => {
373
- test("an uploaded object lists at the size the bucket holds", async () => {
374
- const { client } = createFakeS3Client();
375
- const storage = createS3StorageService(client, "bucket");
376
-
377
- await storage.storeOutboxAttachment({
378
- accountConfigId: "cfg1",
379
- accountId: "acc1",
380
- outboxMessageId: "draft1",
381
- outboxAttachmentId: "att1",
382
- content: Buffer.alloc(777),
383
- });
384
-
385
- const [entry] = await storage.listOutboxAttachments(
386
- "cfg1",
387
- "acc1",
388
- "draft1",
389
- );
390
- assert.strictEqual(entry.outboxAttachmentId, "att1");
391
- // Uncompressed, so the size in the bucket is the size the cap counts.
392
- assert.strictEqual(entry.sizeBytes, 777);
393
- assert.strictEqual(
394
- await storage
395
- .statOutboxAttachment("cfg1", "acc1", "draft1", "att1")
396
- .then((found) => found?.sizeBytes),
397
- 777,
398
- );
399
- });
400
-
401
- test("statOutboxAttachment answers null for an object that was never uploaded", async () => {
402
- const { client } = createFakeS3Client();
403
- const storage = createS3StorageService(client, "bucket");
404
-
405
- assert.strictEqual(
406
- await storage.statOutboxAttachment("cfg1", "acc1", "draft1", "nothing"),
407
- null,
408
- );
409
- });
410
-
411
- test("deleting a draft's attachments empties the prefix", async () => {
412
- const { client } = createFakeS3Client();
413
- const storage = createS3StorageService(client, "bucket");
414
- await storage.storeOutboxAttachment({
415
- accountConfigId: "cfg1",
416
- accountId: "acc1",
417
- outboxMessageId: "draft1",
418
- outboxAttachmentId: "att2",
419
- content: Buffer.alloc(8),
420
- });
421
- await storage.deleteOutboxAttachments("cfg1", "acc1", "draft1");
422
-
423
- assert.deepStrictEqual(
424
- await storage.listOutboxAttachments("cfg1", "acc1", "draft1"),
425
- [],
426
- );
427
- });
428
-
429
- test("listOutboxDraftsWithAttachments finds prefixes whose row may be long gone", async () => {
430
- const { client } = createFakeS3Client();
431
- const storage = createS3StorageService(client, "bucket");
432
- for (const draft of ["draft1", "draft2"]) {
433
- await storage.storeOutboxAttachment({
434
- accountConfigId: "cfg1",
435
- accountId: "acc1",
436
- outboxMessageId: draft,
437
- outboxAttachmentId: "att1",
438
- content: Buffer.alloc(4),
439
- });
440
- }
441
-
442
- assert.deepStrictEqual(
443
- (await storage.listOutboxDraftsWithAttachments("cfg1", "acc1")).sort(),
444
- ["draft1", "draft2"],
445
- );
446
- });
447
- });