@remit/storage-service 0.0.8 → 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.
@@ -0,0 +1,290 @@
1
+ import assert from "node:assert";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { describe, test } from "node:test";
6
+ import { S3Client } from "@aws-sdk/client-s3";
7
+ import { createFilesystemStorageService } from "./backends/filesystem.js";
8
+ import { createS3StorageService } from "./backends/s3.js";
9
+ import {
10
+ authorizeUploadRequest,
11
+ UPLOAD_ROUTE_PREFIX,
12
+ UPLOAD_URL_SIGNING_LABEL,
13
+ } from "./outbox-upload-url.js";
14
+ import { signStoragePath } from "./signed-path.js";
15
+ import {
16
+ buildOutboxAttachmentKey,
17
+ parseOutboxAttachmentKey,
18
+ } from "./storage.js";
19
+
20
+ const SECRET = "a-signing-secret-of-at-least-32-characters";
21
+ const KEY = buildOutboxAttachmentKey("cfg1", "acc1", "draft1", "att1");
22
+
23
+ const sign = (path: string, exp: number, max: number): string =>
24
+ signStoragePath(SECRET, UPLOAD_URL_SIGNING_LABEL, path, [exp, max]);
25
+
26
+ describe("authorizeUploadRequest", () => {
27
+ const now = 1_000_000;
28
+ const exp = now + 900;
29
+
30
+ test("admits a request presenting the signature that was minted for it", () => {
31
+ const result = authorizeUploadRequest({
32
+ secret: SECRET,
33
+ relativePath: KEY,
34
+ exp: String(exp),
35
+ max: "2048",
36
+ sig: sign(KEY, exp, 2048),
37
+ nowSeconds: now,
38
+ });
39
+ assert.deepStrictEqual(result, { authorized: true, maxBytes: 2048 });
40
+ });
41
+
42
+ test("refuses a signature minted for another attachment's key", () => {
43
+ const otherKey = buildOutboxAttachmentKey("cfg1", "acc1", "draft1", "att2");
44
+ const result = authorizeUploadRequest({
45
+ secret: SECRET,
46
+ relativePath: otherKey,
47
+ exp: String(exp),
48
+ max: "2048",
49
+ sig: sign(KEY, exp, 2048),
50
+ nowSeconds: now,
51
+ });
52
+ assert.strictEqual(result.authorized, false);
53
+ });
54
+
55
+ test("refuses a signature minted for another account's key", () => {
56
+ const otherTenant = buildOutboxAttachmentKey(
57
+ "cfg2",
58
+ "acc1",
59
+ "draft1",
60
+ "att1",
61
+ );
62
+ const result = authorizeUploadRequest({
63
+ secret: SECRET,
64
+ relativePath: otherTenant,
65
+ exp: String(exp),
66
+ max: "2048",
67
+ sig: sign(KEY, exp, 2048),
68
+ nowSeconds: now,
69
+ });
70
+ assert.strictEqual(result.authorized, false);
71
+ });
72
+
73
+ test("refuses a raised byte count — the size is part of what was signed", () => {
74
+ const result = authorizeUploadRequest({
75
+ secret: SECRET,
76
+ relativePath: KEY,
77
+ exp: String(exp),
78
+ max: "999999",
79
+ sig: sign(KEY, exp, 2048),
80
+ nowSeconds: now,
81
+ });
82
+ assert.strictEqual(result.authorized, false);
83
+ });
84
+
85
+ test("refuses an extended expiry", () => {
86
+ const result = authorizeUploadRequest({
87
+ secret: SECRET,
88
+ relativePath: KEY,
89
+ exp: String(exp + 10_000),
90
+ max: "2048",
91
+ sig: sign(KEY, exp, 2048),
92
+ nowSeconds: now,
93
+ });
94
+ assert.strictEqual(result.authorized, false);
95
+ });
96
+
97
+ test("refuses a lapsed URL", () => {
98
+ const result = authorizeUploadRequest({
99
+ secret: SECRET,
100
+ relativePath: KEY,
101
+ exp: String(exp),
102
+ max: "2048",
103
+ sig: sign(KEY, exp, 2048),
104
+ nowSeconds: exp + 1,
105
+ });
106
+ assert.strictEqual(result.authorized, false);
107
+ assert.strictEqual(result.authorized === false && result.reason, "expired");
108
+ });
109
+
110
+ test("refuses a request presenting no signature at all", () => {
111
+ const result = authorizeUploadRequest({
112
+ secret: SECRET,
113
+ relativePath: KEY,
114
+ exp: String(exp),
115
+ max: "2048",
116
+ sig: undefined,
117
+ nowSeconds: now,
118
+ });
119
+ assert.strictEqual(result.authorized === false && result.status, 401);
120
+ });
121
+
122
+ test("fails closed when no secret is configured", () => {
123
+ const result = authorizeUploadRequest({
124
+ secret: undefined,
125
+ relativePath: KEY,
126
+ exp: String(exp),
127
+ max: "2048",
128
+ sig: sign(KEY, exp, 2048),
129
+ nowSeconds: now,
130
+ });
131
+ assert.strictEqual(result.authorized === false && result.status, 500);
132
+ });
133
+
134
+ test("a read grant for the same path is not a write grant", () => {
135
+ // The content signer covers the path and an expiry under its own label.
136
+ const readSignature = signStoragePath(
137
+ SECRET,
138
+ "remit-content-url-signing-v1",
139
+ KEY,
140
+ [exp],
141
+ );
142
+ const result = authorizeUploadRequest({
143
+ secret: SECRET,
144
+ relativePath: KEY,
145
+ exp: String(exp),
146
+ max: "2048",
147
+ sig: readSignature,
148
+ nowSeconds: now,
149
+ });
150
+ assert.strictEqual(result.authorized, false);
151
+ });
152
+ });
153
+
154
+ describe("attachment keys", () => {
155
+ test("parseOutboxAttachmentKey recovers the ids the upload route needs", () => {
156
+ assert.deepStrictEqual(parseOutboxAttachmentKey(KEY), {
157
+ accountConfigId: "cfg1",
158
+ accountId: "acc1",
159
+ outboxMessageId: "draft1",
160
+ outboxAttachmentId: "att1",
161
+ });
162
+ });
163
+
164
+ test("parseOutboxAttachmentKey refuses a message content key", () => {
165
+ assert.strictEqual(
166
+ parseOutboxAttachmentKey("accounts/cfg1/acc1/messages/msg1/parts/1.2"),
167
+ null,
168
+ );
169
+ });
170
+
171
+ test("parseOutboxAttachmentKey refuses a traversal, signature or no signature", () => {
172
+ // `..` matches a naive [^/]+ and the write side joins this onto a
173
+ // filesystem root, so the shape has to refuse it before the HMAC is the
174
+ // only thing left standing.
175
+ assert.strictEqual(
176
+ parseOutboxAttachmentKey(
177
+ "accounts/cfg1/acc1/outbox/draft1/attachments/..",
178
+ ),
179
+ null,
180
+ );
181
+ assert.strictEqual(
182
+ parseOutboxAttachmentKey("accounts/../../etc/outbox/x/attachments/y"),
183
+ null,
184
+ );
185
+ assert.strictEqual(
186
+ parseOutboxAttachmentKey(
187
+ "accounts/cfg1/acc1/outbox/draft1/attachments/a.b",
188
+ ),
189
+ null,
190
+ );
191
+ });
192
+ });
193
+
194
+ describe("minting an upload URL", () => {
195
+ test("the filesystem backend mints a URL its own route will admit", async () => {
196
+ const basePath = await mkdtemp(join(tmpdir(), "remit-upload-url-"));
197
+ try {
198
+ const storage = createFilesystemStorageService(basePath, {
199
+ origin: "https://mail.example.test/",
200
+ signingSecret: SECRET,
201
+ });
202
+ const expiresAt = Math.floor(Date.now() / 1000) + 900;
203
+
204
+ const { uploadUrl } = await storage.createOutboxAttachmentUploadUrl({
205
+ accountConfigId: "cfg1",
206
+ accountId: "acc1",
207
+ outboxMessageId: "draft1",
208
+ outboxAttachmentId: "att1",
209
+ sizeBytes: 2048,
210
+ expiresAt,
211
+ });
212
+
213
+ const url = new URL(uploadUrl);
214
+ assert.strictEqual(url.origin, "https://mail.example.test");
215
+ assert.strictEqual(url.pathname, `${UPLOAD_ROUTE_PREFIX}${KEY}`);
216
+
217
+ const admitted = authorizeUploadRequest({
218
+ secret: SECRET,
219
+ relativePath: url.pathname.slice(UPLOAD_ROUTE_PREFIX.length),
220
+ exp: url.searchParams.get("exp") ?? undefined,
221
+ max: url.searchParams.get("max") ?? undefined,
222
+ sig: url.searchParams.get("sig") ?? undefined,
223
+ nowSeconds: Math.floor(Date.now() / 1000),
224
+ });
225
+ assert.deepStrictEqual(admitted, { authorized: true, maxBytes: 2048 });
226
+ } finally {
227
+ await rm(basePath, { recursive: true, force: true });
228
+ }
229
+ });
230
+
231
+ test("the filesystem backend refuses to mint without an origin and a secret", async () => {
232
+ const basePath = await mkdtemp(join(tmpdir(), "remit-upload-url-"));
233
+ try {
234
+ const storage = createFilesystemStorageService(basePath);
235
+ await assert.rejects(
236
+ () =>
237
+ storage.createOutboxAttachmentUploadUrl({
238
+ accountConfigId: "cfg1",
239
+ accountId: "acc1",
240
+ outboxMessageId: "draft1",
241
+ outboxAttachmentId: "att1",
242
+ sizeBytes: 1,
243
+ expiresAt: Math.floor(Date.now() / 1000) + 60,
244
+ }),
245
+ /cannot mint an upload URL/,
246
+ );
247
+ } finally {
248
+ await rm(basePath, { recursive: true, force: true });
249
+ }
250
+ });
251
+
252
+ test("the S3 backend mints a presigned PUT with the size inside the signature", async () => {
253
+ const client = new S3Client({
254
+ region: "eu-west-1",
255
+ credentials: {
256
+ accessKeyId: "AKIATESTTESTTEST",
257
+ secretAccessKey: "test-secret",
258
+ },
259
+ });
260
+ const storage = createS3StorageService(client, "a-bucket");
261
+ const params = {
262
+ accountConfigId: "cfg1",
263
+ accountId: "acc1",
264
+ outboxMessageId: "draft1",
265
+ outboxAttachmentId: "att1",
266
+ sizeBytes: 2048,
267
+ expiresAt: Math.floor(Date.now() / 1000) + 900,
268
+ };
269
+
270
+ const { uploadUrl } = await storage.createOutboxAttachmentUploadUrl(params);
271
+ const url = new URL(uploadUrl);
272
+
273
+ assert.ok(url.pathname.endsWith(KEY));
274
+ // content-length inside SignedHeaders is what makes the declared size
275
+ // unforgeable: S3 recomputes the signature over the length it is sent.
276
+ assert.match(
277
+ url.searchParams.get("X-Amz-SignedHeaders") ?? "",
278
+ /content-length/,
279
+ );
280
+
281
+ const larger = await storage.createOutboxAttachmentUploadUrl({
282
+ ...params,
283
+ sizeBytes: 4096,
284
+ });
285
+ assert.notStrictEqual(
286
+ url.searchParams.get("X-Amz-Signature"),
287
+ new URL(larger.uploadUrl).searchParams.get("X-Amz-Signature"),
288
+ );
289
+ });
290
+ });
@@ -0,0 +1,78 @@
1
+ import { verifyStoragePath } from "./signed-path.js";
2
+
3
+ /**
4
+ * The self-hosted half of the upload seam: what a minted URL looks like when
5
+ * there is no block storage to presign against, and how the route that receives
6
+ * the bytes decides to accept them.
7
+ *
8
+ * A hosted deployment mints a presigned PUT instead and none of this runs — the
9
+ * bytes never reach the deployment at all. Both halves bind the same two
10
+ * things, an expiry and an exact byte count, so a client sees one contract.
11
+ */
12
+
13
+ /** Domain separation: a read grant must never be presentable as a write grant. */
14
+ export const UPLOAD_URL_SIGNING_LABEL = "remit-outbox-upload-url-signing-v1";
15
+
16
+ export const UPLOAD_ROUTE_PREFIX = "/outbox-upload/";
17
+
18
+ /**
19
+ * How long a mint's URL, and the reservation behind it, stay good. Long enough
20
+ * for a slow phone to push 25 MB, short enough that a draft abandoned mid-upload
21
+ * stops holding room within the same session.
22
+ */
23
+ export const UPLOAD_URL_TTL_SECONDS = 900;
24
+
25
+ export interface AuthorizeUploadInput {
26
+ secret: string | undefined;
27
+ /** Decoded storage key the signature was minted over. */
28
+ relativePath: string;
29
+ exp: string | undefined;
30
+ max: string | undefined;
31
+ sig: string | undefined;
32
+ nowSeconds: number;
33
+ }
34
+
35
+ export type AuthorizeUploadResult =
36
+ | { authorized: true; maxBytes: number }
37
+ | { authorized: false; status: number; reason: string };
38
+
39
+ /**
40
+ * Decide whether an upload request may write to the key it names.
41
+ *
42
+ * Pure, so the decision is testable without a live server. Mirrors
43
+ * `authorizeContentRequest` on the read side, with one addition: the byte count
44
+ * is part of the signed message, so the caller learns from the signature how
45
+ * much this URL is allowed to carry and can cut the stream off there.
46
+ */
47
+ export const authorizeUploadRequest = (
48
+ input: AuthorizeUploadInput,
49
+ ): AuthorizeUploadResult => {
50
+ if (!input.secret || input.secret.length === 0) {
51
+ // No secret means no upload URL could have been signed, so nothing
52
+ // presented here can be trusted. Fail closed.
53
+ return {
54
+ authorized: false,
55
+ status: 500,
56
+ reason: "upload-signing-not-configured",
57
+ };
58
+ }
59
+
60
+ const exp = Number(input.exp);
61
+ const maxBytes = Number(input.max);
62
+ if (!Number.isInteger(exp) || !Number.isInteger(maxBytes)) {
63
+ return { authorized: false, status: 403, reason: "malformed" };
64
+ }
65
+
66
+ const result = verifyStoragePath(
67
+ input.secret,
68
+ UPLOAD_URL_SIGNING_LABEL,
69
+ input.relativePath,
70
+ [exp, maxBytes],
71
+ input.sig,
72
+ input.nowSeconds,
73
+ );
74
+ if (result.valid) return { authorized: true, maxBytes };
75
+
76
+ const status = result.reason === "missing" ? 401 : 403;
77
+ return { authorized: false, status, reason: result.reason };
78
+ };
package/src/s3.ts CHANGED
@@ -11,10 +11,26 @@ export const createStorageService = (): StorageService => {
11
11
  if (bucketName) {
12
12
  const client = new S3Client({
13
13
  endpoint: process.env.S3_ENDPOINT,
14
+ // The SDK default (WHEN_SUPPORTED) hangs a CRC32 of an empty body off
15
+ // every presigned PUT as signed query params. Real S3 tolerates it;
16
+ // MinIO and LocalStack reject the request, and S3_ENDPOINT exists to
17
+ // point at exactly those.
18
+ requestChecksumCalculation: "WHEN_REQUIRED",
14
19
  });
15
20
  return createS3StorageService(client, bucketName);
16
21
  }
17
22
 
18
23
  const basePath = process.env.STORAGE_LOCAL_PATH ?? ".remit/storage";
19
- return createFilesystemStorageService(basePath);
24
+ // BETTER_AUTH_URL is the deployment's own public base — the value the token
25
+ // issuer and the JWKS discovery document already agree on — so a minted
26
+ // upload URL resolves from wherever the browser reached the API. Both it and
27
+ // the signing secret are required on the self-host stack; without them a
28
+ // mint fails loud inside the backend rather than handing out a URL that
29
+ // cannot be verified.
30
+ const origin = process.env.BETTER_AUTH_URL;
31
+ const signingSecret = process.env.BETTER_AUTH_SECRET;
32
+ return createFilesystemStorageService(
33
+ basePath,
34
+ origin && signingSecret ? { origin, signingSecret } : undefined,
35
+ );
20
36
  };
@@ -0,0 +1,86 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+
3
+ /**
4
+ * The HMAC scheme behind every signed storage URL this stack mints.
5
+ *
6
+ * It started as the read side — `/content/*` is served against a signature
7
+ * because a bearer token cannot ride on an `<img src>` inside email HTML — and
8
+ * the write side needs exactly the same capability: a URL that grants access to
9
+ * one path, for a while, and is unforgeable without the key. Rather than a
10
+ * second scheme, both use this one with a different derivation label, so a
11
+ * signature minted to read content can never be replayed as permission to
12
+ * write, and vice versa.
13
+ *
14
+ * The signed message is the storage path followed by every claim in a fixed
15
+ * order. Because the path is part of the message, a signature minted for one
16
+ * account's object cannot be presented for another's.
17
+ */
18
+
19
+ const deriveSigningKey = (masterSecret: string, label: string): Buffer =>
20
+ createHmac("sha256", masterSecret).update(label).digest();
21
+
22
+ const canonicalMessage = (
23
+ relativePath: string,
24
+ claims: readonly number[],
25
+ ): string => [relativePath, ...claims].join("\n");
26
+
27
+ const computeSignature = (
28
+ key: Buffer,
29
+ relativePath: string,
30
+ claims: readonly number[],
31
+ ): string =>
32
+ createHmac("sha256", key)
33
+ .update(canonicalMessage(relativePath, claims))
34
+ .digest("base64url");
35
+
36
+ export const signStoragePath = (
37
+ masterSecret: string,
38
+ label: string,
39
+ relativePath: string,
40
+ claims: readonly number[],
41
+ ): string =>
42
+ computeSignature(deriveSigningKey(masterSecret, label), relativePath, claims);
43
+
44
+ export type SignedPathFailure =
45
+ | "missing"
46
+ | "malformed"
47
+ | "expired"
48
+ | "bad-signature";
49
+
50
+ export type SignedPathResult =
51
+ | { valid: true }
52
+ | { valid: false; reason: SignedPathFailure };
53
+
54
+ /**
55
+ * Verify a presented signature against the path and claims it must cover. The
56
+ * first claim is always the expiry, in unix seconds. Pure, so the decision can
57
+ * be unit-tested without a live server.
58
+ */
59
+ export const verifyStoragePath = (
60
+ masterSecret: string,
61
+ label: string,
62
+ relativePath: string,
63
+ claims: readonly number[],
64
+ sig: string | undefined,
65
+ nowSeconds: number,
66
+ ): SignedPathResult => {
67
+ if (!sig || claims.length === 0) return { valid: false, reason: "missing" };
68
+
69
+ const [exp] = claims;
70
+ if (!claims.every((claim) => Number.isInteger(claim) && claim > 0)) {
71
+ return { valid: false, reason: "malformed" };
72
+ }
73
+ if (exp < nowSeconds) return { valid: false, reason: "expired" };
74
+
75
+ const expected = Buffer.from(
76
+ signStoragePath(masterSecret, label, relativePath, claims),
77
+ );
78
+ const presented = Buffer.from(sig);
79
+ if (expected.length !== presented.length) {
80
+ return { valid: false, reason: "bad-signature" };
81
+ }
82
+ if (!timingSafeEqual(expected, presented)) {
83
+ return { valid: false, reason: "bad-signature" };
84
+ }
85
+ return { valid: true };
86
+ };
@@ -14,6 +14,8 @@ import {
14
14
  buildExtractedSkippedKey,
15
15
  buildExtractedTextKey,
16
16
  buildMessageBodyKey,
17
+ buildOutboxAttachmentKey,
18
+ buildOutboxAttachmentPrefix,
17
19
  buildParsedBodyKey,
18
20
  computeChecksum,
19
21
  createMockStorageService,
@@ -85,6 +87,26 @@ describe("path builders", () => {
85
87
  );
86
88
  });
87
89
 
90
+ test("buildOutboxAttachmentKey hangs the file off its own draft, under the same tenant prefix", () => {
91
+ const key = buildOutboxAttachmentKey("cfg1", "acc123", "draft9", "att7");
92
+ assert.strictEqual(
93
+ key,
94
+ "accounts/cfg1/acc123/outbox/draft9/attachments/att7",
95
+ );
96
+ assert.ok(
97
+ key.startsWith(buildOutboxAttachmentPrefix("cfg1", "acc123", "draft9")),
98
+ );
99
+ });
100
+
101
+ test("parseContentStorageKey does not recognise an outbox attachment key", () => {
102
+ assert.strictEqual(
103
+ parseContentStorageKey(
104
+ buildOutboxAttachmentKey("cfg1", "acc123", "draft9", "att7"),
105
+ ),
106
+ null,
107
+ );
108
+ });
109
+
88
110
  test("computeChecksum returns SHA-256 hex", () => {
89
111
  const content = Buffer.from("test content");
90
112
  const expected = createHash("sha256").update(content).digest("hex");
@@ -744,6 +766,120 @@ describe("createFilesystemStorageService", () => {
744
766
  assert.deepStrictEqual(items, []);
745
767
  });
746
768
 
769
+ test("storeOutboxAttachment writes the bytes uncompressed so the size on disk is the size the cap counts", async () => {
770
+ const storage = createFilesystemStorageService(testBasePath);
771
+ // Highly compressible: gzip would make the stored size disagree with the
772
+ // decoded size the per-message cap is expressed in.
773
+ const content = Buffer.alloc(4096, 0x61);
774
+
775
+ const ref = await storage.storeOutboxAttachment({
776
+ accountConfigId: "cfg1",
777
+ accountId: "acc123",
778
+ outboxMessageId: "draft1",
779
+ outboxAttachmentId: "att1",
780
+ content,
781
+ });
782
+
783
+ assert.strictEqual(ref.contentEncoding, ContentEncoding.None);
784
+ assert.strictEqual(ref.sizeBytes, content.length);
785
+ assert.strictEqual(ref.checksumSha256, computeChecksum(content));
786
+ assert.deepStrictEqual(await storage.retrieve(ref.uri), content);
787
+
788
+ const [item] = await storage.listOutboxAttachments(
789
+ "cfg1",
790
+ "acc123",
791
+ "draft1",
792
+ );
793
+ assert.strictEqual(item.sizeBytes, content.length);
794
+ });
795
+
796
+ test("listOutboxAttachments returns only the named draft, and every object in it", async () => {
797
+ const storage = createFilesystemStorageService(testBasePath);
798
+ for (const id of ["a", "b", "c"]) {
799
+ await storage.storeOutboxAttachment({
800
+ accountConfigId: "cfg1",
801
+ accountId: "acc123",
802
+ outboxMessageId: "draft-many",
803
+ outboxAttachmentId: id,
804
+ content: Buffer.from(`file-${id}`),
805
+ });
806
+ }
807
+ await storage.storeOutboxAttachment({
808
+ accountConfigId: "cfg1",
809
+ accountId: "acc123",
810
+ outboxMessageId: "draft-other",
811
+ outboxAttachmentId: "d",
812
+ content: Buffer.from("elsewhere"),
813
+ });
814
+
815
+ const all = await storage.listOutboxAttachments(
816
+ "cfg1",
817
+ "acc123",
818
+ "draft-many",
819
+ );
820
+ assert.deepStrictEqual(all.map((item) => item.outboxAttachmentId).sort(), [
821
+ "a",
822
+ "b",
823
+ "c",
824
+ ]);
825
+ });
826
+
827
+ test("listOutboxAttachments returns an empty array for a draft that has none", async () => {
828
+ const storage = createFilesystemStorageService(testBasePath);
829
+ assert.deepStrictEqual(
830
+ await storage.listOutboxAttachments("cfg1", "acc123", "draft-empty"),
831
+ [],
832
+ );
833
+ });
834
+
835
+ test("deleting a draft's attachments empties the prefix and the ledger with it", async () => {
836
+ const storage = createFilesystemStorageService(testBasePath);
837
+ await storage.storeOutboxAttachment({
838
+ accountConfigId: "cfg1",
839
+ accountId: "acc123",
840
+ outboxMessageId: "draft-gone",
841
+ outboxAttachmentId: "att1",
842
+ content: Buffer.alloc(4),
843
+ });
844
+ await storage.deleteOutboxAttachments("cfg1", "acc123", "draft-gone");
845
+
846
+ assert.deepStrictEqual(
847
+ await storage.listOutboxAttachments("cfg1", "acc123", "draft-gone"),
848
+ [],
849
+ );
850
+ });
851
+
852
+ test("listOutboxDraftsWithAttachments finds prefixes whose row may be long gone", async () => {
853
+ const storage = createFilesystemStorageService(testBasePath);
854
+ for (const draft of ["draft-x", "draft-y"]) {
855
+ await storage.storeOutboxAttachment({
856
+ accountConfigId: "cfg-sweep",
857
+ accountId: "acc-sweep",
858
+ outboxMessageId: draft,
859
+ outboxAttachmentId: "att1",
860
+ content: Buffer.alloc(4),
861
+ });
862
+ }
863
+
864
+ assert.deepStrictEqual(
865
+ (
866
+ await storage.listOutboxDraftsWithAttachments("cfg-sweep", "acc-sweep")
867
+ ).sort(),
868
+ ["draft-x", "draft-y"],
869
+ );
870
+ });
871
+
872
+ test("deleting attachments on a draft that never had any is quiet", async () => {
873
+ const storage = createFilesystemStorageService(testBasePath);
874
+ await storage.deleteOutboxAttachments("cfg1", "acc123", "draft-never");
875
+ await storage.deleteOutboxAttachment(
876
+ "cfg1",
877
+ "acc123",
878
+ "draft-never",
879
+ "att1",
880
+ );
881
+ });
882
+
747
883
  test("cleanup test directory", async () => {
748
884
  await rm(testBasePath, { recursive: true, force: true });
749
885
  });