@gencow/core 0.1.24 → 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.
Files changed (73) hide show
  1. package/dist/crud.d.ts +2 -2
  2. package/dist/crud.js +225 -208
  3. package/dist/index.d.ts +5 -5
  4. package/dist/index.js +2 -2
  5. package/dist/reactive.js +10 -3
  6. package/dist/retry.js +1 -1
  7. package/dist/rls-db.d.ts +2 -2
  8. package/dist/rls-db.js +1 -5
  9. package/dist/scheduler.d.ts +2 -0
  10. package/dist/scheduler.js +16 -6
  11. package/dist/server.d.ts +0 -1
  12. package/dist/server.js +0 -1
  13. package/dist/storage.js +29 -22
  14. package/dist/v.d.ts +2 -2
  15. package/dist/workflow.js +4 -11
  16. package/dist/workflows-api.js +5 -12
  17. package/package.json +46 -42
  18. package/src/__tests__/auth.test.ts +90 -86
  19. package/src/__tests__/crons.test.ts +69 -67
  20. package/src/__tests__/crud-codegen-integration.test.ts +164 -170
  21. package/src/__tests__/crud-owner-rls.test.ts +308 -301
  22. package/src/__tests__/crud.test.ts +694 -711
  23. package/src/__tests__/dist-exports.test.ts +120 -120
  24. package/src/__tests__/fixtures/basic/auth.ts +16 -16
  25. package/src/__tests__/fixtures/basic/drizzle.config.ts +1 -4
  26. package/src/__tests__/fixtures/basic/index.ts +1 -1
  27. package/src/__tests__/fixtures/basic/schema.ts +1 -1
  28. package/src/__tests__/fixtures/basic/tasks.ts +4 -4
  29. package/src/__tests__/fixtures/common/auth-schema.ts +38 -34
  30. package/src/__tests__/helpers/basic-rls-fixture.ts +80 -78
  31. package/src/__tests__/helpers/pglite-migrations.ts +2 -5
  32. package/src/__tests__/helpers/pglite-rls-session.ts +13 -16
  33. package/src/__tests__/helpers/seed-like-fill.ts +50 -44
  34. package/src/__tests__/helpers/test-gencow-ctx-rls.ts +4 -7
  35. package/src/__tests__/httpaction.test.ts +91 -91
  36. package/src/__tests__/image-optimization.test.ts +570 -574
  37. package/src/__tests__/load.test.ts +321 -308
  38. package/src/__tests__/network-sim.test.ts +238 -215
  39. package/src/__tests__/reactive.test.ts +380 -358
  40. package/src/__tests__/retry.test.ts +99 -84
  41. package/src/__tests__/rls-crud-basic.test.ts +172 -245
  42. package/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts +81 -81
  43. package/src/__tests__/rls-custom-mutation-handlers.test.ts +47 -94
  44. package/src/__tests__/rls-custom-query-handlers.test.ts +92 -92
  45. package/src/__tests__/rls-db-leased-connection.test.ts +2 -6
  46. package/src/__tests__/rls-session-and-policies.test.ts +181 -199
  47. package/src/__tests__/scheduler-durable-v2.test.ts +199 -181
  48. package/src/__tests__/scheduler-durable.test.ts +117 -117
  49. package/src/__tests__/scheduler-exec.test.ts +258 -246
  50. package/src/__tests__/scheduler.test.ts +129 -111
  51. package/src/__tests__/storage.test.ts +282 -269
  52. package/src/__tests__/tsconfig.json +6 -6
  53. package/src/__tests__/validator.test.ts +236 -232
  54. package/src/__tests__/workflow.test.ts +309 -286
  55. package/src/__tests__/ws-integration.test.ts +223 -218
  56. package/src/__tests__/ws-scale.test.ts +168 -159
  57. package/src/auth-config.ts +18 -18
  58. package/src/auth.ts +106 -106
  59. package/src/crons.ts +77 -77
  60. package/src/crud.ts +523 -479
  61. package/src/index.ts +69 -5
  62. package/src/reactive.ts +357 -331
  63. package/src/retry.ts +51 -54
  64. package/src/rls-db.ts +195 -205
  65. package/src/rls.ts +33 -36
  66. package/src/scheduler.ts +237 -211
  67. package/src/server.ts +0 -1
  68. package/src/storage.ts +632 -593
  69. package/src/v.ts +119 -114
  70. package/src/workflow-types.ts +67 -70
  71. package/src/workflow.ts +99 -116
  72. package/src/workflows-api.ts +231 -241
  73. package/src/db.ts +0 -18
@@ -15,366 +15,373 @@
15
15
  import { describe, it, expect } from "bun:test";
16
16
  import { pgTable, serial, text, timestamp, pgPolicy } from "drizzle-orm/pg-core";
17
17
  import { sql } from "drizzle-orm";
18
- import { crud } from "../crud";
19
- import { ownerRls, getOwnerRlsMeta, registerOwnerRls } from "../rls";
20
- import { getQueryDef, getRegisteredMutations } from "../reactive";
18
+ import { crud } from "../crud.js";
19
+ import { ownerRls, getOwnerRlsMeta, registerOwnerRls } from "../rls.js";
20
+ import { getQueryDef, getRegisteredMutations } from "../reactive.js";
21
21
 
22
22
  // ─── 테스트 테이블 정의 ────────────────────────────────────────────────────
23
23
 
24
24
  // ownerRls 적용 테이블
25
- const rlsTasks = pgTable("rls_tasks", {
25
+ const rlsTasks = pgTable(
26
+ "rls_tasks",
27
+ {
26
28
  id: serial("id").primaryKey(),
27
29
  title: text("title").notNull(),
28
30
  userId: text("user_id").notNull(),
29
31
  createdAt: timestamp("created_at").defaultNow(),
30
32
  updatedAt: timestamp("updated_at").defaultNow(),
31
- }, (t) => ownerRls(t.userId));
33
+ },
34
+ (t) => ownerRls(t.userId),
35
+ );
32
36
 
33
37
  // ownerRls(read: "public") 테이블
34
- const rlsPosts = pgTable("rls_posts", {
38
+ const rlsPosts = pgTable(
39
+ "rls_posts",
40
+ {
35
41
  id: serial("id").primaryKey(),
36
42
  content: text("content").notNull(),
37
43
  userId: text("user_id").notNull(),
38
44
  createdAt: timestamp("created_at").defaultNow(),
39
- }, (t) => ownerRls(t.userId, { read: "public" }));
45
+ },
46
+ (t) => ownerRls(t.userId, { read: "public" }),
47
+ );
40
48
 
41
49
  // ownerRls 미적용 테이블 (하위호환 검증)
42
50
  const plainItems = pgTable("plain_items", {
43
- id: serial("id").primaryKey(),
44
- name: text("name"),
45
- userId: text("user_id"),
46
- createdAt: timestamp("created_at").defaultNow(),
51
+ id: serial("id").primaryKey(),
52
+ name: text("name"),
53
+ userId: text("user_id"),
54
+ createdAt: timestamp("created_at").defaultNow(),
47
55
  });
48
56
 
49
57
  // ─── Mock 유틸 ─────────────────────────────────────────────────────────
50
58
 
51
59
  function createMockCtx(userId: string, mockData: any[] = []) {
52
- const dataChain: any = {
53
- from: () => dataChain,
54
- where: () => dataChain,
55
- orderBy: () => dataChain,
56
- limit: (n?: number) => n === 1
57
- ? Promise.resolve(mockData.slice(0, 1)) // get handler: .limit(1) → 단일 행 반환
58
- : dataChain, // list handler: .limit(n).offset(m)
59
- offset: () => Promise.resolve(mockData),
60
- };
61
- const countChain = {
62
- from: () => countChain,
63
- where: () => Promise.resolve([{ count: mockData.length }]),
64
- };
65
-
66
- let capturedValues: any = null;
67
- let capturedWhereArg: any = null;
68
- const emitted: { key: string; data: any }[] = [];
69
-
70
- return {
71
- ctx: {
72
- auth: {
73
- requireAuth: () => ({ id: userId, email: `${userId}@test.com` }),
74
- getUserIdentity: () => ({ id: userId }),
75
- getSession: () => null,
76
- },
77
- db: {
78
- select: (selectArg?: any) => {
79
- if (selectArg && typeof selectArg === "object" && "count" in selectArg) {
80
- return countChain;
81
- }
82
- return dataChain;
83
- },
84
- insert: () => ({
85
- values: (v: any) => {
86
- capturedValues = v;
87
- return { returning: () => Promise.resolve([{ ...v, id: 1 }]) };
88
- },
89
- }),
90
- update: () => ({
91
- set: (data: any) => ({
92
- where: (w: any) => {
93
- capturedWhereArg = w;
94
- return { returning: () => Promise.resolve([{ ...data, id: 1 }]) };
95
- },
96
- }),
97
- }),
98
- delete: () => ({
99
- where: (w: any) => {
100
- capturedWhereArg = w;
101
- return Promise.resolve();
102
- },
103
- }),
104
- },
105
- realtime: {
106
- emit: (key: string, data: any) => emitted.push({ key, data }),
107
- },
60
+ const dataChain: any = {
61
+ from: () => dataChain,
62
+ where: () => dataChain,
63
+ orderBy: () => dataChain,
64
+ limit: (n?: number) =>
65
+ n === 1
66
+ ? Promise.resolve(mockData.slice(0, 1)) // get handler: .limit(1) → 단일 행 반환
67
+ : dataChain, // list handler: .limit(n).offset(m)
68
+ offset: () => Promise.resolve(mockData),
69
+ };
70
+ const countChain = {
71
+ from: () => countChain,
72
+ where: () => Promise.resolve([{ count: mockData.length }]),
73
+ };
74
+
75
+ let capturedValues: any = null;
76
+ let capturedWhereArg: any = null;
77
+ const emitted: { key: string; data: any }[] = [];
78
+
79
+ return {
80
+ ctx: {
81
+ auth: {
82
+ requireAuth: () => ({ id: userId, email: `${userId}@test.com` }),
83
+ getUserIdentity: () => ({ id: userId }),
84
+ getSession: () => null,
85
+ },
86
+ db: {
87
+ select: (selectArg?: any) => {
88
+ if (selectArg && typeof selectArg === "object" && "count" in selectArg) {
89
+ return countChain;
90
+ }
91
+ return dataChain;
108
92
  },
109
- getCapturedValues: () => capturedValues,
110
- getCapturedWhere: () => capturedWhereArg,
111
- emitted,
112
- };
93
+ insert: () => ({
94
+ values: (v: any) => {
95
+ capturedValues = v;
96
+ return { returning: () => Promise.resolve([{ ...v, id: 1 }]) };
97
+ },
98
+ }),
99
+ update: () => ({
100
+ set: (data: any) => ({
101
+ where: (w: any) => {
102
+ capturedWhereArg = w;
103
+ return { returning: () => Promise.resolve([{ ...data, id: 1 }]) };
104
+ },
105
+ }),
106
+ }),
107
+ delete: () => ({
108
+ where: (w: any) => {
109
+ capturedWhereArg = w;
110
+ return Promise.resolve();
111
+ },
112
+ }),
113
+ },
114
+ realtime: {
115
+ emit: (key: string, data: any) => emitted.push({ key, data }),
116
+ },
117
+ },
118
+ getCapturedValues: () => capturedValues,
119
+ getCapturedWhere: () => capturedWhereArg,
120
+ emitted,
121
+ };
113
122
  }
114
123
 
115
124
  function createUnauthCtx(mockData: any[] = []) {
116
- const dataChain = {
117
- from: () => dataChain,
118
- where: () => dataChain,
119
- orderBy: () => dataChain,
120
- limit: () => dataChain,
121
- offset: () => Promise.resolve(mockData),
122
- };
123
- const countChain = {
124
- from: () => countChain,
125
- where: () => Promise.resolve([{ count: mockData.length }]),
126
- };
127
-
128
- return {
129
- auth: {
130
- requireAuth: () => { throw new Error("Unauthorized"); },
131
- getUserIdentity: () => null,
132
- getSession: () => null,
133
- },
134
- db: {
135
- select: (selectArg?: any) => {
136
- if (selectArg && typeof selectArg === "object" && "count" in selectArg) {
137
- return countChain;
138
- }
139
- return dataChain;
140
- },
141
- },
142
- realtime: {
143
- emit: () => {},
144
- },
145
- };
125
+ const dataChain = {
126
+ from: () => dataChain,
127
+ where: () => dataChain,
128
+ orderBy: () => dataChain,
129
+ limit: () => dataChain,
130
+ offset: () => Promise.resolve(mockData),
131
+ };
132
+ const countChain = {
133
+ from: () => countChain,
134
+ where: () => Promise.resolve([{ count: mockData.length }]),
135
+ };
136
+
137
+ return {
138
+ auth: {
139
+ requireAuth: () => {
140
+ throw new Error("Unauthorized");
141
+ },
142
+ getUserIdentity: () => null,
143
+ getSession: () => null,
144
+ },
145
+ db: {
146
+ select: (selectArg?: any) => {
147
+ if (selectArg && typeof selectArg === "object" && "count" in selectArg) {
148
+ return countChain;
149
+ }
150
+ return dataChain;
151
+ },
152
+ },
153
+ realtime: {
154
+ emit: () => {},
155
+ },
156
+ };
146
157
  }
147
158
 
148
-
149
159
  // ═══════════════════════════════════════════════════════════════════════════════
150
160
  // Phase 1 검증: ownerRls() 메타데이터
151
161
  // ═══════════════════════════════════════════════════════════════════════════════
152
162
 
153
163
  describe("ownerRls() — 메타데이터 레지스트리", () => {
154
- it("ownerRls 적용 테이블에서 메타데이터를 감지한다", () => {
155
- // ownerRls()가 호출된 rlsTasks 테이블에서 WeakMap으로 건 못 찾더라도
156
- // crud()가 getTableConfig().policies로 fallback 감지
157
- // 여기서는 registerOwnerRls를 직접 호출하여 테스트
158
- registerOwnerRls(rlsTasks, { columnName: "user_id", readPublic: false });
159
-
160
- const meta = getOwnerRlsMeta(rlsTasks);
161
- expect(meta).toBeDefined();
162
- expect(meta!.columnName).toBe("user_id");
163
- expect(meta!.readPublic).toBe(false);
164
- });
165
-
166
- it("read: public 옵션이 메타데이터에 반영된다", () => {
167
- registerOwnerRls(rlsPosts, { columnName: "user_id", readPublic: true });
168
-
169
- const meta = getOwnerRlsMeta(rlsPosts);
170
- expect(meta).toBeDefined();
171
- expect(meta!.readPublic).toBe(true);
172
- });
173
-
174
- it("ownerRls 미적용 테이블은 undefined 반환", () => {
175
- const meta = getOwnerRlsMeta(plainItems);
176
- expect(meta).toBeUndefined();
177
- });
164
+ it("ownerRls 적용 테이블에서 메타데이터를 감지한다", () => {
165
+ // ownerRls()가 호출된 rlsTasks 테이블에서 WeakMap으로 건 못 찾더라도
166
+ // crud()가 getTableConfig().policies로 fallback 감지
167
+ // 여기서는 registerOwnerRls를 직접 호출하여 테스트
168
+ registerOwnerRls(rlsTasks, { columnName: "user_id", readPublic: false });
169
+
170
+ const meta = getOwnerRlsMeta(rlsTasks);
171
+ expect(meta).toBeDefined();
172
+ expect(meta!.columnName).toBe("user_id");
173
+ expect(meta!.readPublic).toBe(false);
174
+ });
175
+
176
+ it("read: public 옵션이 메타데이터에 반영된다", () => {
177
+ registerOwnerRls(rlsPosts, { columnName: "user_id", readPublic: true });
178
+
179
+ const meta = getOwnerRlsMeta(rlsPosts);
180
+ expect(meta).toBeDefined();
181
+ expect(meta!.readPublic).toBe(true);
182
+ });
183
+
184
+ it("ownerRls 미적용 테이블은 undefined 반환", () => {
185
+ const meta = getOwnerRlsMeta(plainItems);
186
+ expect(meta).toBeUndefined();
187
+ });
178
188
  });
179
189
 
180
-
181
190
  // ═══════════════════════════════════════════════════════════════════════════════
182
191
  // Phase 2 검증: crud() ownerRls 감지 + 필터 주입
183
192
  // ═══════════════════════════════════════════════════════════════════════════════
184
193
 
185
194
  describe("crud() + ownerRls — 데이터 격리", () => {
186
- // 초기화: crud 등록
187
- crud(rlsTasks);
188
- crud(rlsPosts);
189
- crud(plainItems);
190
-
191
- // ── list 격리 ──
192
-
193
- it("list: ownerRls 테이블에서 requireAuth가 호출된다", async () => {
194
- const listDef = getQueryDef("rls_tasks.list");
195
- expect(listDef).toBeDefined();
196
-
197
- const { ctx } = createMockCtx("user-A", []);
198
- // 에러 없이 실행되면 requireAuth 호출 성공
199
- const result = await listDef!.handler(ctx, {});
200
- expect(result).toHaveProperty("data");
201
- expect(result).toHaveProperty("total");
202
- });
203
-
204
- it("list: ownerRls 테이블은 인증 없이 접근 불가", async () => {
205
- const listDef = getQueryDef("rls_tasks.list");
206
- const unauthCtx = createUnauthCtx();
207
-
208
- await expect(listDef!.handler(unauthCtx, {})).rejects.toThrow("Unauthorized");
209
- });
210
-
211
- // ── get 격리 ──
212
-
213
- it("get: ownerRls 테이블에서 requireAuth가 호출된다", async () => {
214
- const getDef = getQueryDef("rls_tasks.get");
215
- expect(getDef).toBeDefined();
216
-
217
- const { ctx } = createMockCtx("user-A", [{ id: 1, title: "test", userId: "user-A" }]);
218
- const result = await getDef!.handler(ctx, { id: 1 });
219
- // null 또는 데이터 반환 (mock이므로 체이닝 결과)
220
- // 핵심: 에러 없이 실행되면 성공
221
- });
222
-
223
- // ── create 자동 주입 ──
224
-
225
- it("create: ownerRls 테이블에서 userId가 인증 사용자로 강제 설정된다", async () => {
226
- const mutations = getRegisteredMutations();
227
- const createDef = mutations.find((m: any) => m.name === "rls_tasks.create");
228
- expect(createDef).toBeDefined();
229
-
230
- const { ctx, getCapturedValues } = createMockCtx("user-A");
231
- await createDef!.handler(ctx, { title: "New Task" });
232
-
233
- // userId가 인증된 사용자 ID로 강제 설정됨 (JS 프로퍼티명 사용)
234
- const values = getCapturedValues();
235
- expect(values).toBeDefined();
236
- expect(values.userId).toBe("user-A");
237
- });
238
-
239
- it("create: 타인 user_id 주입 시도는 거부되고 insert까지 가지 않음 (보안)", async () => {
240
- const mutations = getRegisteredMutations();
241
- const createDef = mutations.find((m: any) => m.name === "rls_tasks.create");
242
-
243
- const { ctx, getCapturedValues } = createMockCtx("user-A");
244
- // 해커가 user_id를 "hacker-id"로 조작 시도 — Layer 1은 즉시 Forbidden (덮어쓰기 전 차단)
245
- await expect(
246
- createDef!.handler(ctx, { title: "Spoofed", user_id: "hacker-id" }),
247
- ).rejects.toThrow("Forbidden: cannot create resource for another user");
195
+ // 초기화: crud 등록
196
+ crud(rlsTasks);
197
+ crud(rlsPosts);
198
+ crud(plainItems);
199
+
200
+ // ── list 격리 ──
201
+
202
+ it("list: ownerRls 테이블에서 requireAuth가 호출된다", async () => {
203
+ const listDef = getQueryDef("rls_tasks.list");
204
+ expect(listDef).toBeDefined();
205
+
206
+ const { ctx } = createMockCtx("user-A", []);
207
+ // 에러 없이 실행되면 requireAuth 호출 성공
208
+ const result = await listDef!.handler(ctx, {});
209
+ expect(result).toHaveProperty("data");
210
+ expect(result).toHaveProperty("total");
211
+ });
212
+
213
+ it("list: ownerRls 테이블은 인증 없이 접근 불가", async () => {
214
+ const listDef = getQueryDef("rls_tasks.list");
215
+ const unauthCtx = createUnauthCtx();
216
+
217
+ await expect(listDef!.handler(unauthCtx, {})).rejects.toThrow("Unauthorized");
218
+ });
219
+
220
+ // ── get 격리 ──
221
+
222
+ it("get: ownerRls 테이블에서 requireAuth가 호출된다", async () => {
223
+ const getDef = getQueryDef("rls_tasks.get");
224
+ expect(getDef).toBeDefined();
225
+
226
+ const { ctx } = createMockCtx("user-A", [{ id: 1, title: "test", userId: "user-A" }]);
227
+ const result = await getDef!.handler(ctx, { id: 1 });
228
+ // null 또는 데이터 반환 (mock이므로 체이닝 결과)
229
+ // 핵심: 에러 없이 실행되면 성공
230
+ });
231
+
232
+ // ── create 자동 주입 ──
233
+
234
+ it("create: ownerRls 테이블에서 userId가 인증 사용자로 강제 설정된다", async () => {
235
+ const mutations = getRegisteredMutations();
236
+ const createDef = mutations.find((m: any) => m.name === "rls_tasks.create");
237
+ expect(createDef).toBeDefined();
238
+
239
+ const { ctx, getCapturedValues } = createMockCtx("user-A");
240
+ await createDef!.handler(ctx, { title: "New Task" });
241
+
242
+ // userId가 인증된 사용자 ID로 강제 설정됨 (JS 프로퍼티명 사용)
243
+ const values = getCapturedValues();
244
+ expect(values).toBeDefined();
245
+ expect(values.userId).toBe("user-A");
246
+ });
247
+
248
+ it("create: 타인 user_id 주입 시도는 거부되고 insert까지 가지 않음 (보안)", async () => {
249
+ const mutations = getRegisteredMutations();
250
+ const createDef = mutations.find((m: any) => m.name === "rls_tasks.create");
251
+
252
+ const { ctx, getCapturedValues } = createMockCtx("user-A");
253
+ // 해커가 user_id를 "hacker-id"로 조작 시도 — Layer 1은 즉시 Forbidden (덮어쓰기 전 차단)
254
+ await expect(createDef!.handler(ctx, { title: "Spoofed", user_id: "hacker-id" })).rejects.toThrow(
255
+ "Forbidden: cannot create resource for another user",
256
+ );
257
+
258
+ expect(getCapturedValues()).toBeNull();
259
+ });
260
+
261
+ // ── update 격리 ──
262
+
263
+ it("update: ownerRls 테이블에서 userId 변경 시도가 차단된다", async () => {
264
+ const mutations = getRegisteredMutations();
265
+ const updateDef = mutations.find((m: any) => m.name === "rls_tasks.update");
266
+ expect(updateDef).toBeDefined();
267
+
268
+ // update에서는 set()에 전달되는 데이터에서 user_id 키가 삭제되어야 함
269
+ let capturedSetData: any = null;
270
+ const mockCtx = {
271
+ auth: {
272
+ requireAuth: () => ({ id: "user-A", email: "a@test.com" }),
273
+ getUserIdentity: () => ({ id: "user-A" }),
274
+ },
275
+ db: {
276
+ select: (selectArg?: any) => {
277
+ if (selectArg && typeof selectArg === "object" && "count" in selectArg) {
278
+ return { from: () => ({ where: () => Promise.resolve([{ count: 0 }]) }) };
279
+ }
280
+ return {
281
+ from: () => ({ where: () => ({ orderBy: () => Promise.resolve([]) }) }),
282
+ };
283
+ },
284
+ update: () => ({
285
+ set: (data: any) => {
286
+ capturedSetData = data;
287
+ return {
288
+ where: () => ({
289
+ returning: () => Promise.resolve([{ id: 1, title: "Updated" }]),
290
+ }),
291
+ };
292
+ },
293
+ }),
294
+ },
295
+ realtime: { emit: () => {} },
296
+ };
248
297
 
249
- expect(getCapturedValues()).toBeNull();
298
+ await updateDef!.handler(mockCtx, {
299
+ id: 1,
300
+ title: "Updated",
301
+ user_id: "hacker-id", // userId 변경 시도
250
302
  });
251
303
 
252
- // ── update 격리 ──
253
-
254
- it("update: ownerRls 테이블에서 userId 변경 시도가 차단된다", async () => {
255
- const mutations = getRegisteredMutations();
256
- const updateDef = mutations.find((m: any) => m.name === "rls_tasks.update");
257
- expect(updateDef).toBeDefined();
258
-
259
- // update에서는 set()에 전달되는 데이터에서 user_id 키가 삭제되어야 함
260
- let capturedSetData: any = null;
261
- const mockCtx = {
262
- auth: {
263
- requireAuth: () => ({ id: "user-A", email: "a@test.com" }),
264
- getUserIdentity: () => ({ id: "user-A" }),
265
- },
266
- db: {
267
- select: (selectArg?: any) => {
268
- if (selectArg && typeof selectArg === "object" && "count" in selectArg) {
269
- return { from: () => ({ where: () => Promise.resolve([{ count: 0 }]) }) };
270
- }
271
- return {
272
- from: () => ({ where: () => ({ orderBy: () => Promise.resolve([]) }) }),
273
- };
274
- },
275
- update: () => ({
276
- set: (data: any) => {
277
- capturedSetData = data;
278
- return {
279
- where: () => ({
280
- returning: () => Promise.resolve([{ id: 1, title: "Updated" }]),
281
- }),
282
- };
283
- },
284
- }),
285
- },
286
- realtime: { emit: () => {} },
287
- };
288
-
289
- await updateDef!.handler(mockCtx, {
290
- id: 1,
291
- title: "Updated",
292
- user_id: "hacker-id", // userId 변경 시도
293
- });
294
-
295
- // user_id가 set 데이터에서 삭제되었는지 확인 (JS 프로퍼티명 + DB명 양쪽)
296
- expect(capturedSetData).toBeDefined();
297
- expect(capturedSetData).not.toHaveProperty("userId");
298
- expect(capturedSetData).not.toHaveProperty("user_id");
299
- expect(capturedSetData.title).toBe("Updated");
300
- });
304
+ // user_id가 set 데이터에서 삭제되었는지 확인 (JS 프로퍼티명 + DB명 양쪽)
305
+ expect(capturedSetData).toBeDefined();
306
+ expect(capturedSetData).not.toHaveProperty("userId");
307
+ expect(capturedSetData).not.toHaveProperty("user_id");
308
+ expect(capturedSetData.title).toBe("Updated");
309
+ });
301
310
 
302
- // ── remove 격리 ──
311
+ // ── remove 격리 ──
303
312
 
304
- it("remove: ownerRls 테이블에서 requireAuth가 호출된다", async () => {
305
- const mutations = getRegisteredMutations();
306
- const removeDef = mutations.find((m: any) => m.name === "rls_tasks.remove");
307
- expect(removeDef).toBeDefined();
313
+ it("remove: ownerRls 테이블에서 requireAuth가 호출된다", async () => {
314
+ const mutations = getRegisteredMutations();
315
+ const removeDef = mutations.find((m: any) => m.name === "rls_tasks.remove");
316
+ expect(removeDef).toBeDefined();
308
317
 
309
- const { ctx } = createMockCtx("user-A");
310
- const result = await removeDef!.handler(ctx, { id: 1 });
311
- expect(result).toEqual({ success: true });
312
- });
318
+ const { ctx } = createMockCtx("user-A");
319
+ const result = await removeDef!.handler(ctx, { id: 1 });
320
+ expect(result).toEqual({ success: true });
321
+ });
313
322
  });
314
323
 
315
-
316
324
  // ═══════════════════════════════════════════════════════════════════════════════
317
325
  // read: "public" 테스트
318
326
  // ═══════════════════════════════════════════════════════════════════════════════
319
327
 
320
328
  describe("crud() + ownerRls(read: 'public') — 공개 읽기", () => {
321
- it("list: 인증 없이 접근 가능 (read: public이므로 auth skip)", async () => {
322
- // rlsPosts는 ownerRls(userId, { read: "public" })
323
- // readPublic: true → list에서 userId 필터 생략
324
- const listDef = getQueryDef("rls_posts.list");
325
- expect(listDef).toBeDefined();
326
-
327
- // crud는 isPublic=false로 생성되었으므로 requireAuth는 호출됨
328
- // 하지만 readPublic=true이므로 userId 필터는 추가되지 않음
329
- const { ctx } = createMockCtx("user-A", [{ id: 1, content: "hello" }]);
330
- const result = await listDef!.handler(ctx, {});
331
- expect(result.data).toHaveLength(1);
332
- });
333
-
334
- it("create: 인증 필수 + userId 강제 주입", async () => {
335
- const mutations = getRegisteredMutations();
336
- const createDef = mutations.find((m: any) => m.name === "rls_posts.create");
337
- expect(createDef).toBeDefined();
338
-
339
- const { ctx, getCapturedValues } = createMockCtx("user-B");
340
- await createDef!.handler(ctx, { content: "Public post" });
341
-
342
- const values = getCapturedValues();
343
- expect(values.userId).toBe("user-B");
344
- });
329
+ it("list: 인증 없이 접근 가능 (read: public이므로 auth skip)", async () => {
330
+ // rlsPosts는 ownerRls(userId, { read: "public" })
331
+ // readPublic: true → list에서 userId 필터 생략
332
+ const listDef = getQueryDef("rls_posts.list");
333
+ expect(listDef).toBeDefined();
334
+
335
+ // crud는 isPublic=false로 생성되었으므로 requireAuth는 호출됨
336
+ // 하지만 readPublic=true이므로 userId 필터는 추가되지 않음
337
+ const { ctx } = createMockCtx("user-A", [{ id: 1, content: "hello" }]);
338
+ const result = await listDef!.handler(ctx, {});
339
+ expect(result.data).toHaveLength(1);
340
+ });
341
+
342
+ it("create: 인증 필수 + userId 강제 주입", async () => {
343
+ const mutations = getRegisteredMutations();
344
+ const createDef = mutations.find((m: any) => m.name === "rls_posts.create");
345
+ expect(createDef).toBeDefined();
346
+
347
+ const { ctx, getCapturedValues } = createMockCtx("user-B");
348
+ await createDef!.handler(ctx, { content: "Public post" });
349
+
350
+ const values = getCapturedValues();
351
+ expect(values.userId).toBe("user-B");
352
+ });
345
353
  });
346
354
 
347
-
348
355
  // ═══════════════════════════════════════════════════════════════════════════════
349
356
  // 하위호환 테스트 — ownerRls 미적용 테이블
350
357
  // ═══════════════════════════════════════════════════════════════════════════════
351
358
 
352
359
  describe("crud() — ownerRls 미적용 하위호환", () => {
353
- it("ownerRls 없는 테이블에서 기존 userId 자동 주입 동작 유지", async () => {
354
- const mutations = getRegisteredMutations();
355
- const createDef = mutations.find((m: any) => m.name === "plain_items.create");
356
- expect(createDef).toBeDefined();
357
-
358
- const { ctx, getCapturedValues } = createMockCtx("user-C");
359
- await createDef!.handler(ctx, { name: "Widget" });
360
-
361
- const values = getCapturedValues();
362
- // ownerRls 미적용 → 기존 자동 주입 로직: userId 컬럼이 있고 인증됐으면 주입
363
- expect(values.userId).toBe("user-C");
364
- });
365
-
366
- it("ownerRls 없는 테이블의 list는 userId 필터 없이 전체 반환", async () => {
367
- const listDef = getQueryDef("plain_items.list");
368
- expect(listDef).toBeDefined();
369
-
370
- const allData = [
371
- { id: 1, name: "A", userId: "user-A" },
372
- { id: 2, name: "B", userId: "user-B" },
373
- ];
374
- const { ctx } = createMockCtx("user-A", allData);
375
- const result = await listDef!.handler(ctx, {});
376
-
377
- // ownerRls 미적용 → 모든 데이터 반환 (기존 동작)
378
- expect(result.data).toHaveLength(2);
379
- });
360
+ it("ownerRls 없는 테이블에서 기존 userId 자동 주입 동작 유지", async () => {
361
+ const mutations = getRegisteredMutations();
362
+ const createDef = mutations.find((m: any) => m.name === "plain_items.create");
363
+ expect(createDef).toBeDefined();
364
+
365
+ const { ctx, getCapturedValues } = createMockCtx("user-C");
366
+ await createDef!.handler(ctx, { name: "Widget" });
367
+
368
+ const values = getCapturedValues();
369
+ // ownerRls 미적용 → 기존 자동 주입 로직: userId 컬럼이 있고 인증됐으면 주입
370
+ expect(values.userId).toBe("user-C");
371
+ });
372
+
373
+ it("ownerRls 없는 테이블의 list는 userId 필터 없이 전체 반환", async () => {
374
+ const listDef = getQueryDef("plain_items.list");
375
+ expect(listDef).toBeDefined();
376
+
377
+ const allData = [
378
+ { id: 1, name: "A", userId: "user-A" },
379
+ { id: 2, name: "B", userId: "user-B" },
380
+ ];
381
+ const { ctx } = createMockCtx("user-A", allData);
382
+ const result = await listDef!.handler(ctx, {});
383
+
384
+ // ownerRls 미적용 → 모든 데이터 반환 (기존 동작)
385
+ expect(result.data).toHaveLength(2);
386
+ });
380
387
  });