sonamu 0.11.13 → 0.11.15

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.
@@ -5,7 +5,7 @@
5
5
  <link rel="icon" type="image/svg+xml" href="/sonamu-ui/setting.svg" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>{{projectName}}: Sonamu UI</title>
8
- <script type="module" crossorigin src="/sonamu-ui/assets/index-ClxAM2Lk.js"></script>
8
+ <script type="module" crossorigin src="/sonamu-ui/assets/index-LpE8u19O.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/sonamu-ui/assets/index-BMdIyKXS.css">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sonamu",
3
- "version": "0.11.13",
3
+ "version": "0.11.15",
4
4
  "description": "Sonamu — TypeScript Fullstack API Framework",
5
5
  "keywords": [
6
6
  "framework",
@@ -0,0 +1,211 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { request } from "node:http";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { gunzipSync } from "node:zlib";
6
+
7
+ import { type FastifyInstance } from "fastify";
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9
+
10
+ import { type SonamuFastifyConfig } from "../../types/types";
11
+ import { isNumberValue, isObjectValue } from "../../utils/runtime-value";
12
+ import { type SonamuConfig, type SonamuServerOptions } from "../config";
13
+ import { type Context } from "../context";
14
+ import { type ExtendedApi } from "../decorators";
15
+ import { Sonamu } from "../sonamu";
16
+
17
+ const LARGE_VALUE = "sonamu-compress-".repeat(1024);
18
+
19
+ type HttpResponse = {
20
+ statusCode: number | undefined;
21
+ headers: import("node:http").IncomingHttpHeaders;
22
+ body: Buffer;
23
+ };
24
+
25
+ type SonamuWithBoot = typeof Sonamu & {
26
+ boot(server: FastifyInstance, options: SonamuServerOptions): Promise<void>;
27
+ };
28
+
29
+ const sonamuWithBoot =
30
+ /* SAFETY: 테스트에서 프로세스 시그널을 등록하는 private boot만 격리합니다. */ Sonamu as SonamuWithBoot;
31
+
32
+ function createApi(methodName: string, options: ExtendedApi["options"] = {}): ExtendedApi {
33
+ return {
34
+ modelName: "PayloadModel",
35
+ methodName,
36
+ path: `/payload/${methodName}`,
37
+ options: {
38
+ httpMethod: "GET",
39
+ contentType: "application/json",
40
+ ...options,
41
+ },
42
+ typeParameters: [],
43
+ parameters: [],
44
+ returnType: "unknown",
45
+ };
46
+ }
47
+
48
+ const apis: ExtendedApi[] = [
49
+ createApi("large"),
50
+ createApi("largeWithoutCompression", { compress: false }),
51
+ ];
52
+
53
+ const apiConfig: SonamuFastifyConfig = {
54
+ contextProvider(defaultContext) {
55
+ return /* SAFETY: 실제 HTTP 요청에서 생성된 기본 컨텍스트를 변경하지 않고 사용합니다. */ defaultContext as Context;
56
+ },
57
+ guardHandler() {},
58
+ };
59
+
60
+ function requestServer(
61
+ server: FastifyInstance,
62
+ options: {
63
+ path: string;
64
+ method?: string;
65
+ headers?: Record<string, string>;
66
+ body?: string;
67
+ },
68
+ ): Promise<HttpResponse> {
69
+ const address = server.server.address();
70
+ if (!isObjectValue(address) || !("port" in address) || !isNumberValue(address.port)) {
71
+ throw new Error("테스트 서버의 TCP 주소를 확인할 수 없습니다");
72
+ }
73
+
74
+ return new Promise((resolve, reject) => {
75
+ const req = request(
76
+ {
77
+ host: "127.0.0.1",
78
+ port: address.port,
79
+ path: options.path,
80
+ method: options.method ?? "GET",
81
+ headers: options.headers,
82
+ },
83
+ (response) => {
84
+ const chunks: Buffer[] = [];
85
+ response.on("data", (chunk: Buffer) => chunks.push(chunk));
86
+ response.on("end", () => {
87
+ resolve({
88
+ statusCode: response.statusCode,
89
+ headers: response.headers,
90
+ body: Buffer.concat(chunks),
91
+ });
92
+ });
93
+ },
94
+ );
95
+ req.on("error", reject);
96
+ if (options.body !== undefined) {
97
+ req.write(options.body);
98
+ }
99
+ req.end();
100
+ });
101
+ }
102
+
103
+ describe("Sonamu 서버 플러그인 등록 순서", () => {
104
+ const originalSnapshot = Sonamu.captureTestingSnapshot();
105
+ const originalIsInitialized = Sonamu.isInitialized;
106
+ const originalForTesting = Sonamu.forTesting;
107
+ const originalServer = Sonamu.server;
108
+ const originalNodeEnv = process.env.NODE_ENV;
109
+ const originalHot = process.env.HOT;
110
+ const servers: FastifyInstance[] = [];
111
+ const tempRoots: string[] = [];
112
+ let apiRootPath: string;
113
+
114
+ beforeEach(async () => {
115
+ process.env.NODE_ENV = "production";
116
+ delete process.env.HOT;
117
+ Sonamu.isInitialized = true;
118
+ Sonamu.forTesting = false;
119
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "sonamu-plugin-registration-"));
120
+ tempRoots.push(tempRoot);
121
+ apiRootPath = path.join(tempRoot, "api");
122
+ Reflect.set(Sonamu, "apiRootPathValue", apiRootPath);
123
+ Reflect.set(Sonamu, "syncerValue", {
124
+ apis,
125
+ models: {
126
+ PayloadModel: {
127
+ large: () => ({ value: LARGE_VALUE }),
128
+ largeWithoutCompression: () => ({ value: LARGE_VALUE }),
129
+ },
130
+ },
131
+ types: {},
132
+ });
133
+ vi.spyOn(console, "warn").mockImplementation(() => undefined);
134
+ });
135
+
136
+ afterEach(async () => {
137
+ try {
138
+ await Promise.allSettled(servers.splice(0).map(async (server) => await server.close()));
139
+ } finally {
140
+ Sonamu.restoreTestingSnapshot(originalSnapshot);
141
+ Sonamu.isInitialized = originalIsInitialized;
142
+ Sonamu.forTesting = originalForTesting;
143
+ Sonamu.server = originalServer;
144
+ if (originalNodeEnv === undefined) {
145
+ delete process.env.NODE_ENV;
146
+ } else {
147
+ process.env.NODE_ENV = originalNodeEnv;
148
+ }
149
+ if (originalHot === undefined) {
150
+ delete process.env.HOT;
151
+ } else {
152
+ process.env.HOT = originalHot;
153
+ }
154
+ await Promise.all(
155
+ tempRoots.splice(0).map(async (tempRoot) => {
156
+ await rm(tempRoot, { recursive: true, force: true });
157
+ }),
158
+ );
159
+ vi.restoreAllMocks();
160
+ }
161
+ });
162
+
163
+ async function createProductionServer(
164
+ plugins: SonamuServerOptions["plugins"],
165
+ ): Promise<FastifyInstance> {
166
+ const config: SonamuConfig = {
167
+ api: { dir: "src", route: { prefix: "/api" } },
168
+ i18n: { defaultLocale: "ko", supportedLocales: ["ko"] },
169
+ sync: { targets: [] },
170
+ validation: { zodCompiler: false },
171
+ database: {},
172
+ logging: false,
173
+ server: {
174
+ plugins,
175
+ apiConfig,
176
+ },
177
+ };
178
+ Reflect.set(Sonamu, "configValue", config);
179
+ vi.spyOn(sonamuWithBoot, "boot").mockResolvedValue(undefined);
180
+
181
+ const server = await Sonamu.createServer({ enableSync: false, doSilent: true });
182
+ servers.push(server);
183
+ await server.listen({ host: "127.0.0.1", port: 0 });
184
+ return server;
185
+ }
186
+
187
+ it("운영 서버에서 compress: true이면 JSON 응답을 gzip으로 압축한다", async () => {
188
+ const server = await createProductionServer({ compress: true });
189
+
190
+ const response = await requestServer(server, {
191
+ path: "/api/payload/large",
192
+ headers: { "accept-encoding": "gzip" },
193
+ });
194
+
195
+ expect(response.statusCode).toBe(200);
196
+ expect(response.headers["content-encoding"]).toBe("gzip");
197
+ expect(JSON.parse(gunzipSync(response.body).toString())).toEqual({ value: LARGE_VALUE });
198
+ });
199
+
200
+ it("운영 서버에서 API별 compress: false는 압축하지 않는다", async () => {
201
+ const server = await createProductionServer({ compress: true });
202
+
203
+ const optedOut = await requestServer(server, {
204
+ path: "/api/payload/largeWithoutCompression",
205
+ headers: { "accept-encoding": "gzip" },
206
+ });
207
+
208
+ expect(optedOut.headers["content-encoding"]).toBeUndefined();
209
+ expect(JSON.parse(optedOut.body.toString())).toEqual({ value: LARGE_VALUE });
210
+ });
211
+ });
package/src/api/sonamu.ts CHANGED
@@ -1979,6 +1979,7 @@ class SonamuClass {
1979
1979
  return;
1980
1980
  }
1981
1981
 
1982
+ // route() 시점에 실행되는 onRoute 훅이 설치되도록 플러그인 등록 완료를 기다립니다.
1982
1983
  // compress 플러그인은 다른 플러그인보다 먼저 등록되어야 합니다.
1983
1984
  if (plugins.compress) {
1984
1985
  const compressPlugin = (await import("@fastify/compress")).default;
@@ -1992,9 +1993,9 @@ class SonamuClass {
1992
1993
  };
1993
1994
 
1994
1995
  if (plugins.compress === true) {
1995
- server.register(compressPlugin, defaultOptions);
1996
+ await server.register(compressPlugin, defaultOptions);
1996
1997
  } else {
1997
- server.register(compressPlugin, {
1998
+ await server.register(compressPlugin, {
1998
1999
  ...defaultOptions,
1999
2000
  ...plugins.compress,
2000
2001
  });
@@ -2018,9 +2019,9 @@ class SonamuClass {
2018
2019
  if (!option) return;
2019
2020
 
2020
2021
  if (option === true) {
2021
- server.register((await import(pluginName)).default);
2022
+ await server.register((await import(pluginName)).default);
2022
2023
  } else {
2023
- server.register((await import(pluginName)).default, option);
2024
+ await server.register((await import(pluginName)).default, option);
2024
2025
  }
2025
2026
  };
2026
2027
 
@@ -0,0 +1,210 @@
1
+ import { type FastifyRequest } from "fastify";
2
+ import { describe, expect, test } from "vitest";
3
+
4
+ import { createBetterAuthRequest } from "../better-auth-request";
5
+
6
+ // createBetterAuthRequest가 실제로 사용하는 FastifyRequest 필드만 추린 최소 모킹 타입
7
+ type MockableRequestFields = Pick<FastifyRequest, "url" | "method" | "headers" | "body" | "ip">;
8
+
9
+ function createMockFastifyRequest(overrides: {
10
+ url?: string;
11
+ method?: string;
12
+ headers?: Record<string, string | string[]>;
13
+ body?: unknown;
14
+ ip?: string;
15
+ }): FastifyRequest {
16
+ const mock: MockableRequestFields = {
17
+ url: overrides.url ?? "/api/auth/callback/apple",
18
+ method: overrides.method ?? "POST",
19
+ headers: { host: "localhost:3000", ...overrides.headers },
20
+ body: overrides.body ?? null,
21
+ ip: overrides.ip ?? "127.0.0.1",
22
+ };
23
+ // SAFETY: 대상 함수는 위 필드만 접근하므로 최소 모킹을 FastifyRequest로 간주해도 안전하다.
24
+ return mock as FastifyRequest;
25
+ }
26
+
27
+ describe("createBetterAuthRequest — urlencoded 본문 직렬화 (SON-549)", () => {
28
+ test("urlencoded POST 콜백 본문을 JSON이 아닌 urlencoded로 직렬화한다", async () => {
29
+ // Apple Sign-In form_post 콜백 형태의 본문
30
+ const body = {
31
+ state: "abc123",
32
+ code: "authcode-456",
33
+ user: '{"name":{"firstName":"길동"}}',
34
+ };
35
+ const request = createMockFastifyRequest({
36
+ headers: { "content-type": "application/x-www-form-urlencoded" },
37
+ body,
38
+ });
39
+
40
+ const result = createBetterAuthRequest(request);
41
+ const text = await result.text();
42
+
43
+ // JSON 직렬화 결과가 아니어야 한다
44
+ expect(text).not.toBe(JSON.stringify(body));
45
+
46
+ // urlencoded로 재파싱 시 원본 값이 모두 복원되어야 한다
47
+ const params = new URLSearchParams(text);
48
+ expect(params.get("state")).toBe("abc123");
49
+ expect(params.get("code")).toBe("authcode-456");
50
+ expect(params.get("user")).toBe('{"name":{"firstName":"길동"}}');
51
+ });
52
+
53
+ test("charset 파라미터가 붙은 content-type도 urlencoded로 처리한다", async () => {
54
+ const body = { state: "abc123", code: "authcode-456" };
55
+ const request = createMockFastifyRequest({
56
+ headers: {
57
+ "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
58
+ },
59
+ body,
60
+ });
61
+
62
+ const result = createBetterAuthRequest(request);
63
+ const params = new URLSearchParams(await result.text());
64
+
65
+ expect(params.get("state")).toBe("abc123");
66
+ expect(params.get("code")).toBe("authcode-456");
67
+ });
68
+
69
+ test("특수문자(=, &, +, 공백, 한글)가 포함된 값이 왕복 보존된다", async () => {
70
+ const trickyState = "a=b&c+d e한글";
71
+ const body = { state: trickyState, code: "x" };
72
+ const request = createMockFastifyRequest({
73
+ headers: { "content-type": "application/x-www-form-urlencoded" },
74
+ body,
75
+ });
76
+
77
+ const result = createBetterAuthRequest(request);
78
+ const params = new URLSearchParams(await result.text());
79
+
80
+ expect(params.get("state")).toBe(trickyState);
81
+ expect(params.get("code")).toBe("x");
82
+ });
83
+
84
+ test("본문이 이미 urlencoded 문자열이면 값이 그대로 보존된다", async () => {
85
+ const request = createMockFastifyRequest({
86
+ headers: { "content-type": "application/x-www-form-urlencoded" },
87
+ body: "state=abc&code=def",
88
+ });
89
+
90
+ const result = createBetterAuthRequest(request);
91
+ const params = new URLSearchParams(await result.text());
92
+
93
+ expect(params.get("state")).toBe("abc");
94
+ expect(params.get("code")).toBe("def");
95
+ });
96
+
97
+ test("배열 값은 반복 키로 직렬화되어 값 손실이 없다", async () => {
98
+ const request = createMockFastifyRequest({
99
+ headers: { "content-type": "application/x-www-form-urlencoded" },
100
+ body: { tags: ["a", "b"] },
101
+ });
102
+
103
+ const result = createBetterAuthRequest(request);
104
+ const params = new URLSearchParams(await result.text());
105
+
106
+ // "a,b"로 합쳐지지 않고 반복 키(tags=a&tags=b)로 유지되어야 한다
107
+ expect(params.getAll("tags")).toEqual(["a", "b"]);
108
+ });
109
+
110
+ test("대소문자가 섞인 content-type 값도 urlencoded로 감지한다", async () => {
111
+ // HTTP 미디어 타입은 대소문자를 구분하지 않으며(RFC 9110 §8.3.1),
112
+ // Fastify 파서 조회도 소문자화된 타입으로 매칭하므로 브리지도 동일하게 감지해야 한다
113
+ const body = { state: "mixed-case", code: "authcode-789" };
114
+ const request = createMockFastifyRequest({
115
+ headers: {
116
+ "content-type": "Application/X-WWW-Form-Urlencoded; charset=UTF-8",
117
+ },
118
+ body,
119
+ });
120
+
121
+ const result = createBetterAuthRequest(request);
122
+ const text = await result.text();
123
+
124
+ expect(text).not.toBe(JSON.stringify(body));
125
+
126
+ const params = new URLSearchParams(text);
127
+ expect(params.get("state")).toBe("mixed-case");
128
+ expect(params.get("code")).toBe("authcode-789");
129
+ });
130
+
131
+ test("urlencoded content-type에 중첩 객체 본문이면 JSON 직렬화 폴백을 유지한다", async () => {
132
+ // qs 계열 파서가 생성하는 중첩 객체는 평탄한 레코드가 아니므로
133
+ // 본문을 버리거나 예외를 던지지 않고 기존 JSON 직렬화로 보존해야 한다
134
+ const body = { user: { name: "길동" } };
135
+ const request = createMockFastifyRequest({
136
+ headers: { "content-type": "application/x-www-form-urlencoded" },
137
+ body,
138
+ });
139
+
140
+ const result = createBetterAuthRequest(request);
141
+
142
+ expect(await result.text()).toBe(JSON.stringify(body));
143
+ });
144
+ });
145
+
146
+ describe("createBetterAuthRequest — 기존 동작 회귀 보장", () => {
147
+ test("JSON POST 본문은 JSON.stringify 결과 그대로 전달한다", async () => {
148
+ const body = { email: "user@example.com", password: "secret" };
149
+ const request = createMockFastifyRequest({
150
+ headers: { "content-type": "application/json" },
151
+ body,
152
+ });
153
+
154
+ const result = createBetterAuthRequest(request);
155
+
156
+ expect(await result.text()).toBe(JSON.stringify(body));
157
+ });
158
+
159
+ test("content-type 없는 POST 본문은 JSON.stringify를 유지한다", async () => {
160
+ const body = { foo: "bar" };
161
+ const request = createMockFastifyRequest({ body });
162
+
163
+ const result = createBetterAuthRequest(request);
164
+
165
+ expect(await result.text()).toBe(JSON.stringify(body));
166
+ });
167
+
168
+ test("GET 요청은 본문 없이 메서드와 URL을 보존한다", () => {
169
+ const request = createMockFastifyRequest({
170
+ url: "/api/auth/session?foo=bar",
171
+ method: "GET",
172
+ body: null,
173
+ });
174
+
175
+ const result = createBetterAuthRequest(request);
176
+
177
+ expect(result.body).toBeNull();
178
+ expect(result.method).toBe("GET");
179
+ expect(result.url).toBe("http://localhost:3000/api/auth/session?foo=bar");
180
+ });
181
+
182
+ test("urlencoded 경로에서도 클라이언트 IP 치환과 기타 헤더가 유지된다", async () => {
183
+ const request = createMockFastifyRequest({
184
+ headers: {
185
+ "content-type": "application/x-www-form-urlencoded",
186
+ "x-forwarded-for": "9.9.9.9",
187
+ "user-agent": "test-agent",
188
+ cookie: "session=xyz",
189
+ },
190
+ body: { state: "abc" },
191
+ ip: "1.2.3.4",
192
+ });
193
+
194
+ const result = createBetterAuthRequest(request, ["x-custom-ip"]);
195
+
196
+ // replaceClientIpHeaders가 신뢰 가능한 request.ip로 치환해야 한다
197
+ expect(result.headers.get("x-forwarded-for")).toBe("1.2.3.4");
198
+ expect(result.headers.get("x-real-ip")).toBe("1.2.3.4");
199
+ expect(result.headers.get("x-custom-ip")).toBe("1.2.3.4");
200
+
201
+ // 그 외 헤더는 변경 없이 전달되어야 한다
202
+ expect(result.headers.get("user-agent")).toBe("test-agent");
203
+ expect(result.headers.get("cookie")).toBe("session=xyz");
204
+ expect(result.headers.get("content-type")).toBe("application/x-www-form-urlencoded");
205
+
206
+ // urlencoded 본문 직렬화도 함께 보장한다
207
+ const params = new URLSearchParams(await result.text());
208
+ expect(params.get("state")).toBe("abc");
209
+ });
210
+ });
@@ -1,8 +1,52 @@
1
1
  import { type FastifyRequest } from "fastify";
2
+ import { z } from "zod";
2
3
 
3
4
  import { convertFastifyHeadersToStandard } from "../utils/utils";
4
5
  import { replaceClientIpHeaders } from "./audit-log/client-ip";
5
6
 
7
+ /** charset 등 파라미터나 대소문자 변형도 포함해 urlencoded content-type인지 판별한다 (RFC 9110 §8.3.1) */
8
+ function isUrlEncodedContentType(headers: Headers): boolean {
9
+ return (
10
+ headers.get("content-type")?.toLowerCase().startsWith("application/x-www-form-urlencoded") ??
11
+ false
12
+ );
13
+ }
14
+
15
+ /** 파서가 본문을 가공하지 않고 넘긴 경우의 원문 urlencoded 문자열 */
16
+ const rawUrlEncodedBodySchema = z.string();
17
+
18
+ /** Fastify urlencoded 파서가 생성하는 키-값 본문 (반복 키는 배열로 파싱됨) */
19
+ const urlEncodedValueSchema = z.union([z.string(), z.number(), z.boolean()]);
20
+ const urlEncodedRecordSchema = z.record(
21
+ z.string(),
22
+ z.union([urlEncodedValueSchema, z.array(urlEncodedValueSchema)]),
23
+ );
24
+
25
+ /**
26
+ * Fastify가 파싱한 본문을 원래 content-type에 맞게 재직렬화한다.
27
+ * urlencoded 본문을 JSON으로 재직렬화하면 better-auth가 폼 콜백(예: Apple form_post)을
28
+ * 해석하지 못하므로(state_not_found), content-type과 본문 포맷을 일치시킨다.
29
+ */
30
+ function serializeBody(request: FastifyRequest, headers: Headers): string {
31
+ if (!isUrlEncodedContentType(headers)) return JSON.stringify(request.body);
32
+
33
+ // 이미 urlencoded 문자열이면 그대로 전달해 값 왜곡을 피한다
34
+ const rawBody = rawUrlEncodedBodySchema.safeParse(request.body);
35
+ if (rawBody.success) return rawBody.data;
36
+
37
+ const recordBody = urlEncodedRecordSchema.safeParse(request.body);
38
+ // 예상 밖 형태의 본문은 기존 동작(JSON 직렬화)을 유지한다
39
+ if (!recordBody.success) return JSON.stringify(request.body);
40
+
41
+ const params = new URLSearchParams();
42
+ for (const [key, value] of Object.entries(recordBody.data)) {
43
+ // 배열은 "a,b"로 합치지 않고 반복 키(tags=a&tags=b)로 직렬화한다
44
+ const items = Array.isArray(value) ? value : [value];
45
+ for (const item of items) params.append(key, String(item));
46
+ }
47
+ return params.toString();
48
+ }
49
+
6
50
  export function createBetterAuthRequest(
7
51
  request: FastifyRequest,
8
52
  ipAddressHeaders?: readonly string[],
@@ -16,6 +60,6 @@ export function createBetterAuthRequest(
16
60
  method: request.method,
17
61
  headers,
18
62
  };
19
- if (request.body) init.body = JSON.stringify(request.body);
63
+ if (request.body) init.body = serializeBody(request, headers);
20
64
  return new Request(url.toString(), init);
21
65
  }