feat(academic): защита активов материалов

This commit is contained in:
2026-10-27 19:41:37 +03:00
parent 3fd0d41cf2
commit 9aef82e217
13 changed files with 719 additions and 23 deletions

View File

@@ -2,3 +2,5 @@ package ru.ysu.lms.academic.content
/** Аудитория глобальной главы, доступной без авторизации. */
const val AUDIENCE_ALL = "ALL"
const val AUDIENCE_STUDENTS = "STUDENTS"
const val AUDIENCE_TEACHERS = "TEACHERS"

View File

@@ -2,9 +2,11 @@ package ru.ysu.lms.academic.content
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.academic.domain.ArticleAsset
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.ArticleAssetRepository
import ru.ysu.lms.academic.repo.ContentArticleRepository
import ru.ysu.lms.academic.repo.ContentChapterRepository
import ru.ysu.lms.academic.repo.ContentSectionRepository
@@ -27,6 +29,7 @@ class ContentImporter(
private val chapters: ContentChapterRepository,
private val sections: ContentSectionRepository,
private val articles: ContentArticleRepository,
private val assetLinks: ArticleAssetRepository,
private val assets: AssetStorage,
) {
@Transactional
@@ -43,9 +46,9 @@ class ContentImporter(
if (contentFiles.isEmpty()) {
throw BadRequestException("В архиве нет каталога chapters/ с .md файлами")
}
val linkRewrite = storeAssets(files.filterKeys(::isAssetsPath))
val storedAssets = storeAssets(files.filterKeys(::isAssetsPath))
val parsed = parseArticles(contentFiles)
return mergeIntoDb(parsed, linkRewrite, sync)
return mergeIntoDb(parsed, storedAssets, sync)
}
private fun requireZip(filename: String) {
@@ -54,22 +57,22 @@ class ContentImporter(
private fun mergeIntoDb(
parsed: List<ParsedArticle>,
linkRewrite: Map<String, String>,
storedAssets: StoredAssets,
sync: Boolean,
): ContentImportResult {
val state = ImportState()
for (p in parsed) {
val chapter = chapterFor(p, state)
val section = sectionFor(chapter, p, state)
val body = linkRewrite.entries.fold(p.body) { acc, (from, to) -> acc.replace(from, to) }
upsertArticle(section, p, body, state)
val body = storedAssets.rewrite(p.body)
upsertArticle(section, p, body, storedAssets, state)
}
val removed = if (sync) removeMissing(state) else 0
return ContentImportResult(
chapters = state.chapters,
sections = state.sections,
articles = state.articles,
assets = linkRewrite.size,
assets = storedAssets.refs.size,
removed = removed,
)
}
@@ -136,6 +139,7 @@ class ContentImporter(
section: ContentSection,
p: ParsedArticle,
body: String,
storedAssets: StoredAssets,
state: ImportState,
) {
val slug = p.fmSlug?.takeIf { it.isNotBlank() } ?: Slugs.slugify(p.fileTitle) ?: "article-${state.articles + 1}"
@@ -148,11 +152,33 @@ class ContentImporter(
bodyMd = body
position = p.articlePos
}
articles.save(article)
// saveAndFlush гарантирует строку articles до вставки article_assets (FK).
articles.saveAndFlush(article)
replaceAssetLinks(article, body, storedAssets)
state.seenArticles.add(article.id)
state.articles += 1
}
/** Связи статьи с активами полностью заменяются при повторном импорте. */
private fun replaceAssetLinks(
article: ContentArticle,
body: String,
storedAssets: StoredAssets,
) {
assetLinks.deleteByArticleId(article.id)
for (ref in storedAssets.referencedIn(body)) {
assetLinks.save(
ArticleAsset(
articleId = article.id,
filename = ref.originalName,
storedPath = ref.storedPath,
mime = ref.mime,
size = ref.size,
),
)
}
}
/** При синхронизации удаляет разделы и статьи главы, которых не было в архиве. */
private fun removeMissing(state: ImportState): Int =
state.touchedChapters.sumOf { chapterId ->
@@ -180,15 +206,22 @@ class ContentImporter(
return removed
}
private fun storeAssets(assetFiles: Map<String, ByteArray>): Map<String, String> {
val linkRewrite = mutableMapOf<String, String>()
private fun storeAssets(assetFiles: Map<String, ByteArray>): StoredAssets {
val refs = mutableListOf<StoredAssetRef>()
for ((path, data) in assetFiles) {
val name = path.substringAfterLast('/')
if (name.isBlank()) continue
val stored = assets.store(name, data)
linkRewrite["assets/$name"] = "/api/v1/content/assets/${stored.publicName}"
refs +=
StoredAssetRef(
originalName = name,
storedPath = stored.storedPath.toString(),
mime = stored.mime,
size = data.size.toLong(),
url = "/api/v1/content/assets/${stored.publicName}",
)
}
return linkRewrite
return StoredAssets(refs)
}
private fun isAssetsPath(path: String): Boolean = path.contains("/assets/") || path.startsWith("assets/")
@@ -297,6 +330,22 @@ class ContentImporter(
value
}
private data class StoredAssetRef(
val originalName: String,
val storedPath: String,
val mime: String,
val size: Long,
val url: String,
)
private data class StoredAssets(
val refs: List<StoredAssetRef>,
) {
fun rewrite(body: String): String = refs.fold(body) { acc, ref -> acc.replace("assets/${ref.originalName}", ref.url) }
fun referencedIn(body: String): List<StoredAssetRef> = refs.filter { body.contains(it.url) }
}
private data class ParsedArticle(
val chapterPos: Int,
val chapterDir: String,

View File

@@ -2,7 +2,9 @@ package ru.ysu.lms.academic.content
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.academic.domain.ArticleAsset
import ru.ysu.lms.academic.domain.ContentChapter
import ru.ysu.lms.academic.repo.ArticleAssetRepository
import ru.ysu.lms.academic.repo.ContentArticleRepository
import ru.ysu.lms.academic.repo.ContentChapterRepository
import ru.ysu.lms.academic.repo.ContentSectionRepository
@@ -60,6 +62,17 @@ data class ContentImportResult(
val removed: Int = 0,
)
/**
* Наследуемая видимость актива: статья → раздел → глава.
* `published` — опубликованы все три уровня.
*/
data class ArticleAssetAccess(
val articleId: UUID,
val courseId: UUID?,
val audience: String?,
val published: Boolean,
)
/**
* Новое расположение материалов: порядок глав, порядок разделов внутри глав
* (и перенос разделов между главами), порядок статей внутри разделов
@@ -98,6 +111,7 @@ class ContentService(
private val chapters: ContentChapterRepository,
private val sections: ContentSectionRepository,
private val articles: ContentArticleRepository,
private val articleAssets: ArticleAssetRepository,
private val importer: ContentImporter,
) {
/** Публичное дерево: только глобальные главы с аудиторией ALL. */
@@ -172,6 +186,38 @@ class ContentService(
return articleView(ch, sectionSlug, articleSlug, includeHidden)
}
/**
* Все статьи, к которым привязан актив, с наследуемой видимостью.
* Один файл может использоваться несколькими статьями — решение принимает
* вызывающая сторона по правилу «доступен, если видима хотя бы одна».
*/
@Transactional(readOnly = true)
fun assetAccesses(name: String): List<ArticleAssetAccess> =
(articleAssets.findByStoredPathEndingWith(name) + articleAssets.findByFilename(name))
.distinctBy { it.id }
.filter { it.matchesAssetName(name) }
.mapNotNull { assetAccess(it) }
private fun assetAccess(asset: ArticleAsset): ArticleAssetAccess? {
val articleId = asset.articleId ?: return null
val article = articles.findById(articleId).orElse(null)
val section = article?.let { sections.findById(it.sectionId).orElse(null) }
val chapter = section?.let { chapters.findById(it.chapterId).orElse(null) }
return if (article != null && section != null && chapter != null) {
ArticleAssetAccess(
articleId = article.id,
courseId = chapter.courseId,
audience = chapter.audience,
published = chapter.published && section.published && article.published,
)
} else {
null
}
}
private fun ArticleAsset.matchesAssetName(name: String): Boolean =
filename == name || storedPath == name || storedPath.substringAfterLast('/') == name
private fun articleView(
ch: ContentChapter,
sectionSlug: String,

View File

@@ -3,20 +3,37 @@ package ru.ysu.lms.academic.controller
import org.springframework.core.io.FileSystemResource
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import ru.ysu.lms.academic.content.AUDIENCE_ALL
import ru.ysu.lms.academic.content.AUDIENCE_STUDENTS
import ru.ysu.lms.academic.content.AUDIENCE_TEACHERS
import ru.ysu.lms.academic.content.ArticleAssetAccess
import ru.ysu.lms.academic.content.AssetStorage
import ru.ysu.lms.academic.content.ContentArticleView
import ru.ysu.lms.academic.content.ContentService
import ru.ysu.lms.academic.course.CourseAccessService
import ru.ysu.lms.common.ForbiddenException
import ru.ysu.lms.common.NotFoundException
import java.util.UUID
/** Публичное чтение учебных материалов — доступно без аутентификации. */
/**
* Публичное чтение учебных материалов.
*
* `tree` и статьи глобальных глав с аудиторией ALL доступны без аутентификации.
* Активы без записи в `article_assets` исторически публичны (legacy-ссылки);
* привязанные активы наследуют видимость статьи, раздела и главы.
*/
@RestController
@RequestMapping("/api/v1/content")
class ContentPublicController(
private val contentService: ContentService,
private val assetStorage: AssetStorage,
private val accessService: CourseAccessService,
) {
@GetMapping("/tree")
fun tree() = contentService.tree()
@@ -43,7 +60,10 @@ class ContentPublicController(
@GetMapping("/assets/{name}")
fun asset(
@PathVariable name: String,
@AuthenticationPrincipal jwt: Jwt?,
): ResponseEntity<FileSystemResource> {
val accesses = contentService.assetAccesses(name)
if (accesses.isNotEmpty()) requireAssetAccess(accesses, jwt)
val (path, mime) =
assetStorage.read(name)
?: return ResponseEntity.notFound().build()
@@ -63,6 +83,98 @@ class ContentPublicController(
builder.header("X-Content-Type-Options", "nosniff")
return builder.body(FileSystemResource(path))
}
/**
* Аноним видит только глобальный опубликованный актив аудитории ALL.
* Аутентифицированный — если видима хотя бы одна связанная статья.
* Скрытые материалы отдают 404, чтобы не раскрывать их существование.
*/
private fun requireAssetAccess(
accesses: List<ArticleAssetAccess>,
jwt: Jwt?,
) {
if (jwt == null) {
requireAnonymousAssetAccess(accesses)
} else {
requireAuthenticatedAssetAccess(accesses, jwt)
}
}
private fun requireAnonymousAssetAccess(accesses: List<ArticleAssetAccess>) {
if (accesses.any { it.isPublic }) return
throw NotFoundException("Файл не найден")
}
private fun requireAuthenticatedAssetAccess(
accesses: List<ArticleAssetAccess>,
jwt: Jwt,
) {
val role = jwt.getClaimAsString("role")
if (role == CourseAccessService.ROLE_ADMIN) return
var hidden = false
var denied = false
for (access in accesses) {
when (decision(access, role, jwt)) {
AssetDecision.ALLOW -> return
AssetDecision.HIDDEN -> hidden = true
AssetDecision.DENY -> denied = true
}
}
if (hidden && !denied) throw NotFoundException("Файл не найден")
throw ForbiddenException("Файл недоступен")
}
private fun decision(
access: ArticleAssetAccess,
role: String?,
jwt: Jwt,
): AssetDecision {
val courseId = access.courseId
return if (courseId != null) courseDecision(courseId, access, role, jwt) else globalDecision(access, role)
}
private fun courseDecision(
courseId: UUID,
access: ArticleAssetAccess,
role: String?,
jwt: Jwt,
): AssetDecision {
try {
accessService.requireCourseAccess(courseId, jwt)
} catch (_: ForbiddenException) {
return AssetDecision.DENY
}
return when {
role !in COURSE_ROLES -> AssetDecision.DENY
role == CourseAccessService.ROLE_STUDENT && !access.published -> AssetDecision.HIDDEN
else -> AssetDecision.ALLOW
}
}
private fun globalDecision(
access: ArticleAssetAccess,
role: String?,
): AssetDecision =
when {
!access.published -> AssetDecision.HIDDEN
access.audience == AUDIENCE_ALL && role in COURSE_ROLES -> AssetDecision.ALLOW
access.audience == AUDIENCE_STUDENTS && role == CourseAccessService.ROLE_STUDENT -> AssetDecision.ALLOW
access.audience == AUDIENCE_TEACHERS && role == CourseAccessService.ROLE_TEACHER -> AssetDecision.ALLOW
else -> AssetDecision.DENY
}
private val ArticleAssetAccess.isPublic: Boolean
get() = courseId == null && audience == AUDIENCE_ALL && published
private enum class AssetDecision {
ALLOW,
HIDDEN,
DENY,
}
private companion object {
val COURSE_ROLES = setOf(CourseAccessService.ROLE_STUDENT, CourseAccessService.ROLE_TEACHER)
}
}
internal fun ContentArticleView.toDto(): ContentPublicController.ContentArticleViewDto =

View File

@@ -5,6 +5,7 @@ import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.time.Instant
import java.util.UUID
@Entity
@@ -41,3 +42,19 @@ class ContentArticle(
@Column(nullable = false) var position: Int = 0,
@Column(nullable = false) var published: Boolean = true,
)
/**
* Файл, на который ссылается статья. Видимость наследуется от статьи:
* `article_assets → articles → sections → chapters`.
*/
@Entity
@Table(name = "article_assets")
class ArticleAsset(
@Id var id: UUID = Ids.v7(),
@Column(name = "article_id") var articleId: UUID? = null,
@Column(nullable = false, length = 255) var filename: String = "",
@Column(name = "stored_path", nullable = false, length = 512) var storedPath: String = "",
@Column(nullable = false, length = 128) var mime: String = "",
@Column(nullable = false) var size: Long = 0,
@Column(name = "created_at", nullable = false) var createdAt: Instant = Instant.now(),
)

View File

@@ -1,6 +1,7 @@
package ru.ysu.lms.academic.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.academic.domain.ArticleAsset
import ru.ysu.lms.academic.domain.ContentArticle
import ru.ysu.lms.academic.domain.ContentChapter
import ru.ysu.lms.academic.domain.ContentSection
@@ -56,3 +57,13 @@ interface ContentArticleRepository : JpaRepository<ContentArticle, UUID> {
fun findAllByOrderByPositionAscIdAsc(): List<ContentArticle>
}
interface ArticleAssetRepository : JpaRepository<ArticleAsset, UUID> {
fun findByStoredPathEndingWith(suffix: String): List<ArticleAsset>
fun findByFilename(filename: String): List<ArticleAsset>
fun findByArticleId(articleId: UUID): List<ArticleAsset>
fun deleteByArticleId(articleId: UUID)
}

View File

@@ -0,0 +1,44 @@
-- Backfill связей статья → актив из markdown-ссылок.
-- Исторические статьи уже содержат публичные URL /api/v1/content/assets/{name},
-- но строки article_assets не создавались при импорте. Восстанавливаем связи,
-- чтобы видимость актива наследовалась от статьи/раздела/главы.
WITH refs AS (
SELECT DISTINCT
a.id AS article_id,
(m.match)[1] AS asset_name
FROM articles a
CROSS JOIN LATERAL regexp_matches(
a.body_md,
'/api/v1/content/assets/([A-Za-z0-9._-]+)',
'g'
) AS m(match)
)
INSERT INTO article_assets (article_id, filename, stored_path, mime, size)
SELECT r.article_id,
r.asset_name,
r.asset_name,
CASE lower(substring(r.asset_name from '\.([^.]+)$'))
WHEN 'png' THEN 'image/png'
WHEN 'jpg' THEN 'image/jpeg'
WHEN 'jpeg' THEN 'image/jpeg'
WHEN 'gif' THEN 'image/gif'
WHEN 'svg' THEN 'image/svg+xml'
WHEN 'webp' THEN 'image/webp'
WHEN 'mp4' THEN 'video/mp4'
WHEN 'webm' THEN 'video/webm'
WHEN 'ogg' THEN 'video/ogg'
WHEN 'ogv' THEN 'video/ogg'
ELSE 'application/octet-stream'
END,
0
FROM refs r
WHERE NOT EXISTS (
SELECT 1
FROM article_assets aa
WHERE aa.article_id = r.article_id
AND (
aa.filename = r.asset_name
OR aa.stored_path = r.asset_name
OR right(aa.stored_path, length(r.asset_name)) = r.asset_name
)
);

View File

@@ -0,0 +1,328 @@
package ru.ysu.lms.academic.content
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.anyString
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.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.context.bean.override.mockito.MockitoBean
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
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.CourseGroupRequest
import ru.ysu.lms.academic.course.CourseService
import ru.ysu.lms.academic.course.CourseTeacherRequest
import ru.ysu.lms.academic.domain.ArticleAsset
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.identity.IdentityClient
import ru.ysu.lms.academic.repo.ArticleAssetRepository
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.nio.file.Path
import java.util.UUID
/**
* Наследуемая видимость активов: курсовой актив требует доступ к курсу,
* глобальный ALL-актив публичен, а старые несвязанные файлы остаются публичными.
*/
@Testcontainers
@SpringBootTest
@Transactional
class ContentAssetAccessIntegrationTest {
companion object {
@Container
@ServiceConnection
@JvmStatic
val postgres = PostgreSQLContainer("postgres:18")
private val assetsDir: Path = Files.createTempDirectory("lms-assets")
private val ASSET_BYTES = byteArrayOf(1, 2, 3, 4)
@JvmStatic
@DynamicPropertySource
fun assetsDir(registry: DynamicPropertyRegistry) {
registry.add("lms.content.assets-dir") { assetsDir.toString() }
}
}
@Autowired
private lateinit var context: WebApplicationContext
@Autowired
private lateinit var courseService: CourseService
@Autowired
private lateinit var chapters: ContentChapterRepository
@Autowired
private lateinit var sections: ContentSectionRepository
@Autowired
private lateinit var articles: ContentArticleRepository
@Autowired
private lateinit var articleAssets: ArticleAssetRepository
@MockitoBean
private lateinit var identity: IdentityClient
private val mockMvc: MockMvc by lazy {
MockMvcBuilders
.webAppContextSetup(context)
.apply<DefaultMockMvcBuilder>(springSecurity())
.build()
}
@Test
fun `course asset requires authentication`() {
val chapter = seedChapter(courseId = newCourse(), audience = null)
val material = seedMaterial(chapter)
val name = storeAsset()
link(material.article, name)
mockMvc
.perform(get("/api/v1/content/assets/{name}", name))
.andExpect(status().isNotFound)
}
@Test
fun `course teacher can read asset of own course`() {
val courseId = newCourse()
val teacherId = UUID.randomUUID()
courseService.assignTeacher(courseId, CourseTeacherRequest(teacherId.toString()))
val material = seedMaterial(seedChapter(courseId = courseId, audience = null))
val name = storeAsset()
link(material.article, name)
val response =
mockMvc
.perform(
get("/api/v1/content/assets/{name}", name)
.with(jwtFor(teacherId, "TEACHER")),
).andExpect(status().isOk)
.andReturn()
.response
assertThat(response.contentAsByteArray).isEqualTo(ASSET_BYTES)
}
@Test
fun `foreign teacher and student are forbidden for course asset`() {
val courseId = newCourse()
val material = seedMaterial(seedChapter(courseId = courseId, audience = null))
val name = storeAsset()
link(material.article, name)
`when`(identity.currentGroupIds(anyString())).thenReturn(listOf(UUID.randomUUID()))
mockMvc
.perform(
get("/api/v1/content/assets/{name}", name)
.with(jwtFor(UUID.randomUUID(), "TEACHER")),
).andExpect(status().isForbidden)
mockMvc
.perform(
get("/api/v1/content/assets/{name}", name)
.with(jwtFor(UUID.randomUUID(), "STUDENT")),
).andExpect(status().isForbidden)
}
@Test
fun `course student of current group can read published asset`() {
val courseId = newCourse()
val groupId = UUID.randomUUID()
courseService.attachGroup(courseId, CourseGroupRequest(groupId.toString()))
val material = seedMaterial(seedChapter(courseId = courseId, audience = null))
val name = storeAsset()
link(material.article, name)
`when`(identity.currentGroupIds(anyString())).thenReturn(listOf(groupId))
mockMvc
.perform(
get("/api/v1/content/assets/{name}", name)
.with(jwtFor(UUID.randomUUID(), "STUDENT")),
).andExpect(status().isOk)
}
@Test
fun `hidden course asset is not served to student but is served to teacher`() {
val courseId = newCourse()
val teacherId = UUID.randomUUID()
courseService.assignTeacher(courseId, CourseTeacherRequest(teacherId.toString()))
val material = seedMaterial(seedChapter(courseId = courseId, audience = null), published = false)
val name = storeAsset()
link(material.article, name)
val groupId = UUID.randomUUID()
courseService.attachGroup(courseId, CourseGroupRequest(groupId.toString()))
`when`(identity.currentGroupIds(anyString())).thenReturn(listOf(groupId))
mockMvc
.perform(
get("/api/v1/content/assets/{name}", name)
.with(jwtFor(UUID.randomUUID(), "STUDENT")),
).andExpect(status().isNotFound)
mockMvc
.perform(
get("/api/v1/content/assets/{name}", name)
.with(jwtFor(teacherId, "TEACHER")),
).andExpect(status().isOk)
}
@Test
fun `published global ALL asset stays public`() {
val material = seedMaterial(seedChapter(courseId = null, audience = AUDIENCE_ALL))
val name = storeAsset()
link(material.article, name)
mockMvc
.perform(get("/api/v1/content/assets/{name}", name))
.andExpect(status().isOk)
}
@Test
fun `legacy asset without article association stays public`() {
val name = storeAsset()
mockMvc
.perform(get("/api/v1/content/assets/{name}", name))
.andExpect(status().isOk)
}
@Test
fun `global STUDENTS asset requires student role`() {
val material = seedMaterial(seedChapter(courseId = null, audience = AUDIENCE_STUDENTS))
val name = storeAsset()
link(material.article, name)
mockMvc
.perform(get("/api/v1/content/assets/{name}", name))
.andExpect(status().isNotFound)
mockMvc
.perform(
get("/api/v1/content/assets/{name}", name)
.with(jwtFor(UUID.randomUUID(), "TEACHER")),
).andExpect(status().isForbidden)
mockMvc
.perform(
get("/api/v1/content/assets/{name}", name)
.with(jwtFor(UUID.randomUUID(), "STUDENT")),
).andExpect(status().isOk)
}
@Test
fun `asset is visible when at least one associated article is public`() {
val name = storeAsset()
val hidden = seedMaterial(seedChapter(courseId = null, audience = AUDIENCE_ALL), published = false)
val visible = seedMaterial(seedChapter(courseId = null, audience = AUDIENCE_ALL))
link(hidden.article, name)
link(visible.article, name)
mockMvc
.perform(get("/api/v1/content/assets/{name}", name))
.andExpect(status().isOk)
}
private fun newCourse(): UUID =
UUID.fromString(
courseService
.create(CourseCreateRequest("Курс ${UUID.randomUUID()}"), UUID.randomUUID())
.id,
)
private fun seedChapter(
courseId: UUID?,
audience: String?,
published: Boolean = true,
): ContentChapter =
chapters.saveAndFlush(
ContentChapter(
title = "Глава ${UUID.randomUUID()}",
slug = "chapter-${UUID.randomUUID()}",
position = 1,
published = published,
courseId = courseId,
audience = audience,
),
)
private fun seedMaterial(
chapter: ContentChapter,
published: Boolean = true,
): SeededMaterial {
val section =
sections.saveAndFlush(
ContentSection(
chapterId = chapter.id,
title = "Раздел ${UUID.randomUUID()}",
slug = "section-${UUID.randomUUID()}",
position = 1,
published = published,
),
)
val article =
articles.saveAndFlush(
ContentArticle(
sectionId = section.id,
title = "Статья ${UUID.randomUUID()}",
slug = "article-${UUID.randomUUID()}",
bodyMd = "Текст",
position = 1,
published = published,
),
)
return SeededMaterial(chapter, section, article)
}
private fun link(
article: ContentArticle,
name: String,
) {
articleAssets.saveAndFlush(
ArticleAsset(
articleId = article.id,
filename = name,
storedPath = name,
mime = "image/png",
size = ASSET_BYTES.size.toLong(),
),
)
}
private fun storeAsset(): String {
val name = "${UUID.randomUUID()}.png"
Files.write(assetsDir.resolve(name), ASSET_BYTES)
return name
}
private fun jwtFor(
subject: UUID,
role: String,
) = jwt().jwt { it.subject(subject.toString()).claim("role", role) }
private data class SeededMaterial(
val chapter: ContentChapter,
val section: ContentSection,
val article: ContentArticle,
)
}

View File

@@ -10,6 +10,7 @@ import org.springframework.test.context.DynamicPropertySource
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.postgresql.PostgreSQLContainer
import ru.ysu.lms.academic.repo.ArticleAssetRepository
import ru.ysu.lms.academic.repo.ContentArticleRepository
import ru.ysu.lms.academic.repo.ContentChapterRepository
import ru.ysu.lms.academic.repo.ContentSectionRepository
@@ -42,9 +43,15 @@ class ContentImportIntegrationTest {
ZipOutputStream(out).use { zos ->
zos.putNextEntry(ZipEntry("chapters/05-Тестовая/01-Раздел/01-Статья.md"))
zos.write(
"---\ntitle: \"Тестовая статья\"\nslug: \"test-article\"\n---\nТело статьи".toByteArray(),
(
"---\ntitle: \"Тестовая статья\"\nslug: \"test-article\"\n---\n" +
"![Картинка](assets/pic.png)\n\nТело статьи"
).toByteArray(),
)
zos.closeEntry()
zos.putNextEntry(ZipEntry("assets/pic.png"))
zos.write(byteArrayOf(1, 2, 3))
zos.closeEntry()
}
return out.toByteArray()
}
@@ -62,12 +69,16 @@ class ContentImportIntegrationTest {
@Autowired
private lateinit var articles: ContentArticleRepository
@Autowired
private lateinit var articleAssets: ArticleAssetRepository
@Test
fun `re-import does not duplicate chapter, section or article`() {
val zip = articleZip()
val first = importer.importZip("content.zip", zip, sync = false)
assertThat(first.chapters).isEqualTo(1)
assertThat(first.assets).isEqualTo(1)
val chapter = chapters.findByTitleIgnoreCase("Тестовая")
assertThat(chapter).isNotNull
@@ -76,6 +87,12 @@ class ContentImportIntegrationTest {
val article = articles.findBySectionIdAndTitleIgnoreCase(section!!.id, "Тестовая статья")
assertThat(article).isNotNull
assertThat(article!!.slug).isEqualTo("test-article")
assertThat(article.bodyMd).contains("/api/v1/content/assets/")
assertThat(articleAssets.findByArticleId(article.id)).hasSize(1)
val firstAsset = articleAssets.findByArticleId(article.id).single()
assertThat(firstAsset.filename).isEqualTo("pic.png")
assertThat(firstAsset.mime).isEqualTo("image/png")
assertThat(firstAsset.size).isEqualTo(3)
// повторный импорт того же архива обновляет существующие записи, а не создаёт новые
importer.importZip("content.zip", zip, sync = false)
@@ -83,5 +100,8 @@ class ContentImportIntegrationTest {
assertThat(chapters.findByTitleIgnoreCase("Тестовая")!!.id).isEqualTo(chapter.id)
assertThat(sections.findByChapterIdOrderByPositionAscIdAsc(chapter.id)).hasSize(1)
assertThat(articles.findBySectionIdOrderByPositionAscIdAsc(section.id)).hasSize(1)
val assetsAfterReimport = articleAssets.findByArticleId(article.id)
assertThat(assetsAfterReimport).hasSize(1)
assertThat(assetsAfterReimport.single().id).isNotEqualTo(firstAsset.id)
}
}

View File

@@ -1,7 +1,9 @@
package ru.ysu.lms.academic.content
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.slot
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
@@ -9,6 +11,7 @@ import org.junit.jupiter.api.io.TempDir
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.ArticleAssetRepository
import ru.ysu.lms.academic.repo.ContentArticleRepository
import ru.ysu.lms.academic.repo.ContentChapterRepository
import ru.ysu.lms.academic.repo.ContentSectionRepository
@@ -25,6 +28,7 @@ class ContentImporterTest {
private val chapters = mockk<ContentChapterRepository>()
private val sections = mockk<ContentSectionRepository>()
private val articles = mockk<ContentArticleRepository>()
private val assetLinks = mockk<ArticleAssetRepository>()
private fun importer(): ContentImporter {
every { chapters.findByTitleIgnoreCase(any()) } returns null
@@ -39,9 +43,11 @@ class ContentImporterTest {
every { articles.findBySectionIdAndTitleIgnoreCase(any(), any()) } returns null
every { articles.findBySectionIdAndSlug(any(), any()) } returns null
every { articles.save(any()) } answers { firstArg() }
every { articles.saveAndFlush(any()) } answers { firstArg() }
every { assetLinks.deleteByArticleId(any()) } just runs
every { assetLinks.save(any()) } answers { firstArg() }
return ContentImporter(chapters, sections, articles, AssetStorage(tempDir.toString()))
return ContentImporter(chapters, sections, articles, assetLinks, AssetStorage(tempDir.toString()))
}
private fun articleZip(
@@ -63,7 +69,7 @@ class ContentImporterTest {
val savedChapter = slot<ContentChapter>()
val savedArticle = slot<ContentArticle>()
every { chapters.save(capture(savedChapter)) } answers { firstArg() }
every { articles.save(capture(savedArticle)) } answers { firstArg() }
every { articles.saveAndFlush(capture(savedArticle)) } answers { firstArg() }
val result = importer.importZip("content.zip", articleZip("Ввод-вывод", "c-input"), sync = false)