@gencow/core 0.1.27 → 0.1.28

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.
Files changed (83) hide show
  1. package/dist/document-types.d.ts +65 -0
  2. package/dist/document-types.js +15 -0
  3. package/dist/grounded-answer-types.d.ts +62 -0
  4. package/dist/grounded-answer-types.js +6 -0
  5. package/dist/index.d.ts +10 -1
  6. package/dist/index.js +4 -0
  7. package/dist/rag-ingest-types.d.ts +39 -0
  8. package/dist/rag-ingest-types.js +1 -0
  9. package/dist/rag-operations-types.d.ts +81 -0
  10. package/dist/rag-operations-types.js +1 -0
  11. package/dist/rag-schema.d.ts +1557 -0
  12. package/dist/rag-schema.js +87 -0
  13. package/dist/reactive.d.ts +13 -0
  14. package/dist/rls-db.d.ts +9 -2
  15. package/dist/runtime-env-policy.d.ts +5 -0
  16. package/dist/runtime-env-policy.js +56 -0
  17. package/dist/search-types.d.ts +83 -0
  18. package/dist/search-types.js +1 -0
  19. package/dist/server.d.ts +1 -2
  20. package/dist/server.js +0 -1
  21. package/dist/storage-shared.d.ts +36 -0
  22. package/dist/storage-shared.js +39 -0
  23. package/dist/storage.d.ts +2 -26
  24. package/dist/storage.js +19 -15
  25. package/dist/workflow-types.d.ts +3 -1
  26. package/package.json +8 -7
  27. package/src/document-types.ts +95 -0
  28. package/src/grounded-answer-types.ts +78 -0
  29. package/src/index.ts +66 -1
  30. package/src/rag-ingest-types.ts +52 -0
  31. package/src/rag-operations-types.ts +90 -0
  32. package/src/rag-schema.ts +94 -0
  33. package/src/reactive.ts +13 -0
  34. package/src/rls-db.ts +9 -4
  35. package/src/runtime-env-policy.ts +66 -0
  36. package/src/search-types.ts +91 -0
  37. package/src/server.ts +1 -2
  38. package/src/storage-shared.ts +74 -0
  39. package/src/storage.ts +29 -46
  40. package/src/workflow-types.ts +3 -1
  41. package/src/__tests__/auth.test.ts +0 -118
  42. package/src/__tests__/crons.test.ts +0 -83
  43. package/src/__tests__/crud-codegen-integration.test.ts +0 -246
  44. package/src/__tests__/crud-owner-rls.test.ts +0 -387
  45. package/src/__tests__/crud.test.ts +0 -930
  46. package/src/__tests__/dist-exports.test.ts +0 -176
  47. package/src/__tests__/fixtures/basic/auth.ts +0 -32
  48. package/src/__tests__/fixtures/basic/drizzle.config.ts +0 -12
  49. package/src/__tests__/fixtures/basic/index.ts +0 -6
  50. package/src/__tests__/fixtures/basic/migrations/0000_last_warstar.sql +0 -75
  51. package/src/__tests__/fixtures/basic/migrations/meta/0000_snapshot.json +0 -497
  52. package/src/__tests__/fixtures/basic/migrations/meta/_journal.json +0 -13
  53. package/src/__tests__/fixtures/basic/schema.ts +0 -51
  54. package/src/__tests__/fixtures/basic/tasks.ts +0 -15
  55. package/src/__tests__/fixtures/common/auth-schema.ts +0 -67
  56. package/src/__tests__/helpers/basic-rls-fixture.ts +0 -135
  57. package/src/__tests__/helpers/pglite-migrations.ts +0 -32
  58. package/src/__tests__/helpers/pglite-rls-session.ts +0 -51
  59. package/src/__tests__/helpers/seed-like-fill.ts +0 -202
  60. package/src/__tests__/helpers/test-gencow-ctx-rls.ts +0 -50
  61. package/src/__tests__/httpaction.test.ts +0 -122
  62. package/src/__tests__/image-optimization.test.ts +0 -648
  63. package/src/__tests__/load.test.ts +0 -389
  64. package/src/__tests__/network-sim.test.ts +0 -319
  65. package/src/__tests__/reactive.test.ts +0 -479
  66. package/src/__tests__/retry.test.ts +0 -113
  67. package/src/__tests__/rls-crud-basic.test.ts +0 -317
  68. package/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts +0 -117
  69. package/src/__tests__/rls-custom-mutation-handlers.test.ts +0 -142
  70. package/src/__tests__/rls-custom-query-handlers.test.ts +0 -128
  71. package/src/__tests__/rls-db-leased-connection.test.ts +0 -118
  72. package/src/__tests__/rls-session-and-policies.test.ts +0 -228
  73. package/src/__tests__/scheduler-durable-v2.test.ts +0 -288
  74. package/src/__tests__/scheduler-durable.test.ts +0 -173
  75. package/src/__tests__/scheduler-exec.test.ts +0 -328
  76. package/src/__tests__/scheduler.test.ts +0 -187
  77. package/src/__tests__/storage.test.ts +0 -334
  78. package/src/__tests__/tsconfig.json +0 -8
  79. package/src/__tests__/validator.test.ts +0 -323
  80. package/src/__tests__/workflow.test.ts +0 -606
  81. package/src/__tests__/ws-integration.test.ts +0 -309
  82. package/src/__tests__/ws-scale.test.ts +0 -241
  83. package/src/auth.ts +0 -155
@@ -1,930 +0,0 @@
1
- /**
2
- * packages/core/src/__tests__/crud.test.ts
3
- *
4
- * crud() v2 Zero-Boilerplate API Factory — 유닛 테스트
5
- *
6
- * 검증 항목:
7
- * 1. 팩토리가 query/mutation을 글로벌 레지스트리에 자동 등록하는지
8
- * 2. Secure by Default: 기본 auth 필수, public 옵션 동작
9
- * 3. 반환 객체 구조: { list, get, create, update, remove }
10
- * 4. 테이블명 기반 key 생성 (prefix 오버라이드)
11
- * 5. options 기본값 (defaultLimit, maxLimit, realtime)
12
- * 6. UUID id 자동 감지 (text PK → v.string())
13
- * 7. list 반환값: { data: T[], total: number }
14
- * 8. allowedFilters 화이트리스트 동작
15
- *
16
- * Run: bun test packages/core/src/__tests__/crud.test.ts
17
- */
18
-
19
- import { describe, it, expect, beforeAll } from "bun:test";
20
- import { getRegisteredQueries, getRegisteredMutations, getQueryDef } from "../reactive.js";
21
-
22
- // ─── Mock PgTable ────────────────────────────────────────────────────────────
23
- // 실제 Drizzle pgTable을 사용 — getTableName() 등 Drizzle 공식 API 호환 보장
24
-
25
- import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
26
-
27
- // ─── crud import ───────────────────────────────────────────────────────
28
-
29
- import { crud, parseFilterNode, applyFilterOp } from "../crud.js";
30
-
31
- // ─── 테스트 테이블 정의 ────────────────────────────────────────────────────
32
-
33
- const tasks = pgTable("tasks", {
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
- });
41
-
42
- const articles = pgTable("articles", {
43
- id: serial("id").primaryKey(),
44
- title: text("title"),
45
- createdAt: timestamp("created_at").defaultNow(),
46
- });
47
-
48
- const items = pgTable("items", {
49
- id: serial("id").primaryKey(),
50
- name: text("name"),
51
- });
52
-
53
- // ─── Mock 유틸 ─────────────────────────────────────────────────────────
54
-
55
- /**
56
- * list handler가 parallel로 SELECT + COUNT를 실행하므로,
57
- * select()가 두 가지 호출 패턴을 지원해야 함:
58
- * 1. select() → data query chain
59
- * 2. select({count: _}) → count query chain
60
- */
61
- function createListMockCtx(mockData: any[], opts?: { authCalled?: { value: boolean } }) {
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
- }
96
-
97
- /**
98
- * mutation handler 실행용 mock context.
99
- * insert/update/delete + realtime emit + fetchListWithTotal(select+count) 지원.
100
- */
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]) };
133
- },
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 };
148
- }
149
-
150
- // ═══════════════════════════════════════════════════════════════════════════════
151
- // 1. 기본 동작: 팩토리 반환값 + 레지스트리 등록
152
- // ═══════════════════════════════════════════════════════════════════════════════
153
-
154
- describe("crud() — 기본 동작", () => {
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
- });
194
- });
195
-
196
- // ═══════════════════════════════════════════════════════════════════════════════
197
- // 2. Secure by Default — 인증 기본 활성화
198
- // ═══════════════════════════════════════════════════════════════════════════════
199
-
200
- describe("crud() — Secure by Default", () => {
201
- it("기본 호출 시 모든 query/mutation이 isPublic === false (auth 필수)", () => {
202
- const result = crud(articles);
203
-
204
- const listDef = getQueryDef("articles.list");
205
- const getDef = getQueryDef("articles.get");
206
-
207
- expect(listDef!.isPublic).toBe(false);
208
- expect(getDef!.isPublic).toBe(false);
209
-
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");
214
-
215
- expect(createDef!.isPublic).toBe(false);
216
- expect(updateDef!.isPublic).toBe(false);
217
- expect(removeDef!.isPublic).toBe(false);
218
- });
219
-
220
- it("{ public: true } 옵션 시 모든 엔드포인트가 isPublic === true", () => {
221
- const result = crud(items, { public: true });
222
-
223
- const listDef = getQueryDef("items.list");
224
- const getDef = getQueryDef("items.get");
225
-
226
- expect(listDef!.isPublic).toBe(true);
227
- expect(getDef!.isPublic).toBe(true);
228
-
229
- const mutations = getRegisteredMutations();
230
- const createDef = mutations.find((m: any) => m.name === "items.create");
231
- expect(createDef!.isPublic).toBe(true);
232
- });
233
- });
234
-
235
- // ═══════════════════════════════════════════════════════════════════════════════
236
- // 3. prefix 오버라이드
237
- // ═══════════════════════════════════════════════════════════════════════════════
238
-
239
- describe("crud() — prefix 오버라이드", () => {
240
- it("prefix 옵션으로 키 네임스페이스를 변경할 수 있다", () => {
241
- const customTable = pgTable("my_items", {
242
- id: serial("id").primaryKey(),
243
- name: text("name"),
244
- });
245
-
246
- crud(customTable, { prefix: "products", public: true });
247
-
248
- const listDef = getQueryDef("products.list");
249
- const getDef = getQueryDef("products.get");
250
-
251
- expect(listDef).toBeDefined();
252
- expect(getDef).toBeDefined();
253
-
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
- });
259
- });
260
-
261
- // ═══════════════════════════════════════════════════════════════════════════════
262
- // 4. id 컬럼 필수 체크
263
- // ═══════════════════════════════════════════════════════════════════════════════
264
-
265
- describe("crud() — 검증", () => {
266
- it("id 컬럼이 없는 테이블은 에러를 던진다", () => {
267
- // id 컬럼이 없는 실제 Drizzle 테이블
268
- const badTable = pgTable("bad", {
269
- name: text("name"),
270
- });
271
-
272
- expect(() => crud(badTable)).toThrow("[crud]");
273
- expect(() => crud(badTable)).toThrow("must have an 'id' column");
274
- });
275
- });
276
-
277
- // ═══════════════════════════════════════════════════════════════════════════════
278
- // 5. 핸들러 실행 — list ({ data, total } 반환 검증)
279
- // ═══════════════════════════════════════════════════════════════════════════════
280
-
281
- describe("crud() — list handler 실행", () => {
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
- });
287
-
288
- crud(testTable, { public: true });
289
-
290
- const listDef = getQueryDef("handler_test.list");
291
- expect(listDef).toBeDefined();
292
-
293
- const mockData = [{ id: 1 }, { id: 2 }];
294
- const mockCtx = createListMockCtx(mockData);
295
-
296
- const result = await listDef!.handler(mockCtx, {});
297
-
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
- });
304
- });
305
-
306
- // ═══════════════════════════════════════════════════════════════════════════════
307
- // 6. 핸들러 실행 — create (auth + userId 자동 주입 + realtime { data, total })
308
- // ═══════════════════════════════════════════════════════════════════════════════
309
-
310
- describe("crud() — create handler 실행", () => {
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,
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
- });
344
- });
345
-
346
- // ═══════════════════════════════════════════════════════════════════════════════
347
- // 7. 핸들러 실행 — remove (realtime { data, total })
348
- // ═══════════════════════════════════════════════════════════════════════════════
349
-
350
- describe("crud() — remove handler 실행", () => {
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
- });
356
-
357
- crud(testTable);
358
-
359
- const mutations = getRegisteredMutations();
360
- const removeDef = mutations.find((m: any) => m.name === "remove_test.remove");
361
- expect(removeDef).toBeDefined();
362
-
363
- const { mockCtx, emitted } = createMutationMockCtx({
364
- listData: [], // 삭제 후 빈 리스트
365
- });
366
-
367
- const result = await removeDef!.handler(mockCtx, { id: 99 });
368
- expect(result).toEqual({ success: true });
369
-
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
- });
377
- });
378
-
379
- // ═══════════════════════════════════════════════════════════════════════════════
380
- // 8. public: true — auth skip 확인
381
- // ═══════════════════════════════════════════════════════════════════════════════
382
-
383
- describe("crud() — public 모드 auth skip", () => {
384
- it("public: true 시 requireAuth를 호출하지 않는다", async () => {
385
- const testTable = pgTable("pub_test", {
386
- id: serial("id").primaryKey(),
387
- createdAt: timestamp("created_at").defaultNow(),
388
- });
389
-
390
- crud(testTable, { public: true });
391
-
392
- const listDef = getQueryDef("pub_test.list");
393
- expect(listDef).toBeDefined();
394
-
395
- const authCalled = { value: false };
396
- const mockCtx = createListMockCtx([{ id: 1 }], { authCalled });
397
-
398
- // public 모드에서는 requireAuth를 호출하면 안됨 → 별도 mock
399
- mockCtx.auth.requireAuth = () => {
400
- authCalled.value = true;
401
- throw new Error("should not be called");
402
- };
403
-
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
- });
409
- });
410
-
411
- // ═══════════════════════════════════════════════════════════════════════════════
412
- // 9. UUID id 자동 감지
413
- // ═══════════════════════════════════════════════════════════════════════════════
414
-
415
- describe("crud() — UUID 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"),
420
- });
421
-
422
- crud(uuidTable, { public: true });
423
-
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
- });
430
-
431
- it("serial PK 테이블에서 get args에 number id validator를 사용한다", () => {
432
- const serialTable = pgTable("serial_test", {
433
- id: serial("id").primaryKey(),
434
- name: text("name"),
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
- });
444
- });
445
-
446
- // ═══════════════════════════════════════════════════════════════════════════════
447
- // 10. allowedFilters — 화이트리스트 동작
448
- // ═══════════════════════════════════════════════════════════════════════════════
449
-
450
- describe("crud() — allowedFilters", () => {
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(),
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
- });
528
- });
529
-
530
- // ═══════════════════════════════════════════════════════════════════════════════
531
- // 11. v3 Filter Engine — parseFilterNode 직접 테스트 (spec TC1~5)
532
- // ═══════════════════════════════════════════════════════════════════════════════
533
-
534
- describe("v3 Filter Engine — parseFilterNode", () => {
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"),
581
- });
582
-
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
- });
592
-
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"),
600
- });
601
-
602
- // password_hash 직접 접근 → 묵살
603
- const r1 = parseFilterNode({ passwordHash: { op: "eq", value: "xxx" } }, table, ["status", "title"]);
604
- expect(r1).toBeUndefined(); // 모든 조건 제거됨
605
- });
606
-
607
- it("TC3: OR 틈새 우회 시도 시 미허용 필드만 묵살, 허용 필드는 유지", () => {
608
- const table = pgTable("tc3b_test", {
609
- id: serial("id").primaryKey(),
610
- status: text("status"),
611
- isAdmin: text("is_admin"),
612
- });
613
-
614
- const r = parseFilterNode({ OR: [{ status: "public" }, { isAdmin: true }] }, table, ["status"]);
615
- // status는 허용 → OR 조건 살아있음
616
- expect(r).toBeDefined();
617
- });
618
-
619
- it("TC3: 모든 필드가 미허용이면 undefined 반환", () => {
620
- const table = pgTable("tc3c_test", {
621
- id: serial("id").primaryKey(),
622
- secret: text("secret"),
623
- });
624
-
625
- const r = parseFilterNode({ OR: [{ secret: "hi" }] }, table, ["status"]);
626
- expect(r).toBeUndefined();
627
- });
628
-
629
- // TC4: 악의적 페이로드 구조 방어
630
- it("TC4: OR에 비배열 전달 시 무시, 크래시 없음", () => {
631
- const table = pgTable("tc4_test", {
632
- id: serial("id").primaryKey(),
633
- age: serial("age"),
634
- });
635
-
636
- expect(parseFilterNode({ OR: "not-an-array" as any }, table)).toBeUndefined();
637
- });
638
-
639
- it("TC4: 미지원 op 키워드(DROP TABLE 등) 묵살, 크래시 없음", () => {
640
- const table = pgTable("tc4b_test", {
641
- id: serial("id").primaryKey(),
642
- age: serial("age"),
643
- });
644
-
645
- expect(parseFilterNode({ age: { op: "DROP TABLE", value: 1 } }, table)).toBeUndefined();
646
- });
647
-
648
- it("TC4: AND에 null/string/number 등 비객체 원소 묵살", () => {
649
- const table = pgTable("tc4c_test", {
650
- id: serial("id").primaryKey(),
651
- });
652
-
653
- expect(parseFilterNode({ AND: [null, "string", 42] as any }, table)).toBeUndefined();
654
- expect(parseFilterNode({ AND: null as any }, table)).toBeUndefined();
655
- });
656
-
657
- it("TC4: 존재하지 않는 컬럼명 필터는 묵살", () => {
658
- const table = pgTable("tc4d_test", {
659
- id: serial("id").primaryKey(),
660
- name: text("name"),
661
- });
662
-
663
- const r = parseFilterNode({ nonExistentCol: "value" }, table);
664
- expect(r).toBeUndefined();
665
- });
666
-
667
- // TC5: IN / NIN 연산자 배열값 처리
668
- it("TC5: in 연산자 — 정상 배열은 SQL 생성", () => {
669
- const table = pgTable("tc5_test", {
670
- id: serial("id").primaryKey(),
671
- status: text("status"),
672
- });
673
-
674
- const r = parseFilterNode({ id: { op: "in", value: [1, 2, 3] } }, table);
675
- expect(r).toBeDefined();
676
- });
677
-
678
- it("TC5: in 연산자 — 비배열은 묵살", () => {
679
- const table = pgTable("tc5b_test", {
680
- id: serial("id").primaryKey(),
681
- });
682
-
683
- const r = parseFilterNode({ id: { op: "in", value: "1" } }, table);
684
- expect(r).toBeUndefined();
685
- });
686
-
687
- it("TC5: in 연산자 — 빈 배열은 묵살", () => {
688
- const table = pgTable("tc5c_test", {
689
- id: serial("id").primaryKey(),
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
- });
704
- });
705
-
706
- // ═══════════════════════════════════════════════════════════════════════════════
707
- // 12. v3 Filter Engine — applyFilterOp 직접 테스트
708
- // ═══════════════════════════════════════════════════════════════════════════════
709
-
710
- describe("v3 Filter Engine — applyFilterOp", () => {
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
- });
749
-
750
- // ═══════════════════════════════════════════════════════════════════════════════
751
- // 13. v3 crud() handler 통합 테스트 — Advanced Filters
752
- // ═══════════════════════════════════════════════════════════════════════════════
753
-
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(),
760
- });
761
-
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" } },
771
- });
772
-
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(),
783
- });
784
-
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" } },
791
- });
792
-
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(),
803
- });
804
-
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
- },
817
- });
818
-
819
- expect(result).toHaveProperty("data");
820
- expect(result.total).toBe(0);
821
- });
822
-
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(),
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
- });
850
- });
851
-
852
- // ═══════════════════════════════════════════════════════════════════════════════
853
- // 14. v3 보안 강화 — 재귀 깊이 제한 + like/ilike 타입 검증
854
- // ═══════════════════════════════════════════════════════════════════════════════
855
-
856
- describe("v3 보안 강화 — 재귀 깊이 제한", () => {
857
- it("MAX_FILTER_DEPTH(5) 초과 시 조건 묵살", () => {
858
- const table = pgTable("depth_test", {
859
- id: serial("id").primaryKey(),
860
- name: text("name"),
861
- });
862
-
863
- // 깊이 6단계 중첩 — MAX_FILTER_DEPTH=5 초과
864
- const deepFilter = {
865
- OR: [{ AND: [{ OR: [{ AND: [{ OR: [{ AND: [{ name: "deep" }] }] }] }] }] }],
866
- };
867
-
868
- const result = parseFilterNode(deepFilter, table);
869
- // 깊이 초과된 부분이 묵살되어 결과가 undefined이거나 부분 조건만 남음
870
- // 중요한 것은 스택 오버플로 없이 정상 반환
871
- expect(() => parseFilterNode(deepFilter, table)).not.toThrow();
872
- });
873
-
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"),
879
- });
880
-
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"),
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
- });
930
- });