feat(progress): преподавательские оценки, защиты и аудит
This commit is contained in:
@@ -61,6 +61,24 @@ class AcademicClient(
|
||||
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
|
||||
}
|
||||
|
||||
/** 403 academic означает чужую группу или чужой курс. */
|
||||
private fun groupError(ex: RestClientResponseException): Exception =
|
||||
when (ex.statusCode.value()) {
|
||||
403 -> ForbiddenException("Группа недоступна")
|
||||
404 -> NotFoundException("Группа не найдена")
|
||||
else -> ex
|
||||
}
|
||||
|
||||
@Service
|
||||
class IdentityClient(
|
||||
@Value("\${lms.identity.base-url}") baseUrl: String,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package ru.ysu.lms.progress
|
||||
|
||||
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
|
||||
@@ -9,6 +11,7 @@ import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import ru.ysu.lms.progress.audit.AuditClient
|
||||
import java.util.UUID
|
||||
|
||||
data class DefenseAssignPayload(
|
||||
@@ -32,71 +35,136 @@ data class DefenseAdminDto(
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/defenses")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
class DefenseAdminController(
|
||||
private val service: DefenseService,
|
||||
private val scope: CourseScopeService,
|
||||
private val audit: AuditClient,
|
||||
) {
|
||||
@PostMapping("/assign")
|
||||
fun assign(
|
||||
@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")
|
||||
fun removeAssignment(
|
||||
@RequestParam studentId: String,
|
||||
@RequestParam labId: String,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
) {
|
||||
requireStudentScope(studentId, labId, jwt)
|
||||
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
|
||||
fun list(
|
||||
@RequestParam labId: UUID,
|
||||
): List<DefenseAdminDto> =
|
||||
service.listForLab(labId).map {
|
||||
DefenseAdminDto(
|
||||
it.id,
|
||||
it.studentId,
|
||||
it.labId,
|
||||
it.status,
|
||||
it.code,
|
||||
it.assignmentMd,
|
||||
it.startedAt,
|
||||
it.readyAt,
|
||||
it.readyReason,
|
||||
it.secondsLeft,
|
||||
)
|
||||
}
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): List<DefenseAdminDto> {
|
||||
requireLabScope(labId, jwt)
|
||||
return service.listForLab(labId).map { toAdminDto(it) }
|
||||
}
|
||||
|
||||
/** Синхронизация таймеров статусов по списку лабораторных: `labs=id1,id2,...` */
|
||||
@GetMapping("/watch")
|
||||
fun watch(
|
||||
@RequestParam labs: String,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): List<DefenseAdminDto> {
|
||||
val ids = labs.split(',').mapNotNull { runCatching { UUID.fromString(it.trim()) }.getOrNull() }
|
||||
return service.listForLabs(ids).map {
|
||||
DefenseAdminDto(
|
||||
it.id,
|
||||
it.studentId,
|
||||
it.labId,
|
||||
it.status,
|
||||
it.code,
|
||||
it.assignmentMd,
|
||||
it.startedAt,
|
||||
it.readyAt,
|
||||
it.readyReason,
|
||||
it.secondsLeft,
|
||||
)
|
||||
}
|
||||
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,
|
||||
): Map<String, String> = mapOf("code" to service.issueCode(id))
|
||||
@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,
|
||||
) = service.reset(id)
|
||||
@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(
|
||||
it.id,
|
||||
it.studentId,
|
||||
it.labId,
|
||||
it.status,
|
||||
it.code,
|
||||
it.assignmentMd,
|
||||
it.startedAt,
|
||||
it.readyAt,
|
||||
it.readyReason,
|
||||
it.secondsLeft,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -73,6 +73,13 @@ class DefenseService(
|
||||
return toDto(sessions.save(s))
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun findSession(id: UUID): DefenseDto =
|
||||
sessions
|
||||
.findById(id)
|
||||
.orElseThrow { NotFoundException("Сессия защиты не найдена") }
|
||||
.let { toDto(it) }
|
||||
|
||||
@Transactional
|
||||
fun issueCode(id: UUID): String {
|
||||
val s = sessions.findById(id).orElseThrow { NotFoundException("Сессия защиты не найдена") }
|
||||
|
||||
@@ -87,6 +87,22 @@ data class CourseGroupInfo(
|
||||
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. */
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class StudentContext(
|
||||
|
||||
@@ -7,18 +7,39 @@ import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
class GroupLabDataController(
|
||||
private val service: GroupLabDataService,
|
||||
private val scope: CourseScopeService,
|
||||
) {
|
||||
/** Данные всех студентов группы по выбранным лабораторным — только для админа. */
|
||||
/** Данные всех студентов группы по выбранным лабораторным — для админа и преподавателя своего курса. */
|
||||
@GetMapping("/api/v1/assignments/group/{groupId}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun groupLabData(
|
||||
@PathVariable groupId: UUID,
|
||||
@RequestParam("labId") labIds: List<UUID>,
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import ru.ysu.lms.progress.audit.AuditClient
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@@ -19,6 +21,8 @@ class ProgressController(
|
||||
private val service: ProgressService,
|
||||
private val grades: GradeService,
|
||||
private val publications: GradePublicationService,
|
||||
private val scope: CourseScopeService,
|
||||
private val audit: AuditClient,
|
||||
) {
|
||||
@GetMapping("/me/assignments")
|
||||
fun meAssignments(
|
||||
@@ -38,7 +42,19 @@ class ProgressController(
|
||||
fun setLevel(
|
||||
@Valid @RequestBody body: LevelAssignmentRequest,
|
||||
@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")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
@@ -56,18 +72,40 @@ class ProgressController(
|
||||
}
|
||||
|
||||
@PostMapping("/distribute")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun distribute(
|
||||
@Valid @RequestBody body: DistributeRequest,
|
||||
@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")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun assign(
|
||||
@Valid @RequestBody body: AssignRequest,
|
||||
@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")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
@@ -77,48 +115,84 @@ class ProgressController(
|
||||
): List<VariantAssignmentResponse> = service.assignmentsForLabEnriched(labId, jwt.tokenValue)
|
||||
|
||||
@PostMapping("/grades")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun setGrade(
|
||||
@Valid @RequestBody body: GradeSetRequest,
|
||||
@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")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun listGrades(): List<GradeResponse> = grades.listGrades()
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
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")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun publicationState(
|
||||
@RequestParam groupId: java.util.UUID,
|
||||
@RequestParam labId: java.util.UUID,
|
||||
): PublicationState = publications.state(groupId, labId)
|
||||
@RequestParam groupId: UUID,
|
||||
@RequestParam labId: UUID,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): PublicationState {
|
||||
requirePublicationScope(groupId, labId, jwt)
|
||||
return publications.state(groupId, labId)
|
||||
}
|
||||
|
||||
@PostMapping("/grades/publication")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun setPublication(
|
||||
@RequestBody body: PublicationSetRequest,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
) {
|
||||
val groupId = UUID.fromString(body.groupId)
|
||||
val labId = UUID.fromString(body.labId)
|
||||
requirePublicationScope(groupId, labId, jwt)
|
||||
publications.setStudents(
|
||||
java.util.UUID.fromString(body.groupId),
|
||||
java.util.UUID.fromString(body.labId),
|
||||
body.studentIds.map { java.util.UUID.fromString(it) },
|
||||
groupId,
|
||||
labId,
|
||||
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")
|
||||
fun myGrades(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
@RequestParam(required = false) groupId: java.util.UUID?,
|
||||
@RequestParam(required = false) groupId: UUID?,
|
||||
): List<GradeResponse> {
|
||||
val all = grades.listGrades().filter { it.studentId == jwt.subject }
|
||||
if (groupId == null) return emptyList()
|
||||
val studentId = java.util.UUID.fromString(jwt.subject)
|
||||
return all.filter { publications.isPublished(groupId, java.util.UUID.fromString(it.labId), studentId) }
|
||||
val studentId = UUID.fromString(jwt.subject)
|
||||
return all.filter { publications.isPublished(groupId, UUID.fromString(it.labId), studentId) }
|
||||
}
|
||||
|
||||
@GetMapping("/comment-templates")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun suggestComments(
|
||||
@RequestParam q: String,
|
||||
): List<CommentSuggestion> = grades.suggestComments(q)
|
||||
@@ -130,4 +204,29 @@ class ProgressController(
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package ru.ysu.lms.progress
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
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.StudentLabLevelKey
|
||||
import ru.ysu.lms.progress.domain.VariantAssignment
|
||||
@@ -15,7 +16,6 @@ class ProgressService(
|
||||
private val levels: StudentLabLevelRepository,
|
||||
private val assignments: VariantAssignmentRepository,
|
||||
private val academic: AcademicClient,
|
||||
private val identity: IdentityClient,
|
||||
) {
|
||||
@Transactional
|
||||
fun setLevel(
|
||||
@@ -92,15 +92,17 @@ class ProgressService(
|
||||
token: String,
|
||||
): DistributeResult {
|
||||
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 allStudents = collectStudentIds(request, token)
|
||||
val allStudents = collectStudentIds(request, courseId, token)
|
||||
var assignedTotal = 0
|
||||
var totalStudents = 0
|
||||
var lastLabId: String? = null
|
||||
var lastLabTitle: String? = null
|
||||
for (labIdStr in labIds) {
|
||||
for ((labIdStr, lab) in labs) {
|
||||
val labId = UUID.fromString(labIdStr)
|
||||
val lab = academic.getLab(labIdStr, token)
|
||||
if (force) clearAutoAssignments(labId, allStudents)
|
||||
val kept = keepExisting(labId, allStudents)
|
||||
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> {
|
||||
val labIds =
|
||||
request.labIds
|
||||
@@ -130,8 +142,10 @@ class ProgressService(
|
||||
return labIds
|
||||
}
|
||||
|
||||
/** Студенты активных групп курса: состав берётся из academic, а не из всех групп identity. */
|
||||
private fun collectStudentIds(
|
||||
request: DistributeRequest,
|
||||
courseId: UUID,
|
||||
token: String,
|
||||
): List<UUID> {
|
||||
val groupFilter =
|
||||
@@ -139,11 +153,16 @@ class ProgressService(
|
||||
.orEmpty()
|
||||
.filter { it.isNotBlank() }
|
||||
.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>()
|
||||
for (g in identity.listGroups(token)) {
|
||||
val gid = g["id"] as String
|
||||
if (groupFilter.isNotEmpty() && gid !in groupFilter) continue
|
||||
for (s in identity.getGroup(gid, token).students) studentIds.add(UUID.fromString(s.id))
|
||||
for (groupId in selected) {
|
||||
val roster = academic.getCourseGroupStudents(courseId.toString(), groupId, token)
|
||||
for (student in roster.students) studentIds.add(UUID.fromString(student.id))
|
||||
}
|
||||
return studentIds.distinct()
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RequestPart
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import ru.ysu.lms.progress.audit.AuditClient
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.UUID
|
||||
@@ -22,6 +23,7 @@ import java.util.UUID
|
||||
@RequestMapping("/api/v1")
|
||||
class SubmissionController(
|
||||
private val submissions: SubmissionService,
|
||||
private val audit: AuditClient,
|
||||
) {
|
||||
@PostMapping("/me/submissions/{labId}")
|
||||
fun submit(
|
||||
@@ -47,6 +49,14 @@ class SubmissionController(
|
||||
): ResponseEntity<ByteArray> {
|
||||
val sid = UUID.fromString(jwt.subject)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -64,6 +74,13 @@ class SubmissionController(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): ResponseEntity<ByteArray> {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import ru.ysu.lms.progress.audit.AuditClient
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.UUID
|
||||
@@ -18,6 +19,7 @@ import java.util.UUID
|
||||
@RequestMapping("/api/v1/courses")
|
||||
class ProfileController(
|
||||
private val profiles: StudentProfileService,
|
||||
private val audit: AuditClient,
|
||||
) {
|
||||
@GetMapping("/{courseId}/students/{studentId}/profile")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER','STUDENT')")
|
||||
@@ -25,14 +27,29 @@ class ProfileController(
|
||||
@PathVariable courseId: UUID,
|
||||
@PathVariable studentId: UUID,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): StudentCourseProfile =
|
||||
profiles.profile(
|
||||
courseId = courseId,
|
||||
studentId = studentId,
|
||||
callerId = UUID.fromString(jwt.subject),
|
||||
callerRole = role(jwt),
|
||||
token = jwt.tokenValue,
|
||||
)
|
||||
): StudentCourseProfile {
|
||||
val profile =
|
||||
profiles.profile(
|
||||
courseId = courseId,
|
||||
studentId = studentId,
|
||||
callerId = UUID.fromString(jwt.subject),
|
||||
callerRole = role(jwt),
|
||||
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")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
@@ -41,7 +58,7 @@ class ProfileController(
|
||||
@PathVariable submissionId: UUID,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): ResponseEntity<ByteArray> {
|
||||
val (bytes, name) =
|
||||
val download =
|
||||
profiles.downloadSubmission(
|
||||
courseId = courseId,
|
||||
submissionId = submissionId,
|
||||
@@ -49,11 +66,23 @@ class ProfileController(
|
||||
callerRole = role(jwt),
|
||||
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 companion object {
|
||||
const val ROLE_STUDENT = "STUDENT"
|
||||
}
|
||||
|
||||
private fun attachment(
|
||||
bytes: ByteArray,
|
||||
name: String,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package ru.ysu.lms.progress.profile
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
/** Краткие данные курса в профиле студента. */
|
||||
data class ProfileCourse(
|
||||
val id: String,
|
||||
@@ -87,6 +89,14 @@ data class ProfileLab(
|
||||
val submissions: List<ProfileSubmission>,
|
||||
)
|
||||
|
||||
/** Скачанная сдача вместе с владельцем: нужно для аудита доступа преподавателя. */
|
||||
data class SubmissionDownload(
|
||||
val bytes: ByteArray,
|
||||
val filename: String,
|
||||
val studentId: UUID,
|
||||
val labId: UUID,
|
||||
)
|
||||
|
||||
data class StudentCourseProfile(
|
||||
val course: ProfileCourse,
|
||||
val student: ProfileStudent,
|
||||
|
||||
@@ -118,7 +118,7 @@ class StudentProfileService(
|
||||
callerId: UUID,
|
||||
callerRole: String,
|
||||
token: String,
|
||||
): Pair<ByteArray, String> {
|
||||
): SubmissionDownload {
|
||||
val role = normalizeRole(callerRole)
|
||||
if (role != ROLE_ADMIN && role != ROLE_TEACHER) {
|
||||
forbidden("Нет доступа к этой сдаче")
|
||||
@@ -141,7 +141,13 @@ class StudentProfileService(
|
||||
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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -36,24 +36,20 @@ class ProgressDistributionIntegrationTest {
|
||||
@MockitoBean
|
||||
private lateinit var academic: AcademicClient
|
||||
|
||||
@MockitoBean
|
||||
private lateinit var identity: IdentityClient
|
||||
|
||||
@Test
|
||||
fun `distributes variants to every student of the selected group`() {
|
||||
val courseId = UUID.randomUUID()
|
||||
val labId = UUID.randomUUID()
|
||||
val variantA = UUID.randomUUID().toString()
|
||||
val variantB = UUID.randomUUID().toString()
|
||||
val student1 = UUID.randomUUID().toString()
|
||||
val student2 = UUID.randomUUID().toString()
|
||||
|
||||
`when`(identity.listGroups("t")).thenReturn(listOf(mapOf("id" to "g1")))
|
||||
`when`(identity.getGroup("g1", "t")).thenReturn(
|
||||
GroupInfo(id = "g1", name = "Группа", students = listOf(StudentInfo(student1), StudentInfo(student2))),
|
||||
)
|
||||
stubCourse(courseId, "g1", student1, student2)
|
||||
`when`(academic.getLab(labId.toString(), "t")).thenReturn(
|
||||
LabInfo(
|
||||
id = labId.toString(),
|
||||
courseId = courseId.toString(),
|
||||
levels =
|
||||
listOf(
|
||||
LevelInfo(
|
||||
@@ -85,18 +81,17 @@ class ProgressDistributionIntegrationTest {
|
||||
|
||||
@Test
|
||||
fun `group lab data aggregates per-lab data in one call`() {
|
||||
val courseId = UUID.randomUUID()
|
||||
val labId = UUID.randomUUID()
|
||||
val student1 = UUID.randomUUID().toString()
|
||||
val student2 = UUID.randomUUID().toString()
|
||||
val variantA = UUID.randomUUID().toString()
|
||||
|
||||
`when`(identity.listGroups("t")).thenReturn(listOf(mapOf("id" to "g1")))
|
||||
`when`(identity.getGroup("g1", "t")).thenReturn(
|
||||
GroupInfo(id = "g1", name = "Группа", students = listOf(StudentInfo(student1), StudentInfo(student2))),
|
||||
)
|
||||
stubCourse(courseId, "g1", student1, student2)
|
||||
`when`(academic.getLab(labId.toString(), "t")).thenReturn(
|
||||
LabInfo(
|
||||
id = labId.toString(),
|
||||
courseId = courseId.toString(),
|
||||
levels =
|
||||
listOf(
|
||||
LevelInfo(
|
||||
@@ -121,4 +116,27 @@ class ProgressDistributionIntegrationTest {
|
||||
assertThat(lab.submissions).isEmpty()
|
||||
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 = "Студент") },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,10 +126,11 @@ class StudentProfileIntegrationTest {
|
||||
val commentAuthor = lab.comments.single().author
|
||||
assertThat(commentAuthor?.login).isEqualTo("teacher_login")
|
||||
|
||||
val (bytes, name) =
|
||||
val download =
|
||||
profiles.downloadSubmission(courseId, second.id, UUID.randomUUID(), "TEACHER", TEACHER_TOKEN)
|
||||
assertThat(name).endsWith(".zip")
|
||||
assertThat(bytes).isNotEmpty()
|
||||
assertThat(download.filename).endsWith(".zip")
|
||||
assertThat(download.bytes).isNotEmpty()
|
||||
assertThat(download.studentId).isEqualTo(studentId)
|
||||
assertThat(first.id).isNotEqualTo(second.id)
|
||||
}
|
||||
|
||||
@@ -207,9 +208,9 @@ class StudentProfileIntegrationTest {
|
||||
|
||||
val adminProfile = profiles.profile(courseId, studentId, UUID.randomUUID(), "ADMIN", ADMIN_TOKEN)
|
||||
assertThat(adminProfile.student.groupId).isEqualTo(otherGroup.toString())
|
||||
val (bytes, _) =
|
||||
val download =
|
||||
profiles.downloadSubmission(courseId, submission.id, UUID.randomUUID(), "ADMIN", ADMIN_TOKEN)
|
||||
assertThat(bytes).isNotEmpty()
|
||||
assertThat(download.bytes).isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -229,10 +230,10 @@ class StudentProfileIntegrationTest {
|
||||
profiles.downloadSubmission(courseId, foreign.id, UUID.randomUUID(), "TEACHER", TEACHER_TOKEN)
|
||||
}.isInstanceOf(ForbiddenException::class.java)
|
||||
|
||||
val (bytes, name) =
|
||||
val download =
|
||||
profiles.downloadSubmission(courseId, own.id, UUID.randomUUID(), "TEACHER", TEACHER_TOKEN)
|
||||
assertThat(bytes).isNotEmpty()
|
||||
assertThat(name).endsWith(".zip")
|
||||
assertThat(download.bytes).isNotEmpty()
|
||||
assertThat(download.filename).endsWith(".zip")
|
||||
}
|
||||
|
||||
private fun stubCourse(
|
||||
|
||||
@@ -4,6 +4,7 @@ import org.assertj.core.api.Assertions.assertThat
|
||||
import org.hamcrest.Matchers.containsString
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentMatchers.anyString
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.Mockito.`when`
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
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.postgresql.PostgreSQLContainer
|
||||
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.repo.StudentLabLevelRepository
|
||||
import ru.ysu.lms.progress.repo.SubmissionRepository
|
||||
@@ -80,8 +82,17 @@ class TeacherLabPageAccessIntegrationTest {
|
||||
@MockitoBean
|
||||
private lateinit var academic: AcademicClient
|
||||
|
||||
@MockitoBean
|
||||
private lateinit var identity: IdentityClient
|
||||
|
||||
@MockitoBean
|
||||
private lateinit var audit: AuditClient
|
||||
|
||||
private val ownLab = UUID.randomUUID()
|
||||
private val teacherId = UUID.randomUUID()
|
||||
private val foreignLab = UUID.randomUUID()
|
||||
private val courseId = UUID.randomUUID()
|
||||
private val groupId = UUID.randomUUID()
|
||||
private val levelId = UUID.randomUUID()
|
||||
private val variantId = UUID.randomUUID()
|
||||
|
||||
@@ -96,6 +107,7 @@ class TeacherLabPageAccessIntegrationTest {
|
||||
LabInfo(
|
||||
id = id.toString(),
|
||||
title = "Лабораторная",
|
||||
courseId = courseId.toString(),
|
||||
levels =
|
||||
listOf(
|
||||
LevelInfo(
|
||||
@@ -119,17 +131,39 @@ class TeacherLabPageAccessIntegrationTest {
|
||||
.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 jwtFor(role: String) =
|
||||
jwt()
|
||||
.jwt {
|
||||
it
|
||||
.subject(UUID.randomUUID().toString())
|
||||
.claim("role", role)
|
||||
}.authorities(SimpleGrantedAuthority("ROLE_$role"))
|
||||
private fun jwtFor(
|
||||
role: String,
|
||||
subject: UUID = UUID.randomUUID(),
|
||||
) = jwt()
|
||||
.jwt {
|
||||
it
|
||||
.subject(subject.toString())
|
||||
.claim("role", role)
|
||||
}.authorities(SimpleGrantedAuthority("ROLE_$role"))
|
||||
|
||||
private fun getLevels(
|
||||
labId: UUID,
|
||||
@@ -198,6 +232,7 @@ class TeacherLabPageAccessIntegrationTest {
|
||||
fun `teacher sets level and assigns variant of own lab`() {
|
||||
mockOwnLab()
|
||||
val studentId = UUID.randomUUID()
|
||||
mockStudentInCourse(studentId)
|
||||
|
||||
mockMvc.perform(postLevel(ownLab, "TEACHER", studentId)).andExpect(status().isOk)
|
||||
|
||||
@@ -223,6 +258,16 @@ class TeacherLabPageAccessIntegrationTest {
|
||||
).andExpect(status().isOk)
|
||||
.andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, containsString("attachment")))
|
||||
.andExpect(content().contentType(MediaType.APPLICATION_OCTET_STREAM))
|
||||
|
||||
verify(audit).record(
|
||||
teacherId.toString(),
|
||||
"SUBMISSION_DOWNLOAD",
|
||||
"SUBMISSION",
|
||||
submission.id.toString(),
|
||||
null,
|
||||
null,
|
||||
"staff",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user