@gencow/core 0.1.18 → 0.1.21
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 +18 -0
- package/dist/crud.js +231 -50
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -2
- package/dist/rls-db.d.ts +3 -5
- package/dist/rls-db.js +3 -5
- package/dist/rls.d.ts +44 -1
- package/dist/rls.js +62 -2
- package/dist/server.d.ts +1 -0
- package/dist/storage.d.ts +29 -2
- package/dist/storage.js +404 -15
- package/dist/v.js +5 -1
- package/package.json +42 -39
- package/src/__tests__/crud-owner-rls.test.ts +380 -0
- package/src/__tests__/fixtures/basic/auth.ts +32 -0
- package/src/__tests__/fixtures/basic/drizzle.config.ts +15 -0
- package/src/__tests__/fixtures/basic/index.ts +6 -0
- package/src/__tests__/fixtures/basic/migrations/0000_faithful_silver_sable.sql +66 -0
- package/src/__tests__/fixtures/basic/migrations/meta/0000_snapshot.json +438 -0
- package/src/__tests__/fixtures/basic/migrations/meta/_journal.json +13 -0
- package/src/__tests__/fixtures/basic/schema.ts +35 -0
- package/src/__tests__/fixtures/basic/tasks.ts +15 -0
- package/src/__tests__/fixtures/common/auth-schema.ts +63 -0
- package/src/__tests__/helpers/pglite-migrations.ts +35 -0
- package/src/__tests__/helpers/pglite-rls-session.ts +54 -0
- package/src/__tests__/helpers/seed-like-fill.ts +196 -0
- package/src/__tests__/helpers/test-gencow-ctx-rls.ts +53 -0
- package/src/__tests__/image-optimization.test.ts +652 -0
- package/src/__tests__/rls-crud-basic.test.ts +431 -0
- package/src/__tests__/storage.test.ts +113 -0
- package/src/__tests__/tsconfig.json +8 -0
- package/src/__tests__/validator.test.ts +35 -0
- package/src/crud.ts +270 -47
- package/src/index.ts +3 -2
- package/src/rls-db.ts +3 -5
- package/src/rls.ts +87 -3
- package/src/server.ts +1 -0
- package/src/storage.ts +481 -15
- package/src/v.ts +5 -1
- package/dist/scoped-db.d.ts +0 -34
- package/dist/scoped-db.js +0 -364
- package/dist/table.d.ts +0 -67
- package/dist/table.js +0 -98
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fixtures/basic + PGlite — apply migrations, explicit fixtures, verify tasks.list CRUD handler.
|
|
3
|
+
*
|
|
4
|
+
* Users and tasks: fixed `id` / FK / `done` per row. Any other column omitted from the fixture is
|
|
5
|
+
* filled by drizzle-seed’s same per-type generators as `seed()` (see `fillPartialRowsForInsert`).
|
|
6
|
+
*
|
|
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` + crud’s use of
|
|
9
|
+
* `db.transaction` sets `app.current_user_id` per operation.
|
|
10
|
+
* We rely on `current_setting('app.current_user_id', true)` (missing_ok=true): this avoids
|
|
11
|
+
* missing-GUC errors before `set_config` and keeps PGlite behavior aligned with PostgreSQL.
|
|
12
|
+
*
|
|
13
|
+
* Run: bun test packages/core/src/__tests__/rls-crud-basic.test.ts
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
describe,
|
|
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";
|
|
26
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
27
|
+
import { drizzle } from "drizzle-orm/pglite";
|
|
28
|
+
import { crud } from "../crud";
|
|
29
|
+
import type { UserIdentity } from "../reactive";
|
|
30
|
+
import { tasks, user } from "./fixtures/basic/schema";
|
|
31
|
+
import {
|
|
32
|
+
createPgliteRlsAppRole,
|
|
33
|
+
DEFAULT_PGLITE_RLS_APP_ROLE,
|
|
34
|
+
setPgliteSessionRole,
|
|
35
|
+
} from "./helpers/pglite-rls-session";
|
|
36
|
+
import { loadAndApplyMigrations } from "./helpers/pglite-migrations";
|
|
37
|
+
import { fillPartialRowsForInsert } from "./helpers/seed-like-fill";
|
|
38
|
+
import {
|
|
39
|
+
makeTestGencowCtxWithRls,
|
|
40
|
+
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;
|
|
105
|
+
|
|
106
|
+
type TaskRow = InferSelectModel<typeof tasks>;
|
|
107
|
+
type CrudDefs = ReturnType<typeof crud<typeof tasks>>;
|
|
108
|
+
type TaskListResult = { data: TaskRow[]; total: number };
|
|
109
|
+
|
|
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
|
+
async function createSeededCrudEnv() {
|
|
121
|
+
const client = new PGlite();
|
|
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);
|
|
133
|
+
|
|
134
|
+
const defs = crud(tasks, {
|
|
135
|
+
prefix: "fixture_basic_pglite_tasks",
|
|
136
|
+
searchFields: ["title", "description"],
|
|
137
|
+
defaultLimit: 50,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return { client, db, taskRows, defs };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
describe("fixtures/basic + PGlite + CRUD + RLS", () => {
|
|
144
|
+
let client: PGlite;
|
|
145
|
+
let db: ReturnType<typeof drizzle>;
|
|
146
|
+
let seededTaskRows: TaskRow[];
|
|
147
|
+
let listDef: CrudDefs["list"];
|
|
148
|
+
let listHandler: (ctx: unknown, args: unknown) => Promise<TaskListResult>;
|
|
149
|
+
let getHandler: NonNullable<CrudDefs["get"]>["handler"];
|
|
150
|
+
let createHandler: NonNullable<CrudDefs["create"]>["handler"];
|
|
151
|
+
let updateHandler: NonNullable<CrudDefs["update"]>["handler"];
|
|
152
|
+
let removeHandler: NonNullable<CrudDefs["remove"]>["handler"];
|
|
153
|
+
|
|
154
|
+
beforeAll(async () => {
|
|
155
|
+
const env = await createSeededCrudEnv();
|
|
156
|
+
client = env.client;
|
|
157
|
+
db = env.db;
|
|
158
|
+
seededTaskRows = env.taskRows;
|
|
159
|
+
listDef = env.defs.list;
|
|
160
|
+
listHandler = env.defs.list!.handler as (
|
|
161
|
+
ctx: unknown,
|
|
162
|
+
args: unknown
|
|
163
|
+
) => Promise<TaskListResult>;
|
|
164
|
+
getHandler = env.defs.get!.handler as (
|
|
165
|
+
ctx: unknown,
|
|
166
|
+
args: unknown
|
|
167
|
+
) => Promise<TaskRow | null>;
|
|
168
|
+
createHandler = env.defs.create!.handler as (
|
|
169
|
+
ctx: unknown,
|
|
170
|
+
args: unknown
|
|
171
|
+
) => Promise<TaskRow>;
|
|
172
|
+
updateHandler = env.defs.update!.handler as (
|
|
173
|
+
ctx: unknown,
|
|
174
|
+
args: unknown
|
|
175
|
+
) => Promise<TaskRow | undefined>;
|
|
176
|
+
removeHandler = env.defs.remove!.handler as (
|
|
177
|
+
ctx: unknown,
|
|
178
|
+
args: unknown
|
|
179
|
+
) => Promise<{ success: boolean }>;
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
afterAll(async () => {
|
|
183
|
+
try {
|
|
184
|
+
await client.close();
|
|
185
|
+
} catch {
|
|
186
|
+
/* ignore */
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("tasks.list returns only the current user (us_000) rows per RLS and total matches", async () => {
|
|
191
|
+
expect(listDef).toBeDefined();
|
|
192
|
+
|
|
193
|
+
await runWithRollbackTestGencowCtxWithRls(db, user0Identity, async (ctx) => {
|
|
194
|
+
const listResult = await listHandler(ctx, {});
|
|
195
|
+
|
|
196
|
+
const expected = seededTaskRows.filter(
|
|
197
|
+
(r) => r.userId === fixtureUsers[0].id
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
expect(listResult).toHaveProperty("data");
|
|
201
|
+
expect(listResult).toHaveProperty("total");
|
|
202
|
+
expect(listResult.total).toBe(expected.length);
|
|
203
|
+
|
|
204
|
+
const rows = listResult.data;
|
|
205
|
+
|
|
206
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
207
|
+
expect(byId.size).toBe(expected.length);
|
|
208
|
+
|
|
209
|
+
for (const seeded of expected) {
|
|
210
|
+
const row = byId.get(seeded.id);
|
|
211
|
+
expect(row).toBeDefined();
|
|
212
|
+
expect(row!.id).toBe(seeded.id);
|
|
213
|
+
expect(row!.userId).toBe(seeded.userId);
|
|
214
|
+
expect(row!.done).toBe(seeded.done);
|
|
215
|
+
expect(row!.title).toBe(seeded.title);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("search parameter applies to title/description ilike search", async () => {
|
|
221
|
+
expect(listDef).toBeDefined();
|
|
222
|
+
|
|
223
|
+
await runWithRollbackTestGencowCtxWithRls(db, user0Identity, async (ctx) => {
|
|
224
|
+
const sharedSubstring = "Project Alpha";
|
|
225
|
+
const expectedForUser0 = seededTaskRows.filter(
|
|
226
|
+
(r) =>
|
|
227
|
+
r.userId === user0Identity.id &&
|
|
228
|
+
r.title.toLowerCase().includes(sharedSubstring.toLowerCase())
|
|
229
|
+
);
|
|
230
|
+
expect(expectedForUser0.length).toBe(2);
|
|
231
|
+
|
|
232
|
+
const result = await listHandler(ctx, { search: sharedSubstring });
|
|
233
|
+
const rows = result.data;
|
|
234
|
+
expect(rows.length).toBe(expectedForUser0.length);
|
|
235
|
+
|
|
236
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
237
|
+
for (const seeded of expectedForUser0) {
|
|
238
|
+
expect(byId.get(seeded.id)?.title).toBe(seeded.title);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("tasks.get succeeds when current user reads own task", async () => {
|
|
244
|
+
const ownTaskId = "tk-003";
|
|
245
|
+
|
|
246
|
+
await runWithRollbackTestGencowCtxWithRls(db, user0Identity, async (ctx) => {
|
|
247
|
+
const task = await getHandler(ctx, { id: ownTaskId });
|
|
248
|
+
|
|
249
|
+
expect(task).toBeDefined();
|
|
250
|
+
expect(task?.id).toBe(ownTaskId);
|
|
251
|
+
expect(task?.userId).toBe(user0Identity.id);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("tasks.get fails when current user tries to read another user's task", async () => {
|
|
256
|
+
const user1TaskId = "tk-001";
|
|
257
|
+
|
|
258
|
+
await runWithRollbackTestGencowCtxWithRls(db, user0Identity, async (ctx) => {
|
|
259
|
+
const task = await getHandler(ctx, { id: user1TaskId });
|
|
260
|
+
expect(task).toBeNull();
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("tasks.update succeeds when updating a task owned by current user", async () => {
|
|
265
|
+
const ownTaskId = "tk-000";
|
|
266
|
+
const updatedTitle = "Project Alpha — updated by owner";
|
|
267
|
+
const updatedDone = true;
|
|
268
|
+
|
|
269
|
+
await runWithRollbackTestGencowCtxWithRls(
|
|
270
|
+
db,
|
|
271
|
+
user0Identity,
|
|
272
|
+
async (ctx) => {
|
|
273
|
+
const before = await getHandler(ctx, {
|
|
274
|
+
id: ownTaskId,
|
|
275
|
+
});
|
|
276
|
+
expect(before?.title).not.toBe(updatedTitle);
|
|
277
|
+
expect(before?.done).not.toBe(updatedDone);
|
|
278
|
+
|
|
279
|
+
const updated = await updateHandler(ctx, {
|
|
280
|
+
id: ownTaskId,
|
|
281
|
+
title: updatedTitle,
|
|
282
|
+
done: updatedDone,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
expect(updated).toBeDefined();
|
|
286
|
+
expect(updated?.id).toBe(ownTaskId);
|
|
287
|
+
expect(updated?.userId).toBe(user0Identity.id);
|
|
288
|
+
expect(updated?.title).toBe(updatedTitle);
|
|
289
|
+
expect(updated?.done).toBe(updatedDone);
|
|
290
|
+
|
|
291
|
+
const fetched = await getHandler(ctx, {
|
|
292
|
+
id: ownTaskId,
|
|
293
|
+
});
|
|
294
|
+
expect(fetched?.id).toBe(ownTaskId);
|
|
295
|
+
expect(fetched?.title).toBe(updatedTitle);
|
|
296
|
+
expect(fetched?.done).toBe(updatedDone);
|
|
297
|
+
}
|
|
298
|
+
);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("tasks.create succeeds when creating a task with current userId", async () => {
|
|
302
|
+
const ownTaskId = "tk-own-create";
|
|
303
|
+
const ownTaskTitle = "Project Delta — created by owner";
|
|
304
|
+
|
|
305
|
+
await runWithRollbackTestGencowCtxWithRls(
|
|
306
|
+
db,
|
|
307
|
+
user0Identity,
|
|
308
|
+
async (user0Ctx) => {
|
|
309
|
+
const created = await createHandler(user0Ctx, {
|
|
310
|
+
id: ownTaskId,
|
|
311
|
+
userId: user0Identity.id,
|
|
312
|
+
title: ownTaskTitle,
|
|
313
|
+
done: false,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
expect(created).toBeDefined();
|
|
317
|
+
expect(created.id).toBe(ownTaskId);
|
|
318
|
+
expect(created.userId).toBe(user0Identity.id);
|
|
319
|
+
expect(created.title).toBe(ownTaskTitle);
|
|
320
|
+
expect(created.done).toBe(false);
|
|
321
|
+
|
|
322
|
+
const fetched = await getHandler(user0Ctx, {
|
|
323
|
+
id: ownTaskId,
|
|
324
|
+
});
|
|
325
|
+
expect(fetched).toBeDefined();
|
|
326
|
+
expect(fetched?.id).toBe(ownTaskId);
|
|
327
|
+
expect(fetched?.userId).toBe(user0Identity.id);
|
|
328
|
+
}
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("tasks.create fails when current user tries to create with another userId", async () => {
|
|
333
|
+
const unauthorizedTaskId = "tk-other-user-create";
|
|
334
|
+
await runWithRollbackTestGencowCtxWithRls(
|
|
335
|
+
db,
|
|
336
|
+
user0Identity,
|
|
337
|
+
async (user0Ctx) => {
|
|
338
|
+
await expect(
|
|
339
|
+
createHandler(user0Ctx, {
|
|
340
|
+
id: unauthorizedTaskId,
|
|
341
|
+
userId: user1Identity.id,
|
|
342
|
+
title: "Unauthorized create attempt",
|
|
343
|
+
done: false,
|
|
344
|
+
})
|
|
345
|
+
).rejects.toThrow();
|
|
346
|
+
|
|
347
|
+
const user1Ctx = makeTestGencowCtxWithRls(user0Ctx.unsafeDb as any, user1Identity);
|
|
348
|
+
const after = await getHandler(user1Ctx, { id: unauthorizedTaskId });
|
|
349
|
+
expect(after).toBeNull();
|
|
350
|
+
}
|
|
351
|
+
);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("tasks.update fails when current user tries to update user1 task", async () => {
|
|
355
|
+
const user1TaskId = "tk-001";
|
|
356
|
+
await runWithRollbackTestGencowCtxWithRls(
|
|
357
|
+
db,
|
|
358
|
+
user0Identity,
|
|
359
|
+
async (user0Ctx) => {
|
|
360
|
+
const user1Ctx = makeTestGencowCtxWithRls(user0Ctx.unsafeDb as any, user1Identity);
|
|
361
|
+
|
|
362
|
+
const before = await getHandler(user1Ctx, {
|
|
363
|
+
id: user1TaskId,
|
|
364
|
+
});
|
|
365
|
+
expect(before).toBeDefined();
|
|
366
|
+
const beforeTitle = before?.title;
|
|
367
|
+
const beforeDone = before?.done ?? false;
|
|
368
|
+
|
|
369
|
+
const unauthorizedUpdate = await updateHandler(user0Ctx, {
|
|
370
|
+
id: user1TaskId,
|
|
371
|
+
title: "Unauthorized update attempt",
|
|
372
|
+
done: !beforeDone,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
expect(unauthorizedUpdate).toBeUndefined();
|
|
376
|
+
|
|
377
|
+
const after = await getHandler(user1Ctx, {
|
|
378
|
+
id: user1TaskId,
|
|
379
|
+
});
|
|
380
|
+
expect(after).toBeDefined();
|
|
381
|
+
expect(after?.title).toBe(beforeTitle);
|
|
382
|
+
expect(after?.done).toBe(beforeDone);
|
|
383
|
+
}
|
|
384
|
+
);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("tasks.remove succeeds when deleting a task owned by current user", async () => {
|
|
388
|
+
const ownTaskId = "tk-002";
|
|
389
|
+
await runWithRollbackTestGencowCtxWithRls(
|
|
390
|
+
db,
|
|
391
|
+
user0Identity,
|
|
392
|
+
async (user0Ctx) => {
|
|
393
|
+
const before = await getHandler(user0Ctx, { id: ownTaskId });
|
|
394
|
+
expect(before).toBeDefined();
|
|
395
|
+
|
|
396
|
+
const removeResult = await removeHandler(user0Ctx, {
|
|
397
|
+
id: ownTaskId,
|
|
398
|
+
});
|
|
399
|
+
expect(removeResult.success).toBe(true);
|
|
400
|
+
|
|
401
|
+
const after = await getHandler(user0Ctx, { id: ownTaskId });
|
|
402
|
+
expect(after).toBeNull();
|
|
403
|
+
}
|
|
404
|
+
);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("tasks.remove cannot delete a task owned by another user", async () => {
|
|
408
|
+
const user1TaskId = "tk-004";
|
|
409
|
+
await runWithRollbackTestGencowCtxWithRls(
|
|
410
|
+
db,
|
|
411
|
+
user0Identity,
|
|
412
|
+
async (user0Ctx) => {
|
|
413
|
+
const user1Ctx = makeTestGencowCtxWithRls(user0Ctx.unsafeDb as any, user1Identity);
|
|
414
|
+
|
|
415
|
+
const before = await getHandler(user1Ctx, { id: user1TaskId });
|
|
416
|
+
expect(before).toBeDefined();
|
|
417
|
+
|
|
418
|
+
const removeResult = await removeHandler(user0Ctx, {
|
|
419
|
+
id: user1TaskId,
|
|
420
|
+
});
|
|
421
|
+
expect(removeResult.success).toBe(true);
|
|
422
|
+
|
|
423
|
+
const after = await getHandler(user1Ctx, {
|
|
424
|
+
id: user1TaskId,
|
|
425
|
+
});
|
|
426
|
+
expect(after).toBeDefined();
|
|
427
|
+
expect(after?.id).toBe(user1TaskId);
|
|
428
|
+
}
|
|
429
|
+
);
|
|
430
|
+
});
|
|
431
|
+
});
|
|
@@ -206,3 +206,116 @@ describe("createStorage()", () => {
|
|
|
206
206
|
});
|
|
207
207
|
});
|
|
208
208
|
});
|
|
209
|
+
|
|
210
|
+
// ─── _system_files 테이블 리네이밍 관련 테스트 ────────────
|
|
211
|
+
// 2026-04-10 WSOD 사고 후 추가: files → _system_files 리네이밍 검증
|
|
212
|
+
// 📄 참고: docs/analysis/analysis-files-page-wsod.md
|
|
213
|
+
|
|
214
|
+
describe("_system_files 시스템 테이블 네이밍", () => {
|
|
215
|
+
it("rawSql로 실행되는 모든 SQL에 old 'files' 테이블 참조가 없다", async () => {
|
|
216
|
+
const executedSql: string[] = [];
|
|
217
|
+
const mockRawSql = async (sql: string) => {
|
|
218
|
+
executedSql.push(sql);
|
|
219
|
+
if (sql.includes("SUM")) return [{ total: "0" }];
|
|
220
|
+
if (sql.includes("INSERT")) return [];
|
|
221
|
+
return [];
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gencow-naming-"));
|
|
225
|
+
const storage = createStorage(tmpDir, { rawSql: mockRawSql });
|
|
226
|
+
|
|
227
|
+
// store 트리거 (ensureFilesTable + checkQuota + recordFileToDb)
|
|
228
|
+
const file = new File(["test"], "test.txt", { type: "text/plain" });
|
|
229
|
+
try { await storage.store(file); } catch {}
|
|
230
|
+
|
|
231
|
+
// 모든 실행된 SQL에서 old 테이블명 'files'가 아닌 '_system_files'만 참조하는지 확인
|
|
232
|
+
// (files 단독 참조를 찾되, _system_files는 통과)
|
|
233
|
+
for (const sql of executedSql) {
|
|
234
|
+
// "FROM files", "INTO files", "TABLE files" 같은 old 패턴이 없어야 함
|
|
235
|
+
expect(sql).not.toMatch(/\bFROM\s+files\b(?!_)/i);
|
|
236
|
+
expect(sql).not.toMatch(/\bINTO\s+files\b(?!_)/i);
|
|
237
|
+
expect(sql).not.toMatch(/\bTABLE\s+files\b(?!_)/i);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("쿼터 검증 SQL이 _system_files 테이블을 참조한다", async () => {
|
|
244
|
+
const executedSql: string[] = [];
|
|
245
|
+
const mockRawSql = async (sql: string) => {
|
|
246
|
+
executedSql.push(sql);
|
|
247
|
+
if (sql.includes("SUM")) return [{ total: "0" }];
|
|
248
|
+
if (sql.includes("INSERT")) return [];
|
|
249
|
+
return [];
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gencow-quota-"));
|
|
253
|
+
const storage = createStorage(tmpDir, {
|
|
254
|
+
rawSql: mockRawSql,
|
|
255
|
+
storageQuota: 1000000000,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const file = new File(["small"], "small.txt");
|
|
259
|
+
try { await storage.store(file); } catch {}
|
|
260
|
+
|
|
261
|
+
// SUM 쿼리가 _system_files 테이블을 참조하는지 확인
|
|
262
|
+
const sumSql = executedSql.find(s => s.includes("SUM"));
|
|
263
|
+
if (sumSql) {
|
|
264
|
+
expect(sumSql).toContain("_system_files");
|
|
265
|
+
expect(sumSql).not.toMatch(/FROM\s+files[^_]/);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("recordFileToDb SQL이 _system_files 테이블에 INSERT한다", async () => {
|
|
272
|
+
const executedSql: string[] = [];
|
|
273
|
+
const mockRawSql = async (sql: string) => {
|
|
274
|
+
executedSql.push(sql);
|
|
275
|
+
if (sql.includes("SUM")) return [{ total: "0" }];
|
|
276
|
+
return [];
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gencow-insert-"));
|
|
280
|
+
const storage = createStorage(tmpDir, { rawSql: mockRawSql });
|
|
281
|
+
|
|
282
|
+
const file = new File(["data"], "data.txt", { type: "text/plain" });
|
|
283
|
+
try { await storage.store(file); } catch {}
|
|
284
|
+
|
|
285
|
+
const insertSql = executedSql.find(s => s.includes("INSERT"));
|
|
286
|
+
if (insertSql) {
|
|
287
|
+
expect(insertSql).toContain("_system_files");
|
|
288
|
+
expect(insertSql).not.toMatch(/INTO\s+files[^_]/);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("delete SQL이 _system_files 테이블에서 삭제한다", async () => {
|
|
295
|
+
const executedSql: string[] = [];
|
|
296
|
+
const mockRawSql = async (sql: string) => {
|
|
297
|
+
executedSql.push(sql);
|
|
298
|
+
if (sql.includes("SUM")) return [{ total: "0" }];
|
|
299
|
+
return [];
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gencow-delete-"));
|
|
303
|
+
const storage = createStorage(tmpDir, { rawSql: mockRawSql });
|
|
304
|
+
|
|
305
|
+
// store 후 delete
|
|
306
|
+
const file = new File(["delete-me"], "del.txt");
|
|
307
|
+
let storageId: string | undefined;
|
|
308
|
+
try { storageId = await storage.store(file); } catch {}
|
|
309
|
+
if (storageId) {
|
|
310
|
+
try { await storage.delete(storageId); } catch {}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const deleteSql = executedSql.find(s => s.includes("DELETE"));
|
|
314
|
+
if (deleteSql) {
|
|
315
|
+
expect(deleteSql).toContain("_system_files");
|
|
316
|
+
expect(deleteSql).not.toMatch(/FROM\s+files[^_]/);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
320
|
+
});
|
|
321
|
+
});
|
|
@@ -281,4 +281,39 @@ describe("parseArgs()", () => {
|
|
|
281
281
|
expect(err.statusCode).toBe(400);
|
|
282
282
|
expect(err.name).toBe("GencowValidationError");
|
|
283
283
|
});
|
|
284
|
+
|
|
285
|
+
// ─── 빈 스키마 passthrough (FormData 업로드 버그 회귀 방지) ────
|
|
286
|
+
|
|
287
|
+
it("빈 스키마 {} → args 전체 passthrough (FormData file 필드 보존)", () => {
|
|
288
|
+
const schema = {};
|
|
289
|
+
const args = { file: new File(["hello"], "test.txt"), _mutation: "upload.store" };
|
|
290
|
+
const result = parseArgs(schema, args);
|
|
291
|
+
// 빈 스키마이므로 args가 그대로 반환되어야 함 (file 포함)
|
|
292
|
+
expect(result).toBe(args); // 참조 동일
|
|
293
|
+
expect(result.file).toBeInstanceOf(File);
|
|
294
|
+
expect(result._mutation).toBe("upload.store");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("빈 스키마 {} + 일반 객체 → passthrough", () => {
|
|
298
|
+
const schema = {};
|
|
299
|
+
const args = { name: "test", count: 42, nested: { a: 1 } };
|
|
300
|
+
const result = parseArgs(schema, args);
|
|
301
|
+
expect(result).toBe(args);
|
|
302
|
+
expect(result.name).toBe("test");
|
|
303
|
+
expect(result.count).toBe(42);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("빈 스키마 {} + 빈 args {} → 빈 객체 반환", () => {
|
|
307
|
+
const result = parseArgs({}, {});
|
|
308
|
+
expect(result).toEqual({});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("키가 있는 스키마는 여전히 지정된 키만 추출 (file 제거됨)", () => {
|
|
312
|
+
const schema = { title: v.string() };
|
|
313
|
+
const args = { title: "hello", file: "should-be-stripped", extra: 123 };
|
|
314
|
+
const result = parseArgs(schema, args);
|
|
315
|
+
expect(result).toEqual({ title: "hello" });
|
|
316
|
+
expect(result.file).toBeUndefined();
|
|
317
|
+
expect(result.extra).toBeUndefined();
|
|
318
|
+
});
|
|
284
319
|
});
|