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:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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, "Сессия истекла, войдите заново")
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -900,4 +900,5 @@ data class AuditEventDto(
|
||||
val courseId: String? = null,
|
||||
val studentId: String? = null,
|
||||
val details: String = "",
|
||||
val cursor: String? = null,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
48
web/src/main/resources/templates/admin/legacy-assets.html
Normal file
48
web/src/main/resources/templates/admin/legacy-assets.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> =
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user