@powerhousedao/switchboard 6.2.3-dev.5 → 6.2.3-dev.50
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/.tsbuild/test/attachments/byte-grants.test.d.ts +2 -0
- package/.tsbuild/test/attachments/byte-grants.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts +2 -0
- package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/reserve-metadata-parity.test.d.ts +2 -0
- package/.tsbuild/test/attachments/reserve-metadata-parity.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/url-signer.test.d.ts +2 -0
- package/.tsbuild/test/attachments/url-signer.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-create-signature-policy.test.d.ts +2 -0
- package/.tsbuild/test/boot-create-signature-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-mcp-enabled.test.d.ts +2 -0
- package/.tsbuild/test/boot-mcp-enabled.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-privacy.test.d.ts +2 -0
- package/.tsbuild/test/boot-privacy.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts +2 -0
- package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-without-renown.test.d.ts +2 -0
- package/.tsbuild/test/boot-without-renown.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-without-require-signatures.test.d.ts +2 -0
- package/.tsbuild/test/boot-without-require-signatures.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-worker-trust-policy.test.d.ts +2 -0
- package/.tsbuild/test/boot-worker-trust-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/builder-defaults.test.d.ts +2 -0
- package/.tsbuild/test/builder-defaults.test.d.ts.map +1 -0
- package/.tsbuild/test/create-signature-policy.test.d.ts +2 -0
- package/.tsbuild/test/create-signature-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/durable-node-fs-pg16.test.d.ts +2 -0
- package/.tsbuild/test/durable-node-fs-pg16.test.d.ts.map +1 -0
- package/.tsbuild/test/fatal-shutdown.test.d.ts +2 -0
- package/.tsbuild/test/fatal-shutdown.test.d.ts.map +1 -0
- package/.tsbuild/test/mcp-flag.test.d.ts +2 -0
- package/.tsbuild/test/mcp-flag.test.d.ts.map +1 -0
- package/.tsbuild/test/observability-signals.test.d.ts +2 -0
- package/.tsbuild/test/observability-signals.test.d.ts.map +1 -0
- package/.tsbuild/test/pglite-preflight.test.d.ts +2 -0
- package/.tsbuild/test/pglite-preflight.test.d.ts.map +1 -0
- package/.tsbuild/test/renown-trust-policy.test.d.ts +2 -0
- package/.tsbuild/test/renown-trust-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/snapshot-writer.d.ts +18 -0
- package/.tsbuild/test/snapshot-writer.d.ts.map +1 -0
- package/.tsbuild/test/unsupported-stored-documents.test.d.ts +2 -0
- package/.tsbuild/test/unsupported-stored-documents.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/harness.d.ts +56 -0
- package/.tsbuild/test/workflow/harness.d.ts.map +1 -0
- package/.tsbuild/test/workflow/oauth.test.d.ts +2 -0
- package/.tsbuild/test/workflow/oauth.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/resolvers.test.d.ts +2 -0
- package/.tsbuild/test/workflow/resolvers.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-signed.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-signed.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-reactor-access.test.d.ts +2 -0
- package/.tsbuild/test/workflow-reactor-access.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts +2 -0
- package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-read-gate.test.d.ts +2 -0
- package/.tsbuild/test/workflow-read-gate.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-runtime.test.d.ts +2 -0
- package/.tsbuild/test/workflow-runtime.test.d.ts.map +1 -0
- package/.tsbuild/tsconfig.tsbuildinfo +1 -1
- package/.tsbuild/tsdown.config.d.ts.map +1 -1
- package/.tsbuild/vitest.config.d.ts.map +1 -1
- package/Auth.md +94 -0
- package/CHANGELOG.md +509 -0
- package/README.md +35 -0
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +48 -49
- package/dist/index.mjs.map +1 -1
- package/dist/install-packages.d.mts +2 -1
- package/dist/install-packages.mjs +2 -2
- package/dist/install-packages.mjs.map +1 -1
- package/dist/migrate.d.mts +2 -1
- package/dist/migrate.mjs +4 -3
- package/dist/migrate.mjs.map +1 -1
- package/dist/server-Zz7SqTwO.mjs +3335 -0
- package/dist/server-Zz7SqTwO.mjs.map +1 -0
- package/dist/server.d.mts +154 -30
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +4 -5
- package/dist/{utils-Baw7rThP.mjs → utils-uaBlzyjS.mjs} +15 -9
- package/dist/utils-uaBlzyjS.mjs.map +1 -0
- package/dist/utils.d.mts +4 -5
- package/dist/utils.d.mts.map +1 -1
- package/dist/utils.mjs +3 -3
- package/package.json +22 -18
- package/test/attachment-reference-read-model.test.ts +6 -1
- package/test/attachments/auth.test.ts +85 -0
- package/test/attachments/byte-grants.test.ts +290 -0
- package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
- package/test/attachments/download-target.test.ts +201 -32
- package/test/attachments/index.test.ts +34 -3
- package/test/attachments/reserve-metadata-parity.test.ts +96 -0
- package/test/attachments/routes-integration.test.ts +60 -4
- package/test/attachments/routes.test.ts +166 -23
- package/test/attachments/service-config.test.ts +13 -3
- package/test/attachments/url-signer.test.ts +105 -0
- package/test/boot-create-signature-policy.test.ts +108 -0
- package/test/boot-mcp-enabled.test.ts +121 -0
- package/test/boot-privacy.test.ts +243 -0
- package/test/boot-unsupported-stored-documents.test.ts +213 -0
- package/test/boot-without-renown.test.ts +81 -0
- package/test/boot-without-require-signatures.test.ts +74 -0
- package/test/boot-worker-trust-policy.test.ts +90 -0
- package/test/builder-defaults.test.ts +80 -0
- package/test/create-signature-policy.test.ts +69 -0
- package/test/durable-node-fs-pg16.test.ts +80 -0
- package/test/fatal-shutdown.test.ts +165 -0
- package/test/mcp-flag.test.ts +56 -0
- package/test/observability-signals.test.ts +131 -0
- package/test/pglite-dialect.test.ts +159 -2
- package/test/pglite-preflight.test.ts +313 -0
- package/test/pglite-version.test.ts +9 -1
- package/test/renown-trust-policy.test.ts +315 -0
- package/test/snapshot-writer.ts +94 -0
- package/test/unsupported-stored-documents.test.ts +72 -0
- package/test/worker-pool.test.ts +27 -1
- package/test/workflow/harness.ts +289 -0
- package/test/workflow/oauth.test.ts +214 -0
- package/test/workflow/resolvers.test.ts +322 -0
- package/test/workflow/webhook-dedupe.test.ts +295 -0
- package/test/workflow/webhook-signed.test.ts +509 -0
- package/test/workflow/webhook-trigger-test.test.ts +82 -0
- package/test/workflow/webhook-unsigned.test.ts +345 -0
- package/test/workflow-reactor-access.test.ts +384 -0
- package/test/workflow-reactor-enforcement.test.ts +805 -0
- package/test/workflow-read-gate.test.ts +210 -0
- package/test/workflow-runtime.test.ts +572 -0
- package/tsconfig.json +9 -3
- package/tsdown.config.ts +3 -1
- package/dist/server-CiKkVE6g.mjs +0 -1555
- package/dist/server-CiKkVE6g.mjs.map +0 -1
- package/dist/utils-Baw7rThP.mjs.map +0 -1
|
@@ -9,15 +9,24 @@ import type {
|
|
|
9
9
|
IAttachmentAccessService,
|
|
10
10
|
} from "@powerhousedao/reactor-api";
|
|
11
11
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
12
|
-
import { describe, expect, it, vi } from "vitest";
|
|
12
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
13
13
|
import type { AttachmentActorContext } from "../../src/attachments/auth.js";
|
|
14
14
|
import { registerAttachmentRoutes } from "../../src/attachments/index.js";
|
|
15
15
|
import { makeDownloadTargetHandler } from "../../src/attachments/routes.js";
|
|
16
|
+
import { AttachmentUrlSigner } from "../../src/attachments/url-signer.js";
|
|
16
17
|
|
|
17
18
|
const HASH = "a".repeat(64) as AttachmentHash;
|
|
18
19
|
const REF = `attachment://v1:${HASH}`;
|
|
19
20
|
const DOC_ID = "doc-1";
|
|
20
21
|
const EXPIRES = "2026-07-23T00:00:00.000Z";
|
|
22
|
+
const NOW_MS = Date.parse("2026-07-22T00:00:00.000Z");
|
|
23
|
+
const SIGNER = new AttachmentUrlSigner("s".repeat(32), () => NOW_MS);
|
|
24
|
+
|
|
25
|
+
function signedUrlParts(body: string) {
|
|
26
|
+
const target = JSON.parse(body) as { url: string; expiresAtUtc: string };
|
|
27
|
+
const url = new URL(target.url);
|
|
28
|
+
return { target, url, params: url.searchParams };
|
|
29
|
+
}
|
|
21
30
|
|
|
22
31
|
const ACTOR: AttachmentActorContext = {
|
|
23
32
|
user: {
|
|
@@ -82,7 +91,13 @@ function makeAccess(result: AttachmentAccessResult): {
|
|
|
82
91
|
spy: ReturnType<typeof vi.fn>;
|
|
83
92
|
} {
|
|
84
93
|
const spy = vi.fn().mockResolvedValue(result);
|
|
85
|
-
return {
|
|
94
|
+
return {
|
|
95
|
+
access: {
|
|
96
|
+
canReadAttachment: spy,
|
|
97
|
+
admitCaller: () => Promise.resolve({ kind: "admitted" }),
|
|
98
|
+
},
|
|
99
|
+
spy,
|
|
100
|
+
};
|
|
86
101
|
}
|
|
87
102
|
|
|
88
103
|
function makeAttachments(opts: {
|
|
@@ -106,27 +121,118 @@ const ALLOWED: AttachmentAccessResult = {
|
|
|
106
121
|
};
|
|
107
122
|
|
|
108
123
|
describe("makeDownloadTargetHandler", () => {
|
|
109
|
-
it("returns a switchboard target for filesystem with no-store", async () => {
|
|
124
|
+
it("returns a signed switchboard target for filesystem with no-store", async () => {
|
|
110
125
|
const { access, spy } = makeAccess(ALLOWED);
|
|
111
126
|
const attachments = makeAttachments({});
|
|
112
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
127
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
113
128
|
const res = makeRes();
|
|
114
129
|
|
|
115
130
|
await handler(makeReq({}), res, undefined, ACTOR);
|
|
116
131
|
|
|
117
132
|
expect(res.statusCode).toBe(200);
|
|
118
133
|
expect(res._headers["cache-control"]).toBe("no-store");
|
|
119
|
-
|
|
134
|
+
const { target, url, params } = signedUrlParts(res._body);
|
|
135
|
+
expect(target).toMatchObject({
|
|
120
136
|
kind: "switchboard",
|
|
121
137
|
method: "GET",
|
|
122
|
-
url: `http://sb.example.com/attachments/${HASH}`,
|
|
123
138
|
headers: {},
|
|
139
|
+
expiresAtUtc: new Date(NOW_MS + 300_000).toISOString(),
|
|
124
140
|
});
|
|
141
|
+
expect(`${url.origin}${url.pathname}`).toBe(
|
|
142
|
+
`http://sb.example.com/attachments/${HASH}`,
|
|
143
|
+
);
|
|
144
|
+
expect(params.get("documentId")).toBe(DOC_ID);
|
|
145
|
+
expect(
|
|
146
|
+
SIGNER.verify(
|
|
147
|
+
HASH,
|
|
148
|
+
DOC_ID,
|
|
149
|
+
params.get("expires")!,
|
|
150
|
+
params.get("signature")!,
|
|
151
|
+
),
|
|
152
|
+
).toBe(true);
|
|
125
153
|
expect(spy).toHaveBeenCalledWith({
|
|
126
154
|
documentId: DOC_ID,
|
|
127
155
|
attachmentRef: REF,
|
|
128
156
|
userAddress: "0xverified",
|
|
157
|
+
appKey: "did:key:zAppTest",
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("signs for the requested expiresIn, clamped to the download-target ceiling", async () => {
|
|
162
|
+
const { access } = makeAccess(ALLOWED);
|
|
163
|
+
const handler = makeDownloadTargetHandler(
|
|
164
|
+
makeAttachments({}),
|
|
165
|
+
access,
|
|
166
|
+
SIGNER,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
for (const [requested, granted] of [
|
|
170
|
+
[60, 60],
|
|
171
|
+
[3600, 300],
|
|
172
|
+
[99999999, 300],
|
|
173
|
+
]) {
|
|
174
|
+
const res = makeRes();
|
|
175
|
+
await handler(
|
|
176
|
+
makeReq({
|
|
177
|
+
url: `/attachments/${HASH}/download-target?documentId=${DOC_ID}&expiresIn=${requested}`,
|
|
178
|
+
}),
|
|
179
|
+
res,
|
|
180
|
+
undefined,
|
|
181
|
+
ACTOR,
|
|
182
|
+
);
|
|
183
|
+
expect(signedUrlParts(res._body).target.expiresAtUtc).toBe(
|
|
184
|
+
new Date(NOW_MS + granted * 1000).toISOString(),
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("signs the canonical document id the access decision resolved", async () => {
|
|
190
|
+
const { access } = makeAccess({ ...ALLOWED, documentId: "canon" as never });
|
|
191
|
+
const handler = makeDownloadTargetHandler(
|
|
192
|
+
makeAttachments({}),
|
|
193
|
+
access,
|
|
194
|
+
SIGNER,
|
|
195
|
+
);
|
|
196
|
+
const res = makeRes();
|
|
197
|
+
|
|
198
|
+
await handler(makeReq({}), res, undefined, ACTOR);
|
|
199
|
+
|
|
200
|
+
expect(signedUrlParts(res._body).params.get("documentId")).toBe("canon");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("fails closed with 503 on filesystem when no signer is configured", async () => {
|
|
204
|
+
const { access } = makeAccess(ALLOWED);
|
|
205
|
+
const handler = makeDownloadTargetHandler(
|
|
206
|
+
makeAttachments({}),
|
|
207
|
+
access,
|
|
208
|
+
null,
|
|
209
|
+
);
|
|
210
|
+
const res = makeRes();
|
|
211
|
+
|
|
212
|
+
await handler(makeReq({}), res, undefined, ACTOR);
|
|
213
|
+
|
|
214
|
+
expect(res.statusCode).toBe(503);
|
|
215
|
+
expect(res._body).not.toContain("/attachments/");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("needs no signer for S3 presigned targets", async () => {
|
|
219
|
+
const { access } = makeAccess(ALLOWED);
|
|
220
|
+
const prepareDownloadTarget = vi.fn().mockResolvedValue({
|
|
221
|
+
kind: "presigned-get",
|
|
222
|
+
method: "GET",
|
|
223
|
+
url: "https://bucket.example.com/attachments/aa?sig=1",
|
|
224
|
+
headers: {},
|
|
225
|
+
expiresAtUtc: EXPIRES,
|
|
226
|
+
});
|
|
227
|
+
const attachments = makeAttachments({
|
|
228
|
+
backend: { kind: "s3", prepareDownloadTarget } as never,
|
|
129
229
|
});
|
|
230
|
+
const handler = makeDownloadTargetHandler(attachments, access, null);
|
|
231
|
+
const res = makeRes();
|
|
232
|
+
|
|
233
|
+
await handler(makeReq({}), res, undefined, ACTOR);
|
|
234
|
+
|
|
235
|
+
expect(res.statusCode).toBe(200);
|
|
130
236
|
});
|
|
131
237
|
|
|
132
238
|
it("returns the backend presigned-get target in S3 mode", async () => {
|
|
@@ -141,14 +247,14 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
141
247
|
const attachments = makeAttachments({
|
|
142
248
|
backend: { kind: "s3", prepareDownloadTarget } as never,
|
|
143
249
|
});
|
|
144
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
250
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
145
251
|
const res = makeRes();
|
|
146
252
|
|
|
147
253
|
await handler(makeReq({}), res, undefined, ACTOR);
|
|
148
254
|
|
|
149
255
|
expect(res.statusCode).toBe(200);
|
|
150
256
|
expect(JSON.parse(res._body)).toMatchObject({ kind: "presigned-get" });
|
|
151
|
-
expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH,
|
|
257
|
+
expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH, 300);
|
|
152
258
|
});
|
|
153
259
|
|
|
154
260
|
it("passes a requested expiresIn through to the backend presigner", async () => {
|
|
@@ -163,12 +269,12 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
163
269
|
const attachments = makeAttachments({
|
|
164
270
|
backend: { kind: "s3", prepareDownloadTarget } as never,
|
|
165
271
|
});
|
|
166
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
272
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
167
273
|
const res = makeRes();
|
|
168
274
|
|
|
169
275
|
await handler(
|
|
170
276
|
makeReq({
|
|
171
|
-
url: `/attachments/${HASH}/download-target?documentId=${DOC_ID}&expiresIn=
|
|
277
|
+
url: `/attachments/${HASH}/download-target?documentId=${DOC_ID}&expiresIn=60`,
|
|
172
278
|
}),
|
|
173
279
|
res,
|
|
174
280
|
undefined,
|
|
@@ -176,10 +282,10 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
176
282
|
);
|
|
177
283
|
|
|
178
284
|
expect(res.statusCode).toBe(200);
|
|
179
|
-
expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH,
|
|
285
|
+
expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH, 60);
|
|
180
286
|
});
|
|
181
287
|
|
|
182
|
-
it("clamps expiresIn to the
|
|
288
|
+
it("clamps a requested expiresIn to the ceiling", async () => {
|
|
183
289
|
const { access } = makeAccess(ALLOWED);
|
|
184
290
|
const prepareDownloadTarget = vi.fn().mockResolvedValue({
|
|
185
291
|
kind: "presigned-get",
|
|
@@ -191,7 +297,7 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
191
297
|
const attachments = makeAttachments({
|
|
192
298
|
backend: { kind: "s3", prepareDownloadTarget } as never,
|
|
193
299
|
});
|
|
194
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
300
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
195
301
|
const res = makeRes();
|
|
196
302
|
|
|
197
303
|
await handler(
|
|
@@ -203,10 +309,10 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
203
309
|
ACTOR,
|
|
204
310
|
);
|
|
205
311
|
|
|
206
|
-
expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH,
|
|
312
|
+
expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH, 300);
|
|
207
313
|
});
|
|
208
314
|
|
|
209
|
-
it("
|
|
315
|
+
it("applies the ceiling when expiresIn is absent, rather than the backend default", async () => {
|
|
210
316
|
const { access } = makeAccess(ALLOWED);
|
|
211
317
|
const prepareDownloadTarget = vi.fn().mockResolvedValue({
|
|
212
318
|
kind: "presigned-get",
|
|
@@ -218,11 +324,73 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
218
324
|
const attachments = makeAttachments({
|
|
219
325
|
backend: { kind: "s3", prepareDownloadTarget } as never,
|
|
220
326
|
});
|
|
221
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
327
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
222
328
|
|
|
223
329
|
await handler(makeReq({}), makeRes(), undefined, ACTOR);
|
|
224
330
|
|
|
225
|
-
expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH,
|
|
331
|
+
expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH, 300);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe("ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS", () => {
|
|
335
|
+
const ORIGINAL = process.env.ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS;
|
|
336
|
+
|
|
337
|
+
afterEach(() => {
|
|
338
|
+
if (ORIGINAL === undefined) {
|
|
339
|
+
delete process.env.ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS;
|
|
340
|
+
} else {
|
|
341
|
+
process.env.ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS = ORIGINAL;
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
async function ttlFor(configured: string | undefined, query = "") {
|
|
346
|
+
if (configured === undefined) {
|
|
347
|
+
delete process.env.ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS;
|
|
348
|
+
} else {
|
|
349
|
+
process.env.ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS = configured;
|
|
350
|
+
}
|
|
351
|
+
const { access } = makeAccess(ALLOWED);
|
|
352
|
+
const prepareDownloadTarget = vi.fn().mockResolvedValue({
|
|
353
|
+
kind: "presigned-get",
|
|
354
|
+
method: "GET",
|
|
355
|
+
url: "https://bucket.example.com/attachments/aa?sig=1",
|
|
356
|
+
headers: {},
|
|
357
|
+
expiresAtUtc: EXPIRES,
|
|
358
|
+
});
|
|
359
|
+
const attachments = makeAttachments({
|
|
360
|
+
backend: { kind: "s3", prepareDownloadTarget } as never,
|
|
361
|
+
});
|
|
362
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
363
|
+
await handler(
|
|
364
|
+
makeReq({
|
|
365
|
+
url: `/attachments/${HASH}/download-target?documentId=${DOC_ID}${query}`,
|
|
366
|
+
}),
|
|
367
|
+
makeRes(),
|
|
368
|
+
undefined,
|
|
369
|
+
ACTOR,
|
|
370
|
+
);
|
|
371
|
+
return (prepareDownloadTarget.mock.calls[0] as unknown[])[1];
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
it("raises the ceiling when a deployment configures one", async () => {
|
|
375
|
+
await expect(ttlFor("1800")).resolves.toBe(1800);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("never lets a configured ceiling exceed the signing maximum", async () => {
|
|
379
|
+
await expect(ttlFor("99999999")).resolves.toBe(604800);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it.each(["not-a-number", "0", "-5", "12.5"])(
|
|
383
|
+
"falls back to the default, not the maximum, on %s",
|
|
384
|
+
async (configured) => {
|
|
385
|
+
// A typo must narrow nothing and widen nothing: falling back to the
|
|
386
|
+
// signing ceiling would turn a misconfiguration into a week-long URL.
|
|
387
|
+
await expect(ttlFor(configured)).resolves.toBe(300);
|
|
388
|
+
},
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
it("still clamps a caller's request to the configured ceiling", async () => {
|
|
392
|
+
await expect(ttlFor("1800", "&expiresIn=99999")).resolves.toBe(1800);
|
|
393
|
+
});
|
|
226
394
|
});
|
|
227
395
|
|
|
228
396
|
it.each([
|
|
@@ -234,7 +402,7 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
234
402
|
])("rejects a %s expiresIn with 400 before authorization", async (_, qs) => {
|
|
235
403
|
const { access, spy } = makeAccess(ALLOWED);
|
|
236
404
|
const attachments = makeAttachments({});
|
|
237
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
405
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
238
406
|
const res = makeRes();
|
|
239
407
|
|
|
240
408
|
await handler(
|
|
@@ -253,7 +421,7 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
253
421
|
it("normalizes an uppercase path hash before authorization and lookup", async () => {
|
|
254
422
|
const { access, spy } = makeAccess(ALLOWED);
|
|
255
423
|
const attachments = makeAttachments({});
|
|
256
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
424
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
257
425
|
const res = makeRes();
|
|
258
426
|
|
|
259
427
|
await handler(makeReq({ hash: HASH.toUpperCase() }), res, undefined, ACTOR);
|
|
@@ -268,7 +436,7 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
268
436
|
it("rejects an invalid hash before any access or storage call", async () => {
|
|
269
437
|
const { access, spy } = makeAccess(ALLOWED);
|
|
270
438
|
const attachments = makeAttachments({});
|
|
271
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
439
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
272
440
|
const res = makeRes();
|
|
273
441
|
|
|
274
442
|
await handler(makeReq({ hash: "nothex" }), res, undefined, ACTOR);
|
|
@@ -292,7 +460,7 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
292
460
|
])("rejects a %s documentId before authorization", async (_, url) => {
|
|
293
461
|
const { access, spy } = makeAccess(ALLOWED);
|
|
294
462
|
const attachments = makeAttachments({});
|
|
295
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
463
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
296
464
|
const res = makeRes();
|
|
297
465
|
|
|
298
466
|
await handler(makeReq({ url }), res, undefined, ACTOR);
|
|
@@ -307,7 +475,7 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
307
475
|
const attachments = makeAttachments({
|
|
308
476
|
backend: { kind: "s3", prepareDownloadTarget } as never,
|
|
309
477
|
});
|
|
310
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
478
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
311
479
|
const res = makeRes();
|
|
312
480
|
|
|
313
481
|
await handler(makeReq({}), res, undefined, ACTOR);
|
|
@@ -321,7 +489,7 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
321
489
|
it("maps projection-unavailable to a generic non-cacheable 503 before storage", async () => {
|
|
322
490
|
const { access } = makeAccess({ kind: "projection-unavailable" });
|
|
323
491
|
const attachments = makeAttachments({});
|
|
324
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
492
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
325
493
|
const res = makeRes();
|
|
326
494
|
|
|
327
495
|
await handler(makeReq({}), res, undefined, ACTOR);
|
|
@@ -337,7 +505,7 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
337
505
|
const { AttachmentNotFound } =
|
|
338
506
|
await import("@powerhousedao/reactor-attachments");
|
|
339
507
|
attachments.statSpy.mockRejectedValue(new AttachmentNotFound(HASH));
|
|
340
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
508
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
341
509
|
const res = makeRes();
|
|
342
510
|
|
|
343
511
|
await handler(makeReq({}), res, undefined, ACTOR);
|
|
@@ -351,7 +519,7 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
351
519
|
const attachments = makeAttachments({
|
|
352
520
|
stat: () => Promise.resolve({ ...AVAILABLE_HEADER, status: "pending" }),
|
|
353
521
|
});
|
|
354
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
522
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
355
523
|
const res = makeRes();
|
|
356
524
|
|
|
357
525
|
await handler(makeReq({}), res, undefined, ACTOR);
|
|
@@ -371,7 +539,7 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
371
539
|
const attachments = makeAttachments({
|
|
372
540
|
backend: { kind: "s3", prepareDownloadTarget } as never,
|
|
373
541
|
});
|
|
374
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
542
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
375
543
|
const res = makeRes();
|
|
376
544
|
|
|
377
545
|
await handler(makeReq({}), res, undefined, ACTOR);
|
|
@@ -384,7 +552,7 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
384
552
|
it("passes an anonymous actor as an undefined address (OPEN mode)", async () => {
|
|
385
553
|
const { access, spy } = makeAccess(ALLOWED);
|
|
386
554
|
const attachments = makeAttachments({});
|
|
387
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
555
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
388
556
|
const res = makeRes();
|
|
389
557
|
|
|
390
558
|
await handler(makeReq({}), res, undefined, {
|
|
@@ -400,7 +568,7 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
400
568
|
it("honours x-forwarded proto/host when building the switchboard target", async () => {
|
|
401
569
|
const { access } = makeAccess(ALLOWED);
|
|
402
570
|
const attachments = makeAttachments({});
|
|
403
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
571
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
404
572
|
const res = makeRes();
|
|
405
573
|
|
|
406
574
|
await handler(
|
|
@@ -415,9 +583,10 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
415
583
|
ACTOR,
|
|
416
584
|
);
|
|
417
585
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
586
|
+
const { url } = signedUrlParts(res._body);
|
|
587
|
+
expect(`${url.origin}${url.pathname}`).toBe(
|
|
588
|
+
`https://public.example.com/attachments/${HASH}`,
|
|
589
|
+
);
|
|
421
590
|
});
|
|
422
591
|
});
|
|
423
592
|
|
|
@@ -435,7 +604,7 @@ describe("registerAttachmentRoutes inventory", () => {
|
|
|
435
604
|
attachmentAccess: { canReadAttachment: vi.fn() },
|
|
436
605
|
} as unknown as API;
|
|
437
606
|
|
|
438
|
-
registerAttachmentRoutes(api);
|
|
607
|
+
registerAttachmentRoutes(api, { urlSigner: null });
|
|
439
608
|
|
|
440
609
|
expect(captured).toEqual([
|
|
441
610
|
{ method: "POST", path: "/attachments/reservations" },
|
|
@@ -13,8 +13,16 @@ type Captured = {
|
|
|
13
13
|
) => void | Promise<void>;
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
type FakeApi = Pick<
|
|
17
|
+
API,
|
|
18
|
+
"httpAdapter" | "authService" | "requireAuthenticatedCaller"
|
|
19
|
+
>;
|
|
20
|
+
|
|
21
|
+
function makeFakeApi(
|
|
22
|
+
authService: AuthService | undefined,
|
|
23
|
+
requireAuthenticatedCaller = false,
|
|
24
|
+
): {
|
|
25
|
+
api: FakeApi;
|
|
18
26
|
captured: Captured[];
|
|
19
27
|
} {
|
|
20
28
|
const captured: Captured[] = [];
|
|
@@ -29,7 +37,8 @@ function makeFakeApi(authService: AuthService | undefined): {
|
|
|
29
37
|
},
|
|
30
38
|
},
|
|
31
39
|
authService,
|
|
32
|
-
|
|
40
|
+
requireAuthenticatedCaller,
|
|
41
|
+
} as unknown as FakeApi;
|
|
33
42
|
return { api, captured };
|
|
34
43
|
}
|
|
35
44
|
|
|
@@ -138,6 +147,28 @@ describe("mountAuthenticatedNodeRoute", () => {
|
|
|
138
147
|
});
|
|
139
148
|
});
|
|
140
149
|
|
|
150
|
+
it("carries the deployment's authenticated-caller floor onto the route, over allowAnonymous", async () => {
|
|
151
|
+
// auth_enabled false is the shape a deployment running the document
|
|
152
|
+
// policies has: the boundary policy is OPEN, and this floor is what keeps
|
|
153
|
+
// an anonymous caller off the attachment routes.
|
|
154
|
+
const verifyBearer = vi.fn(() =>
|
|
155
|
+
Promise.resolve({ user: undefined, admins: [], auth_enabled: false }),
|
|
156
|
+
);
|
|
157
|
+
const authService = { verifyBearer } as unknown as AuthService;
|
|
158
|
+
const { api, captured } = makeFakeApi(authService, true);
|
|
159
|
+
const inner = vi.fn();
|
|
160
|
+
|
|
161
|
+
mountAuthenticatedNodeRoute(api, "GET", "/x", inner, {
|
|
162
|
+
allowAnonymous: true,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const res = makeRes();
|
|
166
|
+
await captured[0].handler(makeReq(), res);
|
|
167
|
+
|
|
168
|
+
expect(res.statusCode).toBe(401);
|
|
169
|
+
expect(inner).not.toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
|
|
141
172
|
it("mounts a wrapper that forwards the anonymous actor when authService is undefined", async () => {
|
|
142
173
|
const { api, captured } = makeFakeApi(undefined);
|
|
143
174
|
const inner = vi.fn();
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import {
|
|
2
|
+
InvalidAttachmentMetadata,
|
|
3
|
+
validateReserveMetadata,
|
|
4
|
+
type ReserveMetadata,
|
|
5
|
+
} from "@powerhousedao/reactor-attachments";
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { parseReserveOptions } from "../../src/attachments/routes.js";
|
|
8
|
+
|
|
9
|
+
type Case = [name: string, meta: Record<string, unknown>];
|
|
10
|
+
|
|
11
|
+
const base = { mimeType: "text/plain", fileName: "note.txt" };
|
|
12
|
+
|
|
13
|
+
// The download route's setHeader throws on each of these.
|
|
14
|
+
const headerBreaking: Case[] = [
|
|
15
|
+
["CRLF before a parameter", { ...base, mimeType: "text/plain\r\n;a=b" }],
|
|
16
|
+
["LF after a ;", { ...base, mimeType: "text/plain;\na=b" }],
|
|
17
|
+
["a control char quoted", { ...base, mimeType: 'text/plain; a="\x01"' }],
|
|
18
|
+
["a DEL quoted", { ...base, mimeType: 'text/plain; a="\x7f"' }],
|
|
19
|
+
["a char above 0xFF quoted", { ...base, mimeType: 'text/plain;a="\u0100"' }],
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const cases: Case[] = [
|
|
23
|
+
["plain metadata", base],
|
|
24
|
+
["an extension", { ...base, extension: "txt" }],
|
|
25
|
+
["a null extension", { ...base, extension: null }],
|
|
26
|
+
["a parameter", { ...base, mimeType: "text/plain; charset=utf-8" }],
|
|
27
|
+
["a quoted parameter", { ...base, mimeType: 'text/plain; a="b c"' }],
|
|
28
|
+
["a unicode file name", { ...base, fileName: "résumé.pdf" }],
|
|
29
|
+
["a 255-char mimeType", { ...base, mimeType: `a/${"b".repeat(253)}` }],
|
|
30
|
+
["a 256-char mimeType", { ...base, mimeType: `a/${"b".repeat(254)}` }],
|
|
31
|
+
["a 255-char fileName", { ...base, fileName: "f".repeat(255) }],
|
|
32
|
+
["a 256-char fileName", { ...base, fileName: "f".repeat(256) }],
|
|
33
|
+
["an empty mimeType", { ...base, mimeType: "" }],
|
|
34
|
+
["an empty fileName", { ...base, fileName: "" }],
|
|
35
|
+
["an empty extension", { ...base, extension: "" }],
|
|
36
|
+
["a mimeType with no slash", { ...base, mimeType: "text" }],
|
|
37
|
+
["a mimeType with no subtype", { ...base, mimeType: "text/" }],
|
|
38
|
+
["a mimeType with no type", { ...base, mimeType: "/plain" }],
|
|
39
|
+
["a mimeType with two slashes", { ...base, mimeType: "text/plain/x" }],
|
|
40
|
+
["a mimeType with a space", { ...base, mimeType: "text plain" }],
|
|
41
|
+
["a mimeType with a dangling ;", { ...base, mimeType: "text/plain;" }],
|
|
42
|
+
["a non-string mimeType", { ...base, mimeType: 5 }],
|
|
43
|
+
["a trailing CRLF in mimeType", { ...base, mimeType: "text/plain\r\n" }],
|
|
44
|
+
[
|
|
45
|
+
"an injected header in mimeType",
|
|
46
|
+
{ ...base, mimeType: "text/plain\nX-Injected: 1" },
|
|
47
|
+
],
|
|
48
|
+
["a NUL in mimeType", { ...base, mimeType: "text/pl\x00ain" }],
|
|
49
|
+
...headerBreaking,
|
|
50
|
+
["a NUL in fileName", { ...base, fileName: "a\x00b" }],
|
|
51
|
+
["a LF in fileName", { ...base, fileName: "a\nb" }],
|
|
52
|
+
["a CR in fileName", { ...base, fileName: "a\rb" }],
|
|
53
|
+
["a tab in fileName", { ...base, fileName: "a\tb" }],
|
|
54
|
+
["a DEL in fileName", { ...base, fileName: "a\x7fb" }],
|
|
55
|
+
["a non-string fileName", { ...base, fileName: null }],
|
|
56
|
+
["a slash in extension", { ...base, extension: "a/b" }],
|
|
57
|
+
["a backslash in extension", { ...base, extension: "a\\b" }],
|
|
58
|
+
["a bare slash extension", { ...base, extension: "/" }],
|
|
59
|
+
["a non-string extension", { ...base, extension: 5 }],
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
function routeAccepts(meta: Record<string, unknown>): boolean {
|
|
63
|
+
return parseReserveOptions(meta) !== null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function validatorAccepts(meta: Record<string, unknown>): boolean {
|
|
67
|
+
try {
|
|
68
|
+
validateReserveMetadata(meta as ReserveMetadata);
|
|
69
|
+
return true;
|
|
70
|
+
} catch (err) {
|
|
71
|
+
if (err instanceof InvalidAttachmentMetadata) return false;
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("reserve metadata parity between the route and validateReserveMetadata", () => {
|
|
77
|
+
it("covers both accepted and rejected metadata", () => {
|
|
78
|
+
const verdicts = cases.map(([, meta]) => routeAccepts(meta));
|
|
79
|
+
expect(verdicts).toContain(true);
|
|
80
|
+
expect(verdicts).toContain(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it.each(cases)("agrees on %s", (_name, meta) => {
|
|
84
|
+
expect(validatorAccepts(meta)).toBe(routeAccepts(meta));
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("header-breaking mimeType values", () => {
|
|
89
|
+
it.each(headerBreaking)("the route rejects %s", (_name, meta) => {
|
|
90
|
+
expect(routeAccepts(meta)).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it.each(headerBreaking)("the validator rejects %s", (_name, meta) => {
|
|
94
|
+
expect(validatorAccepts(meta)).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { PGlite } from "@electric-sql/pglite";
|
|
2
2
|
import {
|
|
3
|
+
AttachmentAlreadyExists,
|
|
3
4
|
AttachmentBuilder,
|
|
4
5
|
S3AttachmentBackend,
|
|
5
6
|
type AttachmentBuildResult,
|
|
6
7
|
createRemoteAttachmentService,
|
|
7
8
|
} from "@powerhousedao/reactor-attachments";
|
|
8
|
-
import type { API } from "@powerhousedao/reactor-api";
|
|
9
|
+
import type { API, IAttachmentAccessService } from "@powerhousedao/reactor-api";
|
|
9
10
|
import { createHttpAdapter } from "@powerhousedao/reactor-api";
|
|
10
11
|
import { Kysely } from "kysely";
|
|
11
12
|
import { PGliteDialect } from "kysely-pglite-dialect";
|
|
@@ -22,6 +23,17 @@ import { registerAttachmentRoutes } from "../../src/attachments/index.js";
|
|
|
22
23
|
const EMPTY_STRING_SHA256 =
|
|
23
24
|
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
24
25
|
|
|
26
|
+
const DOC_ID = "doc-1";
|
|
27
|
+
const ALLOW_ALL: IAttachmentAccessService = {
|
|
28
|
+
canReadAttachment: (request) =>
|
|
29
|
+
Promise.resolve({
|
|
30
|
+
kind: "allowed",
|
|
31
|
+
documentId: request.documentId as never,
|
|
32
|
+
ref: request.attachmentRef as never,
|
|
33
|
+
}),
|
|
34
|
+
admitCaller: () => Promise.resolve({ kind: "admitted" }),
|
|
35
|
+
};
|
|
36
|
+
|
|
25
37
|
describe("attachment routes through the real Express middleware stack", () => {
|
|
26
38
|
let attachments: AttachmentBuildResult;
|
|
27
39
|
let kysely: Kysely<unknown>;
|
|
@@ -42,10 +54,11 @@ describe("attachment routes through the real Express middleware stack", () => {
|
|
|
42
54
|
registerAttachmentRoutes({
|
|
43
55
|
httpAdapter: adapter,
|
|
44
56
|
attachments,
|
|
57
|
+
attachmentAccess: ALLOW_ALL,
|
|
45
58
|
authService: undefined,
|
|
46
59
|
} as unknown as API);
|
|
47
60
|
|
|
48
|
-
server = await adapter.listen(0);
|
|
61
|
+
server = await adapter.listen(0, undefined, "127.0.0.1");
|
|
49
62
|
const addr = server.address();
|
|
50
63
|
if (!addr || typeof addr === "string") throw new Error("no addr");
|
|
51
64
|
baseUrl = `http://127.0.0.1:${addr.port}`;
|
|
@@ -82,7 +95,7 @@ describe("attachment routes through the real Express middleware stack", () => {
|
|
|
82
95
|
expect(result.hash).not.toBe(EMPTY_STRING_SHA256);
|
|
83
96
|
expect(result.header.sizeBytes).toBe(bytes.byteLength);
|
|
84
97
|
|
|
85
|
-
const got = await service.get(result.ref);
|
|
98
|
+
const got = await service.get(result.ref, { documentId: DOC_ID });
|
|
86
99
|
expect(got.header.sizeBytes).toBe(bytes.byteLength);
|
|
87
100
|
expect(got.header.mimeType).toBe("application/json");
|
|
88
101
|
const reader = got.body.getReader();
|
|
@@ -101,6 +114,48 @@ describe("attachment routes through the real Express middleware stack", () => {
|
|
|
101
114
|
}
|
|
102
115
|
expect(new TextDecoder().decode(merged)).toBe(payload);
|
|
103
116
|
});
|
|
117
|
+
|
|
118
|
+
it("HEAD and GET /attachments/:hash serve a file name above U+00FF", async () => {
|
|
119
|
+
const fileName = "opłata – 報告 📎.pdf";
|
|
120
|
+
const service = createRemoteAttachmentService({ remoteUrl: baseUrl });
|
|
121
|
+
const upload = await service.reserve({
|
|
122
|
+
mimeType: "application/pdf",
|
|
123
|
+
fileName,
|
|
124
|
+
});
|
|
125
|
+
const bytes = new TextEncoder().encode("%PDF-unicode-name");
|
|
126
|
+
const { hash } = await upload.send(
|
|
127
|
+
new ReadableStream<Uint8Array>({
|
|
128
|
+
start(controller) {
|
|
129
|
+
controller.enqueue(bytes);
|
|
130
|
+
controller.close();
|
|
131
|
+
},
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
for (const method of ["HEAD", "GET"]) {
|
|
136
|
+
const res = await fetch(
|
|
137
|
+
`${baseUrl}/attachments/${hash}?documentId=${DOC_ID}`,
|
|
138
|
+
{ method },
|
|
139
|
+
);
|
|
140
|
+
expect(res.status).toBe(200);
|
|
141
|
+
const meta = JSON.parse(res.headers.get("attachment-metadata")!) as {
|
|
142
|
+
fileName: string;
|
|
143
|
+
};
|
|
144
|
+
expect(meta.fileName).toBe(fileName);
|
|
145
|
+
await res.arrayBuffer();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// A hash-first reserve of the same bytes is what the client sends on the
|
|
149
|
+
// next upload; it stats the hash first.
|
|
150
|
+
await expect(
|
|
151
|
+
service.reserve({
|
|
152
|
+
mimeType: "application/pdf",
|
|
153
|
+
fileName,
|
|
154
|
+
clientHash: hash,
|
|
155
|
+
sizeBytes: bytes.byteLength,
|
|
156
|
+
}),
|
|
157
|
+
).rejects.toBeInstanceOf(AttachmentAlreadyExists);
|
|
158
|
+
});
|
|
104
159
|
});
|
|
105
160
|
|
|
106
161
|
describe("authenticated S3 reservation production path", () => {
|
|
@@ -138,9 +193,10 @@ describe("authenticated S3 reservation production path", () => {
|
|
|
138
193
|
registerAttachmentRoutes({
|
|
139
194
|
httpAdapter: adapter,
|
|
140
195
|
attachments,
|
|
196
|
+
attachmentAccess: ALLOW_ALL,
|
|
141
197
|
authService: { verifyBearer },
|
|
142
198
|
} as unknown as API);
|
|
143
|
-
const server = await adapter.listen(0);
|
|
199
|
+
const server = await adapter.listen(0, undefined, "127.0.0.1");
|
|
144
200
|
const address = server.address();
|
|
145
201
|
if (!address || typeof address === "string") throw new Error("no addr");
|
|
146
202
|
const baseUrl = `http://127.0.0.1:${address.port}`;
|