feat(academic): доступ к курсам и лабораторным по ролям

This commit is contained in:
2026-10-27 15:46:54 +03:00
parent a8fa566d3c
commit 8cc658bf97
10 changed files with 535 additions and 30 deletions

View File

@@ -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,

View File

@@ -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')")

View File

@@ -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"
}
}

View File

@@ -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()

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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}

View File

@@ -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"
}
}