@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
|
@@ -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(
|
|
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
|
-
},
|
|
33
|
+
},
|
|
34
|
+
(t) => ownerRls(t.userId),
|
|
35
|
+
);
|
|
32
36
|
|
|
33
37
|
// ownerRls(read: "public") 테이블
|
|
34
|
-
const rlsPosts = pgTable(
|
|
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
|
-
},
|
|
45
|
+
},
|
|
46
|
+
(t) => ownerRls(t.userId, { read: "public" }),
|
|
47
|
+
);
|
|
40
48
|
|
|
41
49
|
// ownerRls 미적용 테이블 (하위호환 검증)
|
|
42
50
|
const plainItems = pgTable("plain_items", {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
298
|
+
await updateDef!.handler(mockCtx, {
|
|
299
|
+
id: 1,
|
|
300
|
+
title: "Updated",
|
|
301
|
+
user_id: "hacker-id", // userId 변경 시도
|
|
250
302
|
});
|
|
251
303
|
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
311
|
+
// ── remove 격리 ──
|
|
303
312
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
});
|