@powerhousedao/switchboard 6.2.2-dev.3 → 6.2.2-dev.31
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/attachment-reference-read-model.test.d.ts +2 -0
- package/.tsbuild/test/attachment-reference-read-model.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/download-target.test.d.ts +2 -0
- package/.tsbuild/test/attachments/download-target.test.d.ts.map +1 -0
- package/.tsbuild/test/worker-pool.test.d.ts +2 -0
- package/.tsbuild/test/worker-pool.test.d.ts.map +1 -0
- package/.tsbuild/tsconfig.tsbuildinfo +1 -1
- package/CHANGELOG.md +166 -0
- package/README.md +44 -0
- package/dist/index.mjs +1 -1
- package/dist/{server-DgBz3TJW.mjs → server-B4w0-8kx.mjs} +450 -27
- package/dist/server-B4w0-8kx.mjs.map +1 -0
- package/dist/server.d.mts +19 -10
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +3 -3
- package/package.json +12 -12
- package/test/attachment-reference-read-model.test.ts +425 -0
- package/test/attachments/auth.test.ts +138 -8
- package/test/attachments/download-target.test.ts +445 -0
- package/test/attachments/index.test.ts +37 -2
- package/test/attachments/routes-integration.test.ts +98 -1
- package/test/worker-pool.test.ts +232 -0
- package/dist/server-DgBz3TJW.mjs.map +0 -1
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import type { AttachmentHash } from "@powerhousedao/reactor";
|
|
2
|
+
import type {
|
|
3
|
+
AttachmentBuildResult,
|
|
4
|
+
IAttachmentBackend,
|
|
5
|
+
} from "@powerhousedao/reactor-attachments";
|
|
6
|
+
import type {
|
|
7
|
+
API,
|
|
8
|
+
AttachmentAccessResult,
|
|
9
|
+
IAttachmentAccessService,
|
|
10
|
+
} from "@powerhousedao/reactor-api";
|
|
11
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
12
|
+
import { describe, expect, it, vi } from "vitest";
|
|
13
|
+
import type { AttachmentActorContext } from "../../src/attachments/auth.js";
|
|
14
|
+
import { registerAttachmentRoutes } from "../../src/attachments/index.js";
|
|
15
|
+
import { makeDownloadTargetHandler } from "../../src/attachments/routes.js";
|
|
16
|
+
|
|
17
|
+
const HASH = "a".repeat(64) as AttachmentHash;
|
|
18
|
+
const REF = `attachment://v1:${HASH}`;
|
|
19
|
+
const DOC_ID = "doc-1";
|
|
20
|
+
const EXPIRES = "2026-07-23T00:00:00.000Z";
|
|
21
|
+
|
|
22
|
+
const ACTOR: AttachmentActorContext = {
|
|
23
|
+
user: { address: "0xverified", chainId: 1, networkId: "mainnet" },
|
|
24
|
+
authEnabled: true,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const AVAILABLE_HEADER = {
|
|
28
|
+
status: "available",
|
|
29
|
+
mimeType: "application/pdf",
|
|
30
|
+
fileName: "f.pdf",
|
|
31
|
+
sizeBytes: 3,
|
|
32
|
+
extension: "pdf",
|
|
33
|
+
createdAtUtc: EXPIRES,
|
|
34
|
+
lastAccessedAtUtc: EXPIRES,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function makeReq(opts: {
|
|
38
|
+
hash?: string;
|
|
39
|
+
url?: string;
|
|
40
|
+
headers?: Record<string, string>;
|
|
41
|
+
}): IncomingMessage {
|
|
42
|
+
return {
|
|
43
|
+
method: "GET",
|
|
44
|
+
url:
|
|
45
|
+
opts.url ??
|
|
46
|
+
`/attachments/${opts.hash ?? HASH}/download-target?documentId=${DOC_ID}`,
|
|
47
|
+
headers: { host: "sb.example.com", ...(opts.headers ?? {}) },
|
|
48
|
+
socket: {},
|
|
49
|
+
params: { hash: opts.hash ?? HASH },
|
|
50
|
+
} as unknown as IncomingMessage;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function makeRes() {
|
|
54
|
+
const headers: Record<string, string> = {};
|
|
55
|
+
let body = "";
|
|
56
|
+
const res = {
|
|
57
|
+
statusCode: 200,
|
|
58
|
+
setHeader(name: string, value: string | number | readonly string[]) {
|
|
59
|
+
headers[name.toLowerCase()] = String(value);
|
|
60
|
+
},
|
|
61
|
+
end(chunk?: string | Buffer) {
|
|
62
|
+
if (chunk !== undefined) {
|
|
63
|
+
body += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
} as unknown as ServerResponse;
|
|
67
|
+
Object.defineProperty(res, "_headers", { get: () => headers });
|
|
68
|
+
Object.defineProperty(res, "_body", { get: () => body });
|
|
69
|
+
return res as ServerResponse & {
|
|
70
|
+
readonly _headers: Record<string, string>;
|
|
71
|
+
readonly _body: string;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function makeAccess(result: AttachmentAccessResult): {
|
|
76
|
+
access: IAttachmentAccessService;
|
|
77
|
+
spy: ReturnType<typeof vi.fn>;
|
|
78
|
+
} {
|
|
79
|
+
const spy = vi.fn().mockResolvedValue(result);
|
|
80
|
+
return { access: { canReadAttachment: spy }, spy };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function makeAttachments(opts: {
|
|
84
|
+
backend?: Partial<IAttachmentBackend> & { kind: "filesystem" | "s3" };
|
|
85
|
+
stat?: () => Promise<unknown>;
|
|
86
|
+
}): AttachmentBuildResult & { statSpy: ReturnType<typeof vi.fn> } {
|
|
87
|
+
const statSpy = vi.fn(opts.stat ?? (() => Promise.resolve(AVAILABLE_HEADER)));
|
|
88
|
+
return {
|
|
89
|
+
store: { stat: statSpy },
|
|
90
|
+
...(opts.backend ? { backend: opts.backend } : {}),
|
|
91
|
+
statSpy,
|
|
92
|
+
} as unknown as AttachmentBuildResult & {
|
|
93
|
+
statSpy: ReturnType<typeof vi.fn>;
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const ALLOWED: AttachmentAccessResult = {
|
|
98
|
+
kind: "allowed",
|
|
99
|
+
documentId: DOC_ID as never,
|
|
100
|
+
ref: REF as never,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
describe("makeDownloadTargetHandler", () => {
|
|
104
|
+
it("returns a switchboard target for filesystem with no-store", async () => {
|
|
105
|
+
const { access, spy } = makeAccess(ALLOWED);
|
|
106
|
+
const attachments = makeAttachments({});
|
|
107
|
+
const handler = makeDownloadTargetHandler(attachments, access);
|
|
108
|
+
const res = makeRes();
|
|
109
|
+
|
|
110
|
+
await handler(makeReq({}), res, undefined, ACTOR);
|
|
111
|
+
|
|
112
|
+
expect(res.statusCode).toBe(200);
|
|
113
|
+
expect(res._headers["cache-control"]).toBe("no-store");
|
|
114
|
+
expect(JSON.parse(res._body)).toEqual({
|
|
115
|
+
kind: "switchboard",
|
|
116
|
+
method: "GET",
|
|
117
|
+
url: `http://sb.example.com/attachments/${HASH}`,
|
|
118
|
+
headers: {},
|
|
119
|
+
});
|
|
120
|
+
expect(spy).toHaveBeenCalledWith({
|
|
121
|
+
documentId: DOC_ID,
|
|
122
|
+
attachmentRef: REF,
|
|
123
|
+
userAddress: "0xverified",
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("returns the backend presigned-get target in S3 mode", async () => {
|
|
128
|
+
const { access } = makeAccess(ALLOWED);
|
|
129
|
+
const prepareDownloadTarget = vi.fn().mockResolvedValue({
|
|
130
|
+
kind: "presigned-get",
|
|
131
|
+
method: "GET",
|
|
132
|
+
url: "https://bucket.example.com/attachments/aa?sig=1",
|
|
133
|
+
headers: {},
|
|
134
|
+
expiresAtUtc: EXPIRES,
|
|
135
|
+
});
|
|
136
|
+
const attachments = makeAttachments({
|
|
137
|
+
backend: { kind: "s3", prepareDownloadTarget } as never,
|
|
138
|
+
});
|
|
139
|
+
const handler = makeDownloadTargetHandler(attachments, access);
|
|
140
|
+
const res = makeRes();
|
|
141
|
+
|
|
142
|
+
await handler(makeReq({}), res, undefined, ACTOR);
|
|
143
|
+
|
|
144
|
+
expect(res.statusCode).toBe(200);
|
|
145
|
+
expect(JSON.parse(res._body)).toMatchObject({ kind: "presigned-get" });
|
|
146
|
+
expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH, undefined);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("passes a requested expiresIn through to the backend presigner", async () => {
|
|
150
|
+
const { access } = makeAccess(ALLOWED);
|
|
151
|
+
const prepareDownloadTarget = vi.fn().mockResolvedValue({
|
|
152
|
+
kind: "presigned-get",
|
|
153
|
+
method: "GET",
|
|
154
|
+
url: "https://bucket.example.com/attachments/aa?sig=1",
|
|
155
|
+
headers: {},
|
|
156
|
+
expiresAtUtc: EXPIRES,
|
|
157
|
+
});
|
|
158
|
+
const attachments = makeAttachments({
|
|
159
|
+
backend: { kind: "s3", prepareDownloadTarget } as never,
|
|
160
|
+
});
|
|
161
|
+
const handler = makeDownloadTargetHandler(attachments, access);
|
|
162
|
+
const res = makeRes();
|
|
163
|
+
|
|
164
|
+
await handler(
|
|
165
|
+
makeReq({
|
|
166
|
+
url: `/attachments/${HASH}/download-target?documentId=${DOC_ID}&expiresIn=3600`,
|
|
167
|
+
}),
|
|
168
|
+
res,
|
|
169
|
+
undefined,
|
|
170
|
+
ACTOR,
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
expect(res.statusCode).toBe(200);
|
|
174
|
+
expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH, 3600);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("clamps expiresIn to the 7-day presigning ceiling", async () => {
|
|
178
|
+
const { access } = makeAccess(ALLOWED);
|
|
179
|
+
const prepareDownloadTarget = vi.fn().mockResolvedValue({
|
|
180
|
+
kind: "presigned-get",
|
|
181
|
+
method: "GET",
|
|
182
|
+
url: "https://bucket.example.com/attachments/aa?sig=1",
|
|
183
|
+
headers: {},
|
|
184
|
+
expiresAtUtc: EXPIRES,
|
|
185
|
+
});
|
|
186
|
+
const attachments = makeAttachments({
|
|
187
|
+
backend: { kind: "s3", prepareDownloadTarget } as never,
|
|
188
|
+
});
|
|
189
|
+
const handler = makeDownloadTargetHandler(attachments, access);
|
|
190
|
+
const res = makeRes();
|
|
191
|
+
|
|
192
|
+
await handler(
|
|
193
|
+
makeReq({
|
|
194
|
+
url: `/attachments/${HASH}/download-target?documentId=${DOC_ID}&expiresIn=99999999`,
|
|
195
|
+
}),
|
|
196
|
+
res,
|
|
197
|
+
undefined,
|
|
198
|
+
ACTOR,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH, 604800);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("omits the TTL override when expiresIn is absent", async () => {
|
|
205
|
+
const { access } = makeAccess(ALLOWED);
|
|
206
|
+
const prepareDownloadTarget = vi.fn().mockResolvedValue({
|
|
207
|
+
kind: "presigned-get",
|
|
208
|
+
method: "GET",
|
|
209
|
+
url: "https://bucket.example.com/attachments/aa?sig=1",
|
|
210
|
+
headers: {},
|
|
211
|
+
expiresAtUtc: EXPIRES,
|
|
212
|
+
});
|
|
213
|
+
const attachments = makeAttachments({
|
|
214
|
+
backend: { kind: "s3", prepareDownloadTarget } as never,
|
|
215
|
+
});
|
|
216
|
+
const handler = makeDownloadTargetHandler(attachments, access);
|
|
217
|
+
|
|
218
|
+
await handler(makeReq({}), makeRes(), undefined, ACTOR);
|
|
219
|
+
|
|
220
|
+
expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH, undefined);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it.each([
|
|
224
|
+
["duplicated", `expiresIn=60&expiresIn=120`],
|
|
225
|
+
["non-integer", `expiresIn=6.5`],
|
|
226
|
+
["non-numeric", `expiresIn=soon`],
|
|
227
|
+
["zero", `expiresIn=0`],
|
|
228
|
+
["negative", `expiresIn=-5`],
|
|
229
|
+
])("rejects a %s expiresIn with 400 before authorization", async (_, qs) => {
|
|
230
|
+
const { access, spy } = makeAccess(ALLOWED);
|
|
231
|
+
const attachments = makeAttachments({});
|
|
232
|
+
const handler = makeDownloadTargetHandler(attachments, access);
|
|
233
|
+
const res = makeRes();
|
|
234
|
+
|
|
235
|
+
await handler(
|
|
236
|
+
makeReq({
|
|
237
|
+
url: `/attachments/${HASH}/download-target?documentId=${DOC_ID}&${qs}`,
|
|
238
|
+
}),
|
|
239
|
+
res,
|
|
240
|
+
undefined,
|
|
241
|
+
ACTOR,
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
expect(res.statusCode).toBe(400);
|
|
245
|
+
expect(spy).not.toHaveBeenCalled();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("normalizes an uppercase path hash before authorization and lookup", async () => {
|
|
249
|
+
const { access, spy } = makeAccess(ALLOWED);
|
|
250
|
+
const attachments = makeAttachments({});
|
|
251
|
+
const handler = makeDownloadTargetHandler(attachments, access);
|
|
252
|
+
const res = makeRes();
|
|
253
|
+
|
|
254
|
+
await handler(makeReq({ hash: HASH.toUpperCase() }), res, undefined, ACTOR);
|
|
255
|
+
|
|
256
|
+
expect(res.statusCode).toBe(200);
|
|
257
|
+
expect(spy).toHaveBeenCalledWith(
|
|
258
|
+
expect.objectContaining({ attachmentRef: REF }),
|
|
259
|
+
);
|
|
260
|
+
expect(attachments.statSpy).toHaveBeenCalledWith(HASH);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("rejects an invalid hash before any access or storage call", async () => {
|
|
264
|
+
const { access, spy } = makeAccess(ALLOWED);
|
|
265
|
+
const attachments = makeAttachments({});
|
|
266
|
+
const handler = makeDownloadTargetHandler(attachments, access);
|
|
267
|
+
const res = makeRes();
|
|
268
|
+
|
|
269
|
+
await handler(makeReq({ hash: "nothex" }), res, undefined, ACTOR);
|
|
270
|
+
|
|
271
|
+
expect(res.statusCode).toBe(400);
|
|
272
|
+
expect(spy).not.toHaveBeenCalled();
|
|
273
|
+
expect(attachments.statSpy).not.toHaveBeenCalled();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it.each([
|
|
277
|
+
["missing", `/attachments/${HASH}/download-target`],
|
|
278
|
+
[
|
|
279
|
+
"duplicated",
|
|
280
|
+
`/attachments/${HASH}/download-target?documentId=a&documentId=b`,
|
|
281
|
+
],
|
|
282
|
+
["blank", `/attachments/${HASH}/download-target?documentId=%20%20`],
|
|
283
|
+
[
|
|
284
|
+
"oversized",
|
|
285
|
+
`/attachments/${HASH}/download-target?documentId=${"x".repeat(600)}`,
|
|
286
|
+
],
|
|
287
|
+
])("rejects a %s documentId before authorization", async (_, url) => {
|
|
288
|
+
const { access, spy } = makeAccess(ALLOWED);
|
|
289
|
+
const attachments = makeAttachments({});
|
|
290
|
+
const handler = makeDownloadTargetHandler(attachments, access);
|
|
291
|
+
const res = makeRes();
|
|
292
|
+
|
|
293
|
+
await handler(makeReq({ url }), res, undefined, ACTOR);
|
|
294
|
+
|
|
295
|
+
expect(res.statusCode).toBe(400);
|
|
296
|
+
expect(spy).not.toHaveBeenCalled();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("maps a denied decision to the generic 404 with zero storage calls", async () => {
|
|
300
|
+
const { access } = makeAccess({ kind: "denied" });
|
|
301
|
+
const prepareDownloadTarget = vi.fn();
|
|
302
|
+
const attachments = makeAttachments({
|
|
303
|
+
backend: { kind: "s3", prepareDownloadTarget } as never,
|
|
304
|
+
});
|
|
305
|
+
const handler = makeDownloadTargetHandler(attachments, access);
|
|
306
|
+
const res = makeRes();
|
|
307
|
+
|
|
308
|
+
await handler(makeReq({}), res, undefined, ACTOR);
|
|
309
|
+
|
|
310
|
+
expect(res.statusCode).toBe(404);
|
|
311
|
+
expect(JSON.parse(res._body)).toEqual({ error: "Attachment not found" });
|
|
312
|
+
expect(attachments.statSpy).not.toHaveBeenCalled();
|
|
313
|
+
expect(prepareDownloadTarget).not.toHaveBeenCalled();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("maps projection-unavailable to a generic non-cacheable 503 before storage", async () => {
|
|
317
|
+
const { access } = makeAccess({ kind: "projection-unavailable" });
|
|
318
|
+
const attachments = makeAttachments({});
|
|
319
|
+
const handler = makeDownloadTargetHandler(attachments, access);
|
|
320
|
+
const res = makeRes();
|
|
321
|
+
|
|
322
|
+
await handler(makeReq({}), res, undefined, ACTOR);
|
|
323
|
+
|
|
324
|
+
expect(res.statusCode).toBe(503);
|
|
325
|
+
expect(res._headers["cache-control"]).toBe("no-store");
|
|
326
|
+
expect(attachments.statSpy).not.toHaveBeenCalled();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("maps an unknown attachment after authorization to the same generic 404", async () => {
|
|
330
|
+
const { access } = makeAccess(ALLOWED);
|
|
331
|
+
const attachments = makeAttachments({});
|
|
332
|
+
const { AttachmentNotFound } =
|
|
333
|
+
await import("@powerhousedao/reactor-attachments");
|
|
334
|
+
attachments.statSpy.mockRejectedValue(new AttachmentNotFound(HASH));
|
|
335
|
+
const handler = makeDownloadTargetHandler(attachments, access);
|
|
336
|
+
const res = makeRes();
|
|
337
|
+
|
|
338
|
+
await handler(makeReq({}), res, undefined, ACTOR);
|
|
339
|
+
|
|
340
|
+
expect(res.statusCode).toBe(404);
|
|
341
|
+
expect(JSON.parse(res._body)).toEqual({ error: "Attachment not found" });
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("maps a pending attachment to the generic 404", async () => {
|
|
345
|
+
const { access } = makeAccess(ALLOWED);
|
|
346
|
+
const attachments = makeAttachments({
|
|
347
|
+
stat: () => Promise.resolve({ ...AVAILABLE_HEADER, status: "pending" }),
|
|
348
|
+
});
|
|
349
|
+
const handler = makeDownloadTargetHandler(attachments, access);
|
|
350
|
+
const res = makeRes();
|
|
351
|
+
|
|
352
|
+
await handler(makeReq({}), res, undefined, ACTOR);
|
|
353
|
+
|
|
354
|
+
expect(res.statusCode).toBe(404);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("maps presigner failure to a sanitized 502 without URL detail", async () => {
|
|
358
|
+
const { access } = makeAccess(ALLOWED);
|
|
359
|
+
const prepareDownloadTarget = vi
|
|
360
|
+
.fn()
|
|
361
|
+
.mockRejectedValue(
|
|
362
|
+
new Error(
|
|
363
|
+
"https://bucket.internal/attachments/aa?X-Amz-Signature=s3cr3t",
|
|
364
|
+
),
|
|
365
|
+
);
|
|
366
|
+
const attachments = makeAttachments({
|
|
367
|
+
backend: { kind: "s3", prepareDownloadTarget } as never,
|
|
368
|
+
});
|
|
369
|
+
const handler = makeDownloadTargetHandler(attachments, access);
|
|
370
|
+
const res = makeRes();
|
|
371
|
+
|
|
372
|
+
await handler(makeReq({}), res, undefined, ACTOR);
|
|
373
|
+
|
|
374
|
+
expect(res.statusCode).toBe(502);
|
|
375
|
+
expect(res._body).not.toContain("X-Amz-Signature");
|
|
376
|
+
expect(res._body).not.toContain("bucket.internal");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("passes an anonymous actor as an undefined address (OPEN mode)", async () => {
|
|
380
|
+
const { access, spy } = makeAccess(ALLOWED);
|
|
381
|
+
const attachments = makeAttachments({});
|
|
382
|
+
const handler = makeDownloadTargetHandler(attachments, access);
|
|
383
|
+
const res = makeRes();
|
|
384
|
+
|
|
385
|
+
await handler(makeReq({}), res, undefined, {
|
|
386
|
+
user: undefined,
|
|
387
|
+
authEnabled: false,
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
expect(spy).toHaveBeenCalledWith(
|
|
391
|
+
expect.objectContaining({ userAddress: undefined }),
|
|
392
|
+
);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("honours x-forwarded proto/host when building the switchboard target", async () => {
|
|
396
|
+
const { access } = makeAccess(ALLOWED);
|
|
397
|
+
const attachments = makeAttachments({});
|
|
398
|
+
const handler = makeDownloadTargetHandler(attachments, access);
|
|
399
|
+
const res = makeRes();
|
|
400
|
+
|
|
401
|
+
await handler(
|
|
402
|
+
makeReq({
|
|
403
|
+
headers: {
|
|
404
|
+
"x-forwarded-proto": "https",
|
|
405
|
+
"x-forwarded-host": "public.example.com",
|
|
406
|
+
},
|
|
407
|
+
}),
|
|
408
|
+
res,
|
|
409
|
+
undefined,
|
|
410
|
+
ACTOR,
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
expect(JSON.parse(res._body)).toMatchObject({
|
|
414
|
+
url: `https://public.example.com/attachments/${HASH}`,
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
describe("registerAttachmentRoutes inventory", () => {
|
|
420
|
+
it("mounts exactly the six legacy routes plus download-target", () => {
|
|
421
|
+
const captured: Array<{ method: string; path: string }> = [];
|
|
422
|
+
const api = {
|
|
423
|
+
httpAdapter: {
|
|
424
|
+
mountNodeRoute: (method: string, path: string) => {
|
|
425
|
+
captured.push({ method, path });
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
authService: undefined,
|
|
429
|
+
attachments: {},
|
|
430
|
+
attachmentAccess: { canReadAttachment: vi.fn() },
|
|
431
|
+
} as unknown as API;
|
|
432
|
+
|
|
433
|
+
registerAttachmentRoutes(api);
|
|
434
|
+
|
|
435
|
+
expect(captured).toEqual([
|
|
436
|
+
{ method: "POST", path: "/attachments/reservations" },
|
|
437
|
+
{ method: "GET", path: "/attachments/reservations/:reservationId" },
|
|
438
|
+
{ method: "DELETE", path: "/attachments/reservations/:reservationId" },
|
|
439
|
+
{ method: "PUT", path: "/attachments/reservations/:reservationId" },
|
|
440
|
+
{ method: "HEAD", path: "/attachments/:hash" },
|
|
441
|
+
{ method: "GET", path: "/attachments/:hash/download-target" },
|
|
442
|
+
{ method: "GET", path: "/attachments/:hash" },
|
|
443
|
+
]);
|
|
444
|
+
});
|
|
445
|
+
});
|
|
@@ -111,13 +111,48 @@ describe("mountAuthenticatedNodeRoute", () => {
|
|
|
111
111
|
expect(inner).toHaveBeenCalledTimes(1);
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
-
it("
|
|
114
|
+
it("passes allowAnonymous through: a missing bearer reaches the handler as an anonymous actor", async () => {
|
|
115
|
+
const verifyBearer = vi.fn(() =>
|
|
116
|
+
Promise.resolve({
|
|
117
|
+
user: undefined,
|
|
118
|
+
admins: [],
|
|
119
|
+
auth_enabled: true,
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
const authService = { verifyBearer } as unknown as AuthService;
|
|
123
|
+
const { api, captured } = makeFakeApi(authService);
|
|
124
|
+
const inner = vi.fn();
|
|
125
|
+
|
|
126
|
+
mountAuthenticatedNodeRoute(api, "POST", "/x", inner, {
|
|
127
|
+
allowAnonymous: true,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const req = makeReq();
|
|
131
|
+
const res = makeRes();
|
|
132
|
+
await captured[0].handler(req, res);
|
|
133
|
+
|
|
134
|
+
expect(res.statusCode).toBe(200);
|
|
135
|
+
expect(inner).toHaveBeenCalledWith(req, res, undefined, {
|
|
136
|
+
user: undefined,
|
|
137
|
+
authEnabled: true,
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("mounts a wrapper that forwards the anonymous actor when authService is undefined", async () => {
|
|
115
142
|
const { api, captured } = makeFakeApi(undefined);
|
|
116
143
|
const inner = vi.fn();
|
|
117
144
|
|
|
118
145
|
mountAuthenticatedNodeRoute(api, "PUT", "/x", inner);
|
|
119
146
|
|
|
120
147
|
expect(captured).toHaveLength(1);
|
|
121
|
-
|
|
148
|
+
|
|
149
|
+
const req = makeReq();
|
|
150
|
+
const res = makeRes();
|
|
151
|
+
await captured[0].handler(req, res);
|
|
152
|
+
|
|
153
|
+
expect(inner).toHaveBeenCalledWith(req, res, undefined, {
|
|
154
|
+
user: undefined,
|
|
155
|
+
authEnabled: false,
|
|
156
|
+
});
|
|
122
157
|
});
|
|
123
158
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { PGlite } from "@electric-sql/pglite";
|
|
2
2
|
import {
|
|
3
3
|
AttachmentBuilder,
|
|
4
|
+
S3AttachmentBackend,
|
|
4
5
|
type AttachmentBuildResult,
|
|
5
6
|
createRemoteAttachmentService,
|
|
6
7
|
} from "@powerhousedao/reactor-attachments";
|
|
@@ -12,7 +13,7 @@ import { mkdtemp, rm } from "node:fs/promises";
|
|
|
12
13
|
import type { Server } from "node:http";
|
|
13
14
|
import { tmpdir } from "node:os";
|
|
14
15
|
import { join } from "node:path";
|
|
15
|
-
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
16
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
16
17
|
import { registerAttachmentRoutes } from "../../src/attachments/index.js";
|
|
17
18
|
|
|
18
19
|
// SHA-256 of the empty string. If body-parser drains the upload body, the
|
|
@@ -101,3 +102,99 @@ describe("attachment routes through the real Express middleware stack", () => {
|
|
|
101
102
|
expect(new TextDecoder().decode(merged)).toBe(payload);
|
|
102
103
|
});
|
|
103
104
|
});
|
|
105
|
+
|
|
106
|
+
describe("authenticated S3 reservation production path", () => {
|
|
107
|
+
it("returns uploadTarget and refuses the legacy proxy PUT", async () => {
|
|
108
|
+
const pglite = new PGlite();
|
|
109
|
+
const db = new Kysely<unknown>({ dialect: new PGliteDialect(pglite) });
|
|
110
|
+
const storagePath = await mkdtemp(join(tmpdir(), "switchboard-s3-int-"));
|
|
111
|
+
const send = vi.fn().mockResolvedValue({});
|
|
112
|
+
const presign = vi.fn().mockResolvedValue("https://signed.example.test/x");
|
|
113
|
+
const backend = new S3AttachmentBackend(
|
|
114
|
+
db.withSchema("attachments") as never,
|
|
115
|
+
{
|
|
116
|
+
endpoint: "https://s3.example.test",
|
|
117
|
+
region: "eu-central",
|
|
118
|
+
bucket: "attachments",
|
|
119
|
+
accessKeyId: "test-key",
|
|
120
|
+
secretAccessKey: "test-secret",
|
|
121
|
+
prefix: "attachments",
|
|
122
|
+
forcePathStyle: false,
|
|
123
|
+
uploadTtlSeconds: 900,
|
|
124
|
+
downloadTtlSeconds: 300,
|
|
125
|
+
},
|
|
126
|
+
{ client: { send }, presign },
|
|
127
|
+
);
|
|
128
|
+
const attachments = await new AttachmentBuilder(db, storagePath)
|
|
129
|
+
.withBackend(backend)
|
|
130
|
+
.build();
|
|
131
|
+
const { adapter } = await createHttpAdapter("express");
|
|
132
|
+
adapter.setupMiddleware({});
|
|
133
|
+
const verifyBearer = vi.fn().mockResolvedValue({
|
|
134
|
+
user: { address: "0xabc", chainId: 1, networkId: "mainnet" },
|
|
135
|
+
admins: [],
|
|
136
|
+
auth_enabled: true,
|
|
137
|
+
});
|
|
138
|
+
registerAttachmentRoutes({
|
|
139
|
+
httpAdapter: adapter,
|
|
140
|
+
attachments,
|
|
141
|
+
authService: { verifyBearer },
|
|
142
|
+
} as unknown as API);
|
|
143
|
+
const server = await adapter.listen(0);
|
|
144
|
+
const address = server.address();
|
|
145
|
+
if (!address || typeof address === "string") throw new Error("no addr");
|
|
146
|
+
const baseUrl = `http://127.0.0.1:${address.port}`;
|
|
147
|
+
const hash = "b".repeat(64);
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const response = await fetch(`${baseUrl}/attachments/reservations`, {
|
|
151
|
+
method: "POST",
|
|
152
|
+
headers: {
|
|
153
|
+
authorization: "Bearer valid-token",
|
|
154
|
+
"content-type": "application/json",
|
|
155
|
+
},
|
|
156
|
+
body: JSON.stringify({
|
|
157
|
+
mimeType: "application/pdf",
|
|
158
|
+
fileName: "invoice.pdf",
|
|
159
|
+
clientHash: hash,
|
|
160
|
+
sizeBytes: 42,
|
|
161
|
+
}),
|
|
162
|
+
});
|
|
163
|
+
const responseText = await response.text();
|
|
164
|
+
expect(response.status, responseText).toBe(201);
|
|
165
|
+
const body = JSON.parse(responseText) as {
|
|
166
|
+
reservationId: string;
|
|
167
|
+
uploadTarget: { kind: string; method: string; url: string };
|
|
168
|
+
};
|
|
169
|
+
expect(body.uploadTarget).toMatchObject({
|
|
170
|
+
kind: "presigned-put",
|
|
171
|
+
method: "PUT",
|
|
172
|
+
url: "https://signed.example.test/x",
|
|
173
|
+
});
|
|
174
|
+
expect(verifyBearer).toHaveBeenCalledWith("Bearer valid-token");
|
|
175
|
+
expect(presign).toHaveBeenCalledOnce();
|
|
176
|
+
expect(send).not.toHaveBeenCalled();
|
|
177
|
+
|
|
178
|
+
const proxy = await fetch(
|
|
179
|
+
`${baseUrl}/attachments/reservations/${body.reservationId}`,
|
|
180
|
+
{
|
|
181
|
+
method: "PUT",
|
|
182
|
+
headers: { authorization: "Bearer valid-token" },
|
|
183
|
+
body: "must-not-reach-filesystem",
|
|
184
|
+
},
|
|
185
|
+
);
|
|
186
|
+
expect(proxy.status).toBe(405);
|
|
187
|
+
expect(await proxy.json()).toEqual({
|
|
188
|
+
error: "Use the reservation uploadTarget for S3 uploads",
|
|
189
|
+
});
|
|
190
|
+
await expect(
|
|
191
|
+
attachments.reservations.get(body.reservationId),
|
|
192
|
+
).resolves.toBeDefined();
|
|
193
|
+
} finally {
|
|
194
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
195
|
+
attachments.destroy();
|
|
196
|
+
await db.destroy();
|
|
197
|
+
await rm(storagePath, { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
});
|