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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -24,4 +24,6 @@ data class AuditEventDto(
|
||||
val courseId: String?,
|
||||
val studentId: String?,
|
||||
val details: String,
|
||||
/** Непрозрачный курсор для следующей keyset-страницы. */
|
||||
val cursor: String? = null,
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 = "Некорректный курсор аудита"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 * * *}
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user