@gencow/core 0.1.22 → 0.1.24
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.js +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +3 -0
- 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/workflow-types.d.ts +81 -0
- package/dist/workflow-types.js +12 -0
- package/dist/workflow.d.ts +30 -0
- package/dist/workflow.js +157 -0
- package/dist/workflows-api.d.ts +13 -0
- package/dist/workflows-api.js +328 -0
- package/package.json +1 -1
- package/src/__tests__/crud-owner-rls.test.ts +6 -6
- package/src/__tests__/dist-exports.test.ts +6 -0
- 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/__tests__/workflow.test.ts +583 -0
- package/src/crud.ts +1 -1
- package/src/index.ts +6 -4
- package/src/reactive.ts +8 -0
- package/src/rls-db.ts +277 -10
- package/src/rls.ts +1 -1
- package/src/scheduler.ts +124 -46
- package/src/workflow-types.ts +111 -0
- package/src/workflow.ts +205 -0
- package/src/workflows-api.ts +425 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom **mutation** handlers (not `crud()` factory) that use `ctx.db` must respect RLS
|
|
3
|
+
* (update/delete/insert withCheck).
|
|
4
|
+
*
|
|
5
|
+
* Run: bun test packages/core/src/__tests__/rls-custom-mutation-handlers.test.ts
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
9
|
+
import { eq } from "drizzle-orm";
|
|
10
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
11
|
+
import { drizzle } from "drizzle-orm/pglite";
|
|
12
|
+
|
|
13
|
+
import type { GencowCtx } from "../reactive.js";
|
|
14
|
+
import { tasks } from "./fixtures/basic/schema.js";
|
|
15
|
+
import {
|
|
16
|
+
basicFixtureUsers,
|
|
17
|
+
basicUser0Identity,
|
|
18
|
+
basicUser1Identity,
|
|
19
|
+
createBasicRlsEnvironment,
|
|
20
|
+
} from "./helpers/basic-rls-fixture.js";
|
|
21
|
+
import { fillPartialRowsForInsert } from "./helpers/seed-like-fill.js";
|
|
22
|
+
import {
|
|
23
|
+
makeTestGencowCtxWithRls,
|
|
24
|
+
runWithRollbackTestGencowCtxWithRls,
|
|
25
|
+
} from "./helpers/test-gencow-ctx-rls.js";
|
|
26
|
+
|
|
27
|
+
/** User-defined mutation: update title by task id. */
|
|
28
|
+
async function customMutationUpdateTitle(
|
|
29
|
+
ctx: GencowCtx,
|
|
30
|
+
taskId: string,
|
|
31
|
+
title: string
|
|
32
|
+
) {
|
|
33
|
+
return ctx.db
|
|
34
|
+
.update(tasks)
|
|
35
|
+
.set({ title, updatedAt: new Date() })
|
|
36
|
+
.where(eq(tasks.id, taskId))
|
|
37
|
+
.returning();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** User-defined mutation: delete by id. */
|
|
41
|
+
async function customMutationDeleteById(ctx: GencowCtx, taskId: string) {
|
|
42
|
+
return ctx.db.delete(tasks).where(eq(tasks.id, taskId)).returning();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** User-defined mutation: insert a new task for the current user (caller supplies logical fields). */
|
|
46
|
+
async function customMutationInsertTask(
|
|
47
|
+
ctx: GencowCtx,
|
|
48
|
+
values: { id: string; title: string; userId: string; done?: boolean }
|
|
49
|
+
) {
|
|
50
|
+
const [row] = fillPartialRowsForInsert(tasks, [
|
|
51
|
+
{
|
|
52
|
+
id: values.id,
|
|
53
|
+
title: values.title,
|
|
54
|
+
userId: values.userId,
|
|
55
|
+
done: values.done ?? false,
|
|
56
|
+
},
|
|
57
|
+
]);
|
|
58
|
+
return ctx.db.insert(tasks).values(row).returning();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("Custom mutation handlers + ctx.db + RLS", () => {
|
|
62
|
+
let client: PGlite;
|
|
63
|
+
let db: ReturnType<typeof drizzle>;
|
|
64
|
+
|
|
65
|
+
beforeAll(async () => {
|
|
66
|
+
const env = await createBasicRlsEnvironment();
|
|
67
|
+
client = env.client;
|
|
68
|
+
db = env.db;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterAll(async () => {
|
|
72
|
+
try {
|
|
73
|
+
await client.close();
|
|
74
|
+
} catch {
|
|
75
|
+
/* ignore */
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("custom update: can change own task", async () => {
|
|
80
|
+
await runWithRollbackTestGencowCtxWithRls(
|
|
81
|
+
db,
|
|
82
|
+
basicUser0Identity,
|
|
83
|
+
async (ctx) => {
|
|
84
|
+
const updated = await customMutationUpdateTitle(
|
|
85
|
+
ctx,
|
|
86
|
+
"tk-003",
|
|
87
|
+
"Updated by custom handler"
|
|
88
|
+
);
|
|
89
|
+
expect(updated.length).toBe(1);
|
|
90
|
+
expect(updated[0]!.title).toBe("Updated by custom handler");
|
|
91
|
+
}
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("custom update: cannot modify another user's task (0 rows)", async () => {
|
|
96
|
+
await runWithRollbackTestGencowCtxWithRls(
|
|
97
|
+
db,
|
|
98
|
+
basicUser0Identity,
|
|
99
|
+
async (ctx) => {
|
|
100
|
+
const updated = await customMutationUpdateTitle(
|
|
101
|
+
ctx,
|
|
102
|
+
"tk-001",
|
|
103
|
+
"Should not apply"
|
|
104
|
+
);
|
|
105
|
+
expect(updated.length).toBe(0);
|
|
106
|
+
}
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("custom delete: can remove own task", async () => {
|
|
111
|
+
await runWithRollbackTestGencowCtxWithRls(
|
|
112
|
+
db,
|
|
113
|
+
basicUser0Identity,
|
|
114
|
+
async (ctx) => {
|
|
115
|
+
const removed = await customMutationDeleteById(ctx, "tk-000");
|
|
116
|
+
expect(removed.length).toBe(1);
|
|
117
|
+
}
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("custom delete: cannot remove another user's task (0 rows)", async () => {
|
|
122
|
+
await runWithRollbackTestGencowCtxWithRls(
|
|
123
|
+
db,
|
|
124
|
+
basicUser0Identity,
|
|
125
|
+
async (ctx) => {
|
|
126
|
+
const removed = await customMutationDeleteById(ctx, "tk-004");
|
|
127
|
+
expect(removed.length).toBe(0);
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("custom insert: succeeds when userId matches session", async () => {
|
|
133
|
+
await runWithRollbackTestGencowCtxWithRls(
|
|
134
|
+
db,
|
|
135
|
+
basicUser0Identity,
|
|
136
|
+
async (ctx) => {
|
|
137
|
+
const inserted = await customMutationInsertTask(ctx, {
|
|
138
|
+
id: "tk-custom-own",
|
|
139
|
+
title: "Custom insert OK",
|
|
140
|
+
userId: basicUser0Identity.id,
|
|
141
|
+
});
|
|
142
|
+
expect(inserted.length).toBe(1);
|
|
143
|
+
expect(inserted[0]!.userId).toBe(basicUser0Identity.id);
|
|
144
|
+
}
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("custom insert: forged userId (another user) violates RLS withCheck", async () => {
|
|
149
|
+
await runWithRollbackTestGencowCtxWithRls(
|
|
150
|
+
db,
|
|
151
|
+
basicUser0Identity,
|
|
152
|
+
async (ctx) => {
|
|
153
|
+
let thrown: unknown;
|
|
154
|
+
try {
|
|
155
|
+
await customMutationInsertTask(ctx, {
|
|
156
|
+
id: "tk-custom-forge",
|
|
157
|
+
title: "Forged owner",
|
|
158
|
+
userId: basicFixtureUsers[1].id,
|
|
159
|
+
});
|
|
160
|
+
} catch (e) {
|
|
161
|
+
thrown = e;
|
|
162
|
+
}
|
|
163
|
+
expect(thrown).toBeInstanceOf(Error);
|
|
164
|
+
}
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("nested db.transaction in custom handler still applies RLS on inner selects", async () => {
|
|
169
|
+
await runWithRollbackTestGencowCtxWithRls(
|
|
170
|
+
db,
|
|
171
|
+
basicUser0Identity,
|
|
172
|
+
async (ctx) => {
|
|
173
|
+
const inner = await ctx.db.transaction(async (tx: typeof ctx.db) => {
|
|
174
|
+
return tx
|
|
175
|
+
.select()
|
|
176
|
+
.from(tasks)
|
|
177
|
+
.where(eq(tasks.userId, basicFixtureUsers[1].id));
|
|
178
|
+
});
|
|
179
|
+
expect(inner.length).toBe(0);
|
|
180
|
+
}
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("direct ctx.db without outer rollback wrapper: mutation still RLS-scoped", async () => {
|
|
185
|
+
const ctx = makeTestGencowCtxWithRls(db, basicUser1Identity);
|
|
186
|
+
const updated = await customMutationUpdateTitle(ctx, "tk-000", "hax");
|
|
187
|
+
expect(updated.length).toBe(0);
|
|
188
|
+
});
|
|
189
|
+
});
|