diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt index aaace40..ddee81b 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt @@ -61,96 +61,154 @@ class LabController( return labService.detail(id) } + /** Копирование: нужен доступ и к исходной лабораторной, и к целевому курсу. */ @PostMapping("/{id}/copy") - @PreAuthorize("hasAuthority('ROLE_ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") fun copy( @PathVariable id: UUID, @Valid @RequestBody body: LabCopyRequest, - ): LabDetail = labService.copy(id, body) + @AuthenticationPrincipal jwt: Jwt, + ): LabDetail { + accessService.requireLabAccess(id, jwt) + accessService.requireCourseAccess(body.courseId, jwt) + return labService.copy(id, body) + } @PostMapping("/import") - @PreAuthorize("hasAuthority('ROLE_ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") fun importLab( @RequestParam("file") file: MultipartFile, @RequestParam(required = false) courseId: UUID?, + @AuthenticationPrincipal jwt: Jwt, ): LabImportResult { if (file.isEmpty) throw BadRequestException("Выберите файл") - return labService.importFromFile(file.originalFilename ?: "lab.zip", file.bytes, courseId) + val targetCourseId = requireWritableCourse(courseId, jwt) + return labService.importFromFile(file.originalFilename ?: "lab.zip", file.bytes, targetCourseId) } @DeleteMapping("/{id}") - @PreAuthorize("hasAuthority('ROLE_ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") fun delete( @PathVariable id: UUID, + @AuthenticationPrincipal jwt: Jwt, ) { + accessService.requireLabAccess(id, jwt) labService.delete(id) } @PutMapping("/{id}") - @PreAuthorize("hasAuthority('ROLE_ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") fun update( @PathVariable id: UUID, @Valid @RequestBody body: LabUpdateRequest, - ): LabDetail = labService.update(id, body) + @AuthenticationPrincipal jwt: Jwt, + ): LabDetail { + accessService.requireLabAccess(id, jwt) + return labService.update(id, body) + } @PostMapping("/{id}/variants") - @PreAuthorize("hasAuthority('ROLE_ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") fun addVariant( @PathVariable id: UUID, @Valid @RequestBody body: VariantSaveRequest, - ): LabDetail = labService.addVariant(id, body) + @AuthenticationPrincipal jwt: Jwt, + ): LabDetail { + accessService.requireLabAccess(id, jwt) + return labService.addVariant(id, body) + } @PutMapping("/{id}/variants/{variantId}") - @PreAuthorize("hasAuthority('ROLE_ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") fun updateVariant( @PathVariable id: UUID, @PathVariable variantId: UUID, @Valid @RequestBody body: VariantSaveRequest, - ): LabDetail = labService.updateVariant(id, variantId, body) + @AuthenticationPrincipal jwt: Jwt, + ): LabDetail { + accessService.requireLabAccess(id, jwt) + return labService.updateVariant(id, variantId, body) + } @DeleteMapping("/{id}/variants/{variantId}") - @PreAuthorize("hasAuthority('ROLE_ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") fun deleteVariant( @PathVariable id: UUID, @PathVariable variantId: UUID, - ): LabDetail = labService.deleteVariant(id, variantId) + @AuthenticationPrincipal jwt: Jwt, + ): LabDetail { + accessService.requireLabAccess(id, jwt) + return labService.deleteVariant(id, variantId) + } @PostMapping("/{id}/toggle-status") - @PreAuthorize("hasAuthority('ROLE_ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") fun toggleStatus( @PathVariable id: UUID, + @AuthenticationPrincipal jwt: Jwt, ) { + accessService.requireLabAccess(id, jwt) labService.toggleStatus(id) } @PostMapping - @PreAuthorize("hasAuthority('ROLE_ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") fun create( @Valid @RequestBody body: LabCreateRequest, - ): LabDetail = labService.create(body) + @AuthenticationPrincipal jwt: Jwt, + ): LabDetail { + requireWritableCourse(body.courseId, jwt) + return labService.create(body) + } @PostMapping("/{id}/levels") - @PreAuthorize("hasAuthority('ROLE_ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") fun addLevel( @PathVariable id: UUID, @Valid @RequestBody body: LevelSaveRequest, + @AuthenticationPrincipal jwt: Jwt, ): LabDetail { + accessService.requireLabAccess(id, jwt) labService.addLevel(id, body) return labService.detail(id) } @PutMapping("/{id}/levels/{levelId}") - @PreAuthorize("hasAuthority('ROLE_ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") fun renameLevel( @PathVariable id: UUID, @PathVariable levelId: UUID, @Valid @RequestBody body: LevelRenameRequest, - ): LabDetail = labService.renameLevel(id, levelId, body) + @AuthenticationPrincipal jwt: Jwt, + ): LabDetail { + accessService.requireLabAccess(id, jwt) + return labService.renameLevel(id, levelId, body) + } @DeleteMapping("/{id}/levels/{levelId}") - @PreAuthorize("hasAuthority('ROLE_ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") fun deleteLevel( @PathVariable id: UUID, @PathVariable levelId: UUID, - ): LabDetail = labService.deleteLevel(id, levelId) + @AuthenticationPrincipal jwt: Jwt, + ): LabDetail { + accessService.requireLabAccess(id, jwt) + return labService.deleteLevel(id, levelId) + } + + /** + * Для записи не-администратор обязан явно указать курс: legacy-выбор единственного курса + * остаётся только для администратора. Возвращает проверенный курс (или null для admin). + */ + private fun requireWritableCourse( + courseId: UUID?, + jwt: Jwt, + ): UUID? { + if (courseId != null) { + accessService.requireCourseAccess(courseId, jwt) + return courseId + } + if (!accessService.isAdmin(jwt)) throw BadRequestException("Укажите курс") + return null + } } diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseAccessService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseAccessService.kt index 8883959..e71ff75 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseAccessService.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseAccessService.kt @@ -82,6 +82,8 @@ class CourseAccessService( return courseService.list().filter { it.id in allowedIds } } + fun isAdmin(jwt: Jwt): Boolean = role(jwt) == ROLE_ADMIN + private fun activeCourseIds(courseIds: Collection): Set { if (courseIds.isEmpty()) return emptySet() return courses.findAllById(courseIds).filter { it.status == Course.STATUS_ACTIVE }.mapTo(HashSet()) { it.id } diff --git a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/lab/LabTeacherAccessIntegrationTest.kt b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/lab/LabTeacherAccessIntegrationTest.kt new file mode 100644 index 0000000..51d4197 --- /dev/null +++ b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/lab/LabTeacherAccessIntegrationTest.kt @@ -0,0 +1,260 @@ +package ru.ysu.lms.academic.lab + +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.testcontainers.service.connection.ServiceConnection +import org.springframework.http.MediaType +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt +import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.context.WebApplicationContext +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import org.testcontainers.postgresql.PostgreSQLContainer +import ru.ysu.lms.academic.course.CourseCreateRequest +import ru.ysu.lms.academic.course.CourseService +import ru.ysu.lms.academic.course.CourseTeacherRequest +import java.util.UUID + +/** + * Запись в лабораторные: преподаватель управляет только лабораторными своих курсов, + * студент не может писать вовсе, администратор не ограничен. + */ +@Testcontainers +@SpringBootTest +@Transactional +class LabTeacherAccessIntegrationTest { + companion object { + @Container + @ServiceConnection + @JvmStatic + val postgres = PostgreSQLContainer("postgres:18") + } + + @Autowired + private lateinit var context: WebApplicationContext + + @Autowired + private lateinit var courseService: CourseService + + @Autowired + private lateinit var labService: LabService + + private val mockMvc: MockMvc by lazy { + MockMvcBuilders + .webAppContextSetup(context) + .apply(springSecurity()) + .build() + } + + @Test + fun `teacher can update lab of own course`() { + val courseId = newCourse() + val teacherId = UUID.randomUUID() + assignTeacher(courseId, teacherId) + val labId = newLab(courseId) + + mockMvc + .perform( + put("/api/v1/labs/{id}", labId) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"title":"Обновлено преподавателем"}""") + .with(jwtFor(teacherId, "TEACHER")), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.title").value("Обновлено преподавателем")) + } + + @Test + fun `teacher cannot update lab of foreign course`() { + val courseId = newCourse() + val labId = newLab(courseId) + + mockMvc + .perform( + put("/api/v1/labs/{id}", labId) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"title":"Чужая лабораторная"}""") + .with(jwtFor(UUID.randomUUID(), "TEACHER")), + ).andExpect(status().isForbidden) + } + + @Test + fun `teacher can create lab in own course`() { + val courseId = newCourse() + val teacherId = UUID.randomUUID() + assignTeacher(courseId, teacherId) + + mockMvc + .perform( + post("/api/v1/labs") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"number":1,"title":"Новая лабораторная","courseId":"$courseId","levels":[]}""") + .with(jwtFor(teacherId, "TEACHER")), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.courseId").value(courseId.toString())) + } + + @Test + fun `teacher cannot create lab in foreign course`() { + val courseId = newCourse() + + mockMvc + .perform( + post("/api/v1/labs") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"number":1,"title":"Чужая лабораторная","courseId":"$courseId","levels":[]}""") + .with(jwtFor(UUID.randomUUID(), "TEACHER")), + ).andExpect(status().isForbidden) + } + + @Test + fun `teacher create without course is bad request`() { + mockMvc + .perform( + post("/api/v1/labs") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"number":1,"title":"Без курса","levels":[]}""") + .with(jwtFor(UUID.randomUUID(), "TEACHER")), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.detail").value("Укажите курс")) + } + + @Test + fun `admin can update and create labs in any course`() { + val courseId = newCourse() + val labId = newLab(courseId) + val admin = jwtFor(UUID.randomUUID(), "ADMIN") + + mockMvc + .perform( + put("/api/v1/labs/{id}", labId) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"title":"Обновлено администратором"}""") + .with(admin), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.title").value("Обновлено администратором")) + + mockMvc + .perform( + post("/api/v1/labs") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"number":2,"title":"Создано администратором","courseId":"$courseId","levels":[]}""") + .with(admin), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.courseId").value(courseId.toString())) + } + + @Test + fun `student cannot update or create labs`() { + val courseId = newCourse() + val labId = newLab(courseId) + val student = jwtFor(UUID.randomUUID(), "STUDENT") + + mockMvc + .perform( + put("/api/v1/labs/{id}", labId) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"title":"Студент не должен менять"}""") + .with(student), + ).andExpect(status().isForbidden) + + mockMvc + .perform( + post("/api/v1/labs") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"number":2,"title":"Студент не должен создавать","courseId":"$courseId","levels":[]}""") + .with(student), + ).andExpect(status().isForbidden) + } + + @Test + fun `teacher can copy own lab into own course`() { + val sourceCourseId = newCourse() + val targetCourseId = newCourse() + val teacherId = UUID.randomUUID() + assignTeacher(sourceCourseId, teacherId) + assignTeacher(targetCourseId, teacherId) + val sourceLabId = newLab(sourceCourseId) + + mockMvc + .perform( + post("/api/v1/labs/{id}/copy", sourceLabId) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"courseId":"$targetCourseId","number":2}""") + .with(jwtFor(teacherId, "TEACHER")), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.courseId").value(targetCourseId.toString())) + } + + @Test + fun `teacher cannot copy foreign lab or into foreign course`() { + val ownCourseId = newCourse() + val foreignCourseId = newCourse() + val teacherId = UUID.randomUUID() + assignTeacher(ownCourseId, teacherId) + val ownLabId = newLab(ownCourseId) + val foreignLabId = newLab(foreignCourseId) + val teacher = jwtFor(teacherId, "TEACHER") + + mockMvc + .perform( + post("/api/v1/labs/{id}/copy", foreignLabId) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"courseId":"$ownCourseId","number":2}""") + .with(teacher), + ).andExpect(status().isForbidden) + + mockMvc + .perform( + post("/api/v1/labs/{id}/copy", ownLabId) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"courseId":"$foreignCourseId","number":2}""") + .with(teacher), + ).andExpect(status().isForbidden) + } + + private fun newCourse(): UUID = + UUID.fromString( + courseService + .create(CourseCreateRequest("Курс ${UUID.randomUUID()}"), UUID.randomUUID()) + .id, + ) + + private fun assignTeacher( + courseId: UUID, + teacherId: UUID, + ) { + courseService.assignTeacher(courseId, CourseTeacherRequest(teacherId.toString())) + } + + private fun newLab( + courseId: UUID, + number: Int = 1, + ): UUID = + UUID.fromString( + labService + .create( + LabCreateRequest( + number = number, + title = "Лабораторная ${UUID.randomUUID()}", + courseId = courseId, + ), + ).id, + ) + + private fun jwtFor( + subject: UUID, + role: String, + ) = jwt() + .authorities(SimpleGrantedAuthority("ROLE_$role")) + .jwt { it.subject(subject.toString()).claim("role", role) } +}