@studious-lms/server 1.8.0 → 1.9.0

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.
Files changed (71) hide show
  1. package/dist/middleware/security.d.ts.map +1 -1
  2. package/dist/middleware/security.js +3 -3
  3. package/dist/middleware/security.js.map +1 -1
  4. package/dist/models/agenda.d.ts +2 -2
  5. package/dist/models/announcement.d.ts +2 -2
  6. package/dist/models/assignment.d.ts +33 -33
  7. package/dist/models/class.d.ts +2 -2
  8. package/dist/models/cloudStorage.d.ts +14 -1
  9. package/dist/models/cloudStorage.d.ts.map +1 -1
  10. package/dist/models/cloudStorage.js +123 -3
  11. package/dist/models/cloudStorage.js.map +1 -1
  12. package/dist/models/event.d.ts +3 -3
  13. package/dist/models/file.d.ts +27 -26
  14. package/dist/models/file.d.ts.map +1 -1
  15. package/dist/models/file.js +4 -39
  16. package/dist/models/file.js.map +1 -1
  17. package/dist/models/labChat.d.ts +6 -6
  18. package/dist/models/section.d.ts +1 -1
  19. package/dist/routers/_app.d.ts +354 -100
  20. package/dist/routers/_app.d.ts.map +1 -1
  21. package/dist/routers/_app.js +4 -2
  22. package/dist/routers/_app.js.map +1 -1
  23. package/dist/routers/agenda.d.ts +2 -2
  24. package/dist/routers/assignment.d.ts +31 -31
  25. package/dist/routers/class.d.ts +5 -5
  26. package/dist/routers/event.d.ts +2 -2
  27. package/dist/routers/file.d.ts +2 -2
  28. package/dist/routers/folder.d.ts +24 -0
  29. package/dist/routers/folder.d.ts.map +1 -1
  30. package/dist/routers/folder.js +27 -5
  31. package/dist/routers/folder.js.map +1 -1
  32. package/dist/routers/labChat.d.ts +2 -2
  33. package/dist/routers/newtonChat.d.ts +1 -1
  34. package/dist/routers/permissions.d.ts +120 -0
  35. package/dist/routers/permissions.d.ts.map +1 -0
  36. package/dist/routers/permissions.js +50 -0
  37. package/dist/routers/permissions.js.map +1 -0
  38. package/dist/routers/tag.d.ts +5 -5
  39. package/dist/services/agenda.d.ts +2 -2
  40. package/dist/services/assignment.d.ts +31 -31
  41. package/dist/services/class.d.ts +5 -5
  42. package/dist/services/class.js +3 -3
  43. package/dist/services/class.js.map +1 -1
  44. package/dist/services/event.d.ts +2 -2
  45. package/dist/services/file.d.ts +22 -5
  46. package/dist/services/file.d.ts.map +1 -1
  47. package/dist/services/file.js +81 -45
  48. package/dist/services/file.js.map +1 -1
  49. package/dist/services/folder.d.ts +97 -2
  50. package/dist/services/folder.d.ts.map +1 -1
  51. package/dist/services/folder.js +158 -87
  52. package/dist/services/folder.js.map +1 -1
  53. package/dist/services/labChat.d.ts +2 -2
  54. package/dist/services/permissions.d.ts +188 -0
  55. package/dist/services/permissions.d.ts.map +1 -0
  56. package/dist/services/permissions.js +661 -0
  57. package/dist/services/permissions.js.map +1 -0
  58. package/package.json +1 -1
  59. package/prisma/migrations/20260510170000_add_folder_permissions/migration.sql +93 -0
  60. package/prisma/schema.prisma +74 -0
  61. package/src/middleware/security.ts +1 -1
  62. package/src/models/cloudStorage.ts +134 -1
  63. package/src/models/file.ts +2 -37
  64. package/src/routers/_app.ts +2 -0
  65. package/src/routers/folder.ts +32 -2
  66. package/src/routers/permissions.ts +71 -0
  67. package/src/services/class.ts +1 -1
  68. package/src/services/file.ts +76 -47
  69. package/src/services/folder.ts +180 -91
  70. package/src/services/permissions.ts +778 -0
  71. package/tests/unit/services/permissions.test.ts +463 -0
@@ -0,0 +1,463 @@
1
+ import { beforeEach, describe, expect, test, vi } from "vitest";
2
+ import { TRPCError } from "@trpc/server";
3
+
4
+ const { prismaMocks, resolveFolderScope } = vi.hoisted(() => ({
5
+ prismaMocks: {
6
+ user: { findUnique: vi.fn() },
7
+ class: { findMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn() },
8
+ department: { findMany: vi.fn(), findUnique: vi.fn() },
9
+ schoolMembership: { findMany: vi.fn(), findFirst: vi.fn() },
10
+ folder: { findUnique: vi.fn() },
11
+ file: { findUnique: vi.fn() },
12
+ tag: { findUnique: vi.fn() },
13
+ permission: {
14
+ findFirst: vi.fn(),
15
+ findMany: vi.fn(),
16
+ findUnique: vi.fn(),
17
+ create: vi.fn(),
18
+ update: vi.fn(),
19
+ delete: vi.fn(),
20
+ },
21
+ },
22
+ resolveFolderScope: vi.fn(),
23
+ }));
24
+
25
+ vi.mock("../../../src/lib/prisma.js", () => ({
26
+ prisma: prismaMocks,
27
+ }));
28
+
29
+ vi.mock("../../../src/models/cloudStorage.js", () => ({
30
+ resolveFolderScope,
31
+ }));
32
+
33
+ import {
34
+ expandUserPrincipals,
35
+ getScopeDefault,
36
+ grantPermission,
37
+ listSharedWithMe,
38
+ requireFileAccess,
39
+ requireFolderAccess,
40
+ } from "../../../src/services/permissions.js";
41
+
42
+ function setNoMemberships() {
43
+ prismaMocks.user.findUnique.mockResolvedValue({ tags: [] });
44
+ prismaMocks.class.findMany.mockResolvedValue([]);
45
+ prismaMocks.department.findMany.mockResolvedValue([]);
46
+ prismaMocks.schoolMembership.findMany.mockResolvedValue([]);
47
+ }
48
+
49
+ beforeEach(() => {
50
+ vi.clearAllMocks();
51
+ prismaMocks.permission.findFirst.mockResolvedValue(null);
52
+ prismaMocks.permission.findMany.mockResolvedValue([]);
53
+ });
54
+
55
+ describe("expandUserPrincipals", () => {
56
+ test("collects tags, classes (split by role), departments, and active school memberships", async () => {
57
+ prismaMocks.user.findUnique.mockResolvedValue({
58
+ tags: [{ id: "tag-a" }, { id: "tag-b" }],
59
+ });
60
+ prismaMocks.class.findMany
61
+ .mockResolvedValueOnce([{ id: "class-teach" }])
62
+ .mockResolvedValueOnce([
63
+ { id: "class-stud-1" },
64
+ { id: "class-stud-2" },
65
+ ]);
66
+ prismaMocks.department.findMany.mockResolvedValue([{ id: "dep-1" }]);
67
+ prismaMocks.schoolMembership.findMany.mockResolvedValue([
68
+ { schoolId: "school-1" },
69
+ ]);
70
+
71
+ const principals = await expandUserPrincipals("user-1");
72
+
73
+ expect(principals.tagIds).toEqual(["tag-a", "tag-b"]);
74
+ expect(principals.classTeacherIds).toEqual(["class-teach"]);
75
+ expect(principals.classStudentIds).toEqual(["class-stud-1", "class-stud-2"]);
76
+ expect(principals.classIds.sort()).toEqual(
77
+ ["class-teach", "class-stud-1", "class-stud-2"].sort()
78
+ );
79
+ expect(principals.departmentIds).toEqual(["dep-1"]);
80
+ expect(principals.schoolIds).toEqual(["school-1"]);
81
+ });
82
+
83
+ test("returns empty arrays when the user has no memberships", async () => {
84
+ setNoMemberships();
85
+ const principals = await expandUserPrincipals("user-1");
86
+ expect(principals.tagIds).toEqual([]);
87
+ expect(principals.classIds).toEqual([]);
88
+ expect(principals.departmentIds).toEqual([]);
89
+ expect(principals.schoolIds).toEqual([]);
90
+ });
91
+ });
92
+
93
+ describe("getScopeDefault", () => {
94
+ test("class teacher gets WRITE, student gets READ, outsider gets null", async () => {
95
+ prismaMocks.class.findFirst
96
+ .mockResolvedValueOnce({
97
+ id: "class-1",
98
+ schoolId: null,
99
+ teachers: [{ id: "user-1" }],
100
+ students: [],
101
+ })
102
+ .mockResolvedValueOnce({
103
+ id: "class-1",
104
+ schoolId: null,
105
+ teachers: [],
106
+ students: [{ id: "user-2" }],
107
+ })
108
+ .mockResolvedValueOnce({
109
+ id: "class-1",
110
+ schoolId: null,
111
+ teachers: [],
112
+ students: [],
113
+ });
114
+
115
+ expect(
116
+ await getScopeDefault("user-1", { kind: "CLASS", classId: "class-1" })
117
+ ).toBe("WRITE");
118
+ expect(
119
+ await getScopeDefault("user-2", { kind: "CLASS", classId: "class-1" })
120
+ ).toBe("READ");
121
+ expect(
122
+ await getScopeDefault("user-3", { kind: "CLASS", classId: "class-1" })
123
+ ).toBeNull();
124
+ });
125
+
126
+ test("school admin gets WRITE on class even without being teacher/student", async () => {
127
+ prismaMocks.class.findFirst.mockResolvedValueOnce({
128
+ id: "class-1",
129
+ schoolId: "school-1",
130
+ teachers: [],
131
+ students: [],
132
+ });
133
+ prismaMocks.schoolMembership.findFirst.mockResolvedValueOnce({
134
+ id: "membership-1",
135
+ });
136
+
137
+ expect(
138
+ await getScopeDefault("admin-1", { kind: "CLASS", classId: "class-1" })
139
+ ).toBe("WRITE");
140
+ });
141
+
142
+ test("school storage: ADMIN/TEACHER write, STUDENT reads, non-member denied", async () => {
143
+ prismaMocks.schoolMembership.findFirst
144
+ .mockResolvedValueOnce({ role: "TEACHER" })
145
+ .mockResolvedValueOnce({ role: "STUDENT" })
146
+ .mockResolvedValueOnce(null);
147
+
148
+ expect(
149
+ await getScopeDefault("u", {
150
+ kind: "SCHOOL_STORAGE",
151
+ schoolId: "school-1",
152
+ })
153
+ ).toBe("WRITE");
154
+ expect(
155
+ await getScopeDefault("u", {
156
+ kind: "SCHOOL_STORAGE",
157
+ schoolId: "school-1",
158
+ })
159
+ ).toBe("READ");
160
+ expect(
161
+ await getScopeDefault("u", {
162
+ kind: "SCHOOL_STORAGE",
163
+ schoolId: "school-1",
164
+ })
165
+ ).toBeNull();
166
+ });
167
+
168
+ test("personal scope only the owner has WRITE", async () => {
169
+ expect(
170
+ await getScopeDefault("user-1", { kind: "PERSONAL", userId: "user-1" })
171
+ ).toBe("WRITE");
172
+ expect(
173
+ await getScopeDefault("user-2", { kind: "PERSONAL", userId: "user-1" })
174
+ ).toBeNull();
175
+ });
176
+ });
177
+
178
+ describe("requireFolderAccess", () => {
179
+ beforeEach(() => {
180
+ setNoMemberships();
181
+ });
182
+
183
+ test("scope default grants access without consulting permissions", async () => {
184
+ resolveFolderScope.mockResolvedValue({
185
+ kind: "PERSONAL",
186
+ userId: "user-1",
187
+ });
188
+
189
+ const result = await requireFolderAccess("user-1", "folder-1", "READ");
190
+ expect(result.scopeDefault).toBe("WRITE");
191
+ expect(result.viaGrant).toBe(false);
192
+ expect(prismaMocks.permission.findFirst).not.toHaveBeenCalled();
193
+ });
194
+
195
+ test("ancestor inheritance: grant on parent folder lets a non-member access a child", async () => {
196
+ // user is in NO scope (PERSONAL of someone else => null default)
197
+ resolveFolderScope.mockResolvedValue({
198
+ kind: "PERSONAL",
199
+ userId: "owner-x",
200
+ });
201
+ // child folder has parent
202
+ prismaMocks.folder.findUnique
203
+ .mockResolvedValueOnce({ parentFolderId: "folder-parent" })
204
+ .mockResolvedValueOnce({ parentFolderId: null });
205
+ prismaMocks.permission.findFirst.mockResolvedValueOnce({
206
+ folderId: "folder-parent",
207
+ });
208
+
209
+ const result = await requireFolderAccess("user-1", "folder-child", "READ");
210
+ expect(result.viaGrant).toBe(true);
211
+ expect(result.scopeDefault).toBeNull();
212
+ });
213
+
214
+ test("direct grant on the folder itself grants access", async () => {
215
+ resolveFolderScope.mockResolvedValue({
216
+ kind: "PERSONAL",
217
+ userId: "owner-x",
218
+ });
219
+ prismaMocks.folder.findUnique.mockResolvedValueOnce({
220
+ parentFolderId: null,
221
+ });
222
+ prismaMocks.permission.findFirst.mockResolvedValueOnce({
223
+ folderId: "folder-1",
224
+ });
225
+
226
+ const result = await requireFolderAccess("user-1", "folder-1", "READ");
227
+ expect(result.viaGrant).toBe(true);
228
+ });
229
+
230
+ test("denies when neither scope default nor any ancestor grant matches", async () => {
231
+ resolveFolderScope.mockResolvedValue({
232
+ kind: "PERSONAL",
233
+ userId: "owner-x",
234
+ });
235
+ prismaMocks.folder.findUnique.mockResolvedValueOnce({
236
+ parentFolderId: null,
237
+ });
238
+ prismaMocks.permission.findFirst.mockResolvedValueOnce(null);
239
+
240
+ await expect(
241
+ requireFolderAccess("user-1", "folder-1", "READ")
242
+ ).rejects.toThrow(TRPCError);
243
+ });
244
+
245
+ test("WRITE denied when only a READ grant exists", async () => {
246
+ resolveFolderScope.mockResolvedValue({
247
+ kind: "PERSONAL",
248
+ userId: "owner-x",
249
+ });
250
+ prismaMocks.folder.findUnique.mockResolvedValueOnce({
251
+ parentFolderId: null,
252
+ });
253
+ // resolver query is filtered with `level: "WRITE"` so a READ grant
254
+ // simply doesn't match — returns null.
255
+ prismaMocks.permission.findFirst.mockResolvedValueOnce(null);
256
+
257
+ await expect(
258
+ requireFolderAccess("user-1", "folder-1", "WRITE")
259
+ ).rejects.toThrow(TRPCError);
260
+ });
261
+
262
+ test("Drive-style: mutatesSelf WRITE is denied via grant, allowed via scope default", async () => {
263
+ // Case A: grantee has WRITE via explicit grant, but the action targets
264
+ // the share root itself — must be rejected with the Drive-style message.
265
+ resolveFolderScope.mockResolvedValueOnce({
266
+ kind: "PERSONAL",
267
+ userId: "owner-x", // user-1 is NOT the owner -> no scope default
268
+ });
269
+
270
+ await expect(
271
+ requireFolderAccess("user-1", "folder-1", "WRITE", { mutatesSelf: true })
272
+ ).rejects.toThrow(/owner/i);
273
+ // We never even got to the grant lookup because mutatesSelf short-circuits.
274
+ expect(prismaMocks.permission.findFirst).not.toHaveBeenCalled();
275
+
276
+ // Case B: the actual scope owner can mutatesSelf without issue.
277
+ resolveFolderScope.mockResolvedValueOnce({
278
+ kind: "PERSONAL",
279
+ userId: "user-1",
280
+ });
281
+ const result = await requireFolderAccess(
282
+ "user-1",
283
+ "folder-1",
284
+ "WRITE",
285
+ { mutatesSelf: true }
286
+ );
287
+ expect(result.scopeDefault).toBe("WRITE");
288
+ expect(result.viaGrant).toBe(false);
289
+ });
290
+ });
291
+
292
+ describe("requireFileAccess", () => {
293
+ beforeEach(() => {
294
+ setNoMemberships();
295
+ });
296
+
297
+ test("delegates to folder access when the file lives in a folder", async () => {
298
+ prismaMocks.file.findUnique.mockResolvedValueOnce({
299
+ id: "file-1",
300
+ userId: null,
301
+ folderId: "folder-x",
302
+ });
303
+ resolveFolderScope.mockResolvedValue({
304
+ kind: "PERSONAL",
305
+ userId: "user-1",
306
+ });
307
+
308
+ const result = await requireFileAccess("user-1", "file-1", "READ");
309
+ expect(result.scopeDefault).toBe("WRITE");
310
+ });
311
+
312
+ test("unfiled files fall back to owner-only access", async () => {
313
+ prismaMocks.file.findUnique.mockResolvedValueOnce({
314
+ id: "file-1",
315
+ userId: "user-1",
316
+ folderId: null,
317
+ });
318
+
319
+ const result = await requireFileAccess("user-1", "file-1", "READ");
320
+ expect(result.scopeDefault).toBe("WRITE");
321
+ expect(result.viaGrant).toBe(false);
322
+ });
323
+
324
+ test("unfiled files with another owner deny access", async () => {
325
+ prismaMocks.file.findUnique.mockResolvedValueOnce({
326
+ id: "file-1",
327
+ userId: "owner-x",
328
+ folderId: null,
329
+ });
330
+
331
+ await expect(
332
+ requireFileAccess("user-1", "file-1", "READ")
333
+ ).rejects.toThrow(TRPCError);
334
+ });
335
+ });
336
+
337
+ describe("listSharedWithMe (topmost-grant computation)", () => {
338
+ beforeEach(() => {
339
+ setNoMemberships();
340
+ });
341
+
342
+ test("filters out grants whose ancestor is also granted (collapse to the topmost)", async () => {
343
+ prismaMocks.permission.findMany.mockResolvedValueOnce([
344
+ { id: "g-parent", folderId: "parent" },
345
+ { id: "g-child", folderId: "child" },
346
+ ]);
347
+
348
+ // Ancestor walks: child -> parent; parent -> root (no parent).
349
+ prismaMocks.folder.findUnique
350
+ // Walk for "parent" (ancestors)
351
+ .mockResolvedValueOnce({ parentFolderId: null })
352
+ // Folder details fetch for "parent"
353
+ .mockResolvedValueOnce({
354
+ id: "parent",
355
+ name: "Parent",
356
+ color: null,
357
+ })
358
+ // Walk for "child" (ancestors) — first hit child, then parent
359
+ .mockResolvedValueOnce({ parentFolderId: "parent" })
360
+ .mockResolvedValueOnce({ parentFolderId: null });
361
+
362
+ resolveFolderScope.mockResolvedValue(null);
363
+
364
+ const result = await listSharedWithMe("user-1");
365
+ expect(result.folders.map((f) => f.id)).toEqual(["parent"]);
366
+ expect(result.files).toEqual([]);
367
+ });
368
+
369
+ test("filters out grants already covered by the user's scope default", async () => {
370
+ prismaMocks.permission.findMany.mockResolvedValueOnce([
371
+ { id: "g-1", folderId: "folder-1" },
372
+ ]);
373
+ prismaMocks.folder.findUnique.mockResolvedValueOnce({
374
+ parentFolderId: null,
375
+ });
376
+ resolveFolderScope.mockResolvedValue({
377
+ kind: "PERSONAL",
378
+ userId: "user-1",
379
+ });
380
+ // user-1 IS the owner -> scope default WRITE -> filtered out
381
+
382
+ const result = await listSharedWithMe("user-1");
383
+ expect(result.folders).toEqual([]);
384
+ });
385
+ });
386
+
387
+ describe("grantPermission cross-school leak prevention", () => {
388
+ beforeEach(() => {
389
+ setNoMemberships();
390
+ });
391
+
392
+ test("rejects a TAG principal whose school differs from the target's scope school", async () => {
393
+ // Granter has WRITE via scope default (class teacher in school-A).
394
+ resolveFolderScope.mockResolvedValue({
395
+ kind: "CLASS",
396
+ classId: "class-A",
397
+ });
398
+ prismaMocks.class.findFirst.mockResolvedValueOnce({
399
+ id: "class-A",
400
+ schoolId: "school-A",
401
+ teachers: [{ id: "granter-1" }],
402
+ students: [],
403
+ });
404
+ // resolveTargetSchoolId(folder) -> walks scope -> school-A
405
+ prismaMocks.class.findUnique
406
+ .mockResolvedValueOnce({ schoolId: "school-A" })
407
+ // principalSchoolId(TAG: tag-X) -> school-B
408
+ .mockResolvedValue(null);
409
+ prismaMocks.tag.findUnique.mockResolvedValueOnce({ schoolId: "school-B" });
410
+
411
+ await expect(
412
+ grantPermission("granter-1", {
413
+ folderId: "folder-1",
414
+ principal: { kind: "TAG", tagId: "tag-X" },
415
+ level: "READ",
416
+ })
417
+ ).rejects.toThrow(/different school/);
418
+ });
419
+
420
+ test("rejects sharing a personal-scope folder with a non-USER principal", async () => {
421
+ resolveFolderScope.mockResolvedValue({
422
+ kind: "PERSONAL",
423
+ userId: "granter-1",
424
+ });
425
+ // resolveTargetSchoolId(folder) -> personal scope -> null
426
+ prismaMocks.tag.findUnique.mockResolvedValueOnce({ schoolId: "school-A" });
427
+
428
+ await expect(
429
+ grantPermission("granter-1", {
430
+ folderId: "folder-1",
431
+ principal: { kind: "TAG", tagId: "tag-X" },
432
+ level: "READ",
433
+ })
434
+ ).rejects.toThrow(/specific users/i);
435
+ });
436
+
437
+ test("re-granting the same (folder, principal) updates the existing row instead of erroring", async () => {
438
+ // Granter has WRITE via personal-scope ownership.
439
+ resolveFolderScope.mockResolvedValue({
440
+ kind: "PERSONAL",
441
+ userId: "granter-1",
442
+ });
443
+ // Existing READ grant for user-X on this folder.
444
+ prismaMocks.permission.findFirst.mockResolvedValueOnce({ id: "perm-1" });
445
+ prismaMocks.permission.update.mockResolvedValueOnce({
446
+ id: "perm-1",
447
+ level: "WRITE",
448
+ });
449
+
450
+ const result = await grantPermission("granter-1", {
451
+ folderId: "folder-1",
452
+ principal: { kind: "USER", userId: "user-X" },
453
+ level: "WRITE",
454
+ });
455
+
456
+ expect(prismaMocks.permission.update).toHaveBeenCalledWith({
457
+ where: { id: "perm-1" },
458
+ data: { level: "WRITE", grantedById: "granter-1" },
459
+ });
460
+ expect(prismaMocks.permission.create).not.toHaveBeenCalled();
461
+ expect(result.level).toBe("WRITE");
462
+ });
463
+ });