diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseController.kt index 9693752..f2f5772 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseController.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseController.kt @@ -18,6 +18,8 @@ import ru.ysu.lms.academic.course.CourseBootstrapService import ru.ysu.lms.academic.course.CourseCreateRequest import ru.ysu.lms.academic.course.CourseDetail import ru.ysu.lms.academic.course.CourseGroupRequest +import ru.ysu.lms.academic.course.CourseGroupRoster +import ru.ysu.lms.academic.course.CourseRosterService import ru.ysu.lms.academic.course.CourseService import ru.ysu.lms.academic.course.CourseSummary import ru.ysu.lms.academic.course.CourseTeacherRequest @@ -32,6 +34,7 @@ class CourseController( private val courseService: CourseService, private val bootstrapService: CourseBootstrapService, private val accessService: CourseAccessService, + private val rosterService: CourseRosterService, ) { @GetMapping @PreAuthorize("hasAuthority('ROLE_ADMIN')") @@ -44,6 +47,7 @@ class CourseController( ): List = accessService.myCourses(jwt) @PostMapping + @PreAuthorize("hasAuthority('ROLE_ADMIN')") fun create( @RequestBody body: CourseCreateRequest, @AuthenticationPrincipal jwt: Jwt, @@ -58,6 +62,15 @@ class CourseController( return courseService.detail(id) } + /** Состав группы, активно привязанной к курсу. */ + @GetMapping("/{courseId}/groups/{groupId}/students") + @PreAuthorize("isAuthenticated()") + fun groupStudents( + @PathVariable courseId: UUID, + @PathVariable groupId: UUID, + @AuthenticationPrincipal jwt: Jwt, + ): CourseGroupRoster = rosterService.groupRoster(courseId, groupId, jwt) + @PutMapping("/{id}") @PreAuthorize("hasAuthority('ROLE_ADMIN')") fun update( diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseRosterDtos.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseRosterDtos.kt new file mode 100644 index 0000000..a129b7e --- /dev/null +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseRosterDtos.kt @@ -0,0 +1,15 @@ +package ru.ysu.lms.academic.course + +/** Состав группы в разрезе курса — данные берутся из identity, привязка проверяется в academic. */ +data class CourseGroupRoster( + val groupId: String, + val groupName: String, + val students: List, +) + +data class CourseGroupStudent( + val id: String, + val studentNo: String, + val fullName: String, + val status: String, +) diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseRosterService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseRosterService.kt new file mode 100644 index 0000000..a12c3bb --- /dev/null +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseRosterService.kt @@ -0,0 +1,44 @@ +package ru.ysu.lms.academic.course + +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.stereotype.Service +import ru.ysu.lms.academic.identity.IdentityClient +import ru.ysu.lms.academic.repo.CourseGroupRepository +import ru.ysu.lms.common.ForbiddenException +import java.util.UUID + +/** + * Состав группы, привязанной к курсу. Доступ к курсу проверяется той же курсовой + * областью видимости, что и у остальных academic-эндпоинтов; сама группа должна быть + * активной привязкой курса. + */ +@Service +class CourseRosterService( + private val accessService: CourseAccessService, + private val groups: CourseGroupRepository, + private val identity: IdentityClient, +) { + fun groupRoster( + courseId: UUID, + groupId: UUID, + jwt: Jwt, + ): CourseGroupRoster { + accessService.requireCourseAccess(courseId, jwt) + groups.findByCourseIdAndGroupIdAndRemovedAtIsNull(courseId, groupId) + ?: throw ForbiddenException("Группа не привязана к курсу") + val roster = identity.getGroupRoster(groupId.toString()) + return CourseGroupRoster( + groupId = roster.id, + groupName = roster.name, + students = + roster.students.map { + CourseGroupStudent( + id = it.id, + studentNo = it.studentNo, + fullName = it.fullName, + status = it.status, + ) + }, + ) + } +} diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/identity/IdentityClient.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/identity/IdentityClient.kt index 779f36a..393c386 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/identity/IdentityClient.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/identity/IdentityClient.kt @@ -15,12 +15,13 @@ import java.time.Duration import java.util.UUID /** - * Клиент identity-service. Академическому сервису нужны только текущие группы пользователя, - * поэтому из ответа `/api/v1/groups/me` читается минимальный контракт `{id}`. + * Клиент identity-service. Академическому сервису нужны текущие группы пользователя + * и состав группы по сервисному токену. */ @Service class IdentityClient( @Value("\${lms.identity.base-url:http://localhost:8081}") baseUrl: String, + @Value("\${lms.internal.token:dev-internal-token}") private val internalToken: String, ) { private val client = restClient(baseUrl) @@ -40,6 +41,20 @@ class IdentityClient( } catch (e: IllegalArgumentException) { throw IdentityUnavailableException("Сервис идентификации вернул некорректный ответ").apply { initCause(e) } } + + /** Текущий состав группы через внутренний endpoint identity. */ + fun getGroupRoster(groupId: String): GroupRosterInfo = + try { + client + .get() + .uri("/api/v1/internal/groups/{groupId}", groupId) + .header(INTERNAL_TOKEN_HEADER, internalToken) + .retrieve() + .body(GroupRosterInfo::class.java) + ?: throw IdentityUnavailableException("Сервис идентификации вернул пустой ответ") + } catch (e: RestClientException) { + throw IdentityUnavailableException().apply { initCause(e) } + } } /** @@ -55,8 +70,26 @@ internal data class GroupRef( val id: String = "", ) +/** Минимальный контракт `GET /api/v1/internal/groups/{groupId}`. */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class GroupRosterInfo( + val id: String = "", + val name: String = "", + val students: List = emptyList(), +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class GroupRosterStudent( + val id: String = "", + val studentNo: String = "", + val fullName: String = "", + val status: String = "", +) + private val GROUP_LIST = object : ParameterizedTypeReference>() {} +private const val INTERNAL_TOKEN_HEADER = "X-Internal-Token" + /** Таймауты обязательны: зависший downstream держит поток запроса и вешает сервис. */ private const val CONNECT_TIMEOUT_SECONDS = 3L private const val READ_TIMEOUT_SECONDS = 30L diff --git a/services/academic-service/src/main/resources/application.yml b/services/academic-service/src/main/resources/application.yml index ae8dc01..b2b03f0 100644 --- a/services/academic-service/src/main/resources/application.yml +++ b/services/academic-service/src/main/resources/application.yml @@ -45,3 +45,5 @@ lms: assets-dir: ${LMS_CONTENT_ASSETS_DIR:data/content-assets} identity: base-url: ${LMS_IDENTITY_URI:http://localhost:8081} + internal: + token: ${LMS_INTERNAL_TOKEN:dev-internal-token} diff --git a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseRosterServiceTest.kt b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseRosterServiceTest.kt new file mode 100644 index 0000000..8ad95d0 --- /dev/null +++ b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseRosterServiceTest.kt @@ -0,0 +1,158 @@ +package ru.ysu.lms.academic.course + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.assertj.core.api.Assertions.catchThrowable +import org.junit.jupiter.api.Test +import org.springframework.http.HttpStatus +import org.springframework.security.oauth2.jwt.Jwt +import ru.ysu.lms.academic.domain.Course +import ru.ysu.lms.academic.domain.CourseGroup +import ru.ysu.lms.academic.domain.CourseTeacher +import ru.ysu.lms.academic.identity.GroupRosterInfo +import ru.ysu.lms.academic.identity.GroupRosterStudent +import ru.ysu.lms.academic.identity.IdentityClient +import ru.ysu.lms.academic.identity.IdentityUnavailableException +import ru.ysu.lms.academic.repo.CourseGroupRepository +import ru.ysu.lms.academic.repo.CourseRepository +import ru.ysu.lms.academic.repo.CourseTeacherRepository +import ru.ysu.lms.academic.repo.LabRepository +import ru.ysu.lms.common.ForbiddenException +import java.util.UUID + +class CourseRosterServiceTest { + private val courses = mockk() + private val teachers = mockk() + private val groups = mockk() + private val labs = mockk() + private val identity = mockk() + private val courseService = mockk() + + private val accessService = CourseAccessService(courses, teachers, groups, labs, identity, courseService) + private val service = CourseRosterService(accessService, groups, identity) + + @Test + fun `teacher can load roster of an attached group`() { + val courseId = UUID.randomUUID() + val groupId = UUID.randomUUID() + val teacherId = UUID.randomUUID() + assignTeacher(activeCourse(courseId), teacherId) + attachGroup(courseId, groupId) + every { identity.getGroupRoster(groupId.toString()) } returns roster(groupId) + + val result = service.groupRoster(courseId, groupId, jwt("TEACHER", teacherId)) + + assertThat(result.groupId).isEqualTo(groupId.toString()) + assertThat(result.groupName).isEqualTo("ПИ-101") + assertThat(result.students) + .containsExactly(CourseGroupStudent("student-1", "001", "Иванов Иван", "ACTIVE")) + } + + @Test + fun `student without course access is forbidden`() { + val courseId = UUID.randomUUID() + val groupId = UUID.randomUUID() + every { identity.currentGroupIds(TOKEN) } returns emptyList() + + assertThatThrownBy { service.groupRoster(courseId, groupId, jwt("STUDENT")) } + .isInstanceOf(ForbiddenException::class.java) + verify(exactly = 0) { groups.findByCourseIdAndGroupIdAndRemovedAtIsNull(any(), any()) } + verify(exactly = 0) { identity.getGroupRoster(any()) } + } + + @Test + fun `foreign teacher is forbidden`() { + val courseId = UUID.randomUUID() + val groupId = UUID.randomUUID() + val teacherId = UUID.randomUUID() + assignTeacher(activeCourse(UUID.randomUUID()), teacherId) + + assertThatThrownBy { service.groupRoster(courseId, groupId, jwt("TEACHER", teacherId)) } + .isInstanceOf(ForbiddenException::class.java) + verify(exactly = 0) { groups.findByCourseIdAndGroupIdAndRemovedAtIsNull(any(), any()) } + verify(exactly = 0) { identity.getGroupRoster(any()) } + } + + @Test + fun `group not attached to the course is forbidden`() { + val courseId = UUID.randomUUID() + val groupId = UUID.randomUUID() + val teacherId = UUID.randomUUID() + assignTeacher(activeCourse(courseId), teacherId) + every { groups.findByCourseIdAndGroupIdAndRemovedAtIsNull(courseId, groupId) } returns null + + assertThatThrownBy { service.groupRoster(courseId, groupId, jwt("TEACHER", teacherId)) } + .isInstanceOf(ForbiddenException::class.java) + .hasMessageContaining("не привязана") + verify(exactly = 0) { identity.getGroupRoster(any()) } + } + + @Test + fun `admin can load roster of an attached group`() { + val courseId = UUID.randomUUID() + val groupId = UUID.randomUUID() + attachGroup(courseId, groupId) + every { identity.getGroupRoster(groupId.toString()) } returns roster(groupId) + + assertThat(service.groupRoster(courseId, groupId, jwt("ADMIN")).students).hasSize(1) + } + + @Test + fun `identity outage is reported as bad gateway`() { + val courseId = UUID.randomUUID() + val groupId = UUID.randomUUID() + val teacherId = UUID.randomUUID() + assignTeacher(activeCourse(courseId), teacherId) + attachGroup(courseId, groupId) + every { identity.getGroupRoster(groupId.toString()) } throws IdentityUnavailableException() + + val thrown = catchThrowable { service.groupRoster(courseId, groupId, jwt("TEACHER", teacherId)) } + + assertThat(thrown).isInstanceOf(IdentityUnavailableException::class.java) + assertThat((thrown as IdentityUnavailableException).status).isEqualTo(HttpStatus.BAD_GATEWAY) + } + + private fun activeCourse(id: UUID): Course = Course(id = id, title = "Курс", status = Course.STATUS_ACTIVE) + + private fun assignTeacher( + course: Course, + teacherId: UUID, + ) { + every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns + listOf(CourseTeacher(course = course, accountId = teacherId)) + every { courses.findAllById(any()) } returns listOf(course) + } + + private fun attachGroup( + courseId: UUID, + groupId: UUID, + ) { + every { groups.findByCourseIdAndGroupIdAndRemovedAtIsNull(courseId, groupId) } returns + CourseGroup(course = Course(id = courseId), groupId = groupId) + } + + private fun roster(groupId: UUID): GroupRosterInfo = + GroupRosterInfo( + id = groupId.toString(), + name = "ПИ-101", + students = listOf(GroupRosterStudent("student-1", "001", "Иванов Иван", "ACTIVE")), + ) + + private fun jwt( + role: String, + subject: UUID = UUID.randomUUID(), + ): Jwt = + Jwt + .withTokenValue(TOKEN) + .header("alg", "none") + .subject(subject.toString()) + .claim("role", role) + .build() + + private companion object { + const val TOKEN = "token" + } +}