feat(progress): преподавательские оценки, защиты и аудит
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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("Сессия защиты не найдена") }
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
@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 = "Студент") },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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