@gencow/core 0.1.23 → 0.1.25
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/dist/crud.d.ts +2 -2
- package/dist/crud.js +225 -208
- package/dist/index.d.ts +7 -3
- package/dist/index.js +4 -1
- package/dist/reactive.js +10 -3
- package/dist/retry.js +1 -1
- package/dist/rls-db.d.ts +2 -2
- package/dist/rls-db.js +1 -5
- package/dist/scheduler.d.ts +2 -0
- package/dist/scheduler.js +16 -6
- package/dist/server.d.ts +0 -1
- package/dist/server.js +0 -1
- package/dist/storage.js +29 -22
- package/dist/v.d.ts +2 -2
- package/dist/workflow-types.d.ts +81 -0
- package/dist/workflow-types.js +12 -0
- package/dist/workflow.d.ts +30 -0
- package/dist/workflow.js +150 -0
- package/dist/workflows-api.d.ts +13 -0
- package/dist/workflows-api.js +321 -0
- package/package.json +46 -42
- package/src/__tests__/auth.test.ts +90 -86
- package/src/__tests__/crons.test.ts +69 -67
- package/src/__tests__/crud-codegen-integration.test.ts +164 -170
- package/src/__tests__/crud-owner-rls.test.ts +308 -301
- package/src/__tests__/crud.test.ts +694 -711
- package/src/__tests__/dist-exports.test.ts +120 -114
- package/src/__tests__/fixtures/basic/auth.ts +16 -16
- package/src/__tests__/fixtures/basic/drizzle.config.ts +1 -4
- package/src/__tests__/fixtures/basic/index.ts +1 -1
- package/src/__tests__/fixtures/basic/schema.ts +1 -1
- package/src/__tests__/fixtures/basic/tasks.ts +4 -4
- package/src/__tests__/fixtures/common/auth-schema.ts +38 -34
- package/src/__tests__/helpers/basic-rls-fixture.ts +80 -78
- package/src/__tests__/helpers/pglite-migrations.ts +2 -5
- package/src/__tests__/helpers/pglite-rls-session.ts +13 -16
- package/src/__tests__/helpers/seed-like-fill.ts +50 -44
- package/src/__tests__/helpers/test-gencow-ctx-rls.ts +4 -7
- package/src/__tests__/httpaction.test.ts +91 -91
- package/src/__tests__/image-optimization.test.ts +570 -574
- package/src/__tests__/load.test.ts +321 -308
- package/src/__tests__/network-sim.test.ts +238 -215
- package/src/__tests__/reactive.test.ts +380 -358
- package/src/__tests__/retry.test.ts +99 -84
- package/src/__tests__/rls-crud-basic.test.ts +172 -245
- package/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts +81 -81
- package/src/__tests__/rls-custom-mutation-handlers.test.ts +47 -94
- package/src/__tests__/rls-custom-query-handlers.test.ts +92 -92
- package/src/__tests__/rls-db-leased-connection.test.ts +2 -6
- package/src/__tests__/rls-session-and-policies.test.ts +181 -199
- package/src/__tests__/scheduler-durable-v2.test.ts +199 -181
- package/src/__tests__/scheduler-durable.test.ts +117 -117
- package/src/__tests__/scheduler-exec.test.ts +258 -246
- package/src/__tests__/scheduler.test.ts +129 -111
- package/src/__tests__/storage.test.ts +282 -269
- package/src/__tests__/tsconfig.json +6 -6
- package/src/__tests__/validator.test.ts +236 -232
- package/src/__tests__/workflow.test.ts +606 -0
- package/src/__tests__/ws-integration.test.ts +223 -218
- package/src/__tests__/ws-scale.test.ts +168 -159
- package/src/auth-config.ts +18 -18
- package/src/auth.ts +106 -106
- package/src/crons.ts +77 -77
- package/src/crud.ts +523 -479
- package/src/index.ts +71 -6
- package/src/reactive.ts +357 -331
- package/src/retry.ts +51 -54
- package/src/rls-db.ts +195 -205
- package/src/rls.ts +33 -36
- package/src/scheduler.ts +237 -211
- package/src/server.ts +0 -1
- package/src/storage.ts +632 -593
- package/src/v.ts +119 -114
- package/src/workflow-types.ts +108 -0
- package/src/workflow.ts +188 -0
- package/src/workflows-api.ts +415 -0
- package/src/db.ts +0 -18
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { describe, it, expect, beforeAll } from "bun:test";
|
|
20
|
-
import { getRegisteredQueries, getRegisteredMutations, getQueryDef } from "../reactive";
|
|
20
|
+
import { getRegisteredQueries, getRegisteredMutations, getQueryDef } from "../reactive.js";
|
|
21
21
|
|
|
22
22
|
// ─── Mock PgTable ────────────────────────────────────────────────────────────
|
|
23
23
|
// 실제 Drizzle pgTable을 사용 — getTableName() 등 Drizzle 공식 API 호환 보장
|
|
@@ -26,28 +26,28 @@ import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
|
|
|
26
26
|
|
|
27
27
|
// ─── crud import ───────────────────────────────────────────────────────
|
|
28
28
|
|
|
29
|
-
import { crud, parseFilterNode, applyFilterOp } from "../crud";
|
|
29
|
+
import { crud, parseFilterNode, applyFilterOp } from "../crud.js";
|
|
30
30
|
|
|
31
31
|
// ─── 테스트 테이블 정의 ────────────────────────────────────────────────────
|
|
32
32
|
|
|
33
33
|
const tasks = pgTable("tasks", {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
id: serial("id").primaryKey(),
|
|
35
|
+
title: text("title").notNull(),
|
|
36
|
+
description: text("description"),
|
|
37
|
+
userId: text("user_id"),
|
|
38
|
+
createdAt: timestamp("created_at").defaultNow(),
|
|
39
|
+
updatedAt: timestamp("updated_at").defaultNow(),
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
const articles = pgTable("articles", {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
id: serial("id").primaryKey(),
|
|
44
|
+
title: text("title"),
|
|
45
|
+
createdAt: timestamp("created_at").defaultNow(),
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
const items = pgTable("items", {
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
id: serial("id").primaryKey(),
|
|
50
|
+
name: text("name"),
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
// ─── Mock 유틸 ─────────────────────────────────────────────────────────
|
|
@@ -59,140 +59,138 @@ const items = pgTable("items", {
|
|
|
59
59
|
* 2. select({count: _}) → count query chain
|
|
60
60
|
*/
|
|
61
61
|
function createListMockCtx(mockData: any[], opts?: { authCalled?: { value: boolean } }) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
62
|
+
// data select chain
|
|
63
|
+
const dataChain = {
|
|
64
|
+
from: () => dataChain,
|
|
65
|
+
where: () => dataChain,
|
|
66
|
+
orderBy: () => dataChain,
|
|
67
|
+
limit: () => dataChain,
|
|
68
|
+
offset: () => Promise.resolve(mockData),
|
|
69
|
+
};
|
|
70
|
+
// count select chain
|
|
71
|
+
const countChain = {
|
|
72
|
+
from: () => countChain,
|
|
73
|
+
where: () => Promise.resolve([{ count: mockData.length }]),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
auth: {
|
|
78
|
+
requireAuth: () => {
|
|
79
|
+
if (opts?.authCalled) opts.authCalled.value = true;
|
|
80
|
+
return { user: { id: "user-1" } };
|
|
81
|
+
},
|
|
82
|
+
getSession: () => null,
|
|
83
|
+
},
|
|
84
|
+
db: {
|
|
85
|
+
select: (selectArg?: any) => {
|
|
86
|
+
// select({count: ...}) — count query
|
|
87
|
+
if (selectArg && typeof selectArg === "object" && "count" in selectArg) {
|
|
88
|
+
return countChain;
|
|
89
|
+
}
|
|
90
|
+
// select() — data query
|
|
91
|
+
return dataChain;
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
/**
|
|
98
98
|
* mutation handler 실행용 mock context.
|
|
99
99
|
* insert/update/delete + realtime emit + fetchListWithTotal(select+count) 지원.
|
|
100
100
|
*/
|
|
101
|
-
function createMutationMockCtx(opts: {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
insert: () => ({
|
|
134
|
-
values: (v: any) => { capturedValues = v; return { returning: () => Promise.resolve([insertResult]) }; },
|
|
135
|
-
}),
|
|
136
|
-
update: () => ({
|
|
137
|
-
set: () => ({ where: () => ({ returning: () => Promise.resolve([insertResult]) }) }),
|
|
138
|
-
}),
|
|
139
|
-
delete: () => ({
|
|
140
|
-
where: () => Promise.resolve(),
|
|
141
|
-
}),
|
|
142
|
-
},
|
|
143
|
-
realtime: {
|
|
144
|
-
emit: (key: string, data: any) => emitted.push({ key, data }),
|
|
101
|
+
function createMutationMockCtx(opts: { listData?: any[]; insertResult?: any; authUser?: any }) {
|
|
102
|
+
const listData = opts.listData ?? [];
|
|
103
|
+
const insertResult = opts.insertResult;
|
|
104
|
+
const emitted: { key: string; data: any }[] = [];
|
|
105
|
+
let capturedValues: any = null;
|
|
106
|
+
|
|
107
|
+
// select chain: data + count 양쪽 지원
|
|
108
|
+
const selectDataChain = {
|
|
109
|
+
from: () => selectDataChain,
|
|
110
|
+
where: () => selectDataChain,
|
|
111
|
+
orderBy: () => Promise.resolve(listData),
|
|
112
|
+
};
|
|
113
|
+
const selectCountChain = {
|
|
114
|
+
from: () => selectCountChain,
|
|
115
|
+
where: () => Promise.resolve([{ count: listData.length }]),
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const mockCtx = {
|
|
119
|
+
auth: {
|
|
120
|
+
requireAuth: () => opts.authUser ?? { id: "user-1", email: "test@example.com" },
|
|
121
|
+
},
|
|
122
|
+
db: {
|
|
123
|
+
select: (selectArg?: any) => {
|
|
124
|
+
if (selectArg && typeof selectArg === "object" && "count" in selectArg) {
|
|
125
|
+
return selectCountChain;
|
|
126
|
+
}
|
|
127
|
+
return selectDataChain;
|
|
128
|
+
},
|
|
129
|
+
insert: () => ({
|
|
130
|
+
values: (v: any) => {
|
|
131
|
+
capturedValues = v;
|
|
132
|
+
return { returning: () => Promise.resolve([insertResult]) };
|
|
145
133
|
},
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
134
|
+
}),
|
|
135
|
+
update: () => ({
|
|
136
|
+
set: () => ({ where: () => ({ returning: () => Promise.resolve([insertResult]) }) }),
|
|
137
|
+
}),
|
|
138
|
+
delete: () => ({
|
|
139
|
+
where: () => Promise.resolve(),
|
|
140
|
+
}),
|
|
141
|
+
},
|
|
142
|
+
realtime: {
|
|
143
|
+
emit: (key: string, data: any) => emitted.push({ key, data }),
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
return { mockCtx, emitted, getCapturedValues: () => capturedValues };
|
|
149
148
|
}
|
|
150
149
|
|
|
151
|
-
|
|
152
150
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
153
151
|
// 1. 기본 동작: 팩토리 반환값 + 레지스트리 등록
|
|
154
152
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
155
153
|
|
|
156
154
|
describe("crud() — 기본 동작", () => {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
155
|
+
let result: ReturnType<typeof crud>;
|
|
156
|
+
|
|
157
|
+
beforeAll(() => {
|
|
158
|
+
result = crud(tasks);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("{ list, get, create, update, remove } 5개 프로퍼티를 반환한다", () => {
|
|
162
|
+
expect(result).toHaveProperty("list");
|
|
163
|
+
expect(result).toHaveProperty("get");
|
|
164
|
+
expect(result).toHaveProperty("create");
|
|
165
|
+
expect(result).toHaveProperty("update");
|
|
166
|
+
expect(result).toHaveProperty("remove");
|
|
167
|
+
expect(Object.keys(result)).toHaveLength(5);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("query/mutation 각각 레지스트리에 등록된다", () => {
|
|
171
|
+
const queries = getRegisteredQueries();
|
|
172
|
+
const mutations = getRegisteredMutations();
|
|
173
|
+
|
|
174
|
+
// query: tasks.list, tasks.get
|
|
175
|
+
expect(queries).toContain("tasks.list");
|
|
176
|
+
expect(queries).toContain("tasks.get");
|
|
177
|
+
|
|
178
|
+
// mutation: tasks.create, tasks.update, tasks.remove
|
|
179
|
+
const mutationNames = mutations.map((m: any) => m.name);
|
|
180
|
+
expect(mutationNames).toContain("tasks.create");
|
|
181
|
+
expect(mutationNames).toContain("tasks.update");
|
|
182
|
+
expect(mutationNames).toContain("tasks.remove");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("등록된 query/mutation은 핸들러를 가지고 있다", () => {
|
|
186
|
+
const listDef = getQueryDef("tasks.list");
|
|
187
|
+
expect(listDef).toBeDefined();
|
|
188
|
+
expect(typeof listDef!.handler).toBe("function");
|
|
189
|
+
|
|
190
|
+
const getDef = getQueryDef("tasks.get");
|
|
191
|
+
expect(getDef).toBeDefined();
|
|
192
|
+
expect(typeof getDef!.handler).toBe("function");
|
|
193
|
+
});
|
|
196
194
|
});
|
|
197
195
|
|
|
198
196
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -200,38 +198,38 @@ describe("crud() — 기본 동작", () => {
|
|
|
200
198
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
201
199
|
|
|
202
200
|
describe("crud() — Secure by Default", () => {
|
|
203
|
-
|
|
204
|
-
|
|
201
|
+
it("기본 호출 시 모든 query/mutation이 isPublic === false (auth 필수)", () => {
|
|
202
|
+
const result = crud(articles);
|
|
205
203
|
|
|
206
|
-
|
|
207
|
-
|
|
204
|
+
const listDef = getQueryDef("articles.list");
|
|
205
|
+
const getDef = getQueryDef("articles.get");
|
|
208
206
|
|
|
209
|
-
|
|
210
|
-
|
|
207
|
+
expect(listDef!.isPublic).toBe(false);
|
|
208
|
+
expect(getDef!.isPublic).toBe(false);
|
|
211
209
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
210
|
+
const mutations = getRegisteredMutations();
|
|
211
|
+
const createDef = mutations.find((m: any) => m.name === "articles.create");
|
|
212
|
+
const updateDef = mutations.find((m: any) => m.name === "articles.update");
|
|
213
|
+
const removeDef = mutations.find((m: any) => m.name === "articles.remove");
|
|
216
214
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
215
|
+
expect(createDef!.isPublic).toBe(false);
|
|
216
|
+
expect(updateDef!.isPublic).toBe(false);
|
|
217
|
+
expect(removeDef!.isPublic).toBe(false);
|
|
218
|
+
});
|
|
221
219
|
|
|
222
|
-
|
|
223
|
-
|
|
220
|
+
it("{ public: true } 옵션 시 모든 엔드포인트가 isPublic === true", () => {
|
|
221
|
+
const result = crud(items, { public: true });
|
|
224
222
|
|
|
225
|
-
|
|
226
|
-
|
|
223
|
+
const listDef = getQueryDef("items.list");
|
|
224
|
+
const getDef = getQueryDef("items.get");
|
|
227
225
|
|
|
228
|
-
|
|
229
|
-
|
|
226
|
+
expect(listDef!.isPublic).toBe(true);
|
|
227
|
+
expect(getDef!.isPublic).toBe(true);
|
|
230
228
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
229
|
+
const mutations = getRegisteredMutations();
|
|
230
|
+
const createDef = mutations.find((m: any) => m.name === "items.create");
|
|
231
|
+
expect(createDef!.isPublic).toBe(true);
|
|
232
|
+
});
|
|
235
233
|
});
|
|
236
234
|
|
|
237
235
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -239,25 +237,25 @@ describe("crud() — Secure by Default", () => {
|
|
|
239
237
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
240
238
|
|
|
241
239
|
describe("crud() — prefix 오버라이드", () => {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
240
|
+
it("prefix 옵션으로 키 네임스페이스를 변경할 수 있다", () => {
|
|
241
|
+
const customTable = pgTable("my_items", {
|
|
242
|
+
id: serial("id").primaryKey(),
|
|
243
|
+
name: text("name"),
|
|
244
|
+
});
|
|
247
245
|
|
|
248
|
-
|
|
246
|
+
crud(customTable, { prefix: "products", public: true });
|
|
249
247
|
|
|
250
|
-
|
|
251
|
-
|
|
248
|
+
const listDef = getQueryDef("products.list");
|
|
249
|
+
const getDef = getQueryDef("products.get");
|
|
252
250
|
|
|
253
|
-
|
|
254
|
-
|
|
251
|
+
expect(listDef).toBeDefined();
|
|
252
|
+
expect(getDef).toBeDefined();
|
|
255
253
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
254
|
+
const mutations = getRegisteredMutations();
|
|
255
|
+
expect(mutations.find((m: any) => m.name === "products.create")).toBeDefined();
|
|
256
|
+
expect(mutations.find((m: any) => m.name === "products.update")).toBeDefined();
|
|
257
|
+
expect(mutations.find((m: any) => m.name === "products.remove")).toBeDefined();
|
|
258
|
+
});
|
|
261
259
|
});
|
|
262
260
|
|
|
263
261
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -265,15 +263,15 @@ describe("crud() — prefix 오버라이드", () => {
|
|
|
265
263
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
266
264
|
|
|
267
265
|
describe("crud() — 검증", () => {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
expect(() => crud(badTable)).toThrow("[crud]");
|
|
275
|
-
expect(() => crud(badTable)).toThrow("must have an 'id' column");
|
|
266
|
+
it("id 컬럼이 없는 테이블은 에러를 던진다", () => {
|
|
267
|
+
// id 컬럼이 없는 실제 Drizzle 테이블
|
|
268
|
+
const badTable = pgTable("bad", {
|
|
269
|
+
name: text("name"),
|
|
276
270
|
});
|
|
271
|
+
|
|
272
|
+
expect(() => crud(badTable)).toThrow("[crud]");
|
|
273
|
+
expect(() => crud(badTable)).toThrow("must have an 'id' column");
|
|
274
|
+
});
|
|
277
275
|
});
|
|
278
276
|
|
|
279
277
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -281,28 +279,28 @@ describe("crud() — 검증", () => {
|
|
|
281
279
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
282
280
|
|
|
283
281
|
describe("crud() — list handler 실행", () => {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
282
|
+
it("list handler가 { data: T[], total: number } 형태로 반환한다", async () => {
|
|
283
|
+
const testTable = pgTable("handler_test", {
|
|
284
|
+
id: serial("id").primaryKey(),
|
|
285
|
+
createdAt: timestamp("created_at").defaultNow(),
|
|
286
|
+
});
|
|
289
287
|
|
|
290
|
-
|
|
288
|
+
crud(testTable, { public: true });
|
|
291
289
|
|
|
292
|
-
|
|
293
|
-
|
|
290
|
+
const listDef = getQueryDef("handler_test.list");
|
|
291
|
+
expect(listDef).toBeDefined();
|
|
294
292
|
|
|
295
|
-
|
|
296
|
-
|
|
293
|
+
const mockData = [{ id: 1 }, { id: 2 }];
|
|
294
|
+
const mockCtx = createListMockCtx(mockData);
|
|
297
295
|
|
|
298
|
-
|
|
296
|
+
const result = await listDef!.handler(mockCtx, {});
|
|
299
297
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
298
|
+
// { data, total } 형태 검증
|
|
299
|
+
expect(result).toHaveProperty("data");
|
|
300
|
+
expect(result).toHaveProperty("total");
|
|
301
|
+
expect(result.data).toEqual(mockData);
|
|
302
|
+
expect(result.total).toBe(2);
|
|
303
|
+
});
|
|
306
304
|
});
|
|
307
305
|
|
|
308
306
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -310,39 +308,39 @@ describe("crud() — list handler 실행", () => {
|
|
|
310
308
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
311
309
|
|
|
312
310
|
describe("crud() — create handler 실행", () => {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
const result = await createDef!.handler(mockCtx, { title: "New Task" });
|
|
334
|
-
expect(result).toEqual(insertedData);
|
|
335
|
-
|
|
336
|
-
// userId 자동 주입 확인
|
|
337
|
-
expect(getCapturedValues().userId).toBe("user-1");
|
|
338
|
-
|
|
339
|
-
// realtime emit — { data, total } 형태 확인
|
|
340
|
-
expect(emitted.length).toBeGreaterThanOrEqual(1);
|
|
341
|
-
expect(emitted[0].key).toBe("create_test.list");
|
|
342
|
-
expect(emitted[0].data).toHaveProperty("data");
|
|
343
|
-
expect(emitted[0].data).toHaveProperty("total");
|
|
344
|
-
expect(emitted[0].data.total).toBe(1);
|
|
311
|
+
it("인증된 사용자의 create가 userId 자동 주입 + { data, total } realtime emit", async () => {
|
|
312
|
+
const testTable = pgTable("create_test", {
|
|
313
|
+
id: serial("id").primaryKey(),
|
|
314
|
+
title: text("title"),
|
|
315
|
+
userId: text("user_id"),
|
|
316
|
+
createdAt: timestamp("created_at").defaultNow(),
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
crud(testTable);
|
|
320
|
+
|
|
321
|
+
const mutations = getRegisteredMutations();
|
|
322
|
+
const createDef = mutations.find((m: any) => m.name === "create_test.create");
|
|
323
|
+
expect(createDef).toBeDefined();
|
|
324
|
+
|
|
325
|
+
const insertedData = { id: 42, title: "New Task", userId: "user-1" };
|
|
326
|
+
const { mockCtx, emitted, getCapturedValues } = createMutationMockCtx({
|
|
327
|
+
listData: [insertedData],
|
|
328
|
+
insertResult: insertedData,
|
|
345
329
|
});
|
|
330
|
+
|
|
331
|
+
const result = await createDef!.handler(mockCtx, { title: "New Task" });
|
|
332
|
+
expect(result).toEqual(insertedData);
|
|
333
|
+
|
|
334
|
+
// userId 자동 주입 확인
|
|
335
|
+
expect(getCapturedValues().userId).toBe("user-1");
|
|
336
|
+
|
|
337
|
+
// realtime emit — { data, total } 형태 확인
|
|
338
|
+
expect(emitted.length).toBeGreaterThanOrEqual(1);
|
|
339
|
+
expect(emitted[0].key).toBe("create_test.list");
|
|
340
|
+
expect(emitted[0].data).toHaveProperty("data");
|
|
341
|
+
expect(emitted[0].data).toHaveProperty("total");
|
|
342
|
+
expect(emitted[0].data.total).toBe(1);
|
|
343
|
+
});
|
|
346
344
|
});
|
|
347
345
|
|
|
348
346
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -350,32 +348,32 @@ describe("crud() — create handler 실행", () => {
|
|
|
350
348
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
351
349
|
|
|
352
350
|
describe("crud() — remove handler 실행", () => {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
351
|
+
it("remove가 ctx.db.delete().where() 호출 후 { data, total } realtime emit 한다", async () => {
|
|
352
|
+
const testTable = pgTable("remove_test", {
|
|
353
|
+
id: serial("id").primaryKey(),
|
|
354
|
+
createdAt: timestamp("created_at").defaultNow(),
|
|
355
|
+
});
|
|
358
356
|
|
|
359
|
-
|
|
357
|
+
crud(testTable);
|
|
360
358
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
359
|
+
const mutations = getRegisteredMutations();
|
|
360
|
+
const removeDef = mutations.find((m: any) => m.name === "remove_test.remove");
|
|
361
|
+
expect(removeDef).toBeDefined();
|
|
364
362
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
363
|
+
const { mockCtx, emitted } = createMutationMockCtx({
|
|
364
|
+
listData: [], // 삭제 후 빈 리스트
|
|
365
|
+
});
|
|
368
366
|
|
|
369
|
-
|
|
370
|
-
|
|
367
|
+
const result = await removeDef!.handler(mockCtx, { id: 99 });
|
|
368
|
+
expect(result).toEqual({ success: true });
|
|
371
369
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
370
|
+
// realtime emit — { data, total } 형태
|
|
371
|
+
expect(emitted.length).toBeGreaterThanOrEqual(1);
|
|
372
|
+
expect(emitted[0].key).toBe("remove_test.list");
|
|
373
|
+
expect(emitted[0].data).toHaveProperty("data");
|
|
374
|
+
expect(emitted[0].data).toHaveProperty("total");
|
|
375
|
+
expect(emitted[0].data.total).toBe(0);
|
|
376
|
+
});
|
|
379
377
|
});
|
|
380
378
|
|
|
381
379
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -383,28 +381,31 @@ describe("crud() — remove handler 실행", () => {
|
|
|
383
381
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
384
382
|
|
|
385
383
|
describe("crud() — public 모드 auth skip", () => {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
384
|
+
it("public: true 시 requireAuth를 호출하지 않는다", async () => {
|
|
385
|
+
const testTable = pgTable("pub_test", {
|
|
386
|
+
id: serial("id").primaryKey(),
|
|
387
|
+
createdAt: timestamp("created_at").defaultNow(),
|
|
388
|
+
});
|
|
391
389
|
|
|
392
|
-
|
|
390
|
+
crud(testTable, { public: true });
|
|
393
391
|
|
|
394
|
-
|
|
395
|
-
|
|
392
|
+
const listDef = getQueryDef("pub_test.list");
|
|
393
|
+
expect(listDef).toBeDefined();
|
|
396
394
|
|
|
397
|
-
|
|
398
|
-
|
|
395
|
+
const authCalled = { value: false };
|
|
396
|
+
const mockCtx = createListMockCtx([{ id: 1 }], { authCalled });
|
|
399
397
|
|
|
400
|
-
|
|
401
|
-
|
|
398
|
+
// public 모드에서는 requireAuth를 호출하면 안됨 → 별도 mock
|
|
399
|
+
mockCtx.auth.requireAuth = () => {
|
|
400
|
+
authCalled.value = true;
|
|
401
|
+
throw new Error("should not be called");
|
|
402
|
+
};
|
|
402
403
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
404
|
+
const result = await listDef!.handler(mockCtx, {});
|
|
405
|
+
expect(authCalled.value).toBe(false);
|
|
406
|
+
expect(result.data).toEqual([{ id: 1 }]);
|
|
407
|
+
expect(result.total).toBe(1);
|
|
408
|
+
});
|
|
408
409
|
});
|
|
409
410
|
|
|
410
411
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -412,34 +413,34 @@ describe("crud() — public 모드 auth skip", () => {
|
|
|
412
413
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
413
414
|
|
|
414
415
|
describe("crud() — UUID id 자동 감지", () => {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
crud(uuidTable, { public: true });
|
|
422
|
-
|
|
423
|
-
const getDef = getQueryDef("uuid_test.get");
|
|
424
|
-
expect(getDef).toBeDefined();
|
|
425
|
-
// argsSchema에 id가 존재 (string 타입)
|
|
426
|
-
expect(getDef!.argsSchema).toBeDefined();
|
|
427
|
-
expect(getDef!.argsSchema).toHaveProperty("id");
|
|
416
|
+
it("text PK 테이블에서 get args에 string id validator를 사용한다", () => {
|
|
417
|
+
const uuidTable = pgTable("uuid_test", {
|
|
418
|
+
id: text("id").primaryKey(), // UUID string PK
|
|
419
|
+
name: text("name"),
|
|
428
420
|
});
|
|
429
421
|
|
|
430
|
-
|
|
431
|
-
const serialTable = pgTable("serial_test", {
|
|
432
|
-
id: serial("id").primaryKey(),
|
|
433
|
-
name: text("name"),
|
|
434
|
-
});
|
|
422
|
+
crud(uuidTable, { public: true });
|
|
435
423
|
|
|
436
|
-
|
|
424
|
+
const getDef = getQueryDef("uuid_test.get");
|
|
425
|
+
expect(getDef).toBeDefined();
|
|
426
|
+
// argsSchema에 id가 존재 (string 타입)
|
|
427
|
+
expect(getDef!.argsSchema).toBeDefined();
|
|
428
|
+
expect(getDef!.argsSchema).toHaveProperty("id");
|
|
429
|
+
});
|
|
437
430
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
431
|
+
it("serial PK 테이블에서 get args에 number id validator를 사용한다", () => {
|
|
432
|
+
const serialTable = pgTable("serial_test", {
|
|
433
|
+
id: serial("id").primaryKey(),
|
|
434
|
+
name: text("name"),
|
|
442
435
|
});
|
|
436
|
+
|
|
437
|
+
crud(serialTable, { public: true });
|
|
438
|
+
|
|
439
|
+
const getDef = getQueryDef("serial_test.get");
|
|
440
|
+
expect(getDef).toBeDefined();
|
|
441
|
+
expect(getDef!.argsSchema).toBeDefined();
|
|
442
|
+
expect(getDef!.argsSchema).toHaveProperty("id");
|
|
443
|
+
});
|
|
443
444
|
});
|
|
444
445
|
|
|
445
446
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -447,83 +448,83 @@ describe("crud() — UUID id 자동 감지", () => {
|
|
|
447
448
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
448
449
|
|
|
449
450
|
describe("crud() — allowedFilters", () => {
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
});
|
|
458
|
-
|
|
459
|
-
crud(filterTable, {
|
|
460
|
-
public: true,
|
|
461
|
-
allowedFilters: ["status", "category"],
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
const listDef = getQueryDef("filter_test.list");
|
|
465
|
-
expect(listDef).toBeDefined();
|
|
466
|
-
|
|
467
|
-
// allowedFilters를 사용하면 handler가 에러 없이 실행됨
|
|
468
|
-
const mockCtx = createListMockCtx([{ id: 1, status: "active" }]);
|
|
469
|
-
const result = await listDef!.handler(mockCtx, {
|
|
470
|
-
filters: { status: "active" },
|
|
471
|
-
});
|
|
472
|
-
|
|
473
|
-
expect(result).toHaveProperty("data");
|
|
474
|
-
expect(result).toHaveProperty("total");
|
|
475
|
-
});
|
|
476
|
-
|
|
477
|
-
it("allowedFilters에 없는 필드의 필터는 무시된다 (보안)", async () => {
|
|
478
|
-
const filterTable2 = pgTable("filter_test2", {
|
|
479
|
-
id: serial("id").primaryKey(),
|
|
480
|
-
status: text("status"),
|
|
481
|
-
userId: text("user_id"),
|
|
482
|
-
createdAt: timestamp("created_at").defaultNow(),
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
crud(filterTable2, {
|
|
486
|
-
public: true,
|
|
487
|
-
allowedFilters: ["status"],
|
|
488
|
-
});
|
|
489
|
-
|
|
490
|
-
const listDef = getQueryDef("filter_test2.list");
|
|
491
|
-
expect(listDef).toBeDefined();
|
|
492
|
-
|
|
493
|
-
// userId는 allowedFilters에 없으므로 무시되어야 함
|
|
494
|
-
const mockCtx = createListMockCtx([{ id: 1 }]);
|
|
495
|
-
const result = await listDef!.handler(mockCtx, {
|
|
496
|
-
filters: { userId: "hacker-attempt" },
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
// 에러 없이 정상 실행 (필터가 무시됨)
|
|
500
|
-
expect(result).toHaveProperty("data");
|
|
501
|
-
expect(result).toHaveProperty("total");
|
|
502
|
-
});
|
|
503
|
-
|
|
504
|
-
it("filters가 비어있으면 에러 없이 동작한다", async () => {
|
|
505
|
-
const filterTable3 = pgTable("filter_test3", {
|
|
506
|
-
id: serial("id").primaryKey(),
|
|
507
|
-
status: text("status"),
|
|
508
|
-
createdAt: timestamp("created_at").defaultNow(),
|
|
509
|
-
});
|
|
510
|
-
|
|
511
|
-
crud(filterTable3, {
|
|
512
|
-
public: true,
|
|
513
|
-
allowedFilters: ["status"],
|
|
514
|
-
});
|
|
515
|
-
|
|
516
|
-
const listDef = getQueryDef("filter_test3.list");
|
|
517
|
-
const mockCtx = createListMockCtx([{ id: 1 }]);
|
|
518
|
-
|
|
519
|
-
// filters 미전달
|
|
520
|
-
const result1 = await listDef!.handler(mockCtx, {});
|
|
521
|
-
expect(result1).toHaveProperty("data");
|
|
522
|
-
|
|
523
|
-
// filters 빈 객체
|
|
524
|
-
const result2 = await listDef!.handler(mockCtx, { filters: {} });
|
|
525
|
-
expect(result2).toHaveProperty("data");
|
|
451
|
+
it("allowedFilters에 명시된 필드의 필터가 적용된다", async () => {
|
|
452
|
+
const filterTable = pgTable("filter_test", {
|
|
453
|
+
id: serial("id").primaryKey(),
|
|
454
|
+
status: text("status"),
|
|
455
|
+
category: text("category"),
|
|
456
|
+
userId: text("user_id"),
|
|
457
|
+
createdAt: timestamp("created_at").defaultNow(),
|
|
526
458
|
});
|
|
459
|
+
|
|
460
|
+
crud(filterTable, {
|
|
461
|
+
public: true,
|
|
462
|
+
allowedFilters: ["status", "category"],
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
const listDef = getQueryDef("filter_test.list");
|
|
466
|
+
expect(listDef).toBeDefined();
|
|
467
|
+
|
|
468
|
+
// allowedFilters를 사용하면 handler가 에러 없이 실행됨
|
|
469
|
+
const mockCtx = createListMockCtx([{ id: 1, status: "active" }]);
|
|
470
|
+
const result = await listDef!.handler(mockCtx, {
|
|
471
|
+
filters: { status: "active" },
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
expect(result).toHaveProperty("data");
|
|
475
|
+
expect(result).toHaveProperty("total");
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("allowedFilters에 없는 필드의 필터는 무시된다 (보안)", async () => {
|
|
479
|
+
const filterTable2 = pgTable("filter_test2", {
|
|
480
|
+
id: serial("id").primaryKey(),
|
|
481
|
+
status: text("status"),
|
|
482
|
+
userId: text("user_id"),
|
|
483
|
+
createdAt: timestamp("created_at").defaultNow(),
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
crud(filterTable2, {
|
|
487
|
+
public: true,
|
|
488
|
+
allowedFilters: ["status"],
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
const listDef = getQueryDef("filter_test2.list");
|
|
492
|
+
expect(listDef).toBeDefined();
|
|
493
|
+
|
|
494
|
+
// userId는 allowedFilters에 없으므로 무시되어야 함
|
|
495
|
+
const mockCtx = createListMockCtx([{ id: 1 }]);
|
|
496
|
+
const result = await listDef!.handler(mockCtx, {
|
|
497
|
+
filters: { userId: "hacker-attempt" },
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
// 에러 없이 정상 실행 (필터가 무시됨)
|
|
501
|
+
expect(result).toHaveProperty("data");
|
|
502
|
+
expect(result).toHaveProperty("total");
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("filters가 비어있으면 에러 없이 동작한다", async () => {
|
|
506
|
+
const filterTable3 = pgTable("filter_test3", {
|
|
507
|
+
id: serial("id").primaryKey(),
|
|
508
|
+
status: text("status"),
|
|
509
|
+
createdAt: timestamp("created_at").defaultNow(),
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
crud(filterTable3, {
|
|
513
|
+
public: true,
|
|
514
|
+
allowedFilters: ["status"],
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
const listDef = getQueryDef("filter_test3.list");
|
|
518
|
+
const mockCtx = createListMockCtx([{ id: 1 }]);
|
|
519
|
+
|
|
520
|
+
// filters 미전달
|
|
521
|
+
const result1 = await listDef!.handler(mockCtx, {});
|
|
522
|
+
expect(result1).toHaveProperty("data");
|
|
523
|
+
|
|
524
|
+
// filters 빈 객체
|
|
525
|
+
const result2 = await listDef!.handler(mockCtx, { filters: {} });
|
|
526
|
+
expect(result2).toHaveProperty("data");
|
|
527
|
+
});
|
|
527
528
|
});
|
|
528
529
|
|
|
529
530
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -531,190 +532,175 @@ describe("crud() — allowedFilters", () => {
|
|
|
531
532
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
532
533
|
|
|
533
534
|
describe("v3 Filter Engine — parseFilterNode", () => {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
id: serial("id").primaryKey(),
|
|
581
|
-
title: text("title"),
|
|
582
|
-
description: text("description"),
|
|
583
|
-
});
|
|
584
|
-
|
|
585
|
-
const result = parseFilterNode({
|
|
586
|
-
OR: [
|
|
587
|
-
{ title: { op: "ilike", value: "%AI%" } },
|
|
588
|
-
{ description: { op: "ilike", value: "%데이터%" } },
|
|
589
|
-
],
|
|
590
|
-
}, table);
|
|
591
|
-
|
|
592
|
-
expect(result).toBeDefined();
|
|
593
|
-
});
|
|
594
|
-
|
|
595
|
-
// TC3: 보안 방어 — allowedFilters 위반
|
|
596
|
-
it("TC3: allowedFilters에 없는 필드는 완전 묵살 (직접 접근)", () => {
|
|
597
|
-
const table = pgTable("tc3_test", {
|
|
598
|
-
id: serial("id").primaryKey(),
|
|
599
|
-
status: text("status"),
|
|
600
|
-
title: text("title"),
|
|
601
|
-
passwordHash: text("password_hash"),
|
|
602
|
-
});
|
|
603
|
-
|
|
604
|
-
// password_hash 직접 접근 → 묵살
|
|
605
|
-
const r1 = parseFilterNode(
|
|
606
|
-
{ passwordHash: { op: "eq", value: "xxx" } },
|
|
607
|
-
table,
|
|
608
|
-
["status", "title"],
|
|
609
|
-
);
|
|
610
|
-
expect(r1).toBeUndefined(); // 모든 조건 제거됨
|
|
535
|
+
// TC1: 하위 호환성 (Implicit eq)
|
|
536
|
+
it("TC1: 단순 key-value는 암묵적 eq로 파싱된다 (하위호환)", () => {
|
|
537
|
+
const table = pgTable("tc1_test", {
|
|
538
|
+
id: serial("id").primaryKey(),
|
|
539
|
+
status: text("status"),
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
const result = parseFilterNode({ status: "active" }, table);
|
|
543
|
+
expect(result).toBeDefined();
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it("TC1: 여러 key-value 동시 사용 시 모두 AND 결합된다", () => {
|
|
547
|
+
const table = pgTable("tc1b_test", {
|
|
548
|
+
id: serial("id").primaryKey(),
|
|
549
|
+
status: text("status"),
|
|
550
|
+
category: text("category"),
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
const result = parseFilterNode({ status: "active", category: "tech" }, table);
|
|
554
|
+
expect(result).toBeDefined();
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// TC2: 다중 논리망 (Nested OR & AND)
|
|
558
|
+
it("TC2: AND + 중첩 OR 필터가 SQL 조건으로 빌드된다", () => {
|
|
559
|
+
const table = pgTable("tc2_test", {
|
|
560
|
+
id: serial("id").primaryKey(),
|
|
561
|
+
category: text("category"),
|
|
562
|
+
qty: serial("qty"),
|
|
563
|
+
flag: text("flag"),
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
const result = parseFilterNode(
|
|
567
|
+
{
|
|
568
|
+
AND: [{ category: "A" }, { OR: [{ qty: { op: "gte", value: 10 } }, { flag: true }] }],
|
|
569
|
+
},
|
|
570
|
+
table,
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
expect(result).toBeDefined();
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it("TC2: 단순 OR 배열도 파싱된다", () => {
|
|
577
|
+
const table = pgTable("tc2b_test", {
|
|
578
|
+
id: serial("id").primaryKey(),
|
|
579
|
+
title: text("title"),
|
|
580
|
+
description: text("description"),
|
|
611
581
|
});
|
|
612
582
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
583
|
+
const result = parseFilterNode(
|
|
584
|
+
{
|
|
585
|
+
OR: [{ title: { op: "ilike", value: "%AI%" } }, { description: { op: "ilike", value: "%데이터%" } }],
|
|
586
|
+
},
|
|
587
|
+
table,
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
expect(result).toBeDefined();
|
|
591
|
+
});
|
|
619
592
|
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
593
|
+
// TC3: 보안 방어 — allowedFilters 위반
|
|
594
|
+
it("TC3: allowedFilters에 없는 필드는 완전 묵살 (직접 접근)", () => {
|
|
595
|
+
const table = pgTable("tc3_test", {
|
|
596
|
+
id: serial("id").primaryKey(),
|
|
597
|
+
status: text("status"),
|
|
598
|
+
title: text("title"),
|
|
599
|
+
passwordHash: text("password_hash"),
|
|
627
600
|
});
|
|
628
601
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
});
|
|
602
|
+
// password_hash 직접 접근 → 묵살
|
|
603
|
+
const r1 = parseFilterNode({ passwordHash: { op: "eq", value: "xxx" } }, table, ["status", "title"]);
|
|
604
|
+
expect(r1).toBeUndefined(); // 모든 조건 제거됨
|
|
605
|
+
});
|
|
634
606
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
expect(r).toBeUndefined();
|
|
607
|
+
it("TC3: OR 틈새 우회 시도 시 미허용 필드만 묵살, 허용 필드는 유지", () => {
|
|
608
|
+
const table = pgTable("tc3b_test", {
|
|
609
|
+
id: serial("id").primaryKey(),
|
|
610
|
+
status: text("status"),
|
|
611
|
+
isAdmin: text("is_admin"),
|
|
641
612
|
});
|
|
642
613
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
age: serial("age"),
|
|
648
|
-
});
|
|
614
|
+
const r = parseFilterNode({ OR: [{ status: "public" }, { isAdmin: true }] }, table, ["status"]);
|
|
615
|
+
// status는 허용 → OR 조건 살아있음
|
|
616
|
+
expect(r).toBeDefined();
|
|
617
|
+
});
|
|
649
618
|
|
|
650
|
-
|
|
619
|
+
it("TC3: 모든 필드가 미허용이면 undefined 반환", () => {
|
|
620
|
+
const table = pgTable("tc3c_test", {
|
|
621
|
+
id: serial("id").primaryKey(),
|
|
622
|
+
secret: text("secret"),
|
|
651
623
|
});
|
|
652
624
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
age: serial("age"),
|
|
657
|
-
});
|
|
625
|
+
const r = parseFilterNode({ OR: [{ secret: "hi" }] }, table, ["status"]);
|
|
626
|
+
expect(r).toBeUndefined();
|
|
627
|
+
});
|
|
658
628
|
|
|
659
|
-
|
|
629
|
+
// TC4: 악의적 페이로드 구조 방어
|
|
630
|
+
it("TC4: OR에 비배열 전달 시 무시, 크래시 없음", () => {
|
|
631
|
+
const table = pgTable("tc4_test", {
|
|
632
|
+
id: serial("id").primaryKey(),
|
|
633
|
+
age: serial("age"),
|
|
660
634
|
});
|
|
661
635
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
id: serial("id").primaryKey(),
|
|
665
|
-
});
|
|
636
|
+
expect(parseFilterNode({ OR: "not-an-array" as any }, table)).toBeUndefined();
|
|
637
|
+
});
|
|
666
638
|
|
|
667
|
-
|
|
668
|
-
|
|
639
|
+
it("TC4: 미지원 op 키워드(DROP TABLE 등) 묵살, 크래시 없음", () => {
|
|
640
|
+
const table = pgTable("tc4b_test", {
|
|
641
|
+
id: serial("id").primaryKey(),
|
|
642
|
+
age: serial("age"),
|
|
669
643
|
});
|
|
670
644
|
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
id: serial("id").primaryKey(),
|
|
674
|
-
name: text("name"),
|
|
675
|
-
});
|
|
645
|
+
expect(parseFilterNode({ age: { op: "DROP TABLE", value: 1 } }, table)).toBeUndefined();
|
|
646
|
+
});
|
|
676
647
|
|
|
677
|
-
|
|
678
|
-
|
|
648
|
+
it("TC4: AND에 null/string/number 등 비객체 원소 묵살", () => {
|
|
649
|
+
const table = pgTable("tc4c_test", {
|
|
650
|
+
id: serial("id").primaryKey(),
|
|
679
651
|
});
|
|
680
652
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
id: serial("id").primaryKey(),
|
|
685
|
-
status: text("status"),
|
|
686
|
-
});
|
|
653
|
+
expect(parseFilterNode({ AND: [null, "string", 42] as any }, table)).toBeUndefined();
|
|
654
|
+
expect(parseFilterNode({ AND: null as any }, table)).toBeUndefined();
|
|
655
|
+
});
|
|
687
656
|
|
|
688
|
-
|
|
689
|
-
|
|
657
|
+
it("TC4: 존재하지 않는 컬럼명 필터는 묵살", () => {
|
|
658
|
+
const table = pgTable("tc4d_test", {
|
|
659
|
+
id: serial("id").primaryKey(),
|
|
660
|
+
name: text("name"),
|
|
690
661
|
});
|
|
691
662
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
});
|
|
663
|
+
const r = parseFilterNode({ nonExistentCol: "value" }, table);
|
|
664
|
+
expect(r).toBeUndefined();
|
|
665
|
+
});
|
|
696
666
|
|
|
697
|
-
|
|
698
|
-
|
|
667
|
+
// TC5: IN / NIN 연산자 배열값 처리
|
|
668
|
+
it("TC5: in 연산자 — 정상 배열은 SQL 생성", () => {
|
|
669
|
+
const table = pgTable("tc5_test", {
|
|
670
|
+
id: serial("id").primaryKey(),
|
|
671
|
+
status: text("status"),
|
|
699
672
|
});
|
|
700
673
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
});
|
|
674
|
+
const r = parseFilterNode({ id: { op: "in", value: [1, 2, 3] } }, table);
|
|
675
|
+
expect(r).toBeDefined();
|
|
676
|
+
});
|
|
705
677
|
|
|
706
|
-
|
|
707
|
-
|
|
678
|
+
it("TC5: in 연산자 — 비배열은 묵살", () => {
|
|
679
|
+
const table = pgTable("tc5b_test", {
|
|
680
|
+
id: serial("id").primaryKey(),
|
|
708
681
|
});
|
|
709
682
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
});
|
|
683
|
+
const r = parseFilterNode({ id: { op: "in", value: "1" } }, table);
|
|
684
|
+
expect(r).toBeUndefined();
|
|
685
|
+
});
|
|
714
686
|
|
|
715
|
-
|
|
716
|
-
|
|
687
|
+
it("TC5: in 연산자 — 빈 배열은 묵살", () => {
|
|
688
|
+
const table = pgTable("tc5c_test", {
|
|
689
|
+
id: serial("id").primaryKey(),
|
|
717
690
|
});
|
|
691
|
+
|
|
692
|
+
const r = parseFilterNode({ id: { op: "in", value: [] } }, table);
|
|
693
|
+
expect(r).toBeUndefined();
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it("TC5: nin 연산자 — 정상 배열은 SQL 생성", () => {
|
|
697
|
+
const table = pgTable("tc5d_test", {
|
|
698
|
+
id: serial("id").primaryKey(),
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
const r = parseFilterNode({ id: { op: "nin", value: [10, 20] } }, table);
|
|
702
|
+
expect(r).toBeDefined();
|
|
703
|
+
});
|
|
718
704
|
});
|
|
719
705
|
|
|
720
706
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -722,145 +708,145 @@ describe("v3 Filter Engine — parseFilterNode", () => {
|
|
|
722
708
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
723
709
|
|
|
724
710
|
describe("v3 Filter Engine — applyFilterOp", () => {
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
711
|
+
const table = pgTable("op_test", {
|
|
712
|
+
id: serial("id").primaryKey(),
|
|
713
|
+
name: text("name"),
|
|
714
|
+
age: serial("age"),
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
it("eq 연산자가 SQL 조건을 반환한다", () => {
|
|
718
|
+
expect(applyFilterOp(table.name, "eq", "test")).toBeDefined();
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it("ne 연산자가 SQL 조건을 반환한다", () => {
|
|
722
|
+
expect(applyFilterOp(table.name, "ne", "test")).toBeDefined();
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it("gt/gte/lt/lte 비교 연산자가 SQL 조건을 반환한다", () => {
|
|
726
|
+
expect(applyFilterOp(table.age, "gt", 10)).toBeDefined();
|
|
727
|
+
expect(applyFilterOp(table.age, "gte", 18)).toBeDefined();
|
|
728
|
+
expect(applyFilterOp(table.age, "lt", 100)).toBeDefined();
|
|
729
|
+
expect(applyFilterOp(table.age, "lte", 65)).toBeDefined();
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
it("like/ilike 패턴 연산자가 SQL 조건을 반환한다", () => {
|
|
733
|
+
expect(applyFilterOp(table.name, "like", "%test%")).toBeDefined();
|
|
734
|
+
expect(applyFilterOp(table.name, "ilike", "%TEST%")).toBeDefined();
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
it("in 연산자 — 정상 배열은 SQL 반환", () => {
|
|
738
|
+
expect(applyFilterOp(table.id, "in", [1, 2, 3])).toBeDefined();
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it("nin 연산자 — 빈 배열은 undefined 반환", () => {
|
|
742
|
+
expect(applyFilterOp(table.id, "nin", [])).toBeUndefined();
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it("미지원 연산자는 undefined 반환", () => {
|
|
746
|
+
expect(applyFilterOp(table.id, "INVALID" as any, 1)).toBeUndefined();
|
|
747
|
+
});
|
|
748
|
+
});
|
|
730
749
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
750
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
751
|
+
// 13. v3 crud() handler 통합 테스트 — Advanced Filters
|
|
752
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
734
753
|
|
|
735
|
-
|
|
736
|
-
|
|
754
|
+
describe("v3 crud() — advanced filters through handler", () => {
|
|
755
|
+
it("allowedFilters 미설정 시 필터가 무시된다 (Secure by Default)", async () => {
|
|
756
|
+
const openTable = pgTable("v3_open_filter", {
|
|
757
|
+
id: serial("id").primaryKey(),
|
|
758
|
+
status: text("status"),
|
|
759
|
+
createdAt: timestamp("created_at").defaultNow(),
|
|
737
760
|
});
|
|
738
761
|
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
762
|
+
// allowedFilters 미설정 → 필터 전체 무시 (v2 호환, Secure by Default)
|
|
763
|
+
crud(openTable, { public: true });
|
|
764
|
+
|
|
765
|
+
const listDef = getQueryDef("v3_open_filter.list");
|
|
766
|
+
expect(listDef).toBeDefined();
|
|
767
|
+
|
|
768
|
+
const mockCtx = createListMockCtx([{ id: 1, status: "active" }]);
|
|
769
|
+
const result = await listDef!.handler(mockCtx, {
|
|
770
|
+
filters: { status: { op: "eq", value: "active" } },
|
|
744
771
|
});
|
|
745
772
|
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
773
|
+
// 필터가 무시되어도 에러 없이 정상 동작
|
|
774
|
+
expect(result).toHaveProperty("data");
|
|
775
|
+
expect(result).toHaveProperty("total");
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it("allowedFilters 설정 시 v3 고급 필터가 동작한다", async () => {
|
|
779
|
+
const advTable = pgTable("v3_adv_filter", {
|
|
780
|
+
id: serial("id").primaryKey(),
|
|
781
|
+
status: text("status"),
|
|
782
|
+
createdAt: timestamp("created_at").defaultNow(),
|
|
749
783
|
});
|
|
750
784
|
|
|
751
|
-
|
|
752
|
-
|
|
785
|
+
crud(advTable, { public: true, allowedFilters: ["status"] });
|
|
786
|
+
|
|
787
|
+
const listDef = getQueryDef("v3_adv_filter.list");
|
|
788
|
+
const mockCtx = createListMockCtx([{ id: 1, status: "active" }]);
|
|
789
|
+
const result = await listDef!.handler(mockCtx, {
|
|
790
|
+
filters: { status: { op: "eq", value: "active" } },
|
|
753
791
|
});
|
|
754
792
|
|
|
755
|
-
|
|
756
|
-
|
|
793
|
+
expect(result).toHaveProperty("data");
|
|
794
|
+
expect(result).toHaveProperty("total");
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it("복합 OR/AND 필터가 handler에서 에러 없이 실행된다", async () => {
|
|
798
|
+
const complexTable = pgTable("v3_complex", {
|
|
799
|
+
id: serial("id").primaryKey(),
|
|
800
|
+
title: text("title"),
|
|
801
|
+
status: text("status"),
|
|
802
|
+
createdAt: timestamp("created_at").defaultNow(),
|
|
757
803
|
});
|
|
758
804
|
|
|
759
|
-
|
|
760
|
-
|
|
805
|
+
crud(complexTable, { public: true, allowedFilters: ["title", "status"] });
|
|
806
|
+
|
|
807
|
+
const listDef = getQueryDef("v3_complex.list");
|
|
808
|
+
const mockCtx = createListMockCtx([]);
|
|
809
|
+
|
|
810
|
+
const result = await listDef!.handler(mockCtx, {
|
|
811
|
+
filters: {
|
|
812
|
+
OR: [
|
|
813
|
+
{ title: { op: "ilike", value: "%AI%" } },
|
|
814
|
+
{ status: { op: "in", value: ["active", "archived"] } },
|
|
815
|
+
],
|
|
816
|
+
},
|
|
761
817
|
});
|
|
762
|
-
});
|
|
763
818
|
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
819
|
+
expect(result).toHaveProperty("data");
|
|
820
|
+
expect(result.total).toBe(0);
|
|
821
|
+
});
|
|
767
822
|
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
createdAt: timestamp("created_at").defaultNow(),
|
|
774
|
-
});
|
|
775
|
-
|
|
776
|
-
// allowedFilters 미설정 → 필터 전체 무시 (v2 호환, Secure by Default)
|
|
777
|
-
crud(openTable, { public: true });
|
|
778
|
-
|
|
779
|
-
const listDef = getQueryDef("v3_open_filter.list");
|
|
780
|
-
expect(listDef).toBeDefined();
|
|
781
|
-
|
|
782
|
-
const mockCtx = createListMockCtx([{ id: 1, status: "active" }]);
|
|
783
|
-
const result = await listDef!.handler(mockCtx, {
|
|
784
|
-
filters: { status: { op: "eq", value: "active" } },
|
|
785
|
-
});
|
|
786
|
-
|
|
787
|
-
// 필터가 무시되어도 에러 없이 정상 동작
|
|
788
|
-
expect(result).toHaveProperty("data");
|
|
789
|
-
expect(result).toHaveProperty("total");
|
|
790
|
-
});
|
|
791
|
-
|
|
792
|
-
it("allowedFilters 설정 시 v3 고급 필터가 동작한다", async () => {
|
|
793
|
-
const advTable = pgTable("v3_adv_filter", {
|
|
794
|
-
id: serial("id").primaryKey(),
|
|
795
|
-
status: text("status"),
|
|
796
|
-
createdAt: timestamp("created_at").defaultNow(),
|
|
797
|
-
});
|
|
798
|
-
|
|
799
|
-
crud(advTable, { public: true, allowedFilters: ["status"] });
|
|
800
|
-
|
|
801
|
-
const listDef = getQueryDef("v3_adv_filter.list");
|
|
802
|
-
const mockCtx = createListMockCtx([{ id: 1, status: "active" }]);
|
|
803
|
-
const result = await listDef!.handler(mockCtx, {
|
|
804
|
-
filters: { status: { op: "eq", value: "active" } },
|
|
805
|
-
});
|
|
806
|
-
|
|
807
|
-
expect(result).toHaveProperty("data");
|
|
808
|
-
expect(result).toHaveProperty("total");
|
|
809
|
-
});
|
|
810
|
-
|
|
811
|
-
it("복합 OR/AND 필터가 handler에서 에러 없이 실행된다", async () => {
|
|
812
|
-
const complexTable = pgTable("v3_complex", {
|
|
813
|
-
id: serial("id").primaryKey(),
|
|
814
|
-
title: text("title"),
|
|
815
|
-
status: text("status"),
|
|
816
|
-
createdAt: timestamp("created_at").defaultNow(),
|
|
817
|
-
});
|
|
818
|
-
|
|
819
|
-
crud(complexTable, { public: true, allowedFilters: ["title", "status"] });
|
|
820
|
-
|
|
821
|
-
const listDef = getQueryDef("v3_complex.list");
|
|
822
|
-
const mockCtx = createListMockCtx([]);
|
|
823
|
-
|
|
824
|
-
const result = await listDef!.handler(mockCtx, {
|
|
825
|
-
filters: {
|
|
826
|
-
OR: [
|
|
827
|
-
{ title: { op: "ilike", value: "%AI%" } },
|
|
828
|
-
{ status: { op: "in", value: ["active", "archived"] } },
|
|
829
|
-
],
|
|
830
|
-
},
|
|
831
|
-
});
|
|
832
|
-
|
|
833
|
-
expect(result).toHaveProperty("data");
|
|
834
|
-
expect(result.total).toBe(0);
|
|
835
|
-
});
|
|
836
|
-
|
|
837
|
-
it("악성 페이로드가 handler를 크래시시키지 않는다", async () => {
|
|
838
|
-
const safeTable = pgTable("v3_safe", {
|
|
839
|
-
id: serial("id").primaryKey(),
|
|
840
|
-
name: text("name"),
|
|
841
|
-
createdAt: timestamp("created_at").defaultNow(),
|
|
842
|
-
});
|
|
843
|
-
|
|
844
|
-
crud(safeTable, { public: true });
|
|
845
|
-
|
|
846
|
-
const listDef = getQueryDef("v3_safe.list");
|
|
847
|
-
const mockCtx = createListMockCtx([{ id: 1 }]);
|
|
848
|
-
|
|
849
|
-
// 다양한 악성 페이로드 — 모두 에러 없이 실행
|
|
850
|
-
const payloads = [
|
|
851
|
-
{ filters: { OR: "not-array" } },
|
|
852
|
-
{ filters: { AND: null } },
|
|
853
|
-
{ filters: { name: { op: "EVIL_OP", value: 1 } } },
|
|
854
|
-
{ filters: { OR: [null, undefined, 42, "bad"] } },
|
|
855
|
-
{ filters: {} },
|
|
856
|
-
];
|
|
857
|
-
|
|
858
|
-
for (const payload of payloads) {
|
|
859
|
-
const result = await listDef!.handler(mockCtx, payload);
|
|
860
|
-
expect(result).toHaveProperty("data");
|
|
861
|
-
expect(result).toHaveProperty("total");
|
|
862
|
-
}
|
|
823
|
+
it("악성 페이로드가 handler를 크래시시키지 않는다", async () => {
|
|
824
|
+
const safeTable = pgTable("v3_safe", {
|
|
825
|
+
id: serial("id").primaryKey(),
|
|
826
|
+
name: text("name"),
|
|
827
|
+
createdAt: timestamp("created_at").defaultNow(),
|
|
863
828
|
});
|
|
829
|
+
|
|
830
|
+
crud(safeTable, { public: true });
|
|
831
|
+
|
|
832
|
+
const listDef = getQueryDef("v3_safe.list");
|
|
833
|
+
const mockCtx = createListMockCtx([{ id: 1 }]);
|
|
834
|
+
|
|
835
|
+
// 다양한 악성 페이로드 — 모두 에러 없이 실행
|
|
836
|
+
const payloads = [
|
|
837
|
+
{ filters: { OR: "not-array" } },
|
|
838
|
+
{ filters: { AND: null } },
|
|
839
|
+
{ filters: { name: { op: "EVIL_OP", value: 1 } } },
|
|
840
|
+
{ filters: { OR: [null, undefined, 42, "bad"] } },
|
|
841
|
+
{ filters: {} },
|
|
842
|
+
];
|
|
843
|
+
|
|
844
|
+
for (const payload of payloads) {
|
|
845
|
+
const result = await listDef!.handler(mockCtx, payload);
|
|
846
|
+
expect(result).toHaveProperty("data");
|
|
847
|
+
expect(result).toHaveProperty("total");
|
|
848
|
+
}
|
|
849
|
+
});
|
|
864
850
|
});
|
|
865
851
|
|
|
866
852
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -868,80 +854,77 @@ describe("v3 crud() — advanced filters through handler", () => {
|
|
|
868
854
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
869
855
|
|
|
870
856
|
describe("v3 보안 강화 — 재귀 깊이 제한", () => {
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
});
|
|
876
|
-
|
|
877
|
-
// 깊이 6단계 중첩 — MAX_FILTER_DEPTH=5 초과
|
|
878
|
-
const deepFilter = {
|
|
879
|
-
OR: [{ AND: [{ OR: [{ AND: [{ OR: [{ AND: [{ name: "deep" }] }] }] }] }] }],
|
|
880
|
-
};
|
|
881
|
-
|
|
882
|
-
const result = parseFilterNode(deepFilter, table);
|
|
883
|
-
// 깊이 초과된 부분이 묵살되어 결과가 undefined이거나 부분 조건만 남음
|
|
884
|
-
// 중요한 것은 스택 오버플로 없이 정상 반환
|
|
885
|
-
expect(() => parseFilterNode(deepFilter, table)).not.toThrow();
|
|
886
|
-
});
|
|
887
|
-
|
|
888
|
-
it("MAX_FILTER_DEPTH 이내의 중첩은 정상 동작", () => {
|
|
889
|
-
const table = pgTable("depth_ok_test", {
|
|
890
|
-
id: serial("id").primaryKey(),
|
|
891
|
-
name: text("name"),
|
|
892
|
-
status: text("status"),
|
|
893
|
-
});
|
|
894
|
-
|
|
895
|
-
// 깊이 3단계 — 정상 범위
|
|
896
|
-
const normalFilter = {
|
|
897
|
-
OR: [
|
|
898
|
-
{ AND: [{ OR: [{ name: "test" }] }] },
|
|
899
|
-
{ status: "active" },
|
|
900
|
-
],
|
|
901
|
-
};
|
|
902
|
-
|
|
903
|
-
const result = parseFilterNode(normalFilter, table);
|
|
904
|
-
expect(result).toBeDefined();
|
|
905
|
-
});
|
|
906
|
-
|
|
907
|
-
it("극단적 깊이(100단계)에서도 크래시 없음", () => {
|
|
908
|
-
const table = pgTable("depth_extreme_test", {
|
|
909
|
-
id: serial("id").primaryKey(),
|
|
910
|
-
name: text("name"),
|
|
911
|
-
});
|
|
912
|
-
|
|
913
|
-
// 100단계 중첩 생성
|
|
914
|
-
let filter: any = { name: "leaf" };
|
|
915
|
-
for (let i = 0; i < 100; i++) {
|
|
916
|
-
filter = i % 2 === 0 ? { OR: [filter] } : { AND: [filter] };
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
expect(() => parseFilterNode(filter, table)).not.toThrow();
|
|
920
|
-
// 깊이 초과로 조건 묵살 → undefined
|
|
921
|
-
const result = parseFilterNode(filter, table);
|
|
922
|
-
expect(result).toBeUndefined();
|
|
857
|
+
it("MAX_FILTER_DEPTH(5) 초과 시 조건 묵살", () => {
|
|
858
|
+
const table = pgTable("depth_test", {
|
|
859
|
+
id: serial("id").primaryKey(),
|
|
860
|
+
name: text("name"),
|
|
923
861
|
});
|
|
924
|
-
});
|
|
925
862
|
|
|
926
|
-
|
|
927
|
-
const
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
});
|
|
863
|
+
// 깊이 6단계 중첩 — MAX_FILTER_DEPTH=5 초과
|
|
864
|
+
const deepFilter = {
|
|
865
|
+
OR: [{ AND: [{ OR: [{ AND: [{ OR: [{ AND: [{ name: "deep" }] }] }] }] }] }],
|
|
866
|
+
};
|
|
931
867
|
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
868
|
+
const result = parseFilterNode(deepFilter, table);
|
|
869
|
+
// 깊이 초과된 부분이 묵살되어 결과가 undefined이거나 부분 조건만 남음
|
|
870
|
+
// 중요한 것은 스택 오버플로 없이 정상 반환
|
|
871
|
+
expect(() => parseFilterNode(deepFilter, table)).not.toThrow();
|
|
872
|
+
});
|
|
937
873
|
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
874
|
+
it("MAX_FILTER_DEPTH 이내의 중첩은 정상 동작", () => {
|
|
875
|
+
const table = pgTable("depth_ok_test", {
|
|
876
|
+
id: serial("id").primaryKey(),
|
|
877
|
+
name: text("name"),
|
|
878
|
+
status: text("status"),
|
|
941
879
|
});
|
|
942
880
|
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
881
|
+
// 깊이 3단계 — 정상 범위
|
|
882
|
+
const normalFilter = {
|
|
883
|
+
OR: [{ AND: [{ OR: [{ name: "test" }] }] }, { status: "active" }],
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
const result = parseFilterNode(normalFilter, table);
|
|
887
|
+
expect(result).toBeDefined();
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
it("극단적 깊이(100단계)에서도 크래시 없음", () => {
|
|
891
|
+
const table = pgTable("depth_extreme_test", {
|
|
892
|
+
id: serial("id").primaryKey(),
|
|
893
|
+
name: text("name"),
|
|
946
894
|
});
|
|
895
|
+
|
|
896
|
+
// 100단계 중첩 생성
|
|
897
|
+
let filter: any = { name: "leaf" };
|
|
898
|
+
for (let i = 0; i < 100; i++) {
|
|
899
|
+
filter = i % 2 === 0 ? { OR: [filter] } : { AND: [filter] };
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
expect(() => parseFilterNode(filter, table)).not.toThrow();
|
|
903
|
+
// 깊이 초과로 조건 묵살 → undefined
|
|
904
|
+
const result = parseFilterNode(filter, table);
|
|
905
|
+
expect(result).toBeUndefined();
|
|
906
|
+
});
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
describe("v3 보안 강화 — like/ilike 타입 검증", () => {
|
|
910
|
+
const table = pgTable("like_type_test", {
|
|
911
|
+
id: serial("id").primaryKey(),
|
|
912
|
+
name: text("name"),
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
it("like에 문자열이 아닌 값 전달 시 undefined 반환", () => {
|
|
916
|
+
expect(applyFilterOp(table.name, "like", 12345)).toBeUndefined();
|
|
917
|
+
expect(applyFilterOp(table.name, "like", null)).toBeUndefined();
|
|
918
|
+
expect(applyFilterOp(table.name, "like", { evil: true })).toBeUndefined();
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
it("ilike에 문자열이 아닌 값 전달 시 undefined 반환", () => {
|
|
922
|
+
expect(applyFilterOp(table.name, "ilike", 99)).toBeUndefined();
|
|
923
|
+
expect(applyFilterOp(table.name, "ilike", ["array"])).toBeUndefined();
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
it("like/ilike에 정상 문자열 전달 시 SQL 반환", () => {
|
|
927
|
+
expect(applyFilterOp(table.name, "like", "%test%")).toBeDefined();
|
|
928
|
+
expect(applyFilterOp(table.name, "ilike", "%TEST%")).toBeDefined();
|
|
929
|
+
});
|
|
947
930
|
});
|