From 5108f92df33922c45816f04aacb54ab82845a388 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Sun, 13 Sep 2026 16:32:00 +0000 Subject: [PATCH 1/4] chore: cleanup api endpoints --- src/controllers/documentRoots.ts | 52 ++++++-------------------------- src/controllers/documents.ts | 5 --- src/controllers/studentGroups.ts | 5 --- src/routes/router.ts | 29 ++---------------- 4 files changed, 12 insertions(+), 79 deletions(-) diff --git a/src/controllers/documentRoots.ts b/src/controllers/documentRoots.ts index 0e0b8ad..24e32af 100644 --- a/src/controllers/documentRoots.ts +++ b/src/controllers/documentRoots.ts @@ -6,21 +6,6 @@ import { HTTP400Error, HTTP403Error } from '../utils/errors/Errors.js'; import Document from '../models/Document.js'; import { NoneAccess, RO_RW_DocumentRootAccess } from '../helpers/accessPolicy.js'; import { hasElevatedAccess } from '../models/User.js'; -import { Access } from '../../prisma/generated/enums.js'; - -export const find: RequestHandler<{ id: string }> = async (req, res, next) => { - const document = await DocumentRoot.findModel((req as any).user!, req.params.id); - res.json(document); -}; - -export const findMany: RequestHandler = async (req, res, next) => { - const ids = Array.isArray(req.query.ids) ? req.query.ids : [req.query.ids]; - if (ids.length === 0 || !req.query.ids) { - return res.json([]); - } - const documents = await DocumentRoot.findManyModels((req as any).user!.id, ids); - res.json(documents); -}; export const findMultipleFor: RequestHandler< { id: string /** userId */ }, @@ -45,31 +30,25 @@ export const findMultipleFor: RequestHandler< res.json(documents); }; -export const allDocuments: RequestHandler = async (req, res, next) => { - if (!hasElevatedAccess((req as any).user!.role)) { - throw new HTTP403Error('Not Authorized'); - } - const ids = Array.isArray(req.query.rids) ? req.query.rids : [req.query.rids]; - if (ids.length === 0) { - return res.json([]); - } - const documents = await Document.allOfDocumentRoots((req as any).user!, ids); - res.json(documents); -}; - export const multipleDocuments: RequestHandler< any, any, { documentRootIds: string[]; userId?: string } > = async (req, res, next) => { const user = req.user; - if (!hasElevatedAccess(user.role)) { - throw new HTTP403Error('Not Authorized'); - } const ids = req.body.documentRootIds; if (ids.length === 0) { return res.json([]); } + if (!hasElevatedAccess(user.role)) { + if (req.body.userId && req.body.userId !== user.id) { + throw new HTTP403Error('Not authorized'); + } + const documents = await DocumentRoot.findManyModels(req.params.id, ids, { + ignoreMissingRoots: false + }); + return res.json(documents?.flatMap((dr) => dr.documents ?? []) ?? []); + } const documents = await Document.allOfDocumentRoots( { role: user.role, id: req.body.userId ?? user.id }, ids @@ -136,19 +115,6 @@ export const permissions: RequestHandler = async (req, res, next) => { - const permissions = await DocumentRoot.getPermissions((req as any).user!, [req.params.id]); - res.json( - permissions[0] ?? { - id: req.params.id, - access: Access.None_DocumentRoot, - sharedAccess: Access.None_DocumentRoot, - userPermissions: [], - groupPermissions: [] - } - ); -}; export const destroy: RequestHandler<{ id: string }> = async (req, res, next) => { const model = await DocumentRoot.deleteModel((req as any).user!, req.params.id); diff --git a/src/controllers/documents.ts b/src/controllers/documents.ts index 5abf044..6e20b2b 100644 --- a/src/controllers/documents.ts +++ b/src/controllers/documents.ts @@ -8,11 +8,6 @@ import prisma from '../prisma.js'; import { HTTP403Error, HTTP404Error } from '../utils/errors/Errors.js'; import type { JsonObject } from '@prisma/client/runtime/client'; -export const find: RequestHandler<{ id: string }> = async (req, res, next) => { - const document = await Document.findModel((req as any).user!, req.params.id); - res.json(document); -}; - export const create: RequestHandler< any, any, diff --git a/src/controllers/studentGroups.ts b/src/controllers/studentGroups.ts index 6d74037..537527e 100644 --- a/src/controllers/studentGroups.ts +++ b/src/controllers/studentGroups.ts @@ -4,11 +4,6 @@ import StudentGroup from '../models/StudentGroup.js'; import { IoEvent, RecordType } from '../routes/socketEventTypes.js'; import { JsonObject } from '@prisma/client/runtime/client'; -export const find: RequestHandler<{ id: string }> = async (req, res, next) => { - const group = await StudentGroup.findModel((req as any).user!, req.params.id); - res.json(group); -}; - export const create: RequestHandler = async (req, res, next) => { const { name, description, parentId } = req.body; const model = await StudentGroup.createModel((req as any).user!, name, description, parentId); diff --git a/src/routes/router.ts b/src/routes/router.ts index 2eb3724..713a8ee 100644 --- a/src/routes/router.ts +++ b/src/routes/router.ts @@ -4,7 +4,6 @@ import { all as allStudentGroups, create as createStudentGroup, destroy as deleteStudentGroup, - find as findStudentGroup, update as updateStudentGroup, addUser as addStudentGroupUser, removeUser as removeStudentGroupUser, @@ -23,19 +22,14 @@ import { import { create as createDocument, destroy as deleteDocument, - find as findDocument, update as updateDocument, linkTo as linkDocument } from '../controllers/documents.js'; import { create as createDocumentRoot, - find as findDocumentRoot, - findMany as findManyDocumentRoots, update as updateDocumentRoot, permissions as allPermissions, - singlePermissions as allPermissionsFor, findMultipleFor as findMultipleDocumentRootsFor, - allDocuments, destroy as deleteDocumentRoot, multipleDocuments } from '../controllers/documentRoots.js'; @@ -71,10 +65,6 @@ router.post('/users/:id/documentRoots', findMultipleDocumentRootsFor); router.get('/studentGroups', allStudentGroups); router.post('/studentGroups', createStudentGroup); -/** - * TODO: do we need id-based access? - */ -router.get('/studentGroups/:id', findStudentGroup); router.put('/studentGroups/:id', updateStudentGroup); router.delete('/studentGroups/:id', deleteStudentGroup); @@ -90,33 +80,19 @@ router.post('/permissions/group', createStudentGroupPermission); router.put('/permissions/group/:id', updateStudentGroupPermission); router.delete('/permissions/group/:id', deleteStudentGroupPermission); -router.get('/documentRoots', findManyDocumentRoots); -router.get('/documentRoots/:id', findDocumentRoot); -// TODO: remove this endpoint once the permissions [POST]/documentRoots/permissions endpoint is established and clients are updated -router.get('/documentRoots/:id/permissions', allPermissionsFor); -// order matters here! /documentRoots/:id would match /documentRoots/:id/permissions if it was placed before router.post('/documentRoots/permissions', allPermissions); router.post('/documentRoots/:id', createDocumentRoot); router.put('/documentRoots/:id', updateDocumentRoot); router.delete('/documentRoots/:id', deleteDocumentRoot); router.post('/documents', createDocument); -/** - * TODO: remove once /documents/multiple is established and clients are updated - * - * @adminOnly --> handle in controller - * Returns all documents which are linked to the **document roots**. - * @requires ?rids: string[] -> the document root ids - */ -router.get('/documents', allDocuments); + /** * a post endpoint to prevent issues with long query strings when requesting - * many document roots for a user - * @adminOnly --> handle in controller + * many document roots (for the current user, or when having elevated access, for any user) * Returns all documents which are linked to the **document roots**. */ router.post('/documents/multiple', multipleDocuments); -router.get('/documents/:id', findDocument); router.put('/documents/:id', updateDocument); router.put('/documents/:id/linkTo/:parentId', linkDocument); router.delete('/documents/:id', deleteDocument); @@ -132,4 +108,5 @@ router.get('/cms/settings', findCmsSettings); router.put('/cms/settings', updateCmsSettings); router.get('/cms/github-token', githubToken); router.post('/cms/logout', githubLogout); + export default router; From bbe0b90b854e9bf3feea2a4a0675f22cb8547ce0 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Sun, 13 Sep 2026 17:46:35 +0000 Subject: [PATCH 2/4] fix tests --- src/controllers/documents.ts | 5 +++++ src/routes/router.ts | 4 +++- src/tests/integration/documentRoots.test.ts | 9 ++++++--- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/controllers/documents.ts b/src/controllers/documents.ts index 6e20b2b..5abf044 100644 --- a/src/controllers/documents.ts +++ b/src/controllers/documents.ts @@ -8,6 +8,11 @@ import prisma from '../prisma.js'; import { HTTP403Error, HTTP404Error } from '../utils/errors/Errors.js'; import type { JsonObject } from '@prisma/client/runtime/client'; +export const find: RequestHandler<{ id: string }> = async (req, res, next) => { + const document = await Document.findModel((req as any).user!, req.params.id); + res.json(document); +}; + export const create: RequestHandler< any, any, diff --git a/src/routes/router.ts b/src/routes/router.ts index 713a8ee..e45b439 100644 --- a/src/routes/router.ts +++ b/src/routes/router.ts @@ -23,7 +23,8 @@ import { create as createDocument, destroy as deleteDocument, update as updateDocument, - linkTo as linkDocument + linkTo as linkDocument, + find as findDocument } from '../controllers/documents.js'; import { create as createDocumentRoot, @@ -93,6 +94,7 @@ router.post('/documents', createDocument); * Returns all documents which are linked to the **document roots**. */ router.post('/documents/multiple', multipleDocuments); +router.get('/documents/:id', findDocument); router.put('/documents/:id', updateDocument); router.put('/documents/:id/linkTo/:parentId', linkDocument); router.delete('/documents/:id', deleteDocument); diff --git a/src/tests/integration/documentRoots.test.ts b/src/tests/integration/documentRoots.test.ts index 480a0e6..28cf8a5 100644 --- a/src/tests/integration/documentRoots.test.ts +++ b/src/tests/integration/documentRoots.test.ts @@ -21,10 +21,13 @@ describe('DocumentRoots (integration)', () => { expect(createRes.body.id).toBe(documentRootId); expect(createRes.body.access).toBe(Access.RW_DocumentRoot); - const getRes = await agent.get(`${API_URL}/documentRoots/${documentRootId}`); + const getRes = await agent.post(`${API_URL}/users/${user.id}/documentRoots`).send({ + documentRootIds: [documentRootId] + }); expect(getRes.status).toBe(200); - expect(getRes.body.id).toBe(documentRootId); - expect(getRes.body.documents).toEqual([]); + expect(getRes.body.length).toBe(1); + expect(getRes.body[0].id).toBe(documentRootId); + expect(getRes.body[0].documents).toEqual([]); }); it('rejects unauthenticated requests', async () => { From d08de044fd1caadb877ffb464814712b3f7a00c3 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Sun, 13 Sep 2026 18:40:07 +0000 Subject: [PATCH 3/4] add integration tests for [post]/documents/multiple --- src/controllers/documentRoots.ts | 7 +- src/models/Document.ts | 20 +++- src/models/DocumentRoot.ts | 3 + src/routes/authConfig.ts | 7 +- src/tests/integration/documentRoots.test.ts | 21 +++- src/tests/integration/documents.test.ts | 121 +++++++++++++++++++- src/tests/integration/helpers.ts | 18 +++ 7 files changed, 179 insertions(+), 18 deletions(-) diff --git a/src/controllers/documentRoots.ts b/src/controllers/documentRoots.ts index 24e32af..457fa5e 100644 --- a/src/controllers/documentRoots.ts +++ b/src/controllers/documentRoots.ts @@ -44,15 +44,12 @@ export const multipleDocuments: RequestHandler< if (req.body.userId && req.body.userId !== user.id) { throw new HTTP403Error('Not authorized'); } - const documents = await DocumentRoot.findManyModels(req.params.id, ids, { + const documents = await DocumentRoot.findManyModels(user.id, ids, { ignoreMissingRoots: false }); return res.json(documents?.flatMap((dr) => dr.documents ?? []) ?? []); } - const documents = await Document.allOfDocumentRoots( - { role: user.role, id: req.body.userId ?? user.id }, - ids - ); + const documents = await Document.allOfDocumentRoots(user, ids, req.body.userId); res.json(documents); }; diff --git a/src/models/Document.ts b/src/models/Document.ts index c023bf1..8d8b030 100644 --- a/src/models/Document.ts +++ b/src/models/Document.ts @@ -265,20 +265,32 @@ function Document(db: PrismaClient['document']) { }, async allOfDocumentRoots( - actor: User | { role: Role | string; id: string }, - documentRootIds: string[] + actor: User, + documentRootIds: string[], + authorId?: string ): Promise { if (!hasElevatedAccess(actor.role)) { throw new HTTP403Error('Not authorized'); } if (actor.role === Role.ADMIN) { - return db.findMany({ where: { documentRootId: { in: documentRootIds } } }); + return db.findMany({ + where: { documentRootId: { in: documentRootIds }, authorId: authorId } + }); } // only include documents where the author is in the same group as the actor. const documents = await db.findMany({ where: { documentRootId: { in: documentRootIds }, - author: whereStudentGroupAccess(actor.id, true) + author: { + id: authorId, + studentGroups: { + some: { + studentGroup: { + users: { some: { userId: actor.id, isAdmin: true } } + } + } + } + } } }); return documents; diff --git a/src/models/DocumentRoot.ts b/src/models/DocumentRoot.ts index db965da..eccd7f1 100644 --- a/src/models/DocumentRoot.ts +++ b/src/models/DocumentRoot.ts @@ -105,6 +105,9 @@ function DocumentRoot(db: PrismaClient['documentRoot']) { documentType?: string; } = {} ): Promise { + if (!actorId) { + throw new HTTP403Error('Not authorized'); + } const documentRoots = (await prisma.view_UsersDocuments.findMany({ where: { id: { in: ids }, userId: actorId }, relationLoadStrategy: 'query' diff --git a/src/routes/authConfig.ts b/src/routes/authConfig.ts index b17b733..39ab854 100644 --- a/src/routes/authConfig.ts +++ b/src/routes/authConfig.ts @@ -42,7 +42,7 @@ const authConfig: Config = { }, documentsMultiple: { path: '/documents/multiple', - access: [{ methods: ['POST'], minRole: Role.TEACHER }] + access: [{ methods: ['POST'], minRole: Role.STUDENT }] }, documentRoots: { path: '/documentRoots', @@ -55,11 +55,6 @@ const authConfig: Config = { path: '/documentRoots/permissions', access: [{ methods: ['POST'], minRole: Role.TEACHER }] }, - // TODO: remove this endpoint once the permissions [POST]/documentRoots/permissions endpoint is established and clients are updated - documentRootPermissions: { - path: '/documentRoots/:id/permissions', - access: [{ methods: ['GET'], minRole: Role.TEACHER }] - }, githubToken: { path: '/cms', access: [{ methods: ['GET', 'PUT'], minRole: Role.STUDENT }] }, githubLogout: { path: '/cms/logout', access: [{ methods: ['POST'], minRole: Role.STUDENT }] } } diff --git a/src/tests/integration/documentRoots.test.ts b/src/tests/integration/documentRoots.test.ts index 28cf8a5..4fed583 100644 --- a/src/tests/integration/documentRoots.test.ts +++ b/src/tests/integration/documentRoots.test.ts @@ -31,11 +31,30 @@ describe('DocumentRoots (integration)', () => { }); it('rejects unauthenticated requests', async () => { + const user = await createTestUser(Role.STUDENT); const documentRootId = randomUUID(); - const res = await request(app).get(`${API_URL}/documentRoots/${documentRootId}`); + + const res = await request(app) + .post(`${API_URL}/users/${user.id}/documentRoots`) + .send({ + documentRootIds: [documentRootId] + }); expect(res.status).toBe(401); }); + it('rejects user to fetch others documents', async () => { + const user = await createTestUser(Role.STUDENT); + const otherUser = await createTestUser(Role.STUDENT); + const documentRootId = randomUUID(); + + const agent = agentAs(user.id); + + const res = await agent.post(`${API_URL}/users/${otherUser.id}/documentRoots`).send({ + documentRootIds: [documentRootId] + }); + expect(res.status).toBe(403); + }); + it('only allows an admin to delete a document root', async () => { const student = await createTestUser(Role.STUDENT); const admin = await createTestUser(Role.ADMIN); diff --git a/src/tests/integration/documents.test.ts b/src/tests/integration/documents.test.ts index cd256a6..0cb4d7f 100644 --- a/src/tests/integration/documents.test.ts +++ b/src/tests/integration/documents.test.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'crypto'; import { describe, expect, it } from 'vitest'; import { Access } from '../../../prisma/generated/enums.js'; import { Role } from '../../models/User.js'; -import { API_URL, agentAs, createTestUser } from './helpers.js'; +import { API_URL, agentAs, createTestStudentGroup, createTestUser } from './helpers.js'; describe('Documents (integration)', () => { it('creates, reads, updates and deletes a document', async () => { @@ -46,7 +46,7 @@ describe('Documents (integration)', () => { expect(getAfterDelete.body).toBeNull(); }); - it('does not allow a user without access to read another users document data', async () => { + it('[get]/documents/:id does not allow a user without access to read another users document data', async () => { const owner = await createTestUser(Role.STUDENT); const stranger = await createTestUser(Role.STUDENT); @@ -69,4 +69,121 @@ describe('Documents (integration)', () => { expect(strangerRes.status).toBe(200); expect(strangerRes.body).toBeNull(); }); + + it('[post]/documents/multiple', async () => { + const owner = await createTestUser(Role.STUDENT); + const admin = await createTestUser(Role.ADMIN); + const teacher1 = await createTestUser(Role.TEACHER); + const teacher2 = await createTestUser(Role.TEACHER); + const stranger = await createTestUser(Role.STUDENT); + await createTestStudentGroup('Test Group', [teacher1.id], [owner.id]); + const group = await agentAs(teacher1.id).get(`${API_URL}/studentGroups`); + expect(group.status).toBe(200); + expect(group.body.length).toBe(1); + expect(group.body[0].name).toBe('Test Group'); + expect(group.body[0].userIds).toEqual(expect.arrayContaining([owner.id, teacher1.id])); + expect(group.body[0].adminIds).toEqual(expect.arrayContaining([teacher1.id])); + + const documentRootId = randomUUID(); + await agentAs(owner.id) + .post(`${API_URL}/documentRoots/${documentRootId}`) + .send({ access: Access.RW_DocumentRoot, sharedAccess: Access.None_DocumentRoot }); + + const createRes = await agentAs(owner.id) + .post(`${API_URL}/documents`) + .send({ + type: 'test-type', + documentRootId, + data: { secret: true } + }); + const strangersDocRes = await agentAs(stranger.id) + .post(`${API_URL}/documents`) + .send({ + type: 'test-type', + documentRootId, + data: { secret: true } + }); + expect(createRes.status).toBe(200); + expect(strangersDocRes.status).toBe(200); + const documentId = createRes.body.id as string; + const strangersDocumentId = strangersDocRes.body.id as string; + + const ownerRes = await agentAs(owner.id) + .post(`${API_URL}/documents/multiple`) + .send({ + documentRootIds: [documentRootId], + userId: owner.id + }); + expect(ownerRes.status).toBe(200); + expect(ownerRes.body.length).toBe(1); + expect(ownerRes.body[0].id).toBe(documentId); + + const strangerRes = await agentAs(stranger.id) + .post(`${API_URL}/documents/multiple`) + .send({ + documentRootIds: [documentRootId], + userId: owner.id + }); + expect(strangerRes.status).toBe(403); + + // requesting docs for one user + + const adminRes = await agentAs(admin.id) + .post(`${API_URL}/documents/multiple`) + .send({ + documentRootIds: [documentRootId], + userId: owner.id + }); + expect(adminRes.status).toBe(200); + expect(adminRes.body.length).toBe(1); + expect(adminRes.body[0].id).toBe(documentId); + + const teacher1Res = await agentAs(teacher1.id) + .post(`${API_URL}/documents/multiple`) + .send({ + documentRootIds: [documentRootId], + userId: owner.id + }); + expect(teacher1Res.status).toBe(200); + expect(teacher1Res.body.length).toBe(1); + expect(teacher1Res.body[0].id).toBe(documentId); + + const teacher2Res = await agentAs(teacher2.id) + .post(`${API_URL}/documents/multiple`) + .send({ + documentRootIds: [documentRootId], + userId: owner.id + }); + expect(teacher2Res.status).toBe(200); + expect(teacher2Res.body.length).toBe(0); + + // requesting all docs of this document root + const adminRes2 = await agentAs(admin.id) + .post(`${API_URL}/documents/multiple`) + .send({ + documentRootIds: [documentRootId] + }); + expect(adminRes2.status).toBe(200); + expect(adminRes2.body.length).toBe(2); + expect(adminRes2.body.map((d: { id: string }) => d.id)).toEqual( + expect.arrayContaining([documentId, strangersDocumentId]) + ); + + const teacher1Res2 = await agentAs(teacher1.id) + .post(`${API_URL}/documents/multiple`) + .send({ + documentRootIds: [documentRootId] + }); + expect(teacher1Res2.status).toBe(200); + expect(teacher1Res2.body.length).toBe(1); + expect(teacher1Res2.body[0].id).toBe(documentId); + + const teacher2Res2 = await agentAs(teacher2.id) + .post(`${API_URL}/documents/multiple`) + .send({ + documentRootIds: [documentRootId] + }); + expect(teacher2Res2.status).toBe(200); + expect(teacher2Res2.body.length).toBe(0); + }); }); diff --git a/src/tests/integration/helpers.ts b/src/tests/integration/helpers.ts index eff461d..5258b03 100644 --- a/src/tests/integration/helpers.ts +++ b/src/tests/integration/helpers.ts @@ -34,6 +34,24 @@ export const createTestUser = async (role: Role = Role.STUDENT) => { }); }; +export const createTestStudentGroup = async (name: string, adminIds: string[], userIds: string[]) => { + const id = randomUUID(); + return prisma.studentGroup.create({ + data: { + id, + name, + users: { + createMany: { + data: [ + ...adminIds.map((userId) => ({ userId, isAdmin: true })), + ...userIds.map((userId) => ({ userId, isAdmin: false })) + ] + } + } + } + }); +}; + export const resetDatabase = async () => { await prisma.$executeRawUnsafe(` DO $reset$ From aef3b1a545a2a910b651e74fb4dcf36c24220e33 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Sun, 13 Sep 2026 20:39:51 +0000 Subject: [PATCH 4/4] remove unused checklogin path --- src/routes/authConfig.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/routes/authConfig.ts b/src/routes/authConfig.ts index 39ab854..b9ff220 100644 --- a/src/routes/authConfig.ts +++ b/src/routes/authConfig.ts @@ -13,7 +13,6 @@ interface Config { const authConfig: Config = { accessMatrix: { - checklogin: { path: '/checklogin', access: [{ methods: ['GET'], minRole: Role.STUDENT }] }, user: { path: '/user', access: [{ methods: ['GET', 'POST'], minRole: Role.STUDENT }] }, admin: { path: '/admin',