@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.
- package/dist/middleware/security.d.ts.map +1 -1
- package/dist/middleware/security.js +3 -3
- package/dist/middleware/security.js.map +1 -1
- package/dist/models/agenda.d.ts +2 -2
- package/dist/models/announcement.d.ts +2 -2
- package/dist/models/assignment.d.ts +33 -33
- package/dist/models/class.d.ts +2 -2
- package/dist/models/cloudStorage.d.ts +14 -1
- package/dist/models/cloudStorage.d.ts.map +1 -1
- package/dist/models/cloudStorage.js +123 -3
- package/dist/models/cloudStorage.js.map +1 -1
- package/dist/models/event.d.ts +3 -3
- package/dist/models/file.d.ts +27 -26
- package/dist/models/file.d.ts.map +1 -1
- package/dist/models/file.js +4 -39
- package/dist/models/file.js.map +1 -1
- package/dist/models/labChat.d.ts +6 -6
- package/dist/models/section.d.ts +1 -1
- package/dist/routers/_app.d.ts +354 -100
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/_app.js +4 -2
- package/dist/routers/_app.js.map +1 -1
- package/dist/routers/agenda.d.ts +2 -2
- package/dist/routers/assignment.d.ts +31 -31
- package/dist/routers/class.d.ts +5 -5
- package/dist/routers/event.d.ts +2 -2
- package/dist/routers/file.d.ts +2 -2
- package/dist/routers/folder.d.ts +24 -0
- package/dist/routers/folder.d.ts.map +1 -1
- package/dist/routers/folder.js +27 -5
- package/dist/routers/folder.js.map +1 -1
- package/dist/routers/labChat.d.ts +2 -2
- package/dist/routers/newtonChat.d.ts +1 -1
- package/dist/routers/permissions.d.ts +120 -0
- package/dist/routers/permissions.d.ts.map +1 -0
- package/dist/routers/permissions.js +50 -0
- package/dist/routers/permissions.js.map +1 -0
- package/dist/routers/tag.d.ts +5 -5
- package/dist/services/agenda.d.ts +2 -2
- package/dist/services/assignment.d.ts +31 -31
- package/dist/services/class.d.ts +5 -5
- package/dist/services/class.js +3 -3
- package/dist/services/class.js.map +1 -1
- package/dist/services/event.d.ts +2 -2
- package/dist/services/file.d.ts +22 -5
- package/dist/services/file.d.ts.map +1 -1
- package/dist/services/file.js +81 -45
- package/dist/services/file.js.map +1 -1
- package/dist/services/folder.d.ts +97 -2
- package/dist/services/folder.d.ts.map +1 -1
- package/dist/services/folder.js +158 -87
- package/dist/services/folder.js.map +1 -1
- package/dist/services/labChat.d.ts +2 -2
- package/dist/services/permissions.d.ts +188 -0
- package/dist/services/permissions.d.ts.map +1 -0
- package/dist/services/permissions.js +661 -0
- package/dist/services/permissions.js.map +1 -0
- package/package.json +1 -1
- package/prisma/migrations/20260510170000_add_folder_permissions/migration.sql +93 -0
- package/prisma/schema.prisma +74 -0
- package/src/middleware/security.ts +1 -1
- package/src/models/cloudStorage.ts +134 -1
- package/src/models/file.ts +2 -37
- package/src/routers/_app.ts +2 -0
- package/src/routers/folder.ts +32 -2
- package/src/routers/permissions.ts +71 -0
- package/src/services/class.ts +1 -1
- package/src/services/file.ts +76 -47
- package/src/services/folder.ts +180 -91
- package/src/services/permissions.ts +778 -0
- 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
|
+
});
|