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:
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user