feat(academic): защита активов материалов
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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" +
|
||||
"\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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user