Build: enable ktlint+detekt, format sources, add core unit tests

This commit is contained in:
2026-09-27 21:46:05 +03:00
parent 802616918f
commit f3e8b2668f
103 changed files with 3834 additions and 1909 deletions

View File

@@ -6,7 +6,6 @@ import org.springframework.context.annotation.Bean
@AutoConfiguration
class LmsCommonAutoConfiguration {
@Bean
@ConditionalOnMissingBean
fun lmsExceptionHandler(): LmsExceptionHandler = LmsExceptionHandler()

View File

@@ -13,17 +13,27 @@ open class LmsException(
val violations: List<FieldViolation> = emptyList(),
) : RuntimeException(message)
class BadRequestException(message: String, code: String? = null) :
LmsException(HttpStatus.BAD_REQUEST, message, code)
class BadRequestException(
message: String,
code: String? = null,
) : LmsException(HttpStatus.BAD_REQUEST, message, code)
class NotFoundException(message: String, code: String? = null) :
LmsException(HttpStatus.NOT_FOUND, message, code)
class NotFoundException(
message: String,
code: String? = null,
) : LmsException(HttpStatus.NOT_FOUND, message, code)
class ConflictException(message: String, code: String? = null) :
LmsException(HttpStatus.CONFLICT, message, code)
class ConflictException(
message: String,
code: String? = null,
) : LmsException(HttpStatus.CONFLICT, message, code)
class ForbiddenException(message: String, code: String? = null) :
LmsException(HttpStatus.FORBIDDEN, message, code)
class ForbiddenException(
message: String,
code: String? = null,
) : LmsException(HttpStatus.FORBIDDEN, message, code)
class UnauthorizedException(message: String, code: String? = null) :
LmsException(HttpStatus.UNAUTHORIZED, message, code)
class UnauthorizedException(
message: String,
code: String? = null,
) : LmsException(HttpStatus.UNAUTHORIZED, message, code)

View File

@@ -7,7 +7,6 @@ import org.springframework.web.bind.annotation.RestControllerAdvice
@RestControllerAdvice
class LmsExceptionHandler {
@ExceptionHandler(LmsException::class)
fun handleDomain(ex: LmsException): ResponseEntity<ApiError> =
ResponseEntity.status(ex.status).body(
@@ -22,9 +21,10 @@ class LmsExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity<ApiError> {
val violations = ex.bindingResult.fieldErrors.map {
FieldViolation(field = it.field, message = it.defaultMessage ?: "invalid value")
}
val violations =
ex.bindingResult.fieldErrors.map {
FieldViolation(field = it.field, message = it.defaultMessage ?: "invalid value")
}
return ResponseEntity.badRequest().body(
ApiError(
title = "Validation failed",

View File

@@ -9,4 +9,6 @@ dependencies {
api(libs.owaspHtmlSanitizer)
api(libs.springContext)
api(libs.springBootAutoconfigure)
testImplementation(project(":libs:test-support"))
}

View File

@@ -8,9 +8,7 @@ import org.springframework.context.annotation.Bean
@AutoConfiguration
@EnableConfigurationProperties(MarkdownProperties::class)
class LmsMarkdownAutoConfiguration {
@Bean
@ConditionalOnMissingBean
fun markdownRenderer(properties: MarkdownProperties): MarkdownRenderer =
MarkdownRenderer(allowHtml = properties.allowHtml)
fun markdownRenderer(properties: MarkdownProperties): MarkdownRenderer = MarkdownRenderer(allowHtml = properties.allowHtml)
}

View File

@@ -32,20 +32,21 @@ import org.owasp.html.PolicyFactory
class MarkdownRenderer(
private val allowHtml: Boolean = false,
) {
private val options = MutableDataSet().apply {
set(
Parser.EXTENSIONS,
listOf(
TablesExtension.create(),
StrikethroughExtension.create(),
TaskListExtension.create(),
FootnoteExtension.create(),
AutolinkExtension.create(),
),
)
set(HtmlRenderer.GENERATE_HEADER_ID, true)
set(HtmlRenderer.ESCAPE_HTML, !allowHtml)
}
private val options =
MutableDataSet().apply {
set(
Parser.EXTENSIONS,
listOf(
TablesExtension.create(),
StrikethroughExtension.create(),
TaskListExtension.create(),
FootnoteExtension.create(),
AutolinkExtension.create(),
),
)
set(HtmlRenderer.GENERATE_HEADER_ID, true)
set(HtmlRenderer.ESCAPE_HTML, !allowHtml)
}
private val parser: Parser = Parser.builder(options).build()
private val renderer: HtmlRenderer = HtmlRenderer.builder(options).build()
@@ -55,21 +56,26 @@ class MarkdownRenderer(
/** Оборачивает таблицы в прокручиваемый контейнер, чтобы на мобильных они не выезжали. */
private fun wrapTables(html: String): String =
html.replace("<table", "<div class=\"table-scroll\"><table")
html
.replace("<table", "<div class=\"table-scroll\"><table")
.replace("</table>", "</table></div>")
private fun renderRaw(markdown: String, depth: Int): String {
private fun renderRaw(
markdown: String,
depth: Int,
): String {
val out = StringBuilder()
var cursor = 0
for (match in SPOILER.findAll(markdown)) {
out.append(renderWithDirectives(markdown.substring(cursor, match.range.first)))
val title = match.groupValues[1].trim().ifBlank { "Спойлер" }
val body = match.groupValues[2]
val inner = if (depth < MAX_DEPTH) {
renderRaw(body, depth + 1)
} else {
renderWithDirectives(body)
}
val inner =
if (depth < MAX_DEPTH) {
renderRaw(body, depth + 1)
} else {
renderWithDirectives(body)
}
out.append("<details class=\"spoiler\"><summary>")
out.append(escapeHtml(title))
out.append("</summary><div class=\"spoiler-body\">")
@@ -81,8 +87,7 @@ class MarkdownRenderer(
return out.toString()
}
private fun renderSegment(markdown: String): String =
renderer.render(parser.parse(markdown))
private fun renderSegment(markdown: String): String = renderer.render(parser.parse(markdown))
/** Обрабатывает построчные директивы `:::figure` (картинка с подписью) и `:::video`. */
private fun renderWithDirectives(markdown: String): String {
@@ -118,51 +123,104 @@ class MarkdownRenderer(
return out.toString()
}
private fun renderInline(text: String): String =
renderSegment(text).removePrefix("<p>").removeSuffix("</p>").trim()
private fun renderInline(text: String): String = renderSegment(text).removePrefix("<p>").removeSuffix("</p>").trim()
private fun escapeHtml(value: String): String = value
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
private fun escapeHtml(value: String): String =
value
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
private fun buildSanitizer(): PolicyFactory = HtmlPolicyBuilder()
.allowElements(
"p", "br", "hr",
"h1", "h2", "h3", "h4", "h5", "h6",
"strong", "b", "em", "i", "del", "s", "blockquote",
"ul", "ol", "li",
"a", "img",
"code", "pre",
"table", "thead", "tbody", "tr", "th", "td",
"details", "summary", "div", "span", "sup", "sub", "kbd", "figure", "figcaption",
"video", "source", "track", "audio",
)
.allowAttributes("href").onElements("a")
.allowAttributes("title").onElements("a", "img")
.allowAttributes("src", "alt").onElements("img")
.allowAttributes("src", "poster", "controls", "preload", "width", "height", "loop", "muted", "playsinline")
.onElements("video")
.allowAttributes("src", "type").onElements("source")
.allowAttributes("src", "kind", "label", "srclang", "default").onElements("track")
.allowAttributes("class").onElements(
"code", "pre", "div", "span", "details", "summary", "table", "th", "td",
"figure", "figcaption", "video", "img",
)
.allowAttributes("id").onElements("h1", "h2", "h3", "h4", "h5", "h6")
.allowAttributes("colspan", "rowspan").onElements("td", "th")
.allowUrlProtocols("http", "https", "mailto")
.requireRelNofollowOnLinks()
.toFactory()
private fun buildSanitizer(): PolicyFactory =
HtmlPolicyBuilder()
.allowElements(
"p",
"br",
"hr",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"strong",
"b",
"em",
"i",
"del",
"s",
"blockquote",
"ul",
"ol",
"li",
"a",
"img",
"code",
"pre",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
"details",
"summary",
"div",
"span",
"sup",
"sub",
"kbd",
"figure",
"figcaption",
"video",
"source",
"track",
"audio",
).allowAttributes("href")
.onElements("a")
.allowAttributes("title")
.onElements("a", "img")
.allowAttributes("src", "alt")
.onElements("img")
.allowAttributes("src", "poster", "controls", "preload", "width", "height", "loop", "muted", "playsinline")
.onElements("video")
.allowAttributes("src", "type")
.onElements("source")
.allowAttributes("src", "kind", "label", "srclang", "default")
.onElements("track")
.allowAttributes("class")
.onElements(
"code",
"pre",
"div",
"span",
"details",
"summary",
"table",
"th",
"td",
"figure",
"figcaption",
"video",
"img",
).allowAttributes("id")
.onElements("h1", "h2", "h3", "h4", "h5", "h6")
.allowAttributes("colspan", "rowspan")
.onElements("td", "th")
.allowUrlProtocols("http", "https", "mailto")
.requireRelNofollowOnLinks()
.toFactory()
private companion object {
const val MAX_DEPTH = 5
val MEDIA = Regex(
pattern = "(?m)^[ \\t]*:::[ \\t]*(figure|video)[ \\t]+(\\S+)[ \\t]*(?:\\|[ \\t]*(.*?))?[ \\t]*$",
)
val SPOILER = Regex(
pattern = "(?ms)^[ \\t]*:::[ \\t]*spoiler(?:[ \\t]+(.+?))?[ \\t]*\\r?\\n(.*?)^[ \\t]*:::[ \\t]*$",
)
val MEDIA =
Regex(
pattern = "(?m)^[ \\t]*:::[ \\t]*(figure|video)[ \\t]+(\\S+)[ \\t]*(?:\\|[ \\t]*(.*?))?[ \\t]*$",
)
val SPOILER =
Regex(
pattern = "(?ms)^[ \\t]*:::[ \\t]*spoiler(?:[ \\t]+(.+?))?[ \\t]*\\r?\\n(.*?)^[ \\t]*:::[ \\t]*$",
)
}
}

View File

@@ -0,0 +1,61 @@
package ru.ysu.lms.markdown
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
class MarkdownRendererTest {
private val renderer = MarkdownRenderer()
@Test
fun `renders basic markdown`() {
val html = renderer.render("# Заголовок\n\n**жирный** и *курсив*")
assertThat(html).contains("<h1")
assertThat(html).contains("Заголовок")
assertThat(html).contains("<strong>жирный</strong>")
assertThat(html).contains("<em>курсив</em>")
}
@Test
fun `renders lists and code blocks`() {
val html = renderer.render("- один\n- два\n\n```c\nint x = 1;\n```")
assertThat(html).contains("<ul>")
assertThat(html).contains("<li>")
assertThat(html).contains("один")
assertThat(html).contains("<pre>")
assertThat(html).contains("language-c")
assertThat(html).contains("int x")
}
@Test
fun `wraps tables into scrollable container`() {
val md =
"""
| № | Задание |
|---|---|
| 1 | Сумма |
""".trimIndent()
val html = renderer.render(md)
assertThat(html).contains("<div class=\"table-scroll\"><table")
assertThat(html).contains("</table></div>")
}
@Test
fun `escapes raw html and strips scripts`() {
val html = renderer.render("<script>alert(1)</script>")
assertThat(html).doesNotContain("<script>")
assertThat(html).contains("&lt;script&gt;")
}
@Test
fun `renders spoiler container`() {
val html = renderer.render(":::spoiler Ответ\nскрытый текст\n:::")
assertThat(html).contains("spoiler")
assertThat(html).contains("скрытый текст")
}
}

View File

@@ -2,7 +2,8 @@ package ru.ysu.lms.security
enum class LmsRole {
ADMIN,
STUDENT;
STUDENT,
;
fun authority(): String = "ROLE_$name"
}

View File

@@ -19,14 +19,15 @@ import org.springframework.security.oauth2.jwt.NimbusJwtEncoder
@AutoConfiguration
@EnableConfigurationProperties(JwtProperties::class)
class LmsSecurityAutoConfiguration {
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = "lms.jwt", name = ["private-key"])
fun jwtEncoder(properties: JwtProperties): JwtEncoder {
val rsaKey = RSAKey.Builder(RsaKeys.publicKey(properties.publicKey))
.privateKey(RsaKeys.privateKey(properties.privateKey))
.build()
val rsaKey =
RSAKey
.Builder(RsaKeys.publicKey(properties.publicKey))
.privateKey(RsaKeys.privateKey(properties.privateKey))
.build()
return NimbusJwtEncoder(ImmutableJWKSet<SecurityContext>(JWKSet(rsaKey)))
}

View File

@@ -9,24 +9,26 @@ import java.util.Base64
/** Parses PEM-encoded RSA keys (PKCS#8 private, X.509 public). */
object RsaKeys {
fun publicKey(pem: String): RSAPublicKey {
val der = decode(pem)
return KeyFactory.getInstance("RSA")
return KeyFactory
.getInstance("RSA")
.generatePublic(X509EncodedKeySpec(der)) as RSAPublicKey
}
fun privateKey(pem: String): RSAPrivateKey {
val der = decode(pem)
return KeyFactory.getInstance("RSA")
return KeyFactory
.getInstance("RSA")
.generatePrivate(PKCS8EncodedKeySpec(der)) as RSAPrivateKey
}
private fun decode(pem: String): ByteArray {
val body = pem
.replace(Regex("-----BEGIN [A-Z ]+-----"), "")
.replace(Regex("-----END [A-Z ]+-----"), "")
.replace(Regex("\\s"), "")
val body =
pem
.replace(Regex("-----BEGIN [A-Z ]+-----"), "")
.replace(Regex("-----END [A-Z ]+-----"), "")
.replace(Regex("\\s"), "")
return Base64.getDecoder().decode(body)
}
}

View File

@@ -3,11 +3,14 @@ plugins {
}
dependencies {
implementation(platform(libs.springBootBom))
api(platform(libs.springBootBom))
// Shared integration-test infrastructure. Consumers add this module as testImplementation.
api(libs.springBootTestcontainers)
api(libs.testcontainersPostgresql)
api(libs.testcontainersJunitJupiter)
api(libs.springBootStarterTest)
api(libs.mockk)
// Gradle 9 требует launcher JUnit Platform на тестовом classpath явно.
api(libs.junitPlatformLauncher)
}