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:
2026-10-29 18:58:21 +03:00
parent 9cb4d0d263
commit 9412d87fed
63 changed files with 1281 additions and 162 deletions

View File

@@ -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("Защита ещё не начата")

View File

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

View File

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

View File

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

View File

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

View File

@@ -287,6 +287,7 @@ class StudentProfileIntegrationTest {
id = labId.toString(),
number = number,
title = "Лабораторная $number",
status = "published",
deadlineAt = "2026-12-01T00:00:00Z",
levels =
listOf(

View File

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