@gencow/core 0.1.9 → 0.1.11

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.
@@ -1,442 +0,0 @@
1
- /**
2
- * packages/core/src/__tests__/scoped-db.test.ts
3
- *
4
- * Tests for createScopedDb() — Proxy wrapping Drizzle DB with auto-filter injection.
5
- *
6
- * Run: bun test packages/core/src/__tests__/scoped-db.test.ts
7
- */
8
-
9
- import { describe, it, expect, beforeEach, mock } from "bun:test";
10
- import { createScopedDb, applyFieldAccess } from "../scoped-db";
11
- import { gencowTable, ownerFilter, _resetTableRegistry, getTableAccessMeta } from "../table";
12
- import type { GencowCtx } from "../reactive";
13
- import { serial, text, integer } from "drizzle-orm/pg-core";
14
- import { pgTable } from "drizzle-orm/pg-core";
15
- import { eq, and, sql } from "drizzle-orm";
16
-
17
- // ─── Mock DB builder ────────────────────────────────────
18
-
19
- /**
20
- * Create a mock Drizzle-like DB that records method calls.
21
- * Supports chaining: db.select().from(table).where(cond).limit(n)
22
- */
23
- function makeMockDb() {
24
- const calls: Array<{ method: string; args: any[] }> = [];
25
- let lastWhere: any = undefined;
26
-
27
- const chainable = (self: any) =>
28
- new Proxy(self, {
29
- get(target, prop: string) {
30
- if (prop === "_calls") return calls;
31
- if (prop === "_lastWhere") return lastWhere;
32
- if (prop === "then") return undefined; // Not a thenable by default
33
- if (prop === "execute") return undefined;
34
-
35
- return (...args: any[]) => {
36
- calls.push({ method: prop, args });
37
- if (prop === "where") {
38
- lastWhere = args[0];
39
- }
40
- return chainable({});
41
- };
42
- },
43
- });
44
-
45
- const db = {
46
- select: (...args: any[]) => {
47
- calls.push({ method: "select", args });
48
- return chainable({});
49
- },
50
- insert: (table: any) => {
51
- calls.push({ method: "insert", args: [table] });
52
- return chainable({});
53
- },
54
- update: (table: any) => {
55
- calls.push({ method: "update", args: [table] });
56
- return chainable({});
57
- },
58
- delete: (table: any) => {
59
- calls.push({ method: "delete", args: [table] });
60
- return chainable({});
61
- },
62
- execute: (sqlQuery: any) => {
63
- calls.push({ method: "execute", args: [sqlQuery] });
64
- return Promise.resolve([]);
65
- },
66
- query: {},
67
- $client: {},
68
- _: { session: {} },
69
- _calls: calls,
70
- _lastWhere: () => lastWhere,
71
- };
72
-
73
- return db;
74
- }
75
-
76
- function makeCtx(userId: string, role = "user"): GencowCtx {
77
- return {
78
- auth: {
79
- getUserIdentity: () => ({ id: userId, email: `${userId}@test.com`, name: "Test" }),
80
- requireAuth: () => ({ id: userId, email: `${userId}@test.com`, name: "Test", role } as any),
81
- },
82
- db: {},
83
- unsafeDb: {},
84
- storage: {} as any,
85
- scheduler: {} as any,
86
- realtime: {} as any,
87
- retry: {} as any,
88
- };
89
- }
90
-
91
- function makeAnonCtx(): GencowCtx {
92
- return {
93
- auth: {
94
- getUserIdentity: () => null,
95
- requireAuth: () => { throw new Error("Authentication required"); },
96
- },
97
- db: {},
98
- unsafeDb: {},
99
- storage: {} as any,
100
- scheduler: {} as any,
101
- realtime: {} as any,
102
- retry: {} as any,
103
- };
104
- }
105
-
106
- // ─── Tests ───────────────────────────────────────────────
107
-
108
- describe("createScopedDb()", () => {
109
- beforeEach(() => {
110
- _resetTableRegistry();
111
- });
112
-
113
- // ── execute() 차단 ──────────────────────────────────
114
-
115
- describe("execute() 차단", () => {
116
- it("ctx.db.execute()를 호출하면 에러를 던진다", () => {
117
- const db = makeMockDb();
118
- const ctx = makeCtx("user-1");
119
- const scopedDb = createScopedDb(db, ctx);
120
-
121
- expect(() => scopedDb.execute(sql`SELECT 1`)).toThrow(
122
- "ctx.db.execute() is not allowed"
123
- );
124
- });
125
-
126
- it("에러 메시지에 unsafeDb 사용 안내를 포함한다", () => {
127
- const db = makeMockDb();
128
- const ctx = makeCtx("user-1");
129
- const scopedDb = createScopedDb(db, ctx);
130
-
131
- try {
132
- scopedDb.execute(sql`SELECT 1`);
133
- expect(true).toBe(false); // should not reach
134
- } catch (err: any) {
135
- expect(err.message).toContain("ctx.unsafeDb.execute()");
136
- }
137
- });
138
- });
139
-
140
- // ── $client / _ 접근 차단 ────────────────────────────
141
-
142
- describe("직접 클라이언트 접근 차단", () => {
143
- it("ctx.db.$client 접근 시 에러", () => {
144
- const db = makeMockDb();
145
- const ctx = makeCtx("user-1");
146
- const scopedDb = createScopedDb(db, ctx);
147
-
148
- expect(() => scopedDb.$client).toThrow("ctx.db.$client is not allowed");
149
- });
150
-
151
- it("ctx.db._ 접근 시 에러", () => {
152
- const db = makeMockDb();
153
- const ctx = makeCtx("user-1");
154
- const scopedDb = createScopedDb(db, ctx);
155
-
156
- expect(() => scopedDb._).toThrow("ctx.db._ is not allowed");
157
- });
158
- });
159
-
160
- // ── select().from() filter 주입 ─────────────────────
161
-
162
- describe("select().from() — filter 주입", () => {
163
- it("gencowTable에서 select().from() 호출 시 from이 실행된다", () => {
164
- const table = gencowTable("scoped_tasks", {
165
- id: serial("id").primaryKey(),
166
- userId: text("user_id").notNull(),
167
- }, {
168
- filter: () => true,
169
- });
170
-
171
- const db = makeMockDb();
172
- const ctx = makeCtx("user-1");
173
- const scopedDb = createScopedDb(db, ctx);
174
-
175
- scopedDb.select().from(table);
176
-
177
- const fromCall = db._calls.find((c: any) => c.method === "from");
178
- expect(fromCall).toBeDefined();
179
- });
180
-
181
- it("pgTable에서 select().from() 호출 시 그대로 통과한다", () => {
182
- const table = pgTable("plain_tasks", {
183
- id: serial("id").primaryKey(),
184
- });
185
-
186
- const db = makeMockDb();
187
- const ctx = makeCtx("user-1");
188
- const scopedDb = createScopedDb(db, ctx);
189
-
190
- scopedDb.select().from(table);
191
-
192
- const fromCall = db._calls.find((c: any) => c.method === "from");
193
- expect(fromCall).toBeDefined();
194
- // No where should be added for plain pgTable
195
- });
196
-
197
- it("filter: () => true 일 때 추가 where 없음", () => {
198
- const table = gencowTable("public_table", {
199
- id: serial("id").primaryKey(),
200
- }, {
201
- filter: () => true,
202
- });
203
-
204
- const db = makeMockDb();
205
- const ctx = makeCtx("user-1");
206
- const scopedDb = createScopedDb(db, ctx);
207
-
208
- const chain = scopedDb.select().from(table);
209
-
210
- // true filter 일 때 where가 호출되지 않아야 함
211
- // (then에서 pending filters가 true이므로)
212
- const calls = db._calls;
213
- const whereCall = calls.find((c: any) => c.method === "where");
214
- // No where for public tables
215
- });
216
- });
217
-
218
- // ── insert는 필터 없이 통과 ─────────────────────────
219
-
220
- describe("insert() — 패스스루", () => {
221
- it("insert()는 필터 없이 통과한다", () => {
222
- const table = gencowTable("insert_test", {
223
- id: serial("id").primaryKey(),
224
- userId: text("user_id").notNull(),
225
- }, ownerFilter("userId"));
226
-
227
- const db = makeMockDb();
228
- const ctx = makeCtx("user-1");
229
- const scopedDb = createScopedDb(db, ctx);
230
-
231
- // insert는 새 행 추가이므로 filter 적용하지 않음
232
- // (filter는 읽기/수정/삭제용)
233
- scopedDb.insert(table);
234
-
235
- const insertCall = db._calls.find((c: any) => c.method === "insert");
236
- expect(insertCall).toBeDefined();
237
- });
238
- });
239
-
240
- // ── update/delete filter 주입 ───────────────────────
241
-
242
- describe("update() / delete() — filter 주입", () => {
243
- it("update(gencowTable)가 실행된다", () => {
244
- const table = gencowTable("update_test", {
245
- id: serial("id").primaryKey(),
246
- userId: text("user_id").notNull(),
247
- }, ownerFilter("userId"));
248
-
249
- const db = makeMockDb();
250
- const ctx = makeCtx("user-1");
251
- const scopedDb = createScopedDb(db, ctx);
252
-
253
- scopedDb.update(table);
254
-
255
- const updateCall = db._calls.find((c: any) => c.method === "update");
256
- expect(updateCall).toBeDefined();
257
- });
258
-
259
- it("delete(gencowTable)가 실행된다", () => {
260
- const table = gencowTable("delete_test", {
261
- id: serial("id").primaryKey(),
262
- userId: text("user_id").notNull(),
263
- }, ownerFilter("userId"));
264
-
265
- const db = makeMockDb();
266
- const ctx = makeCtx("user-1");
267
- const scopedDb = createScopedDb(db, ctx);
268
-
269
- scopedDb.delete(table);
270
-
271
- const deleteCall = db._calls.find((c: any) => c.method === "delete");
272
- expect(deleteCall).toBeDefined();
273
- });
274
- });
275
- });
276
-
277
- // ─── applyFieldAccess ───────────────────────────────────
278
-
279
- describe("applyFieldAccess()", () => {
280
- beforeEach(() => {
281
- _resetTableRegistry();
282
- });
283
-
284
- it("미허용 필드를 null로 치환한다", () => {
285
- const table = gencowTable("field_mask", {
286
- id: serial("id").primaryKey(),
287
- salary: integer("salary"),
288
- name: text("name"),
289
- }, {
290
- filter: () => true,
291
- fieldAccess: {
292
- salary: { read: (ctx) => (ctx.auth.requireAuth() as any).role === "admin" },
293
- },
294
- });
295
-
296
- const result = [
297
- { id: 1, salary: 100000, name: "Alice" },
298
- { id: 2, salary: 200000, name: "Bob" },
299
- ];
300
-
301
- const userCtx = makeCtx("user-1", "user");
302
- const masked = applyFieldAccess(result, table, userCtx);
303
-
304
- expect(masked[0].salary).toBeNull();
305
- expect(masked[0].name).toBe("Alice");
306
- expect(masked[1].salary).toBeNull();
307
- expect(masked[1].name).toBe("Bob");
308
- });
309
-
310
- it("허용된 사용자의 필드는 유지된다", () => {
311
- const table = gencowTable("field_keep", {
312
- id: serial("id").primaryKey(),
313
- salary: integer("salary"),
314
- }, {
315
- filter: () => true,
316
- fieldAccess: {
317
- salary: { read: (ctx) => (ctx.auth.requireAuth() as any).role === "admin" },
318
- },
319
- });
320
-
321
- const result = [{ id: 1, salary: 100000 }];
322
- const adminCtx = makeCtx("admin-1", "admin");
323
- const kept = applyFieldAccess(result, table, adminCtx);
324
-
325
- expect(kept[0].salary).toBe(100000);
326
- });
327
-
328
- it("fieldAccess가 없는 테이블은 결과를 그대로 반환한다", () => {
329
- const table = gencowTable("no_field_access", {
330
- id: serial("id").primaryKey(),
331
- salary: integer("salary"),
332
- }, {
333
- filter: () => true,
334
- });
335
-
336
- const result = [{ id: 1, salary: 100000 }];
337
- const ctx = makeCtx("user-1");
338
- const kept = applyFieldAccess(result, table, ctx);
339
-
340
- expect(kept[0].salary).toBe(100000);
341
- });
342
-
343
- it("단일 객체도 처리한다 (배열이 아닌 경우)", () => {
344
- const table = gencowTable("single_obj", {
345
- id: serial("id").primaryKey(),
346
- salary: integer("salary"),
347
- }, {
348
- filter: () => true,
349
- fieldAccess: {
350
- salary: { read: () => false },
351
- },
352
- });
353
-
354
- const result = { id: 1, salary: 100000 };
355
- const ctx = makeCtx("user-1");
356
- const masked = applyFieldAccess(result, table, ctx);
357
-
358
- expect(masked.salary).toBeNull();
359
- expect(masked.id).toBe(1);
360
- });
361
-
362
- it("pgTable은 그대로 반환한다", () => {
363
- const table = pgTable("pg_no_field", {
364
- id: serial("id").primaryKey(),
365
- salary: integer("salary"),
366
- });
367
-
368
- const result = [{ id: 1, salary: 100000 }];
369
- const ctx = makeCtx("user-1");
370
- const kept = applyFieldAccess(result, table, ctx);
371
-
372
- expect(kept[0].salary).toBe(100000);
373
- });
374
-
375
- it("비인증 사용자 — read 체크 실패 시 필드를 null로 치환", () => {
376
- const table = gencowTable("anon_field", {
377
- id: serial("id").primaryKey(),
378
- secret: text("secret"),
379
- }, {
380
- filter: () => true,
381
- fieldAccess: {
382
- secret: { read: (ctx) => !!ctx.auth.getUserIdentity() },
383
- },
384
- });
385
-
386
- const result = [{ id: 1, secret: "top-secret" }];
387
- const anonCtx = makeAnonCtx();
388
- const masked = applyFieldAccess(result, table, anonCtx);
389
-
390
- expect(masked[0].secret).toBeNull();
391
- });
392
-
393
- it("여러 필드에 대한 fieldAccess 적용", () => {
394
- const table = gencowTable("multi_field", {
395
- id: serial("id").primaryKey(),
396
- salary: integer("salary"),
397
- ssn: text("ssn"),
398
- name: text("name"),
399
- }, {
400
- filter: () => true,
401
- fieldAccess: {
402
- salary: { read: () => false },
403
- ssn: { read: () => false },
404
- },
405
- });
406
-
407
- const result = { id: 1, salary: 100000, ssn: "123-45-6789", name: "Alice" };
408
- const ctx = makeCtx("user-1");
409
- const masked = applyFieldAccess(result, table, ctx);
410
-
411
- expect(masked.salary).toBeNull();
412
- expect(masked.ssn).toBeNull();
413
- expect(masked.name).toBe("Alice");
414
- expect(masked.id).toBe(1);
415
- });
416
-
417
- it("null/undefined 결과는 그대로 반환", () => {
418
- const table = gencowTable("null_result", {
419
- id: serial("id").primaryKey(),
420
- }, {
421
- filter: () => true,
422
- fieldAccess: { id: { read: () => false } },
423
- });
424
-
425
- const ctx = makeCtx("user-1");
426
- expect(applyFieldAccess(null, table, ctx)).toBeNull();
427
- expect(applyFieldAccess(undefined, table, ctx)).toBeUndefined();
428
- });
429
-
430
- it("빈 배열은 그대로 반환", () => {
431
- const table = gencowTable("empty_arr", {
432
- id: serial("id").primaryKey(),
433
- }, {
434
- filter: () => true,
435
- fieldAccess: { id: { read: () => false } },
436
- });
437
-
438
- const ctx = makeCtx("user-1");
439
- const result = applyFieldAccess([], table, ctx);
440
- expect(result).toEqual([]);
441
- });
442
- });