feat(academic): доступ к курсам и лабораторным по ролям
This commit is contained in:
@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.PutMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import ru.ysu.lms.academic.course.CourseAccessService
|
||||
import ru.ysu.lms.academic.course.CourseBootstrapRequest
|
||||
import ru.ysu.lms.academic.course.CourseBootstrapResult
|
||||
import ru.ysu.lms.academic.course.CourseBootstrapService
|
||||
@@ -26,14 +27,22 @@ import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/courses")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
class CourseController(
|
||||
private val courseService: CourseService,
|
||||
private val bootstrapService: CourseBootstrapService,
|
||||
private val accessService: CourseAccessService,
|
||||
) {
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun list(): List<CourseSummary> = courseService.list()
|
||||
|
||||
/** Курсы текущего пользователя: все для admin, назначенные/по группам для остальных. */
|
||||
@GetMapping("/mine")
|
||||
fun mine(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): List<CourseSummary> = accessService.myCourses(jwt)
|
||||
|
||||
@PostMapping
|
||||
fun create(
|
||||
@RequestBody body: CourseCreateRequest,
|
||||
@@ -43,39 +52,49 @@ class CourseController(
|
||||
@GetMapping("/{id}")
|
||||
fun detail(
|
||||
@PathVariable id: UUID,
|
||||
): CourseDetail = courseService.detail(id)
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): CourseDetail {
|
||||
accessService.requireCourseAccess(id, jwt)
|
||||
return courseService.detail(id)
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun update(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody body: CourseUpdateRequest,
|
||||
): CourseDetail = courseService.update(id, body)
|
||||
|
||||
@PostMapping("/{id}/teachers")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun assignTeacher(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody body: CourseTeacherRequest,
|
||||
): CourseDetail = courseService.assignTeacher(id, body)
|
||||
|
||||
@DeleteMapping("/{id}/teachers/{accountId}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun removeTeacher(
|
||||
@PathVariable id: UUID,
|
||||
@PathVariable accountId: String,
|
||||
): CourseDetail = courseService.removeTeacher(id, accountId)
|
||||
|
||||
@PostMapping("/{id}/groups")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun attachGroup(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody body: CourseGroupRequest,
|
||||
): CourseDetail = courseService.attachGroup(id, body)
|
||||
|
||||
@DeleteMapping("/{id}/groups/{groupId}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun detachGroup(
|
||||
@PathVariable id: UUID,
|
||||
@PathVariable groupId: String,
|
||||
): CourseDetail = courseService.detachGroup(id, groupId)
|
||||
|
||||
@PostMapping("/bootstrap")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun bootstrap(
|
||||
@RequestBody body: CourseBootstrapRequest,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
|
||||
@@ -2,6 +2,8 @@ package ru.ysu.lms.academic.controller
|
||||
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
@@ -12,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import ru.ysu.lms.academic.course.CourseAccessService
|
||||
import ru.ysu.lms.academic.lab.LabCopyRequest
|
||||
import ru.ysu.lms.academic.lab.LabCreateRequest
|
||||
import ru.ysu.lms.academic.lab.LabDetail
|
||||
@@ -27,18 +30,36 @@ import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/labs")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
class LabController(
|
||||
private val labService: LabService,
|
||||
private val accessService: CourseAccessService,
|
||||
) {
|
||||
/**
|
||||
* Без `courseId` список ограничен доступными курсами (admin видит все).
|
||||
* С `courseId` доступ к курсу проверяется явно.
|
||||
*/
|
||||
@GetMapping
|
||||
fun list(
|
||||
@RequestParam(required = false) courseId: UUID?,
|
||||
): List<LabSummary> = labService.list(courseId)
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): List<LabSummary> {
|
||||
if (courseId != null) {
|
||||
accessService.requireCourseAccess(courseId, jwt)
|
||||
return labService.list(courseId)
|
||||
}
|
||||
val accessible = accessService.accessibleCourseIds(jwt) ?: return labService.list()
|
||||
return labService.list(accessible)
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
fun detail(
|
||||
@PathVariable id: UUID,
|
||||
): LabDetail = labService.detail(id)
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): LabDetail {
|
||||
accessService.requireLabAccess(id, jwt)
|
||||
return labService.detail(id)
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/copy")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package ru.ysu.lms.academic.course
|
||||
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.stereotype.Service
|
||||
import ru.ysu.lms.academic.domain.Course
|
||||
import ru.ysu.lms.academic.identity.IdentityClient
|
||||
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 ru.ysu.lms.common.NotFoundException
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Курсовая область видимости для не-администраторов.
|
||||
*
|
||||
* Администратор видит все курсы, включая архивные. Преподаватель — только курсы с активным
|
||||
* назначением `course_teachers`; студент — только активные курсы групп, в которых он состоит
|
||||
* сейчас (по данным identity-service). Архивный курс для не-администратора недоступен.
|
||||
*/
|
||||
@Service
|
||||
class CourseAccessService(
|
||||
private val courses: CourseRepository,
|
||||
private val teachers: CourseTeacherRepository,
|
||||
private val groups: CourseGroupRepository,
|
||||
private val labs: LabRepository,
|
||||
private val identity: IdentityClient,
|
||||
private val courseService: CourseService,
|
||||
) {
|
||||
/**
|
||||
* `null` — администратор, ограничений нет; пустое множество — доступных курсов нет.
|
||||
* Набор содержит только активные курсы.
|
||||
*/
|
||||
fun accessibleCourseIds(jwt: Jwt): Set<UUID>? =
|
||||
when (role(jwt)) {
|
||||
ROLE_ADMIN -> null
|
||||
ROLE_TEACHER -> {
|
||||
val assignments = teachers.findByAccountIdAndRemovedAtIsNull(requireAccountId(jwt))
|
||||
activeCourseIds(assignments.mapNotNull { it.course?.id })
|
||||
}
|
||||
ROLE_STUDENT -> {
|
||||
val groupIds = identity.currentGroupIds(jwt.tokenValue)
|
||||
if (groupIds.isEmpty()) {
|
||||
emptySet()
|
||||
} else {
|
||||
activeCourseIds(groups.findByGroupIdInAndRemovedAtIsNull(groupIds).mapNotNull { it.course?.id })
|
||||
}
|
||||
}
|
||||
else -> emptySet()
|
||||
}
|
||||
|
||||
fun requireCourseAccess(
|
||||
courseId: UUID,
|
||||
jwt: Jwt,
|
||||
) {
|
||||
val allowed = accessibleCourseIds(jwt) ?: return
|
||||
if (courseId !in allowed) throw ForbiddenException("Курс недоступен")
|
||||
}
|
||||
|
||||
/**
|
||||
* Лаборатория без курса доступна только администратору: иначе её нельзя было бы
|
||||
* сопоставить с курсовой областью видимости.
|
||||
*/
|
||||
fun requireLabAccess(
|
||||
labId: UUID,
|
||||
jwt: Jwt,
|
||||
) {
|
||||
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
|
||||
val courseId = lab.course?.id
|
||||
if (courseId == null) {
|
||||
if (role(jwt) != ROLE_ADMIN) throw ForbiddenException("Лабораторная недоступна")
|
||||
return
|
||||
}
|
||||
requireCourseAccess(courseId, jwt)
|
||||
}
|
||||
|
||||
fun myCourses(jwt: Jwt): List<CourseSummary> {
|
||||
val allowed = accessibleCourseIds(jwt) ?: return courseService.list()
|
||||
if (allowed.isEmpty()) return emptyList()
|
||||
val allowedIds = allowed.mapTo(HashSet()) { it.toString() }
|
||||
return courseService.list().filter { it.id in allowedIds }
|
||||
}
|
||||
|
||||
private fun activeCourseIds(courseIds: Collection<UUID>): Set<UUID> {
|
||||
if (courseIds.isEmpty()) return emptySet()
|
||||
return courses.findAllById(courseIds).filter { it.status == Course.STATUS_ACTIVE }.mapTo(HashSet()) { it.id }
|
||||
}
|
||||
|
||||
private fun requireAccountId(jwt: Jwt): UUID =
|
||||
runCatching { UUID.fromString(jwt.subject) }
|
||||
.getOrElse { throw ForbiddenException("Некорректный идентификатор пользователя") }
|
||||
|
||||
private fun role(jwt: Jwt): String? = jwt.getClaimAsString("role")
|
||||
|
||||
companion object {
|
||||
const val ROLE_ADMIN = "ADMIN"
|
||||
const val ROLE_TEACHER = "TEACHER"
|
||||
const val ROLE_STUDENT = "STUDENT"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package ru.ysu.lms.academic.identity
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.core.ParameterizedTypeReference
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.client.JdkClientHttpRequestFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.web.client.RestClient
|
||||
import org.springframework.web.client.RestClientException
|
||||
import ru.ysu.lms.common.LmsException
|
||||
import java.net.http.HttpClient
|
||||
import java.time.Duration
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Клиент identity-service. Академическому сервису нужны только текущие группы пользователя,
|
||||
* поэтому из ответа `/api/v1/groups/me` читается минимальный контракт `{id}`.
|
||||
*/
|
||||
@Service
|
||||
class IdentityClient(
|
||||
@Value("\${lms.identity.base-url:http://localhost:8081}") baseUrl: String,
|
||||
) {
|
||||
private val client = restClient(baseUrl)
|
||||
|
||||
/** Идентификаторы групп, в которых пользователь состоит сейчас. */
|
||||
fun currentGroupIds(token: String): List<UUID> =
|
||||
try {
|
||||
val groups =
|
||||
client
|
||||
.get()
|
||||
.uri("/api/v1/groups/me")
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer $token")
|
||||
.retrieve()
|
||||
.body(GROUP_LIST) ?: emptyList()
|
||||
groups.map { UUID.fromString(it.id) }
|
||||
} catch (e: RestClientException) {
|
||||
throw IdentityUnavailableException().apply { initCause(e) }
|
||||
} catch (e: IllegalArgumentException) {
|
||||
throw IdentityUnavailableException("Сервис идентификации вернул некорректный ответ").apply { initCause(e) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity недоступен или ответил ошибкой. Это не отказ в доступе пользователю,
|
||||
* а сбой инфраструктуры, поэтому 502 вместо 403.
|
||||
*/
|
||||
class IdentityUnavailableException(
|
||||
message: String = "Сервис идентификации недоступен",
|
||||
) : LmsException(HttpStatus.BAD_GATEWAY, message, code = "IDENTITY_UNAVAILABLE")
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
internal data class GroupRef(
|
||||
val id: String = "",
|
||||
)
|
||||
|
||||
private val GROUP_LIST = object : ParameterizedTypeReference<List<GroupRef>>() {}
|
||||
|
||||
/** Таймауты обязательны: зависший downstream держит поток запроса и вешает сервис. */
|
||||
private const val CONNECT_TIMEOUT_SECONDS = 3L
|
||||
private const val READ_TIMEOUT_SECONDS = 30L
|
||||
|
||||
private fun restClient(baseUrl: String): RestClient =
|
||||
RestClient
|
||||
.builder()
|
||||
.baseUrl(baseUrl)
|
||||
.requestFactory(
|
||||
JdkClientHttpRequestFactory(
|
||||
HttpClient
|
||||
.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SECONDS))
|
||||
.build(),
|
||||
).apply { setReadTimeout(Duration.ofSeconds(READ_TIMEOUT_SECONDS)) },
|
||||
).build()
|
||||
@@ -103,23 +103,16 @@ class LabService(
|
||||
@Transactional(readOnly = true)
|
||||
fun list(courseId: UUID? = null): List<LabSummary> {
|
||||
val labList = if (courseId == null) labs.findAllByOrderByNumberAsc() else labs.findAllByCourseIdOrderByNumberAsc(courseId)
|
||||
return labList.map { lab ->
|
||||
val variants = variantsRepo.findByLabIdOrderByNumber(lab.id)
|
||||
LabSummary(
|
||||
id = lab.id.toString(),
|
||||
number = lab.number,
|
||||
title = lab.title,
|
||||
status = lab.status,
|
||||
semesterTitle = lab.semester?.let { "${it.title} ${it.year}" } ?: "—",
|
||||
deadlineAt = lab.deadlineAt?.toString(),
|
||||
defenseAt = lab.defenseAt?.toString(),
|
||||
pointsMax = lab.pointsMax,
|
||||
variantCount = variants.size,
|
||||
courseId = lab.course?.id?.toString(),
|
||||
courseNo = lab.course?.courseNo,
|
||||
courseTitle = lab.course?.title,
|
||||
)
|
||||
}
|
||||
return labList.map { it.toSummary(variantsRepo.findByLabIdOrderByNumber(it.id)) }
|
||||
}
|
||||
|
||||
/** Список лабораторных только по доступным курсам (курсовая область видимости). */
|
||||
@Transactional(readOnly = true)
|
||||
fun list(courseIds: Collection<UUID>): List<LabSummary> {
|
||||
if (courseIds.isEmpty()) return emptyList()
|
||||
return labs
|
||||
.findAllByCourseIdInOrderByNumberAsc(courseIds)
|
||||
.map { it.toSummary(variantsRepo.findByLabIdOrderByNumber(it.id)) }
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -399,14 +392,6 @@ class LabService(
|
||||
}
|
||||
return semesters.save(Semester(title = t, year = y, active = true))
|
||||
}
|
||||
|
||||
private fun parseInstant(value: String?): Instant? {
|
||||
val v = value?.takeIf { it.isNotBlank() } ?: return null
|
||||
return runCatching { Instant.parse(v) }
|
||||
.recoverCatching { OffsetDateTime.parse(v).toInstant() }
|
||||
.recoverCatching { LocalDateTime.parse(v).atZone(ZoneId.of("Europe/Moscow")).toInstant() }
|
||||
.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
/** Явно заданный курс или единственный существующий (для импорта без courseId). */
|
||||
@@ -428,3 +413,27 @@ private fun Course.rejectArchived(): Course {
|
||||
}
|
||||
|
||||
private fun naturalNumber(value: String): Int = Regex("\\d+").find(value)?.value?.toIntOrNull() ?: Int.MAX_VALUE
|
||||
|
||||
private fun Lab.toSummary(variants: List<Variant>): LabSummary =
|
||||
LabSummary(
|
||||
id = id.toString(),
|
||||
number = number,
|
||||
title = title,
|
||||
status = status,
|
||||
semesterTitle = semester?.let { "${it.title} ${it.year}" } ?: "—",
|
||||
deadlineAt = deadlineAt?.toString(),
|
||||
defenseAt = defenseAt?.toString(),
|
||||
pointsMax = pointsMax,
|
||||
variantCount = variants.size,
|
||||
courseId = course?.id?.toString(),
|
||||
courseNo = course?.courseNo,
|
||||
courseTitle = course?.title,
|
||||
)
|
||||
|
||||
private fun parseInstant(value: String?): Instant? {
|
||||
val v = value?.takeIf { it.isNotBlank() } ?: return null
|
||||
return runCatching { Instant.parse(v) }
|
||||
.recoverCatching { OffsetDateTime.parse(v).toInstant() }
|
||||
.recoverCatching { LocalDateTime.parse(v).atZone(ZoneId.of("Europe/Moscow")).toInstant() }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ interface CourseTeacherRepository : JpaRepository<CourseTeacher, UUID> {
|
||||
accountId: UUID,
|
||||
): CourseTeacher?
|
||||
|
||||
fun findByAccountIdAndRemovedAtIsNull(accountId: UUID): List<CourseTeacher>
|
||||
|
||||
fun countByCourseIdAndRemovedAtIsNull(courseId: UUID): Long
|
||||
}
|
||||
|
||||
@@ -31,5 +33,7 @@ interface CourseGroupRepository : JpaRepository<CourseGroup, UUID> {
|
||||
groupId: UUID,
|
||||
): CourseGroup?
|
||||
|
||||
fun findByGroupIdInAndRemovedAtIsNull(groupIds: Collection<UUID>): List<CourseGroup>
|
||||
|
||||
fun countByCourseIdAndRemovedAtIsNull(courseId: UUID): Long
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ interface LabRepository : JpaRepository<Lab, UUID> {
|
||||
|
||||
fun findAllByCourseIdOrderByNumberAsc(courseId: UUID): List<Lab>
|
||||
|
||||
fun findAllByCourseIdInOrderByNumberAsc(courseIds: Collection<UUID>): List<Lab>
|
||||
|
||||
fun existsByNumberAndCourseId(
|
||||
number: Int,
|
||||
courseId: UUID,
|
||||
|
||||
@@ -43,3 +43,5 @@ springdoc:
|
||||
lms:
|
||||
content:
|
||||
assets-dir: ${LMS_CONTENT_ASSETS_DIR:data/content-assets}
|
||||
identity:
|
||||
base-url: ${LMS_IDENTITY_URI:http://localhost:8081}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
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.assertThatCode
|
||||
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.domain.Lab
|
||||
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 ru.ysu.lms.common.NotFoundException
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
class CourseAccessServiceTest {
|
||||
private val courses = mockk<CourseRepository>()
|
||||
private val teachers = mockk<CourseTeacherRepository>()
|
||||
private val groups = mockk<CourseGroupRepository>()
|
||||
private val labs = mockk<LabRepository>()
|
||||
private val identity = mockk<IdentityClient>()
|
||||
private val courseService = mockk<CourseService>()
|
||||
|
||||
private val service = CourseAccessService(courses, teachers, groups, labs, identity, courseService)
|
||||
|
||||
@Test
|
||||
fun `admin bypasses checks and has no course restriction`() {
|
||||
val archived = course(Course.STATUS_ARCHIVED)
|
||||
val admin = jwt(CourseAccessService.ROLE_ADMIN)
|
||||
|
||||
assertThatCode { service.requireCourseAccess(archived.id, admin) }.doesNotThrowAnyException()
|
||||
assertThat(service.accessibleCourseIds(admin)).isNull()
|
||||
verify(exactly = 0) { courses.findAllById(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `teacher with active assignment can access active course`() {
|
||||
val active = course()
|
||||
val teacherId = UUID.randomUUID()
|
||||
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns
|
||||
listOf(CourseTeacher(course = active, accountId = teacherId))
|
||||
every { courses.findAllById(any()) } returns listOf(active)
|
||||
|
||||
assertThatCode {
|
||||
service.requireCourseAccess(active.id, jwt(CourseAccessService.ROLE_TEACHER, teacherId))
|
||||
}.doesNotThrowAnyException()
|
||||
verify(exactly = 0) { identity.currentGroupIds(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `teacher cannot access a foreign course`() {
|
||||
val assigned = course()
|
||||
val foreign = course()
|
||||
val teacherId = UUID.randomUUID()
|
||||
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns
|
||||
listOf(CourseTeacher(course = assigned, accountId = teacherId))
|
||||
every { courses.findAllById(any()) } returns listOf(assigned)
|
||||
|
||||
assertThatThrownBy {
|
||||
service.requireCourseAccess(foreign.id, jwt(CourseAccessService.ROLE_TEACHER, teacherId))
|
||||
}.isInstanceOf(ForbiddenException::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `teacher with removed assignment is denied`() {
|
||||
val teacherId = UUID.randomUUID()
|
||||
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns emptyList()
|
||||
|
||||
assertThatThrownBy {
|
||||
service.requireCourseAccess(course().id, jwt(CourseAccessService.ROLE_TEACHER, teacherId))
|
||||
}.isInstanceOf(ForbiddenException::class.java)
|
||||
verify(exactly = 0) { courses.findAllById(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `teacher cannot access archived course`() {
|
||||
val archived = course(Course.STATUS_ARCHIVED)
|
||||
val teacherId = UUID.randomUUID()
|
||||
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns
|
||||
listOf(CourseTeacher(course = archived, accountId = teacherId))
|
||||
every { courses.findAllById(any()) } returns listOf(archived)
|
||||
|
||||
assertThatThrownBy {
|
||||
service.requireCourseAccess(archived.id, jwt(CourseAccessService.ROLE_TEACHER, teacherId))
|
||||
}.isInstanceOf(ForbiddenException::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `student can access course of a current group`() {
|
||||
val active = course()
|
||||
val groupId = UUID.randomUUID()
|
||||
stubStudentGroup(active, groupId)
|
||||
|
||||
assertThatCode {
|
||||
service.requireCourseAccess(active.id, jwt(CourseAccessService.ROLE_STUDENT))
|
||||
}.doesNotThrowAnyException()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `student cannot access course of a foreign group`() {
|
||||
val foreign = course()
|
||||
val other = course()
|
||||
val groupId = UUID.randomUUID()
|
||||
stubStudentGroup(other, groupId)
|
||||
every { courses.findAllById(any()) } returns listOf(other)
|
||||
|
||||
assertThatThrownBy {
|
||||
service.requireCourseAccess(foreign.id, jwt(CourseAccessService.ROLE_STUDENT))
|
||||
}.isInstanceOf(ForbiddenException::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `student without current groups is denied without querying course groups`() {
|
||||
every { identity.currentGroupIds(TOKEN) } returns emptyList()
|
||||
|
||||
assertThatThrownBy {
|
||||
service.requireCourseAccess(course().id, jwt(CourseAccessService.ROLE_STUDENT))
|
||||
}.isInstanceOf(ForbiddenException::class.java)
|
||||
verify(exactly = 0) { groups.findByGroupIdInAndRemovedAtIsNull(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `student cannot access archived course of a current group`() {
|
||||
val archived = course(Course.STATUS_ARCHIVED)
|
||||
val groupId = UUID.randomUUID()
|
||||
stubStudentGroup(archived, groupId)
|
||||
every { courses.findAllById(any()) } returns listOf(archived)
|
||||
|
||||
assertThatThrownBy {
|
||||
service.requireCourseAccess(archived.id, jwt(CourseAccessService.ROLE_STUDENT))
|
||||
}.isInstanceOf(ForbiddenException::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `identity outage is reported as bad gateway instead of forbidden`() {
|
||||
every { identity.currentGroupIds(TOKEN) } throws IdentityUnavailableException()
|
||||
|
||||
val thrown =
|
||||
catchThrowable {
|
||||
service.requireCourseAccess(course().id, jwt(CourseAccessService.ROLE_STUDENT))
|
||||
}
|
||||
|
||||
assertThat(thrown).isInstanceOf(IdentityUnavailableException::class.java)
|
||||
assertThat((thrown as IdentityUnavailableException).status).isEqualTo(HttpStatus.BAD_GATEWAY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `admin my courses contains every course`() {
|
||||
val active = course()
|
||||
val archived = course(Course.STATUS_ARCHIVED)
|
||||
every { courseService.list() } returns listOf(summary(active), summary(archived))
|
||||
|
||||
assertThat(service.myCourses(jwt(CourseAccessService.ROLE_ADMIN)).map { it.id })
|
||||
.containsExactly(active.id.toString(), archived.id.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `teacher my courses contains only active assigned courses`() {
|
||||
val active = course()
|
||||
val archived = course(Course.STATUS_ARCHIVED)
|
||||
val teacherId = UUID.randomUUID()
|
||||
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns
|
||||
listOf(
|
||||
CourseTeacher(course = active, accountId = teacherId),
|
||||
CourseTeacher(course = archived, accountId = teacherId),
|
||||
)
|
||||
every { courses.findAllById(any()) } returns listOf(active, archived)
|
||||
every { courseService.list() } returns listOf(summary(active), summary(archived))
|
||||
|
||||
assertThat(service.myCourses(jwt(CourseAccessService.ROLE_TEACHER, teacherId)).map { it.id })
|
||||
.containsExactly(active.id.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `student my courses contains only courses of current groups`() {
|
||||
val active = course()
|
||||
val foreign = course()
|
||||
val groupId = UUID.randomUUID()
|
||||
stubStudentGroup(active, groupId)
|
||||
every { courses.findAllById(any()) } returns listOf(active)
|
||||
every { courseService.list() } returns listOf(summary(active), summary(foreign))
|
||||
|
||||
assertThat(service.myCourses(jwt(CourseAccessService.ROLE_STUDENT)).map { it.id })
|
||||
.containsExactly(active.id.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lab access follows its course`() {
|
||||
val active = course()
|
||||
val lab = Lab(number = 1, title = "Лабораторная", course = active)
|
||||
val groupId = UUID.randomUUID()
|
||||
every { labs.findById(lab.id) } returns Optional.of(lab)
|
||||
stubStudentGroup(active, groupId)
|
||||
|
||||
assertThatCode {
|
||||
service.requireLabAccess(lab.id, jwt(CourseAccessService.ROLE_STUDENT))
|
||||
}.doesNotThrowAnyException()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lab without course is admin only`() {
|
||||
val lab = Lab(number = 1, title = "Без курса")
|
||||
every { labs.findById(lab.id) } returns Optional.of(lab)
|
||||
|
||||
assertThatCode {
|
||||
service.requireLabAccess(lab.id, jwt(CourseAccessService.ROLE_ADMIN))
|
||||
}.doesNotThrowAnyException()
|
||||
assertThatThrownBy {
|
||||
service.requireLabAccess(lab.id, jwt(CourseAccessService.ROLE_STUDENT))
|
||||
}.isInstanceOf(ForbiddenException::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing lab is not found`() {
|
||||
every { labs.findById(any()) } returns Optional.empty()
|
||||
|
||||
assertThatThrownBy {
|
||||
service.requireLabAccess(UUID.randomUUID(), jwt(CourseAccessService.ROLE_ADMIN))
|
||||
}.isInstanceOf(NotFoundException::class.java)
|
||||
}
|
||||
|
||||
private fun stubStudentGroup(
|
||||
course: Course,
|
||||
groupId: UUID,
|
||||
) {
|
||||
every { identity.currentGroupIds(TOKEN) } returns listOf(groupId)
|
||||
every { groups.findByGroupIdInAndRemovedAtIsNull(any()) } returns
|
||||
listOf(CourseGroup(course = course, groupId = groupId))
|
||||
every { courses.findAllById(any()) } returns listOf(course)
|
||||
}
|
||||
|
||||
private fun course(status: String = Course.STATUS_ACTIVE): Course = Course(title = "Курс", status = status)
|
||||
|
||||
private fun summary(course: Course): CourseSummary =
|
||||
CourseSummary(
|
||||
id = course.id.toString(),
|
||||
courseNo = 1,
|
||||
title = course.title,
|
||||
ownerAccountId = course.ownerAccountId.toString(),
|
||||
status = course.status,
|
||||
teacherCount = 0,
|
||||
groupCount = 0,
|
||||
labCount = 0,
|
||||
)
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user