From 1de7207c3cc8c116cf3efaa44e5233eb582a4cf8 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Tue, 27 Oct 2026 20:39:18 +0300 Subject: [PATCH] =?UTF-8?q?feat(academic):=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=B5=D0=BF=D0=BE=D0=B4=D0=B0=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8F,=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF?= =?UTF-8?q?=D1=8B=20=D0=BA=D1=83=D1=80=D1=81=D0=B0=20=D0=B8=20=D0=B0=D1=83?= =?UTF-8?q?=D0=B4=D0=B8=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ru/ysu/lms/academic/audit/AuditClient.kt | 104 ++++ .../content/ContentAuthoringService.kt | 158 +++++-- .../controller/ContentAdminController.kt | 132 +++++- .../CourseContentAdminController.kt | 316 +++++++++++++ .../academic/controller/CourseController.kt | 116 ++++- .../lms/academic/controller/LabController.kt | 79 +++- .../ru/ysu/lms/academic/course/CourseDtos.kt | 7 + .../lms/academic/course/CourseGroupService.kt | 49 ++ .../lms/academic/identity/IdentityClient.kt | 23 + .../lms/academic/repo/ContentRepositories.kt | 2 + .../lms/academic/repo/CourseRepositories.kt | 2 + .../ysu/lms/academic/audit/AuditClientTest.kt | 79 ++++ .../CourseContentAuthoringIntegrationTest.kt | 447 ++++++++++++++++++ .../course/CourseGroupTeacherAccessTest.kt | 248 ++++++++++ 14 files changed, 1696 insertions(+), 66 deletions(-) create mode 100644 services/academic-service/src/main/kotlin/ru/ysu/lms/academic/audit/AuditClient.kt create mode 100644 services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseContentAdminController.kt create mode 100644 services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseGroupService.kt create mode 100644 services/academic-service/src/test/kotlin/ru/ysu/lms/academic/audit/AuditClientTest.kt create mode 100644 services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/CourseContentAuthoringIntegrationTest.kt create mode 100644 services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseGroupTeacherAccessTest.kt diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/audit/AuditClient.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/audit/AuditClient.kt new file mode 100644 index 0000000..8566b77 --- /dev/null +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/audit/AuditClient.kt @@ -0,0 +1,104 @@ +package ru.ysu.lms.academic.audit + +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.MediaType +import org.springframework.http.client.JdkClientHttpRequestFactory +import org.springframework.stereotype.Service +import org.springframework.web.client.RestClient +import java.net.http.HttpClient +import java.time.Duration + +/** + * Клиент внутреннего endpoint аудита identity-service. + * + * Запись аудита — best-effort: недоступность identity не должна ломать бизнес-операцию, + * поэтому любые ошибки только логируются. Логин актора здесь не разрешается — identity + * уже знает пользователя по `actorAccountId`. + */ +@Service +class AuditClient internal constructor( + private val client: RestClient, + private val internalToken: String, +) { + @Autowired + constructor( + @Value("\${lms.identity.base-url:http://localhost:8081}") baseUrl: String, + @Value("\${lms.internal.token:dev-internal-token}") internalToken: String, + ) : this(restClient(baseUrl), internalToken) + + @Suppress("TooGenericExceptionCaught") + fun record( + actorAccountId: String?, + action: String, + entityType: String, + entityId: String?, + courseId: String? = null, + studentId: String? = null, + details: String = "", + ) { + try { + client + .post() + .uri(AUDIT_PATH) + .header(INTERNAL_TOKEN_HEADER, internalToken) + .contentType(MediaType.APPLICATION_JSON) + .body( + AuditRequest( + actorAccountId = actorAccountId, + action = action, + entityType = entityType, + entityId = entityId, + courseId = courseId, + studentId = studentId, + details = details, + ), + ).retrieve() + .toBodilessEntity() + } catch (e: Exception) { + log.warn( + "Не удалось записать аудит: action={}, entityType={}, entityId={}", + action, + entityType, + entityId, + e, + ) + } + } + + private companion object { + private val log = LoggerFactory.getLogger(AuditClient::class.java) + private const val AUDIT_PATH = "/api/v1/internal/audit" + private const val INTERNAL_TOKEN_HEADER = "X-Internal-Token" + } +} + +/** Тело `POST /api/v1/internal/audit`; поля null допустимы по контракту. */ +internal data class AuditRequest( + val actorAccountId: String?, + val actorLogin: String? = null, + val action: String, + val entityType: String, + val entityId: String?, + val courseId: String?, + val studentId: String?, + val details: String, +) + +/** Таймауты обязательны: зависший audit не должен надолго задерживать запрос. */ +private const val CONNECT_TIMEOUT_SECONDS = 3L +private const val READ_TIMEOUT_SECONDS = 5L + +private fun restClient(baseUrl: String): RestClient = + RestClient + .builder() + .baseUrl(baseUrl) + .requestFactory( + JdkClientHttpRequestFactory( + HttpClient + .newBuilder() + .connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SECONDS)) + .build(), + ).apply { setReadTimeout(Duration.ofSeconds(READ_TIMEOUT_SECONDS)) }, + ).build() diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentAuthoringService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentAuthoringService.kt index a8bfc46..e9a9584 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentAuthoringService.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentAuthoringService.kt @@ -9,6 +9,7 @@ import ru.ysu.lms.academic.repo.ContentArticleRepository import ru.ysu.lms.academic.repo.ContentChapterRepository import ru.ysu.lms.academic.repo.ContentSectionRepository import ru.ysu.lms.common.BadRequestException +import ru.ysu.lms.common.ForbiddenException import ru.ysu.lms.common.NotFoundException import java.util.UUID @@ -32,8 +33,14 @@ class ContentAuthoringService( articleId: UUID, title: String, bodyMd: String, + courseId: UUID? = null, ) { - val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") } + val a = + if (courseId == null) { + articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") } + } else { + requireArticleInCourse(articleId, courseId) + } if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым") a.title = title.trim() a.bodyMd = bodyMd @@ -41,28 +48,48 @@ class ContentAuthoringService( } @Transactional - fun deleteArticle(articleId: UUID) { - if (!articles.existsById(articleId)) throw NotFoundException("Статья не найдена") + fun deleteArticle( + articleId: UUID, + courseId: UUID? = null, + ) { + if (courseId != null) { + requireArticleInCourse(articleId, courseId) + } else if (!articles.existsById(articleId)) { + throw NotFoundException("Статья не найдена") + } articles.deleteById(articleId) } + /** + * Глобальная глава (`courseId == null`) получает аудиторию ALL и уникальный slug + * среди глобальных глав. Курсовая глава получает `courseId`, пустую аудиторию + * и уникальный slug внутри курса. + */ @Transactional fun createChapter( title: String, slug: String?, + courseId: UUID? = null, ): ChapterTreeNode { if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым") + val scope = + if (courseId == null) { + chapters.findByCourseIdIsNullOrderByPositionAscIdAsc() + } else { + chapters.findByCourseIdOrderByPositionAscIdAsc(courseId) + } val cleanSlug = slug?.takeIf { it.isNotBlank() }?.let { Slugs.slugify(it) } - ?: Slugs.uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet()) - val maxPos = chapters.findAllByOrderByPositionAscIdAsc().maxOfOrNull { it.position } ?: 0 + ?: Slugs.uniqueSlug(title, scope.map { it.slug }.toSet()) + val maxPos = scope.maxOfOrNull { it.position } ?: 0 val ch = chapters.save( ContentChapter( title = title.trim(), slug = cleanSlug, position = maxPos + 1, - audience = AUDIENCE_ALL, + audience = if (courseId == null) AUDIENCE_ALL else null, + courseId = courseId, ), ) return ChapterTreeNode( @@ -79,16 +106,24 @@ class ContentAuthoringService( fun renameChapter( chapterId: UUID, title: String, + courseId: UUID? = null, ) { - val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") } + val ch = findChapter(chapterId, courseId) if (title.isBlank()) throw BadRequestException("Название не может быть пустым") ch.title = title.trim() chapters.save(ch) } @Transactional - fun deleteChapter(chapterId: UUID) { - if (!chapters.existsById(chapterId)) throw NotFoundException("Глава не найдена") + fun deleteChapter( + chapterId: UUID, + courseId: UUID? = null, + ) { + if (courseId != null) { + requireChapterInCourse(chapterId, courseId) + } else if (!chapters.existsById(chapterId)) { + throw NotFoundException("Глава не найдена") + } chapters.deleteById(chapterId) } @@ -97,8 +132,9 @@ class ContentAuthoringService( chapterId: UUID, title: String, slug: String?, + courseId: UUID? = null, ): SectionTreeNode { - val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") } + val ch = findChapter(chapterId, courseId) if (title.isBlank()) throw BadRequestException("Название раздела не может быть пустым") val siblings = sections.findByChapterIdOrderByPositionAscIdAsc(ch.id) val cleanSlug = @@ -123,16 +159,24 @@ class ContentAuthoringService( fun renameSection( sectionId: UUID, title: String, + courseId: UUID? = null, ) { - val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") } + val sec = findSection(sectionId, courseId, "Раздел не найден") if (title.isBlank()) throw BadRequestException("Название не может быть пустым") sec.title = title.trim() sections.save(sec) } @Transactional - fun deleteSection(sectionId: UUID) { - if (!sections.existsById(sectionId)) throw NotFoundException("Раздел не найден") + fun deleteSection( + sectionId: UUID, + courseId: UUID? = null, + ) { + if (courseId != null) { + requireSectionInCourse(sectionId, courseId) + } else if (!sections.existsById(sectionId)) { + throw NotFoundException("Раздел не найден") + } sections.deleteById(sectionId) } @@ -141,9 +185,10 @@ class ContentAuthoringService( fun moveArticlesAndDeleteSection( sectionId: UUID, targetSectionId: UUID, + courseId: UUID? = null, ) { - val src = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") } - val dst = sections.findById(targetSectionId).orElseThrow { NotFoundException("Раздел назначения не найден") } + val src = findSection(sectionId, courseId, "Раздел не найден") + val dst = findSection(targetSectionId, courseId, "Раздел назначения не найден") if (src.id == dst.id) throw BadRequestException("Раздел назначения совпадает с исходным") val moving = articles.findBySectionIdOrderByPositionAscIdAsc(src.id) var pos = articles.findBySectionIdOrderByPositionAscIdAsc(dst.id).maxOfOrNull { it.position } ?: 0 @@ -156,41 +201,39 @@ class ContentAuthoringService( sections.delete(src) } - /** Создаёт статью; если раздела нет — создаёт главу/раздел по названиям. */ + /** + * Создаёт статью; если раздела нет — создаёт главу/раздел по названиям. + * При `courseId != null` все вложенные сущности обязаны принадлежать этому курсу, + * а новые главы и разделы создаются в нём. + */ @Transactional - fun createArticlePlaced(placement: ArticlePlacement): ContentArticleView { + fun createArticlePlaced( + placement: ArticlePlacement, + courseId: UUID? = null, + ): ContentArticleView { val targetSection: ContentSection = when { placement.sectionId != null && !placement.newSectionTitle.isNullOrBlank() -> { - val anchor = - sections - .findById(placement.sectionId) - .orElseThrow { NotFoundException("Раздел не найден") } - val node = createSection(anchor.chapterId, placement.newSectionTitle, null) + val anchor = findSection(placement.sectionId, courseId, "Раздел не найден") + val node = createSection(anchor.chapterId, placement.newSectionTitle, null, courseId) sections .findById(UUID.fromString(node.id)) .orElseThrow { NotFoundException("Раздел не найден") } } - placement.sectionId != null -> - sections - .findById(placement.sectionId) - .orElseThrow { NotFoundException("Раздел не найден") } + placement.sectionId != null -> findSection(placement.sectionId, courseId, "Раздел не найден") !placement.newSectionTitle.isNullOrBlank() -> { val chapter: ContentChapter = when { - placement.chapterId != null -> - chapters - .findById(placement.chapterId) - .orElseThrow { NotFoundException("Глава не найдена") } + placement.chapterId != null -> findChapter(placement.chapterId, courseId) !placement.newChapterTitle.isNullOrBlank() -> { - val node = createChapter(placement.newChapterTitle, null) + val node = createChapter(placement.newChapterTitle, null, courseId) chapters .findById(UUID.fromString(node.id)) .orElseThrow { NotFoundException("Глава не найдена") } } else -> throw BadRequestException("Укажите главу") } - val node = createSection(chapter.id, placement.newSectionTitle, null) + val node = createSection(chapter.id, placement.newSectionTitle, null, courseId) sections .findById(UUID.fromString(node.id)) .orElseThrow { NotFoundException("Раздел не найден") } @@ -251,4 +294,55 @@ class ContentAuthoringService( bodyMd = a.bodyMd, ) } + + /** + * Проверяет, что глава существует и принадлежит курсу. Чужой курс — 403, + * отсутствующая глава — 404, чтобы не путать ошибку доступа с опечаткой в id. + */ + fun requireChapterInCourse( + chapterId: UUID, + courseId: UUID, + ): ContentChapter { + val chapter = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") } + if (chapter.courseId != courseId) throw ForbiddenException("Глава принадлежит другому курсу") + return chapter + } + + fun requireSectionInCourse( + sectionId: UUID, + courseId: UUID, + ): ContentSection { + val section = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") } + requireChapterInCourse(section.chapterId, courseId) + return section + } + + fun requireArticleInCourse( + articleId: UUID, + courseId: UUID, + ): ContentArticle { + val article = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") } + requireSectionInCourse(article.sectionId, courseId) + return article + } + + private fun findChapter( + chapterId: UUID, + courseId: UUID?, + ): ContentChapter = + if (courseId == null) { + chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") } + } else { + requireChapterInCourse(chapterId, courseId) + } + + private fun findSection( + sectionId: UUID, + courseId: UUID?, + notFoundMessage: String, + ): ContentSection { + val section = sections.findById(sectionId).orElseThrow { NotFoundException(notFoundMessage) } + if (courseId != null) requireChapterInCourse(section.chapterId, courseId) + return section + } } diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt index 44898f0..5cd24ad 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt @@ -2,6 +2,8 @@ package ru.ysu.lms.academic.controller import org.springframework.http.ResponseEntity import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.oauth2.jwt.Jwt import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -12,7 +14,9 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController import org.springframework.web.multipart.MultipartFile +import ru.ysu.lms.academic.audit.AuditClient import ru.ysu.lms.academic.content.ArticlePlacement +import ru.ysu.lms.academic.content.ContentArticleView import ru.ysu.lms.academic.content.ContentAuthoringService import ru.ysu.lms.academic.content.ContentImportResult import ru.ysu.lms.academic.content.ContentService @@ -25,6 +29,7 @@ import java.util.UUID class ContentAdminController( private val contentService: ContentService, private val authoring: ContentAuthoringService, + private val audit: AuditClient, ) { data class ArticleUpdatePayload( val title: String, @@ -81,33 +86,66 @@ class ContentAdminController( fun chapterPublished( @PathVariable id: UUID, @RequestBody body: PublishedPayload, + @AuthenticationPrincipal jwt: Jwt, ) { contentService.setChapterPublished(id, body.published) + audit.record( + jwt.subject, + "CONTENT_CHAPTER_PUBLISH", + ENTITY_CHAPTER, + id.toString(), + details = "published=${body.published}", + ) } @PutMapping("/sections/{id}/published") fun sectionPublished( @PathVariable id: UUID, @RequestBody body: PublishedPayload, + @AuthenticationPrincipal jwt: Jwt, ) { contentService.setSectionPublished(id, body.published) + audit.record( + jwt.subject, + "CONTENT_SECTION_PUBLISH", + ENTITY_SECTION, + id.toString(), + details = "published=${body.published}", + ) } @PutMapping("/articles/{id}/published") fun articlePublished( @PathVariable id: UUID, @RequestBody body: PublishedPayload, + @AuthenticationPrincipal jwt: Jwt, ) { contentService.setArticlePublished(id, body.published) + audit.record( + jwt.subject, + "CONTENT_ARTICLE_PUBLISH", + ENTITY_ARTICLE, + id.toString(), + details = "published=${body.published}", + ) } @PostMapping("/import") fun import( @RequestParam("file") file: MultipartFile, @RequestParam(value = "sync", defaultValue = "true") sync: Boolean, + @AuthenticationPrincipal jwt: Jwt, ): ContentImportResult { if (file.isEmpty) throw BadRequestException("Выберите файл") - return contentService.importZip(file.originalFilename ?: "content.zip", file.bytes, sync) + val result = contentService.importZip(file.originalFilename ?: "content.zip", file.bytes, sync) + audit.record( + jwt.subject, + "CONTENT_IMPORT", + ENTITY_CONTENT, + null, + details = "chapters=${result.chapters}, sections=${result.sections}, articles=${result.articles}", + ) + return result } @GetMapping("/articles") @@ -116,19 +154,34 @@ class ContentAdminController( @PostMapping("/chapters") fun createChapter( @RequestBody body: ChapterCreatePayload, - ) = authoring.createChapter(body.title, body.slug) + @AuthenticationPrincipal jwt: Jwt, + ) = authoring.createChapter(body.title, body.slug).also { + audit.record( + jwt.subject, + "CONTENT_CHAPTER_CREATE", + ENTITY_CHAPTER, + it.id, + details = "title=${body.title}", + ) + } @PutMapping("/chapters/{id}") fun renameChapter( @PathVariable id: UUID, @RequestBody body: TitlePayload, - ) = authoring.renameChapter(id, body.title) + @AuthenticationPrincipal jwt: Jwt, + ) { + authoring.renameChapter(id, body.title) + audit.record(jwt.subject, "CONTENT_CHAPTER_RENAME", ENTITY_CHAPTER, id.toString(), details = "title=${body.title}") + } @DeleteMapping("/chapters/{id}") fun deleteChapter( @PathVariable id: UUID, + @AuthenticationPrincipal jwt: Jwt, ): ResponseEntity { authoring.deleteChapter(id) + audit.record(jwt.subject, "CONTENT_CHAPTER_DELETE", ENTITY_CHAPTER, id.toString()) return ResponseEntity.noContent().build() } @@ -136,60 +189,107 @@ class ContentAdminController( fun createSection( @RequestParam chapterId: UUID, @RequestBody body: SectionCreatePayload, - ) = authoring.createSection(chapterId, body.title, body.slug) + @AuthenticationPrincipal jwt: Jwt, + ) = authoring.createSection(chapterId, body.title, body.slug).also { + audit.record( + jwt.subject, + "CONTENT_SECTION_CREATE", + ENTITY_SECTION, + it.id, + details = "title=${body.title}", + ) + } @PutMapping("/sections/{id}") fun renameSection( @PathVariable id: UUID, @RequestBody body: TitlePayload, - ) = authoring.renameSection(id, body.title) + @AuthenticationPrincipal jwt: Jwt, + ) { + authoring.renameSection(id, body.title) + audit.record(jwt.subject, "CONTENT_SECTION_RENAME", ENTITY_SECTION, id.toString(), details = "title=${body.title}") + } @DeleteMapping("/sections/{id}") fun deleteSection( @PathVariable id: UUID, @RequestParam(required = false) moveTo: UUID?, + @AuthenticationPrincipal jwt: Jwt, ): ResponseEntity { if (moveTo != null) { authoring.moveArticlesAndDeleteSection(id, moveTo) } else { authoring.deleteSection(id) } + audit.record(jwt.subject, "CONTENT_SECTION_DELETE", ENTITY_SECTION, id.toString()) return ResponseEntity.noContent().build() } @PostMapping("/articles") fun createArticle( @RequestBody body: ArticleCreatePayload, - ) = authoring.createArticlePlaced( - ArticlePlacement( - title = body.title, - bodyMd = body.bodyMd, - chapterId = body.chapterId?.let { UUID.fromString(it) }, - newChapterTitle = body.newChapterTitle, - sectionId = body.sectionId?.let { UUID.fromString(it) }, - newSectionTitle = body.newSectionTitle, - ), - ) + @AuthenticationPrincipal jwt: Jwt, + ): ContentArticleView { + val article = + authoring.createArticlePlaced( + ArticlePlacement( + title = body.title, + bodyMd = body.bodyMd, + chapterId = body.chapterId?.let { UUID.fromString(it) }, + newChapterTitle = body.newChapterTitle, + sectionId = body.sectionId?.let { UUID.fromString(it) }, + newSectionTitle = body.newSectionTitle, + ), + ) + audit.record( + jwt.subject, + "CONTENT_ARTICLE_CREATE", + ENTITY_ARTICLE, + article.id, + details = "title=${body.title}", + ) + return article + } @PutMapping("/articles/{id}") fun update( @PathVariable id: UUID, @RequestBody body: ArticleUpdatePayload, + @AuthenticationPrincipal jwt: Jwt, ) { authoring.updateArticle(id, body.title, body.bodyMd) + audit.record(jwt.subject, "CONTENT_ARTICLE_UPDATE", ENTITY_ARTICLE, id.toString(), details = "title=${body.title}") } @PostMapping("/articles/{id}/move") fun move( @PathVariable id: UUID, @RequestBody body: ArticleMovePayload, - ) = authoring.moveArticle(id, UUID.fromString(body.sectionId)) + @AuthenticationPrincipal jwt: Jwt, + ) = authoring.moveArticle(id, UUID.fromString(body.sectionId)).also { + audit.record( + jwt.subject, + "CONTENT_ARTICLE_MOVE", + ENTITY_ARTICLE, + it.id, + details = "sectionId=${body.sectionId}", + ) + } @DeleteMapping("/articles/{id}") fun delete( @PathVariable id: UUID, + @AuthenticationPrincipal jwt: Jwt, ): ResponseEntity { authoring.deleteArticle(id) + audit.record(jwt.subject, "CONTENT_ARTICLE_DELETE", ENTITY_ARTICLE, id.toString()) return ResponseEntity.noContent().build() } + + private companion object { + private const val ENTITY_CONTENT = "CONTENT" + private const val ENTITY_CHAPTER = "CONTENT_CHAPTER" + private const val ENTITY_SECTION = "CONTENT_SECTION" + private const val ENTITY_ARTICLE = "CONTENT_ARTICLE" + } } diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseContentAdminController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseContentAdminController.kt new file mode 100644 index 0000000..85669f3 --- /dev/null +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseContentAdminController.kt @@ -0,0 +1,316 @@ +package ru.ysu.lms.academic.controller + +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import ru.ysu.lms.academic.audit.AuditClient +import ru.ysu.lms.academic.content.ArticlePlacement +import ru.ysu.lms.academic.content.ContentArticleView +import ru.ysu.lms.academic.content.ContentAuthoringService +import ru.ysu.lms.academic.content.ContentService +import ru.ysu.lms.academic.course.CourseAccessService +import ru.ysu.lms.academic.repo.CourseRepository +import ru.ysu.lms.common.NotFoundException +import java.util.UUID + +/** + * Авторский контент курса: администратор и преподаватель, назначенный на курс. + * Любая изменяемая сущность обязана принадлежать курсу из пути — чужие id + * отклоняются, чтобы нельзя было править материалы другого курса. + */ +@RestController +@RequestMapping("/api/v1/courses/{courseId}/content") +@PreAuthorize("hasAnyRole('ADMIN','TEACHER')") +class CourseContentAdminController( + private val contentService: ContentService, + private val authoring: ContentAuthoringService, + private val accessService: CourseAccessService, + private val courses: CourseRepository, + private val audit: AuditClient, +) { + data class ChapterCreatePayload( + val title: String, + val slug: String? = null, + ) + + data class SectionCreatePayload( + val title: String, + val slug: String? = null, + ) + + data class TitlePayload( + val title: String, + ) + + data class ArticleCreatePayload( + val chapterId: String? = null, + val sectionId: String? = null, + val newChapterTitle: String? = null, + val newSectionTitle: String? = null, + val title: String, + val bodyMd: String? = null, + ) + + data class ArticleUpdatePayload( + val title: String, + val bodyMd: String, + ) + + data class PublishedPayload( + val published: Boolean, + ) + + @PostMapping("/chapters") + fun createChapter( + @PathVariable courseId: UUID, + @RequestBody body: ChapterCreatePayload, + @AuthenticationPrincipal jwt: Jwt, + ) = authoring.createChapter(body.title, body.slug, requireAccess(courseId, jwt)).also { + audit.record( + jwt.subject, + "CONTENT_CHAPTER_CREATE", + ENTITY_CHAPTER, + it.id, + courseId = courseId.toString(), + details = "title=${body.title}", + ) + } + + @PutMapping("/chapters/{id}") + fun renameChapter( + @PathVariable courseId: UUID, + @PathVariable id: UUID, + @RequestBody body: TitlePayload, + @AuthenticationPrincipal jwt: Jwt, + ): ResponseEntity { + requireAccess(courseId, jwt) + authoring.renameChapter(id, body.title, courseId) + audit.record( + jwt.subject, + "CONTENT_CHAPTER_RENAME", + ENTITY_CHAPTER, + id.toString(), + courseId = courseId.toString(), + details = "title=${body.title}", + ) + return ResponseEntity.noContent().build() + } + + @DeleteMapping("/chapters/{id}") + fun deleteChapter( + @PathVariable courseId: UUID, + @PathVariable id: UUID, + @AuthenticationPrincipal jwt: Jwt, + ): ResponseEntity { + requireAccess(courseId, jwt) + authoring.deleteChapter(id, courseId) + audit.record(jwt.subject, "CONTENT_CHAPTER_DELETE", ENTITY_CHAPTER, id.toString(), courseId = courseId.toString()) + return ResponseEntity.noContent().build() + } + + @PostMapping("/sections") + fun createSection( + @PathVariable courseId: UUID, + @RequestParam chapterId: UUID, + @RequestBody body: SectionCreatePayload, + @AuthenticationPrincipal jwt: Jwt, + ) = authoring.createSection(chapterId, body.title, body.slug, requireAccess(courseId, jwt)).also { + audit.record( + jwt.subject, + "CONTENT_SECTION_CREATE", + ENTITY_SECTION, + it.id, + courseId = courseId.toString(), + details = "title=${body.title}", + ) + } + + @PutMapping("/sections/{id}") + fun renameSection( + @PathVariable courseId: UUID, + @PathVariable id: UUID, + @RequestBody body: TitlePayload, + @AuthenticationPrincipal jwt: Jwt, + ): ResponseEntity { + requireAccess(courseId, jwt) + authoring.renameSection(id, body.title, courseId) + audit.record( + jwt.subject, + "CONTENT_SECTION_RENAME", + ENTITY_SECTION, + id.toString(), + courseId = courseId.toString(), + details = "title=${body.title}", + ) + return ResponseEntity.noContent().build() + } + + @DeleteMapping("/sections/{id}") + fun deleteSection( + @PathVariable courseId: UUID, + @PathVariable id: UUID, + @RequestParam(required = false) moveTo: UUID?, + @AuthenticationPrincipal jwt: Jwt, + ): ResponseEntity { + requireAccess(courseId, jwt) + if (moveTo != null) { + authoring.moveArticlesAndDeleteSection(id, moveTo, courseId) + } else { + authoring.deleteSection(id, courseId) + } + audit.record(jwt.subject, "CONTENT_SECTION_DELETE", ENTITY_SECTION, id.toString(), courseId = courseId.toString()) + return ResponseEntity.noContent().build() + } + + @PostMapping("/articles") + fun createArticle( + @PathVariable courseId: UUID, + @RequestBody body: ArticleCreatePayload, + @AuthenticationPrincipal jwt: Jwt, + ): ContentArticleView { + val article = + authoring.createArticlePlaced( + ArticlePlacement( + title = body.title, + bodyMd = body.bodyMd ?: "", + chapterId = body.chapterId?.let { UUID.fromString(it) }, + newChapterTitle = body.newChapterTitle, + sectionId = body.sectionId?.let { UUID.fromString(it) }, + newSectionTitle = body.newSectionTitle, + ), + courseId = requireAccess(courseId, jwt), + ) + audit.record( + jwt.subject, + "CONTENT_ARTICLE_CREATE", + ENTITY_ARTICLE, + article.id, + courseId = courseId.toString(), + details = "title=${body.title}", + ) + return article + } + + @PutMapping("/articles/{id}") + fun updateArticle( + @PathVariable courseId: UUID, + @PathVariable id: UUID, + @RequestBody body: ArticleUpdatePayload, + @AuthenticationPrincipal jwt: Jwt, + ): ResponseEntity { + requireAccess(courseId, jwt) + authoring.updateArticle(id, body.title, body.bodyMd, courseId) + audit.record( + jwt.subject, + "CONTENT_ARTICLE_UPDATE", + ENTITY_ARTICLE, + id.toString(), + courseId = courseId.toString(), + details = "title=${body.title}", + ) + return ResponseEntity.noContent().build() + } + + @DeleteMapping("/articles/{id}") + fun deleteArticle( + @PathVariable courseId: UUID, + @PathVariable id: UUID, + @AuthenticationPrincipal jwt: Jwt, + ): ResponseEntity { + requireAccess(courseId, jwt) + authoring.deleteArticle(id, courseId) + audit.record(jwt.subject, "CONTENT_ARTICLE_DELETE", ENTITY_ARTICLE, id.toString(), courseId = courseId.toString()) + return ResponseEntity.noContent().build() + } + + @PutMapping("/chapters/{id}/published") + fun chapterPublished( + @PathVariable courseId: UUID, + @PathVariable id: UUID, + @RequestBody body: PublishedPayload, + @AuthenticationPrincipal jwt: Jwt, + ): ResponseEntity { + requireAccess(courseId, jwt) + authoring.requireChapterInCourse(id, courseId) + contentService.setChapterPublished(id, body.published) + audit.record( + jwt.subject, + "CONTENT_CHAPTER_PUBLISH", + ENTITY_CHAPTER, + id.toString(), + courseId = courseId.toString(), + details = "published=${body.published}", + ) + return ResponseEntity.noContent().build() + } + + @PutMapping("/sections/{id}/published") + fun sectionPublished( + @PathVariable courseId: UUID, + @PathVariable id: UUID, + @RequestBody body: PublishedPayload, + @AuthenticationPrincipal jwt: Jwt, + ): ResponseEntity { + requireAccess(courseId, jwt) + authoring.requireSectionInCourse(id, courseId) + contentService.setSectionPublished(id, body.published) + audit.record( + jwt.subject, + "CONTENT_SECTION_PUBLISH", + ENTITY_SECTION, + id.toString(), + courseId = courseId.toString(), + details = "published=${body.published}", + ) + return ResponseEntity.noContent().build() + } + + @PutMapping("/articles/{id}/published") + fun articlePublished( + @PathVariable courseId: UUID, + @PathVariable id: UUID, + @RequestBody body: PublishedPayload, + @AuthenticationPrincipal jwt: Jwt, + ): ResponseEntity { + requireAccess(courseId, jwt) + authoring.requireArticleInCourse(id, courseId) + contentService.setArticlePublished(id, body.published) + audit.record( + jwt.subject, + "CONTENT_ARTICLE_PUBLISH", + ENTITY_ARTICLE, + id.toString(), + courseId = courseId.toString(), + details = "published=${body.published}", + ) + return ResponseEntity.noContent().build() + } + + /** + * Проверяет курсовую область видимости и существование курса (для администратора, + * который проходит проверку доступа без запроса к БД). Возвращает id курса. + */ + private fun requireAccess( + courseId: UUID, + jwt: Jwt, + ): UUID { + accessService.requireCourseAccess(courseId, jwt) + if (!courses.existsById(courseId)) throw NotFoundException("Курс не найден") + return courseId + } + + private companion object { + private const val ENTITY_CHAPTER = "CONTENT_CHAPTER" + private const val ENTITY_SECTION = "CONTENT_SECTION" + private const val ENTITY_ARTICLE = "CONTENT_ARTICLE" + } +} diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseController.kt index 508d74e..0981e48 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseController.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseController.kt @@ -11,14 +11,17 @@ import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +import ru.ysu.lms.academic.audit.AuditClient import ru.ysu.lms.academic.course.CourseAccessService import ru.ysu.lms.academic.course.CourseBootstrapRequest import ru.ysu.lms.academic.course.CourseBootstrapResult import ru.ysu.lms.academic.course.CourseBootstrapService import ru.ysu.lms.academic.course.CourseCreateRequest import ru.ysu.lms.academic.course.CourseDetail +import ru.ysu.lms.academic.course.CourseGroupOption import ru.ysu.lms.academic.course.CourseGroupRequest import ru.ysu.lms.academic.course.CourseGroupRoster +import ru.ysu.lms.academic.course.CourseGroupService import ru.ysu.lms.academic.course.CourseRosterService import ru.ysu.lms.academic.course.CourseService import ru.ysu.lms.academic.course.CourseSummary @@ -35,6 +38,8 @@ class CourseController( private val bootstrapService: CourseBootstrapService, private val accessService: CourseAccessService, private val rosterService: CourseRosterService, + private val courseGroupService: CourseGroupService, + private val audit: AuditClient, ) { @GetMapping @PreAuthorize("hasAuthority('ROLE_ADMIN')") @@ -51,7 +56,11 @@ class CourseController( fun create( @RequestBody body: CourseCreateRequest, @AuthenticationPrincipal jwt: Jwt, - ): CourseDetail = courseService.create(body, ownerAccountId(jwt)) + ): CourseDetail { + val course = courseService.create(body, ownerAccountId(jwt)) + audit.record(jwt.subject, "COURSE_CREATE", ENTITY_COURSE, course.id, courseId = course.id) + return course + } @GetMapping("/{id}") fun detail( @@ -87,42 +96,131 @@ class CourseController( fun update( @PathVariable id: UUID, @RequestBody body: CourseUpdateRequest, - ): CourseDetail = courseService.update(id, body) + @AuthenticationPrincipal jwt: Jwt, + ): CourseDetail { + val course = courseService.update(id, body) + val details = + listOfNotNull( + body.title?.let { "title=$it" }, + body.status?.let { "status=$it" }, + ).joinToString(", ") + audit.record(jwt.subject, "COURSE_UPDATE", ENTITY_COURSE, course.id, courseId = course.id, details = details) + return course + } @PostMapping("/{id}/teachers") @PreAuthorize("hasAuthority('ROLE_ADMIN')") fun assignTeacher( @PathVariable id: UUID, @RequestBody body: CourseTeacherRequest, - ): CourseDetail = courseService.assignTeacher(id, body) + @AuthenticationPrincipal jwt: Jwt, + ): CourseDetail { + val course = courseService.assignTeacher(id, body) + audit.record( + jwt.subject, + "COURSE_TEACHER_ASSIGN", + ENTITY_COURSE, + course.id, + courseId = course.id, + details = "accountId=${body.accountId}", + ) + return course + } @DeleteMapping("/{id}/teachers/{accountId}") @PreAuthorize("hasAuthority('ROLE_ADMIN')") fun removeTeacher( @PathVariable id: UUID, @PathVariable accountId: String, - ): CourseDetail = courseService.removeTeacher(id, accountId) + @AuthenticationPrincipal jwt: Jwt, + ): CourseDetail { + val course = courseService.removeTeacher(id, accountId) + audit.record( + jwt.subject, + "COURSE_TEACHER_REMOVE", + ENTITY_COURSE, + course.id, + courseId = course.id, + details = "accountId=$accountId", + ) + return course + } @PostMapping("/{id}/groups") - @PreAuthorize("hasAuthority('ROLE_ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") fun attachGroup( @PathVariable id: UUID, @RequestBody body: CourseGroupRequest, - ): CourseDetail = courseService.attachGroup(id, body) + @AuthenticationPrincipal jwt: Jwt, + ): CourseDetail { + accessService.requireCourseAccess(id, jwt) + courseGroupService.requireAttachable(body.groupId) + val course = courseService.attachGroup(id, body) + audit.record( + jwt.subject, + "COURSE_GROUP_ATTACH", + ENTITY_COURSE, + course.id, + courseId = course.id, + details = "groupId=${body.groupId}", + ) + return course + } @DeleteMapping("/{id}/groups/{groupId}") - @PreAuthorize("hasAuthority('ROLE_ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") fun detachGroup( @PathVariable id: UUID, @PathVariable groupId: String, - ): CourseDetail = courseService.detachGroup(id, groupId) + @AuthenticationPrincipal jwt: Jwt, + ): CourseDetail { + accessService.requireCourseAccess(id, jwt) + val course = courseService.detachGroup(id, groupId) + audit.record( + jwt.subject, + "COURSE_GROUP_DETACH", + ENTITY_COURSE, + course.id, + courseId = course.id, + details = "groupId=$groupId", + ) + return course + } + + /** Активные группы identity, ещё не привязанные к курсу. */ + @GetMapping("/{id}/available-groups") + @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") + fun availableGroups( + @PathVariable id: UUID, + @AuthenticationPrincipal jwt: Jwt, + ): List { + accessService.requireCourseAccess(id, jwt) + return courseGroupService.availableGroups(id) + } @PostMapping("/bootstrap") @PreAuthorize("hasAuthority('ROLE_ADMIN')") fun bootstrap( @RequestBody body: CourseBootstrapRequest, @AuthenticationPrincipal jwt: Jwt, - ): CourseBootstrapResult = bootstrapService.bootstrap(body, ownerAccountId(jwt)) + ): CourseBootstrapResult { + val result = bootstrapService.bootstrap(body, ownerAccountId(jwt)) + audit.record( + jwt.subject, + "COURSE_BOOTSTRAP", + ENTITY_COURSE, + result.courseId, + courseId = result.courseId, + details = + "groups=${result.attachedGroups}, labs=${result.assignedLabs}, " + + "chapters=${result.assignedChapters}", + ) + return result + } private fun ownerAccountId(jwt: Jwt): UUID = parseUuidOrBadRequest(jwt.subject ?: "", "идентификатор пользователя в токене") + + private companion object { + private const val ENTITY_COURSE = "COURSE" + } } diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt index ddee81b..9b328fa 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt @@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController import org.springframework.web.multipart.MultipartFile +import ru.ysu.lms.academic.audit.AuditClient import ru.ysu.lms.academic.course.CourseAccessService import ru.ysu.lms.academic.lab.LabCopyRequest import ru.ysu.lms.academic.lab.LabCreateRequest @@ -34,6 +35,7 @@ import java.util.UUID class LabController( private val labService: LabService, private val accessService: CourseAccessService, + private val audit: AuditClient, ) { /** * Без `courseId` список ограничен доступными курсами (admin видит все). @@ -71,7 +73,16 @@ class LabController( ): LabDetail { accessService.requireLabAccess(id, jwt) accessService.requireCourseAccess(body.courseId, jwt) - return labService.copy(id, body) + val copy = labService.copy(id, body) + audit.record( + jwt.subject, + "LAB_COPY", + ENTITY_LAB, + copy.id, + courseId = copy.courseId, + details = "sourceLabId=$id", + ) + return copy } @PostMapping("/import") @@ -83,7 +94,16 @@ class LabController( ): LabImportResult { if (file.isEmpty) throw BadRequestException("Выберите файл") val targetCourseId = requireWritableCourse(courseId, jwt) - return labService.importFromFile(file.originalFilename ?: "lab.zip", file.bytes, targetCourseId) + val result = labService.importFromFile(file.originalFilename ?: "lab.zip", file.bytes, targetCourseId) + audit.record( + jwt.subject, + "LAB_IMPORT", + ENTITY_LAB, + result.labId, + courseId = targetCourseId?.toString(), + details = "levels=${result.levelCount}, variants=${result.variantCount}", + ) + return result } @DeleteMapping("/{id}") @@ -94,6 +114,7 @@ class LabController( ) { accessService.requireLabAccess(id, jwt) labService.delete(id) + audit.record(jwt.subject, "LAB_DELETE", ENTITY_LAB, id.toString()) } @PutMapping("/{id}") @@ -104,7 +125,16 @@ class LabController( @AuthenticationPrincipal jwt: Jwt, ): LabDetail { accessService.requireLabAccess(id, jwt) - return labService.update(id, body) + val lab = labService.update(id, body) + audit.record( + jwt.subject, + "LAB_UPDATE", + ENTITY_LAB, + lab.id, + courseId = lab.courseId, + details = "status=${lab.status}", + ) + return lab } @PostMapping("/{id}/variants") @@ -115,7 +145,9 @@ class LabController( @AuthenticationPrincipal jwt: Jwt, ): LabDetail { accessService.requireLabAccess(id, jwt) - return labService.addVariant(id, body) + val lab = labService.addVariant(id, body) + audit.record(jwt.subject, "LAB_VARIANT_ADD", ENTITY_LAB, id.toString(), details = "number=${body.number}") + return lab } @PutMapping("/{id}/variants/{variantId}") @@ -127,7 +159,15 @@ class LabController( @AuthenticationPrincipal jwt: Jwt, ): LabDetail { accessService.requireLabAccess(id, jwt) - return labService.updateVariant(id, variantId, body) + val lab = labService.updateVariant(id, variantId, body) + audit.record( + jwt.subject, + "LAB_VARIANT_UPDATE", + ENTITY_LAB, + id.toString(), + details = "variantId=$variantId, number=${body.number}", + ) + return lab } @DeleteMapping("/{id}/variants/{variantId}") @@ -138,7 +178,9 @@ class LabController( @AuthenticationPrincipal jwt: Jwt, ): LabDetail { accessService.requireLabAccess(id, jwt) - return labService.deleteVariant(id, variantId) + val lab = labService.deleteVariant(id, variantId) + audit.record(jwt.subject, "LAB_VARIANT_DELETE", ENTITY_LAB, id.toString(), details = "variantId=$variantId") + return lab } @PostMapping("/{id}/toggle-status") @@ -149,6 +191,7 @@ class LabController( ) { accessService.requireLabAccess(id, jwt) labService.toggleStatus(id) + audit.record(jwt.subject, "LAB_PUBLISH", ENTITY_LAB, id.toString()) } @PostMapping @@ -158,7 +201,16 @@ class LabController( @AuthenticationPrincipal jwt: Jwt, ): LabDetail { requireWritableCourse(body.courseId, jwt) - return labService.create(body) + val lab = labService.create(body) + audit.record( + jwt.subject, + "LAB_CREATE", + ENTITY_LAB, + lab.id, + courseId = lab.courseId, + details = "title=${lab.title}", + ) + return lab } @PostMapping("/{id}/levels") @@ -170,6 +222,7 @@ class LabController( ): LabDetail { accessService.requireLabAccess(id, jwt) labService.addLevel(id, body) + audit.record(jwt.subject, "LAB_LEVEL_ADD", ENTITY_LAB, id.toString(), details = "code=${body.code}") return labService.detail(id) } @@ -182,7 +235,9 @@ class LabController( @AuthenticationPrincipal jwt: Jwt, ): LabDetail { accessService.requireLabAccess(id, jwt) - return labService.renameLevel(id, levelId, body) + val lab = labService.renameLevel(id, levelId, body) + audit.record(jwt.subject, "LAB_LEVEL_UPDATE", ENTITY_LAB, id.toString(), details = "levelId=$levelId") + return lab } @DeleteMapping("/{id}/levels/{levelId}") @@ -193,7 +248,9 @@ class LabController( @AuthenticationPrincipal jwt: Jwt, ): LabDetail { accessService.requireLabAccess(id, jwt) - return labService.deleteLevel(id, levelId) + val lab = labService.deleteLevel(id, levelId) + audit.record(jwt.subject, "LAB_LEVEL_DELETE", ENTITY_LAB, id.toString(), details = "levelId=$levelId") + return lab } /** @@ -211,4 +268,8 @@ class LabController( if (!accessService.isAdmin(jwt)) throw BadRequestException("Укажите курс") return null } + + private companion object { + private const val ENTITY_LAB = "LAB" + } } diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseDtos.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseDtos.kt index 36d1881..8e52bb8 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseDtos.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseDtos.kt @@ -52,6 +52,13 @@ data class CourseGroupRequest( val groupId: String, ) +/** Группа, которую можно привязать к курсу: ещё не привязана и активна в identity. */ +data class CourseGroupOption( + val id: String, + val name: String, + val status: String, +) + data class CourseBootstrapRequest( val groupIds: List = emptyList(), val title: String? = null, diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseGroupService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseGroupService.kt new file mode 100644 index 0000000..8f67e53 --- /dev/null +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseGroupService.kt @@ -0,0 +1,49 @@ +package ru.ysu.lms.academic.course + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import ru.ysu.lms.academic.identity.IdentityClient +import ru.ysu.lms.academic.identity.IdentityGroupSummary +import ru.ysu.lms.academic.repo.CourseGroupRepository +import ru.ysu.lms.academic.repo.CourseRepository +import ru.ysu.lms.common.BadRequestException +import ru.ysu.lms.common.NotFoundException +import java.util.UUID + +/** + * Выбор групп для привязки к курсу. Источник групп — identity-service, а активные + * привязки курса хранятся в academic. Уже привязанные и неактивные группы не предлагаются. + */ +@Service +class CourseGroupService( + private val courses: CourseRepository, + private val groups: CourseGroupRepository, + private val identity: IdentityClient, +) { + @Transactional(readOnly = true) + fun availableGroups(courseId: UUID): List { + if (!courses.existsById(courseId)) throw NotFoundException("Курс не найден") + val attachedIds = groups.findByCourseIdAndRemovedAtIsNull(courseId).mapTo(HashSet()) { it.groupId.toString() } + return identity + .listGroups() + .filter { it.status == STATUS_ACTIVE && it.id !in attachedIds } + .sortedWith(compareBy { it.name.lowercase() }.thenBy { it.id }) + .map { CourseGroupOption(id = it.id, name = it.name, status = it.status) } + } + + /** Привязывать можно только существующую активную группу identity. */ + @Transactional(readOnly = true) + fun requireAttachable(groupIdValue: String) { + val groupId = parseUuidOrBadRequest(groupIdValue, "идентификатор группы").toString() + val group = + identity + .listGroups() + .firstOrNull { it.id == groupId } + ?: throw NotFoundException("Группа не найдена") + if (group.status != STATUS_ACTIVE) throw BadRequestException("Группа неактивна") + } + + private companion object { + const val STATUS_ACTIVE = "ACTIVE" + } +} diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/identity/IdentityClient.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/identity/IdentityClient.kt index 393c386..4720fab 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/identity/IdentityClient.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/identity/IdentityClient.kt @@ -42,6 +42,19 @@ class IdentityClient( throw IdentityUnavailableException("Сервис идентификации вернул некорректный ответ").apply { initCause(e) } } + /** Все группы identity для выбора при привязке к курсу. */ + fun listGroups(): List = + try { + client + .get() + .uri("/api/v1/internal/groups") + .header(INTERNAL_TOKEN_HEADER, internalToken) + .retrieve() + .body(GROUP_SUMMARY_LIST) ?: emptyList() + } catch (e: RestClientException) { + throw IdentityUnavailableException().apply { initCause(e) } + } + /** Текущий состав группы через внутренний endpoint identity. */ fun getGroupRoster(groupId: String): GroupRosterInfo = try { @@ -70,6 +83,14 @@ internal data class GroupRef( val id: String = "", ) +/** Минимальный контракт `GET /api/v1/internal/groups`. */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class IdentityGroupSummary( + val id: String = "", + val name: String = "", + val status: String = "", +) + /** Минимальный контракт `GET /api/v1/internal/groups/{groupId}`. */ @JsonIgnoreProperties(ignoreUnknown = true) data class GroupRosterInfo( @@ -88,6 +109,8 @@ data class GroupRosterStudent( private val GROUP_LIST = object : ParameterizedTypeReference>() {} +private val GROUP_SUMMARY_LIST = object : ParameterizedTypeReference>() {} + private const val INTERNAL_TOKEN_HEADER = "X-Internal-Token" /** Таймауты обязательны: зависший downstream держит поток запроса и вешает сервис. */ diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/ContentRepositories.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/ContentRepositories.kt index 8ce7a25..eef703a 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/ContentRepositories.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/ContentRepositories.kt @@ -18,6 +18,8 @@ interface ContentChapterRepository : JpaRepository { fun findByCourseIdOrderByPositionAscIdAsc(courseId: UUID): List + fun findByCourseIdIsNullOrderByPositionAscIdAsc(): List + fun findByCourseIdAndSlug( courseId: UUID, slug: String, diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/CourseRepositories.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/CourseRepositories.kt index b02e997..af229c8 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/CourseRepositories.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/CourseRepositories.kt @@ -30,6 +30,8 @@ interface CourseTeacherRepository : JpaRepository { interface CourseGroupRepository : JpaRepository { fun findByCourseIdOrderByAddedAtAsc(courseId: UUID): List + fun findByCourseIdAndRemovedAtIsNull(courseId: UUID): List + fun findByCourseIdAndGroupIdAndRemovedAtIsNull( courseId: UUID, groupId: UUID, diff --git a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/audit/AuditClientTest.kt b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/audit/AuditClientTest.kt new file mode 100644 index 0000000..1e48aa1 --- /dev/null +++ b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/audit/AuditClientTest.kt @@ -0,0 +1,79 @@ +package ru.ysu.lms.academic.audit + +import org.assertj.core.api.Assertions.assertThatCode +import org.hamcrest.Matchers.containsString +import org.junit.jupiter.api.Test +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.test.web.client.MockRestServiceServer +import org.springframework.test.web.client.match.MockRestRequestMatchers.content +import org.springframework.test.web.client.match.MockRestRequestMatchers.header +import org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath +import org.springframework.test.web.client.match.MockRestRequestMatchers.method +import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo +import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus +import org.springframework.web.client.RestClient + +/** + * Аудит — best-effort: проверяем контракт запроса и то, что ошибки identity + * не пробрасываются в бизнес-операцию. + */ +class AuditClientTest { + private val actorId = "11111111-1111-1111-1111-111111111111" + private val courseId = "22222222-2222-2222-2222-222222222222" + + @Test + fun `record sends actor action and entity to identity internal audit endpoint`() { + val builder = RestClient.builder().baseUrl(IDENTITY_URL) + val server = MockRestServiceServer.bindTo(builder).build() + val client = AuditClient(builder.build(), INTERNAL_TOKEN) + + server + .expect(requestTo("$IDENTITY_URL/api/v1/internal/audit")) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("X-Internal-Token", INTERNAL_TOKEN)) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.actorAccountId").value(actorId)) + .andExpect(content().string(containsString("\"actorLogin\":null"))) + .andExpect(jsonPath("$.action").value("COURSE_TEACHER_ASSIGN")) + .andExpect(jsonPath("$.entityType").value("COURSE")) + .andExpect(jsonPath("$.entityId").value(courseId)) + .andExpect(jsonPath("$.courseId").value(courseId)) + .andExpect(jsonPath("$.details").value("accountId=$actorId")) + .andRespond(withStatus(HttpStatus.NO_CONTENT)) + + client.record( + actorAccountId = actorId, + action = "COURSE_TEACHER_ASSIGN", + entityType = "COURSE", + entityId = courseId, + courseId = courseId, + details = "accountId=$actorId", + ) + + server.verify() + } + + @Test + fun `record swallows identity errors`() { + val builder = RestClient.builder().baseUrl(IDENTITY_URL) + val server = MockRestServiceServer.bindTo(builder).build() + val client = AuditClient(builder.build(), INTERNAL_TOKEN) + + server + .expect(requestTo("$IDENTITY_URL/api/v1/internal/audit")) + .andRespond(withStatus(HttpStatus.FORBIDDEN)) + + assertThatCode { + client.record(actorId, "LAB_DELETE", "LAB", courseId) + }.doesNotThrowAnyException() + + server.verify() + } + + private companion object { + private const val IDENTITY_URL = "http://identity.test" + private const val INTERNAL_TOKEN = "test-internal-token" + } +} diff --git a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/CourseContentAuthoringIntegrationTest.kt b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/CourseContentAuthoringIntegrationTest.kt new file mode 100644 index 0000000..0d0485f --- /dev/null +++ b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/CourseContentAuthoringIntegrationTest.kt @@ -0,0 +1,447 @@ +package ru.ysu.lms.academic.content + +import com.jayway.jsonpath.JsonPath +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.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.context.bean.override.mockito.MockitoBean +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.MvcResult +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +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.CourseGroupRequest +import ru.ysu.lms.academic.course.CourseService +import ru.ysu.lms.academic.course.CourseTeacherRequest +import ru.ysu.lms.academic.domain.ContentChapter +import ru.ysu.lms.academic.identity.IdentityClient +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 + +/** + * Авторский контент курса: преподаватель пишет только в свои курсы, студент — никогда, + * администратор не ограничен; глобальные материалы остаются админскими. + */ +@Testcontainers +@SpringBootTest +@Transactional +class CourseContentAuthoringIntegrationTest { + 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 courseService: CourseService + + @Autowired + private lateinit var contentService: ContentService + + @Autowired + private lateinit var authoring: ContentAuthoringService + + @Autowired + private lateinit var chapters: ContentChapterRepository + + @Autowired + private lateinit var sections: ContentSectionRepository + + @Autowired + private lateinit var articles: ContentArticleRepository + + @MockitoBean + private lateinit var identity: IdentityClient + + private val mockMvc: MockMvc by lazy { + MockMvcBuilders + .webAppContextSetup(context) + .apply(springSecurity()) + .build() + } + + @Test + fun `teacher creates chapter section and article in own course`() { + val courseId = newCourse() + val teacher = teacherOf(courseId) + + val chapterId = createChapter(courseId, "Глава преподавателя", teacher) + val sectionId = createSection(courseId, chapterId, "Раздел преподавателя", teacher) + val articleId = createArticle(courseId, """{"sectionId":"$sectionId","title":"Статья","bodyMd":"Текст"}""", teacher) + + val chapter = chapters.findById(chapterId).orElseThrow() + assertThat(chapter.courseId).isEqualTo(courseId) + assertThat(chapter.audience).isNull() + assertThat(sections.findById(sectionId).orElseThrow().chapterId).isEqualTo(chapterId) + assertThat(articles.findById(articleId).orElseThrow().sectionId).isEqualTo(sectionId) + + mockMvc + .perform(get("/api/v1/courses/{courseId}/content/tree", courseId).with(teacher)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.chapters.length()").value(1)) + .andExpect(jsonPath("$.chapters[0].id").value(chapterId.toString())) + .andExpect(jsonPath("$.chapters[0].sections[0].id").value(sectionId.toString())) + .andExpect(jsonPath("$.chapters[0].sections[0].articles[0].id").value(articleId.toString())) + } + + @Test + fun `teacher can create new chapter and section while placing article`() { + val courseId = newCourse() + val teacher = teacherOf(courseId) + + val result = + mockMvc + .perform( + post("/api/v1/courses/{courseId}/content/articles", courseId) + .contentType(MediaType.APPLICATION_JSON) + .content( + """{"newChapterTitle":"Новая глава","newSectionTitle":"Новый раздел","title":"Статья","bodyMd":"Текст"}""", + ).with(teacher), + ).andExpect(status().isOk) + .andReturn() + val articleId = idOf(result) + + val tree = contentService.courseTree(courseId, includeHidden = true) + val chapter = tree.chapters.single() + val section = chapter.sections.single() + assertThat(chapter.title).isEqualTo("Новая глава") + assertThat(section.title).isEqualTo("Новый раздел") + assertThat(section.articles.single().id).isEqualTo(articleId.toString()) + assertThat(chapters.findByCourseIdOrderByPositionAscIdAsc(courseId)).hasSize(1) + } + + @Test + fun `foreign teacher cannot author course content`() { + val courseId = newCourse() + val foreign = jwtFor(UUID.randomUUID(), "TEACHER") + + mockMvc + .perform( + post("/api/v1/courses/{courseId}/content/chapters", courseId) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"title":"Чужая глава"}""") + .with(foreign), + ).andExpect(status().isForbidden) + + mockMvc + .perform( + post("/api/v1/courses/{courseId}/content/articles", courseId) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"newChapterTitle":"Чужая глава","newSectionTitle":"Чужой раздел","title":"Статья"}""") + .with(foreign), + ).andExpect(status().isForbidden) + } + + @Test + fun `student cannot author course content`() { + val courseId = newCourse() + val chapterId = authoring.createChapter("Глава курса", null, courseId).let { UUID.fromString(it.id) } + val student = jwtFor(UUID.randomUUID(), "STUDENT") + + mockMvc + .perform( + post("/api/v1/courses/{courseId}/content/chapters", courseId) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"title":"Глава студента"}""") + .with(student), + ).andExpect(status().isForbidden) + + mockMvc + .perform( + post("/api/v1/courses/{courseId}/content/articles", courseId) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"chapterId":"$chapterId","newSectionTitle":"Раздел","title":"Статья"}""") + .with(student), + ).andExpect(status().isForbidden) + + mockMvc + .perform( + delete("/api/v1/courses/{courseId}/content/chapters/{id}", courseId, chapterId) + .with(student), + ).andExpect(status().isForbidden) + } + + @Test + fun `teacher cannot change chapter or section of another course`() { + val ownCourseId = newCourse() + val ownTeacher = teacherOf(ownCourseId) + val ownChapterId = createChapter(ownCourseId, "Своя глава", ownTeacher) + val foreign = seedForeignMaterial() + + mockMvc + .perform( + put("/api/v1/courses/{courseId}/content/chapters/{id}", ownCourseId, foreign.chapterId) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"title":"Подмена"}""") + .with(ownTeacher), + ).andExpect(status().isForbidden) + + mockMvc + .perform( + delete("/api/v1/courses/{courseId}/content/chapters/{id}", ownCourseId, foreign.chapterId) + .with(ownTeacher), + ).andExpect(status().isForbidden) + + mockMvc + .perform( + put("/api/v1/courses/{courseId}/content/sections/{id}", ownCourseId, foreign.sectionId) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"title":"Подмена"}""") + .with(ownTeacher), + ).andExpect(status().isForbidden) + + mockMvc + .perform( + put("/api/v1/courses/{courseId}/content/chapters/{id}", foreign.courseId, ownChapterId) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"title":"Чужой курс"}""") + .with(ownTeacher), + ).andExpect(status().isForbidden) + } + + @Test + fun `teacher cannot change article of another course`() { + val ownCourseId = newCourse() + val ownTeacher = teacherOf(ownCourseId) + val foreign = seedForeignMaterial() + + mockMvc + .perform( + put("/api/v1/courses/{courseId}/content/articles/{id}", ownCourseId, foreign.articleId) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"title":"Подмена","bodyMd":"Текст"}""") + .with(ownTeacher), + ).andExpect(status().isForbidden) + + mockMvc + .perform( + delete("/api/v1/courses/{courseId}/content/articles/{id}", ownCourseId, foreign.articleId) + .with(ownTeacher), + ).andExpect(status().isForbidden) + } + + @Test + fun `published toggle hides article from student tree`() { + val courseId = newCourse() + val teacher = teacherOf(courseId) + val groupId = UUID.randomUUID() + courseService.attachGroup(courseId, CourseGroupRequest(groupId.toString())) + `when`(identity.currentGroupIds(anyString())).thenReturn(listOf(groupId)) + val student = jwtFor(UUID.randomUUID(), "STUDENT") + val chapterId = createChapter(courseId, "Глава", teacher) + val sectionId = createSection(courseId, chapterId, "Раздел", teacher) + val articleId = createArticle(courseId, """{"sectionId":"$sectionId","title":"Статья"}""", teacher) + + mockMvc + .perform(get("/api/v1/courses/{courseId}/content/tree", courseId).with(student)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.chapters[0].sections[0].articles[0].id").value(articleId.toString())) + + mockMvc + .perform( + put("/api/v1/courses/{courseId}/content/articles/{id}/published", courseId, articleId) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"published":false}""") + .with(teacher), + ).andExpect(status().isNoContent) + + mockMvc + .perform(get("/api/v1/courses/{courseId}/content/tree", courseId).with(student)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.chapters[0].sections[0].articles.length()").value(0)) + + mockMvc + .perform(get("/api/v1/courses/{courseId}/content/tree", courseId).with(teacher)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.chapters[0].sections[0].articles[0].published").value(false)) + + mockMvc + .perform( + put("/api/v1/courses/{courseId}/content/sections/{id}/published", courseId, sectionId) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"published":false}""") + .with(teacher), + ).andExpect(status().isNoContent) + + mockMvc + .perform(get("/api/v1/courses/{courseId}/content/tree", courseId).with(student)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.chapters[0].sections.length()").value(0)) + } + + @Test + fun `global chapter authoring stays admin only`() { + val teacher = jwtFor(UUID.randomUUID(), "TEACHER") + val admin = jwtFor(UUID.randomUUID(), "ADMIN") + + mockMvc + .perform( + post("/api/v1/content/chapters") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"title":"Глобальная глава преподавателя"}""") + .with(teacher), + ).andExpect(status().isForbidden) + + val result = + mockMvc + .perform( + post("/api/v1/content/chapters") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"title":"Глобальная глава администратора"}""") + .with(admin), + ).andExpect(status().isOk) + .andReturn() + val chapterId = idOf(result) + val chapter: ContentChapter = chapters.findById(chapterId).orElseThrow() + + assertThat(chapter.courseId).isNull() + assertThat(chapter.audience).isEqualTo(AUDIENCE_ALL) + assertThat(contentService.tree().chapters.map { it.id }).contains(chapterId.toString()) + } + + @Test + fun `admin can author content of any course`() { + val courseId = newCourse() + val admin = jwtFor(UUID.randomUUID(), "ADMIN") + + val chapterId = createChapter(courseId, "Глава администратора", admin) + + assertThat(chapters.findById(chapterId).orElseThrow().courseId).isEqualTo(courseId) + } + + private fun seedForeignMaterial(): ForeignMaterial { + val courseId = newCourse() + val chapterId = authoring.createChapter("Чужая глава", null, courseId).let { UUID.fromString(it.id) } + val sectionId = authoring.createSection(chapterId, "Чужой раздел", null, courseId).let { UUID.fromString(it.id) } + val articleId = + authoring + .createArticlePlaced( + ArticlePlacement( + title = "Чужая статья", + bodyMd = "Текст", + chapterId = null, + newChapterTitle = null, + sectionId = sectionId, + newSectionTitle = null, + ), + courseId, + ).let { UUID.fromString(it.id) } + return ForeignMaterial(courseId, chapterId, sectionId, articleId) + } + + private fun newCourse(): UUID = + UUID.fromString( + courseService + .create(CourseCreateRequest("Курс контента ${UUID.randomUUID()}"), UUID.randomUUID()) + .id, + ) + + private fun teacherOf(courseId: UUID): RequestPostProcessor { + val teacherId = UUID.randomUUID() + courseService.assignTeacher(courseId, CourseTeacherRequest(teacherId.toString())) + return jwtFor(teacherId, "TEACHER") + } + + private fun createChapter( + courseId: UUID, + title: String, + user: RequestPostProcessor, + ): UUID = + idOf( + mockMvc + .perform( + post("/api/v1/courses/{courseId}/content/chapters", courseId) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"title":"$title"}""") + .with(user), + ).andExpect(status().isOk) + .andReturn(), + ) + + private fun createSection( + courseId: UUID, + chapterId: UUID, + title: String, + user: RequestPostProcessor, + ): UUID = + idOf( + mockMvc + .perform( + post("/api/v1/courses/{courseId}/content/sections?chapterId=$chapterId", courseId) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"title":"$title"}""") + .with(user), + ).andExpect(status().isOk) + .andReturn(), + ) + + private fun createArticle( + courseId: UUID, + body: String, + user: RequestPostProcessor, + ): UUID = + idOf( + mockMvc + .perform( + post("/api/v1/courses/{courseId}/content/articles", courseId) + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .with(user), + ).andExpect(status().isOk) + .andReturn(), + ) + + private fun idOf(result: MvcResult): UUID { + val id: String = JsonPath.read(result.response.contentAsString, "$.id") + return UUID.fromString(id) + } + + private fun jwtFor( + subject: UUID, + role: String, + ) = jwt() + .authorities(SimpleGrantedAuthority("ROLE_$role")) + .jwt { it.subject(subject.toString()).claim("role", role) } + + private data class ForeignMaterial( + val courseId: UUID, + val chapterId: UUID, + val sectionId: UUID, + val articleId: UUID, + ) +} diff --git a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseGroupTeacherAccessTest.kt b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseGroupTeacherAccessTest.kt new file mode 100644 index 0000000..1fa8397 --- /dev/null +++ b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseGroupTeacherAccessTest.kt @@ -0,0 +1,248 @@ +package ru.ysu.lms.academic.course + +import org.junit.jupiter.api.Test +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.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.bean.override.mockito.MockitoBean +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +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.identity.IdentityClient +import ru.ysu.lms.academic.identity.IdentityGroupSummary +import java.util.UUID + +/** + * Привязка групп к курсу: преподаватель управляет только своими курсами, + * студент не может менять привязки, администратор не ограничен. Identity подменён. + */ +@Testcontainers +@SpringBootTest +@Transactional +class CourseGroupTeacherAccessTest { + companion object { + @Container + @ServiceConnection + @JvmStatic + val postgres = PostgreSQLContainer("postgres:18") + } + + @Autowired + private lateinit var context: WebApplicationContext + + @Autowired + private lateinit var courseService: CourseService + + @MockitoBean + private lateinit var identity: IdentityClient + + private val mockMvc: MockMvc by lazy { + MockMvcBuilders + .webAppContextSetup(context) + .apply(springSecurity()) + .build() + } + + @Test + fun `teacher sees available groups, attaches and detaches on own course`() { + val courseId = newCourse() + val teacherId = UUID.randomUUID() + assignTeacher(courseId, teacherId) + val alpha = UUID.randomUUID() + val beta = UUID.randomUUID() + val graduated = UUID.randomUUID() + `when`(identity.listGroups()).thenReturn( + listOf( + IdentityGroupSummary(beta.toString(), "Бета", "ACTIVE"), + IdentityGroupSummary(graduated.toString(), "Выпускники", "GRADUATED"), + IdentityGroupSummary(alpha.toString(), "Альфа", "ACTIVE"), + ), + ) + val teacher = jwtFor(teacherId, "TEACHER") + + mockMvc + .perform(get("/api/v1/courses/{id}/available-groups", courseId).with(teacher)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].id").value(alpha.toString())) + .andExpect(jsonPath("$[0].name").value("Альфа")) + .andExpect(jsonPath("$[0].status").value("ACTIVE")) + .andExpect(jsonPath("$[1].id").value(beta.toString())) + + mockMvc + .perform( + post("/api/v1/courses/{id}/groups", courseId) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"groupId":"$alpha"}""") + .with(teacher), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.groups.length()").value(1)) + .andExpect(jsonPath("$.groups[0].groupId").value(alpha.toString())) + + mockMvc + .perform(get("/api/v1/courses/{id}/available-groups", courseId).with(teacher)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].id").value(beta.toString())) + + mockMvc + .perform( + delete("/api/v1/courses/{id}/groups/{groupId}", courseId, alpha) + .with(teacher), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.groups[0].removedAt").exists()) + } + + @Test + fun `foreign teacher cannot list attach or detach groups`() { + val courseId = newCourse() + val groupId = UUID.randomUUID() + `when`(identity.listGroups()).thenReturn(listOf(IdentityGroupSummary(groupId.toString(), "Альфа", "ACTIVE"))) + val foreign = jwtFor(UUID.randomUUID(), "TEACHER") + + mockMvc + .perform(get("/api/v1/courses/{id}/available-groups", courseId).with(foreign)) + .andExpect(status().isForbidden) + + mockMvc + .perform( + post("/api/v1/courses/{id}/groups", courseId) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"groupId":"$groupId"}""") + .with(foreign), + ).andExpect(status().isForbidden) + + mockMvc + .perform(delete("/api/v1/courses/{id}/groups/{groupId}", courseId, groupId).with(foreign)) + .andExpect(status().isForbidden) + } + + @Test + fun `student cannot list attach or detach groups`() { + val courseId = newCourse() + val groupId = UUID.randomUUID() + `when`(identity.listGroups()).thenReturn(listOf(IdentityGroupSummary(groupId.toString(), "Альфа", "ACTIVE"))) + val student = jwtFor(UUID.randomUUID(), "STUDENT") + + mockMvc + .perform(get("/api/v1/courses/{id}/available-groups", courseId).with(student)) + .andExpect(status().isForbidden) + + mockMvc + .perform( + post("/api/v1/courses/{id}/groups", courseId) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"groupId":"$groupId"}""") + .with(student), + ).andExpect(status().isForbidden) + + mockMvc + .perform(delete("/api/v1/courses/{id}/groups/{groupId}", courseId, groupId).with(student)) + .andExpect(status().isForbidden) + } + + @Test + fun `admin can list attach and detach groups without assignment`() { + val courseId = newCourse() + val groupId = UUID.randomUUID() + `when`(identity.listGroups()).thenReturn(listOf(IdentityGroupSummary(groupId.toString(), "Альфа", "ACTIVE"))) + val admin = jwtFor(UUID.randomUUID(), "ADMIN") + + mockMvc + .perform(get("/api/v1/courses/{id}/available-groups", courseId).with(admin)) + .andExpect(status().isOk) + .andExpect(jsonPath("$[0].id").value(groupId.toString())) + + mockMvc + .perform( + post("/api/v1/courses/{id}/groups", courseId) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"groupId":"$groupId"}""") + .with(admin), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.groups[0].groupId").value(groupId.toString())) + + mockMvc + .perform(delete("/api/v1/courses/{id}/groups/{groupId}", courseId, groupId).with(admin)) + .andExpect(status().isOk) + } + + @Test + fun `already attached group is excluded from available groups`() { + val courseId = newCourse() + val teacherId = UUID.randomUUID() + assignTeacher(courseId, teacherId) + val attached = UUID.randomUUID() + val free = UUID.randomUUID() + courseService.attachGroup(courseId, CourseGroupRequest(attached.toString())) + `when`(identity.listGroups()).thenReturn( + listOf( + IdentityGroupSummary(attached.toString(), "Привязана", "ACTIVE"), + IdentityGroupSummary(free.toString(), "Свободна", "ACTIVE"), + ), + ) + + mockMvc + .perform( + get("/api/v1/courses/{id}/available-groups", courseId) + .with(jwtFor(teacherId, "TEACHER")), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].id").value(free.toString())) + } + + @Test + fun `removed group becomes available again`() { + val courseId = newCourse() + val teacherId = UUID.randomUUID() + assignTeacher(courseId, teacherId) + val groupId = UUID.randomUUID() + courseService.attachGroup(courseId, CourseGroupRequest(groupId.toString())) + courseService.detachGroup(courseId, groupId.toString()) + `when`(identity.listGroups()).thenReturn(listOf(IdentityGroupSummary(groupId.toString(), "Возвращена", "ACTIVE"))) + + mockMvc + .perform( + get("/api/v1/courses/{id}/available-groups", courseId) + .with(jwtFor(teacherId, "TEACHER")), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].id").value(groupId.toString())) + } + + private fun newCourse(): UUID = + UUID.fromString( + courseService + .create(CourseCreateRequest("Курс групп ${UUID.randomUUID()}"), UUID.randomUUID()) + .id, + ) + + private fun assignTeacher( + courseId: UUID, + teacherId: UUID, + ) { + courseService.assignTeacher(courseId, CourseTeacherRequest(teacherId.toString())) + } + + private fun jwtFor( + subject: UUID, + role: String, + ) = jwt() + .authorities(SimpleGrantedAuthority("ROLE_$role")) + .jwt { it.subject(subject.toString()).claim("role", role) } +}