@gencow/core 0.1.21 → 0.1.23
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 +12 -12
- package/dist/crud.js +4 -4
- package/dist/index.d.ts +19 -18
- package/dist/index.js +10 -10
- package/dist/reactive.d.ts +4 -4
- package/dist/reactive.js +6 -0
- package/dist/rls-db.d.ts +43 -4
- package/dist/rls-db.js +212 -7
- package/dist/rls.d.ts +1 -1
- package/dist/rls.js +1 -1
- package/dist/scheduler.d.ts +35 -5
- package/dist/scheduler.js +83 -42
- package/dist/server.d.ts +5 -5
- package/dist/server.js +4 -4
- package/package.json +43 -42
- package/src/__tests__/crud-owner-rls.test.ts +6 -6
- package/src/__tests__/fixtures/basic/migrations/{0000_faithful_silver_sable.sql → 0000_last_warstar.sql} +9 -0
- package/src/__tests__/fixtures/basic/migrations/meta/0000_snapshot.json +60 -1
- package/src/__tests__/fixtures/basic/migrations/meta/_journal.json +2 -2
- package/src/__tests__/fixtures/basic/schema.ts +19 -3
- package/src/__tests__/helpers/basic-rls-fixture.ts +133 -0
- package/src/__tests__/helpers/test-gencow-ctx-rls.ts +1 -1
- package/src/__tests__/reactive.test.ts +161 -0
- package/src/__tests__/rls-crud-basic.test.ts +120 -161
- package/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts +117 -0
- package/src/__tests__/rls-custom-mutation-handlers.test.ts +189 -0
- package/src/__tests__/rls-custom-query-handlers.test.ts +128 -0
- package/src/__tests__/rls-db-leased-connection.test.ts +122 -0
- package/src/__tests__/rls-session-and-policies.test.ts +246 -0
- package/src/__tests__/scheduler-durable-v2.test.ts +270 -0
- package/src/__tests__/scheduler-durable.test.ts +173 -0
- package/src/crud.ts +4 -4
- package/src/index.ts +19 -18
- package/src/reactive.ts +12 -4
- package/src/rls-db.ts +277 -10
- package/src/rls.ts +1 -1
- package/src/scheduler.ts +124 -46
- package/src/server.ts +5 -5
|
@@ -294,3 +294,164 @@ describe("mutation(name, def) — query와 동일 패턴", () => {
|
|
|
294
294
|
console.warn = originalWarn;
|
|
295
295
|
});
|
|
296
296
|
});
|
|
297
|
+
|
|
298
|
+
// ─── _flushRefresh — buildCtxForRefresh 통합 테스트 ──────────────────────────
|
|
299
|
+
|
|
300
|
+
describe("_flushRefresh() — query re-run via buildCtxForRefresh", () => {
|
|
301
|
+
|
|
302
|
+
it("buildCtxForRefresh 전달 시 query handler가 정상 ctx로 re-run된다", async () => {
|
|
303
|
+
// 테스트용 query 등록
|
|
304
|
+
const testQueryKey = "flush.test.rerun";
|
|
305
|
+
const handler = mock(async (ctx: any) => {
|
|
306
|
+
// ctx.db가 존재하는지 확인 (이전 버그: ctx = {} → crash)
|
|
307
|
+
if (!ctx.db) throw new Error("ctx.db is undefined!");
|
|
308
|
+
return [{ id: 1, count: ctx.db.mockValue }];
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
query(testQueryKey, { public: true, handler });
|
|
312
|
+
|
|
313
|
+
// buildCtxForRefresh 콜백 제공
|
|
314
|
+
const mockDb = { mockValue: 42 };
|
|
315
|
+
const rt = buildRealtimeCtx({
|
|
316
|
+
buildCtxForRefresh: () => ({
|
|
317
|
+
db: mockDb,
|
|
318
|
+
auth: { getUserIdentity: () => null, requireAuth: () => { throw new Error(); } },
|
|
319
|
+
realtime: { emit: () => {}, refresh: () => {} },
|
|
320
|
+
} as any),
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
rt.refresh(testQueryKey);
|
|
324
|
+
await rt._flushRefresh();
|
|
325
|
+
|
|
326
|
+
// handler가 호출됐는지 확인
|
|
327
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
328
|
+
// ctx.db가 올바르게 전달됐는지 확인
|
|
329
|
+
const callCtx = handler.mock.calls[0][0];
|
|
330
|
+
expect(callCtx.db).toBe(mockDb);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("buildCtxForRefresh 미전달 시 ({} as ctx) 사용 + 경고 로그 출력", async () => {
|
|
334
|
+
const testQueryKey = "flush.test.noCallback";
|
|
335
|
+
const handler = mock(async (_ctx: any) => [{ id: 1 }]);
|
|
336
|
+
query(testQueryKey, { public: true, handler });
|
|
337
|
+
|
|
338
|
+
const warnSpy = mock(() => {});
|
|
339
|
+
const originalWarn = console.warn;
|
|
340
|
+
console.warn = warnSpy;
|
|
341
|
+
|
|
342
|
+
// buildCtxForRefresh 없이 생성
|
|
343
|
+
const rt = buildRealtimeCtx();
|
|
344
|
+
rt.refresh(testQueryKey);
|
|
345
|
+
await rt._flushRefresh();
|
|
346
|
+
|
|
347
|
+
// 경고 출력 확인
|
|
348
|
+
const warnCalls = warnSpy.mock.calls.map(c => String(c[0]));
|
|
349
|
+
const hasWarning = warnCalls.some(msg =>
|
|
350
|
+
msg.includes("buildCtxForRefresh not provided")
|
|
351
|
+
);
|
|
352
|
+
expect(hasWarning).toBe(true);
|
|
353
|
+
|
|
354
|
+
console.warn = originalWarn;
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("refresh 결과가 WS 구독자에게 query:updated로 push된다", async () => {
|
|
358
|
+
const testQueryKey = "flush.test.push";
|
|
359
|
+
const freshData = [{ id: 99, name: "Refreshed" }];
|
|
360
|
+
query(testQueryKey, {
|
|
361
|
+
public: true,
|
|
362
|
+
handler: async () => freshData,
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const ws = makeMockWs();
|
|
366
|
+
subscribe(testQueryKey, ws);
|
|
367
|
+
|
|
368
|
+
const rt = buildRealtimeCtx({
|
|
369
|
+
buildCtxForRefresh: () => ({
|
|
370
|
+
db: {},
|
|
371
|
+
auth: { getUserIdentity: () => null, requireAuth: () => { throw new Error(); } },
|
|
372
|
+
realtime: { emit: () => {}, refresh: () => {} },
|
|
373
|
+
} as any),
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
rt.refresh(testQueryKey);
|
|
377
|
+
await rt._flushRefresh();
|
|
378
|
+
|
|
379
|
+
// 50ms debounce 대기
|
|
380
|
+
await new Promise(r => setTimeout(r, 80));
|
|
381
|
+
|
|
382
|
+
expect(ws._sent.length).toBeGreaterThanOrEqual(1);
|
|
383
|
+
const msg = JSON.parse(ws._sent[ws._sent.length - 1]);
|
|
384
|
+
expect(msg.type).toBe("query:updated");
|
|
385
|
+
expect(msg.query).toBe(testQueryKey);
|
|
386
|
+
expect(msg.data).toEqual(freshData);
|
|
387
|
+
|
|
388
|
+
deregisterClient(ws);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("httpCallback 모드에서 refresh 결과가 callback으로 전달된다", async () => {
|
|
392
|
+
const testQueryKey = "flush.test.http";
|
|
393
|
+
const freshData = [{ id: 77, title: "HTTP Push" }];
|
|
394
|
+
query(testQueryKey, {
|
|
395
|
+
public: true,
|
|
396
|
+
handler: async () => freshData,
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const httpCallback = mock((_event: any) => {});
|
|
400
|
+
|
|
401
|
+
const rt = buildRealtimeCtx({
|
|
402
|
+
httpCallback,
|
|
403
|
+
buildCtxForRefresh: () => ({
|
|
404
|
+
db: {},
|
|
405
|
+
auth: { getUserIdentity: () => null, requireAuth: () => { throw new Error(); } },
|
|
406
|
+
realtime: { emit: () => {}, refresh: () => {} },
|
|
407
|
+
} as any),
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
rt.refresh(testQueryKey);
|
|
411
|
+
await rt._flushRefresh();
|
|
412
|
+
|
|
413
|
+
expect(httpCallback).toHaveBeenCalledTimes(1);
|
|
414
|
+
const event = httpCallback.mock.calls[0][0];
|
|
415
|
+
expect(event.type).toBe("emit");
|
|
416
|
+
expect(event.queryKey).toBe(testQueryKey);
|
|
417
|
+
expect(event.data).toEqual(freshData);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("flush 후 _pendingRefresh가 비워진다", async () => {
|
|
421
|
+
const testQueryKey = "flush.test.clear";
|
|
422
|
+
query(testQueryKey, { public: true, handler: async () => [] });
|
|
423
|
+
|
|
424
|
+
const rt = buildRealtimeCtx({
|
|
425
|
+
buildCtxForRefresh: () => ({
|
|
426
|
+
db: {},
|
|
427
|
+
auth: { getUserIdentity: () => null, requireAuth: () => { throw new Error(); } },
|
|
428
|
+
realtime: { emit: () => {}, refresh: () => {} },
|
|
429
|
+
} as any),
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
rt.refresh(testQueryKey);
|
|
433
|
+
expect(rt._pendingRefresh).toHaveLength(1);
|
|
434
|
+
|
|
435
|
+
await rt._flushRefresh();
|
|
436
|
+
expect(rt._pendingRefresh).toHaveLength(0);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("미등록 queryKey refresh는 무시되고 경고 출력", async () => {
|
|
440
|
+
const warnSpy = mock(() => {});
|
|
441
|
+
const originalWarn = console.warn;
|
|
442
|
+
console.warn = warnSpy;
|
|
443
|
+
|
|
444
|
+
const rt = buildRealtimeCtx({
|
|
445
|
+
buildCtxForRefresh: () => ({} as any),
|
|
446
|
+
});
|
|
447
|
+
rt.refresh("nonexistent.query.key.xyz");
|
|
448
|
+
await rt._flushRefresh();
|
|
449
|
+
|
|
450
|
+
const hasWarning = warnSpy.mock.calls.some(c =>
|
|
451
|
+
String(c[0]).includes("query not found in registry")
|
|
452
|
+
);
|
|
453
|
+
expect(hasWarning).toBe(true);
|
|
454
|
+
|
|
455
|
+
console.warn = originalWarn;
|
|
456
|
+
});
|
|
457
|
+
});
|
|
@@ -5,131 +5,39 @@
|
|
|
5
5
|
* filled by drizzle-seed’s same per-type generators as `seed()` (see `fillPartialRowsForInsert`).
|
|
6
6
|
*
|
|
7
7
|
* Migrations run as the PGlite bootstrap user (table owner). We then create `gencow_rls_app` and
|
|
8
|
-
* `SET ROLE` so the session is non-owner → RLS policies apply. `createRlsDb`
|
|
9
|
-
* `
|
|
8
|
+
* `SET ROLE` so the session is non-owner → RLS policies apply. `createRlsDb` injects
|
|
9
|
+
* `app.current_user_id` for every query path (including bare `select()` / `execute()`).
|
|
10
10
|
* We rely on `current_setting('app.current_user_id', true)` (missing_ok=true): this avoids
|
|
11
11
|
* missing-GUC errors before `set_config` and keeps PGlite behavior aligned with PostgreSQL.
|
|
12
12
|
*
|
|
13
13
|
* Run: bun test packages/core/src/__tests__/rls-crud-basic.test.ts
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
it,
|
|
19
|
-
expect,
|
|
20
|
-
beforeAll,
|
|
21
|
-
afterAll,
|
|
22
|
-
} from "bun:test";
|
|
23
|
-
import { dirname, join } from "path";
|
|
24
|
-
import { fileURLToPath } from "url";
|
|
25
|
-
import type { InferSelectModel } from "drizzle-orm";
|
|
16
|
+
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
17
|
+
import { eq, type InferSelectModel } from "drizzle-orm";
|
|
26
18
|
import { PGlite } from "@electric-sql/pglite";
|
|
27
19
|
import { drizzle } from "drizzle-orm/pglite";
|
|
28
|
-
import { crud } from "../crud";
|
|
29
|
-
import
|
|
30
|
-
import { tasks
|
|
20
|
+
import { crud } from "../crud.js";
|
|
21
|
+
import { createRlsDb } from "../rls-db.js";
|
|
22
|
+
import { tasks } from "./fixtures/basic/schema.js";
|
|
31
23
|
import {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
24
|
+
basicFixtureUsers as fixtureUsers,
|
|
25
|
+
basicFixtureTasks as fixtureTasks,
|
|
26
|
+
basicUser0Identity as user0Identity,
|
|
27
|
+
basicUser1Identity as user1Identity,
|
|
28
|
+
createBasicRlsEnvironment,
|
|
29
|
+
} from "./helpers/basic-rls-fixture.js";
|
|
38
30
|
import {
|
|
39
31
|
makeTestGencowCtxWithRls,
|
|
40
32
|
runWithRollbackTestGencowCtxWithRls,
|
|
41
|
-
} from "./helpers/test-gencow-ctx-rls";
|
|
42
|
-
|
|
43
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
44
|
-
|
|
45
|
-
const fixtureUsers = [
|
|
46
|
-
{ id: "us_000", name: "User 0", email: "user-0@s.com", emailVerified: true },
|
|
47
|
-
{ id: "us_001", name: "User 1", email: "user-1@s.com", emailVerified: true },
|
|
48
|
-
];
|
|
49
|
-
|
|
50
|
-
/** Realistic titles; "Project Alpha" appears on two rows for us_000 and one for us_001 (RLS). */
|
|
51
|
-
const fixtureTasks = [
|
|
52
|
-
{
|
|
53
|
-
id: "tk-000",
|
|
54
|
-
userId: fixtureUsers[0].id,
|
|
55
|
-
done: false,
|
|
56
|
-
title: "Project Alpha — Q4 review prep",
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
id: "tk-001",
|
|
60
|
-
userId: fixtureUsers[1].id,
|
|
61
|
-
done: true,
|
|
62
|
-
title: "Project Alpha — teammate handoff",
|
|
63
|
-
},
|
|
64
|
-
{
|
|
65
|
-
id: "tk-002",
|
|
66
|
-
userId: fixtureUsers[0].id,
|
|
67
|
-
done: false,
|
|
68
|
-
title: "Project Alpha — backlog grooming",
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
id: "tk-003",
|
|
72
|
-
userId: fixtureUsers[0].id,
|
|
73
|
-
done: false,
|
|
74
|
-
title: "Quarterly planning — Q4",
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
id: "tk-004",
|
|
78
|
-
userId: fixtureUsers[1].id,
|
|
79
|
-
done: false,
|
|
80
|
-
title: "Project Beta — API docs",
|
|
81
|
-
},
|
|
82
|
-
{
|
|
83
|
-
id: "tk-005",
|
|
84
|
-
userId: fixtureUsers[1].id,
|
|
85
|
-
done: false,
|
|
86
|
-
title: "Project Gamma — research notes",
|
|
87
|
-
},
|
|
88
|
-
{
|
|
89
|
-
id: "tk-006",
|
|
90
|
-
userId: fixtureUsers[0].id,
|
|
91
|
-
done: false,
|
|
92
|
-
title: "Project Beta — spike",
|
|
93
|
-
},
|
|
94
|
-
];
|
|
95
|
-
|
|
96
|
-
const user0Identity = {
|
|
97
|
-
id: fixtureUsers[0].id,
|
|
98
|
-
email: fixtureUsers[0].email,
|
|
99
|
-
} satisfies UserIdentity;
|
|
100
|
-
|
|
101
|
-
const user1Identity = {
|
|
102
|
-
id: fixtureUsers[1].id,
|
|
103
|
-
email: fixtureUsers[1].email,
|
|
104
|
-
} satisfies UserIdentity;
|
|
33
|
+
} from "./helpers/test-gencow-ctx-rls.js";
|
|
105
34
|
|
|
106
35
|
type TaskRow = InferSelectModel<typeof tasks>;
|
|
107
36
|
type CrudDefs = ReturnType<typeof crud<typeof tasks>>;
|
|
108
37
|
type TaskListResult = { data: TaskRow[]; total: number };
|
|
109
38
|
|
|
110
|
-
async function seedBasicFixtures(db: ReturnType<typeof drizzle>) {
|
|
111
|
-
await db.insert(user).values(fillPartialRowsForInsert(user, fixtureUsers));
|
|
112
|
-
const taskRows = fillPartialRowsForInsert(
|
|
113
|
-
tasks,
|
|
114
|
-
fixtureTasks
|
|
115
|
-
) as TaskRow[];
|
|
116
|
-
await db.insert(tasks).values(taskRows);
|
|
117
|
-
return taskRows;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
39
|
async function createSeededCrudEnv() {
|
|
121
|
-
const client =
|
|
122
|
-
await client.waitReady;
|
|
123
|
-
await loadAndApplyMigrations(
|
|
124
|
-
client,
|
|
125
|
-
join(__dirname, "fixtures/basic/migrations")
|
|
126
|
-
);
|
|
127
|
-
const db = drizzle(client);
|
|
128
|
-
const taskRows = await seedBasicFixtures(db);
|
|
129
|
-
await createPgliteRlsAppRole(client, {
|
|
130
|
-
roleName: DEFAULT_PGLITE_RLS_APP_ROLE,
|
|
131
|
-
});
|
|
132
|
-
await setPgliteSessionRole(client, DEFAULT_PGLITE_RLS_APP_ROLE);
|
|
40
|
+
const { client, db, taskRows } = await createBasicRlsEnvironment();
|
|
133
41
|
|
|
134
42
|
const defs = crud(tasks, {
|
|
135
43
|
prefix: "fixture_basic_pglite_tasks",
|
|
@@ -187,78 +95,116 @@ describe("fixtures/basic + PGlite + CRUD + RLS", () => {
|
|
|
187
95
|
}
|
|
188
96
|
});
|
|
189
97
|
|
|
98
|
+
it("createRlsDb: bare select() applies RLS without explicit db.transaction", async () => {
|
|
99
|
+
const scoped = createRlsDb(db as any, {
|
|
100
|
+
userId: user0Identity.id,
|
|
101
|
+
role: "user",
|
|
102
|
+
tenantId: "tenant_fixture",
|
|
103
|
+
vars: { "app.note": "ok" },
|
|
104
|
+
});
|
|
105
|
+
const otherUserRows = await scoped
|
|
106
|
+
.select()
|
|
107
|
+
.from(tasks)
|
|
108
|
+
.where(eq(tasks.userId, fixtureUsers[1].id));
|
|
109
|
+
expect(otherUserRows.length).toBe(0);
|
|
110
|
+
|
|
111
|
+
const ownRows = await scoped
|
|
112
|
+
.select()
|
|
113
|
+
.from(tasks)
|
|
114
|
+
.where(eq(tasks.userId, user0Identity.id));
|
|
115
|
+
expect(ownRows.length).toBe(
|
|
116
|
+
seededTaskRows.filter((r) => r.userId === user0Identity.id).length
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
190
120
|
it("tasks.list returns only the current user (us_000) rows per RLS and total matches", async () => {
|
|
191
121
|
expect(listDef).toBeDefined();
|
|
192
122
|
|
|
193
|
-
await runWithRollbackTestGencowCtxWithRls(
|
|
194
|
-
|
|
123
|
+
await runWithRollbackTestGencowCtxWithRls(
|
|
124
|
+
db,
|
|
125
|
+
user0Identity,
|
|
126
|
+
async (ctx) => {
|
|
127
|
+
const listResult = await listHandler(ctx, {});
|
|
195
128
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
129
|
+
const expected = seededTaskRows.filter(
|
|
130
|
+
(r) => r.userId === fixtureUsers[0].id
|
|
131
|
+
);
|
|
199
132
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
133
|
+
expect(listResult).toHaveProperty("data");
|
|
134
|
+
expect(listResult).toHaveProperty("total");
|
|
135
|
+
expect(listResult.total).toBe(expected.length);
|
|
203
136
|
|
|
204
|
-
|
|
137
|
+
const rows = listResult.data;
|
|
205
138
|
|
|
206
|
-
|
|
207
|
-
|
|
139
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
140
|
+
expect(byId.size).toBe(expected.length);
|
|
208
141
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
142
|
+
for (const seeded of expected) {
|
|
143
|
+
const row = byId.get(seeded.id);
|
|
144
|
+
expect(row).toBeDefined();
|
|
145
|
+
expect(row!.id).toBe(seeded.id);
|
|
146
|
+
expect(row!.userId).toBe(seeded.userId);
|
|
147
|
+
expect(row!.done).toBe(seeded.done);
|
|
148
|
+
expect(row!.title).toBe(seeded.title);
|
|
149
|
+
}
|
|
216
150
|
}
|
|
217
|
-
|
|
151
|
+
);
|
|
218
152
|
});
|
|
219
153
|
|
|
220
154
|
it("search parameter applies to title/description ilike search", async () => {
|
|
221
155
|
expect(listDef).toBeDefined();
|
|
222
156
|
|
|
223
|
-
await runWithRollbackTestGencowCtxWithRls(
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
157
|
+
await runWithRollbackTestGencowCtxWithRls(
|
|
158
|
+
db,
|
|
159
|
+
user0Identity,
|
|
160
|
+
async (ctx) => {
|
|
161
|
+
const sharedSubstring = "Project Alpha";
|
|
162
|
+
const expectedForUser0 = seededTaskRows.filter(
|
|
163
|
+
(r) =>
|
|
164
|
+
r.userId === user0Identity.id &&
|
|
165
|
+
r.title.toLowerCase().includes(sharedSubstring.toLowerCase())
|
|
166
|
+
);
|
|
167
|
+
expect(expectedForUser0.length).toBe(2);
|
|
168
|
+
|
|
169
|
+
const result = await listHandler(ctx, { search: sharedSubstring });
|
|
170
|
+
const rows = result.data;
|
|
171
|
+
expect(rows.length).toBe(expectedForUser0.length);
|
|
172
|
+
|
|
173
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
174
|
+
for (const seeded of expectedForUser0) {
|
|
175
|
+
expect(byId.get(seeded.id)?.title).toBe(seeded.title);
|
|
176
|
+
}
|
|
239
177
|
}
|
|
240
|
-
|
|
178
|
+
);
|
|
241
179
|
});
|
|
242
180
|
|
|
243
181
|
it("tasks.get succeeds when current user reads own task", async () => {
|
|
244
182
|
const ownTaskId = "tk-003";
|
|
245
183
|
|
|
246
|
-
await runWithRollbackTestGencowCtxWithRls(
|
|
247
|
-
|
|
184
|
+
await runWithRollbackTestGencowCtxWithRls(
|
|
185
|
+
db,
|
|
186
|
+
user0Identity,
|
|
187
|
+
async (ctx) => {
|
|
188
|
+
const task = await getHandler(ctx, { id: ownTaskId });
|
|
248
189
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
190
|
+
expect(task).toBeDefined();
|
|
191
|
+
expect(task?.id).toBe(ownTaskId);
|
|
192
|
+
expect(task?.userId).toBe(user0Identity.id);
|
|
193
|
+
}
|
|
194
|
+
);
|
|
253
195
|
});
|
|
254
196
|
|
|
255
197
|
it("tasks.get fails when current user tries to read another user's task", async () => {
|
|
256
198
|
const user1TaskId = "tk-001";
|
|
257
199
|
|
|
258
|
-
await runWithRollbackTestGencowCtxWithRls(
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
200
|
+
await runWithRollbackTestGencowCtxWithRls(
|
|
201
|
+
db,
|
|
202
|
+
user0Identity,
|
|
203
|
+
async (ctx) => {
|
|
204
|
+
const task = await getHandler(ctx, { id: user1TaskId });
|
|
205
|
+
expect(task).toBeNull();
|
|
206
|
+
}
|
|
207
|
+
);
|
|
262
208
|
});
|
|
263
209
|
|
|
264
210
|
it("tasks.update succeeds when updating a task owned by current user", async () => {
|
|
@@ -335,16 +281,23 @@ describe("fixtures/basic + PGlite + CRUD + RLS", () => {
|
|
|
335
281
|
db,
|
|
336
282
|
user0Identity,
|
|
337
283
|
async (user0Ctx) => {
|
|
338
|
-
|
|
339
|
-
|
|
284
|
+
let thrown: unknown;
|
|
285
|
+
try {
|
|
286
|
+
await createHandler(user0Ctx, {
|
|
340
287
|
id: unauthorizedTaskId,
|
|
341
288
|
userId: user1Identity.id,
|
|
342
289
|
title: "Unauthorized create attempt",
|
|
343
290
|
done: false,
|
|
344
|
-
})
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
291
|
+
});
|
|
292
|
+
} catch (e) {
|
|
293
|
+
thrown = e;
|
|
294
|
+
}
|
|
295
|
+
expect(thrown).toBeInstanceOf(Error);
|
|
296
|
+
|
|
297
|
+
const user1Ctx = makeTestGencowCtxWithRls(
|
|
298
|
+
user0Ctx.unsafeDb as any,
|
|
299
|
+
user1Identity
|
|
300
|
+
);
|
|
348
301
|
const after = await getHandler(user1Ctx, { id: unauthorizedTaskId });
|
|
349
302
|
expect(after).toBeNull();
|
|
350
303
|
}
|
|
@@ -357,7 +310,10 @@ describe("fixtures/basic + PGlite + CRUD + RLS", () => {
|
|
|
357
310
|
db,
|
|
358
311
|
user0Identity,
|
|
359
312
|
async (user0Ctx) => {
|
|
360
|
-
const user1Ctx = makeTestGencowCtxWithRls(
|
|
313
|
+
const user1Ctx = makeTestGencowCtxWithRls(
|
|
314
|
+
user0Ctx.unsafeDb as any,
|
|
315
|
+
user1Identity
|
|
316
|
+
);
|
|
361
317
|
|
|
362
318
|
const before = await getHandler(user1Ctx, {
|
|
363
319
|
id: user1TaskId,
|
|
@@ -410,7 +366,10 @@ describe("fixtures/basic + PGlite + CRUD + RLS", () => {
|
|
|
410
366
|
db,
|
|
411
367
|
user0Identity,
|
|
412
368
|
async (user0Ctx) => {
|
|
413
|
-
const user1Ctx = makeTestGencowCtxWithRls(
|
|
369
|
+
const user1Ctx = makeTestGencowCtxWithRls(
|
|
370
|
+
user0Ctx.unsafeDb as any,
|
|
371
|
+
user1Identity
|
|
372
|
+
);
|
|
414
373
|
|
|
415
374
|
const before = await getHandler(user1Ctx, { id: user1TaskId });
|
|
416
375
|
expect(before).toBeDefined();
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `news` has **no** `ownerRls()` and **no** PostgreSQL RLS (`fixtures/basic/migrations/0001_news.sql`).
|
|
3
|
+
* `crud(news, { public: true })` keeps legacy behavior: no Layer-1 owner filter on list/get/update/remove.
|
|
4
|
+
*
|
|
5
|
+
* Complements mock-based `crud-owner-rls.test.ts` (“하위호환”) with PGlite + non-owner session.
|
|
6
|
+
*
|
|
7
|
+
* Run: bun test packages/core/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
11
|
+
|
|
12
|
+
import { crud } from "../crud.js";
|
|
13
|
+
import { createRlsDb } from "../rls-db.js";
|
|
14
|
+
import { news } from "./fixtures/basic/schema.js";
|
|
15
|
+
import {
|
|
16
|
+
assertTableRowLevelSecurityDisabled,
|
|
17
|
+
basicUser0Identity,
|
|
18
|
+
basicUser1Identity,
|
|
19
|
+
createBasicRlsEnvironment,
|
|
20
|
+
} from "./helpers/basic-rls-fixture.js";
|
|
21
|
+
import {
|
|
22
|
+
makeTestGencowCtxWithRls,
|
|
23
|
+
runWithRollbackTestGencowCtxWithRls,
|
|
24
|
+
} from "./helpers/test-gencow-ctx-rls.js";
|
|
25
|
+
|
|
26
|
+
describe("crud + PGlite: news without ownerRls, public crud", () => {
|
|
27
|
+
let client: import("@electric-sql/pglite").PGlite;
|
|
28
|
+
let db: ReturnType<typeof import("drizzle-orm/pglite").drizzle>;
|
|
29
|
+
let listHandler: (ctx: unknown, args: unknown) => Promise<{ data: unknown[]; total: number }>;
|
|
30
|
+
let getHandler: (ctx: unknown, args: unknown) => Promise<unknown>;
|
|
31
|
+
let createHandler: (ctx: unknown, args: unknown) => Promise<unknown>;
|
|
32
|
+
let updateHandler: (ctx: unknown, args: unknown) => Promise<unknown>;
|
|
33
|
+
let removeHandler: (ctx: unknown, args: unknown) => Promise<{ success: boolean }>;
|
|
34
|
+
|
|
35
|
+
beforeAll(async () => {
|
|
36
|
+
const env = await createBasicRlsEnvironment();
|
|
37
|
+
client = env.client;
|
|
38
|
+
db = env.db;
|
|
39
|
+
|
|
40
|
+
const defs = crud(news, {
|
|
41
|
+
prefix: "fixture_basic_news",
|
|
42
|
+
public: true,
|
|
43
|
+
defaultLimit: 50,
|
|
44
|
+
});
|
|
45
|
+
listHandler = defs.list!.handler as (typeof listHandler);
|
|
46
|
+
getHandler = defs.get!.handler as (typeof getHandler);
|
|
47
|
+
createHandler = defs.create!.handler as (typeof createHandler);
|
|
48
|
+
updateHandler = defs.update!.handler as (typeof updateHandler);
|
|
49
|
+
removeHandler = defs.remove!.handler as (typeof removeHandler);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterAll(async () => {
|
|
53
|
+
try {
|
|
54
|
+
await client.close();
|
|
55
|
+
} catch {
|
|
56
|
+
/* ignore */
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("list: authenticated user sees all rows (no owner filter)", async () => {
|
|
61
|
+
const ctx = makeTestGencowCtxWithRls(db, basicUser0Identity);
|
|
62
|
+
const result = await listHandler(ctx, {});
|
|
63
|
+
expect(result.total).toBe(2);
|
|
64
|
+
expect(result.data).toHaveLength(2);
|
|
65
|
+
const ids = new Set(result.data.map((r: any) => r.id));
|
|
66
|
+
expect(ids.has("nw-000")).toBe(true);
|
|
67
|
+
expect(ids.has("nw-001")).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("get: can read another user row by id (no owner filter)", async () => {
|
|
71
|
+
const ctx = makeTestGencowCtxWithRls(db, basicUser0Identity);
|
|
72
|
+
const row = await getHandler(ctx, { id: "nw-001" });
|
|
73
|
+
expect(row).not.toBeNull();
|
|
74
|
+
expect((row as any).userId).toBe(basicUser1Identity.id);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("createRlsDb + bare select: no DB policies — still full table visibility", async () => {
|
|
78
|
+
const scoped = createRlsDb(db as Parameters<typeof createRlsDb>[0], {
|
|
79
|
+
userId: basicUser0Identity.id,
|
|
80
|
+
});
|
|
81
|
+
const rows = await scoped.select().from(news);
|
|
82
|
+
expect(rows.length).toBe(2);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("create: with public crud, pass userId explicitly (no auth-based auto-inject)", async () => {
|
|
86
|
+
await runWithRollbackTestGencowCtxWithRls(db, basicUser0Identity, async (ctx) => {
|
|
87
|
+
const created = await createHandler(ctx, {
|
|
88
|
+
id: "nw-created",
|
|
89
|
+
title: "new row",
|
|
90
|
+
userId: basicUser0Identity.id,
|
|
91
|
+
});
|
|
92
|
+
expect((created as any).userId).toBe(basicUser0Identity.id);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("update: can change another user row by id (no owner WHERE)", async () => {
|
|
97
|
+
await runWithRollbackTestGencowCtxWithRls(db, basicUser0Identity, async (ctx) => {
|
|
98
|
+
const updated = await updateHandler(ctx, {
|
|
99
|
+
id: "nw-001",
|
|
100
|
+
title: "touched by user0",
|
|
101
|
+
});
|
|
102
|
+
expect((updated as any).title).toBe("touched by user0");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("remove: can delete another user row by id (no owner WHERE)", async () => {
|
|
107
|
+
await runWithRollbackTestGencowCtxWithRls(db, basicUser0Identity, async (ctx) => {
|
|
108
|
+
await removeHandler(ctx, { id: "nw-001" });
|
|
109
|
+
const after = await getHandler(ctx, { id: "nw-001" });
|
|
110
|
+
expect(after).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("pg_catalog: news has row security disabled", async () => {
|
|
115
|
+
await assertTableRowLevelSecurityDisabled(db, news);
|
|
116
|
+
});
|
|
117
|
+
});
|