@studious-lms/server 1.8.0 → 1.11.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 +444 -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/studentProgress.d.ts +45 -0
- package/dist/routers/studentProgress.d.ts.map +1 -1
- package/dist/routers/studentProgress.js +12 -3
- package/dist/routers/studentProgress.js.map +1 -1
- 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/dist/services/studentProgress.d.ts +94 -0
- package/dist/services/studentProgress.d.ts.map +1 -1
- package/dist/services/studentProgress.js +94 -3
- package/dist/services/studentProgress.js.map +1 -1
- 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/routers/studentProgress.ts +21 -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/src/services/studentProgress.ts +197 -11
- package/tests/unit/services/permissions.test.ts +463 -0
- package/tests/unit/services/studentProgress.test.ts +168 -2
|
@@ -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
|
+
});
|
|
@@ -31,6 +31,9 @@ import {
|
|
|
31
31
|
chatAboutStudentProgress,
|
|
32
32
|
dismissStudentRecommendation,
|
|
33
33
|
getClassProgressRecommendations,
|
|
34
|
+
getStudentProgressFocus,
|
|
35
|
+
getStudentProgressForecast,
|
|
36
|
+
getStudentProgressInsights,
|
|
34
37
|
getStudentProgressRecommendations,
|
|
35
38
|
} from "../../../src/services/studentProgress.js";
|
|
36
39
|
|
|
@@ -221,7 +224,10 @@ describe("studentProgress service", () => {
|
|
|
221
224
|
],
|
|
222
225
|
});
|
|
223
226
|
|
|
224
|
-
const result = await getClassProgressRecommendations(
|
|
227
|
+
const result = await getClassProgressRecommendations(
|
|
228
|
+
"teacher-1",
|
|
229
|
+
"class-1",
|
|
230
|
+
);
|
|
225
231
|
|
|
226
232
|
expect(result.class).toMatchObject({
|
|
227
233
|
id: "class-1",
|
|
@@ -297,7 +303,9 @@ describe("studentProgress service", () => {
|
|
|
297
303
|
});
|
|
298
304
|
|
|
299
305
|
expect(result.isFallback).toBe(true);
|
|
300
|
-
expect(result.message).toContain(
|
|
306
|
+
expect(result.message).toContain(
|
|
307
|
+
"Student One's current overall grade is 60%.",
|
|
308
|
+
);
|
|
301
309
|
expect(result.message).toContain("1 assignment appears overdue.");
|
|
302
310
|
expect(result.message).toContain("1 graded assignment is below 70%.");
|
|
303
311
|
expect(logger.error).toHaveBeenCalled();
|
|
@@ -340,6 +348,164 @@ describe("studentProgress service", () => {
|
|
|
340
348
|
expect(result.recommendations).toHaveLength(0);
|
|
341
349
|
});
|
|
342
350
|
|
|
351
|
+
test("generates structured student insights from progress context", async () => {
|
|
352
|
+
isTeacherInClass.mockResolvedValue(true);
|
|
353
|
+
inference.mockResolvedValue({
|
|
354
|
+
summary:
|
|
355
|
+
"Student One is at 72% with a steady trajectory. Fractions Test is a strength, while Missing Quiz needs attention.",
|
|
356
|
+
strengths: [
|
|
357
|
+
{ label: "Fraction fluency", evidence: "Fractions Test scored 84%." },
|
|
358
|
+
{ label: "Quiz recovery", evidence: "Retake Review improved to 78%." },
|
|
359
|
+
],
|
|
360
|
+
growth: [
|
|
361
|
+
{ label: "Missing work", evidence: "Missing Quiz is overdue." },
|
|
362
|
+
{ label: "Test precision", evidence: "Unit Test scored 62%." },
|
|
363
|
+
],
|
|
364
|
+
talkingPoints: [
|
|
365
|
+
"What helped you on Fractions Test?",
|
|
366
|
+
"What blocked Missing Quiz?",
|
|
367
|
+
"Which review format would help this week?",
|
|
368
|
+
],
|
|
369
|
+
});
|
|
370
|
+
prismaMocks.class.findUnique.mockResolvedValue({
|
|
371
|
+
id: "class-1",
|
|
372
|
+
name: "Algebra",
|
|
373
|
+
subject: "Math",
|
|
374
|
+
});
|
|
375
|
+
prismaMocks.user.findFirst.mockResolvedValue({
|
|
376
|
+
id: "student-1",
|
|
377
|
+
username: "student1",
|
|
378
|
+
profile: { displayName: "Student One" },
|
|
379
|
+
});
|
|
380
|
+
prismaMocks.submission.findMany.mockResolvedValue([
|
|
381
|
+
createSubmission({
|
|
382
|
+
id: "graded-submission",
|
|
383
|
+
gradeReceived: 84,
|
|
384
|
+
submitted: true,
|
|
385
|
+
returned: true,
|
|
386
|
+
submittedAt: daysFromNow(-4),
|
|
387
|
+
assignment: {
|
|
388
|
+
id: "graded-assignment",
|
|
389
|
+
title: "Fractions Test",
|
|
390
|
+
dueDate: daysFromNow(-5),
|
|
391
|
+
type: "TEST",
|
|
392
|
+
},
|
|
393
|
+
}),
|
|
394
|
+
]);
|
|
395
|
+
|
|
396
|
+
const result = await getStudentProgressInsights(
|
|
397
|
+
"teacher-1",
|
|
398
|
+
"class-1",
|
|
399
|
+
"student-1",
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
expect(result.strengths[0]?.label).toBe("Fraction fluency");
|
|
403
|
+
expect(inference).toHaveBeenCalledWith(
|
|
404
|
+
expect.arrayContaining([
|
|
405
|
+
expect.objectContaining({ role: "system" }),
|
|
406
|
+
expect.objectContaining({
|
|
407
|
+
role: "user",
|
|
408
|
+
content: expect.stringContaining("Fractions Test"),
|
|
409
|
+
}),
|
|
410
|
+
]),
|
|
411
|
+
expect.any(Object),
|
|
412
|
+
expect.objectContaining({
|
|
413
|
+
reason: "student_progress_insights",
|
|
414
|
+
metadata: {
|
|
415
|
+
viewerId: "teacher-1",
|
|
416
|
+
classId: "class-1",
|
|
417
|
+
studentId: "student-1",
|
|
418
|
+
},
|
|
419
|
+
}),
|
|
420
|
+
);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("generates a structured student forecast", async () => {
|
|
424
|
+
isTeacherInClass.mockResolvedValue(false);
|
|
425
|
+
inference.mockResolvedValue({
|
|
426
|
+
forecastLow: 62,
|
|
427
|
+
forecastHigh: 74,
|
|
428
|
+
risk: "watch",
|
|
429
|
+
reason:
|
|
430
|
+
"Recent low score and one missing assignment create moderate risk.",
|
|
431
|
+
});
|
|
432
|
+
prismaMocks.class.findUnique.mockResolvedValue({
|
|
433
|
+
id: "class-1",
|
|
434
|
+
name: "Algebra",
|
|
435
|
+
subject: "Math",
|
|
436
|
+
});
|
|
437
|
+
prismaMocks.user.findFirst.mockResolvedValue({
|
|
438
|
+
id: "student-1",
|
|
439
|
+
username: "student1",
|
|
440
|
+
profile: { displayName: "Student One" },
|
|
441
|
+
});
|
|
442
|
+
prismaMocks.submission.findMany.mockResolvedValue([
|
|
443
|
+
createSubmission({
|
|
444
|
+
gradeReceived: 62,
|
|
445
|
+
submitted: true,
|
|
446
|
+
returned: true,
|
|
447
|
+
submittedAt: daysFromNow(-2),
|
|
448
|
+
assignment: { title: "Unit Test", dueDate: daysFromNow(-3) },
|
|
449
|
+
}),
|
|
450
|
+
]);
|
|
451
|
+
|
|
452
|
+
const result = await getStudentProgressForecast(
|
|
453
|
+
"student-1",
|
|
454
|
+
"class-1",
|
|
455
|
+
"student-1",
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
expect(result).toMatchObject({
|
|
459
|
+
forecastLow: 62,
|
|
460
|
+
forecastHigh: 74,
|
|
461
|
+
risk: "watch",
|
|
462
|
+
});
|
|
463
|
+
expect(inference).toHaveBeenCalledWith(
|
|
464
|
+
expect.any(Array),
|
|
465
|
+
expect.any(Object),
|
|
466
|
+
expect.objectContaining({ reason: "student_progress_forecast" }),
|
|
467
|
+
);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test("generates student-facing weekly focus items", async () => {
|
|
471
|
+
isTeacherInClass.mockResolvedValue(false);
|
|
472
|
+
inference.mockResolvedValue({
|
|
473
|
+
focusItems: [
|
|
474
|
+
"Finish Missing Quiz make-up.",
|
|
475
|
+
"Review Unit Test mistakes.",
|
|
476
|
+
"Ask for help on fractions.",
|
|
477
|
+
],
|
|
478
|
+
});
|
|
479
|
+
prismaMocks.class.findUnique.mockResolvedValue({
|
|
480
|
+
id: "class-1",
|
|
481
|
+
name: "Algebra",
|
|
482
|
+
subject: "Math",
|
|
483
|
+
});
|
|
484
|
+
prismaMocks.user.findFirst.mockResolvedValue({
|
|
485
|
+
id: "student-1",
|
|
486
|
+
username: "student1",
|
|
487
|
+
profile: { displayName: "Student One" },
|
|
488
|
+
});
|
|
489
|
+
prismaMocks.submission.findMany.mockResolvedValue([
|
|
490
|
+
createSubmission({
|
|
491
|
+
assignment: { title: "Missing Quiz", dueDate: daysFromNow(-2) },
|
|
492
|
+
}),
|
|
493
|
+
]);
|
|
494
|
+
|
|
495
|
+
const result = await getStudentProgressFocus(
|
|
496
|
+
"student-1",
|
|
497
|
+
"class-1",
|
|
498
|
+
"student-1",
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
expect(result.focusItems).toHaveLength(3);
|
|
502
|
+
expect(inference).toHaveBeenCalledWith(
|
|
503
|
+
expect.any(Array),
|
|
504
|
+
expect.any(Object),
|
|
505
|
+
expect.objectContaining({ reason: "student_progress_focus" }),
|
|
506
|
+
);
|
|
507
|
+
});
|
|
508
|
+
|
|
343
509
|
test("allows teachers to dismiss a recommendation", async () => {
|
|
344
510
|
isTeacherInClass.mockResolvedValue(true);
|
|
345
511
|
prismaMocks.submission.findFirst.mockResolvedValue({ id: "submission-1" });
|