feat(progress): преподавательские оценки, защиты и аудит

This commit is contained in:
2026-10-27 20:39:22 +03:00
parent 1de7207c3c
commit c6ca981eca
19 changed files with 1302 additions and 103 deletions

View File

@@ -61,6 +61,24 @@ class AcademicClient(
throw courseError(ex) throw courseError(ex)
} }
/** Состав группы, активно привязанной к курсу (вместе с проверкой доступа academic). */
fun getCourseGroupStudents(
courseId: String,
groupId: String,
token: String,
): CourseGroupRosterInfo =
try {
client
.get()
.uri("/api/v1/courses/{courseId}/groups/{groupId}/students", courseId, groupId)
.header("Authorization", "Bearer $token")
.retrieve()
.body(CourseGroupRosterInfo::class.java)
?: throw NotFoundException("Группа не найдена")
} catch (ex: RestClientResponseException) {
throw groupError(ex)
}
/** /**
* Лабораторные курса вместе с уровнями и вариантами: список отдаёт только сводки, * Лабораторные курса вместе с уровнями и вариантами: список отдаёт только сводки,
* поэтому детали каждой лабораторной подтягиваются отдельно. Ошибка детали не * поэтому детали каждой лабораторной подтягиваются отдельно. Ошибка детали не
@@ -112,6 +130,14 @@ private fun labError(ex: RestClientResponseException): Exception =
else -> ex else -> ex
} }
/** 403 academic означает чужую группу или чужой курс. */
private fun groupError(ex: RestClientResponseException): Exception =
when (ex.statusCode.value()) {
403 -> ForbiddenException("Группа недоступна")
404 -> NotFoundException("Группа не найдена")
else -> ex
}
@Service @Service
class IdentityClient( class IdentityClient(
@Value("\${lms.identity.base-url}") baseUrl: String, @Value("\${lms.identity.base-url}") baseUrl: String,

View File

@@ -0,0 +1,78 @@
package ru.ysu.lms.progress
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.stereotype.Service
import ru.ysu.lms.common.ForbiddenException
import java.util.UUID
/**
* Единая проверка курсовой области для преподавателя: чужая лабораторная, студент
* или группа вне активных групп курса должны давать 403 до любой записи.
* Администратор проверки не проходит — у него полный доступ.
*/
@Service
class CourseScopeService(
private val academic: AcademicClient,
private val identity: IdentityClient,
) {
fun lab(
labId: UUID,
token: String,
): LabInfo = academic.getLab(labId.toString(), token)
fun courseIdOf(
labId: UUID,
token: String,
): UUID {
val lab = lab(labId, token)
val courseId = lab.courseId?.takeIf { it.isNotBlank() } ?: throw ForbiddenException("Лабораторная недоступна")
return UUID.fromString(courseId)
}
fun requireStudentInCourse(
studentId: UUID,
courseId: UUID,
token: String,
) {
val groupId =
identity
.getStudentContext(studentId.toString())
.groupId
?.takeIf { it.isNotBlank() }
?: throw ForbiddenException("Студент не входит в группу курса")
val activeGroups = courseGroups(courseId, token).map { it.groupId }
if (groupId !in activeGroups) throw ForbiddenException("Студент не входит в группу курса")
}
fun requireGroupInCourse(
groupId: UUID,
courseId: UUID,
token: String,
) {
val activeGroups = courseGroups(courseId, token).map { it.groupId }
if (groupId.toString() !in activeGroups) throw ForbiddenException("Группа не привязана к курсу")
}
fun role(jwt: Jwt): String? = jwt.getClaimAsString("role")
fun isAdmin(jwt: Jwt): Boolean = role(jwt) == ROLE_ADMIN
fun course(
courseId: UUID,
token: String,
): CourseInfo = academic.getCourse(courseId.toString(), token)
fun courseLabs(
courseId: UUID,
token: String,
): List<LabInfo> = academic.getCourseLabs(courseId.toString(), token)
fun courseGroups(
courseId: UUID,
token: String,
): List<CourseGroupInfo> = course(courseId, token).groups.filter { it.removedAt == null }
private companion object {
const val ROLE_ADMIN = "ADMIN"
}
}

View File

@@ -1,6 +1,8 @@
package ru.ysu.lms.progress package ru.ysu.lms.progress
import org.springframework.security.access.prepost.PreAuthorize 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.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
@@ -9,6 +11,7 @@ import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import ru.ysu.lms.progress.audit.AuditClient
import java.util.UUID import java.util.UUID
data class DefenseAssignPayload( data class DefenseAssignPayload(
@@ -32,49 +35,126 @@ data class DefenseAdminDto(
@RestController @RestController
@RequestMapping("/api/v1/defenses") @RequestMapping("/api/v1/defenses")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
class DefenseAdminController( class DefenseAdminController(
private val service: DefenseService, private val service: DefenseService,
private val scope: CourseScopeService,
private val audit: AuditClient,
) { ) {
@PostMapping("/assign") @PostMapping("/assign")
fun assign( fun assign(
@RequestBody body: DefenseAssignPayload, @RequestBody body: DefenseAssignPayload,
): DefenseDto = service.assign(UUID.fromString(body.studentId), UUID.fromString(body.labId), body.assignmentMd) @AuthenticationPrincipal jwt: Jwt,
): DefenseDto {
requireStudentScope(body.studentId, body.labId, jwt)
val defense =
service.assign(UUID.fromString(body.studentId), UUID.fromString(body.labId), body.assignmentMd)
audit.record(
actorAccountId = jwt.subject,
action = "DEFENSE_ASSIGN",
entityType = "DEFENSE",
entityId = defense.id,
studentId = body.studentId,
details = "lab=${body.labId}",
)
return defense
}
@DeleteMapping("/assign") @DeleteMapping("/assign")
fun removeAssignment( fun removeAssignment(
@RequestParam studentId: String, @RequestParam studentId: String,
@RequestParam labId: String, @RequestParam labId: String,
@AuthenticationPrincipal jwt: Jwt,
) { ) {
requireStudentScope(studentId, labId, jwt)
service.resetByStudentLab(UUID.fromString(studentId), UUID.fromString(labId)) service.resetByStudentLab(UUID.fromString(studentId), UUID.fromString(labId))
audit.record(
actorAccountId = jwt.subject,
action = "DEFENSE_REMOVE",
entityType = "DEFENSE",
entityId = "$studentId|$labId",
studentId = studentId,
details = "lab=$labId",
)
} }
@GetMapping @GetMapping
fun list( fun list(
@RequestParam labId: UUID, @RequestParam labId: UUID,
): List<DefenseAdminDto> = @AuthenticationPrincipal jwt: Jwt,
service.listForLab(labId).map { ): List<DefenseAdminDto> {
DefenseAdminDto( requireLabScope(labId, jwt)
it.id, return service.listForLab(labId).map { toAdminDto(it) }
it.studentId,
it.labId,
it.status,
it.code,
it.assignmentMd,
it.startedAt,
it.readyAt,
it.readyReason,
it.secondsLeft,
)
} }
/** Синхронизация таймеров статусов по списку лабораторных: `labs=id1,id2,...` */ /** Синхронизация таймеров статусов по списку лабораторных: `labs=id1,id2,...` */
@GetMapping("/watch") @GetMapping("/watch")
fun watch( fun watch(
@RequestParam labs: String, @RequestParam labs: String,
@AuthenticationPrincipal jwt: Jwt,
): List<DefenseAdminDto> { ): List<DefenseAdminDto> {
val ids = labs.split(',').mapNotNull { runCatching { UUID.fromString(it.trim()) }.getOrNull() } val ids = labs.split(',').mapNotNull { runCatching { UUID.fromString(it.trim()) }.getOrNull() }
return service.listForLabs(ids).map { if (!scope.isAdmin(jwt)) ids.forEach { requireLabScope(it, jwt) }
return service.listForLabs(ids).map { toAdminDto(it) }
}
@PostMapping("/{id}/issue-code")
fun issueCode(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): Map<String, String> {
val session = service.findSession(id)
requireStudentScope(session.studentId, session.labId, jwt)
val code = service.issueCode(id)
audit.record(
actorAccountId = jwt.subject,
action = "DEFENSE_CODE_ISSUE",
entityType = "DEFENSE",
entityId = id.toString(),
studentId = session.studentId,
details = "lab=${session.labId}",
)
return mapOf("code" to code)
}
@PostMapping("/{id}/reset")
fun reset(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
) {
val session = service.findSession(id)
requireLabScope(UUID.fromString(session.labId), jwt)
service.reset(id)
audit.record(
actorAccountId = jwt.subject,
action = "DEFENSE_RESET",
entityType = "DEFENSE",
entityId = id.toString(),
studentId = session.studentId,
details = "lab=${session.labId}",
)
}
private fun requireLabScope(
labId: UUID,
jwt: Jwt,
) {
if (scope.isAdmin(jwt)) return
scope.lab(labId, jwt.tokenValue)
}
private fun requireStudentScope(
studentId: String,
labId: String,
jwt: Jwt,
) {
if (scope.isAdmin(jwt)) return
val token = jwt.tokenValue
val courseId = scope.courseIdOf(UUID.fromString(labId), token)
scope.requireStudentInCourse(UUID.fromString(studentId), courseId, token)
}
private fun toAdminDto(it: DefenseDto): DefenseAdminDto =
DefenseAdminDto( DefenseAdminDto(
it.id, it.id,
it.studentId, it.studentId,
@@ -88,15 +168,3 @@ class DefenseAdminController(
it.secondsLeft, it.secondsLeft,
) )
} }
}
@PostMapping("/{id}/issue-code")
fun issueCode(
@PathVariable id: UUID,
): Map<String, String> = mapOf("code" to service.issueCode(id))
@PostMapping("/{id}/reset")
fun reset(
@PathVariable id: UUID,
) = service.reset(id)
}

View File

@@ -73,6 +73,13 @@ class DefenseService(
return toDto(sessions.save(s)) return toDto(sessions.save(s))
} }
@Transactional(readOnly = true)
fun findSession(id: UUID): DefenseDto =
sessions
.findById(id)
.orElseThrow { NotFoundException("Сессия защиты не найдена") }
.let { toDto(it) }
@Transactional @Transactional
fun issueCode(id: UUID): String { fun issueCode(id: UUID): String {
val s = sessions.findById(id).orElseThrow { NotFoundException("Сессия защиты не найдена") } val s = sessions.findById(id).orElseThrow { NotFoundException("Сессия защиты не найдена") }

View File

@@ -87,6 +87,22 @@ data class CourseGroupInfo(
val removedAt: String? = null, val removedAt: String? = null,
) )
/** Состав группы в разрезе курса: привязка проверяется academic-service. */
@JsonIgnoreProperties(ignoreUnknown = true)
data class CourseGroupRosterInfo(
val groupId: String = "",
val groupName: String = "",
val students: List<CourseGroupStudentInfo> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class CourseGroupStudentInfo(
val id: String = "",
val studentNo: String = "",
val fullName: String = "",
val status: String = "",
)
/** Внутренний контекст студента из identity-service. */ /** Внутренний контекст студента из identity-service. */
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class StudentContext( data class StudentContext(

View File

@@ -7,18 +7,39 @@ import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import ru.ysu.lms.common.BadRequestException
import java.util.UUID import java.util.UUID
@RestController @RestController
class GroupLabDataController( class GroupLabDataController(
private val service: GroupLabDataService, private val service: GroupLabDataService,
private val scope: CourseScopeService,
) { ) {
/** Данные всех студентов группы по выбранным лабораторным — только для админа. */ /** Данные всех студентов группы по выбранным лабораторным — для админа и преподавателя своего курса. */
@GetMapping("/api/v1/assignments/group/{groupId}") @GetMapping("/api/v1/assignments/group/{groupId}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun groupLabData( fun groupLabData(
@PathVariable groupId: UUID, @PathVariable groupId: UUID,
@RequestParam("labId") labIds: List<UUID>, @RequestParam("labId") labIds: List<UUID>,
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
): GroupLabDataResponse = service.labData(groupId, labIds, jwt.tokenValue) ): GroupLabDataResponse {
requireScope(groupId, labIds, jwt)
return service.labData(groupId, labIds, jwt.tokenValue)
}
private fun requireScope(
groupId: UUID,
labIds: List<UUID>,
jwt: Jwt,
) {
if (scope.isAdmin(jwt) || labIds.isEmpty()) return
val token = jwt.tokenValue
val courseId = scope.courseIdOf(labIds.first(), token)
for (labId in labIds.drop(1)) {
if (scope.courseIdOf(labId, token) != courseId) {
throw BadRequestException("Лабораторные относятся к разным курсам")
}
}
scope.requireGroupInCourse(groupId, courseId, token)
}
} }

View File

@@ -11,6 +11,8 @@ import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.progress.audit.AuditClient
import java.util.UUID import java.util.UUID
@RestController @RestController
@@ -19,6 +21,8 @@ class ProgressController(
private val service: ProgressService, private val service: ProgressService,
private val grades: GradeService, private val grades: GradeService,
private val publications: GradePublicationService, private val publications: GradePublicationService,
private val scope: CourseScopeService,
private val audit: AuditClient,
) { ) {
@GetMapping("/me/assignments") @GetMapping("/me/assignments")
fun meAssignments( fun meAssignments(
@@ -38,7 +42,19 @@ class ProgressController(
fun setLevel( fun setLevel(
@Valid @RequestBody body: LevelAssignmentRequest, @Valid @RequestBody body: LevelAssignmentRequest,
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
): LevelAssignmentResponse = service.setLevel(body, jwt.tokenValue) ): LevelAssignmentResponse {
requireStudentScope(body.studentId, body.labId, jwt)
val response = service.setLevel(body, jwt.tokenValue)
audit.record(
actorAccountId = jwt.subject,
action = "VARIANT_LEVEL_SET",
entityType = "LAB",
entityId = body.labId,
studentId = body.studentId,
details = "level=${body.labLevelId}",
)
return response
}
@GetMapping("/levels") @GetMapping("/levels")
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
@@ -56,18 +72,40 @@ class ProgressController(
} }
@PostMapping("/distribute") @PostMapping("/distribute")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun distribute( fun distribute(
@Valid @RequestBody body: DistributeRequest, @Valid @RequestBody body: DistributeRequest,
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
): DistributeResult = service.distribute(body, jwt.tokenValue) ): DistributeResult {
val result = service.distribute(body, jwt.tokenValue)
audit.record(
actorAccountId = jwt.subject,
action = "VARIANT_DISTRIBUTE",
entityType = "LAB",
entityId = result.labId ?: requestedLabIds(body).joinToString(","),
details = "assigned=${result.assigned}/${result.total}",
)
return result
}
@PostMapping("/assign") @PostMapping("/assign")
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun assign( fun assign(
@Valid @RequestBody body: AssignRequest, @Valid @RequestBody body: AssignRequest,
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
): VariantAssignmentResponse = service.assignWithInfo(body, jwt.tokenValue) ): VariantAssignmentResponse {
requireStudentScope(body.studentId, body.labId, jwt)
val response = service.assignWithInfo(body, jwt.tokenValue)
audit.record(
actorAccountId = jwt.subject,
action = "VARIANT_ASSIGN",
entityType = "LAB",
entityId = body.labId,
studentId = body.studentId,
details = "variant=${body.variantId}",
)
return response
}
@GetMapping("/assignments") @GetMapping("/assignments")
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
@@ -77,48 +115,84 @@ class ProgressController(
): List<VariantAssignmentResponse> = service.assignmentsForLabEnriched(labId, jwt.tokenValue) ): List<VariantAssignmentResponse> = service.assignmentsForLabEnriched(labId, jwt.tokenValue)
@PostMapping("/grades") @PostMapping("/grades")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun setGrade( fun setGrade(
@Valid @RequestBody body: GradeSetRequest, @Valid @RequestBody body: GradeSetRequest,
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
): GradeResponse = grades.setGrade(body, UUID.fromString(jwt.subject)) ): GradeResponse {
requireStudentScope(body.studentId, body.labId, jwt)
val response = grades.setGrade(body, UUID.fromString(jwt.subject))
audit.record(
actorAccountId = jwt.subject,
action = "GRADE_SET",
entityType = "GRADE",
entityId = "${body.studentId}|${body.labId}",
studentId = body.studentId,
details = "score=${body.score.toPlainString()}",
)
return response
}
@GetMapping("/grades") @GetMapping("/grades")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun listGrades(): List<GradeResponse> = grades.listGrades() fun listGrades(
@RequestParam(required = false) courseId: UUID?,
@AuthenticationPrincipal jwt: Jwt,
): List<GradeResponse> {
if (scope.isAdmin(jwt)) return grades.listGrades()
val cid = courseId ?: throw BadRequestException("Укажите курс")
scope.course(cid, jwt.tokenValue)
val labIds = scope.courseLabs(cid, jwt.tokenValue).map { it.id }.toSet()
return grades.listGrades().filter { it.labId in labIds }
}
@GetMapping("/grades/publication") @GetMapping("/grades/publication")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun publicationState( fun publicationState(
@RequestParam groupId: java.util.UUID, @RequestParam groupId: UUID,
@RequestParam labId: java.util.UUID, @RequestParam labId: UUID,
): PublicationState = publications.state(groupId, labId) @AuthenticationPrincipal jwt: Jwt,
): PublicationState {
requirePublicationScope(groupId, labId, jwt)
return publications.state(groupId, labId)
}
@PostMapping("/grades/publication") @PostMapping("/grades/publication")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun setPublication( fun setPublication(
@RequestBody body: PublicationSetRequest, @RequestBody body: PublicationSetRequest,
@AuthenticationPrincipal jwt: Jwt,
) { ) {
val groupId = UUID.fromString(body.groupId)
val labId = UUID.fromString(body.labId)
requirePublicationScope(groupId, labId, jwt)
publications.setStudents( publications.setStudents(
java.util.UUID.fromString(body.groupId), groupId,
java.util.UUID.fromString(body.labId), labId,
body.studentIds.map { java.util.UUID.fromString(it) }, body.studentIds.map { UUID.fromString(it) },
)
audit.record(
actorAccountId = jwt.subject,
action = "GRADE_PUBLISH",
entityType = "GRADE_PUBLICATION",
entityId = "$groupId|$labId",
details = "students=${body.studentIds.size};group=$groupId",
) )
} }
@GetMapping("/me/grades") @GetMapping("/me/grades")
fun myGrades( fun myGrades(
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
@RequestParam(required = false) groupId: java.util.UUID?, @RequestParam(required = false) groupId: UUID?,
): List<GradeResponse> { ): List<GradeResponse> {
val all = grades.listGrades().filter { it.studentId == jwt.subject } val all = grades.listGrades().filter { it.studentId == jwt.subject }
if (groupId == null) return emptyList() if (groupId == null) return emptyList()
val studentId = java.util.UUID.fromString(jwt.subject) val studentId = UUID.fromString(jwt.subject)
return all.filter { publications.isPublished(groupId, java.util.UUID.fromString(it.labId), studentId) } return all.filter { publications.isPublished(groupId, UUID.fromString(it.labId), studentId) }
} }
@GetMapping("/comment-templates") @GetMapping("/comment-templates")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun suggestComments( fun suggestComments(
@RequestParam q: String, @RequestParam q: String,
): List<CommentSuggestion> = grades.suggestComments(q) ): List<CommentSuggestion> = grades.suggestComments(q)
@@ -130,4 +204,29 @@ class ProgressController(
) { ) {
grades.deleteCommentTemplate(text) grades.deleteCommentTemplate(text)
} }
private fun requestedLabIds(body: DistributeRequest): List<String> = body.labIds.orEmpty().ifEmpty { listOfNotNull(body.labId) }
/** Преподаватель пишет только по своим лабораторным и только студентам групп своего курса. */
private fun requireStudentScope(
studentId: String,
labId: String,
jwt: Jwt,
) {
if (scope.isAdmin(jwt)) return
val token = jwt.tokenValue
val courseId = scope.courseIdOf(UUID.fromString(labId), token)
scope.requireStudentInCourse(UUID.fromString(studentId), courseId, token)
}
private fun requirePublicationScope(
groupId: UUID,
labId: UUID,
jwt: Jwt,
) {
if (scope.isAdmin(jwt)) return
val token = jwt.tokenValue
val courseId = scope.courseIdOf(labId, token)
scope.requireGroupInCourse(groupId, courseId, token)
}
} }

View File

@@ -3,6 +3,7 @@ package ru.ysu.lms.progress
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.BadRequestException import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.ForbiddenException
import ru.ysu.lms.progress.domain.StudentLabLevel import ru.ysu.lms.progress.domain.StudentLabLevel
import ru.ysu.lms.progress.domain.StudentLabLevelKey import ru.ysu.lms.progress.domain.StudentLabLevelKey
import ru.ysu.lms.progress.domain.VariantAssignment import ru.ysu.lms.progress.domain.VariantAssignment
@@ -15,7 +16,6 @@ class ProgressService(
private val levels: StudentLabLevelRepository, private val levels: StudentLabLevelRepository,
private val assignments: VariantAssignmentRepository, private val assignments: VariantAssignmentRepository,
private val academic: AcademicClient, private val academic: AcademicClient,
private val identity: IdentityClient,
) { ) {
@Transactional @Transactional
fun setLevel( fun setLevel(
@@ -92,15 +92,17 @@ class ProgressService(
token: String, token: String,
): DistributeResult { ): DistributeResult {
val labIds = resolveLabIds(request) val labIds = resolveLabIds(request)
// Каждую лабораторную читаем один раз: academic сам отсекает чужие (403).
val labs = labIds.map { it to academic.getLab(it, token) }
val courseId = courseIdOf(labs.map { it.second })
val force = request.force == true val force = request.force == true
val allStudents = collectStudentIds(request, token) val allStudents = collectStudentIds(request, courseId, token)
var assignedTotal = 0 var assignedTotal = 0
var totalStudents = 0 var totalStudents = 0
var lastLabId: String? = null var lastLabId: String? = null
var lastLabTitle: String? = null var lastLabTitle: String? = null
for (labIdStr in labIds) { for ((labIdStr, lab) in labs) {
val labId = UUID.fromString(labIdStr) val labId = UUID.fromString(labIdStr)
val lab = academic.getLab(labIdStr, token)
if (force) clearAutoAssignments(labId, allStudents) if (force) clearAutoAssignments(labId, allStudents)
val kept = keepExisting(labId, allStudents) val kept = keepExisting(labId, allStudents)
assignedTotal += kept.size assignedTotal += kept.size
@@ -118,6 +120,16 @@ class ProgressService(
) )
} }
private fun courseIdOf(labs: List<LabInfo>): UUID {
val courseId =
labs.firstOrNull()?.courseId?.takeIf { it.isNotBlank() }
?: throw ForbiddenException("Лабораторная недоступна")
if (labs.any { it.courseId != courseId }) {
throw BadRequestException("Лабораторные относятся к разным курсам")
}
return UUID.fromString(courseId)
}
private fun resolveLabIds(request: DistributeRequest): List<String> { private fun resolveLabIds(request: DistributeRequest): List<String> {
val labIds = val labIds =
request.labIds request.labIds
@@ -130,8 +142,10 @@ class ProgressService(
return labIds return labIds
} }
/** Студенты активных групп курса: состав берётся из academic, а не из всех групп identity. */
private fun collectStudentIds( private fun collectStudentIds(
request: DistributeRequest, request: DistributeRequest,
courseId: UUID,
token: String, token: String,
): List<UUID> { ): List<UUID> {
val groupFilter = val groupFilter =
@@ -139,11 +153,16 @@ class ProgressService(
.orEmpty() .orEmpty()
.filter { it.isNotBlank() } .filter { it.isNotBlank() }
.toSet() .toSet()
val activeGroups = academic.getCourse(courseId.toString(), token).groups.filter { it.removedAt == null }
val activeGroupIds = activeGroups.map { it.groupId }
if (groupFilter.isNotEmpty() && !activeGroupIds.containsAll(groupFilter)) {
throw ForbiddenException("Группа не привязана к курсу")
}
val selected = if (groupFilter.isEmpty()) activeGroupIds else activeGroupIds.filter { it in groupFilter }
val studentIds = mutableListOf<UUID>() val studentIds = mutableListOf<UUID>()
for (g in identity.listGroups(token)) { for (groupId in selected) {
val gid = g["id"] as String val roster = academic.getCourseGroupStudents(courseId.toString(), groupId, token)
if (groupFilter.isNotEmpty() && gid !in groupFilter) continue for (student in roster.students) studentIds.add(UUID.fromString(student.id))
for (s in identity.getGroup(gid, token).students) studentIds.add(UUID.fromString(s.id))
} }
return studentIds.distinct() return studentIds.distinct()
} }

View File

@@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RequestPart import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import ru.ysu.lms.progress.audit.AuditClient
import java.net.URLEncoder import java.net.URLEncoder
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.util.UUID import java.util.UUID
@@ -22,6 +23,7 @@ import java.util.UUID
@RequestMapping("/api/v1") @RequestMapping("/api/v1")
class SubmissionController( class SubmissionController(
private val submissions: SubmissionService, private val submissions: SubmissionService,
private val audit: AuditClient,
) { ) {
@PostMapping("/me/submissions/{labId}") @PostMapping("/me/submissions/{labId}")
fun submit( fun submit(
@@ -47,6 +49,14 @@ class SubmissionController(
): ResponseEntity<ByteArray> { ): ResponseEntity<ByteArray> {
val sid = UUID.fromString(jwt.subject) val sid = UUID.fromString(jwt.subject)
val (bytes, name) = submissions.download(sid, id, sid, isAdmin = false) val (bytes, name) = submissions.download(sid, id, sid, isAdmin = false)
audit.record(
actorAccountId = sid.toString(),
action = "SUBMISSION_DOWNLOAD_SELF",
entityType = "SUBMISSION",
entityId = id.toString(),
studentId = sid.toString(),
details = "self",
)
return attachment(bytes, name) return attachment(bytes, name)
} }
@@ -64,6 +74,13 @@ class SubmissionController(
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<ByteArray> { ): ResponseEntity<ByteArray> {
val (bytes, name) = submissions.downloadForStaff(id, jwt.tokenValue) val (bytes, name) = submissions.downloadForStaff(id, jwt.tokenValue)
audit.record(
actorAccountId = jwt.subject,
action = "SUBMISSION_DOWNLOAD",
entityType = "SUBMISSION",
entityId = id.toString(),
details = "staff",
)
return attachment(bytes, name) return attachment(bytes, name)
} }

View File

@@ -0,0 +1,89 @@
package ru.ysu.lms.progress.audit
import com.fasterxml.jackson.annotation.JsonInclude
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.MediaType
import org.springframework.http.client.JdkClientHttpRequestFactory
import org.springframework.stereotype.Service
import org.springframework.web.client.RestClient
import java.net.http.HttpClient
import java.time.Duration
/**
* Best-effort запись действий в аудит identity-service.
* Ошибка аудита не должна ломать бизнес-операцию: всё логируется и проглатывается.
*/
@Service
class AuditClient(
@Value("\${lms.identity.base-url}") baseUrl: String,
@Value("\${lms.internal.token:dev-internal-token}") private val internalToken: String,
) {
private val client = restClient(baseUrl)
fun record(
actorAccountId: String?,
action: String,
entityType: String,
entityId: String?,
courseId: String? = null,
studentId: String? = null,
details: String = "",
) {
runCatching {
client
.post()
.uri(AUDIT_PATH)
.header(INTERNAL_TOKEN_HEADER, internalToken)
.contentType(MediaType.APPLICATION_JSON)
.body(
AuditRequest(
actorAccountId = actorAccountId,
action = action,
entityType = entityType,
entityId = entityId,
courseId = courseId,
studentId = studentId,
details = details,
),
).retrieve()
.toBodilessEntity()
}.onFailure { ex ->
log.warn("Не удалось записать аудит {}/{}: {}", action, entityType, ex.message)
}
}
@JsonInclude(JsonInclude.Include.NON_NULL)
private data class AuditRequest(
val actorAccountId: String?,
val action: String,
val entityType: String,
val entityId: String?,
val courseId: String?,
val studentId: String?,
val details: String,
)
private companion object {
val log = LoggerFactory.getLogger(AuditClient::class.java)
}
}
private const val AUDIT_PATH = "/api/v1/internal/audit"
private const val INTERNAL_TOKEN_HEADER = "X-Internal-Token"
private const val CONNECT_TIMEOUT_SECONDS = 3L
private const val READ_TIMEOUT_SECONDS = 5L
/** Отдельный клиент с коротким read-timeout: аудит не должен задерживать ответ пользователю. */
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

@@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import ru.ysu.lms.progress.audit.AuditClient
import java.net.URLEncoder import java.net.URLEncoder
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.util.UUID import java.util.UUID
@@ -18,6 +19,7 @@ import java.util.UUID
@RequestMapping("/api/v1/courses") @RequestMapping("/api/v1/courses")
class ProfileController( class ProfileController(
private val profiles: StudentProfileService, private val profiles: StudentProfileService,
private val audit: AuditClient,
) { ) {
@GetMapping("/{courseId}/students/{studentId}/profile") @GetMapping("/{courseId}/students/{studentId}/profile")
@PreAuthorize("hasAnyRole('ADMIN','TEACHER','STUDENT')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER','STUDENT')")
@@ -25,7 +27,8 @@ class ProfileController(
@PathVariable courseId: UUID, @PathVariable courseId: UUID,
@PathVariable studentId: UUID, @PathVariable studentId: UUID,
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
): StudentCourseProfile = ): StudentCourseProfile {
val profile =
profiles.profile( profiles.profile(
courseId = courseId, courseId = courseId,
studentId = studentId, studentId = studentId,
@@ -33,6 +36,20 @@ class ProfileController(
callerRole = role(jwt), callerRole = role(jwt),
token = jwt.tokenValue, token = jwt.tokenValue,
) )
// Свой профиль студент открывает часто; аудит нужен для Staff-просмотров,
// чтобы не нагружать identity на горячем пути и не засорять журнал.
if (role(jwt) != ROLE_STUDENT) {
audit.record(
actorAccountId = jwt.subject,
action = "PROFILE_VIEW",
entityType = "STUDENT",
entityId = studentId.toString(),
courseId = courseId.toString(),
studentId = studentId.toString(),
)
}
return profile
}
@GetMapping("/{courseId}/submissions/{submissionId}/download") @GetMapping("/{courseId}/submissions/{submissionId}/download")
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
@@ -41,7 +58,7 @@ class ProfileController(
@PathVariable submissionId: UUID, @PathVariable submissionId: UUID,
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<ByteArray> { ): ResponseEntity<ByteArray> {
val (bytes, name) = val download =
profiles.downloadSubmission( profiles.downloadSubmission(
courseId = courseId, courseId = courseId,
submissionId = submissionId, submissionId = submissionId,
@@ -49,11 +66,23 @@ class ProfileController(
callerRole = role(jwt), callerRole = role(jwt),
token = jwt.tokenValue, token = jwt.tokenValue,
) )
return attachment(bytes, name) audit.record(
actorAccountId = jwt.subject,
action = "SUBMISSION_DOWNLOAD",
entityType = "SUBMISSION",
entityId = submissionId.toString(),
courseId = courseId.toString(),
studentId = download.studentId.toString(),
)
return attachment(download.bytes, download.filename)
} }
private fun role(jwt: Jwt): String = jwt.getClaimAsString("role") ?: "" private fun role(jwt: Jwt): String = jwt.getClaimAsString("role") ?: ""
private companion object {
const val ROLE_STUDENT = "STUDENT"
}
private fun attachment( private fun attachment(
bytes: ByteArray, bytes: ByteArray,
name: String, name: String,

View File

@@ -1,5 +1,7 @@
package ru.ysu.lms.progress.profile package ru.ysu.lms.progress.profile
import java.util.UUID
/** Краткие данные курса в профиле студента. */ /** Краткие данные курса в профиле студента. */
data class ProfileCourse( data class ProfileCourse(
val id: String, val id: String,
@@ -87,6 +89,14 @@ data class ProfileLab(
val submissions: List<ProfileSubmission>, val submissions: List<ProfileSubmission>,
) )
/** Скачанная сдача вместе с владельцем: нужно для аудита доступа преподавателя. */
data class SubmissionDownload(
val bytes: ByteArray,
val filename: String,
val studentId: UUID,
val labId: UUID,
)
data class StudentCourseProfile( data class StudentCourseProfile(
val course: ProfileCourse, val course: ProfileCourse,
val student: ProfileStudent, val student: ProfileStudent,

View File

@@ -118,7 +118,7 @@ class StudentProfileService(
callerId: UUID, callerId: UUID,
callerRole: String, callerRole: String,
token: String, token: String,
): Pair<ByteArray, String> { ): SubmissionDownload {
val role = normalizeRole(callerRole) val role = normalizeRole(callerRole)
if (role != ROLE_ADMIN && role != ROLE_TEACHER) { if (role != ROLE_ADMIN && role != ROLE_TEACHER) {
forbidden("Нет доступа к этой сдаче") forbidden("Нет доступа к этой сдаче")
@@ -141,7 +141,13 @@ class StudentProfileService(
forbidden("Студент не состоит в группе курса") forbidden("Студент не состоит в группе курса")
} }
} }
return submissionService.download(callerId, submissionId, null, isAdmin = true) val (bytes, filename) = submissionService.download(callerId, submissionId, null, isAdmin = true)
return SubmissionDownload(
bytes = bytes,
filename = filename,
studentId = submission.studentId,
labId = submission.labId,
)
} }
private fun requireProfileAccess( private fun requireProfileAccess(

View File

@@ -0,0 +1,84 @@
package ru.ysu.lms.progress
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Test
import org.springframework.security.oauth2.jwt.Jwt
import ru.ysu.lms.progress.audit.AuditClient
import java.math.BigDecimal
import java.util.UUID
/** Контроллер пишет аудит только после успешной операции и берёт актора из JWT. */
class ProgressControllerAuditTest {
private val service = mockk<ProgressService>(relaxed = true)
private val grades = mockk<GradeService>(relaxed = true)
private val publications = mockk<GradePublicationService>(relaxed = true)
private val scope = mockk<CourseScopeService>(relaxed = true)
private val audit = mockk<AuditClient>(relaxed = true)
private val controller = ProgressController(service, grades, publications, scope, audit)
private val actorId = UUID.randomUUID()
private val studentId = UUID.randomUUID()
private val labId = UUID.randomUUID()
private val groupId = UUID.randomUUID()
private val jwt = jwt(actorId, ROLE_ADMIN)
@Test
fun `grade write records audit entry for actor from jwt`() {
every { scope.isAdmin(jwt) } returns true
every { grades.setGrade(any(), actorId) } returns
GradeResponse(studentId.toString(), labId.toString(), BigDecimal("4"))
controller.setGrade(GradeSetRequest(studentId.toString(), labId.toString(), BigDecimal("4")), jwt)
verify {
audit.record(
actorId.toString(),
"GRADE_SET",
"GRADE",
"$studentId|$labId",
null,
studentId.toString(),
"score=4",
)
}
}
@Test
fun `publication records student count`() {
every { scope.isAdmin(jwt) } returns true
controller.setPublication(
PublicationSetRequest(groupId.toString(), labId.toString(), listOf(studentId.toString())),
jwt,
)
verify {
audit.record(
actorId.toString(),
"GRADE_PUBLISH",
"GRADE_PUBLICATION",
"$groupId|$labId",
null,
null,
"students=1;group=$groupId",
)
}
}
private fun jwt(
subject: UUID,
role: String,
): Jwt =
Jwt
.withTokenValue("token")
.header("alg", "none")
.subject(subject.toString())
.claim("role", role)
.build()
private companion object {
const val ROLE_ADMIN = "ADMIN"
}
}

View File

@@ -36,24 +36,20 @@ class ProgressDistributionIntegrationTest {
@MockitoBean @MockitoBean
private lateinit var academic: AcademicClient private lateinit var academic: AcademicClient
@MockitoBean
private lateinit var identity: IdentityClient
@Test @Test
fun `distributes variants to every student of the selected group`() { fun `distributes variants to every student of the selected group`() {
val courseId = UUID.randomUUID()
val labId = UUID.randomUUID() val labId = UUID.randomUUID()
val variantA = UUID.randomUUID().toString() val variantA = UUID.randomUUID().toString()
val variantB = UUID.randomUUID().toString() val variantB = UUID.randomUUID().toString()
val student1 = UUID.randomUUID().toString() val student1 = UUID.randomUUID().toString()
val student2 = UUID.randomUUID().toString() val student2 = UUID.randomUUID().toString()
`when`(identity.listGroups("t")).thenReturn(listOf(mapOf("id" to "g1"))) stubCourse(courseId, "g1", student1, student2)
`when`(identity.getGroup("g1", "t")).thenReturn(
GroupInfo(id = "g1", name = "Группа", students = listOf(StudentInfo(student1), StudentInfo(student2))),
)
`when`(academic.getLab(labId.toString(), "t")).thenReturn( `when`(academic.getLab(labId.toString(), "t")).thenReturn(
LabInfo( LabInfo(
id = labId.toString(), id = labId.toString(),
courseId = courseId.toString(),
levels = levels =
listOf( listOf(
LevelInfo( LevelInfo(
@@ -85,18 +81,17 @@ class ProgressDistributionIntegrationTest {
@Test @Test
fun `group lab data aggregates per-lab data in one call`() { fun `group lab data aggregates per-lab data in one call`() {
val courseId = UUID.randomUUID()
val labId = UUID.randomUUID() val labId = UUID.randomUUID()
val student1 = UUID.randomUUID().toString() val student1 = UUID.randomUUID().toString()
val student2 = UUID.randomUUID().toString() val student2 = UUID.randomUUID().toString()
val variantA = UUID.randomUUID().toString() val variantA = UUID.randomUUID().toString()
`when`(identity.listGroups("t")).thenReturn(listOf(mapOf("id" to "g1"))) stubCourse(courseId, "g1", student1, student2)
`when`(identity.getGroup("g1", "t")).thenReturn(
GroupInfo(id = "g1", name = "Группа", students = listOf(StudentInfo(student1), StudentInfo(student2))),
)
`when`(academic.getLab(labId.toString(), "t")).thenReturn( `when`(academic.getLab(labId.toString(), "t")).thenReturn(
LabInfo( LabInfo(
id = labId.toString(), id = labId.toString(),
courseId = courseId.toString(),
levels = levels =
listOf( listOf(
LevelInfo( LevelInfo(
@@ -121,4 +116,27 @@ class ProgressDistributionIntegrationTest {
assertThat(lab.submissions).isEmpty() assertThat(lab.submissions).isEmpty()
assertThat(lab.publication.all).isFalse() assertThat(lab.publication.all).isFalse()
} }
/** Курс с активной группой и её состав: distribute больше не ходит в identity.listGroups/getGroup. */
private fun stubCourse(
courseId: UUID,
groupId: String,
vararg studentIds: String,
) {
`when`(academic.getCourse(courseId.toString(), "t")).thenReturn(
CourseInfo(
id = courseId.toString(),
courseNo = 42,
title = "Алгоритмы",
groups = listOf(CourseGroupInfo(groupId = groupId)),
),
)
`when`(academic.getCourseGroupStudents(courseId.toString(), groupId, "t")).thenReturn(
CourseGroupRosterInfo(
groupId = groupId,
groupName = "Группа",
students = studentIds.map { CourseGroupStudentInfo(id = it, fullName = "Студент") },
),
)
}
} }

View File

@@ -126,10 +126,11 @@ class StudentProfileIntegrationTest {
val commentAuthor = lab.comments.single().author val commentAuthor = lab.comments.single().author
assertThat(commentAuthor?.login).isEqualTo("teacher_login") assertThat(commentAuthor?.login).isEqualTo("teacher_login")
val (bytes, name) = val download =
profiles.downloadSubmission(courseId, second.id, UUID.randomUUID(), "TEACHER", TEACHER_TOKEN) profiles.downloadSubmission(courseId, second.id, UUID.randomUUID(), "TEACHER", TEACHER_TOKEN)
assertThat(name).endsWith(".zip") assertThat(download.filename).endsWith(".zip")
assertThat(bytes).isNotEmpty() assertThat(download.bytes).isNotEmpty()
assertThat(download.studentId).isEqualTo(studentId)
assertThat(first.id).isNotEqualTo(second.id) assertThat(first.id).isNotEqualTo(second.id)
} }
@@ -207,9 +208,9 @@ class StudentProfileIntegrationTest {
val adminProfile = profiles.profile(courseId, studentId, UUID.randomUUID(), "ADMIN", ADMIN_TOKEN) val adminProfile = profiles.profile(courseId, studentId, UUID.randomUUID(), "ADMIN", ADMIN_TOKEN)
assertThat(adminProfile.student.groupId).isEqualTo(otherGroup.toString()) assertThat(adminProfile.student.groupId).isEqualTo(otherGroup.toString())
val (bytes, _) = val download =
profiles.downloadSubmission(courseId, submission.id, UUID.randomUUID(), "ADMIN", ADMIN_TOKEN) profiles.downloadSubmission(courseId, submission.id, UUID.randomUUID(), "ADMIN", ADMIN_TOKEN)
assertThat(bytes).isNotEmpty() assertThat(download.bytes).isNotEmpty()
} }
@Test @Test
@@ -229,10 +230,10 @@ class StudentProfileIntegrationTest {
profiles.downloadSubmission(courseId, foreign.id, UUID.randomUUID(), "TEACHER", TEACHER_TOKEN) profiles.downloadSubmission(courseId, foreign.id, UUID.randomUUID(), "TEACHER", TEACHER_TOKEN)
}.isInstanceOf(ForbiddenException::class.java) }.isInstanceOf(ForbiddenException::class.java)
val (bytes, name) = val download =
profiles.downloadSubmission(courseId, own.id, UUID.randomUUID(), "TEACHER", TEACHER_TOKEN) profiles.downloadSubmission(courseId, own.id, UUID.randomUUID(), "TEACHER", TEACHER_TOKEN)
assertThat(bytes).isNotEmpty() assertThat(download.bytes).isNotEmpty()
assertThat(name).endsWith(".zip") assertThat(download.filename).endsWith(".zip")
} }
private fun stubCourse( private fun stubCourse(

View File

@@ -4,6 +4,7 @@ import org.assertj.core.api.Assertions.assertThat
import org.hamcrest.Matchers.containsString import org.hamcrest.Matchers.containsString
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.anyString import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when` import org.mockito.Mockito.`when`
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
@@ -30,6 +31,7 @@ import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.postgresql.PostgreSQLContainer import org.testcontainers.postgresql.PostgreSQLContainer
import ru.ysu.lms.common.ForbiddenException import ru.ysu.lms.common.ForbiddenException
import ru.ysu.lms.progress.audit.AuditClient
import ru.ysu.lms.progress.domain.Submission import ru.ysu.lms.progress.domain.Submission
import ru.ysu.lms.progress.repo.StudentLabLevelRepository import ru.ysu.lms.progress.repo.StudentLabLevelRepository
import ru.ysu.lms.progress.repo.SubmissionRepository import ru.ysu.lms.progress.repo.SubmissionRepository
@@ -80,8 +82,17 @@ class TeacherLabPageAccessIntegrationTest {
@MockitoBean @MockitoBean
private lateinit var academic: AcademicClient private lateinit var academic: AcademicClient
@MockitoBean
private lateinit var identity: IdentityClient
@MockitoBean
private lateinit var audit: AuditClient
private val ownLab = UUID.randomUUID() private val ownLab = UUID.randomUUID()
private val teacherId = UUID.randomUUID()
private val foreignLab = UUID.randomUUID() private val foreignLab = UUID.randomUUID()
private val courseId = UUID.randomUUID()
private val groupId = UUID.randomUUID()
private val levelId = UUID.randomUUID() private val levelId = UUID.randomUUID()
private val variantId = UUID.randomUUID() private val variantId = UUID.randomUUID()
@@ -96,6 +107,7 @@ class TeacherLabPageAccessIntegrationTest {
LabInfo( LabInfo(
id = id.toString(), id = id.toString(),
title = "Лабораторная", title = "Лабораторная",
courseId = courseId.toString(),
levels = levels =
listOf( listOf(
LevelInfo( LevelInfo(
@@ -119,15 +131,37 @@ class TeacherLabPageAccessIntegrationTest {
.thenThrow(ForbiddenException("Лабораторная недоступна")) .thenThrow(ForbiddenException("Лабораторная недоступна"))
} }
private fun teacher() = jwtFor("TEACHER") /** Преподаватель пишет только студентам активной группы своего курса. */
private fun mockStudentInCourse(studentId: UUID) {
`when`(identity.getStudentContext(studentId.toString())).thenReturn(
StudentContext(
id = studentId.toString(),
fullName = "Иванов Иван",
status = "active",
groupId = groupId.toString(),
),
)
`when`(academic.getCourse(courseId.toString(), "token")).thenReturn(
CourseInfo(
id = courseId.toString(),
courseNo = 42,
title = "Алгоритмы",
groups = listOf(CourseGroupInfo(groupId = groupId.toString())),
),
)
}
private fun teacher() = jwtFor("TEACHER", teacherId)
private fun admin() = jwtFor("ADMIN") private fun admin() = jwtFor("ADMIN")
private fun jwtFor(role: String) = private fun jwtFor(
jwt() role: String,
subject: UUID = UUID.randomUUID(),
) = jwt()
.jwt { .jwt {
it it
.subject(UUID.randomUUID().toString()) .subject(subject.toString())
.claim("role", role) .claim("role", role)
}.authorities(SimpleGrantedAuthority("ROLE_$role")) }.authorities(SimpleGrantedAuthority("ROLE_$role"))
@@ -198,6 +232,7 @@ class TeacherLabPageAccessIntegrationTest {
fun `teacher sets level and assigns variant of own lab`() { fun `teacher sets level and assigns variant of own lab`() {
mockOwnLab() mockOwnLab()
val studentId = UUID.randomUUID() val studentId = UUID.randomUUID()
mockStudentInCourse(studentId)
mockMvc.perform(postLevel(ownLab, "TEACHER", studentId)).andExpect(status().isOk) mockMvc.perform(postLevel(ownLab, "TEACHER", studentId)).andExpect(status().isOk)
@@ -223,6 +258,16 @@ class TeacherLabPageAccessIntegrationTest {
).andExpect(status().isOk) ).andExpect(status().isOk)
.andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, containsString("attachment"))) .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, containsString("attachment")))
.andExpect(content().contentType(MediaType.APPLICATION_OCTET_STREAM)) .andExpect(content().contentType(MediaType.APPLICATION_OCTET_STREAM))
verify(audit).record(
teacherId.toString(),
"SUBMISSION_DOWNLOAD",
"SUBMISSION",
submission.id.toString(),
null,
null,
"staff",
)
} }
@Test @Test

View File

@@ -0,0 +1,478 @@
package ru.ysu.lms.progress
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
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.context.bean.override.mockito.MockitoBean
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
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.common.ForbiddenException
import ru.ysu.lms.progress.audit.AuditClient
import ru.ysu.lms.progress.repo.VariantAssignmentRepository
import java.math.BigDecimal
import java.util.UUID
/**
* Запись преподавателем только в своём курсе: оценки, комментарии, публикации,
* защиты, распределение вариантов и данные группы. Чужой курс — 403, студент — 403.
* Academic/identity подменены так, чтобы вести себя как реальные сервисы.
*/
@Testcontainers
@SpringBootTest
@Transactional
class TeacherWriteAccessIntegrationTest {
companion object {
@Container
@ServiceConnection
@JvmStatic
val postgres = PostgreSQLContainer("postgres:18")
}
@Autowired
private lateinit var context: WebApplicationContext
@Autowired
private lateinit var grades: GradeService
@Autowired
private lateinit var assignments: VariantAssignmentRepository
@MockitoBean
private lateinit var academic: AcademicClient
@MockitoBean
private lateinit var identity: IdentityClient
@MockitoBean
private lateinit var audit: AuditClient
private val teacherId = UUID.randomUUID()
private val courseId = UUID.randomUUID()
private val otherCourseId = UUID.randomUUID()
private val groupId = UUID.randomUUID()
private val otherGroupId = UUID.randomUUID()
private val ownLab = UUID.randomUUID()
private val foreignLab = UUID.randomUUID()
private val otherCourseLab = UUID.randomUUID()
private val studentId = UUID.randomUUID()
private val otherStudentId = UUID.randomUUID()
private val levelId = UUID.randomUUID()
private val variantId = UUID.randomUUID()
private val mockMvc: MockMvc by lazy {
MockMvcBuilders
.webAppContextSetup(context)
.apply<DefaultMockMvcBuilder>(springSecurity())
.build()
}
private fun teacher() = jwtFor("TEACHER", teacherId)
private fun admin() = jwtFor("ADMIN")
private fun student() = jwtFor("STUDENT", studentId)
private fun jwtFor(
role: String,
subject: UUID = UUID.randomUUID(),
) = jwt()
.jwt {
it
.subject(subject.toString())
.claim("role", role)
}.authorities(SimpleGrantedAuthority("ROLE_$role"))
private fun course(
id: UUID,
vararg groupIds: UUID,
) = CourseInfo(
id = id.toString(),
courseNo = 42,
title = "Алгоритмы",
groups = groupIds.map { CourseGroupInfo(groupId = it.toString()) },
)
private fun roster(
groupId: UUID,
vararg studentIds: UUID,
) = CourseGroupRosterInfo(
groupId = groupId.toString(),
groupName = "Группа",
students = studentIds.map { CourseGroupStudentInfo(id = it.toString(), fullName = "Студент", status = "active") },
)
private fun studentContext(
studentId: UUID,
groupId: UUID?,
) = StudentContext(
id = studentId.toString(),
login = "student",
fullName = "Иванов Иван",
studentNo = "2024-001",
status = "active",
groupId = groupId?.toString(),
groupName = "Группа",
)
private fun labInfo(
labId: UUID,
courseId: UUID,
) = LabInfo(
id = labId.toString(),
number = 1,
title = "Лабораторная",
courseId = courseId.toString(),
levels =
listOf(
LevelInfo(
id = levelId.toString(),
code = "basic",
title = "Основная",
variants = listOf(VariantInfo(id = variantId.toString(), number = "1")),
),
),
)
private fun stubOwnCourse() {
`when`(academic.getCourse(courseId.toString(), "token")).thenReturn(course(courseId, groupId))
`when`(academic.getCourseLabs(courseId.toString(), "token"))
.thenReturn(listOf(labInfo(ownLab, courseId)))
`when`(academic.getCourseGroupStudents(courseId.toString(), groupId.toString(), "token"))
.thenReturn(roster(groupId, studentId))
}
private fun stubOwnLab() {
`when`(academic.getLab(ownLab.toString(), "token")).thenReturn(labInfo(ownLab, courseId))
`when`(academic.getLabSummary(ownLab.toString(), "token")).thenReturn(labInfo(ownLab, courseId))
}
private fun stubForeignLab() {
`when`(academic.getLab(foreignLab.toString(), "token"))
.thenThrow(ForbiddenException("Лабораторная недоступна"))
`when`(academic.getLabSummary(foreignLab.toString(), "token"))
.thenThrow(ForbiddenException("Лабораторная недоступна"))
}
private fun stubOwnStudent() {
`when`(identity.getStudentContext(studentId.toString())).thenReturn(studentContext(studentId, groupId))
}
private fun stubOtherCourseStudent() {
`when`(identity.getStudentContext(otherStudentId.toString()))
.thenReturn(studentContext(otherStudentId, otherGroupId))
}
private fun gradeBody(
studentId: UUID,
labId: UUID,
score: String = "4",
) = """
{"studentId":"$studentId","labId":"$labId","score":$score,
"comments":[{"text":"Хорошо","tone":"positive"}],"generalComment":"Зачтено"}
""".trimIndent()
private fun publicationBody(
groupId: UUID,
labId: UUID,
studentIds: List<UUID>,
) = """
{"groupId":"$groupId","labId":"$labId","studentIds":[${studentIds.joinToString(",") { "\"$it\"" }}]}
""".trimIndent()
@Test
fun `teacher writes grade for student of own course`() {
stubOwnCourse()
stubOwnLab()
stubOwnStudent()
mockMvc
.perform(
post("/api/v1/grades")
.contentType(MediaType.APPLICATION_JSON)
.content(gradeBody(studentId, ownLab))
.with(teacher()),
).andExpect(status().isOk)
.andExpect(jsonPath("$.score").value(4))
verify(audit).record(
teacherId.toString(),
"GRADE_SET",
"GRADE",
"$studentId|$ownLab",
null,
studentId.toString(),
"score=4",
)
}
@Test
fun `teacher is forbidden to grade foreign lab`() {
stubForeignLab()
mockMvc
.perform(
post("/api/v1/grades")
.contentType(MediaType.APPLICATION_JSON)
.content(gradeBody(studentId, foreignLab))
.with(teacher()),
).andExpect(status().isForbidden)
}
@Test
fun `teacher is forbidden to grade student from another course`() {
stubOwnCourse()
stubOwnLab()
stubOtherCourseStudent()
mockMvc
.perform(
post("/api/v1/grades")
.contentType(MediaType.APPLICATION_JSON)
.content(gradeBody(otherStudentId, ownLab))
.with(teacher()),
).andExpect(status().isForbidden)
}
@Test
fun `teacher lists grades only for own course and must pass courseId`() {
stubOwnCourse()
grades.setGrade(GradeSetRequest(studentId.toString(), ownLab.toString(), BigDecimal("4")))
grades.setGrade(GradeSetRequest(studentId.toString(), foreignLab.toString(), BigDecimal("5")))
mockMvc
.perform(get("/api/v1/grades").param("courseId", courseId.toString()).with(teacher()))
.andExpect(status().isOk)
.andExpect(jsonPath("$[0].labId").value(ownLab.toString()))
.andExpect(jsonPath("$.length()").value(1))
mockMvc
.perform(get("/api/v1/grades").with(teacher()))
.andExpect(status().isBadRequest)
}
@Test
fun `teacher manages publication of own course and is forbidden for foreign lab`() {
stubOwnCourse()
stubOwnLab()
stubForeignLab()
mockMvc
.perform(
get("/api/v1/grades/publication")
.param("groupId", groupId.toString())
.param("labId", ownLab.toString())
.with(teacher()),
).andExpect(status().isOk)
mockMvc
.perform(
post("/api/v1/grades/publication")
.contentType(MediaType.APPLICATION_JSON)
.content(publicationBody(groupId, ownLab, listOf(studentId)))
.with(teacher()),
).andExpect(status().isOk)
mockMvc
.perform(
get("/api/v1/grades/publication")
.param("groupId", groupId.toString())
.param("labId", foreignLab.toString())
.with(teacher()),
).andExpect(status().isForbidden)
}
@Test
fun `teacher assigns and lists defenses of own lab but not foreign`() {
stubOwnCourse()
stubOwnLab()
stubOwnStudent()
stubForeignLab()
mockMvc
.perform(
post("/api/v1/defenses/assign")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"studentId":"$studentId","labId":"$ownLab","assignmentMd":"Задание"}""")
.with(teacher()),
).andExpect(status().isOk)
mockMvc
.perform(get("/api/v1/defenses").param("labId", ownLab.toString()).with(teacher()))
.andExpect(status().isOk)
mockMvc
.perform(
post("/api/v1/defenses/assign")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"studentId":"$studentId","labId":"$foreignLab","assignmentMd":"Задание"}""")
.with(teacher()),
).andExpect(status().isForbidden)
mockMvc
.perform(get("/api/v1/defenses").param("labId", foreignLab.toString()).with(teacher()))
.andExpect(status().isForbidden)
}
@Test
fun `teacher distributes variants within own course`() {
stubOwnCourse()
`when`(academic.getLab(ownLab.toString(), "token")).thenReturn(labInfo(ownLab, courseId))
mockMvc
.perform(
post("/api/v1/distribute")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"labIds":["$ownLab"],"groupIds":["$groupId"]}""")
.with(teacher()),
).andExpect(status().isOk)
.andExpect(jsonPath("$.assigned").value(1))
assertThat(assignments.findByLabId(ownLab).map { it.studentId }).containsExactly(studentId)
}
@Test
fun `teacher cannot distribute to foreign or mixed course labs`() {
stubForeignLab()
mockMvc
.perform(
post("/api/v1/distribute")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"labIds":["$foreignLab"]}""")
.with(teacher()),
).andExpect(status().isForbidden)
`when`(academic.getLab(ownLab.toString(), "token")).thenReturn(labInfo(ownLab, courseId))
`when`(academic.getLab(otherCourseLab.toString(), "token"))
.thenReturn(labInfo(otherCourseLab, otherCourseId))
mockMvc
.perform(
post("/api/v1/distribute")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"labIds":["$ownLab","$otherCourseLab"]}""")
.with(teacher()),
).andExpect(status().isBadRequest)
}
@Test
fun `teacher reads group lab data of own course only`() {
stubOwnCourse()
stubOwnLab()
mockMvc
.perform(
get("/api/v1/assignments/group/{groupId}", groupId)
.param("labId", ownLab.toString())
.with(teacher()),
).andExpect(status().isOk)
mockMvc
.perform(
get("/api/v1/assignments/group/{groupId}", otherGroupId)
.param("labId", ownLab.toString())
.with(teacher()),
).andExpect(status().isForbidden)
stubForeignLab()
mockMvc
.perform(
get("/api/v1/assignments/group/{groupId}", groupId)
.param("labId", foreignLab.toString())
.with(teacher()),
).andExpect(status().isForbidden)
}
@Test
fun `student is forbidden on write endpoints`() {
mockMvc
.perform(
post("/api/v1/grades")
.contentType(MediaType.APPLICATION_JSON)
.content(gradeBody(studentId, ownLab))
.with(student()),
).andExpect(status().isForbidden)
mockMvc
.perform(
post("/api/v1/grades/publication")
.contentType(MediaType.APPLICATION_JSON)
.content(publicationBody(groupId, ownLab, listOf(studentId)))
.with(student()),
).andExpect(status().isForbidden)
mockMvc
.perform(
post("/api/v1/distribute")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"labIds":["$ownLab"]}""")
.with(student()),
).andExpect(status().isForbidden)
mockMvc
.perform(
post("/api/v1/defenses/assign")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"studentId":"$studentId","labId":"$ownLab","assignmentMd":"Задание"}""")
.with(student()),
).andExpect(status().isForbidden)
mockMvc
.perform(get("/api/v1/defenses").param("labId", ownLab.toString()).with(student()))
.andExpect(status().isForbidden)
mockMvc
.perform(
post("/api/v1/levels")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"studentId":"$studentId","labId":"$ownLab","labLevelId":"$levelId"}""")
.with(student()),
).andExpect(status().isForbidden)
mockMvc
.perform(
get("/api/v1/assignments/group/{groupId}", groupId)
.param("labId", ownLab.toString())
.with(student()),
).andExpect(status().isForbidden)
mockMvc
.perform(
delete("/api/v1/comment-templates")
.param("text", "Хорошо")
.with(student()),
).andExpect(status().isForbidden)
}
@Test
fun `admin keeps access without course scope checks`() {
mockMvc
.perform(
post("/api/v1/grades")
.contentType(MediaType.APPLICATION_JSON)
.content(gradeBody(studentId, foreignLab))
.with(admin()),
).andExpect(status().isOk)
mockMvc
.perform(get("/api/v1/grades").with(admin()))
.andExpect(status().isOk)
}
}

View File

@@ -0,0 +1,88 @@
package ru.ysu.lms.progress.audit
import com.sun.net.httpserver.HttpServer
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatCode
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.net.InetSocketAddress
import java.util.concurrent.CopyOnWriteArrayList
/** Аудит — best-effort: тело запроса уходит в identity, любые сбои проглатываются. */
class AuditClientTest {
private lateinit var server: HttpServer
private val bodies = CopyOnWriteArrayList<String>()
private val tokens = CopyOnWriteArrayList<String>()
@BeforeEach
fun startServer() {
server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
server.createContext(AUDIT_PATH) { exchange ->
tokens += exchange.requestHeaders.getFirst(INTERNAL_TOKEN_HEADER).orEmpty()
bodies += exchange.requestBody.use { it.readBytes().toString(Charsets.UTF_8) }
exchange.sendResponseHeaders(204, -1)
exchange.close()
}
server.start()
}
@AfterEach
fun stopServer() {
server.stop(0)
}
@Test
fun `record sends actor action and entity to identity`() {
client().record(
actorAccountId = ACTOR_ID,
action = "GRADE_SET",
entityType = "GRADE",
entityId = "$STUDENT_ID|$LAB_ID",
courseId = COURSE_ID,
studentId = STUDENT_ID,
details = "score=4",
)
assertThat(tokens).containsExactly(INTERNAL_TOKEN)
assertThat(bodies).hasSize(1)
assertThat(bodies.single())
.contains("\"actorAccountId\":\"$ACTOR_ID\"")
.contains("\"action\":\"GRADE_SET\"")
.contains("\"entityType\":\"GRADE\"")
.contains("\"entityId\":\"$STUDENT_ID|$LAB_ID\"")
.contains("\"courseId\":\"$COURSE_ID\"")
.contains("\"studentId\":\"$STUDENT_ID\"")
.contains("\"details\":\"score=4\"")
}
@Test
fun `record swallows unreachable identity`() {
val client = AuditClient(baseUrl = "http://127.0.0.1:1", internalToken = INTERNAL_TOKEN)
assertThatCode {
client.record(
actorAccountId = ACTOR_ID,
action = "PROFILE_VIEW",
entityType = "STUDENT",
entityId = STUDENT_ID,
)
}.doesNotThrowAnyException()
}
private fun client(): AuditClient =
AuditClient(
baseUrl = "http://127.0.0.1:${server.address.port}",
internalToken = INTERNAL_TOKEN,
)
private companion object {
const val AUDIT_PATH = "/api/v1/internal/audit"
const val INTERNAL_TOKEN_HEADER = "X-Internal-Token"
const val INTERNAL_TOKEN = "test-internal-token"
const val ACTOR_ID = "11111111-1111-1111-1111-111111111111"
const val STUDENT_ID = "22222222-2222-2222-2222-222222222222"
const val LAB_ID = "33333333-3333-3333-3333-333333333333"
const val COURSE_ID = "44444444-4444-4444-4444-444444444444"
}
}