fix: закрыть P0/P1 расхождения с целевой моделью
- академический отпуск: readOnly, запрет submit/start/finish защиты - черновики лабораторных скрыты от студентов в списке/деталях/по UUID - удалено действие «Убрать из группы» из UI и API - импорт: restoreInactiveStatuses, skipped-отчёт, разблокировка архивного аккаунта - глобальные материалы STUDENTS/TEACHERS с JWT-фильтрацией и сменой аудитории - LDD-AUTH-005 переведён на unit-evidence, clean check проходит по P0 - backup: безопасный парсинг .env, verify-latest, кавычки cron - robots/noindex, комментарии без автора, профиль без черновиков - docs/18-goal-audit.md; baseline 57 claims / 366 тестов / release PASS
This commit is contained in:
@@ -30,6 +30,7 @@ data class DefenseDto(
|
||||
@Service
|
||||
class DefenseService(
|
||||
private val sessions: DefenseSessionRepository,
|
||||
private val writeGuard: StudentWriteGuard,
|
||||
) {
|
||||
companion object {
|
||||
val DURATION: Duration = Duration.ofMinutes(30)
|
||||
@@ -129,6 +130,7 @@ class DefenseService(
|
||||
studentId: UUID,
|
||||
code: String,
|
||||
): DefenseDto {
|
||||
writeGuard.ensureWritable(studentId)
|
||||
syncTimeouts()
|
||||
val s = requireWaitingSession(studentId, code)
|
||||
s.status = DefenseStatus.started.name
|
||||
@@ -163,6 +165,7 @@ class DefenseService(
|
||||
studentId: UUID,
|
||||
sessionId: UUID,
|
||||
): DefenseDto {
|
||||
writeGuard.ensureWritable(studentId)
|
||||
val s = sessions.findById(sessionId).orElseThrow { NotFoundException("Сессия не найдена") }
|
||||
if (s.studentId != studentId) throw ForbiddenException("Не ваша сессия")
|
||||
if (s.status != DefenseStatus.started.name) throw BadRequestException("Защита ещё не начата")
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package ru.ysu.lms.progress
|
||||
|
||||
import org.springframework.stereotype.Component
|
||||
import ru.ysu.lms.common.ForbiddenException
|
||||
import java.util.UUID
|
||||
|
||||
/** Академический отпуск: чтение разрешено, сдача и защиты — нет. */
|
||||
@Component
|
||||
class StudentWriteGuard(
|
||||
private val identity: IdentityClient,
|
||||
) {
|
||||
fun ensureWritable(studentId: UUID) {
|
||||
val status = identity.getStudentContext(studentId.toString()).status
|
||||
if (status == STATUS_ACADEMIC_LEAVE) {
|
||||
throw ForbiddenException("Академический отпуск: доступ только для чтения")
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val STATUS_ACADEMIC_LEAVE = "ACADEMIC_LEAVE"
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ class SubmissionService(
|
||||
private val submissions: SubmissionRepository,
|
||||
private val storage: SubmissionStorage,
|
||||
private val academic: AcademicClient,
|
||||
private val writeGuard: StudentWriteGuard,
|
||||
) {
|
||||
@Transactional
|
||||
fun submit(
|
||||
@@ -27,6 +28,7 @@ class SubmissionService(
|
||||
files: List<MultipartFile>,
|
||||
token: String,
|
||||
): SubmissionDto {
|
||||
writeGuard.ensureWritable(studentId)
|
||||
val payload = validatedPayload(files)
|
||||
ensureDeadline(requireLab(labId, token))
|
||||
val seq = nextSeq(studentId, labId)
|
||||
|
||||
@@ -63,7 +63,10 @@ class StudentProfileService(
|
||||
val context = identity.getStudentContext(studentId.toString())
|
||||
requireProfileAccess(course, context, studentId, callerId, role)
|
||||
|
||||
val labs = courseLabs(courseId, token)
|
||||
val labs =
|
||||
courseLabs(courseId, token).filter { (_, lab) ->
|
||||
role != ROLE_STUDENT || lab.status == "published"
|
||||
}
|
||||
val labIds = labs.map { it.first }
|
||||
val sid = studentId
|
||||
val visibleGrades = gradesByLab(sid, labIds)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package ru.ysu.lms.progress
|
||||
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.assertj.core.api.Assertions.assertThatThrownBy
|
||||
import org.junit.jupiter.api.Test
|
||||
import ru.ysu.lms.common.ForbiddenException
|
||||
import ru.ysu.lms.progress.repo.DefenseSessionRepository
|
||||
import java.util.UUID
|
||||
|
||||
class DefenseServiceAcademicLeaveTest {
|
||||
@Test
|
||||
fun `academic leave student cannot start or finish defense`() {
|
||||
val guard = mockk<StudentWriteGuard>()
|
||||
every { guard.ensureWritable(any()) } throws ForbiddenException("только чтение")
|
||||
val service = DefenseService(mockk<DefenseSessionRepository>(relaxed = true), guard)
|
||||
val studentId = UUID.randomUUID()
|
||||
|
||||
assertThatThrownBy { service.startByCode(studentId, "123456") }
|
||||
.isInstanceOf(ForbiddenException::class.java)
|
||||
assertThatThrownBy { service.finish(studentId, UUID.randomUUID()) }
|
||||
.isInstanceOf(ForbiddenException::class.java)
|
||||
}
|
||||
}
|
||||
@@ -287,6 +287,7 @@ class StudentProfileIntegrationTest {
|
||||
id = labId.toString(),
|
||||
number = number,
|
||||
title = "Лабораторная $number",
|
||||
status = "published",
|
||||
deadlineAt = "2026-12-01T00:00:00Z",
|
||||
levels =
|
||||
listOf(
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.junit.jupiter.api.io.TempDir
|
||||
import org.springframework.dao.DataIntegrityViolationException
|
||||
import org.springframework.mock.web.MockMultipartFile
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import ru.ysu.lms.common.ForbiddenException
|
||||
import ru.ysu.lms.progress.domain.Submission
|
||||
import ru.ysu.lms.progress.repo.SubmissionRepository
|
||||
import java.nio.file.Path
|
||||
@@ -33,7 +34,9 @@ class SubmissionServiceTest {
|
||||
}
|
||||
every { repo.findById(any()) } answers { java.util.Optional.ofNullable(saved) }
|
||||
val storage = SubmissionStorage(tempDir.toString())
|
||||
return SubmissionService(repo, storage, academic) to repo
|
||||
val guard = mockk<StudentWriteGuard>()
|
||||
every { guard.ensureWritable(any()) } returns Unit
|
||||
return SubmissionService(repo, storage, academic, guard) to repo
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -53,6 +56,20 @@ class SubmissionServiceTest {
|
||||
assertThat(String(entries.getValue("main.c"), Charsets.UTF_8)).contains("#include")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `academic leave student cannot submit`() {
|
||||
val repo = mockk<SubmissionRepository>()
|
||||
val academic = mockk<AcademicClient>()
|
||||
val guard = mockk<StudentWriteGuard>()
|
||||
every { guard.ensureWritable(studentId) } throws ForbiddenException("только чтение")
|
||||
val service = SubmissionService(repo, SubmissionStorage(tempDir.toString()), academic, guard)
|
||||
val file = MockMultipartFile("files", "main.c", "text/plain", "int main(void){}".toByteArray())
|
||||
|
||||
assertThatThrownBy { service.submit(studentId, labId, listOf(file), "token") }
|
||||
.isInstanceOf(ForbiddenException::class.java)
|
||||
.hasMessageContaining("только чтение")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects xml project file renamed to source extension`() {
|
||||
val (service, _) = service()
|
||||
|
||||
Reference in New Issue
Block a user