@powerhousedao/switchboard 6.0.0-dev.207 → 6.0.0-dev.209

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,119 @@
1
+ import type { API, AuthService } from "@powerhousedao/reactor-api";
2
+ import type { IncomingMessage, ServerResponse } from "node:http";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { mountAuthenticatedNodeRoute } from "../../src/attachments/mount-auth.js";
5
+
6
+ type Captured = {
7
+ method: string;
8
+ path: string;
9
+ handler: (
10
+ req: IncomingMessage,
11
+ res: ServerResponse,
12
+ body?: unknown,
13
+ ) => void | Promise<void>;
14
+ };
15
+
16
+ function makeFakeApi(authService: AuthService | undefined): {
17
+ api: Pick<API, "httpAdapter" | "authService">;
18
+ captured: Captured[];
19
+ } {
20
+ const captured: Captured[] = [];
21
+ const api = {
22
+ httpAdapter: {
23
+ mountNodeRoute: (
24
+ method: string,
25
+ path: string,
26
+ handler: Captured["handler"],
27
+ ) => {
28
+ captured.push({ method, path, handler });
29
+ },
30
+ },
31
+ authService,
32
+ } as unknown as Pick<API, "httpAdapter" | "authService">;
33
+ return { api, captured };
34
+ }
35
+
36
+ function makeReq(headers: Record<string, string> = {}): IncomingMessage {
37
+ return { method: "POST", url: "/x", headers } as unknown as IncomingMessage;
38
+ }
39
+
40
+ function makeRes() {
41
+ const headers: Record<string, string> = {};
42
+ let body = "";
43
+ const res = {
44
+ statusCode: 200,
45
+ setHeader(name: string, value: string | number | readonly string[]) {
46
+ headers[name.toLowerCase()] = String(value);
47
+ },
48
+ end(chunk?: string | Buffer) {
49
+ if (chunk !== undefined) {
50
+ body += typeof chunk === "string" ? chunk : chunk.toString("utf8");
51
+ }
52
+ },
53
+ } as unknown as ServerResponse;
54
+ Object.defineProperty(res, "_headers", { get: () => headers });
55
+ Object.defineProperty(res, "_body", { get: () => body });
56
+ return res as ServerResponse & {
57
+ readonly _headers: Record<string, string>;
58
+ readonly _body: string;
59
+ };
60
+ }
61
+
62
+ describe("mountAuthenticatedNodeRoute", () => {
63
+ it("wraps the handler with auth enforcement when authService is defined", async () => {
64
+ const verifyBearer = vi.fn(async () => ({
65
+ user: undefined,
66
+ admins: [],
67
+ auth_enabled: true,
68
+ }));
69
+ const authService = { verifyBearer } as unknown as AuthService;
70
+ const { api, captured } = makeFakeApi(authService);
71
+ const inner = vi.fn();
72
+
73
+ mountAuthenticatedNodeRoute(api, "POST", "/x", inner);
74
+
75
+ expect(captured).toHaveLength(1);
76
+ expect(captured[0].method).toBe("POST");
77
+ expect(captured[0].path).toBe("/x");
78
+ // The mounted handler must NOT be the raw inner handler — it must be wrapped.
79
+ expect(captured[0].handler).not.toBe(inner);
80
+
81
+ const res = makeRes();
82
+ await captured[0].handler(makeReq(), res);
83
+
84
+ expect(verifyBearer).toHaveBeenCalledTimes(1);
85
+ expect(inner).not.toHaveBeenCalled();
86
+ expect(res.statusCode).toBe(401);
87
+ expect(JSON.parse(res._body)).toEqual({ error: "Authentication required" });
88
+ });
89
+
90
+ it("invokes the inner handler when verifyBearer returns a valid user", async () => {
91
+ const verifyBearer = vi.fn(async () => ({
92
+ user: { address: "0x1", chainId: 1, networkId: "mainnet" },
93
+ admins: [],
94
+ auth_enabled: true,
95
+ }));
96
+ const authService = { verifyBearer } as unknown as AuthService;
97
+ const { api, captured } = makeFakeApi(authService);
98
+ const inner = vi.fn();
99
+
100
+ mountAuthenticatedNodeRoute(api, "GET", "/x", inner);
101
+
102
+ await captured[0].handler(
103
+ makeReq({ authorization: "Bearer t" }),
104
+ makeRes(),
105
+ );
106
+
107
+ expect(inner).toHaveBeenCalledTimes(1);
108
+ });
109
+
110
+ it("mounts the inner handler unwrapped when authService is undefined", () => {
111
+ const { api, captured } = makeFakeApi(undefined);
112
+ const inner = vi.fn();
113
+
114
+ mountAuthenticatedNodeRoute(api, "PUT", "/x", inner);
115
+
116
+ expect(captured).toHaveLength(1);
117
+ expect(captured[0].handler).toBe(inner);
118
+ });
119
+ });
@@ -0,0 +1,103 @@
1
+ import { PGlite } from "@electric-sql/pglite";
2
+ import {
3
+ AttachmentBuilder,
4
+ type AttachmentBuildResult,
5
+ createRemoteAttachmentService,
6
+ } from "@powerhousedao/reactor-attachments";
7
+ import type { API } from "@powerhousedao/reactor-api";
8
+ import { createHttpAdapter } from "@powerhousedao/reactor-api";
9
+ import { Kysely } from "kysely";
10
+ import { PGliteDialect } from "kysely-pglite-dialect";
11
+ import { mkdtemp, rm } from "node:fs/promises";
12
+ import type { Server } from "node:http";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
16
+ import { registerAttachmentRoutes } from "../../src/attachments/index.js";
17
+
18
+ // SHA-256 of the empty string. If body-parser drains the upload body, the
19
+ // handler hashes zero bytes and returns this value -- the silent-data-loss
20
+ // signature this test guards against.
21
+ const EMPTY_STRING_SHA256 =
22
+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
23
+
24
+ describe("attachment routes through the real Express middleware stack", () => {
25
+ let attachments: AttachmentBuildResult;
26
+ let kysely: Kysely<unknown>;
27
+ let storagePath: string;
28
+ let server: Server;
29
+ let baseUrl: string;
30
+
31
+ beforeAll(async () => {
32
+ const pglite = new PGlite();
33
+ kysely = new Kysely<unknown>({ dialect: new PGliteDialect(pglite) });
34
+ storagePath = await mkdtemp(join(tmpdir(), "switchboard-attach-int-"));
35
+ attachments = await new AttachmentBuilder(kysely, storagePath).build();
36
+
37
+ const { adapter } = createHttpAdapter("express");
38
+ // Install bodyParser.json + cors -- the production middleware stack that
39
+ // drained JSON request bodies before the upload handler could read them.
40
+ adapter.setupMiddleware({});
41
+ registerAttachmentRoutes({
42
+ httpAdapter: adapter,
43
+ attachments,
44
+ authService: undefined,
45
+ } as unknown as API);
46
+
47
+ server = await adapter.listen(0);
48
+ const addr = server.address();
49
+ if (!addr || typeof addr === "string") throw new Error("no addr");
50
+ baseUrl = `http://127.0.0.1:${addr.port}`;
51
+ });
52
+
53
+ afterAll(async () => {
54
+ await new Promise<void>((resolve) => server.close(() => resolve()));
55
+ await kysely.destroy();
56
+ await rm(storagePath, { recursive: true, force: true });
57
+ });
58
+
59
+ it("RemoteAttachmentUpload.send() round-trips JSON payloads through the Express body-parser stack", async () => {
60
+ // application/json reservations were the failure case: the client used to
61
+ // PUT with Content-Type: application/json, which body-parser consumed
62
+ // before the route handler ran. The fix sends Content-Type:
63
+ // application/octet-stream regardless of the reserved mime type.
64
+ const service = createRemoteAttachmentService({ remoteUrl: baseUrl });
65
+ const upload = await service.reserve({
66
+ mimeType: "application/json",
67
+ fileName: "doc.json",
68
+ extension: "json",
69
+ });
70
+
71
+ const payload = '{"hello":"world","n":42}';
72
+ const bytes = new TextEncoder().encode(payload);
73
+ const stream = new ReadableStream<Uint8Array>({
74
+ start(controller) {
75
+ controller.enqueue(bytes);
76
+ controller.close();
77
+ },
78
+ });
79
+ const result = await upload.send(stream);
80
+
81
+ expect(result.hash).not.toBe(EMPTY_STRING_SHA256);
82
+ expect(result.header.sizeBytes).toBe(bytes.byteLength);
83
+
84
+ const got = await service.get(result.ref);
85
+ expect(got.header.sizeBytes).toBe(bytes.byteLength);
86
+ expect(got.header.mimeType).toBe("application/json");
87
+ const reader = got.body.getReader();
88
+ const chunks: Uint8Array[] = [];
89
+ for (;;) {
90
+ const { done, value } = await reader.read();
91
+ if (done) break;
92
+ chunks.push(value);
93
+ }
94
+ const total = chunks.reduce((n, c) => n + c.byteLength, 0);
95
+ const merged = new Uint8Array(total);
96
+ let off = 0;
97
+ for (const c of chunks) {
98
+ merged.set(c, off);
99
+ off += c.byteLength;
100
+ }
101
+ expect(new TextDecoder().decode(merged)).toBe(payload);
102
+ });
103
+ });