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

@@ -20,30 +20,49 @@ class AdminAuditController(
markdown: MarkdownRenderer,
mapper: ObjectMapper,
) : AdminSupport(api, markdown, mapper) {
@Suppress("LongParameterList")
@GetMapping
fun list(
@RequestParam(required = false) action: String?,
@RequestParam(required = false) limit: String?,
@RequestParam(required = false) page: String?,
@RequestParam(required = false) cursor: String?,
session: HttpSession,
model: Model,
): String {
val filter = action?.trim()?.takeIf { it.isNotEmpty() }
val pageSize = (limit?.trim()?.toIntOrNull() ?: DEFAULT_LIMIT).coerceIn(1, MAX_LIMIT)
val pageNumber = (page?.trim()?.toIntOrNull() ?: 0).coerceAtLeast(0)
val cursorValue = cursor?.trim()?.takeIf { it.isNotEmpty() }
val events: List<AuditEventDto> =
try {
api.listAudit(token(session), pageSize, filter, pageNumber)
api.listAudit(
token = token(session),
limit = pageSize,
action = filter,
page = pageNumber,
cursor = cursorValue,
)
} catch (ex: ApiException) {
model.addAttribute("error", ex.message)
emptyList()
}
val nextCursor =
if (events.size == pageSize) {
events.lastOrNull()?.cursor?.takeIf { it.isNotBlank() }
} else {
null
}
val cursorMode = cursorValue != null
model.addAttribute("events", AuditViews.rows(events))
model.addAttribute("action", filter.orEmpty())
model.addAttribute("limit", pageSize)
model.addAttribute("page", pageNumber)
model.addAttribute("hasNext", events.size == pageSize)
model.addAttribute("hasPrev", pageNumber > 0)
model.addAttribute("nextCursor", nextCursor)
model.addAttribute("hasNext", nextCursor != null)
// «Назад» работает только для offset-совместимости; основной путь — cursor + «В начало».
model.addAttribute("hasPrev", !cursorMode && pageNumber > 0)
model.addAttribute("isCursor", cursorMode)
model.addAttribute("actionOptions", AuditViews.actionOptions(filter))
return "admin/audit"
}

View File

@@ -0,0 +1,38 @@
package ru.ysu.lms.web.admin
import jakarta.servlet.http.HttpSession
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.legacyAssets
import ru.ysu.lms.web.session.SessionKeys
/** Инвентаризация медиафайлов, на которые больше не ссылается ни одна статья. */
@Controller
@RequestMapping("/admin/legacy-assets")
class LegacyAssetsController(
private val api: ApiClient,
) {
@GetMapping
fun list(
session: HttpSession,
model: Model,
): String {
val assets =
try {
api.legacyAssets(token(session)).sorted()
} catch (ex: ApiException) {
model.addAttribute("error", ex.message)
emptyList()
}
model.addAttribute("assets", assets)
return "admin/legacy-assets"
}
private fun token(session: HttpSession): String =
session.getAttribute(SessionKeys.TOKEN) as? String
?: throw ApiException(401, "Сессия истекла, войдите заново")
}

View File

@@ -8,6 +8,7 @@ fun ApiClient.listAudit(
limit: Int = 100,
action: String? = null,
page: Int = 0,
cursor: String? = null,
): List<AuditEventDto> {
val type = object : ParameterizedTypeReference<List<AuditEventDto>>() {}
return call {
@@ -18,6 +19,7 @@ fun ApiClient.listAudit(
builder.queryParam("limit", limit)
if (!action.isNullOrBlank()) builder.queryParam("action", action)
builder.queryParam("page", page.coerceAtLeast(0))
if (!cursor.isNullOrBlank()) builder.queryParam("cursor", cursor)
builder.build()
}.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()

View File

@@ -131,6 +131,18 @@ fun ApiClient.deleteContentArticle(
}
}
fun ApiClient.legacyAssets(token: String): List<String> {
val type = object : ParameterizedTypeReference<List<String>>() {}
return call {
client
.get()
.uri("/api/v1/content/legacy-assets")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(type)
}
}
fun ApiClient.contentAsset(
name: String,
token: String? = null,

View File

@@ -900,4 +900,5 @@ data class AuditEventDto(
val courseId: String? = null,
val studentId: String? = null,
val details: String = "",
val cursor: String? = null,
)

View File

@@ -27,6 +27,7 @@ class AuthPageController(
@GetMapping("/login")
fun loginPage(
@RequestParam(required = false) expired: String?,
session: HttpSession,
model: Model,
): String {
@@ -37,6 +38,9 @@ class AuthPageController(
session.removeAttribute("csrfError")
model.addAttribute("error", it)
}
if (!expired.isNullOrBlank()) {
model.addAttribute("error", "Срок сессии истёк. Войдите заново.")
}
model.addAttribute("adminExists", api.adminExists())
return "login"
}
@@ -133,6 +137,7 @@ class AuthPageController(
val session = request.getSession(true)
session.setAttribute(SessionKeys.TOKEN, auth.token)
auth.refreshToken?.let { session.setAttribute(SessionKeys.REFRESH_TOKEN, it) }
session.setAttribute(SessionKeys.AUTHENTICATED_AT, System.currentTimeMillis())
session.setAttribute(
SessionKeys.TOKEN_EXPIRES_AT,
System.currentTimeMillis() + auth.accessTokenExpiresIn * 1000,

View File

@@ -3,11 +3,13 @@ package ru.ysu.lms.web.session
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import jakarta.servlet.http.HttpSession
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import org.springframework.web.servlet.HandlerInterceptor
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.MeDto
import java.time.Duration
/**
* Проверяет вход, поддерживает access-токен живым и периодически перепроверяет
@@ -19,7 +21,10 @@ import ru.ysu.lms.web.api.MeDto
class AuthInterceptor(
private val api: ApiClient,
private val tokenService: SessionTokenService,
@Value("\${lms.session.absolute-timeout:30d}") absoluteTimeout: Duration = Duration.ofDays(30),
) : HandlerInterceptor {
private val lifetime = SessionLifetime(absoluteTimeout)
override fun preHandle(
request: HttpServletRequest,
response: HttpServletResponse,
@@ -31,10 +36,14 @@ class AuthInterceptor(
response.sendRedirect("/login")
return false
}
if (lifetime.isAbsoluteExpired(session)) {
expire(session, response, "/login?expired=absolute")
return false
}
if (!ensureFreshToken(session, user, response)) return false
val refreshedUser = session.getAttribute(SessionKeys.USER) as? SessionUser ?: user
if (!revalidate(session, refreshedUser, response)) return false
return !redirectIfPasswordMustChange(session, refreshedUser, request, response)
return revalidate(session, refreshedUser, response) &&
!redirectIfPasswordMustChange(session, refreshedUser, request, response)
}
/** true — отправлен redirect на смену пароля; false — запрос можно продолжать. */
@@ -122,9 +131,10 @@ class AuthInterceptor(
private fun expire(
session: HttpSession,
response: HttpServletResponse,
redirect: String = "/login?expired=1",
) {
session.invalidate()
response.sendRedirect("/login?expired=1")
response.sendRedirect(redirect)
}
private companion object {

View File

@@ -3,6 +3,7 @@ package ru.ysu.lms.web.session
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import jakarta.servlet.http.HttpSession
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpHeaders
import org.springframework.http.ResponseCookie
@@ -14,13 +15,18 @@ import java.time.Duration
* Sliding-сессия: серверный timeout продлевается при активности, но cookie
* с абсолютным Max-Age сама по себе не продлевается. Перевыпускаем cookie на
* каждом ответе, чтобы через 7 дней активной работы не требовать пароль.
* Абсолютный срок жизни сессии (по умолчанию 30 дней) ограничивает и Max-Age:
* cookie не должна жить дольше, чем осталось до повторного входа.
*/
@Component
class SessionCookieRefreshFilter(
@Value("\${server.servlet.session.cookie.name:LMS_SESSION}") private val cookieName: String,
@Value("\${server.servlet.session.timeout:7d}") private val sessionTtl: Duration,
@Value("\${server.servlet.session.cookie.secure:true}") private val secureCookie: Boolean,
@Value("\${lms.session.absolute-timeout:30d}") absoluteTimeout: Duration = Duration.ofDays(30),
) : OncePerRequestFilter() {
private val lifetime = SessionLifetime(absoluteTimeout)
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
@@ -29,6 +35,7 @@ class SessionCookieRefreshFilter(
filterChain.doFilter(request, response)
val session = request.getSession(false) ?: return
if (response.isCommitted) return
val maxAge = cookieMaxAge(session)
val cookie =
ResponseCookie
.from(cookieName, session.id)
@@ -36,8 +43,17 @@ class SessionCookieRefreshFilter(
.secure(secureCookie || request.isSecure)
.path(request.contextPath.ifBlank { "/" })
.sameSite("Lax")
.maxAge(sessionTtl)
.maxAge(maxAge)
.build()
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString())
}
/** Max-Age не может пережить абсолютный срок сессии; истёкшая сессия получает 0. */
private fun cookieMaxAge(session: HttpSession): Duration {
val remainingMillis = lifetime.remainingMillis(session)
if (remainingMillis <= 0) return Duration.ZERO
// Округляем вверх: иначе последняя неполная секунда даёт Max-Age=0 при живой сессии.
val remainingSeconds = (remainingMillis + 999) / 1000
return Duration.ofSeconds(minOf(sessionTtl.seconds, remainingSeconds))
}
}

View File

@@ -0,0 +1,31 @@
package ru.ysu.lms.web.session
import jakarta.servlet.http.HttpSession
import java.time.Duration
/**
* Абсолютный срок жизни web-сессии. Sliding-timeout продлевается активностью,
* но после [absoluteTimeout] с момента входа пользователь обязан войти заново.
* Для сессий, созданных до появления [SessionKeys.AUTHENTICATED_AT], точкой
* отсчёта служит creationTime контейнера.
*/
class SessionLifetime(
private val absoluteTimeout: Duration,
) {
fun authenticatedAt(session: HttpSession): Long =
(session.getAttribute(SessionKeys.AUTHENTICATED_AT) as? Long)
?: session.creationTime
fun expiresAt(session: HttpSession): Long = authenticatedAt(session) + absoluteTimeout.toMillis()
/** Остаток абсолютного срока в миллисекундах; 0 — срок уже истёк. */
fun remainingMillis(
session: HttpSession,
now: Long = System.currentTimeMillis(),
): Long = (expiresAt(session) - now).coerceAtLeast(0)
fun isAbsoluteExpired(
session: HttpSession,
now: Long = System.currentTimeMillis(),
): Boolean = now >= expiresAt(session)
}

View File

@@ -18,4 +18,5 @@ object SessionKeys {
const val TOKEN_EXPIRES_AT = "lms.tokenExpiresAt"
const val USER = "lms.user"
const val STATUS_CHECKED_AT = "lms.statusCheckedAt"
const val AUTHENTICATED_AT = "lms.authenticatedAt"
}

View File

@@ -29,6 +29,9 @@ spring:
lms:
gateway:
base-url: ${LMS_GATEWAY_URL:http://localhost:8080}
session:
# Жёсткий потолок жизни web-сессии: даже активный пользователь перелогинивается через 30 дней.
absolute-timeout: ${LMS_SESSION_ABSOLUTE_TIMEOUT:30d}
management:
endpoints:

View File

@@ -66,14 +66,18 @@
</div>
<nav class="flex flex-wrap items-center justify-between gap-3 mt-4" aria-label="Постраничная навигация">
<span class="meta">Страница <span th:text="${page + 1}">1</span></span>
<span class="meta" th:if="${isCursor}">Продолжение</span>
<span class="meta" th:unless="${isCursor}">Страница <span th:text="${page + 1}">1</span></span>
<div class="flex items-center gap-2">
<a class="btn btn-secondary"
th:if="${hasPrev}"
th:href="@{/admin/audit(action=${action},limit=${limit},page=${page - 1})}">Назад</a>
<a class="btn btn-secondary"
th:if="${isCursor}"
th:href="@{/admin/audit(action=${action},limit=${limit})}">В начало</a>
<a class="btn btn-secondary"
th:if="${hasNext}"
th:href="@{/admin/audit(action=${action},limit=${limit},page=${page + 1})}">Показать ещё</a>
th:href="@{/admin/audit(action=${action},limit=${limit},cursor=${nextCursor})}">Показать ещё</a>
</div>
</nav>
</div>

View File

@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title>Активы — LMS</title></head>
<body>
<div id="content">
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div>
<h1 class="page-title m-0">Активы</h1>
<p class="meta">
Медиафайлы, на которые не ссылается ни одна статья. Они не удалены и доступны
только администраторам: обычные пользователи получают для них 404.
</p>
</div>
<a class="btn btn-secondary" th:href="@{/admin/labs}">К лабораторным</a>
</div>
<div class="card card-flush">
<table class="tablekit">
<thead>
<tr>
<th>Файл</th>
<th>Ссылка</th>
</tr>
</thead>
<tbody>
<tr th:each="asset : ${assets}">
<td class="font-mono text-break" th:text="${asset}">file.png</td>
<td>
<a th:href="@{/api/v1/content/assets/{name}(name=${asset})}"
th:text="${asset}"
target="_blank"
rel="noopener">file.png</a>
</td>
</tr>
<tr th:if="${#lists.isEmpty(assets)}">
<td colspan="2" class="empty">Бесхозных файлов нет.</td>
</tr>
</tbody>
</table>
</div>
<p class="hint mt-4">
Такие файлы остаются после удаления статей или переименования картинок. Если файл больше
не нужен, его можно удалить на стороне academic-service.
</p>
</div>
</body>
</html>

View File

@@ -53,6 +53,7 @@
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/courses}">Курсы</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/users}">Пользователи</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/audit}">Аудит</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/legacy-assets}">Активы</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/teachers}">Преподаватели</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/labs}">Лабораторные</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.teacher}"><a class="nav-link" th:href="@{/teacher}">Мои курсы</a></li>

View File

@@ -8,6 +8,7 @@
<div th:if="${!adminExists}" class="alert alert-warning">
Аккаунт администратора ещё не создан. <a th:href="@{/setup}">Создать первый аккаунт</a>
</div>
<div th:if="${error}" class="alert alert-warning" th:text="${error}">Срок сессии истёк. Войдите заново.</div>
<form class="card form-card form-stack" th:action="@{/login}" method="post">
<div><label class="label" for="login">Логин</label><input class="input" id="login" name="login" autocomplete="username" th:value="${login}" autofocus required></div>
<div><label class="label" for="password">Пароль</label><input class="input" id="password" name="password" type="password" autocomplete="current-password" required></div>

View File

@@ -35,8 +35,10 @@ class AdminAuditTemplateTest {
"action" to "GRADE_SET",
"limit" to 100,
"page" to 1,
"nextCursor" to "cursor-1",
"hasNext" to true,
"hasPrev" to true,
"isCursor" to false,
"actionOptions" to AuditViews.actionOptions("GRADE_SET"),
),
)
@@ -62,7 +64,8 @@ class AdminAuditTemplateTest {
assertThat(html).contains("action=GRADE_SET")
assertThat(html).contains("limit=100")
assertThat(html).contains("page=0")
assertThat(html).contains("page=2")
assertThat(html).contains("cursor=cursor-1")
assertThat(html).doesNotContain("page=2")
}
@Test
@@ -75,8 +78,10 @@ class AdminAuditTemplateTest {
"action" to "",
"limit" to 100,
"page" to 0,
"nextCursor" to null,
"hasNext" to false,
"hasPrev" to false,
"isCursor" to false,
"actionOptions" to AuditViews.actionOptions(null),
),
)
@@ -88,6 +93,33 @@ class AdminAuditTemplateTest {
assertThat(html).doesNotContain(">Показать ещё</a>")
}
@Test
fun `cursor mode links next page with cursor and resets to start`() {
val html =
render(
"admin/audit",
mapOf(
"events" to auditRows(),
"action" to "GRADE_SET",
"limit" to 50,
"page" to 0,
"nextCursor" to "cursor-2",
"hasNext" to true,
"hasPrev" to false,
"isCursor" to true,
"actionOptions" to AuditViews.actionOptions("GRADE_SET"),
),
)
assertThat(html).contains("Продолжение")
assertThat(html).contains("cursor=cursor-2")
assertThat(html).contains("action=GRADE_SET")
assertThat(html).contains("limit=50")
assertThat(html).contains(">В начало</a>")
assertThat(html).doesNotContain("page=1")
assertThat(html).doesNotContain(">Назад</a>")
}
@Test
fun `keeps unknown action filter selectable`() {
val html =
@@ -98,8 +130,10 @@ class AdminAuditTemplateTest {
"action" to "CUSTOM_ACTION",
"limit" to 100,
"page" to 2,
"nextCursor" to "cursor-9",
"hasNext" to true,
"hasPrev" to true,
"isCursor" to false,
"actionOptions" to AuditViews.actionOptions("CUSTOM_ACTION"),
),
)
@@ -107,7 +141,8 @@ class AdminAuditTemplateTest {
assertThat(html).contains("value=\"CUSTOM_ACTION\"")
assertThat(html).contains("CUSTOM_ACTION")
assertThat(html).contains("page=1")
assertThat(html).contains("page=3")
assertThat(html).contains("cursor=cursor-9")
assertThat(html).doesNotContain("page=3")
}
private fun auditRows(): List<AuditRow> =

View File

@@ -0,0 +1,77 @@
package ru.ysu.lms.web.admin
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.mock.web.MockHttpServletResponse
import org.springframework.mock.web.MockServletContext
import org.thymeleaf.context.WebContext
import org.thymeleaf.spring6.SpringTemplateEngine
import org.thymeleaf.templatemode.TemplateMode
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver
import org.thymeleaf.web.servlet.JakartaServletWebApplication
import ru.ysu.lms.web.session.SessionUser
import java.util.Locale
class LegacyAssetsTemplateTest {
private val engine =
SpringTemplateEngine().apply {
setTemplateResolver(
ClassLoaderTemplateResolver().apply {
prefix = "templates/"
suffix = ".html"
characterEncoding = "UTF-8"
templateMode = TemplateMode.HTML
},
)
}
@Test
fun `renders orphan assets with links and admin-only explanation`() {
val html = render(listOf("old-picture.png", "unused-video.mp4"))
assertThat(html).contains("Активы")
assertThat(html).contains("old-picture.png")
assertThat(html).contains("unused-video.mp4")
assertThat(html).contains("href=\"/api/v1/content/assets/old-picture.png\"")
assertThat(html).contains("href=\"/api/v1/content/assets/unused-video.mp4\"")
assertThat(html).contains("не ссылается ни одна статья")
assertThat(html).contains("только администраторам")
assertThat(html).doesNotContain("Бесхозных файлов нет")
assertThat(html).contains(">Активы</a>")
assertThat(html).contains("href=\"/admin/labs\"")
}
@Test
fun `renders empty legacy assets state`() {
val html = render(emptyList())
assertThat(html).contains("Активы")
assertThat(html).contains("Бесхозных файлов нет")
assertThat(html).doesNotContain("/api/v1/content/assets/")
}
private fun render(assets: List<String>): String {
val servletContext = MockServletContext()
val application = JakartaServletWebApplication.buildApplication(servletContext)
val exchange =
application.buildExchange(
MockHttpServletRequest(servletContext),
MockHttpServletResponse(),
)
val context = WebContext(exchange, Locale.forLanguageTag("ru"))
context.setVariable("csrfToken", "csrf")
context.setVariable(
"currentUser",
SessionUser(
id = "admin-id",
login = "admin",
role = "ADMIN",
name = "Администратор",
mustChangePassword = false,
),
)
context.setVariable("assets", assets)
return engine.process("admin/legacy-assets", context)
}
}

View File

@@ -25,6 +25,31 @@ class ApiClientAuditTest {
}
}
@Test
fun `listAudit sends cursor for keyset pagination`() {
val query = AtomicReference<String?>()
val authorization = AtomicReference<String?>()
withAuditServer(query, authorization) { baseUrl ->
val events = ApiClient(baseUrl, ObjectMapper()).listAudit("session-token", cursor = "opaque-cursor")
assertThat(events).hasSize(1)
assertThat(events[0].cursor).isEqualTo("next-cursor")
assertThat(query.get()).contains("limit=100")
assertThat(query.get()).contains("cursor=opaque-cursor")
}
}
@Test
fun `listAudit omits blank cursor`() {
val query = AtomicReference<String?>()
val authorization = AtomicReference<String?>()
withAuditServer(query, authorization) { baseUrl ->
ApiClient(baseUrl, ObjectMapper()).listAudit("session-token", cursor = " ")
assertThat(query.get()).doesNotContain("cursor=")
}
}
@Test
fun `listAudit defaults to first page without filter`() {
val query = AtomicReference<String?>()
@@ -61,7 +86,8 @@ class ApiClientAuditTest {
"entityId": "g1",
"courseId": "c1",
"studentId": "s1",
"details": "Оценка 5"
"details": "Оценка 5",
"cursor": "next-cursor"
}
]
""".trimIndent(),

View File

@@ -23,6 +23,7 @@ class AuditDtoTest {
"courseId": "c1",
"studentId": "s1",
"details": "Оценка 5",
"cursor": "next-cursor",
"unknownField": "ignored"
},
{
@@ -53,6 +54,7 @@ class AuditDtoTest {
assertThat(events[0].courseId).isEqualTo("c1")
assertThat(events[0].studentId).isEqualTo("s1")
assertThat(events[0].details).isEqualTo("Оценка 5")
assertThat(events[0].cursor).isEqualTo("next-cursor")
assertThat(events[1].id).isEqualTo("e2")
assertThat(events[1].actorAccountId).isNull()
@@ -61,5 +63,6 @@ class AuditDtoTest {
assertThat(events[1].courseId).isNull()
assertThat(events[1].studentId).isNull()
assertThat(events[1].details).isEmpty()
assertThat(events[1].cursor).isNull()
}
}

View File

@@ -20,6 +20,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.AuthResponseDto
import ru.ysu.lms.web.api.adminExists
import ru.ysu.lms.web.api.changePassword
import ru.ysu.lms.web.api.login
import ru.ysu.lms.web.api.logout
@@ -73,6 +74,33 @@ class AuthPageControllerTest {
assertThat((session.getAttribute(SessionKeys.USER) as SessionUser).login).isEqualTo("student")
}
@Test
fun `login stores authenticated at timestamp`() {
every { api.login("student", "secret") } returns
AuthResponseDto(
token = "access",
refreshToken = "refresh",
accessTokenExpiresIn = 900,
id = "s1",
login = "student",
role = "STUDENT",
name = "Студент",
mustChangePassword = false,
)
val before = System.currentTimeMillis()
val result =
mockMvc
.perform(post("/login").param("login", "student").param("password", "secret"))
.andExpect(status().is3xxRedirection)
.andReturn()
val session = result.request.getSession(false) as MockHttpSession
val authenticatedAt = session.getAttribute(SessionKeys.AUTHENTICATED_AT) as Long
assertThat(authenticatedAt).isGreaterThanOrEqualTo(before)
assertThat(authenticatedAt).isLessThanOrEqualTo(System.currentTimeMillis())
}
@Test
fun `login without refresh token still creates the session`() {
every { api.login("student", "secret") } returns
@@ -96,6 +124,17 @@ class AuthPageControllerTest {
assertThat(session.getAttribute(SessionKeys.REFRESH_TOKEN)).isNull()
}
@Test
fun `login page shows session expired message`() {
every { api.adminExists() } returns true
val model = org.springframework.ui.ExtendedModelMap()
val view = AuthPageController(api).loginPage("absolute", MockHttpSession(), model)
assertThat(view).isEqualTo("login")
assertThat(model["error"]).isEqualTo("Срок сессии истёк. Войдите заново.")
}
@Test
fun `logout revokes refresh token and invalidates session`() {
every { api.logout("refresh") } just Runs

View File

@@ -22,6 +22,7 @@ import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.AuthResponseDto
import ru.ysu.lms.web.api.MeDto
import ru.ysu.lms.web.api.refresh
import java.time.Duration
class AuthInterceptorTest {
@RestController
@@ -35,7 +36,7 @@ class AuthInterceptorTest {
private val mockMvc: MockMvc =
MockMvcBuilders
.standaloneSetup(TestPage())
.addInterceptors(AuthInterceptor(api, SessionTokenService(api)))
.addInterceptors(AuthInterceptor(api, SessionTokenService(api), Duration.ofDays(30)))
.build()
@BeforeEach
@@ -141,6 +142,38 @@ class AuthInterceptorTest {
assertThat(user.mustChangePassword).isTrue()
}
@Test
fun `absolute expired session redirects to absolute login without identity call`() {
val session =
sessionFor(
authenticatedAt = System.currentTimeMillis() - Duration.ofDays(31).toMillis(),
)
mockMvc
.perform(get("/test").session(session))
.andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/login?expired=absolute"))
assertThat(session.isInvalid).isTrue()
verify(exactly = 0) { api.refresh(any()) }
verify(exactly = 0) { api.me(any()) }
}
@Test
fun `session younger than absolute timeout passes`() {
val session =
sessionFor(
authenticatedAt = System.currentTimeMillis() - Duration.ofDays(29).toMillis(),
)
session.setAttribute(SessionKeys.STATUS_CHECKED_AT, System.currentTimeMillis())
mockMvc
.perform(get("/test").session(session))
.andExpect(status().isOk)
assertThat(session.isInvalid).isFalse()
}
@Test
fun `anonymous user is redirected to login`() {
mockMvc
@@ -233,10 +266,12 @@ class AuthInterceptorTest {
mustChangePassword: Boolean = false,
expiresInMs: Long = 600_000L,
refreshToken: String? = REFRESH_TOKEN,
authenticatedAt: Long = System.currentTimeMillis(),
): MockHttpSession =
MockHttpSession().apply {
setAttribute(SessionKeys.TOKEN, TOKEN)
setAttribute(SessionKeys.TOKEN_EXPIRES_AT, System.currentTimeMillis() + expiresInMs)
setAttribute(SessionKeys.AUTHENTICATED_AT, authenticatedAt)
if (refreshToken != null) setAttribute(SessionKeys.REFRESH_TOKEN, refreshToken)
setAttribute(
SessionKeys.USER,

View File

@@ -8,12 +8,18 @@ import org.springframework.mock.web.MockHttpServletResponse
import java.time.Duration
class SessionCookieRefreshFilterTest {
private val filter = SessionCookieRefreshFilter("LMS_SESSION", Duration.ofDays(7), secureCookie = false)
private val filter =
SessionCookieRefreshFilter(
"LMS_SESSION",
Duration.ofDays(7),
secureCookie = false,
absoluteTimeout = Duration.ofDays(30),
)
@Test
fun `refreshes cookie max age on every existing session`() {
val request = MockHttpServletRequest()
request.getSession(true)
request.getSession(true)!!.setAttribute(SessionKeys.AUTHENTICATED_AT, System.currentTimeMillis())
val response = MockHttpServletResponse()
filter.doFilter(request, response, MockFilterChain())
@@ -25,6 +31,42 @@ class SessionCookieRefreshFilterTest {
assertThat(cookie).contains("SameSite=Lax")
}
@Test
fun `cookie max age is capped by remaining absolute lifetime`() {
val request = MockHttpServletRequest()
request
.getSession(true)!!
.setAttribute(
SessionKeys.AUTHENTICATED_AT,
System.currentTimeMillis() - Duration.ofDays(29).toMillis(),
)
val response = MockHttpServletResponse()
filter.doFilter(request, response, MockFilterChain())
val maxAge = maxAgeOf(response.getHeader("Set-Cookie"))
assertThat(maxAge).isBetween(86_000L, 86_400L)
assertThat(maxAge).isLessThan(Duration.ofDays(7).seconds)
}
@Test
fun `absolute expired session receives zero max age cookie`() {
val request = MockHttpServletRequest()
request
.getSession(true)!!
.setAttribute(
SessionKeys.AUTHENTICATED_AT,
System.currentTimeMillis() - Duration.ofDays(31).toMillis(),
)
val response = MockHttpServletResponse()
filter.doFilter(request, response, MockFilterChain())
val cookie = response.getHeader("Set-Cookie")
assertThat(cookie).contains("LMS_SESSION=")
assertThat(cookie).contains("Max-Age=0")
}
@Test
fun `does not create cookie without session`() {
val response = MockHttpServletResponse()
@@ -33,4 +75,12 @@ class SessionCookieRefreshFilterTest {
assertThat(response.getHeader("Set-Cookie")).isNull()
}
private fun maxAgeOf(cookie: String?): Long =
Regex("Max-Age=(-?\\d+)")
.find(cookie.orEmpty())
?.groupValues
?.get(1)
?.toLong()
?: error("Max-Age is missing in cookie: $cookie")
}