feat: закрыть остаточный backlog

- keyset-пагинация аудита с курсором (occurred_at, id) и UI «Показать ещё»
- пакетный /api/v1/labs/details и переход progress на один запрос
- абсолютный потолок сессии 30 дней (identity V12 + web), очистка refresh
- админская инвентаризация legacy-активов (/admin/legacy-assets, скрипт)
- E2E в Chromium/Firefox/WebKit (18/18), CI preflight
- LDD: 51 claim, 346 тестов, docs и handoff обновлены
This commit is contained in:
2026-10-28 19:29:23 +03:00
parent d45137220c
commit 1e1e9ae0e4
64 changed files with 2124 additions and 372 deletions

View File

@@ -54,6 +54,16 @@ class LabController(
return labService.list(accessible)
}
/** Полные данные всех лабораторных курса одним запросом (без N+1 по деталям). */
@GetMapping("/details")
fun details(
@RequestParam courseId: UUID,
@AuthenticationPrincipal jwt: Jwt,
): List<LabDetail> {
accessService.requireCourseAccess(courseId, jwt)
return labService.details(courseId)
}
@GetMapping("/{id}")
fun detail(
@PathVariable id: UUID,

View File

@@ -22,7 +22,7 @@ import java.time.OffsetDateTime
import java.time.ZoneId
import java.util.UUID
@Suppress("LongParameterList")
@Suppress("LongParameterList", "TooManyFunctions")
@Service
class LabService(
private val labs: LabRepository,
@@ -118,45 +118,25 @@ class LabService(
@Transactional(readOnly = true)
fun detail(labId: UUID): LabDetail {
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
val levels = labLevels.findByLabIdOrderByPosition(lab.id)
return LabDetail(
id = lab.id.toString(),
number = lab.number,
title = lab.title,
status = lab.status,
descriptionMd = lab.descriptionMd,
deadlineAt = lab.deadlineAt?.toString(),
defenseAt = lab.defenseAt?.toString(),
pointsMax = lab.pointsMax,
levels =
levels.map { level ->
// Сортировка по числу в номере: «2» раньше «10» (номер хранится строкой).
val vs =
variantsRepo
.findByLabIdOrderByNumber(lab.id)
.filter { it.labLevel?.id == level.id }
.sortedWith(compareBy({ naturalNumber(it.number) }, { it.number }))
LabLevelDetail(
id = level.id.toString(),
code = level.code,
title = level.title,
variants =
vs.map { v ->
VariantDetail(
id = v.id.toString(),
number = v.number,
title = v.title,
statementMd = v.statementMd,
)
},
)
},
courseId = lab.course?.id?.toString(),
courseNo = lab.course?.courseNo,
courseTitle = lab.course?.title,
return lab.toDetail(
labLevels.findByLabIdOrderByPosition(lab.id),
variantsRepo.findByLabIdOrderByNumber(lab.id),
)
}
/** Все лабораторные курса с уровнями и вариантами: три запроса вместо N+1. */
@Transactional(readOnly = true)
fun details(courseId: UUID): List<LabDetail> {
val labList = labs.findAllByCourseIdOrderByNumberAsc(courseId)
if (labList.isEmpty()) return emptyList()
val labIds = labList.map { it.id }
val levelsByLab = labLevels.findByLabIdInOrderByPosition(labIds).groupBy { it.lab?.id }
val variantsByLab = variantsRepo.findByLabIdInOrderByNumberAsc(labIds).groupBy { it.lab?.id }
return labList.map { lab ->
lab.toDetail(levelsByLab[lab.id].orEmpty(), variantsByLab[lab.id].orEmpty())
}
}
@Transactional
fun delete(labId: UUID) {
if (!labs.existsById(labId)) {
@@ -414,6 +394,49 @@ private fun Course.rejectArchived(): Course {
private fun naturalNumber(value: String): Int = Regex("\\d+").find(value)?.value?.toIntOrNull() ?: Int.MAX_VALUE
/** Единая сборка деталей лабораторной для одиночного и пакетного ответов. */
private fun Lab.toDetail(
levels: List<LabLevel>,
variants: List<Variant>,
): LabDetail {
val variantsByLevel = variants.groupBy { it.labLevel?.id }
return LabDetail(
id = id.toString(),
number = number,
title = title,
status = status,
descriptionMd = descriptionMd,
deadlineAt = deadlineAt?.toString(),
defenseAt = defenseAt?.toString(),
pointsMax = pointsMax,
levels =
levels.map { level ->
// Сортировка по числу в номере: «2» раньше «10» (номер хранится строкой).
val vs =
variantsByLevel[level.id]
.orEmpty()
.sortedWith(compareBy({ naturalNumber(it.number) }, { it.number }))
LabLevelDetail(
id = level.id.toString(),
code = level.code,
title = level.title,
variants =
vs.map { v ->
VariantDetail(
id = v.id.toString(),
number = v.number,
title = v.title,
statementMd = v.statementMd,
)
},
)
},
courseId = course?.id?.toString(),
courseNo = course?.courseNo,
courseTitle = course?.title,
)
}
private fun Lab.toSummary(variants: List<Variant>): LabSummary =
LabSummary(
id = id.toString(),

View File

@@ -7,8 +7,12 @@ import java.util.UUID
interface LabLevelRepository : JpaRepository<LabLevel, UUID> {
fun findByLabIdOrderByPosition(labId: UUID): List<LabLevel>
fun findByLabIdInOrderByPosition(labIds: Collection<UUID>): List<LabLevel>
}
interface VariantRepository : JpaRepository<Variant, UUID> {
fun findByLabIdOrderByNumber(labId: UUID): List<Variant>
fun findByLabIdInOrderByNumberAsc(labIds: Collection<UUID>): List<Variant>
}

View File

@@ -0,0 +1,268 @@
package ru.ysu.lms.academic.lab
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.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.get
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.identity.IdentityClient
import java.util.UUID
/**
* Пакетные детали лабораторных курса: один запрос отдаёт все лабораторные с их
* уровнями и вариантами, без утечки вариантов между уровнями и лабораторными.
* Доступ — тот же, что у `GET /api/v1/labs?courseId=`: `requireCourseAccess`.
*/
@Testcontainers
@SpringBootTest
@Transactional
class LabDetailsIntegrationTest {
companion object {
@Container
@ServiceConnection
@JvmStatic
val postgres = PostgreSQLContainer("postgres:18")
}
@Autowired
private lateinit var context: WebApplicationContext
@Autowired
private lateinit var courseService: CourseService
@Autowired
private lateinit var labService: LabService
@MockitoBean
private lateinit var identity: IdentityClient
private val mockMvc: MockMvc by lazy {
MockMvcBuilders
.webAppContextSetup(context)
.apply<DefaultMockMvcBuilder>(springSecurity())
.build()
}
@Test
fun `teacher gets all course labs with levels and variants in one call`() {
val courseId = newCourse()
val teacherId = UUID.randomUUID()
assignTeacher(courseId, teacherId)
seedFirstLab(courseId)
seedSecondLab(courseId)
mockMvc
.perform(
get("/api/v1/labs/details")
.param("courseId", courseId.toString())
.with(jwtFor(teacherId, "TEACHER")),
).andExpect(status().isOk)
.andExpect(jsonPath("$.length()").value(2))
.andExpect(jsonPath("$[0].number").value(1))
.andExpect(jsonPath("$[0].title").value("Первая лабораторная"))
.andExpect(jsonPath("$[0].courseId").value(courseId.toString()))
.andExpect(jsonPath("$[0].levels.length()").value(2))
.andExpect(jsonPath("$[0].levels[0].code").value("basic"))
.andExpect(jsonPath("$[0].levels[0].title").value("Базовый уровень"))
.andExpect(jsonPath("$[0].levels[0].variants.length()").value(2))
.andExpect(jsonPath("$[0].levels[0].variants[0].number").value("2"))
.andExpect(jsonPath("$[0].levels[0].variants[0].title").value("Б2"))
.andExpect(jsonPath("$[0].levels[0].variants[1].number").value("10"))
.andExpect(jsonPath("$[0].levels[0].variants[1].title").value("Б10"))
.andExpect(jsonPath("$[0].levels[1].code").value("advanced"))
.andExpect(jsonPath("$[0].levels[1].title").value("Продвинутый уровень"))
.andExpect(jsonPath("$[0].levels[1].variants.length()").value(1))
.andExpect(jsonPath("$[0].levels[1].variants[0].number").value("1"))
.andExpect(jsonPath("$[0].levels[1].variants[0].title").value("А1"))
.andExpect(jsonPath("$[1].number").value(2))
.andExpect(jsonPath("$[1].title").value("Вторая лабораторная"))
.andExpect(jsonPath("$[1].levels.length()").value(1))
.andExpect(jsonPath("$[1].levels[0].code").value("basic"))
.andExpect(jsonPath("$[1].levels[0].variants.length()").value(1))
.andExpect(jsonPath("$[1].levels[0].variants[0].number").value("3"))
.andExpect(jsonPath("$[1].levels[0].variants[0].title").value("В3"))
}
@Test
fun `admin gets all labs of any course in one call`() {
val courseId = newCourse()
seedFirstLab(courseId)
mockMvc
.perform(
get("/api/v1/labs/details")
.param("courseId", courseId.toString())
.with(jwtFor(UUID.randomUUID(), "ADMIN")),
).andExpect(status().isOk)
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].number").value(1))
.andExpect(jsonPath("$[0].levels.length()").value(2))
}
@Test
fun `foreign teacher is forbidden for lab details`() {
val courseId = newCourse()
seedFirstLab(courseId)
mockMvc
.perform(
get("/api/v1/labs/details")
.param("courseId", courseId.toString())
.with(jwtFor(UUID.randomUUID(), "TEACHER")),
).andExpect(status().isForbidden)
}
@Test
fun `student of the course receives lab details`() {
val courseId = newCourse()
seedFirstLab(courseId)
val groupId = UUID.randomUUID()
courseService.attachGroup(courseId, CourseGroupRequest(groupId.toString()))
`when`(identity.currentGroupIds(anyString())).thenReturn(listOf(groupId))
mockMvc
.perform(
get("/api/v1/labs/details")
.param("courseId", courseId.toString())
.with(jwtFor(UUID.randomUUID(), "STUDENT")),
).andExpect(status().isOk)
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].levels.length()").value(2))
}
@Test
fun `student outside the course is forbidden for lab details`() {
val courseId = newCourse()
seedFirstLab(courseId)
`when`(identity.currentGroupIds(anyString())).thenReturn(listOf(UUID.randomUUID()))
mockMvc
.perform(
get("/api/v1/labs/details")
.param("courseId", courseId.toString())
.with(jwtFor(UUID.randomUUID(), "STUDENT")),
).andExpect(status().isForbidden)
}
@Test
fun `course without labs returns an empty details list`() {
val courseId = newCourse()
val teacherId = UUID.randomUUID()
assignTeacher(courseId, teacherId)
mockMvc
.perform(
get("/api/v1/labs/details")
.param("courseId", courseId.toString())
.with(jwtFor(teacherId, "TEACHER")),
).andExpect(status().isOk)
.andExpect(jsonPath("$.length()").value(0))
}
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 seedFirstLab(courseId: UUID) {
createLab(
courseId = courseId,
number = 1,
title = "Первая лабораторная",
levels =
listOf(
LevelSaveRequest(code = "basic", title = "Базовый уровень"),
LevelSaveRequest(code = "advanced", title = "Продвинутый уровень"),
),
variants =
listOf(
SeededVariant("basic", "10", "Б10"),
SeededVariant("basic", "2", "Б2"),
SeededVariant("advanced", "1", "А1"),
),
)
}
private fun seedSecondLab(courseId: UUID) {
createLab(
courseId = courseId,
number = 2,
title = "Вторая лабораторная",
levels = listOf(LevelSaveRequest(code = "basic", title = "Базовый уровень")),
variants = listOf(SeededVariant("basic", "3", "В3")),
)
}
private fun createLab(
courseId: UUID,
number: Int,
title: String,
levels: List<LevelSaveRequest>,
variants: List<SeededVariant>,
) {
val created =
labService.create(
LabCreateRequest(
number = number,
title = title,
courseId = courseId,
levels = levels,
),
)
val levelIdsByCode = created.levels.associate { it.code to it.id }
val labId = UUID.fromString(created.id)
variants.forEach { variant ->
labService.addVariant(
labId,
VariantSaveRequest(
labLevelId = levelIdsByCode.getValue(variant.levelCode),
number = variant.number,
title = variant.title,
statementMd = "Условие ${variant.title}",
),
)
}
}
private fun jwtFor(
subject: UUID,
role: String,
) = jwt()
.authorities(SimpleGrantedAuthority("ROLE_$role"))
.jwt { it.subject(subject.toString()).claim("role", role) }
private data class SeededVariant(
val levelCode: String,
val number: String,
val title: String,
)
}

View File

@@ -18,5 +18,6 @@ class AuditController(
@RequestParam(defaultValue = "100") limit: Int,
@RequestParam(required = false) action: String?,
@RequestParam(defaultValue = "0") page: Int,
): List<AuditEventDto> = auditService.list(limit, action, page)
@RequestParam(required = false) cursor: String?,
): List<AuditEventDto> = auditService.list(limit, action, page, cursor)
}

View File

@@ -24,4 +24,6 @@ data class AuditEventDto(
val courseId: String?,
val studentId: String?,
val details: String,
/** Непрозрачный курсор для следующей keyset-страницы. */
val cursor: String? = null,
)

View File

@@ -2,6 +2,9 @@ package ru.ysu.lms.identity.audit
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import java.time.Instant
import java.util.UUID
interface AuditEventRepository : JpaRepository<AuditEvent, UUID> {
@@ -13,4 +16,20 @@ interface AuditEventRepository : JpaRepository<AuditEvent, UUID> {
action: String,
pageable: Pageable,
): List<AuditEvent>
/** Keyset-страница: строго после курсорной пары `(occurredAt, id)` при сортировке DESC. */
@Query(
"""
select e from AuditEvent e
where (:action is null or e.action = :action)
and (e.occurredAt < :beforeAt or (e.occurredAt = :beforeAt and e.id < :beforeId))
order by e.occurredAt desc, e.id desc
""",
)
fun findBefore(
@Param("action") action: String?,
@Param("beforeAt") beforeAt: Instant,
@Param("beforeId") beforeId: UUID,
pageable: Pageable,
): List<AuditEvent>
}

View File

@@ -4,6 +4,8 @@ import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.BadRequestException
import java.time.Instant
import java.util.Base64
import java.util.UUID
/**
@@ -39,16 +41,22 @@ class AuditService(
limit: Int = DEFAULT_LIMIT,
action: String? = null,
page: Int = 0,
cursor: String? = null,
): List<AuditEventDto> {
val size = limit.coerceIn(1, MAX_LIMIT)
val pageIndex = page.coerceAtLeast(0)
val pageable = PageRequest.of(pageIndex, size)
val filter = action?.trim()?.takeIf { it.isNotEmpty() }
val found =
if (filter == null) {
events.findAllByOrderByOccurredAtDescIdDesc(pageable)
if (cursor != null) {
val (beforeAt, beforeId) = decodeCursor(cursor)
events.findBefore(filter, beforeAt, beforeId, PageRequest.of(0, size))
} else {
events.findAllByActionOrderByOccurredAtDescIdDesc(filter, pageable)
val pageIndex = page.coerceAtLeast(0)
val pageable = PageRequest.of(pageIndex, size)
if (filter == null) {
events.findAllByOrderByOccurredAtDescIdDesc(pageable)
} else {
events.findAllByActionOrderByOccurredAtDescIdDesc(filter, pageable)
}
}
return found.map(::toDto)
}
@@ -65,8 +73,30 @@ class AuditService(
courseId = event.courseId?.toString(),
studentId = event.studentId?.toString(),
details = event.details,
cursor = encodeCursor(event.occurredAt, event.id),
)
/** Курсор — URL-safe Base64 без паддинга от пары `occurredAt|id`. */
private fun encodeCursor(
occurredAt: Instant,
id: UUID,
): String =
Base64
.getUrlEncoder()
.withoutPadding()
.encodeToString("$occurredAt|$id".toByteArray(Charsets.UTF_8))
private fun decodeCursor(raw: String): Pair<Instant, UUID> {
val parts =
runCatching { String(Base64.getUrlDecoder().decode(raw), Charsets.UTF_8) }
.getOrNull()
?.split("|", limit = 2)
val occurredAt = parts?.getOrNull(0)?.let { runCatching { Instant.parse(it) }.getOrNull() }
val id = parts?.getOrNull(1)?.let { runCatching { UUID.fromString(it) }.getOrNull() }
if (occurredAt == null || id == null) throw BadRequestException(INVALID_CURSOR)
return occurredAt to id
}
private fun requireText(
raw: String,
field: String,
@@ -97,5 +127,6 @@ class AuditService(
const val ENTITY_ID_MAX = 128
const val ACTOR_LOGIN_MAX = 100
const val DETAILS_MAX = 2000
const val INVALID_CURSOR = "Некорректный курсор аудита"
}
}

View File

@@ -26,6 +26,8 @@ class RefreshToken(
var issuedAt: Instant = Instant.now(),
@Column(name = "expires_at", nullable = false)
var expiresAt: Instant = Instant.now(),
@Column(name = "absolute_expires_at", nullable = false)
var absoluteExpiresAt: Instant = Instant.now(),
@Column(name = "revoked_at")
var revokedAt: Instant? = null,
@Column(name = "replaced_by")

View File

@@ -1,10 +1,21 @@
package ru.ysu.lms.identity.auth.refresh
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import java.time.Instant
import java.util.UUID
interface RefreshTokenRepository : JpaRepository<RefreshToken, UUID> {
fun findByTokenHash(tokenHash: String): RefreshToken?
fun findAllByAccountIdAndRevokedAtIsNull(accountId: UUID): List<RefreshToken>
/** Удаляет и sliding-, и absolute-мёртвые токены, чтобы цепочка не жила лишние дни. */
@Modifying
@Query("delete from RefreshToken t where t.expiresAt < :cutoff or t.absoluteExpiresAt < :cutoff")
fun deleteExpiredBefore(
@Param("cutoff") cutoff: Instant,
): Int
}

View File

@@ -1,8 +1,11 @@
package ru.ysu.lms.identity.auth.refresh
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Lazy
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.Ids
import ru.ysu.lms.common.UnauthorizedException
import ru.ysu.lms.identity.auth.AuthService
@@ -23,6 +26,8 @@ data class RefreshTokenRotation(
/**
* Выдаёт, ротирует и отзывает refresh-токены. Access-токен живёт минуты,
* а сессия продлевается скользяще: каждый refresh возвращает новую пару.
* Абсолютный срок (`absoluteExpiresAt`) при этом не продлевается: сессия
* живёт не дольше [absoluteTtl] с момента выдачи.
*/
@Service
class RefreshTokenService(
@@ -30,7 +35,10 @@ class RefreshTokenService(
// AuthService сам зависит от RefreshTokenService (выдача при login), поэтому прокси.
@Lazy private val authService: AuthService,
@Value("\${lms.auth.refresh-token-ttl:7d}") private val ttl: Duration,
@Value("\${lms.auth.absolute-session-ttl:30d}") private val absoluteTtl: Duration,
@Value("\${lms.auth.refresh-token-retention:30d}") private val retention: Duration,
) {
private val log = LoggerFactory.getLogger(RefreshTokenService::class.java)
private val random = SecureRandom()
fun issue(accountId: UUID): String {
@@ -48,13 +56,16 @@ class RefreshTokenService(
fun rotate(raw: String): RefreshTokenRotation {
val token = activeTokenOrThrow(raw)
val now = Instant.now()
if (!token.absoluteExpiresAt.isAfter(now)) {
throw UnauthorizedException(SESSION_EXPIRED)
}
authService.ensureSessionAllowed(token.accountId)
val newId = Ids.v7()
val newRaw = generateRaw()
token.revokedAt = now
token.replacedBy = newId
tokens.saveAll(listOf(token, newToken(token.accountId, newRaw, now, newId)))
tokens.saveAll(listOf(token, newToken(token.accountId, newRaw, now, newId, token.absoluteExpiresAt)))
return RefreshTokenRotation(token.accountId, newRaw)
}
@@ -95,11 +106,32 @@ class RefreshTokenService(
tokens.saveAll(active)
}
/**
* Удаляет токены, истёкшие раньше `now - retention`. Отозванные, но ещё
* не истёкшие токены сохраняются: по ним детектируется reuse.
*/
@Transactional
fun cleanupExpired(now: Instant = Instant.now()): Int {
val cutoff = now.minus(retention)
val deleted = tokens.deleteExpiredBefore(cutoff)
if (deleted > 0) {
log.info("Удалено истёкших refresh-токенов: {}", deleted)
}
return deleted
}
@Scheduled(cron = "\${lms.auth.refresh-token-cleanup-cron:0 30 3 * * *}")
@Transactional
fun cleanupExpiredTokens() {
cleanupExpired()
}
private fun newToken(
accountId: UUID,
raw: String,
now: Instant,
id: UUID = Ids.v7(),
absoluteExpiresAt: Instant = now.plus(absoluteTtl),
): RefreshToken =
RefreshToken(
id = id,
@@ -107,6 +139,7 @@ class RefreshTokenService(
tokenHash = hash(raw),
issuedAt = now,
expiresAt = now.plus(ttl),
absoluteExpiresAt = absoluteExpiresAt,
)
private fun generateRaw(): String {

View File

@@ -45,3 +45,8 @@ lms:
auth:
# Скользящая сессия: каждый refresh продлевает её на этот срок.
refresh-token-ttl: ${LMS_REFRESH_TOKEN_TTL:7d}
# Абсолютный потолок сессии: ротация не может продлить её дальше этого срока.
absolute-session-ttl: ${LMS_SESSION_ABSOLUTE_TIMEOUT:30d}
# Сколько хранить истёкшие refresh-токены перед очисткой.
refresh-token-retention: ${LMS_REFRESH_TOKEN_RETENTION:30d}
refresh-token-cleanup-cron: ${LMS_REFRESH_TOKEN_CLEANUP_CRON:0 30 3 * * *}

View File

@@ -0,0 +1,7 @@
-- Абсолютный срок жизни сессии: ротация refresh-токена продлевает скользящее
-- окно, но не может растянуть сессию дальше absolute_expires_at.
-- Бэкфилл существующих строк использует дефолтные 30 дней; если на стенде
-- LMS_SESSION_ABSOLUTE_TIMEOUT меньше, старые сессии доживают один раз до 30 дней.
ALTER TABLE identity.refresh_tokens ADD COLUMN absolute_expires_at timestamptz;
UPDATE identity.refresh_tokens SET absolute_expires_at = issued_at + interval '30 days' WHERE absolute_expires_at IS NULL;
ALTER TABLE identity.refresh_tokens ALTER COLUMN absolute_expires_at SET NOT NULL;

View File

@@ -0,0 +1,4 @@
-- Индексы под keyset-пагинацию: сортировка (occurred_at DESC, id DESC) и та же
-- сортировка с фильтром по action.
CREATE INDEX audit_events_occurred_id_idx ON identity.audit_events (occurred_at DESC, id DESC);
CREATE INDEX audit_events_action_occurred_id_idx ON identity.audit_events (action, occurred_at DESC, id DESC);

View File

@@ -25,6 +25,8 @@ import ru.ysu.lms.identity.auth.AuthService
import ru.ysu.lms.identity.auth.ChangePasswordRequest
import ru.ysu.lms.identity.auth.LoginRequest
import ru.ysu.lms.identity.auth.RefreshTokenRequest
import ru.ysu.lms.identity.auth.refresh.RefreshToken
import ru.ysu.lms.identity.auth.refresh.RefreshTokenRepository
import ru.ysu.lms.identity.auth.refresh.RefreshTokenService
import ru.ysu.lms.identity.roster.CreateGroupRequest
import ru.ysu.lms.identity.roster.CreateStudentRequest
@@ -32,6 +34,7 @@ import ru.ysu.lms.identity.roster.Credential
import ru.ysu.lms.identity.roster.GroupService
import ru.ysu.lms.identity.roster.StudentLifecycleService
import java.time.Duration
import java.time.Instant
import java.util.UUID
import java.util.concurrent.atomic.AtomicInteger
@@ -62,6 +65,8 @@ class RefreshTokenIntegrationTest {
@Autowired lateinit var refreshTokens: RefreshTokenService
@Autowired lateinit var refreshTokenRepository: RefreshTokenRepository
@Autowired lateinit var groupService: GroupService
@Autowired lateinit var lifecycle: StudentLifecycleService
@@ -188,6 +193,93 @@ class RefreshTokenIntegrationTest {
.hasMessage(RefreshTokenService.SESSION_EXPIRED)
}
@Test
fun `refresh past the absolute session expiry is rejected with unauthorized`() {
val student = createStudent()
val refreshToken = login(student).refreshToken!!
val token = activeToken(UUID.fromString(student.id))
token.absoluteExpiresAt = Instant.now().minusSeconds(1)
refreshTokenRepository.save(token)
mockMvc
.post("/api/v1/auth/refresh") {
contentType = MediaType.APPLICATION_JSON
content = jsonMapper.writeValueAsString(RefreshTokenRequest(refreshToken))
}.andExpect {
status { isUnauthorized() }
}
assertThatThrownBy { refreshTokens.rotate(refreshToken) }
.isInstanceOf(UnauthorizedException::class.java)
.hasMessage(RefreshTokenService.SESSION_EXPIRED)
}
@Test
fun `rotation keeps the absolute session expiry unchanged`() {
val student = createStudent()
val accountId = UUID.fromString(student.id)
val original = login(student).refreshToken!!
val absoluteExpiresAt = activeToken(accountId).absoluteExpiresAt
assertThat(absoluteExpiresAt).isAfter(Instant.now().plus(Duration.ofDays(29)))
val rotated = refreshTokens.rotate(original)
assertThat(activeToken(accountId).absoluteExpiresAt).isEqualTo(absoluteExpiresAt)
val rotatedAgain = refreshTokens.rotate(rotated.refreshToken)
assertThat(rotatedAgain.accountId).isEqualTo(accountId)
assertThat(activeToken(accountId).absoluteExpiresAt).isEqualTo(absoluteExpiresAt)
}
@Test
fun `cleanup deletes only tokens expired beyond retention`() {
val student = createStudent()
val accountId = UUID.fromString(student.id)
val now = Instant.parse("2026-01-31T00:00:00Z")
val expired =
token(
accountId,
"a".repeat(64),
now.minus(Duration.ofDays(40)),
now.minus(Duration.ofDays(31)),
now.minus(Duration.ofDays(10)),
)
val revokedRecently =
token(
accountId,
"b".repeat(64),
now.minus(Duration.ofDays(2)),
now.minus(Duration.ofDays(1)),
now.plus(Duration.ofDays(28)),
)
revokedRecently.revokedAt = now.minus(Duration.ofHours(2))
val absoluteDead =
token(
accountId,
"d".repeat(64),
now.minus(Duration.ofDays(45)),
now.plus(Duration.ofDays(7)),
now.minus(Duration.ofDays(40)),
)
val fresh =
token(
accountId,
"c".repeat(64),
now,
now.plus(Duration.ofDays(7)),
now.plus(Duration.ofDays(30)),
)
refreshTokenRepository.saveAll(listOf(expired, revokedRecently, absoluteDead, fresh))
val deleted = refreshTokens.cleanupExpired(now)
assertThat(deleted).isEqualTo(2)
assertThat(refreshTokenRepository.findById(expired.id)).isEmpty
assertThat(refreshTokenRepository.findById(absoluteDead.id)).isEmpty
assertThat(refreshTokenRepository.findById(revokedRecently.id)).isPresent
assertThat(refreshTokenRepository.findById(fresh.id)).isPresent
}
@Test
fun `logout revokes the refresh token idempotently`() {
val student = createStudent()
@@ -252,6 +344,23 @@ class RefreshTokenIntegrationTest {
.hasMessage("Группа закрыта")
}
private fun activeToken(accountId: UUID): RefreshToken = refreshTokenRepository.findAllByAccountIdAndRevokedAtIsNull(accountId).single()
private fun token(
accountId: UUID,
tokenHash: String,
issuedAt: Instant,
expiresAt: Instant,
absoluteExpiresAt: Instant,
): RefreshToken =
RefreshToken(
accountId = accountId,
tokenHash = tokenHash,
issuedAt = issuedAt,
expiresAt = expiresAt,
absoluteExpiresAt = absoluteExpiresAt,
)
private fun createStudent(): StudentFixture {
val number = sequence.incrementAndGet()
val group =

View File

@@ -216,6 +216,65 @@ class AuditIntegrationTest {
assertTrue((first + second).intersect(third.toSet()).isEmpty())
}
@Test
fun `cursor pagination walks all events newest first`() {
repeat(5) { index ->
write(AuditEventRequest(action = "AUDIT_CURSOR", entityType = "STUDENT", details = "cursor-${index + 1}"))
}
val first = auditPage(limit = 2, page = 0)
assertEquals(listOf("cursor-5", "cursor-4"), first.details)
assertTrue(first.cursor != null)
val second = auditPage(limit = 2, cursor = first.cursor)
assertEquals(listOf("cursor-3", "cursor-2"), second.details)
assertTrue(first.details.intersect(second.details.toSet()).isEmpty())
val third = auditPage(limit = 2, cursor = second.cursor)
assertEquals(listOf("cursor-1"), third.details)
assertTrue((first.details + second.details).intersect(third.details.toSet()).isEmpty())
val all = first.details + second.details + third.details
assertEquals(5, all.distinct().size)
}
@Test
fun `cursor pagination respects the action filter`() {
write(AuditEventRequest(action = "AUDIT_CURSOR_FILTER", entityType = "STUDENT", details = "a1"))
write(AuditEventRequest(action = "AUDIT_CURSOR_OTHER", entityType = "STUDENT", details = "x"))
write(AuditEventRequest(action = "AUDIT_CURSOR_FILTER", entityType = "STUDENT", details = "a2"))
write(AuditEventRequest(action = "AUDIT_CURSOR_FILTER", entityType = "STUDENT", details = "a3"))
val first = auditPage(limit = 2, action = "AUDIT_CURSOR_FILTER")
assertEquals(listOf("a3", "a2"), first.details)
val second = auditPage(limit = 2, action = "AUDIT_CURSOR_FILTER", cursor = first.cursor)
assertEquals(listOf("a1"), second.details)
}
@Test
fun `malformed audit cursor is rejected with bad request`() {
mockMvc
.get("/api/v1/audit") {
with(admin())
param("cursor", "%%%not-base64%%%")
}.andExpect {
status { isBadRequest() }
jsonPath("$.detail") { value("Некорректный курсор аудита") }
}
}
@Test
fun `cursor from the newest event returns an empty page`() {
write(AuditEventRequest(action = "AUDIT_CURSOR_END", entityType = "STUDENT", details = "only"))
val first = auditPage(limit = 1, action = "AUDIT_CURSOR_END")
assertEquals(listOf("only"), first.details)
val next = auditPage(limit = 5, action = "AUDIT_CURSOR_END", cursor = first.cursor)
assertEquals(emptyList<String>(), next.details)
}
@Test
fun `negative page is clamped to the first page and page past the end is empty`() {
repeat(3) { index ->
@@ -319,6 +378,42 @@ class AuditIntegrationTest {
assertTrue(auditEvents.findById(deleteEvent.id).isPresent)
}
private data class AuditPage(
val details: List<String>,
val cursor: String?,
)
private fun auditPage(
limit: Int,
page: Int? = null,
cursor: String? = null,
action: String? = null,
): AuditPage {
val body =
mockMvc
.get("/api/v1/audit") {
with(admin())
param("limit", limit.toString())
page?.let { param("page", it.toString()) }
cursor?.let { param("cursor", it) }
action?.let { param("action", it) }
}.andExpect {
status { isOk() }
}.andReturn()
.response
.getContentAsString(StandardCharsets.UTF_8)
val nodes = objectMapper.readTree(body)
return AuditPage(
details = nodes.map { it.path("details").asText() },
cursor =
nodes
.lastOrNull()
?.path("cursor")
?.asText()
?.takeIf { it.isNotEmpty() },
)
}
private fun detailsOfPage(
limit: Int,
page: Int,

View File

@@ -80,37 +80,27 @@ class AcademicClient(
}
/**
* Лабораторные курса вместе с уровнями и вариантами: список отдаёт только сводки,
* поэтому детали каждой лабораторной подтягиваются отдельно. Ошибка детали не
* должна ронять весь профиль — остаётся сводка без уровней.
* Лабораторные курса вместе с уровнями и вариантами: один пакетный запрос
* деталей на весь курс, без N+1 запросов на каждую лабораторную.
*/
fun getCourseLabs(
courseId: String,
token: String,
): List<LabInfo> {
val type = object : ParameterizedTypeReference<List<LabInfo>>() {}
val summaries =
val labs =
try {
client
.get()
.uri("/api/v1/labs?courseId={courseId}", courseId)
.uri("/api/v1/labs/details?courseId={courseId}", courseId)
.header("Authorization", "Bearer $token")
.retrieve()
.body(type) ?: emptyList()
} catch (ex: RestClientResponseException) {
throw courseError(ex)
}
return summaries.map { summary ->
if (summary.id.isBlank()) {
summary
} else {
val detail = runCatching { getLab(summary.id, token) }.getOrNull() ?: summary
detail.copy(
courseId = detail.courseId ?: summary.courseId ?: courseId,
courseNo = detail.courseNo ?: summary.courseNo,
courseTitle = detail.courseTitle ?: summary.courseTitle,
)
}
return labs.map { lab ->
if (lab.courseId.isNullOrBlank()) lab.copy(courseId = courseId) else lab
}
}
}

View File

@@ -0,0 +1,173 @@
package ru.ysu.lms.progress
import com.sun.net.httpserver.HttpServer
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import ru.ysu.lms.common.ForbiddenException
import java.net.InetSocketAddress
import java.util.concurrent.CopyOnWriteArrayList
/** Пакетная загрузка лабораторных курса: один запрос вместо N+1 по каждой лабораторной. */
class AcademicClientBatchLabsTest {
private lateinit var server: HttpServer
private val requestedUris = CopyOnWriteArrayList<String>()
@Volatile
private var responseStatus = 200
@Volatile
private var responseBody = "[]"
@BeforeEach
fun startServer() {
server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
server.createContext(DETAILS_PATH) { exchange ->
requestedUris += exchange.requestURI.toString()
val bytes = responseBody.toByteArray(Charsets.UTF_8)
exchange.responseHeaders.add("Content-Type", "application/json")
exchange.sendResponseHeaders(responseStatus, bytes.size.toLong())
exchange.responseBody.use { it.write(bytes) }
}
server.start()
}
@AfterEach
fun stopServer() {
server.stop(0)
}
@Test
fun `getCourseLabs loads all labs with levels and variants in one request`() {
respond(200, LABS_JSON)
val labs = client().getCourseLabs(COURSE_ID, TOKEN)
assertThat(requestedUris).hasSize(1)
assertThat(requestedUris.single())
.startsWith(DETAILS_PATH)
.contains("courseId=$COURSE_ID")
assertThat(labs).hasSize(2)
val first = labs[0]
assertThat(first.id).isEqualTo(LAB_ONE_ID)
assertThat(first.number).isEqualTo(1)
assertThat(first.title).isEqualTo("Лабораторная 1")
assertThat(first.courseId).isEqualTo(COURSE_ID)
assertThat(first.levels).hasSize(1)
val firstLevel = first.levels.single()
assertThat(firstLevel.code).isEqualTo("L1")
assertThat(firstLevel.variants).hasSize(2)
assertThat(firstLevel.variants.map { it.number }).containsExactly("1", "2")
assertThat(firstLevel.variants[1].statementMd).isEqualTo("Условие 1.2")
val second = labs[1]
assertThat(second.id).isEqualTo(LAB_TWO_ID)
assertThat(second.levels).hasSize(1)
val secondLevel = second.levels.single()
assertThat(secondLevel.variants.single().id).isEqualTo(VARIANT_TWO_ID)
}
@Test
fun `getCourseLabs falls back to requested course id when response omits it`() {
respond(200, LABS_WITHOUT_COURSE_JSON)
val labs = client().getCourseLabs(COURSE_ID, TOKEN)
assertThat(requestedUris).hasSize(1)
assertThat(labs).hasSize(2)
assertThat(labs).allSatisfy { lab -> assertThat(lab.courseId).isEqualTo(COURSE_ID) }
}
@Test
fun `getCourseLabs maps forbidden response to ForbiddenException`() {
respond(403, "{}")
assertThatThrownBy { client().getCourseLabs(COURSE_ID, TOKEN) }
.isInstanceOf(ForbiddenException::class.java)
.hasMessage("Курс недоступен")
assertThat(requestedUris).hasSize(1)
}
private fun respond(
status: Int,
body: String,
) {
responseStatus = status
responseBody = body
}
private fun client(): AcademicClient = AcademicClient(baseUrl = "http://127.0.0.1:${server.address.port}")
private companion object {
const val DETAILS_PATH = "/api/v1/labs/details"
const val TOKEN = "test-token"
const val COURSE_ID = "44444444-4444-4444-4444-444444444444"
const val LAB_ONE_ID = "11111111-1111-1111-1111-111111111111"
const val LAB_TWO_ID = "22222222-2222-2222-2222-222222222222"
const val VARIANT_TWO_ID = "33333333-3333-3333-3333-333333333333"
val LABS_JSON =
"""
[
{
"id": "$LAB_ONE_ID",
"number": 1,
"title": "Лабораторная 1",
"status": "PUBLISHED",
"descriptionMd": "Описание 1",
"deadlineAt": "2026-11-01T00:00:00Z",
"defenseAt": "2026-11-08T00:00:00Z",
"pointsMax": 10,
"courseId": "$COURSE_ID",
"courseNo": 1,
"courseTitle": "Курс",
"levels": [
{
"id": "level-1",
"code": "L1",
"title": "Уровень 1",
"variants": [
{ "id": "variant-1", "number": "1", "title": "Вариант 1.1", "statementMd": "Условие 1.1" },
{ "id": "variant-2", "number": "2", "title": "Вариант 1.2", "statementMd": "Условие 1.2" }
]
}
]
},
{
"id": "$LAB_TWO_ID",
"number": 2,
"title": "Лабораторная 2",
"status": "PUBLISHED",
"descriptionMd": "Описание 2",
"pointsMax": 5,
"courseId": "$COURSE_ID",
"courseNo": 1,
"courseTitle": "Курс",
"levels": [
{
"id": "level-2",
"code": "L2",
"title": "Уровень 2",
"variants": [
{ "id": "$VARIANT_TWO_ID", "number": "1", "title": "Вариант 2.1", "statementMd": "Условие 2.1" }
]
}
]
}
]
""".trimIndent()
val LABS_WITHOUT_COURSE_JSON =
"""
[
{ "id": "$LAB_ONE_ID", "number": 1, "title": "Лабораторная 1", "levels": [] },
{ "id": "$LAB_TWO_ID", "number": 2, "title": "Лабораторная 2", "levels": [] }
]
""".trimIndent()
}
}