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

@@ -1,6 +1,35 @@
package ru.ysu.lms.academic.content
/** Аудитория глобальной главы, доступной без авторизации. */
import ru.ysu.lms.common.BadRequestException
/** Аудитории глобальных глав; ALL доступна без авторизации. */
const val AUDIENCE_ALL = "ALL"
const val AUDIENCE_STUDENTS = "STUDENTS"
const val AUDIENCE_TEACHERS = "TEACHERS"
/** Допустимые аудитории глобальной главы. */
val CONTENT_AUDIENCES: Set<String> = setOf(AUDIENCE_ALL, AUDIENCE_STUDENTS, AUDIENCE_TEACHERS)
/**
* Аудитории глобальных глав, доступные роли. Аноним видит только ALL,
* студент — ALL и STUDENTS, преподаватель — ALL и TEACHERS, администратор — всё.
*/
fun audiencesForRole(role: String?): Set<String> =
when (role) {
ROLE_ADMIN -> CONTENT_AUDIENCES
ROLE_STUDENT -> setOf(AUDIENCE_ALL, AUDIENCE_STUDENTS)
ROLE_TEACHER -> setOf(AUDIENCE_ALL, AUDIENCE_TEACHERS)
else -> setOf(AUDIENCE_ALL)
}
/** Нормализует и проверяет аудиторию; `null` остаётся `null`. */
fun normalizeAudience(audience: String?): String? {
if (audience == null) return null
val normalized = audience.trim().uppercase()
if (normalized !in CONTENT_AUDIENCES) throw BadRequestException("Недопустимая аудитория: $audience")
return normalized
}
private const val ROLE_ADMIN = "ADMIN"
private const val ROLE_STUDENT = "STUDENT"
private const val ROLE_TEACHER = "TEACHER"

View File

@@ -61,17 +61,19 @@ class ContentAuthoringService(
}
/**
* Глобальная глава (`courseId == null`) получает аудиторию ALL и уникальный slug
* среди глобальных глав. Курсовая глава получает `courseId`, пустую аудиторию
* и уникальный slug внутри курса.
* Глобальная глава (`courseId == null`) получает запрошенную аудиторию
* (по умолчанию ALL) и уникальный slug среди глобальных глав. Курсовая глава
* получает `courseId`, пустую аудиторию и уникальный slug внутри курса.
*/
@Transactional
fun createChapter(
title: String,
slug: String?,
courseId: UUID? = null,
audience: String? = null,
): ChapterTreeNode {
if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым")
val requestedAudience = normalizeAudience(audience)
val scope =
if (courseId == null) {
chapters.findByCourseIdIsNullOrderByPositionAscIdAsc()
@@ -88,7 +90,7 @@ class ContentAuthoringService(
title = title.trim(),
slug = cleanSlug,
position = maxPos + 1,
audience = if (courseId == null) AUDIENCE_ALL else null,
audience = if (courseId == null) requestedAudience ?: AUDIENCE_ALL else null,
courseId = courseId,
),
)
@@ -98,10 +100,23 @@ class ContentAuthoringService(
slug = ch.slug,
position = ch.position,
published = ch.published,
audience = ch.audience,
sections = emptyList(),
)
}
/** Меняет аудиторию только у глобальной главы; у курсовой её нет. */
@Transactional
fun updateChapterAudience(
chapterId: UUID,
audience: String,
) {
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
if (ch.courseId != null) throw BadRequestException("Аудитория задаётся только для глобальных глав")
ch.audience = normalizeAudience(audience)
chapters.save(ch)
}
@Transactional
fun renameChapter(
chapterId: UUID,

View File

@@ -34,6 +34,7 @@ data class ChapterTreeNode(
val slug: String,
val position: Int,
val published: Boolean = true,
val audience: String? = null,
val sections: List<SectionTreeNode>,
)
@@ -115,19 +116,28 @@ class ContentService(
private val importer: ContentImporter,
private val assetStorage: AssetStorage,
) {
/** Публичное дерево: только глобальные главы с аудиторией ALL. */
/**
* Публичное дерево: только глобальные главы, аудитория которых доступна роли.
* Аноним видит ALL, студент — ALL/STUDENTS, преподаватель — ALL/TEACHERS,
* администратор — все аудитории. Курсовые главы здесь не показываются.
*/
@Transactional(readOnly = true)
fun tree(includeHidden: Boolean = false): ContentTree =
buildTree(
fun tree(
includeHidden: Boolean = false,
role: String? = null,
): ContentTree {
val allowedAudiences = audiencesForRole(role)
return buildTree(
chapters
.findAllByOrderByPositionAscIdAsc()
.filter {
it.courseId == null &&
it.audience == AUDIENCE_ALL &&
it.audience in allowedAudiences &&
(includeHidden || it.published)
},
includeHidden = includeHidden,
)
}
/** Полное дерево (включая скрытые и курсовые главы) — только для админки. */
@Transactional(readOnly = true)
@@ -150,9 +160,10 @@ class ContentService(
sectionSlug: String,
articleSlug: String,
includeHidden: Boolean = false,
role: String? = null,
): ContentArticleView {
val ch = requireFound(chapters.findBySlugAndCourseIdIsNull(chapterSlug), "Глава не найдена")
requirePublicChapter(ch, includeHidden)
requirePublicChapter(ch, includeHidden, role)
return articleView(ch, sectionSlug, articleSlug, includeHidden)
}
@@ -255,9 +266,10 @@ class ContentService(
private fun requirePublicChapter(
ch: ContentChapter,
includeHidden: Boolean,
role: String?,
) {
requireVisible(ch.published, includeHidden, "Глава скрыта")
if (!includeHidden && ch.audience != AUDIENCE_ALL) throw NotFoundException("Глава недоступна")
if (ch.audience !in audiencesForRole(role)) throw NotFoundException("Глава недоступна")
}
private fun buildTree(
@@ -284,6 +296,7 @@ class ContentService(
slug = ch.slug,
position = ch.position,
published = ch.published,
audience = ch.audience,
sections =
(sectionList[ch.id] ?: emptyList()).map { sec ->
SectionTreeNode(

View File

@@ -60,6 +60,11 @@ class ContentAdminController(
data class ChapterCreatePayload(
val title: String,
val slug: String? = null,
val audience: String? = null,
)
data class ChapterAudiencePayload(
val audience: String,
)
data class SectionCreatePayload(
@@ -159,13 +164,29 @@ class ContentAdminController(
fun createChapter(
@RequestBody body: ChapterCreatePayload,
@AuthenticationPrincipal jwt: Jwt,
) = authoring.createChapter(body.title, body.slug).also {
) = authoring.createChapter(body.title, body.slug, audience = body.audience).also {
audit.record(
jwt.subject,
"CONTENT_CHAPTER_CREATE",
ENTITY_CHAPTER,
it.id,
details = "title=${body.title}",
details = "title=${body.title}, audience=${it.audience}",
)
}
@PutMapping("/chapters/{id}/audience")
fun chapterAudience(
@PathVariable id: UUID,
@RequestBody body: ChapterAudiencePayload,
@AuthenticationPrincipal jwt: Jwt,
) {
authoring.updateChapterAudience(id, body.audience)
audit.record(
jwt.subject,
"CONTENT_CHAPTER_AUDIENCE",
ENTITY_CHAPTER,
id.toString(),
details = "audience=${body.audience}",
)
}

View File

@@ -25,6 +25,8 @@ import java.util.UUID
* Публичное чтение учебных материалов.
*
* `tree` и статьи глобальных глав с аудиторией ALL доступны без аутентификации.
* С JWT дополнительно открываются STUDENTS для студента и TEACHERS для преподавателя;
* администратор видит все аудитории. Курсовые главы отдаются только курсовыми эндпоинтами.
* Активы без записи в `article_assets` по умолчанию не публичны (secure-by-default):
* их может скачать только админ для очистки. Привязанные активы наследуют
* видимость статьи, раздела и главы.
@@ -37,14 +39,20 @@ class ContentPublicController(
private val accessService: CourseAccessService,
) {
@GetMapping("/tree")
fun tree() = contentService.tree()
fun tree(
@AuthenticationPrincipal jwt: Jwt?,
) = contentService.tree(role = jwt?.getClaimAsString("role"))
@GetMapping("/{chapter}/{section}/{article}")
fun article(
@PathVariable chapter: String,
@PathVariable section: String,
@PathVariable article: String,
): ContentArticleViewDto = contentService.findArticle(chapter, section, article).toDto()
@AuthenticationPrincipal jwt: Jwt?,
): ContentArticleViewDto =
contentService
.findArticle(chapter, section, article, role = jwt?.getClaimAsString("role"))
.toDto()
data class ContentArticleViewDto(
val id: String,

View File

@@ -46,12 +46,13 @@ class LabController(
@RequestParam(required = false) courseId: UUID?,
@AuthenticationPrincipal jwt: Jwt,
): List<LabSummary> {
val student = accessService.isStudent(jwt)
if (courseId != null) {
accessService.requireCourseAccess(courseId, jwt)
return labService.list(courseId)
return labService.list(courseId).filter { !student || it.status == "published" }
}
val accessible = accessService.accessibleCourseIds(jwt) ?: return labService.list()
return labService.list(accessible)
return labService.list(accessible).filter { !student || it.status == "published" }
}
/** Полные данные всех лабораторных курса одним запросом (без N+1 по деталям). */
@@ -61,7 +62,8 @@ class LabController(
@AuthenticationPrincipal jwt: Jwt,
): List<LabDetail> {
accessService.requireCourseAccess(courseId, jwt)
return labService.details(courseId)
val details = labService.details(courseId)
return if (accessService.isStudent(jwt)) details.filter { it.status == "published" } else details
}
@GetMapping("/{id}")

View File

@@ -67,6 +67,10 @@ class CourseAccessService(
jwt: Jwt,
) {
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
if (role(jwt) == ROLE_STUDENT && lab.status != "published") {
// Черновик не существует для студента: не раскрываем его даже прямым UUID.
throw NotFoundException("Лабораторная не найдена")
}
val courseId = lab.course?.id
if (courseId == null) {
if (role(jwt) != ROLE_ADMIN) throw ForbiddenException("Лабораторная недоступна")
@@ -84,6 +88,8 @@ class CourseAccessService(
fun isAdmin(jwt: Jwt): Boolean = role(jwt) == ROLE_ADMIN
fun isStudent(jwt: Jwt): Boolean = role(jwt) == ROLE_STUDENT
private fun activeCourseIds(courseIds: Collection<UUID>): Set<UUID> {
if (courseIds.isEmpty()) return emptySet()
return courses.findAllById(courseIds).filter { it.status == Course.STATUS_ACTIVE }.mapTo(HashSet()) { it.id }

View File

@@ -93,7 +93,7 @@ class CourseContentIntegrationTest {
}
@Test
fun `public tree contains only global chapters with ALL audience`() {
fun `anonymous tree contains only global chapters with ALL audience`() {
val global = seedChapter(title = "Глобальная", courseId = null, audience = "ALL")
seedChapter(title = "Только студентам", courseId = null, audience = "STUDENTS")
val course = newCourse()

View File

@@ -0,0 +1,272 @@
package ru.ysu.lms.academic.content
import org.assertj.core.api.Assertions.assertThat
import org.hamcrest.Matchers.containsInAnyOrder
import org.junit.jupiter.api.Test
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.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
import org.springframework.test.web.servlet.request.RequestPostProcessor
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.academic.course.CourseCreateRequest
import ru.ysu.lms.academic.course.CourseService
import ru.ysu.lms.academic.domain.ContentArticle
import ru.ysu.lms.academic.domain.ContentChapter
import ru.ysu.lms.academic.domain.ContentSection
import ru.ysu.lms.academic.repo.ContentArticleRepository
import ru.ysu.lms.academic.repo.ContentChapterRepository
import ru.ysu.lms.academic.repo.ContentSectionRepository
import java.nio.file.Files
import java.util.UUID
/**
* Аудитория глобальных глав: аноним видит только ALL, студент — ALL/STUDENTS,
* преподаватель — ALL/TEACHERS, админ — всё. Курсовые главы аудитории не имеют.
*/
@Testcontainers
@SpringBootTest
@Transactional
class GlobalAudienceAccessIntegrationTest {
companion object {
@Container
@ServiceConnection
@JvmStatic
val postgres = PostgreSQLContainer("postgres:18")
@JvmStatic
@DynamicPropertySource
fun assetsDir(registry: DynamicPropertyRegistry) {
registry.add("lms.content.assets-dir") { Files.createTempDirectory("lms-assets").toString() }
}
}
@Autowired
private lateinit var context: WebApplicationContext
@Autowired
private lateinit var authoring: ContentAuthoringService
@Autowired
private lateinit var courseService: CourseService
@Autowired
private lateinit var chapters: ContentChapterRepository
@Autowired
private lateinit var sections: ContentSectionRepository
@Autowired
private lateinit var articles: ContentArticleRepository
private val mockMvc: MockMvc by lazy {
MockMvcBuilders
.webAppContextSetup(context)
.apply<DefaultMockMvcBuilder>(springSecurity())
.build()
}
@Test
fun `global tree is filtered by role audience`() {
val all = seedMaterial(AUDIENCE_ALL)
val students = seedMaterial(AUDIENCE_STUDENTS)
val teachers = seedMaterial(AUDIENCE_TEACHERS)
mockMvc
.perform(get("/api/v1/content/tree"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.chapters[*].slug", containsInAnyOrder(all.chapter.slug)))
mockMvc
.perform(get("/api/v1/content/tree").with(jwtFor("STUDENT")))
.andExpect(status().isOk)
.andExpect(
jsonPath("$.chapters[*].slug", containsInAnyOrder(all.chapter.slug, students.chapter.slug)),
)
mockMvc
.perform(get("/api/v1/content/tree").with(jwtFor("TEACHER")))
.andExpect(status().isOk)
.andExpect(
jsonPath("$.chapters[*].slug", containsInAnyOrder(all.chapter.slug, teachers.chapter.slug)),
)
mockMvc
.perform(get("/api/v1/content/tree/all").with(jwtFor("ADMIN")))
.andExpect(status().isOk)
.andExpect(
jsonPath(
"$.chapters[*].slug",
containsInAnyOrder(all.chapter.slug, students.chapter.slug, teachers.chapter.slug),
),
)
}
@Test
fun `global article access follows audience and role`() {
val students = seedMaterial(AUDIENCE_STUDENTS)
val teachers = seedMaterial(AUDIENCE_TEACHERS)
mockMvc
.perform(get(articlePath(students)))
.andExpect(status().isNotFound)
mockMvc
.perform(get(articlePath(students)).with(jwtFor("STUDENT")))
.andExpect(status().isOk)
.andExpect(jsonPath("$.title").value(students.article.title))
mockMvc
.perform(get(articlePath(students)).with(jwtFor("TEACHER")))
.andExpect(status().isNotFound)
mockMvc
.perform(get(articlePath(teachers)).with(jwtFor("TEACHER")))
.andExpect(status().isOk)
.andExpect(jsonPath("$.title").value(teachers.article.title))
mockMvc
.perform(get(articlePath(teachers)).with(jwtFor("STUDENT")))
.andExpect(status().isNotFound)
}
@Test
fun `authoring creates requested audience and rejects invalid`() {
val node = authoring.createChapter("Студенческая глава ${UUID.randomUUID()}", null, audience = AUDIENCE_STUDENTS)
assertThat(chapters.findById(UUID.fromString(node.id)).orElseThrow().audience).isEqualTo(AUDIENCE_STUDENTS)
val defaultNode = authoring.createChapter("Публичная глава ${UUID.randomUUID()}", null)
assertThat(chapters.findById(UUID.fromString(defaultNode.id)).orElseThrow().audience).isEqualTo(AUDIENCE_ALL)
mockMvc
.perform(
post("/api/v1/content/chapters")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"title":"Неверная аудитория","audience":"EVERYONE"}""")
.with(jwtFor("ADMIN")),
).andExpect(status().isBadRequest)
}
@Test
fun `course chapter never gets audience and cannot change it`() {
val courseId =
UUID.fromString(
courseService
.create(CourseCreateRequest("Курс аудитории ${UUID.randomUUID()}"), UUID.randomUUID())
.id,
)
val node =
authoring.createChapter(
"Курсовая глава ${UUID.randomUUID()}",
null,
courseId,
audience = AUDIENCE_STUDENTS,
)
val chapterId = UUID.fromString(node.id)
assertThat(chapters.findById(chapterId).orElseThrow().audience).isNull()
mockMvc
.perform(
put("/api/v1/content/chapters/{id}/audience", chapterId)
.contentType(MediaType.APPLICATION_JSON)
.content("""{"audience":"TEACHERS"}""")
.with(jwtFor("ADMIN")),
).andExpect(status().isBadRequest)
}
@Test
fun `changing audience immediately changes access`() {
val material = seedMaterial(AUDIENCE_STUDENTS)
mockMvc
.perform(get(articlePath(material)).with(jwtFor("STUDENT")))
.andExpect(status().isOk)
mockMvc
.perform(get(articlePath(material)).with(jwtFor("TEACHER")))
.andExpect(status().isNotFound)
mockMvc
.perform(
put("/api/v1/content/chapters/{id}/audience", material.chapter.id)
.contentType(MediaType.APPLICATION_JSON)
.content("""{"audience":"TEACHERS"}""")
.with(jwtFor("ADMIN")),
).andExpect(status().isOk)
mockMvc
.perform(get(articlePath(material)).with(jwtFor("TEACHER")))
.andExpect(status().isOk)
mockMvc
.perform(get(articlePath(material)).with(jwtFor("STUDENT")))
.andExpect(status().isNotFound)
}
private fun seedMaterial(audience: String): SeededMaterial {
val chapter =
chapters.saveAndFlush(
ContentChapter(
title = "Глава ${UUID.randomUUID()}",
slug = "chapter-${UUID.randomUUID()}",
position = 1,
published = true,
audience = audience,
),
)
val section =
sections.saveAndFlush(
ContentSection(
chapterId = chapter.id,
title = "Раздел ${UUID.randomUUID()}",
slug = "section-${UUID.randomUUID()}",
position = 1,
published = true,
),
)
val article =
articles.saveAndFlush(
ContentArticle(
sectionId = section.id,
title = "Статья ${UUID.randomUUID()}",
slug = "article-${UUID.randomUUID()}",
bodyMd = "Текст",
position = 1,
published = true,
),
)
return SeededMaterial(chapter, section, article)
}
private fun articlePath(material: SeededMaterial): String =
"/api/v1/content/${material.chapter.slug}/${material.section.slug}/${material.article.slug}"
private fun jwtFor(role: String): RequestPostProcessor =
jwt()
.authorities(SimpleGrantedAuthority("ROLE_$role"))
.jwt { it.subject(UUID.randomUUID().toString()).claim("role", role) }
private data class SeededMaterial(
val chapter: ContentChapter,
val section: ContentSection,
val article: ContentArticle,
)
}

View File

@@ -129,7 +129,7 @@ class CourseAccessServiceMutationTest {
@Test
fun `lab without course is admin only`() {
val lab = Lab(number = 1, title = "Без курса")
val lab = Lab(number = 1, title = "Без курса", status = "published")
every { labs.findById(lab.id) } returns Optional.of(lab)
assertThatCode {
@@ -143,7 +143,7 @@ class CourseAccessServiceMutationTest {
@Test
fun `lab access follows course`() {
val active = course(Course.STATUS_ACTIVE)
val lab = Lab(number = 1, title = "Лабораторная", course = active)
val lab = Lab(number = 1, title = "Лабораторная", course = active, status = "published")
val groupId = UUID.randomUUID()
every { labs.findById(lab.id) } returns Optional.of(lab)
every { identity.currentGroupIds(TOKEN) } returns listOf(groupId)
@@ -210,7 +210,7 @@ class CourseAccessServiceMutationTest {
val active = course(Course.STATUS_ACTIVE)
val foreign = course(Course.STATUS_ACTIVE)
val teacherId = UUID.randomUUID()
val lab = Lab(number = 1, title = "Лабораторная", course = foreign)
val lab = Lab(number = 1, title = "Лабораторная", course = foreign, status = "published")
every { labs.findById(lab.id) } returns Optional.of(lab)
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns
listOf(CourseTeacher(course = active, accountId = teacherId))
@@ -219,6 +219,22 @@ class CourseAccessServiceMutationTest {
.isInstanceOf(ForbiddenException::class.java)
}
@Test
fun `draft lab is hidden from student`() {
val active = course(Course.STATUS_ACTIVE)
val lab = Lab(number = 1, title = "Черновик", course = active, status = "draft")
val groupId = UUID.randomUUID()
every { labs.findById(lab.id) } returns Optional.of(lab)
every { identity.currentGroupIds(TOKEN) } returns listOf(groupId)
every { groups.findByGroupIdInAndRemovedAtIsNull(any()) } returns
listOf(CourseGroup(course = active, groupId = groupId))
every { courses.findAllById(any()) } returns listOf(active)
assertThatThrownBy {
service.requireLabAccess(lab.id, jwt(CourseAccessService.ROLE_STUDENT))
}.isInstanceOf(ru.ysu.lms.common.NotFoundException::class.java)
}
@Test
fun `missing lab is not found`() {
every { labs.findById(any()) } returns Optional.empty()

View File

@@ -199,7 +199,7 @@ class CourseAccessServiceTest {
@Test
fun `lab access follows its course`() {
val active = course()
val lab = Lab(number = 1, title = "Лабораторная", course = active)
val lab = Lab(number = 1, title = "Лабораторная", course = active, status = "published")
val groupId = UUID.randomUUID()
every { labs.findById(lab.id) } returns Optional.of(lab)
stubStudentGroup(active, groupId)
@@ -211,7 +211,7 @@ class CourseAccessServiceTest {
@Test
fun `lab without course is admin only`() {
val lab = Lab(number = 1, title = "Без курса")
val lab = Lab(number = 1, title = "Без курса", status = "published")
every { labs.findById(lab.id) } returns Optional.of(lab)
assertThatCode {
@@ -222,6 +222,19 @@ class CourseAccessServiceTest {
}.isInstanceOf(ForbiddenException::class.java)
}
@Test
fun `student cannot access a draft lab of own course`() {
val active = course()
val lab = Lab(number = 1, title = "Черновик", course = active, status = "draft")
val groupId = UUID.randomUUID()
every { labs.findById(lab.id) } returns Optional.of(lab)
stubStudentGroup(active, groupId)
assertThatThrownBy {
service.requireLabAccess(lab.id, jwt(CourseAccessService.ROLE_STUDENT))
}.isInstanceOf(NotFoundException::class.java)
}
@Test
fun `missing lab is not found`() {
every { labs.findById(any()) } returns Optional.empty()

View File

@@ -164,6 +164,34 @@ class LabDetailsIntegrationTest {
).andExpect(status().isForbidden)
}
@Test
fun `student sees published labs but not drafts in list details or by id`() {
val courseId = newCourse()
val groupId = UUID.randomUUID()
courseService.attachGroup(courseId, CourseGroupRequest(groupId.toString()))
`when`(identity.currentGroupIds(anyString())).thenReturn(listOf(groupId))
val publishedId = createLab(courseId, 1, "Опубликованная", emptyList(), emptyList(), status = "published")
val draftId = createLab(courseId, 2, "Черновик", emptyList(), emptyList(), status = "draft")
val student = jwtFor(UUID.randomUUID(), "STUDENT")
mockMvc
.perform(get("/api/v1/labs").param("courseId", courseId.toString()).with(student))
.andExpect(status().isOk)
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].id").value(publishedId.toString()))
.andExpect(jsonPath("$[0].status").value("published"))
mockMvc
.perform(get("/api/v1/labs/details").param("courseId", courseId.toString()).with(student))
.andExpect(status().isOk)
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].id").value(publishedId.toString()))
mockMvc
.perform(get("/api/v1/labs/{id}", draftId).with(student))
.andExpect(status().isNotFound)
}
@Test
fun `course without labs returns an empty details list`() {
val courseId = newCourse()
@@ -228,13 +256,15 @@ class LabDetailsIntegrationTest {
title: String,
levels: List<LevelSaveRequest>,
variants: List<SeededVariant>,
) {
status: String = "published",
): UUID {
val created =
labService.create(
LabCreateRequest(
number = number,
title = title,
courseId = courseId,
status = status,
levels = levels,
),
)
@@ -251,6 +281,7 @@ class LabDetailsIntegrationTest {
),
)
}
return labId
}
private fun jwtFor(