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

@@ -28,3 +28,8 @@ LMS_ACCESS_TOKEN_TTL=15m
LMS_REFRESH_TOKEN_TTL=7d LMS_REFRESH_TOKEN_TTL=7d
# Web-сессия LMS_SESSION: 7 дней sliding, access обновляется автоматически. # Web-сессия LMS_SESSION: 7 дней sliding, access обновляется автоматически.
LMS_SESSION_TIMEOUT=7d LMS_SESSION_TIMEOUT=7d
# Жёсткий потолок сессии: даже при ежедневной активности раз в 30 дней нужен повторный вход.
LMS_SESSION_ABSOLUTE_TIMEOUT=30d
# Истёкшие refresh-токены старше этого срока удаляются ночной очисткой.
LMS_REFRESH_TOKEN_RETENTION=30d
LMS_REFRESH_TOKEN_CLEANUP_CRON=0 30 3 * * *

View File

@@ -41,6 +41,8 @@ smoke:
# Shell-runner: скрипт поднимает реальный стек на localhost и проверяет его. # Shell-runner: скрипт поднимает реальный стек на localhost и проверяет его.
# На runner нужны Java 21, Docker Compose v2, Python 3 и bash. # На runner нужны Java 21, Docker Compose v2, Python 3 и bash.
tags: [lms-shell] tags: [lms-shell]
before_script:
- ./scripts/ci-preflight.sh
cache: cache:
key: "gradle-$CI_COMMIT_REF_SLUG" key: "gradle-$CI_COMMIT_REF_SLUG"
paths: paths:
@@ -57,13 +59,15 @@ smoke:
- if: '$CI_COMMIT_BRANCH' - if: '$CI_COMMIT_BRANCH'
# Ручной release gate: runtime, mutation и browser evidence. # Ручной release gate: runtime, mutation и browser evidence.
# Нужен privileged docker:dind; Chromium и Node ставятся в before_script. # Нужен privileged docker:dind; Chromium/Firefox/WebKit и Node ставятся в before_script.
release-evidence: release-evidence:
stage: release-evidence stage: release-evidence
# Shell-runner с Java 21, Docker Compose v2, Node 22, Chromium Playwright. # Shell-runner с Java 21, Docker Compose v2, Node 22, браузерами Playwright.
tags: [lms-release] tags: [lms-release]
before_script: before_script:
- cd e2e && npm ci && npx playwright install chromium && cd .. - cd e2e && npm ci && cd ..
- ./scripts/ci-preflight.sh --release
- cd e2e && npx playwright install --with-deps chromium firefox webkit && cd ..
cache: cache:
key: "gradle-$CI_COMMIT_REF_SLUG" key: "gradle-$CI_COMMIT_REF_SLUG"
paths: paths:

View File

@@ -24,6 +24,16 @@ UML- и архитектурные диаграммы (Mermaid) — в ката
- `services/gateway` — Spring Cloud Gateway, маршрутизация и проверка JWT. - `services/gateway` — Spring Cloud Gateway, маршрутизация и проверка JWT.
- `web` — серверный веб-интерфейс (Thymeleaf + htmx), BFF. - `web` — серверный веб-интерфейс (Thymeleaf + htmx), BFF.
Ключевое в текущей версии:
- роли `ADMIN/TEACHER/STUDENT`, курсы и группы-когорты, повышение и жизненный
цикл студента, профиль студента курса;
- access JWT 15 минут + refresh 7 дней с ротацией/отзывом; web-сессия 7 дней
sliding с абсолютным потолком 30 дней и автоматическим обновлением access;
- аудит с keyset-пагинацией, пакетная выдача лабораторных и групп курса;
- материалы и активы secure-by-default: файл без статьи доступен только
админу (инвентаризация `/admin/legacy-assets`).
## Требования ## Требования
- JDK 21 (runtime) + Docker (для PostgreSQL 18). Gradle сам скачает toolchain 21 для сборки. - JDK 21 (runtime) + Docker (для PostgreSQL 18). Gradle сам скачает toolchain 21 для сборки.
@@ -58,8 +68,10 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
./scripts/ldd-runtime.sh # runtime-smoke полного стека + scale load ./scripts/ldd-runtime.sh # runtime-smoke полного стека + scale load
./scripts/ldd-migration-upgrade.sh # upgrade с master-схемы ./scripts/ldd-migration-upgrade.sh # upgrade с master-схемы
./scripts/ldd-mutation.sh # PIT: mutation strength критичных P0-компонентов ./scripts/ldd-mutation.sh # PIT: mutation strength критичных P0-компонентов
./scripts/ldd-browser.sh # Playwright E2E (cd e2e && npm install && npx playwright install chromium) ./scripts/ldd-browser.sh # Playwright E2E: Chromium/Firefox/WebKit
./scripts/ldd-release.sh # полный release gate: test/upgrade/mutation/runtime/E2E ./scripts/ldd-release.sh # полный release gate: test/upgrade/mutation/runtime/E2E
./scripts/ci-preflight.sh --release # проверка окружения runner без установки
./scripts/legacy-assets.sh # инвентаризация бесхозных файлов на стенде (admin)
``` ```
- `ktlint` отвечает за формат, `detekt` — за качество (сложность, вложенность, ошибки). - `ktlint` отвечает за формат, `detekt` — за качество (сложность, вложенность, ошибки).
@@ -77,7 +89,8 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
`release-evidence` (`scripts/ldd-release.sh`) обязателен для тегов и `release-evidence` (`scripts/ldd-release.sh`) обязателен для тегов и
запускается вручную для веток. `check` требует privileged `docker:dind` запускается вручную для веток. `check` требует privileged `docker:dind`
для Testcontainers; `smoke`/`release-evidence` — shell-runner с Java 21, для Testcontainers; `smoke`/`release-evidence` — shell-runner с Java 21,
Docker Compose v2, Python 3 и (для release) Node/Chromium. Docker Compose v2, Python 3 и (для release) Node + Chromium/Firefox/WebKit.
Baseline: **51 claim, 346 тестов, 18/18 browser E2E**.
## Запуск в Docker (для сервера) ## Запуск в Docker (для сервера)

View File

@@ -60,13 +60,18 @@ Compose передаёт одно и то же значение identity, academ
Сроки аутентификации и сессии (можно не задавать — действуют значения ниже): Сроки аутентификации и сессии (можно не задавать — действуют значения ниже):
```dotenv ```dotenv
LMS_ACCESS_TOKEN_TTL=15m # access JWT identity, короткий LMS_ACCESS_TOKEN_TTL=15m # access JWT identity, короткий
LMS_REFRESH_TOKEN_TTL=7d # refresh-токен, ротация и отзыв LMS_REFRESH_TOKEN_TTL=7d # refresh-токен, ротация и отзыв
LMS_SESSION_TIMEOUT=7d # web-сессия LMS_SESSION, sliding LMS_SESSION_TIMEOUT=7d # web-сессия LMS_SESSION, sliding
LMS_SESSION_ABSOLUTE_TIMEOUT=30d # жёсткий потолок сессии
LMS_REFRESH_TOKEN_RETENTION=30d # сколько хранить истёкшие refresh
LMS_REFRESH_TOKEN_CLEANUP_CRON=0 30 3 * * *
``` ```
`LMS_ACCESS_TOKEN_TTL` и `LMS_REFRESH_TOKEN_TTL` читает identity, `LMS_ACCESS_TOKEN_TTL`, `LMS_REFRESH_TOKEN_TTL`,
`LMS_SESSION_TIMEOUT` — web. `docker-compose.prod.yml` прокидывает эти `LMS_SESSION_ABSOLUTE_TIMEOUT`, `LMS_REFRESH_TOKEN_RETENTION` и
`LMS_REFRESH_TOKEN_CLEANUP_CRON` читает identity; `LMS_SESSION_TIMEOUT` и
`LMS_SESSION_ABSOLUTE_TIMEOUT` — web. `docker-compose.prod.yml` прокидывает эти
переменные в environment соответствующих сервисов; при переопределении переменные в environment соответствующих сервисов; при переопределении
используются значения из `.env.prod`. Access используются значения из `.env.prod`. Access
обновляется автоматически за 2 минуты до истечения; прямой API-клиент должен обновляется автоматически за 2 минуты до истечения; прямой API-клиент должен
@@ -125,24 +130,30 @@ git pull
`deploy.sh up` **обязательно** делает backup перед обновлением. Если backup `deploy.sh up` **обязательно** делает backup перед обновлением. Если backup
завершился ошибкой, деплой прерывается. Миграции БД (Flyway) применяются завершился ошибкой, деплой прерывается. Миграции БД (Flyway) применяются
автоматически при старте сервисов: identity `V4–V11` (`V10` создаёт автоматически при старте сервисов: identity `V4–V13` (`V10` создаёт
`identity.audit_events`, `V11` — `identity.refresh_tokens`), academic `V6–V12`, `identity.audit_events`, `V11` — `identity.refresh_tokens`, `V12` —
`absolute_expires_at`, `V13` — keyset-индексы аудита), academic `V6–V12`,
progress `V13`. progress `V13`.
После обновления проверьте, что админ открывает `/admin/audit` и видит события После обновления проверьте, что админ открывает `/admin/audit` и видит события
(например, после входа в профиль студента — `PROFILE_VIEW`); преподаватель и (например, после входа в профиль студента — `PROFILE_VIEW`); преподаватель и
студент на эту страницу не попадают. Журнал листается через `page`/`limit` студент на эту страницу не попадают. Журнал листается keyset-курсором
(«Показать ещё»). Для инвентаризации старых файлов админ может вызвать (`cursor`/`limit`, «Показать ещё»; offset `page` остаётся для совместимости).
`GET /api/v1/content/legacy-assets`: файлы без привязки к статье больше не Для инвентаризации старых файлов админ открывает `/admin/legacy-assets`,
публичны (`404` для обычных пользователей), их нужно привязать или удалить. вызывает `GET /api/v1/content/legacy-assets` или запускает
`scripts/legacy-assets.sh`: файлы без привязки к статье больше не публичны
(`404` для обычных пользователей), их нужно привязать или удалить.
CI (`.gitlab-ci.yml`) на каждом push/MR выполняет CI (`.gitlab-ci.yml`) на каждом push/MR выполняет
`./gradlew --no-daemon test ktlintCheck detekt evidenceCheck` и job `smoke` `./gradlew --no-daemon test ktlintCheck detekt evidenceCheck` и job `smoke`
(`./scripts/ldd-smoke.sh`: полный стек, health, вход админа, один курс). (`./scripts/ldd-smoke.sh`: полный стек, health, вход админа, один курс).
Полный job `release-evidence` (`./scripts/ldd-release.sh`) обязателен для тегов Полный job `release-evidence` (`./scripts/ldd-release.sh`) обязателен для тегов
и запускается вручную для веток. `check` использует privileged `docker:dind` и запускается вручную для веток. `smoke` и release запускают
`scripts/ci-preflight.sh`, который проверяет Java 21, Docker Compose v2,
Python 3, Node и Playwright. `check` использует privileged `docker:dind`
для Testcontainers; `smoke`/`release-evidence` выполняются на shell-runner для Testcontainers; `smoke`/`release-evidence` выполняются на shell-runner
с Java 21, Docker Compose v2, Python 3, Node и Chromium Playwright. с Java 21, Docker Compose v2, Python 3, Node и браузерами
Chromium/Firefox/WebKit.
## 8. Резервное копирование ## 8. Резервное копирование

View File

@@ -47,6 +47,9 @@ services:
LMS_DB_URL: jdbc:postgresql://postgres:5432/${LMS_DB_NAME:-lms}?currentSchema=identity LMS_DB_URL: jdbc:postgresql://postgres:5432/${LMS_DB_NAME:-lms}?currentSchema=identity
LMS_ACCESS_TOKEN_TTL: ${LMS_ACCESS_TOKEN_TTL:-15m} LMS_ACCESS_TOKEN_TTL: ${LMS_ACCESS_TOKEN_TTL:-15m}
LMS_REFRESH_TOKEN_TTL: ${LMS_REFRESH_TOKEN_TTL:-7d} LMS_REFRESH_TOKEN_TTL: ${LMS_REFRESH_TOKEN_TTL:-7d}
LMS_SESSION_ABSOLUTE_TIMEOUT: ${LMS_SESSION_ABSOLUTE_TIMEOUT:-30d}
LMS_REFRESH_TOKEN_RETENTION: ${LMS_REFRESH_TOKEN_RETENTION:-30d}
LMS_REFRESH_TOKEN_CLEANUP_CRON: ${LMS_REFRESH_TOKEN_CLEANUP_CRON:-0 30 3 * * *}
depends_on: depends_on:
postgres: { condition: service_healthy } postgres: { condition: service_healthy }
networks: [lms] networks: [lms]
@@ -108,6 +111,7 @@ services:
LMS_GATEWAY_URL: http://gateway:8080 LMS_GATEWAY_URL: http://gateway:8080
SERVER_PORT: 8090 SERVER_PORT: 8090
LMS_SESSION_TIMEOUT: ${LMS_SESSION_TIMEOUT:-7d} LMS_SESSION_TIMEOUT: ${LMS_SESSION_TIMEOUT:-7d}
LMS_SESSION_ABSOLUTE_TIMEOUT: ${LMS_SESSION_ABSOLUTE_TIMEOUT:-30d}
ports: ports:
- "127.0.0.1:${LMS_WEB_PORT:-8090}:8090" - "127.0.0.1:${LMS_WEB_PORT:-8090}:8090"
depends_on: depends_on:

View File

@@ -53,8 +53,10 @@ flowchart TB
в контейнерах это `http://identity:8081/api/v1/auth/jwks.json` (иначе 401). в контейнерах это `http://identity:8081/api/v1/auth/jwks.json` (иначе 401).
Сроки сессии задаются переменными (значения по умолчанию подходят): Сроки сессии задаются переменными (значения по умолчанию подходят):
`LMS_ACCESS_TOKEN_TTL=15m` и `LMS_REFRESH_TOKEN_TTL=7d` для identity, `LMS_ACCESS_TOKEN_TTL=15m`, `LMS_REFRESH_TOKEN_TTL=7d`,
`LMS_SESSION_TIMEOUT=7d` для web. `LMS_SESSION_ABSOLUTE_TIMEOUT=30d`, `LMS_REFRESH_TOKEN_RETENTION=30d`,
`LMS_REFRESH_TOKEN_CLEANUP_CRON` для identity; `LMS_SESSION_TIMEOUT=7d` и
`LMS_SESSION_ABSOLUTE_TIMEOUT=30d` для web.
## 3. Сборка ## 3. Сборка
@@ -105,7 +107,7 @@ sequenceDiagram
DC->>DC: docker build (5 стадий) DC->>DC: docker build (5 стадий)
DC->>PG: старт postgres (volume lms_pgdata) DC->>PG: старт postgres (volume lms_pgdata)
DC->>DC: старт identity → academic → progress → gateway → web DC->>DC: старт identity → academic → progress → gateway → web
Note over DC: Flyway применяет миграции автоматически<br/>при старте сервисов (identity V1..V11, academic V1..V12, progress V1..V9 и V11..V13; у web миграций нет) Note over DC: Flyway применяет миграции автоматически<br/>при старте сервисов (identity V1..V13, academic V1..V12, progress V1..V9 и V11..V13; у web миграций нет)
``` ```
- Локально (`docker-compose.yml`) поднимается только `postgres`; прод-стек — `docker-compose.prod.yml`. - Локально (`docker-compose.yml`) поднимается только `postgres`; прод-стек — `docker-compose.prod.yml`.

View File

@@ -227,9 +227,10 @@ CREATE INDEX audit_events_action_idx ON identity.audit_events (action);
``` ```
Внешних ключей нет: событие должно переживать удаление субъекта. Запись идёт Внешних ключей нет: событие должно переживать удаление субъекта. Запись идёт
из сервисов через `POST /api/v1/internal/audit` (`X-Internal-Token`), чтение — из сервисов через `POST /api/v1/internal/audit` (`X-Internal-Token`), чтение —
`GET /api/v1/audit?page=&limit=&action=` и `/admin/audit` только для админа. `GET /api/v1/audit?limit=&action=&cursor=` и `/admin/audit` только для админа.
Страница аудита поддерживает `page` (с нуля) и `limit` (максимум 500), в UI — Страница аудита продолжается keyset-курсором `(occurred_at, id)`; offset `page`
переход «Показать ещё». остаётся для совместимости, `limit` — максимум 500. В UI — «Показать ещё» и
«В начало»; битый курсор даёт `400`.
**V11 — refresh-токены (добавлена при закрытии рисков аутентификации)** **V11 — refresh-токены (добавлена при закрытии рисков аутентификации)**
```sql ```sql
@@ -422,7 +423,8 @@ V13 идемпотентна: если старая feature-миграция V10
и защиты. Свой профиль студент открывает без записи в аудит, чтобы не и защиты. Свой профиль студент открывает без записи в аудит, чтобы не
засорять журнал и не нагружать identity на горячем пути. Админ смотрит засорять журнал и не нагружать identity на горячем пути. Админ смотрит
журнал на `/admin/audit` с фильтром по действию; время отображается в МСК. журнал на `/admin/audit` с фильтром по действию; время отображается в МСК.
Журнал читается страницами (`page`/`limit`, «Показать ещё»), а не только Журнал читается keyset-курсором (`cursor`/`limit`, «Показать ещё»; offset
`page` — только совместимость), а не только
последними N событиями. `membership_history` и `promotion_runs/items` последними N событиями. `membership_history` и `promotion_runs/items`
остаются доменной историей и дополняют общий журнал. остаются доменной историей и дополняют общий журнал.
@@ -433,19 +435,20 @@ V13 идемпотентна: если старая feature-миграция V10
сервисами на каждом запросе). сервисами на каждом запросе).
- **Refresh-токен** — 7 дней (`LMS_REFRESH_TOKEN_TTL`), хранится хешем в - **Refresh-токен** — 7 дней (`LMS_REFRESH_TOKEN_TTL`), хранится хешем в
`identity.refresh_tokens` (`V11`), ротируется при каждом обновлении. `identity.refresh_tokens` (`V11`), ротируется при каждом обновлении.
Повторное использование старого токена отзывает все активные сессии Абсолютный потолок сессии — 30 дней (`LMS_SESSION_ABSOLUTE_TIMEOUT`, `V12`):
аккаунта; выход, смена пароля, отчисление/выпуск/архив и закрытая группа ротация не продлевает `absolute_expires_at`. Повторное использование старого
обновление запрещают. токена отзывает все активные сессии аккаунта; выход, смена пароля,
отчисление/выпуск/архив и закрытая группа обновление запрещают. Истёкшие
токены старше retention удаляет ночная задача.
- **Прямой API-клиент** обязан обновлять пару через - **Прямой API-клиент** обязан обновлять пару через
`POST /api/v1/auth/refresh`; `POST /api/v1/auth/logout` отзывает refresh. `POST /api/v1/auth/refresh`; `POST /api/v1/auth/logout` отзывает refresh.
- **Web-сессия** — 7 дней sliding (`LMS_SESSION_TIMEOUT`), cookie `LMS_SESSION` - **Web-сессия** — 7 дней sliding (`LMS_SESSION_TIMEOUT`), cookie `LMS_SESSION`
(`HttpOnly`, `SameSite=Lax`, `Max-Age` обновляется на каждом ответе). (`HttpOnly`, `SameSite=Lax`, `Max-Age` обновляется на каждом ответе), но не
Access-токен автоматически обновляется за 2 минуты до истечения, поэтому дольше 30 дней с момента входа (`LMS_SESSION_ABSOLUTE_TIMEOUT`). После
активная вкладка не требует повторного входа каждые 15 минут; при выходе абсолютного срока cookie получает `Max-Age=0`, а пользователь — повторный
refresh отзывается. вход. Access-токен автоматически обновляется за 2 минуты до истечения,
- **Ограничение:** абсолютного лимита жизни сессии нет — при непрерывной поэтому активная вкладка не требует повторного входа каждые 15 минут; при
активности она продлевается. Если политика потребует принудительный выходе refresh отзывается.
повторный вход раз в N дней, добавляется отдельный абсолютный `max-age`.
--- ---
@@ -457,8 +460,9 @@ V13 идемпотентна: если старая feature-миграция V10
- **Активы (реализовано):** видимость актива наследуется от статьи → раздела → - **Активы (реализовано):** видимость актива наследуется от статьи → раздела →
главы, маршрут `/api/v1/content/assets/{name}` проверяет доступ. Файл без главы, маршрут `/api/v1/content/assets/{name}` проверяет доступ. Файл без
связи со статьёй не публичен: аноним/студент/преподаватель получают `404`, связи со статьёй не публичен: аноним/студент/преподаватель получают `404`,
админ может скачать файл и получить список таких файлов через админ может скачать файл, открыть страницу `/admin/legacy-assets`, вызвать
`GET /api/v1/content/legacy-assets` для привязки или удаления. Старые `GET /api/v1/content/legacy-assets` или `scripts/legacy-assets.sh` для
привязки или удаления. Старые
внешние ссылки на несвязанные файлы у обычных пользователей перестают внешние ссылки на несвязанные файлы у обычных пользователей перестают
работать — это осознанное secure-by-default поведение. работать — это осознанное secure-by-default поведение.
- Поисковая индексация: для `ALL` — по умолчанию разрешена; для остального — закрыть (robots/noindex). - Поисковая индексация: для `ALL` — по умолчанию разрешена; для остального — закрыть (robots/noindex).
@@ -527,13 +531,16 @@ V13 идемпотентна: если старая feature-миграция V10
--- ---
## 12. Интеграция «Контингент» / next.uniyar.ac.ru (задел) ## 12. Интеграция «Контингент» / next.uniyar.ac.ru — исключена из плана
- Позже — **отдельный сервис-адаптер** (anti-corruption layer); внешняя система — источник истины по Решением от 2026-10-28 интеграция **не входит в объём работ**: нет API-спецификации
курсу/группе/статусу; LMS — зеркало; синхронизация односторонняя + отчёт дрифта. и доступа к внешней системе, а без них можно сделать только непроверяемую заглушку.
- Сейчас закладываем: `student_no` уникальный и стабильный (ключ склейки); внешние id добавим миграцией. `student_no` остаётся уникальным и стабильным, поэтому при появлении нового ТЗ
- Обязательно: идемпотентность, журнал синхронизаций, отчёт расхождений, обработка академа/переводов/отчислений, адаптер можно добавить без переделки модели.
устойчивость к недоступности внешней системы, ПДн.
Если интеграция понадобится позже: отдельный anti-corruption layer, идемпотентная
синхронизация, журнал, отчёт дрифта, обработка академа/переводов/отчислений,
устойчивость к недоступности внешней системы и ПДн.
--- ---
@@ -573,6 +580,6 @@ V13 идемпотентна: если старая feature-миграция V10
4. Запись/снятие групп на курс. 4. Запись/снятие групп на курс.
5. Повышение курса: dry-run, журнал, идемпотентность, откат, выпуск. 5. Повышение курса: dry-run, журнал, идемпотентность, откат, выпуск.
6. Академический отпуск/возврат, отчисление, выпуск, архив; усиленное удаление. 6. Академический отпуск/возврат, отчисление, выпуск, архив; усиленное удаление.
7. (Позже) сервис-адаптер «Контингент»/`next.uniyar.ac.ru`. 7. ~~Сервис-адаптер «Контингент»/`next.uniyar.ac.ru`~~ — исключён из плана.
Открытых вопросов нет: все решения зафиксированы в §1. Открытых вопросов нет: все решения зафиксированы в §1.

View File

@@ -211,11 +211,11 @@
Номера уточняются при реализации, чтобы не конфликтовать с максимумами master Номера уточняются при реализации, чтобы не конфликтовать с максимумами master
(identity V3, **academic V5**, progress V12). Максимумы этой ветки: (identity V3, **academic V5**, progress V12). Максимумы этой ветки:
identity V11, **academic V12**, progress V13. identity V13, **academic V12**, progress V13.
| Сервис | Миграции | Содержание | | Сервис | Миграции | Содержание |
|---|---|---| |---|---|---|
| identity | V4–V11 | роли, поля групп, статусы студентов, `membership_history`, `promotion_runs/items`, `accounts.status`; `V10` — `audit_events` без внешних ключей и индексы по `occurred_at`/`action`; `V11` — `refresh_tokens` (хеш, срок, отзыв, цепочка замен) | | identity | V4–V13 | роли, поля групп, статусы студентов, `membership_history`, `promotion_runs/items`, `accounts.status`; `V10` — `audit_events`; `V11` — `refresh_tokens` (хеш, срок, отзыв, цепочка замен); `V12` — `absolute_expires_at`; `V13` — keyset-индексы аудита |
| academic | V6–V12 + post-bootstrap step | курсы, преподаватели, группы курса, `labs.course_id` (nullable), материалы курса/аудитории, частичные индексы, активы; `V10` — уникальный индекс `lower(title)` по курсам; `V11` — backfill `audience='ALL'` для глобальных глав; `V12` — backfill связей `article_assets` из markdown-ссылок; `SET NOT NULL` — post-bootstrap шаг вне Flyway | | academic | V6–V12 + post-bootstrap step | курсы, преподаватели, группы курса, `labs.course_id` (nullable), материалы курса/аудитории, частичные индексы, активы; `V10` — уникальный индекс `lower(title)` по курсам; `V11` — backfill `audience='ALL'` для глобальных глав; `V12` — backfill связей `article_assets` из markdown-ссылок; `SET NOT NULL` — post-bootstrap шаг вне Flyway |
| progress | V13 | `grade_comments.author_account_id`, `lab_grades.updated_by_account_id`, индекс `lab_grades(lab_id)`; аудит реализован в identity `V10` | | progress | V13 | `grade_comments.author_account_id`, `lab_grades.updated_by_account_id`, индекс `lab_grades(lab_id)`; аудит реализован в identity `V10` |
@@ -297,13 +297,15 @@ identity V11, **academic V12**, progress V13.
- **Legacy-активы.** Закрыто: связанные активы наследуют видимость статьи - **Legacy-активы.** Закрыто: связанные активы наследуют видимость статьи
(миграция `V12`), а файлы без markdown-ссылки по умолчанию отдают `404` (миграция `V12`), а файлы без markdown-ссылки по умолчанию отдают `404`
анониму/студенту/преподавателю. Админ перечисляет их через анониму/студенту/преподавателю. Админ перечисляет их через
`GET /api/v1/content/legacy-assets` для привязки или удаления. `GET /api/v1/content/legacy-assets`, страницу `/admin/legacy-assets` или
`scripts/legacy-assets.sh` для привязки или удаления.
- **JWT и сессии.** Закрыто: access-токен 15 минут, refresh 7 дней с ротацией - **JWT и сессии.** Закрыто: access-токен 15 минут, refresh 7 дней с ротацией
и отзывом, web-сессия 7 дней sliding. Прямые API-клиенты используют и отзывом, web-сессия 7 дней sliding с абсолютным потолком 30 дней.
`POST /api/v1/auth/refresh`; logout и смена пароля отзывают refresh. Прямые API-клиенты используют `POST /api/v1/auth/refresh`; logout и смена
- **Оставшаяся производительность.** Аудит постраничный и группы курса пароля отзывают refresh; истёкшие токены чистит ночная задача.
отдаются одним вызовом, но N+1 в отдельных лабораторных списках и - **Производительность.** Аудит продолжается keyset-курсором, детали всех
offset-пагинация аудита остаются лабораторными деталями. лабораторных курса и группы курса отдаются пакетно. Отдельный запрос сдач
по выбранной лабораторной в ведомости остаётся, но это не N+1 по студентам.
- **Комментарии без автора.** Старые записи останутся с «автор не указан». - **Комментарии без автора.** Старые записи останутся с «автор не указан».
- **Архивные студенты.** Открывать ли профиль после отвязки группы — уточнить - **Архивные студенты.** Открывать ли профиль после отвязки группы — уточнить
на этапе 3; по умолчанию история курса доступна, если студент был в курсе. на этапе 3; по умолчанию история курса доступна, если студент был в курсе.
@@ -334,25 +336,31 @@ identity V11, **academic V12**, progress V13.
- **Этап 6 — выполнен.** Права преподавателя на запись (лабораторные, - **Этап 6 — выполнен.** Права преподавателя на запись (лабораторные,
ведомость, группы, материалы), центральный аудит `identity.audit_events` ведомость, группы, материалы), центральный аудит `identity.audit_events`
с `/admin/audit`, CI merge gate и полный hardening. Проверены прогон с `/admin/audit`, CI merge gate и полный hardening. Проверены прогон
тестов/линтеров (**319**, 0 failures), upgrade миграций с master-схемы тестов/линтеров (**346**, 0 failures), upgrade миграций с master-схемы
(identity `V4–V11`, academic `V6–V12`, progress `V13`), security-матрица, (identity `V4–V13`, academic `V6–V12`, progress `V13`), security-матрица,
gateway, видимость активов и deploy-runbook. LDD baseline: **46 claims gateway, видимость активов и deploy-runbook. LDD baseline: **51 claims
(25 P0 / 21 P1), 46/46 proven**, browser E2E 4/4. Отчёт: (25 P0 / 26 P1), 51/51 proven**, browser E2E 18/18 (3 браузера). Отчёт:
[16-stage6-report.md](16-stage6-report.md). [16-stage6-report.md](16-stage6-report.md).
### 9.1. Известные пробелы после этапа 6 ### 9.1. Известные пробелы после этапа 6
- **Импорт.** Старый формат не поддерживается, принимается только шаблон v2. - **Импорт.** Старый формат не поддерживается, принимается только шаблон v2.
Номер группы — числовой; отдельного требования к ведущим нулям нет. Номер группы — числовой; отдельного требования к ведущим нулям нет.
- **Пагинация и N+1.** `GET /api/v1/audit` поддерживает `page` + `limit`, - **Пагинация и N+1.** Аудит продолжается keyset-курсором `(occurred_at, id)`
в UI есть «Показать ещё»; `GET /api/v1/courses/{id}/groups` отдаёт с offset-fallback для старых ссылок; `GET /api/v1/labs/details` отдаёт все
привязанные группы с именами одним вызовом. Остаются offset-пагинация лабораторные курса с уровнями и вариантами одним запросом; группы курса —
аудита и N+1 в отдельных лабораторных списках/ведомости на больших курсах. одним вызовом. Ведомость по-прежнему запрашивает сдачи по выбранной
- **Абсолютный лимит сессии.** Web-сессия скользящая (7 дней) и отзывается лабораторной — это не N+1 по студентам.
при выходе/смене пароля/закрытии доступа, но при непрерывной активности - **Legacy-активы.** Файлы без статьи уже не публичны; админская страница
может жить бессрочно; при необходимости нужен абсолютный `max-age`. `/admin/legacy-assets`, API и `scripts/legacy-assets.sh` готовы. Остаётся
- **Внешняя интеграция.** «Контингент»/`next.uniyar.ac.ru` остаётся заделом: прогнать инвентаризацию на production/staging и привязать или удалить файлы.
отдельный anti-corruption слой и внешние идентификаторы — позже. - **Сессии.** Web-сессия скользящая 7 дней, абсолютный потолок 30 дней
(`LMS_SESSION_ABSOLUTE_TIMEOUT`); истёкшие refresh-токены чистит ночная
задача (`LMS_REFRESH_TOKEN_RETENTION`).
- **CI.** Конфигурация и `ci-preflight.sh` готовы; первый реальный pipeline
на GitLab shell-runners ещё нужно подтвердить.
- **Внешняя интеграция «Контингент»/`next.uniyar.ac.ru` исключена из плана**
до появления нового ТЗ и доступа к API.
## 10. Definition of Done ## 10. Definition of Done
@@ -363,12 +371,13 @@ identity V11, **academic V12**, progress V13.
- Профиль показывает статистику, комментарии и все попытки; файлы скачиваются. - Профиль показывает статистику, комментарии и все попытки; файлы скачиваются.
- Повышение, академотпуск, выпуск и архив работают по ТЗ. - Повышение, академотпуск, выпуск и архив работают по ТЗ.
- Аудит чувствительных действий пишется в `identity.audit_events` и доступен - Аудит чувствительных действий пишется в `identity.audit_events` и доступен
админу на `/admin/audit` с пагинацией. админу на `/admin/audit` с keyset-пагинацией.
- Активы наследуют видимость материала; файлы без привязки не публичны и - Активы наследуют видимость материала; файлы без привязки не публичны и
доступны только админу через инвентаризацию `legacy-assets`. доступны только админу через страницу `/admin/legacy-assets`.
- Access/refresh-токены и web-сессия работают по срокам 15 минут / 7 дней / - Access/refresh-токены и web-сессия работают по срокам 15 минут / 7 дней /
7 дней; logout, смена пароля и закрытие доступа отзывают refresh. 7 дней sliding с абсолютным потолком 30 дней; logout, смена пароля и
- Security-матрица и миграционный upgrade-тест зелёные (identity до `V11`). закрытие доступа отзывают refresh.
- Security-матрица и миграционный upgrade-тест зелёные (identity до `V13`).
- CI на каждом push/MR прогоняет `test ktlintCheck detekt evidenceCheck` и - CI на каждом push/MR прогоняет `test ktlintCheck detekt evidenceCheck` и
smoke полного стека; полный release-evidence обязателен для тегов. smoke полного стека; полный release-evidence обязателен для тегов.
- Backup перед деплоем обязателен; restore проверен на staging. - Backup перед деплоем обязателен; restore проверен на staging.

View File

@@ -9,10 +9,11 @@
- ADR с ключевыми решениями: [adr/0001-groups-courses-foundation.md](adr/0001-groups-courses-foundation.md). - ADR с ключевыми решениями: [adr/0001-groups-courses-foundation.md](adr/0001-groups-courses-foundation.md).
- Контракт API профиля студента: [09-teacher-student-profile.md](09-teacher-student-profile.md). - Контракт API профиля студента: [09-teacher-student-profile.md](09-teacher-student-profile.md).
- Миграции: - Миграции:
- identity `V4–V11`: роли, группы-когорты, статусы студентов, повышение, - identity `V4–V13`: роли, группы-когорты, статусы студентов, повышение,
история членства, статусы аккаунтов; `V10` (`audit_events`) добавлена история членства, статусы аккаунтов; `V10` (`audit_events`) добавлена
позже, на этапе 6; `V11` (`refresh_tokens`) — при закрытии рисков позже, на этапе 6; `V11` (`refresh_tokens`) — при закрытии рисков
аутентификации, тоже после этого отчёта; аутентификации, тоже после этого отчёта; `V12` (`absolute_expires_at`) и
`V13` (keyset-индексы аудита) — при финальной доработке;
- academic `V6–V12`: курсы, преподаватели, группы курса, `labs.course_id`, - academic `V6–V12`: курсы, преподаватели, группы курса, `labs.course_id`,
материалы курса/аудитории, индексы активов; `V10` (уникальный индекс материалы курса/аудитории, индексы активов; `V10` (уникальный индекс
`lower(title)` по курсам) добавлена на этапе 2, после этого отчёта; `lower(title)` по курсам) добавлена на этапе 2, после этого отчёта;

View File

@@ -54,12 +54,12 @@
`V12` закрывает файлы, на которые есть ссылки в markdown. Если файл был `V12` закрывает файлы, на которые есть ссылки в markdown. Если файл был
загружен, но ни одна статья на него не ссылается, он больше **не публичен**: загружен, но ни одна статья на него не ссылается, он больше **не публичен**:
аноним, студент и преподаватель получают `404`, чтобы не раскрывать аноним, студент и преподаватель получают `404`, чтобы не раскрывать
существование файла. Админ скачивает такой файл и получает его имя в существование файла. Админ скачивает такой файл и получает его имя на
инвентаризации `GET /api/v1/content/legacy-assets`, после чего привязывает странице `/admin/legacy-assets`, через `GET /api/v1/content/legacy-assets`
файл к статье или удаляет. Старые внешние ссылки на несвязанные файлы у или `scripts/legacy-assets.sh`, после чего привязывает файл к статье или
обычных пользователей перестают работать — это осознанное поведение удаляет. Старые внешние ссылки на несвязанные файлы у обычных пользователей
secure-by-default, а не регрессия. Эндпоинт инвентаризации защищён перестают работать — это осознанное поведение secure-by-default, а не
`ROLE_ADMIN`. регрессия. Эндпоинт и страница инвентаризации защищены `ROLE_ADMIN`.
## Авторство материалов преподавателем (добавлено на этапе 6) ## Авторство материалов преподавателем (добавлено на этапе 6)

View File

@@ -10,7 +10,7 @@
| Этап | Что реализовано | Отчёт | | Этап | Что реализовано | Отчёт |
|---|---|---| |---|---|---|
| 0 | Схема и контракты: identity `V4–V11` (`V10` — аудит, `V11` — refresh-токены), academic `V6–V12`, progress `V13`; preflight-скрипты; ADR; контракт профиля студента курса | [10](10-stage0-report.md) | | 0 | Схема и контракты: identity `V4–V13` (`V10` — аудит, `V11` — refresh, `V12` — absolute, `V13` — keyset-индексы), academic `V6–V12`, progress `V13`; preflight-скрипты; ADR; контракт профиля студента курса | [10](10-stage0-report.md) |
| 1 | Роли `ADMIN/TEACHER/STUDENT`, группы-когорты и парсер имени, статусы студентов и аккаунтов, `membership_history`, импорт v2 | [11](11-stage1-report.md) | | 1 | Роли `ADMIN/TEACHER/STUDENT`, группы-когорты и парсер имени, статусы студентов и аккаунтов, `membership_history`, импорт v2 | [11](11-stage1-report.md) |
| 2 | Курсы, `course_no` для браузерных URL, история преподавателей и групп, bootstrap курса C, привязка лабораторных к курсу, копирование лабораторных, админ-UI | [12](12-stage2-report.md) | | 2 | Курсы, `course_no` для браузерных URL, история преподавателей и групп, bootstrap курса C, привязка лабораторных к курсу, копирование лабораторных, админ-UI | [12](12-stage2-report.md) |
| 3 | Аккаунты преподавателей, курсовая область видимости, внутренние API identity с `LMS_INTERNAL_TOKEN`, кабинет преподавателя, профиль студента курса и скачивание всех попыток | [13](13-stage3-report.md) | | 3 | Аккаунты преподавателей, курсовая область видимости, внутренние API identity с `LMS_INTERNAL_TOKEN`, кабинет преподавателя, профиль студента курса и скачивание всех попыток | [13](13-stage3-report.md) |
@@ -31,7 +31,10 @@
недоступны (`404`) всем, кроме админа; админ перечисляет их через недоступны (`404`) всем, кроме админа; админ перечисляет их через
`GET /api/v1/content/legacy-assets`; `GET /api/v1/content/legacy-assets`;
- access-токен живёт 15 минут, refresh — 7 дней с ротацией и отзывом; web-сессия - access-токен живёт 15 минут, refresh — 7 дней с ротацией и отзывом; web-сессия
скользящая на 7 дней и сама обновляет access-токен; скользящая на 7 дней, но не дольше абсолютного потолка 30 дней, и сама
обновляет access-токен; истёкшие refresh-токены очищаются ночью;
- аудит продолжается keyset-курсором `(occurred_at, id)`, а детали всех
лабораторных курса отдаются одним пакетным запросом без N+1;
- преподаватель своего курса может писать: лабораторные/уровни/варианты, - преподаватель своего курса может писать: лабораторные/уровни/варианты,
ведомость, привязка групп и авторские материалы курса; чужие курс, лаба, ведомость, привязка групп и авторские материалы курса; чужие курс, лаба,
студент и группа отклоняются на сервисах; студент и группа отклоняются на сервисах;
@@ -62,14 +65,15 @@
| Модуль | Тестов | Failures | Errors | Skipped | | Модуль | Тестов | Failures | Errors | Skipped |
|---|---:|---:|---:|---:| |---|---:|---:|---:|---:|
| identity-service | 72 | 0 | 0 | 0 | | identity-service | 98 | 0 | 0 | 0 |
| academic-service | 88 | 0 | 0 | 0 | | academic-service | 99 | 0 | 0 | 0 |
| progress-service | 33 | 0 | 0 | 0 | | progress-service | 36 | 0 | 0 | 0 |
| web | 69 | 0 | 0 | 0 | | web | 106 | 0 | 0 | 0 |
| gateway | 2 | 0 | 0 | 0 |
| markdown | 5 | 0 | 0 | 0 | | markdown | 5 | 0 | 0 | 0 |
| **Итого на момент этапа 6** | **267** | **0** | **0** | **0** | | **Итого на момент этапа 6** | **346** | **0** | **0** | **0** |
После закрытия рисков актуальный LDD-baseline — **319 тестов, 0 failures** После закрытия рисков актуальный LDD-baseline — **346 тестов, 0 failures**
(см. раздел 7). `ktlintCheck` и `detekt` прошли без замечаний. У gateway теперь есть unit-тесты публичных auth-путей и 401; маршрутизация (см. раздел 7). `ktlintCheck` и `detekt` прошли без замечаний. У gateway теперь есть unit-тесты публичных auth-путей и 401; маршрутизация
gateway дополнительно проверена конфигурацией и security-матрицей ниже. gateway дополнительно проверена конфигурацией и security-матрицей ниже.
Тот же набор команд выполняет `.gitlab-ci.yml`; на каждый push/MR добавлен Тот же набор команд выполняет `.gitlab-ci.yml`; на каждый push/MR добавлен
@@ -86,7 +90,7 @@ smoke-job, полный release-evidence обязателен для тегов.
(`ПИ-11БО`, `ПИ-21БО`), зачисления, семестр, лабораторная, глава/раздел/ (`ПИ-11БО`, `ПИ-21БО`), зачисления, семестр, лабораторная, глава/раздел/
статья/актив, оценка, комментарий и попытка сдачи. статья/актив, оценка, комментарий и попытка сдачи.
3. Поверх master применены миграции ветки: 3. Поверх master применены миграции ветки:
- identity `V4–V11`; - identity `V4–V13`;
- academic `V6–V12`; - academic `V6–V12`;
- progress `V13`. - progress `V13`.
4. Выполнены postflight-запросы и повторное применение `V13`. 4. Выполнены postflight-запросы и повторное применение `V13`.
@@ -100,7 +104,10 @@ smoke-job, полный release-evidence обязателен для тегов.
действием `ENROLL`; добавлен `accounts.status`; `V10` создаёт действием `ENROLL`; добавлен `accounts.status`; `V10` создаёт
`identity.audit_events` без внешних ключей и индексы по `occurred_at`/`action`; `identity.audit_events` без внешних ключей и индексы по `occurred_at`/`action`;
`V11` создаёт `identity.refresh_tokens` (SHA-256-хеш, `expires_at`, `V11` создаёт `identity.refresh_tokens` (SHA-256-хеш, `expires_at`,
`revoked_at`, `replaced_by`, индексы по аккаунту и сроку). `revoked_at`, `replaced_by`, индексы по аккаунту и сроку); `V12` добавляет
`absolute_expires_at` с бэкфиллом и `NOT NULL`; `V13` создаёт композитные
индексы `(occurred_at DESC, id DESC)` и `(action, occurred_at DESC, id DESC)`
под keyset-пагинацию.
- **academic:** добавлены `labs.course_id`, `chapters.course_id/audience`, - **academic:** добавлены `labs.course_id`, `chapters.course_id/audience`,
индексы `labs_number_course_uidx`, `chapters_slug_global_uidx`, индексы `labs_number_course_uidx`, `chapters_slug_global_uidx`,
`chapters_slug_course_uidx`, `article_assets_article_idx`, `chapters_slug_course_uidx`, `article_assets_article_idx`,
@@ -126,7 +133,7 @@ smoke-job, полный release-evidence обязателен для тегов.
### 2.3. Security-проверки ### 2.3. Security-проверки
Проверено автоматическими тестами (актуальный прогон — 319 тестов, 0 failures) Проверено автоматическими тестами (актуальный прогон — 346 тестов, 0 failures)
и разбором конфигурации gateway. Запись преподавателя дополнительно покрыта и разбором конфигурации gateway. Запись преподавателя дополнительно покрыта
negative-тестами «свой/чужой курс». negative-тестами «свой/чужой курс».
@@ -141,7 +148,7 @@ negative-тестами «свой/чужой курс».
| Преподаватель своего курса → скрытый курсовой актив | доступ | `200`; админ видит любой актив | | Преподаватель своего курса → скрытый курсовой актив | доступ | `200`; админ видит любой актив |
| Глобальный `STUDENTS`-актив → преподаватель | `403` | `403`; студент получает `200` | | Глобальный `STUDENTS`-актив → преподаватель | `403` | `403`; студент получает `200` |
| Legacy-актив без связи со статьёй | `404` для анонима/студента/преподавателя, файл доступен только админу | `404` всем, кроме `ADMIN`; `GET /api/v1/content/legacy-assets` под `ROLE_ADMIN` перечисляет несвязанные файлы | | Legacy-актив без связи со статьёй | `404` для анонима/студента/преподавателя, файл доступен только админу | `404` всем, кроме `ADMIN`; `GET /api/v1/content/legacy-assets` под `ROLE_ADMIN` перечисляет несвязанные файлы |
| Access/refresh | короткий access + ротация refresh | access JWT 15 минут (`LMS_ACCESS_TOKEN_TTL`), refresh 7 дней (`LMS_REFRESH_TOKEN_TTL`); `POST /api/v1/auth/refresh` ротирует пару, повторное использование старого токена отзывает все сессии аккаунта | | Access/refresh | короткий access + ротация refresh | access JWT 15 минут (`LMS_ACCESS_TOKEN_TTL`), refresh 7 дней (`LMS_REFRESH_TOKEN_TTL`), абсолютный потолок сессии 30 дней (`LMS_SESSION_ABSOLUTE_TIMEOUT`); `POST /api/v1/auth/refresh` ротирует пару, не продлевая `absolute_expires_at`; повторное использование старого токена отзывает все сессии аккаунта; истёкшие токены чистит ночная задача |
| Logout / смена пароля | сессии отзываются | `POST /api/v1/auth/logout` отзывает refresh; смена пароля — `revokeAll` | | Logout / смена пароля | сессии отзываются | `POST /api/v1/auth/logout` отзывает refresh; смена пароля — `revokeAll` |
| Архив/отчисление/закрытая группа → refresh | обновление запрещено | `ensureSessionAllowed` при ротации: `EXPELLED`, `GRADUATED`, `ARCHIVED`, архивный аккаунт и `GRADUATED`/`ARCHIVED` группа дают `401` | | Архив/отчисление/закрытая группа → refresh | обновление запрещено | `ensureSessionAllowed` при ротации: `EXPELLED`, `GRADUATED`, `ARCHIVED`, архивный аккаунт и `GRADUATED`/`ARCHIVED` группа дают `401` |
| Web-сессия | 7 дней sliding, без повторного входа, быстрый отзыв | cookie `LMS_SESSION`, `Max-Age` 7 дней (`LMS_SESSION_TIMEOUT`), `HttpOnly`/`SameSite=Lax`; access обновляется за 2 минуты до истечения, cookie перевыпускается на каждом ответе; статус перепроверяется не реже чем раз в 45 секунд, `active=false` или `401/403` инвалидируют сессию, недоступность identity не разлогинивает всех; выход отзывает refresh | | Web-сессия | 7 дней sliding, без повторного входа, быстрый отзыв | cookie `LMS_SESSION`, `Max-Age` 7 дней (`LMS_SESSION_TIMEOUT`), `HttpOnly`/`SameSite=Lax`; access обновляется за 2 минуты до истечения, cookie перевыпускается на каждом ответе; статус перепроверяется не реже чем раз в 45 секунд, `active=false` или `401/403` инвалидируют сессию, недоступность identity не разлогинивает всех; выход отзывает refresh |
@@ -149,7 +156,7 @@ negative-тестами «свой/чужой курс».
| Преподаватель своего курса → чтение | курс, группы, профиль, материалы | видит опубликованные и скрытые материалы, всех студентов активной группы курса, неопубликованные оценки и все попытки; `GET /api/v1/courses/{id}/groups` отдаёт привязанные группы с именами одним вызовом (без N+1) | | Преподаватель своего курса → чтение | курс, группы, профиль, материалы | видит опубликованные и скрытые материалы, всех студентов активной группы курса, неопубликованные оценки и все попытки; `GET /api/v1/courses/{id}/groups` отдаёт привязанные группы с именами одним вызовом (без N+1) |
| Преподаватель своего курса → запись | лабы/уровни/варианты, ведомость, группы, материалы | оценки/комментарии, публикация, уровни/варианты и раздача, защиты (код/reset), привязка групп, авторство материалов; проверки в progress `CourseScopeService` и academic course/content authoring | | Преподаватель своего курса → запись | лабы/уровни/варианты, ведомость, группы, материалы | оценки/комментарии, публикация, уровни/варианты и раздача, защиты (код/reset), привязка групп, авторство материалов; проверки в progress `CourseScopeService` и academic course/content authoring |
| Преподаватель чужого курса | `403` | `403` на чтение и запись: материалы, курс по `course_no`, состав группы, профиль, лабы, оценки, группы | | Преподаватель чужого курса | `403` | `403` на чтение и запись: материалы, курс по `course_no`, состав группы, профиль, лабы, оценки, группы |
| Аудит → админ | чтение журнала | `GET /api/v1/audit?page=&limit=&action=` и `/admin/audit` доступны только `ADMIN`; в UI — постраничный переход «Показать ещё» | | Аудит → админ | чтение журнала | `GET /api/v1/audit?limit=&action=&cursor=` (offset `page` — для совместимости) и `/admin/audit` доступны только `ADMIN`; в UI — keyset-переход «Показать ещё», битый курсор — `400` |
| Аудит → запись из сервисов | только по токену | `POST /api/v1/internal/audit` с `X-Internal-Token`; без/неверный токен — `403` | | Аудит → запись из сервисов | только по токену | `POST /api/v1/internal/audit` с `X-Internal-Token`; без/неверный токен — `403` |
| Студент → свой профиль | только своё и опубликованное | скрытые оценки/комментарии отсутствуют; чужой `studentId` — `403` | | Студент → свой профиль | только своё и опубликованное | скрытые оценки/комментарии отсутствуют; чужой `studentId` — `403` |
| Студент → чужой курс/группа | `403` | доступ только к курсам активной группы; архивный курс — `403` | | Студент → чужой курс/группа | `403` | доступ только к курсам активной группы; архивный курс — `403` |
@@ -158,10 +165,12 @@ negative-тестами «свой/чужой курс».
| Gateway | внутренние пути не публикуются | маршрутов на `/api/v1/internal/**` нет; gateway требует JWT для всего, кроме `/actuator`, входа/setup/JWKS/refresh/logout и публичного `/api/v1/content/**` | | Gateway | внутренние пути не публикуются | маршрутов на `/api/v1/internal/**` нет; gateway требует JWT для всего, кроме `/actuator`, входа/setup/JWKS/refresh/logout и публичного `/api/v1/content/**` |
| Gateway → заголовки личности | клиент не подменяет | `X-Account-Id/Login/Role` удаляются и проставляются только после проверки JWT | | Gateway → заголовки личности | клиент не подменяет | `X-Account-Id/Login/Role` удаляются и проставляются только после проверки JWT |
Остаточные ограничения перечислены в разделе 4: offset-пагинация аудита, Остаточные ограничения перечислены в разделе 4: ручная инвентаризация
N+1 в отдельных лабораторных списках и отсутствие абсолютного лимита legacy-активов на production, сдачи по выбранной лабораторной в ведомости,
web-сессии. Продуктовые риски legacy-активов, 30-дневного JWT и ведущих непроверенный первый CI-прогон и отсутствие визуальной регрессии. Продуктовые
нулей номера группы закрыты или сняты как не относящиеся к требованиям. риски legacy-активов, срока access-токена, пагинации, N+1 деталей лабораторных,
абсолютного лимита сессии и ведущих нулей закрыты или сняты как не относящиеся
к требованиям.
## 3. Чек-лист деплоя ## 3. Чек-лист деплоя
@@ -185,16 +194,19 @@ web-сессии. Продуктовые риски legacy-активов, 30-д
`smoke`/`release-evidence` — shell-runner с Java 21, Docker Compose v2, `smoke`/`release-evidence` — shell-runner с Java 21, Docker Compose v2,
Python 3, Node и Chromium Playwright. Python 3, Node и Chromium Playwright.
- [ ] Заданы сроки сессии: `LMS_ACCESS_TOKEN_TTL=15m`, - [ ] Заданы сроки сессии: `LMS_ACCESS_TOKEN_TTL=15m`,
`LMS_REFRESH_TOKEN_TTL=7d`, `LMS_SESSION_TIMEOUT=7d` (значения по умолчанию `LMS_REFRESH_TOKEN_TTL=7d`, `LMS_SESSION_TIMEOUT=7d`,
подходят; менять только осознанно). `LMS_SESSION_ABSOLUTE_TIMEOUT=30d`, `LMS_REFRESH_TOKEN_RETENTION=30d`
(значения по умолчанию подходят; менять только осознанно). Очистка
refresh запускается по `LMS_REFRESH_TOKEN_CLEANUP_CRON` (по умолчанию 03:30).
### 3.2. Порядок выкладки ### 3.2. Порядок выкладки
1. **Flyway.** `./deploy/deploy.sh up` собирает образы и запускает 1. **Flyway.** `./deploy/deploy.sh up` собирает образы и запускает
`postgres → identity → academic → progress → gateway → web`. Миграции `postgres → identity → academic → progress → gateway → web`. Миграции
применяются автоматически при старте сервисов: применяются автоматически при старте сервисов:
identity `V4–V11` (`V10` — `identity.audit_events`, `V11` — identity `V4–V13` (`V10` — `identity.audit_events`, `V11` —
`identity.refresh_tokens`), academic `V6–V12`, progress `V13`. `identity.refresh_tokens`, `V12` — `absolute_expires_at`, `V13` —
keyset-индексы аудита), academic `V6–V12`, progress `V13`.
Отдельный ручной запуск Flyway не нужен. Отдельный ручной запуск Flyway не нужен.
2. **Bootstrap курса C.** После входа админа открыть `/admin/courses`. 2. **Bootstrap курса C.** После входа админа открыть `/admin/courses`.
Пока курсов нет, показан блок первичной настройки. Bootstrap идемпотентно Пока курсов нет, показан блок первичной настройки. Bootstrap идемпотентно
@@ -213,7 +225,9 @@ web-сессии. Продуктовые риски legacy-активов, 30-д
- `comments_without_author` соответствует старым комментариям; - `comments_without_author` соответствует старым комментариям;
- группы без полного разбора отсутствуют; - группы без полного разбора отсутствуют;
- таблица `identity.audit_events` существует (`V10`); - таблица `identity.audit_events` существует (`V10`);
- таблица `identity.refresh_tokens` существует (`V11`). - таблица `identity.refresh_tokens` существует (`V11`);
- у `refresh_tokens` есть `absolute_expires_at NOT NULL` (`V12`);
- индекс `audit_events_occurred_id_idx` существует (`V13`).
4. **V10 → V13 — только dev/staging.** На production/master записи 4. **V10 → V13 — только dev/staging.** На production/master записи
`progress V10__profile_authors.sql` нет, ничего удалять не нужно. Не путать `progress V10__profile_authors.sql` нет, ничего удалять не нужно. Не путать
с academic `V10__course_title_unique.sql` — он относится к другой схеме и с academic `V10__course_title_unique.sql` — он относится к другой схеме и
@@ -249,8 +263,8 @@ web-сессии. Продуктовые риски legacy-активов, 30-д
- [ ] **Активы:** картинки курсовой статьи открываются у студента своего курса - [ ] **Активы:** картинки курсовой статьи открываются у студента своего курса
и преподавателя; чужой курс — `403`, скрытая картинка у студента — `404`, и преподавателя; чужой курс — `403`, скрытая картинка у студента — `404`,
публичная `ALL` — без входа; файл без привязки к статье — `404` для всех, публичная `ALL` — без входа; файл без привязки к статье — `404` для всех,
кроме админа. Админ проверяет инвентаризацию через кроме админа. Админ открывает `/admin/legacy-assets` (или
`GET /api/v1/content/legacy-assets` (или админский UI/curl) и при `GET /api/v1/content/legacy-assets`, или `scripts/legacy-assets.sh`) и при
необходимости привязывает или удаляет файлы. необходимости привязывает или удаляет файлы.
- [ ] **Копирование лабораторной:** админ копирует лабораторную в другой курс; - [ ] **Копирование лабораторной:** админ копирует лабораторную в другой курс;
создаётся черновик с уровнями и вариантами; оценки, сдачи, защиты и создаётся черновик с уровнями и вариантами; оценки, сдачи, защиты и
@@ -263,13 +277,13 @@ web-сессии. Продуктовые риски legacy-активов, 30-д
- [ ] **Аудит:** `/admin/audit` открывается у админа, показывает события - [ ] **Аудит:** `/admin/audit` открывается у админа, показывает события
(например, `PROFILE_VIEW`, `SUBMISSION_DOWNLOAD`, `GRADE_SET`, (например, `PROFILE_VIEW`, `SUBMISSION_DOWNLOAD`, `GRADE_SET`,
`CONTENT_*`, `TEACHER_*`) с фильтром по действию и временем МСК; `CONTENT_*`, `TEACHER_*`) с фильтром по действию и временем МСК;
«Показать ещё» переходит на следующую страницу (`page`/`limit`); «Показать ещё» переходит по keyset-курсору (`cursor`), «В начало»
преподаватель/студент получают `403`. возвращает к первой странице; преподаватель/студент получают `403`.
- [ ] **Сессия:** после входа cookie `LMS_SESSION` живёт 7 дней; при работе - [ ] **Сессия:** после входа cookie `LMS_SESSION` живёт 7 дней, но не дольше
дольше 15 минут страница продолжает работать без повторного входа 30 дней с момента входа; при работе дольше 15 минут страница продолжает
(access обновляется автоматически). Выход и смена пароля требуют повторного работать без повторного входа (access обновляется автоматически). Выход и
входа; вход отчисленного/архивного студента и refresh закрытой группы смена пароля требуют повторного входа; вход отчисленного/архивного студента
отклоняются. и refresh закрытой группы отклоняются.
- [ ] Проверить логи `web`, `gateway`, `identity`, `academic`, `progress` на - [ ] Проверить логи `web`, `gateway`, `identity`, `academic`, `progress` на
`ERROR`/`WARN` после smoke. `ERROR`/`WARN` после smoke.
@@ -279,53 +293,65 @@ web-сессии. Продуктовые риски legacy-активов, 30-д
шаблон v2 (поток/курс/номер/степень/год/ФИО/№/база). Ограничение шаблон v2 (поток/курс/номер/степень/год/ФИО/№/база). Ограничение
зафиксировано в [11](11-stage1-report.md). Номер группы — числовой, поэтому зафиксировано в [11](11-stage1-report.md). Номер группы — числовой, поэтому
отдельного требования к ведущим нулям нет. отдельного требования к ведущим нулям нет.
2. **Пагинация аудита — offset.** `GET /api/v1/audit` поддерживает `page` и 2. **Инвентаризация legacy-активов на реальном хранилище.** UI
`limit` (максимум 500), UI — «Показать ещё». Для очень больших журналов `/admin/legacy-assets`, API и `scripts/legacy-assets.sh` готовы; остаётся
keyset-пагинация была бы устойчивее к вставкам между страницами. прогнать их на production/staging и привязать или удалить найденные файлы.
3. **N+1 в отдельных списках.** Имена групп курса отдаются одним вызовом Это гигиена хранилища, а не открытый риск доступа: обычные пользователи
(`GET /api/v1/courses/{id}/groups`), но часть лабораторных списков и уже получают `404`.
ведомости на больших объёмах всё ещё собирается по частям. Полный нагрузочный 3. **Ведомость запрашивает сдачи по выбранной лабораторной.** Детали всех
профиль 100 групп / 1000 студентов проходит, однако пакетные выборки для лабораторных курса теперь пакетные (`GET /api/v1/labs/details`), но
оставшихся экранов — лабораторная деталь, а не блокер передачи. ведомость при открытии конкретной лабораторной делает отдельный запрос
4. **Нет абсолютного лимита web-сессии.** Сессия скользящая (7 дней) и сдач. Это не N+1 по студентам и не блокер.
отзывается при выходе, смене пароля, отчислении/архиве и закрытии группы, 4. **Browser E2E без визуальной регрессии.** 6 сценариев × Chromium/Firefox/
но при непрерывной активности может жить бессрочно. Если политика требует WebKit = 18/18, но нет screenshot-diff и полного набора мобильных страниц.
принудительного повторного входа раз в N дней, нужен абсолютный `max-age` На Fedora WebKit запускается через Docker-образ Playwright, потому что
поверх sliding-сессии. системных Ubuntu-библиотек нет.
5. **Первый реальный CI-прогон не подтверждён.** Конфигурация, smoke и
`scripts/ci-preflight.sh` готовы, но наличие GitLab shell-runners с тегами
`lms-shell`/`lms-release` и первый tag-job нужно проверить в GitLab.
6. **Внешняя интеграция «Контингент» исключена из плана.** Отдельного
anti-corruption слоя, синхронизации и внешних идентификаторов не будет без
нового ТЗ и доступа к API.
7. **Mutation testing не 100%.** Остаются эквивалентные мутанты и
Kotlin/Spring-специфичные мутации; PIT harness — JUnit 4.
8. **Нагрузочный smoke — не SLA.** Одна машина, синтетические данные;
125 чтений и p95 < 1 с — ориентир, а не гарантия production.
## 5. Рекомендуемые следующие шаги ## 5. Рекомендуемые следующие шаги
Приоритет — сверху вниз: Приоритет — сверху вниз:
1. **Инвентаризация legacy-активов.** Несвязанные файлы уже не публичны; админ 1. **Прогнать инвентаризацию legacy-активов на production/staging** через
получает их список через `GET /api/v1/content/legacy-assets`, после чего `/admin/legacy-assets` или `scripts/legacy-assets.sh`, привязать нужные
привязывает к статьям или удаляет. Это гигиена хранилища, а не открытый файлы к статьям, остальные удалить.
риск доступа. 2. **Проверить CI на реальном runner.** Убедиться, что есть shell-runners
2. **Keyset-пагинация и оставшиеся N+1.** Устойчивый курсор для аудита, `lms-shell`/`lms-release` с Java 21, Docker Compose v2, Python 3,
пакетные выборки для лабораторных списков и ведомости на больших курсах. Node/Chromium/Firefox/WebKit; дождаться зелёного push-MR pipeline и
3. **Абсолютный лимит сессии.** Опционально: принудительный повторный вход tag-job `release-evidence`.
раз в N дней поверх 7-дневной sliding-сессии. 3. **Дальнейшее расширение E2E** — визуальная регрессия и остальные мобильные
4. **Внешняя интеграция «Контингент»/next.uniyar.ac.ru.** Отдельный страницы, если UI продолжит расти.
anti-corruption слой, идемпотентная синхронизация, журнал и отчёт дрифта; 4. **Пакетные сдачи ведомости**, если появятся курсы, где открытие ведомости
внешние идентификаторы добавляются миграцией. станет заметно медленным.
## 6. Итог ## 6. Итог
Этап 6 закрыт: права преподавателя на запись, аудит, CI, security-матрица, Этап 6 закрыт: права преподавателя на запись, аудит, CI, security-матрица,
upgrade-тест миграций, видимость активов и deploy-runbook проверены. upgrade-тест миграций, видимость активов и deploy-runbook проверены.
Продуктовые риски закрыты: legacy-активы secure-by-default, access/refresh и Продуктовые риски закрыты: legacy-активы secure-by-default, access/refresh и
web-сессия с отзывом, пагинация аудита и bulk-выдача групп. Оставшиеся web-сессия с отзывом и абсолютным потолком, keyset-пагинация аудита,
ограничения (offset-пагинация, отдельные N+1, отсутствие абсолютного лимита пакетные детали лабораторных и bulk-выдача групп. Оставшиеся ограничения
сессии) задокументированы в разделе 4 и не блокируют передачу. (ручная инвентаризация на production, сдачи по выбранной лабораторной,
непроверенный первый CI-прогон) задокументированы в разделе 4 и не блокируют
передачу.
## 7. LDD baseline ## 7. LDD baseline
После этапа 6 и закрытия рисков текущая функциональность проверена через После этапа 6 и закрытия рисков текущая функциональность проверена через
LDD-контур: реестр `docs/evidence/claims.json` (**46 claims, 25 P0 / 21 P1**), LDD-контур: реестр `docs/evidence/claims.json` (**51 claims, 25 P0 / 26 P1**),
gate `./gradlew evidenceCheck`, upgrade-скрипт, runtime-smoke, smoke-job, gate `./gradlew evidenceCheck`, upgrade-скрипт, runtime-smoke, smoke-job,
mutation testing и браузерный Playwright E2E. Результат: mutation testing и браузерный Playwright E2E. Результат:
**46/46 claims proven, P0 gaps = 0, 319 тестов, 0 failures**; mutation strength **51/51 claims proven, P0 gaps = 0, 346 тестов, 0 failures**; mutation strength
`CourseAccessService` 75%, `PromotionPlanner` 69%; browser E2E 4/4; `CourseAccessService` 75%, `PromotionPlanner` 69%; browser E2E 18/18 (6 сценариев × Chromium/Firefox/WebKit);
конкурентные apply/rollback и масштабный нагрузочный smoke (100 групп / конкурентные apply/rollback и масштабный нагрузочный smoke (100 групп /
1000 студентов, 125 параллельных чтений, 0 ошибок, p95 < 1 с) зелёные. 1000 студентов, 125 параллельных чтений, 0 ошибок, p95 < 1 с) зелёные.
Smoke-job выполняется на каждом push/MR, полный `release-evidence` обязателен Smoke-job выполняется на каждом push/MR, полный `release-evidence` обязателен

View File

@@ -1,8 +1,8 @@
# 17. Handoff: состояние проекта после закрытия продуктовых рисков # 17. Handoff: состояние проекта после закрытия продуктовых рисков
**Дата:** 2026-10-28. **Дата:** 2026-10-28.
**Ветка:** `feature/groups-courses` (54 коммита от master до этого документа). **Ветка:** `feature/groups-courses` (последняя история — `git log --oneline`).
**HEAD на момент написания:** `1ca507b` (`docs: закрытие продуктовых рисков и новый LDD baseline`). **Последний коммит перед этой итерацией:** `3aae2b8` (`docs: handoff после закрытия продуктовых рисков`).
**Пуш:** не выполнялся. **Пуш:** не выполнялся.
**Рабочее дерево:** чистое. `exports/` и `texput.log` игнорируются и в коммиты не входят. **Рабочее дерево:** чистое. `exports/` и `texput.log` игнорируются и в коммиты не входят.
@@ -57,8 +57,8 @@
**Legacy-активы** **Legacy-активы**
- Файл без записи в `article_assets` больше не публичен: - Файл без записи в `article_assets` больше не публичен:
аноним/студент/преподаватель → `404`; админ → доступ. аноним/студент/преподаватель → `404`; админ → доступ.
- Инвентаризация: `GET /api/v1/content/legacy-assets` (ADMIN) возвращает - Инвентаризация: `GET /api/v1/content/legacy-assets` (ADMIN), страница
список файлов без привязки. `/admin/legacy-assets` и `scripts/legacy-assets.sh` для работы со стендом.
- Старые прямые ссылки на бесхозные файлы у обычных пользователей ломаются - Старые прямые ссылки на бесхозные файлы у обычных пользователей ломаются
осознанно. осознанно.
@@ -66,17 +66,21 @@
- Access JWT: **15 минут** (`LMS_ACCESS_TOKEN_TTL`). - Access JWT: **15 минут** (`LMS_ACCESS_TOKEN_TTL`).
- Refresh-токен: **7 дней** (`LMS_REFRESH_TOKEN_TTL`), хранится как SHA-256 - Refresh-токен: **7 дней** (`LMS_REFRESH_TOKEN_TTL`), хранится как SHA-256
hash, ротируется при обновлении. hash, ротируется при обновлении.
- Абсолютный потолок сессии: **30 дней** (`LMS_SESSION_ABSOLUTE_TIMEOUT`);
ротация refresh не продлевает `absolute_expires_at` (миграция `V12`).
- Web-сессия: **7 дней sliding** (`LMS_SESSION_TIMEOUT`), cookie `LMS_SESSION`; - Web-сессия: **7 дней sliding** (`LMS_SESSION_TIMEOUT`), cookie `LMS_SESSION`;
каждый ответ перевыпускает cookie с новым `Max-Age`; access обновляется каждый ответ перевыпускает cookie с новым `Max-Age`, но не дольше остатка
автоматически за 2 минуты до истечения. абсолютного срока; access обновляется автоматически за 2 минуты до истечения.
- Logout и смена пароля отзывают refresh; отчисленный/архивный студент, - Logout и смена пароля отзывают refresh; отчисленный/архивный студент,
архивный аккаунт и закрытая группа не могут обновить сессию. архивный аккаунт и закрытая группа не могут обновить сессию.
- Повторное использование старого refresh отзывает все активные сессии - Повторное использование старого refresh отзывает все активные сессии
аккаунта. аккаунта.
- Истёкшие refresh-токены старше 30 дней удаляет ночная задача
(`LMS_REFRESH_TOKEN_RETENTION`, `LMS_REFRESH_TOKEN_CLEANUP_CRON`).
- Gateway публикует без access-JWT: `/auth/login`, `/auth/setup`, - Gateway публикует без access-JWT: `/auth/login`, `/auth/setup`,
`/auth/refresh`, `/auth/logout`, `/auth/health`, `/auth/jwks.json`, `/auth/refresh`, `/auth/logout`, `/auth/health`, `/auth/jwks.json`,
`/content/**`, `/actuator`. `/content/**`, `/actuator`.
- Миграция `identity V11__refresh_tokens.sql`. - Миграции `identity V11__refresh_tokens.sql` и `V12__refresh_absolute.sql`.
### 1.5. Аудит ### 1.5. Аудит
@@ -84,8 +88,10 @@
админский `GET /api/v1/audit`. админский `GET /api/v1/audit`.
- Пишутся доступы/изменения курсов, групп, материалов, лабораторных, оценки, - Пишутся доступы/изменения курсов, групп, материалов, лабораторных, оценки,
публикации, защиты, профиль (staff), скачивания, lifecycle, promotion. публикации, защиты, профиль (staff), скачивания, lifecycle, promotion.
- `/admin/audit`: фильтр по действию, страницы `page`/`limit` и «Показать ещё». - `/admin/audit`: фильтр по действию, keyset-курсор `(occurred_at, id)`,
- Пагинация пока offset, не keyset. «Показать ещё» и «В начало»; offset `page` остался для совместимости.
- `V13` добавляет индексы `(occurred_at DESC, id DESC)` и
`(action, occurred_at DESC, id DESC)`.
### 1.6. Материалы и активы ### 1.6. Материалы и активы
@@ -106,26 +112,30 @@
### 1.8. LDD и проверки ### 1.8. LDD и проверки
- Реестр: `docs/evidence/claims.json`. - Реестр: `docs/evidence/claims.json`.
- **46 claims**: 25 P0 + 21 P1; **46/46 proven, P0 gaps = 0**. - **51 claim**: 25 P0 + 26 P1; **51/51 proven, P0 gaps = 0**.
- **319 автотестов**, 0 failures/errors/skipped. - **346 автотестов**, 0 failures/errors/skipped.
- Mutation testing: - Mutation testing:
- `CourseAccessService` — 75% test strength; - `CourseAccessService` — 75% test strength;
- `PromotionPlanner` — 69%. - `PromotionPlanner` — 69%.
- Browser E2E Playwright: 4/4 (admin, teacher, student/anonymous, сдача + mobile). - Browser E2E Playwright: 6 сценариев × Chromium/Firefox/WebKit = **18/18**
(admin, teacher, student/anonymous, сдача + mobile, cursor-аудит,
инвентаризация активов).
- Runtime/load: 100 групп / 1000 студентов, 125 параллельных чтений, - Runtime/load: 100 групп / 1000 студентов, 125 параллельных чтений,
0 ошибок, p95 < 1 c. 0 ошибок, p95 < 1 c.
- CI: - CI:
- `check` на push/MR: тесты, ktlint, detekt, `evidenceCheck`; - `check` на push/MR: тесты, ktlint, detekt, `evidenceCheck`;
- `smoke` на push/MR: полный стек, health, вход админа, один курс; - `smoke` на push/MR: preflight + полный стек, health, вход админа, один курс;
- `release-evidence` обязателен для тегов, вручную для веток: тесты, - `release-evidence` обязателен для тегов, вручную для веток: тесты,
миграции, mutation, runtime/load, browser E2E, финальный evidence. миграции, mutation, runtime/load, browser E2E, финальный evidence.
- Полный `./scripts/ldd-release.sh` локально прошёл. - `scripts/ci-preflight.sh` проверяет Java 21, Docker Compose v2, Python 3,
Node и Playwright; первый реальный GitLab pipeline ещё не подтверждён.
- Полный `./scripts/ldd-release.sh` локально прошёл; browser E2E — 18/18.
### 1.9. Миграции ### 1.9. Миграции
| Сервис | Диапазон | Что важно | | Сервис | Диапазон | Что важно |
|---|---|---| |---|---|---|
| identity | `V4–V11` | роли, группы, статусы, promotion, история, аудит (`V10`), refresh (`V11`) | | identity | `V4–V13` | роли, группы, статусы, promotion, история, аудит (`V10`), refresh (`V11`), absolute (`V12`), keyset-индексы (`V13`) |
| academic | `V6–V12` | курсы, `labs.course_id`, главы/аудитории, индексы, `article_assets`, backfill | | academic | `V6–V12` | курсы, `labs.course_id`, главы/аудитории, индексы, `article_assets`, backfill |
| progress | `V13` | автор комментария/оценки; V13 идемпотентна | | progress | `V13` | автор комментария/оценки; V13 идемпотентна |
@@ -145,7 +155,8 @@
сроки сессии можно переопределить. сроки сессии можно переопределить.
- `docker-compose.prod.yml` прокидывает: - `docker-compose.prod.yml` прокидывает:
`LMS_ACCESS_TOKEN_TTL`, `LMS_REFRESH_TOKEN_TTL`, `LMS_SESSION_TIMEOUT`, `LMS_ACCESS_TOKEN_TTL`, `LMS_REFRESH_TOKEN_TTL`, `LMS_SESSION_TIMEOUT`,
`LMS_INTERNAL_TOKEN`. `LMS_SESSION_ABSOLUTE_TIMEOUT`, `LMS_REFRESH_TOKEN_RETENTION`,
`LMS_REFRESH_TOKEN_CLEANUP_CRON`, `LMS_INTERNAL_TOKEN`.
- Перед деплоем: backup, preflight, затем deploy, bootstrap курса C (если - Перед деплоем: backup, preflight, затем deploy, bootstrap курса C (если
курсов нет), postflight, smoke. курсов нет), postflight, smoke.
- Gateway не маршрутизирует `/api/v1/internal/**`. - Gateway не маршрутизирует `/api/v1/internal/**`.
@@ -158,7 +169,7 @@
# 1. Полный локальный gate # 1. Полный локальный gate
./gradlew test ktlintCheck detekt evidenceCheck ./gradlew test ktlintCheck detekt evidenceCheck
# 2. Полный LDD release (долго, нужны Docker/Java/Node/Chromium) # 2. Полный LDD release (долго, нужны Docker/Java/Node + Chromium/Firefox/WebKit)
./scripts/ldd-release.sh ./scripts/ldd-release.sh
# 3. Быстрый smoke (то же, что CI smoke) # 3. Быстрый smoke (то же, что CI smoke)
@@ -175,10 +186,11 @@ cat build/reports/evidence/index.md
``` ```
Последний подтверждённый результат: Последний подтверждённый результат:
- `319` тестов, 0 failures; - `346` тестов, 0 failures;
- `46/46` claims; - `51/51` claims;
- `ldd-release.sh` — PASS; - `ldd-release.sh` — PASS;
- smoke — PASS; - smoke — PASS;
- browser E2E — 18/18 (Chromium/Firefox/WebKit);
- `git diff --check` — чисто. - `git diff --check` — чисто.
Артефакты: `build/evidence/{runtime,migration,mutation,browser,smoke}.json`. Артефакты: `build/evidence/{runtime,migration,mutation,browser,smoke}.json`.
@@ -187,51 +199,33 @@ cat build/reports/evidence/index.md
## 3. Что осталось внести / закрыть ## 3. Что осталось внести / закрыть
Это уже не текущие P0-риски, а следующий backlog. Порядок recommended. Это уже не P0-риски, а эксплуатационный backlog. Порядок recommended.
1. **Инвентаризация legacy-активов на реальном хранилище.** 1. **Инвентаризация legacy-активов на реальном хранилище.**
Endpoint `/api/v1/content/legacy-assets` готов. Нужно на production/staging Страница `/admin/legacy-assets`, API и `scripts/legacy-assets.sh` готовы.
получить список бесхозных файлов, привязать их к статьям или удалить; Нужно на production/staging получить список бесхозных файлов, привязать их
после этого можно решить, удалять ли админский fallback. к статьям или удалить. Старые внешние ссылки на такие файлы уже не работают
Старые внешние ссылки на такие файлы уже не работают у обычных пользователей. у обычных пользователей.
2. **Keyset-пагинация аудита.** 2. **Первый реальный CI-прогон.**
Сейчас `page`/`limit` offset. Для очень больших журналов возможны сдвиги Конфиг, smoke и `scripts/ci-preflight.sh` готовы, но нужно убедиться, что в
при вставках между страницами. Нужен курсор `(occurred_at, id)`. GitLab есть shell-runners с тегами `lms-shell` (smoke) и `lms-release`
(полный release), Java 21, Docker Compose v2, Python 3, Node и браузерами
Chromium/Firefox/WebKit. `check` использует privileged `docker:dind`.
Дождаться зелёного push-MR pipeline и первого tag-job `release-evidence`.
3. **Оставшиеся N+1.** 3. **Визуальная регрессия и мобильный набор E2E.**
- `AcademicClient.getCourseLabs` подтягивает детали лабораторных по одной; Сейчас 6 сценариев × 3 браузера, без screenshot-diff. Добавлять по мере
- часть данных ведомости/профиля на больших курсах собирается по частям. роста UI.
Имена групп курса уже bulk; аудит paginated.
Нужны пакетные эндпоинты или join-выборки, затем повторный load-тест.
4. **Абсолютный лимит web-сессии.** 4. **Пакетные сдачи в ведомости.**
Сессия sliding 7 дней и отзывается, но при непрерывной активности может Детали лабораторных курса уже пакетные, но ведомость запрашивает сдачи по
жить бессрочно. Если политика требует повторный вход раз в N дней — выбранной лабораторной отдельно. Оптимизировать, если появятся медленные
добавить абсолютный `max-age`/`absolute timeout`. курсы.
5. **Очистка refresh_tokens.** «Контингент»/`next.uniyar.ac.ru` **исключён из плана** решением от 2026-10-28:
Истёкшие записи сейчас не удаляются. Добавить scheduled cleanup нет API-спецификации и доступа к внешней системе. При появлении нового ТЗ
(например, раз в сутки) и метрику/аудит. вернуться к отдельному anti-corruption слою.
6. **CI на реальном runner.**
Конфиг готов, но нужно убедиться, что в GitLab есть shell-runners с тегами
`lms-shell` (smoke) и `lms-release` (полный release), Java 21,
Docker Compose v2, Python 3, Node/Chromium. `check` использует
privileged `docker:dind` для Testcontainers.
Проверить первый pipeline на push и первый tag-job.
7. **Browser E2E: расширение.**
Сейчас 4 сценария, один Chromium. Нет Safari/Firefox, визуальной регрессии
и полного мобильного набора. Добавлять по мере роста UI.
8. **Внешняя интеграция «Контингент»/next.uniyar.ac.ru.**
Отдельный anti-corruption слой, идемпотентная синхронизация, журнал,
отчёт дрифта, внешние идентификаторы. В текущий объём не входила.
9. **Release-evidence job на теге.**
В `.gitlab-ci.yml` он обязателен для тегов и ручной для веток, но реальный
прогон в CI ещё не подтверждён — см. п. 6.
--- ---
@@ -240,15 +234,25 @@ cat build/reports/evidence/index.md
- Не возвращать публичный доступ к legacy-активам. - Не возвращать публичный доступ к legacy-активам.
- Не добавлять `number_label`/строковый номер группы: по требованиям номер - Не добавлять `number_label`/строковый номер группы: по требованиям номер
числовой, ведущего нуля быть не может. числовой, ведущего нуля быть не может.
- Не делать access JWT снова 30-дневным. 30 дней — только refresh/web session. - Не делать access JWT снова 30-дневным. 30 дней — только абсолютный потолок
сессии.
- Gateway обязан пропускать `/api/v1/auth/refresh` и `/api/v1/auth/logout` - Gateway обязан пропускать `/api/v1/auth/refresh` и `/api/v1/auth/logout`
без access-JWT; иначе автообновление и logout ломаются. без access-JWT; иначе автообновление и logout ломаются.
- `LMS_ACCESS_TOKEN_TTL`, `LMS_REFRESH_TOKEN_TTL`, `LMS_SESSION_TIMEOUT` - `LMS_ACCESS_TOKEN_TTL`, `LMS_REFRESH_TOKEN_TTL`, `LMS_SESSION_TIMEOUT`,
должны попадать в environment соответствующих контейнеров; сейчас `LMS_SESSION_ABSOLUTE_TIMEOUT`, `LMS_REFRESH_TOKEN_RETENTION` и
`docker-compose.prod.yml` это делает. `LMS_REFRESH_TOKEN_CLEANUP_CRON` должны попадать в environment
- Session cookie `LMS_SESSION` перевыпускается фильтром на каждом ответе; соответствующих контейнеров; сейчас `docker-compose.prod.yml` это делает.
не убирать его, иначе 7-дневная sliding-сессия станет абсолютной. - Session cookie `LMS_SESSION` перевыпускается фильтром на каждом ответе, но
`Max-Age` ограничен остатком абсолютного срока; не убирать ни фильтр, ни
проверку `AUTHENTICATED_AT`, иначе 30-дневный потолок перестанет работать.
- Keyset-аудит использует `(occurred_at, id)`; не менять сортировку на одну
`id` или `occurred_at` без пересчёта курсора. Offset `page` — только
совместимость.
- `article_assets` без связи → 404 для всех, кроме админа. Это намеренно. - `article_assets` без связи → 404 для всех, кроме админа. Это намеренно.
- `/api/v1/labs/details` — пакетный контракт progress; не заменять его обратно
на цикл `/labs/{id}` без load-теста.
- WebKit на Fedora требует Docker-образа Playwright; `ldd-browser.sh` сам
переключается, не ломать этот fallback.
- Не коммитить `exports/`, `texput.log`, `data/`, `backups/`, `.env.prod`. - Не коммитить `exports/`, `texput.log`, `data/`, `backups/`, `.env.prod`.
- Не включать Flyway `out-of-order`; progress V10 не существует. - Не включать Flyway `out-of-order`; progress V10 не существует.
- `labs.course_id NOT NULL` применяется bootstrap-сервисом в runtime. - `labs.course_id NOT NULL` применяется bootstrap-сервисом в runtime.
@@ -264,12 +268,12 @@ cat build/reports/evidence/index.md
`feature/groups-courses`. `feature/groups-courses`.
2. Прочитать этот файл, затем `docs/16-stage6-report.md` §7 и 2. Прочитать этот файл, затем `docs/16-stage6-report.md` §7 и
`docs/evidence/baseline-2026-10-27.md`. `docs/evidence/baseline-2026-10-27.md`.
3. `./gradlew test ktlintCheck detekt evidenceCheck` — должно быть 319 тестов 3. `./gradlew test ktlintCheck detekt evidenceCheck` — должно быть 346 тестов
и 46/46 claims. и 51/51 claims.
4. Для release-проверки: `./scripts/ldd-release.sh`. 4. Для release-проверки: `./scripts/ldd-release.sh`.
5. Для быстрой проверки: `./scripts/ldd-smoke.sh`. 5. Для быстрой проверки: `./scripts/ldd-smoke.sh` и `./scripts/ci-preflight.sh`.
6. Продолжать backlog из §3, начиная с инвентаризации legacy-активов 6. Продолжать backlog из §3, начиная с инвентаризации legacy-активов и
и keyset-пагинации. первого реального CI-прогона.
## 6. Карта ключевых файлов ## 6. Карта ключевых файлов
@@ -278,14 +282,19 @@ cat build/reports/evidence/index.md
- `docs/16-stage6-report.md` — итоговый отчёт, deploy и ограничения. - `docs/16-stage6-report.md` — итоговый отчёт, deploy и ограничения.
- `docs/evidence/claims.json` — LDD-реестр. - `docs/evidence/claims.json` — LDD-реестр.
- `docs/evidence/baseline-2026-10-27.md` — фактический baseline. - `docs/evidence/baseline-2026-10-27.md` — фактический baseline.
- `services/identity-service/.../auth/refresh/` — refresh-токены. - `services/identity-service/.../auth/refresh/` — refresh-токены, absolute,
- `services/identity-service/.../db/migration/V11__refresh_tokens.sql`. cleanup.
- `services/identity-service/.../audit/` — аудит. - `services/identity-service/.../db/migration/V11__refresh_tokens.sql`,
`V12__refresh_absolute.sql`, `V13__audit_keyset_indexes.sql`.
- `services/identity-service/.../audit/` — аудит и keyset-курсор.
- `services/academic-service/.../content/` — материалы/активы. - `services/academic-service/.../content/` — материалы/активы.
- `services/academic-service/.../course/CourseGroupService.kt` — bulk-группы. - `services/academic-service/.../course/CourseGroupService.kt` — bulk-группы.
- `services/academic-service/.../lab/LabService.kt` — `details(courseId)`.
- `services/progress-service/.../AcademicClient.kt` — пакетный вызов лаб.
- `services/progress-service/.../CourseScopeService.kt` — course-scope. - `services/progress-service/.../CourseScopeService.kt` — course-scope.
- `web/.../session/SessionTokenService.kt`, `SessionCookieRefreshFilter.kt`. - `web/.../session/SessionTokenService.kt`, `SessionLifetime.kt`,
- `web/.../admin/AdminAuditController.kt`. `SessionCookieRefreshFilter.kt`.
- `web/.../admin/AdminAuditController.kt`, `LegacyAssetsController.kt`.
- `services/gateway/.../JwtAuthenticationFilter.kt`. - `services/gateway/.../JwtAuthenticationFilter.kt`.
- `scripts/ldd-*.sh`. - `scripts/ldd-*.sh`, `scripts/ci-preflight.sh`, `scripts/legacy-assets.sh`.
- `deploy/backup.sh`, `deploy/deploy.sh`, `deploy/DEPLOY.md`. - `deploy/backup.sh`, `deploy/deploy.sh`, `deploy/DEPLOY.md`.

View File

@@ -14,7 +14,7 @@ Preview Mermaid Support) и в IntelliJ IDEA (плагин Mermaid).
| [04-sequences.md](04-sequences.md) | Диаграммы последовательностей: вход, импорт ЛР, раздача вариантов, сдача, защита, оценки, материалы | | [04-sequences.md](04-sequences.md) | Диаграммы последовательностей: вход, импорт ЛР, раздача вариантов, сдача, защита, оценки, материалы |
| [05-states.md](05-states.md) | Диаграммы состояний: защита, публикация оценок, статус ЛР, публикация материалов | | [05-states.md](05-states.md) | Диаграммы состояний: защита, публикация оценок, статус ЛР, публикация материалов |
| [06-authoring.md](06-authoring.md) | Работа с учебными материалами: структура, markdown-редактор, импорт zip | | [06-authoring.md](06-authoring.md) | Работа с учебными материалами: структура, markdown-редактор, импорт zip |
| [07-groups-courses.md](07-groups-courses.md) | ТЗ: группы/потоки, курсы и преподаватели, повышение курса, академический отпуск, интеграция с «Контингент» | | [07-groups-courses.md](07-groups-courses.md) | ТЗ: группы/потоки, курсы и преподаватели, повышение курса, академический отпуск; «Контингент» исключён из плана |
| [08-groups-courses-plan.md](08-groups-courses-plan.md) | План реализации ТЗ 07: решения, этапы 0–6, профиль студента курса, агенты и skills | | [08-groups-courses-plan.md](08-groups-courses-plan.md) | План реализации ТЗ 07: решения, этапы 0–6, профиль студента курса, агенты и skills |
| [09-teacher-student-profile.md](09-teacher-student-profile.md) | Контракт API профиля студента курса для преподавателя | | [09-teacher-student-profile.md](09-teacher-student-profile.md) | Контракт API профиля студента курса для преподавателя |
| [10-stage0-report.md](10-stage0-report.md) | Отчёт этапа 0: миграции, preflight и проверка на копии БД | | [10-stage0-report.md](10-stage0-report.md) | Отчёт этапа 0: миграции, preflight и проверка на копии БД |
@@ -26,7 +26,7 @@ Preview Mermaid Support) и в IntelliJ IDEA (плагин Mermaid).
| [16-stage6-report.md](16-stage6-report.md) | Отчёт этапа 6: права преподавателя на запись, аудит, CI, upgrade-тест миграций, security-матрица, чек-лист деплоя и остаточные ограничения | | [16-stage6-report.md](16-stage6-report.md) | Отчёт этапа 6: права преподавателя на запись, аудит, CI, upgrade-тест миграций, security-матрица, чек-лист деплоя и остаточные ограничения |
| [17-handoff.md](17-handoff.md) | Handoff: что уже внесено, что осталось, как восстановить контекст и прогнать проверки | | [17-handoff.md](17-handoff.md) | Handoff: что уже внесено, что осталось, как восстановить контекст и прогнать проверки |
| [evidence/README.md](evidence/README.md) | LDD: реестр утверждений, evidence gate, runtime- и migration-проверки | | [evidence/README.md](evidence/README.md) | LDD: реестр утверждений, evidence gate, runtime- и migration-проверки |
| [evidence/baseline-2026-10-27.md](evidence/baseline-2026-10-27.md) | LDD-baseline: 46 claims, 319 тестов, upgrade, runtime/scale, mutation и browser E2E | | [evidence/baseline-2026-10-27.md](evidence/baseline-2026-10-27.md) | LDD-baseline: 51 claims, 346 тестов, upgrade, runtime/scale, mutation и browser E2E |
| [ADR 0001](adr/0001-groups-courses-foundation.md) | Ключевые архитектурные решения: курс C, миграции, доступ, статусы, повышение | | [ADR 0001](adr/0001-groups-courses-foundation.md) | Ключевые архитектурные решения: курс C, миграции, доступ, статусы, повышение |
## Кратко о системе ## Кратко о системе

View File

@@ -92,8 +92,9 @@
- `ACADEMIC_LEAVE` — вход в режиме только чтения. - `ACADEMIC_LEAVE` — вход в режиме только чтения.
- Web перепроверяет статус с кэшем 30–60 секунд; сервисы проверяют статус - Web перепроверяет статус с кэшем 30–60 секунд; сервисы проверяют статус
студента и группы при доступе к курсу. студента и группы при доступе к курсу.
- JWT остаётся 30-дневным; остаточный риск прямого API фиксируется в - ~~JWT остаётся 30-дневным~~ — решение пересмотрено: access 15 минут,
документации. Для браузера токен не доступен. refresh 7 дней с ротацией и абсолютным потолком 30 дней (`V11`–`V12`).
Для браузера токен не доступен.
### 6. Повышение курса ### 6. Повышение курса

View File

@@ -52,14 +52,14 @@
# 6. Mutation testing критичных P0-компонентов # 6. Mutation testing критичных P0-компонентов
./scripts/ldd-mutation.sh ./scripts/ldd-mutation.sh
# 7. Браузерный E2E (Playwright + Chromium) # 7. Браузерный E2E (Playwright: Chromium/Firefox/WebKit)
./scripts/ldd-browser.sh ./scripts/ldd-browser.sh
# 8. Полный release gate # 8. Полный release gate
./scripts/ldd-release.sh ./scripts/ldd-release.sh
``` ```
Для E2E один раз установите зависимости и браузер: `cd e2e && npm install && npx playwright install chromium`. Для E2E один раз установите зависимости и браузеры: `cd e2e && npm install && npx playwright install chromium firefox webkit`.
Mutation harness использует JUnit 4: JUnit5-плагин PIT несовместим с JUnit Mutation harness использует JUnit 4: JUnit5-плагин PIT несовместим с JUnit
Platform 6 из Spring Boot 4. Platform 6 из Spring Boot 4.
@@ -87,15 +87,17 @@ Platform 6 из Spring Boot 4.
LDD-циклы выполнены 2026-10-27/28; последний — после закрытия продуктовых LDD-циклы выполнены 2026-10-27/28; последний — после закрытия продуктовых
рисков: рисков:
- **319 автоматических тестов**, 0 failures/errors; - **346 автоматических тестов**, 0 failures/errors;
- **46 claims**: 25 P0 + 21 P1, **46/46 proven, P0 gaps = 0**. P0 — доступы, - **51 claims**: 25 P0 + 26 P1, **51/51 proven, P0 gaps = 0**. P0 — доступы,
повышение, жизненный цикл, профиль/скачивание, материалы/активы повышение, жизненный цикл, профиль/скачивание, материалы/активы
(включая secure-by-default legacy-файлы), миграция V13, аудит, refresh-токены (включая secure-by-default legacy-файлы), миграции V11–V13, аудит,
и их отзыв; P1 — группы, авторство, UI, контракты, импорт, копирование лаб, refresh-токены и их отзыв; P1 — группы, авторство, UI, контракты, импорт,
пагинация аудита, bulk-группы курса, runtime, mutation и browser E2E; копирование лаб, keyset-пагинация, пакетные детали лабораторных,
абсолютный потолок сессии, runtime, mutation и browser E2E;
- mutation testing: `CourseAccessService` 75% test strength, - mutation testing: `CourseAccessService` 75% test strength,
`PromotionPlanner` 69%; `PromotionPlanner` 69%;
- Playwright: 4 сценария (admin, teacher, student/anonymous, сдача+mobile), - Playwright: 6 сценариев (admin, teacher, student/anonymous, сдача+mobile,
cursor-аудит, инвентаризация активов) × Chromium/Firefox/WebKit = **18/18**,
все зелёные; все зелёные;
- масштабный load: 100 групп / 1000 студентов, 125 параллельных чтений, - масштабный load: 100 групп / 1000 студентов, 125 параллельных чтений,
0 ошибок, p95 < 1 с (порог p95 < 2 с); 0 ошибок, p95 < 1 с (порог p95 < 2 с);
@@ -107,7 +109,9 @@ LDD-циклы выполнены 2026-10-27/28; последний — посл
Kotlin-модуль, поэтому DTO с default-значениями требовали все поля JSON Kotlin-модуль, поэтому DTO с default-значениями требовали все поля JSON
(например, создание студента без `firstName`/`basis` давало 400). (например, создание студента без `firstName`/`basis` давало 400).
Остаточные ограничения: offset-пагинация аудита, N+1 в отдельных лабораторных Остаточные ограничения: ручная инвентаризация legacy-активов на production,
списках/ведомости, отсутствие абсолютного лимита web-сессии. Legacy-активы, сдачи по выбранной лабораторной в ведомости, непроверенный первый CI-прогон на
срок жизни JWT и «ведущие нули» больше не являются открытыми рисками. реальных runner'ах и отсутствие визуальной регрессии. Legacy-активы, срок
access-токена, пагинация, N+1 деталей лабораторных, абсолютный лимит сессии и
«ведущие нули» больше не являются открытыми рисками.
Результаты — в `baseline-2026-10-27.md`. Результаты — в `baseline-2026-10-27.md`.

View File

@@ -7,13 +7,13 @@ Playwright. В ходе проверки найден и исправлен ре
## 1. Что проверялось ## 1. Что проверялось
Реестр: `docs/evidence/claims.json`, **44 утверждения**. Реестр: `docs/evidence/claims.json`, **51 утверждение**.
| Критичность | Claims | Proven | Gaps | | Критичность | Claims | Proven | Gaps |
|---|---:|---:|---:| |---|---:|---:|---:|
| P0 | 25 | 25 | 0 | | P0 | 25 | 25 | 0 |
| P1 | 21 | 21 | 0 | | P1 | 26 | 26 | 0 |
| **Итого** | **46** | **46** | **0** | | **Итого** | **51** | **51** | **0** |
Домены: Домены:
@@ -23,12 +23,13 @@ Playwright. В ходе проверки найден и исправлен ре
- жизненный цикл: академ, возврат, отчисление, архив, восстановление, вход; - жизненный цикл: академ, возврат, отчисление, архив, восстановление, вход;
- профиль студента и скачивание сдач; - профиль студента и скачивание сдач;
- материалы и активы, включая secure-by-default legacy-файлы; - материалы и активы, включая secure-by-default legacy-файлы;
- миграции V11 и V13, полный upgrade с master-схемы; - миграции V11–V13, полный upgrade с master-схемы;
- аудит: токен, admin-only, сохранность после удаления, representative-хуки, - аудит: токен, admin-only, сохранность после удаления, representative-хуки,
пагинация; keyset-пагинация;
- аутентификация: короткий access-токен, ротация/отзыв refresh, web-сессия; - аутентификация: короткий access-токен, ротация/отзыв refresh, web-сессия,
- группы курса без N+1, авторство материалов, ведомость, UI/контракты, импорт, абсолютный потолок 30 дней и очистка истёкших токенов;
копирование лабораторных; - группы курса и детали лабораторных без N+1, авторство материалов, ведомость,
UI/контракты, импорт, копирование лабораторных;
- runtime-smoke, масштабный load, mutation testing, браузерный E2E и CI smoke. - runtime-smoke, масштабный load, mutation testing, браузерный E2E и CI smoke.
## 2. Команды и результаты ## 2. Команды и результаты
@@ -39,7 +40,7 @@ Playwright. В ходе проверки найден и исправлен ре
./gradlew test --rerun-tasks ./gradlew test --rerun-tasks
``` ```
Результат: **319 тестов, 0 failures, 0 errors, 0 skipped**. Результат: **346 тестов, 0 failures, 0 errors, 0 skipped**.
`ktlintCheck` и `detekt` — зелёные. `ktlintCheck` и `detekt` — зелёные.
### 2.2. LDD gate ### 2.2. LDD gate
@@ -48,7 +49,7 @@ Playwright. В ходе проверки найден и исправлен ре
./gradlew evidenceCheck ./gradlew evidenceCheck
``` ```
Результат: **46/46 claims proven, P0 gaps = 0**. Результат: **51/51 claims proven, P0 gaps = 0**.
Отчёт: `build/reports/evidence/index.md`. Отчёт: `build/reports/evidence/index.md`.
Gate устроен так: Gate устроен так:
@@ -67,11 +68,13 @@ Gate устроен так:
Сценарий: одноразовая PostgreSQL 18, master identity `V1–V3`, academic Сценарий: одноразовая PostgreSQL 18, master identity `V1–V3`, academic
`V1–V5`, progress `V1–V9,V11,V12`, синтетические данные, затем ветка: `V1–V5`, progress `V1–V9,V11,V12`, синтетические данные, затем ветка:
identity `V4–V11`, academic `V6–V12`, progress `V13`. identity `V4–V13`, academic `V6–V12`, progress `V13`.
Проверено: Проверено:
- `identity.audit_events` (`V10`) и `identity.refresh_tokens` (`V11`) созданы; - `identity.audit_events` (`V10`), `identity.refresh_tokens` (`V11`),
`absolute_expires_at` (`V12`) и keyset-индекс `audit_events_occurred_id_idx`
(`V13`) созданы;
- `students`, `groups`, `membership_history` сохранились; - `students`, `groups`, `membership_history` сохранились;
- `labs.course_id`, `chapters.course_id`, `article_assets` созданы; - `labs.course_id`, `chapters.course_id`, `article_assets` созданы;
- `grade_comments.author_account_id`, `lab_grades.updated_by_account_id` созданы; - `grade_comments.author_account_id`, `lab_grades.updated_by_account_id` созданы;
@@ -117,7 +120,8 @@ identity `V4–V11`, academic `V6–V12`, progress `V13`.
преподавателю (существование не раскрывается); преподавателю (существование не раскрывается);
- админ может скачать такой файл; - админ может скачать такой файл;
- `GET /api/v1/content/legacy-assets` защищён `ROLE_ADMIN` и перечисляет - `GET /api/v1/content/legacy-assets` защищён `ROLE_ADMIN` и перечисляет
несвязанные файлы для привязки или удаления; несвязанные файлы; админская страница `/admin/legacy-assets` и
`scripts/legacy-assets.sh` дают тот же список для привязки или удаления;
- связанный актив по-прежнему наследует видимость статьи → раздела → главы: - связанный актив по-прежнему наследует видимость статьи → раздела → главы:
публичная `ALL` — без входа, курсовая — только участникам курса, скрытая публичная `ALL` — без входа, курсовая — только участникам курса, скрытая
для студента — `404`. для студента — `404`.
@@ -138,30 +142,44 @@ identity `V4–V11`, academic `V6–V12`, progress `V13`.
токена отзывает все сессии аккаунта; токена отзывает все сессии аккаунта;
- logout и смена пароля отзывают refresh; отчисленный, выпущенный, архивный - logout и смена пароля отзывают refresh; отчисленный, выпущенный, архивный
студент, архивный аккаунт и закрытая группа не могут обновить сессию; студент, архивный аккаунт и закрытая группа не могут обновить сессию;
- web-сессия — 7 дней sliding (`LMS_SESSION_TIMEOUT`), cookie `LMS_SESSION` - `V12__refresh_absolute.sql` задаёт `absolute_expires_at`; ротация не
(`HttpOnly`, `SameSite=Lax`, `Max-Age` перевыпускается на каждом ответе); продлевает абсолютный срок, после 30 дней refresh отклоняется;
- web-сессия — 7 дней sliding (`LMS_SESSION_TIMEOUT`), но не дольше 30 дней
(`LMS_SESSION_ABSOLUTE_TIMEOUT`); cookie `LMS_SESSION`
(`HttpOnly`, `SameSite=Lax`, `Max-Age` перевыпускается на каждом ответе и
ограничен остатком абсолютного срока);
- access обновляется автоматически за 2 минуты до истечения, поэтому активная - access обновляется автоматически за 2 минуты до истечения, поэтому активная
вкладка не требует входа каждые 15 минут; logout отзывает refresh. вкладка не требует входа каждые 15 минут; logout отзывает refresh;
- истёкшие refresh-токены старше retention (30 дней) удаляет ночная задача
(`LMS_REFRESH_TOKEN_CLEANUP_CRON`), свежие отозванные токены сохраняются для
detection reuse.
Прямой API-клиент должен сам использовать `POST /api/v1/auth/refresh`; Прямой API-клиент должен сам использовать `POST /api/v1/auth/refresh`;
автоматического обновления вне web BFF нет. автоматического обновления вне web BFF нет.
Результат: **PASS**, claims `LDD-AUTH-001`, `LDD-AUTH-002`, `LDD-AUTH-003`. Результат: **PASS**, claims `LDD-AUTH-001`, `LDD-AUTH-002`, `LDD-AUTH-003`,
`LDD-AUTH-006`, `LDD-AUTH-007`.
### 2.7. Пагинация, N+1 и масштаб ### 2.7. Пагинация, N+1 и масштаб
- `GET /api/v1/audit` принимает `page` и `limit` (максимум 500); в UI есть - `GET /api/v1/audit` принимает keyset-курсор `cursor` (opaque `occurredAt|id`),
«Показать ещё». Это offset-пагинация, а не keyset. `limit` (максимум 500) и `action`; offset `page` остался для совместимости.
Курсорные страницы не пересекаются, битый курсор — `400`; в UI «Показать ещё»
идёт по курсору, «В начало» возвращает к первой странице.
- `GET /api/v1/labs/details` отдаёт все лабораторные курса с уровнями и
вариантами одним запросом; progress делает ровно один HTTP-вызов вместо
запроса на каждую лабораторную.
- `GET /api/v1/courses/{id}/groups` возвращает привязанные группы с именами - `GET /api/v1/courses/{id}/groups` возвращает привязанные группы с именами
одним вызовом; страница преподавателя не делает N+1 по именам групп. одним вызовом; страница преподавателя не делает N+1 по именам групп.
- Полный load использует seed на **100 групп / 1000 студентов** (SQL, только - Полный load использует seed на **100 групп / 1000 студентов** (SQL, только
release-контур) и **125 параллельных чтений** курса, лаб, профиля, групп и release-контур) и **125 параллельных чтений** курса, лаб, профиля, групп и
аудита: **0 ошибок, p95 < 1 с** (порог p95 < 2 с). аудита: **0 ошибок, p95 < 1 с** (порог p95 < 2 с).
Остаются offset-пагинация аудита и N+1 в отдельных лабораторных Остаётся запрос сдач по выбранной лабораторной в ведомости — это не N+1 по
списках/ведомости — это лабораторные детали, не продуктовые риски доступа. студентам и не продуктовый риск доступа.
Результат: **PASS**, claims `LDD-AUDIT-004`, `LDD-PERF-001`, `LDD-RUNTIME-003`. Результат: **PASS**, claims `LDD-AUDIT-004`, `LDD-AUDIT-005`, `LDD-PERF-001`,
`LDD-PERF-002`, `LDD-RUNTIME-003`.
### 2.8. Mutation testing ### 2.8. Mutation testing
@@ -187,16 +205,20 @@ PIT запускается через CLI с JUnit 4 harness: JUnit5-плаги
./scripts/ldd-browser.sh ./scripts/ldd-browser.sh
``` ```
Playwright/Chromium на полном стеке, одноразовая БД: Playwright в Chromium, Firefox и WebKit на полном стеке, одноразовая БД:
- admin: вход, `/admin/courses` с курсом, `/admin/audit` с событием; - admin: вход, `/admin/courses` с курсом, `/admin/audit` с событием;
- teacher: вход, курс, ведомость (группа/студент, без админских кнопок), - teacher: вход, курс, ведомость (группа/студент, без админских кнопок),
профиль студента, создание главы курса; профиль студента, создание главы курса;
- anonymous: `/admin/audit` → `/login`; - anonymous: `/admin/audit` → `/login`;
- student: вход, `/admin/audit` → `/student`, `/learn/course/{no}`. - student: вход, `/admin/audit` → `/student`, `/learn/course/{no}`;
- admin: cursor-пагинация аудита («Показать ещё» → «Продолжение» → «В начало»);
- admin: страница `/admin/legacy-assets` со списком или пустым состоянием.
Результат: **4/4 passed**, evidence: `build/evidence/browser.json`. Результат: **18/18 passed** (6 сценариев × 3 браузера), evidence:
Дополнительно проверены сдача файла студентом и mobile-размер teacher-страницы. `build/evidence/browser.json`. Дополнительно проверены сдача файла студентом и
mobile-размер teacher-страницы. На Fedora WebKit запускается через Docker-образ
Playwright, потому что системных Ubuntu-библиотек нет.
### 2.10. Конкурентные apply/rollback ### 2.10. Конкурентные apply/rollback
@@ -215,8 +237,12 @@ mutation, runtime + scale и browser E2E, затем финальный `evidenc
- `check` — `test ktlintCheck detekt evidenceCheck` на push/MR; - `check` — `test ktlintCheck detekt evidenceCheck` на push/MR;
- `smoke` — `ldd-smoke.sh` (полный стек, health, вход админа, один курс) - `smoke` — `ldd-smoke.sh` (полный стек, health, вход админа, один курс)
на каждом push/MR; на каждом push/MR;
- `release-evidence` — полный `ldd-release.sh` с Chromium/Node: обязателен для - `release-evidence` — полный `ldd-release.sh` с Chromium/Firefox/WebKit и
тегов, для веток запускается вручную. Node: обязателен для тегов, для веток запускается вручную.
- smoke/release начинаются с `scripts/ci-preflight.sh`, который проверяет
Java 21, Docker Compose v2, Python 3, Node и Playwright.
- Первый реальный прогон на GitLab shell-runners ещё не подтверждён; локальные
скрипты и конфигурация проверены.
### 2.12. Найденный дефект ### 2.12. Найденный дефект
@@ -240,9 +266,12 @@ optional json fields`.
- Legacy-актив без привязки не утекает обычным пользователям, а админ может - Legacy-актив без привязки не утекает обычным пользователям, а админ может
провести инвентаризацию. провести инвентаризацию.
- Refresh-токены ротируются и отзываются; закрытый статус блокирует продление - Refresh-токены ротируются и отзываются; закрытый статус блокирует продление
сессии, а web-сессия переживает 15-минутный access-токен. сессии, абсолютный потолок 30 дней ограничивает сессию, а web-сессия
- Аудит листается по страницам, а группы курса отдаются одним вызовом без N+1. переживает 15-минутный access-токен.
- Аудит пишется и читается; upgrade не теряет синтетические данные. - Аудит листается keyset-курсором, детали лабораторных и группы курса
отдаются пакетно без N+1.
- Аудит пишется и читается; upgrade не теряет синтетические данные;
истёкшие refresh-токены очищаются.
- Мутационная прочность критичных компонентов измерена, а не заявлена. - Мутационная прочность критичных компонентов измерена, а не заявлена.
- Масштабный load на 100 группах / 1000 студентов проходит без ошибок. - Масштабный load на 100 группах / 1000 студентов проходит без ошибок.
- Реальный дефект DTO/Jackson найден именно E2E-контуром и закрыт тестом. - Реальный дефект DTO/Jackson найден именно E2E-контуром и закрыт тестом.
@@ -254,35 +283,36 @@ optional json fields`.
2. **Property-based тесты покрывают не все ветки.** Есть генераторы для 2. **Property-based тесты покрывают не все ветки.** Есть генераторы для
матрицы доступа, планировщика и 10 транзакционных apply/rollback-кейсов, матрицы доступа, планировщика и 10 транзакционных apply/rollback-кейсов,
но не для всех сочетаний полей и распределённой нагрузки. но не для всех сочетаний полей и распределённой нагрузки.
3. **Браузерный E2E — один Chromium и 4 сценария.** Нет визуальной регрессии, 3. **Браузерный E2E — 3 браузера, 6 сценариев.** Нет визуальной регрессии и
Safari/Firefox и полного мобильного набора страниц. полного мобильного набора страниц; WebKit на Fedora требует Docker-образа.
4. **Нагрузочный smoke — не benchmark.** Одна машина, синтетические данные, 4. **Нагрузочный smoke — не benchmark.** Одна машина, синтетические данные,
без SLA production; 125 чтений и p95 < 1 с — ориентир, а не гарантия. без SLA production; 125 чтений и p95 < 1 с — ориентир, а не гарантия.
5. **Offset-пагинация аудита.** Вставки между страницами могут сдвигать 5. **Сдачи по выбранной лабораторной в ведомости** запрашиваются отдельно;
выдачу; keyset-курсор не реализован. это не N+1 по студентам, но и не пакетная выборка.
6. **N+1 в отдельных лабораторных списках/ведомости.** Имена групп курса и 6. **Инвентаризация legacy-активов на production не выполнена.** UI, API и
аудит исправлены, но не все большие списки собраны пакетно. скрипт готовы, но реальные файлы нужно привязать или удалить вручную.
7. **Нет абсолютного лимита web-сессии.** Sliding-сессия может продлеваться 7. **Первый CI-прогон не подтверждён.** Конфигурация и preflight готовы, но
бесконечно при непрерывной активности; отзыв по статусу при этом работает. наличие shell-runners `lms-shell`/`lms-release` проверяется в GitLab.
8. **Полный release-evidence не на каждом коммите.** На push/MR идёт smoke, 8. **Полный release-evidence не на каждом коммите.** На push/MR идёт smoke,
а mutation/browser/scale — для тегов или вручную. а mutation/browser/scale — для тегов или вручную.
9. **Внешняя интеграция «Контингент» не проверялась** — она остаётся заделом. 9. **Интеграция «Контингент» исключена из плана** — без нового ТЗ и доступа к
API она не проверялась и не реализуется.
10. **Номер группы числовой.** Отдельного требования к ведущим нулям нет; 10. **Номер группы числовой.** Отдельного требования к ведущим нулям нет;
`01` и `1` — одно значение, это не открытый риск. `01` и `1` — одно значение, это не открытый риск.
## 5. Вывод ## 5. Вывод
Текущая функциональность прошла LDD-контур: **46/46 claims proven, P0 gaps = 0**, Текущая функциональность прошла LDD-контур: **51/51 claims proven, P0 gaps = 0**,
**319 тестов**, upgrade, mutation baseline, browser E2E, smoke и масштабный load **346 тестов**, upgrade, mutation baseline, 18/18 browser E2E, smoke и
зелёные. Это достаточное основание продолжать доработки, но не «формальное масштабный load зелёные. Это достаточное основание продолжать доработки, но не
доказательство всей системы». «формальное доказательство всей системы».
Следующий цикл LDD: Следующий цикл LDD:
1. почистить инвентарь legacy-активов через `GET /api/v1/content/legacy-assets` 1. прогнать инвентаризацию legacy-активов на production/staging через
(файлы уже не публичны, это гигиена хранилища); `/admin/legacy-assets` или `scripts/legacy-assets.sh` и привязать/удалить
2. добавить keyset-пагинацию аудита и убрать оставшийся N+1 в лабораторных найденные файлы;
списках/ведомости; 2. подтвердить первый реальный CI-прогон на shell-runners и tag-job;
3. при необходимости ввести абсолютный лимит web-сессии; 3. при необходимости расширить E2E на визуальную регрессию и остальные
4. расширить browser E2E на публикацию оценки и остальные мобильные страницы; мобильные страницы;
5. подготовить внешнюю интеграцию «Контингент»/`next.uniyar.ac.ru`. 4. пакетно отдавать сдачи в ведомости, если появятся медленные курсы.

View File

@@ -435,7 +435,7 @@
"criticality": "P1", "criticality": "P1",
"status": "active", "status": "active",
"requirement": "docs/16-stage6-report.md, §2.2", "requirement": "docs/16-stage6-report.md, §2.2",
"claim": "Схема master + синтетические данные → миграции identity V4–V11, academic V6–V12, progress V13; V10 audit и V11 refresh создаются, V13 идемпотентна.", "claim": "Схема master + синтетические данные → миграции identity V4–V13, academic V6–V12, progress V13; V10 audit, V11 refresh, V12 absolute-expiry и V13 keyset-индексы создаются, V13 идемпотентна.",
"oracle": "Успешное применение и postflight-наличие таблиц/колонок/индексов", "oracle": "Успешное применение и postflight-наличие таблиц/колонок/индексов",
"runtime": [ "runtime": [
"migration.master_upgrade" "migration.master_upgrade"
@@ -488,16 +488,16 @@
}, },
{ {
"id": "LDD-BROWSER-001", "id": "LDD-BROWSER-001",
"title": "Браузерный E2E админа, преподавателя и студента, включая сдачу и mobile", "title": "Браузерный E2E в Chromium, Firefox и WebKit",
"criticality": "P1", "criticality": "P1",
"status": "active", "status": "active",
"requirement": "docs/evidence/README.md", "requirement": "docs/evidence/README.md",
"claim": "Playwright на реальном стеке: admin видит курс и аудит; teacher открывает курс/ведомость/профиль, создаёт главу и видит mobile-вид; student сдаёт файл; аноним и студент не попадают в админку.", "claim": "6 сценариев (админ, преподаватель, студент/аноним, сдача+mobile, cursor-аудит, инвентаризация активов) проходят в Chromium, Firefox и WebKit: 18/18 без unexpected.",
"oracle": "Playwright assertions на DOM/URL", "oracle": "Playwright assertions на DOM/URL",
"runtime": [ "runtime": [
"browser.e2e" "browser.e2e"
], ],
"limitations": "4 сценария, один Chromium; нет визуальной регрессии и Safari/Firefox." "limitations": "Нет визуальной регрессии и полного набора мобильных страниц; WebKit на Fedora запускается через Docker-образ Playwright."
}, },
{ {
"id": "LDD-RUNTIME-002", "id": "LDD-RUNTIME-002",
@@ -613,7 +613,7 @@
"ru.ysu.lms.web.session.SessionCookieRefreshFilterTest#refreshes cookie max age on every existing session()", "ru.ysu.lms.web.session.SessionCookieRefreshFilterTest#refreshes cookie max age on every existing session()",
"ru.ysu.lms.web.auth.AuthPageControllerTest#logout revokes refresh token and invalidates session()" "ru.ysu.lms.web.auth.AuthPageControllerTest#logout revokes refresh token and invalidates session()"
], ],
"limitations": "Cookie и серверный timeout оба 7 дней; абсолютного верхнего предела сессии нет." "limitations": "Cookie и серверный timeout 7 дней; поверх них действует абсолютный потолок 30 дней (LDD-AUTH-006)."
}, },
{ {
"id": "LDD-AUDIT-004", "id": "LDD-AUDIT-004",
@@ -621,13 +621,13 @@
"criticality": "P1", "criticality": "P1",
"status": "active", "status": "active",
"requirement": "docs/evidence/README.md", "requirement": "docs/evidence/README.md",
"claim": "Audit API отдаёт страницы limit/page без пересечений и с устойчивым порядком; UI показывает «Показать ещё».", "claim": "Audit API поддерживает offset-страницы `page`/`limit` для совместимости; UI по умолчанию продолжает журнал keyset-курсором.",
"oracle": "Страницы AuditIntegrationTest и ссылки шаблона", "oracle": "Страницы AuditIntegrationTest и ссылки шаблона",
"evidence": [ "evidence": [
"ru.ysu.lms.identity.audit.AuditIntegrationTest#pagination returns disjoint pages newest first()", "ru.ysu.lms.identity.audit.AuditIntegrationTest#pagination returns disjoint pages newest first()",
"ru.ysu.lms.web.admin.AdminAuditTemplateTest#renders audit events with labels and filter()" "ru.ysu.lms.web.admin.AdminAuditTemplateTest#renders audit events with labels and filter()"
], ],
"limitations": "Offset-пагинация, не keyset; при частых вставках возможны сдвиги." "limitations": "Offset-режим остаётся только для старых ссылок; основной путь — курсор (LDD-AUDIT-005)."
}, },
{ {
"id": "LDD-PERF-001", "id": "LDD-PERF-001",
@@ -641,7 +641,7 @@
"ru.ysu.lms.academic.course.CourseGroupTeacherAccessTest#teacher gets attached groups with names in one call()", "ru.ysu.lms.academic.course.CourseGroupTeacherAccessTest#teacher gets attached groups with names in one call()",
"ru.ysu.lms.web.teacher.TeacherControllerCourseGroupsTest#loads group names with a single call instead of per-group requests()" "ru.ysu.lms.web.teacher.TeacherControllerCourseGroupsTest#loads group names with a single call instead of per-group requests()"
], ],
"limitations": "Детали лабораторных профиля всё ещё подтягиваются по одной; это следующий шаг." "limitations": "Детали лабораторных теперь пакетные (LDD-PERF-002); отдельные запросы на лабораторную в ведомости ещё остаются."
}, },
{ {
"id": "LDD-AUTH-004", "id": "LDD-AUTH-004",
@@ -669,6 +669,84 @@
"runtime.auth_refresh" "runtime.auth_refresh"
], ],
"limitations": "Проверено на одноразовом стеке; TTL не истекает в рамках smoke." "limitations": "Проверено на одноразовом стеке; TTL не истекает в рамках smoke."
},
{
"id": "LDD-AUTH-006",
"title": "Абсолютный потолок сессии 30 дней",
"criticality": "P1",
"status": "active",
"requirement": "docs/16-stage6-report.md, §4",
"claim": "Ротация refresh не продлевает absolute_expires_at; после 30 дней refresh отклоняется, а web-сессия принудительно завершается и cookie не живёт дольше остатка.",
"oracle": "absolute_expires_at в цепочке ротаций, 401 на refresh, redirect /login?expired=absolute, Max-Age cookie",
"evidence": [
"ru.ysu.lms.identity.RefreshTokenIntegrationTest#refresh past the absolute session expiry is rejected with unauthorized()",
"ru.ysu.lms.identity.RefreshTokenIntegrationTest#rotation keeps the absolute session expiry unchanged()",
"ru.ysu.lms.web.session.AuthInterceptorTest#absolute expired session redirects to absolute login without identity call()",
"ru.ysu.lms.web.session.SessionCookieRefreshFilterTest#cookie max age is capped by remaining absolute lifetime()",
"ru.ysu.lms.web.session.SessionCookieRefreshFilterTest#absolute expired session receives zero max age cookie()",
"ru.ysu.lms.web.auth.AuthPageControllerTest#login stores authenticated at timestamp()"
],
"limitations": "Срок настраивается LMS_SESSION_ABSOLUTE_TIMEOUT; абсолютный отсчёт начинается с логина, а не с создания web-сессии."
},
{
"id": "LDD-AUTH-007",
"title": "Очистка истёкших refresh-токенов",
"criticality": "P1",
"status": "active",
"requirement": "docs/16-stage6-report.md, §4",
"claim": "Ночная очистка удаляет токены, у которых sliding- или absolute-срок истёк раньше retention (30 дней), и сохраняет свежие/недавно отозванные токены для detection reuse.",
"oracle": "cleanupExpired(now) и количество удалённых записей",
"evidence": [
"ru.ysu.lms.identity.RefreshTokenIntegrationTest#cleanup deletes only tokens expired beyond retention()"
],
"limitations": "Расписание задаётся LMS_REFRESH_TOKEN_CLEANUP_CRON; в тесте вызывается метод напрямую, без ожидания cron."
},
{
"id": "LDD-AUDIT-005",
"title": "Keyset-пагинация аудита",
"criticality": "P1",
"status": "active",
"requirement": "docs/16-stage6-report.md, §2.7",
"claim": "Audit API принимает непрозрачный cursor (occurredAt|id), отдаёт строго следующие записи без пересечений и сохраняет фильтр по action; битый курсор — 400; UI ведёт «Показать ещё» по курсору.",
"oracle": "Страницы AuditIntegrationTest и cursor-ссылка шаблона",
"evidence": [
"ru.ysu.lms.identity.audit.AuditIntegrationTest#cursor pagination walks all events newest first()",
"ru.ysu.lms.identity.audit.AuditIntegrationTest#cursor pagination respects the action filter()",
"ru.ysu.lms.identity.audit.AuditIntegrationTest#malformed audit cursor is rejected with bad request()",
"ru.ysu.lms.identity.audit.AuditIntegrationTest#cursor from the newest event returns an empty page()",
"ru.ysu.lms.web.admin.AdminAuditTemplateTest#cursor mode links next page with cursor and resets to start()"
],
"limitations": "Offset page остаётся для обратной совместимости; keyset-курсор не подписан, но не содержит секретов."
},
{
"id": "LDD-PERF-002",
"title": "Пакетные детали лабораторных без N+1",
"criticality": "P1",
"status": "active",
"requirement": "docs/16-stage6-report.md, §2.7",
"claim": "Academic отдаёт все лабораторные курса с уровнями и вариантами одним запросом /api/v1/labs/details; progress делает ровно один HTTP-вызов вместо запроса на каждую лабораторную.",
"oracle": "LabDetailsIntegrationTest и счётчик запросов AcademicClientBatchLabsTest",
"evidence": [
"ru.ysu.lms.academic.lab.LabDetailsIntegrationTest#teacher gets all course labs with levels and variants in one call()",
"ru.ysu.lms.academic.lab.LabDetailsIntegrationTest#student of the course receives lab details()",
"ru.ysu.lms.academic.lab.LabDetailsIntegrationTest#foreign teacher is forbidden for lab details()",
"ru.ysu.lms.progress.AcademicClientBatchLabsTest#getCourseLabs loads all labs with levels and variants in one request()"
],
"limitations": "Ведомость всё ещё запрашивает сдачи по выбранной лабораторной; это не N+1 по студентам."
},
{
"id": "LDD-ASSET-003",
"title": "Админская инвентаризация legacy-активов",
"criticality": "P1",
"status": "active",
"requirement": "docs/15-stage5-course-materials.md",
"claim": "Админ открывает /admin/legacy-assets, видит список файлов без статьи, ссылку на файл и пустое состояние; страница не показывает ошибку при пустом списке.",
"oracle": "LegacyAssetsTemplateTest и HTTP-страница",
"evidence": [
"ru.ysu.lms.web.admin.LegacyAssetsTemplateTest#renders orphan assets with links and admin-only explanation()",
"ru.ysu.lms.web.admin.LegacyAssetsTemplateTest#renders empty legacy assets state()"
],
"limitations": "Удаление файлов остаётся ручной операцией на стороне academic-service; UI только инвентаризирует."
} }
] ]
} }

View File

@@ -204,3 +204,39 @@ test("student submits a solution and mobile teacher page renders", async ({ page
await expect(page.locator("h1")).toContainText(seeded.courseTitle); await expect(page.locator("h1")).toContainText(seeded.courseTitle);
await expect(page.locator("body")).toContainText(seeded.groupName); await expect(page.locator("body")).toContainText(seeded.groupName);
}); });
test("admin paginates audit with cursor and returns to start", async ({ page }) => {
await login(page, seeded.adminLogin, seeded.adminPassword, "**/admin");
await page.goto("/admin/audit?limit=1");
await expect(page.locator("body")).toContainText("Страница 1");
const nextLink = page.getByRole("link", { name: "Показать ещё" });
await expect(nextLink).toBeVisible();
await expect(nextLink).toHaveAttribute("href", /cursor=/);
await nextLink.click();
await expect(page).toHaveURL(/cursor=/);
await expect(page.locator("body")).toContainText("Продолжение");
await expect(page.getByRole("link", { name: "В начало" })).toBeVisible();
await page.getByRole("link", { name: "Показать ещё" }).click();
await expect(page).toHaveURL(/cursor=/);
await expect(page.locator("body")).toContainText("Продолжение");
await expect(page.getByRole("link", { name: "В начало" })).toBeVisible();
await page.getByRole("link", { name: "В начало" }).click();
await expect(page).not.toHaveURL(/cursor=/);
await expect(page.locator("body")).toContainText("Страница 1");
});
test("admin opens legacy assets inventory", async ({ page }) => {
await login(page, seeded.adminLogin, seeded.adminPassword, "**/admin");
await page.goto("/admin/legacy-assets");
await expect(page.locator("h1")).toContainText("Активы");
await expect(page.locator("body")).toContainText("только администраторам");
const assetRow = page.locator("table tbody tr td.font-mono").first();
const empty = page.getByText("Бесхозных файлов нет.");
await expect(assetRow.or(empty)).toBeVisible();
await expect(page.locator(".toast-error")).toHaveCount(0);
});

View File

@@ -23,5 +23,13 @@ export default defineConfig({
name: "chromium", name: "chromium",
use: { ...devices["Desktop Chrome"] }, use: { ...devices["Desktop Chrome"] },
}, },
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
], ],
}); });

111
scripts/ci-preflight.sh Executable file
View File

@@ -0,0 +1,111 @@
#!/usr/bin/env bash
# Проверка окружения shell-runner перед smoke/release. Ничего не устанавливает.
set -euo pipefail
cd "$(dirname "$0")/.."
release=0
case "${1:-}" in
"") ;;
--release) release=1 ;;
-h | --help)
echo "Usage: $0 [--release]"
exit 0
;;
*)
echo "Usage: $0 [--release]" >&2
exit 2
;;
esac
failures=()
ok() { printf 'OK %s\n' "$*"; }
fail() {
printf 'FAIL %s\n' "$*"
failures+=("$*")
}
if [[ -n "${BASH_VERSION:-}" ]]; then
ok "bash ${BASH_VERSION}"
else
fail "bash: не найден"
fi
if command -v java >/dev/null 2>&1; then
java_line=$(java -version 2>&1 | head -n 1 || true)
if [[ "$java_line" =~ version\ \"21([.\"-]|$) ]]; then
ok "java 21: ${java_line:-java}"
else
fail "java: нужна версия 21, найдено: ${java_line:-неизвестно}"
fi
else
fail "java: не найден в PATH"
fi
if command -v docker >/dev/null 2>&1; then
docker_line=$(docker --version 2>/dev/null | head -n 1 || true)
ok "docker: ${docker_line:-команда доступна}"
else
fail "docker: не найден в PATH"
fi
if command -v docker >/dev/null 2>&1; then
compose_short=$(docker compose version --short 2>/dev/null | head -n 1 || true)
if [[ -z "$compose_short" ]]; then
compose_short=$(docker compose version 2>/dev/null | head -n 1 || true)
compose_short=${compose_short#Docker Compose version }
compose_short=${compose_short#v}
fi
compose_short=${compose_short#v}
compose_major=${compose_short%%.*}
if [[ "$compose_major" =~ ^[0-9]+$ ]] && ((compose_major >= 2)); then
ok "docker compose v2+: ${compose_short:-неизвестно}"
else
fail "docker compose: нужен v2+, найдено: ${compose_short:-версия не определена}"
fi
else
fail "docker compose: docker не найден"
fi
if command -v python3 >/dev/null 2>&1; then
ok "python3: $(python3 --version 2>&1 | head -n 1)"
else
fail "python3: не найден в PATH"
fi
if ((release)); then
if command -v node >/dev/null 2>&1; then
ok "node: $(node --version 2>&1 | head -n 1)"
else
fail "node: не найден в PATH"
fi
if command -v npx >/dev/null 2>&1; then
ok "npx: $(npx --version 2>&1 | head -n 1)"
else
fail "npx: не найден в PATH"
fi
if [[ -d e2e/node_modules ]]; then
ok "e2e/node_modules: найден"
playwright_line=$(cd e2e && npx playwright --version 2>/dev/null | head -n 1 || true)
if [[ -n "$playwright_line" ]]; then
ok "playwright: $playwright_line"
else
fail "npx playwright --version: не удалось получить версию"
fi
else
fail "e2e/node_modules: не найден (выполните: cd e2e && npm ci)"
fail "npx playwright --version: пропущено без e2e/node_modules"
fi
fi
echo
if ((${#failures[@]} > 0)); then
echo "Preflight не пройден, ошибок: ${#failures[@]}."
printf ' - %s\n' "${failures[@]}"
exit 1
fi
echo "Preflight пройден."

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# LDD browser E2E: полный стек + Playwright (Chromium) на одноразовой БД. # LDD browser E2E: полный стек + Playwright (Chromium/Firefox/WebKit) на одноразовой БД.
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
@@ -11,6 +11,47 @@ E2E_STATUS=1
log() { echo ">> $*"; } log() { echo ">> $*"; }
playwright_version() {
(cd e2e && node -p "require('@playwright/test/package.json').version" 2>/dev/null || true)
}
host_playwright_usable() {
# WebKit на Fedora часто не запускается из-за Ubuntu-специфичных libicu/libjpeg;
# проверяем все три браузера, чтобы fallback сработал и при нехватке lib для Firefox.
(cd e2e && node -e '
const { chromium, firefox, webkit } = require("playwright");
(async () => {
for (const type of [chromium, firefox, webkit]) {
const browser = await type.launch();
await browser.close();
}
})().catch(() => process.exit(1));
') >/dev/null 2>&1
}
ensure_playwright_browsers() {
local browser detected missing=()
# Проверяем точные пути браузеров текущей версии Playwright, а не кэш целиком.
if ! detected=$(cd e2e && node -e '
const fs = require("fs");
const { chromium, firefox, webkit } = require("playwright");
for (const [name, type] of [["chromium", chromium], ["firefox", firefox], ["webkit", webkit]]) {
if (!fs.existsSync(type.executablePath())) console.log(name);
}
' 2>/dev/null); then
detected=$'chromium\nfirefox\nwebkit'
fi
while IFS= read -r browser; do
[[ -n "$browser" ]] && missing+=("$browser")
done <<<"$detected"
if ((${#missing[@]} == 0)); then
log "Браузеры Playwright уже установлены (Chromium/Firefox/WebKit)."
return
fi
log "Устанавливаю браузеры Playwright: ${missing[*]}; системные зависимости ставит CI."
(cd e2e && npx playwright install --no-progress "${missing[@]}")
}
cleanup() { cleanup() {
local code=$? local code=$?
if ((STACK_STARTED)); then if ((STACK_STARTED)); then
@@ -68,15 +109,34 @@ STACK_STARTED=1
./run.sh start ./run.sh start
mkdir -p build/evidence mkdir -p build/evidence
log "Запускаю Playwright..." ensure_playwright_browsers
set +e set +e
( if host_playwright_usable; then
cd e2e log "Запускаю Playwright на хосте (Chromium/Firefox/WebKit)..."
LDD_WEB_URL="http://localhost:8090" \ (
LDD_API_URL="http://localhost:8080" \ cd e2e
npx playwright test --config=playwright.config.ts LDD_WEB_URL="http://localhost:8090" \
) LDD_API_URL="http://localhost:8080" \
E2E_STATUS=$? npx playwright test --config=playwright.config.ts
)
E2E_STATUS=$?
else
version=$(playwright_version)
if [[ -z "$version" ]]; then
echo "WebKit не запускается на хосте, а версию Playwright определить не удалось." >&2
E2E_STATUS=1
else
log "WebKit не запускается на хосте; запускаю Chromium/Firefox/WebKit в Docker-образе Playwright ${version}."
docker run --rm --network host \
-v "$PWD:/work" \
-w /work/e2e \
-e LDD_WEB_URL="http://localhost:8090" \
-e LDD_API_URL="http://localhost:8080" \
"mcr.microsoft.com/playwright:v${version}-noble" \
npx playwright test --config=playwright.config.ts
E2E_STATUS=$?
fi
fi
set -e set -e
python3 - "$E2E_STATUS" <<'PY' python3 - "$E2E_STATUS" <<'PY'

View File

@@ -103,6 +103,8 @@ log "Применяю миграции ветки..."
cat services/identity-service/src/main/resources/db/migration/V9__account_statuses.sql cat services/identity-service/src/main/resources/db/migration/V9__account_statuses.sql
cat services/identity-service/src/main/resources/db/migration/V10__audit_events.sql cat services/identity-service/src/main/resources/db/migration/V10__audit_events.sql
cat services/identity-service/src/main/resources/db/migration/V11__refresh_tokens.sql cat services/identity-service/src/main/resources/db/migration/V11__refresh_tokens.sql
cat services/identity-service/src/main/resources/db/migration/V12__refresh_absolute.sql
cat services/identity-service/src/main/resources/db/migration/V13__audit_keyset_indexes.sql
echo 'SET search_path TO academic;' echo 'SET search_path TO academic;'
cat services/academic-service/src/main/resources/db/migration/V6__courses.sql cat services/academic-service/src/main/resources/db/migration/V6__courses.sql
cat services/academic-service/src/main/resources/db/migration/V7__labs_course.sql cat services/academic-service/src/main/resources/db/migration/V7__labs_course.sql
@@ -122,6 +124,10 @@ SELECT 'audit_events=' || count(*) FROM information_schema.tables
WHERE table_schema='identity' AND table_name='audit_events'; WHERE table_schema='identity' AND table_name='audit_events';
SELECT 'refresh_tokens=' || count(*) FROM information_schema.tables SELECT 'refresh_tokens=' || count(*) FROM information_schema.tables
WHERE table_schema='identity' AND table_name='refresh_tokens'; WHERE table_schema='identity' AND table_name='refresh_tokens';
SELECT 'refresh_absolute=' || count(*) FROM information_schema.columns
WHERE table_schema='identity' AND table_name='refresh_tokens' AND column_name='absolute_expires_at';
SELECT 'audit_keyset_idx=' || count(*) FROM pg_indexes
WHERE schemaname='identity' AND indexname='audit_events_occurred_id_idx';
SELECT 'students=' || count(*) FROM students; SELECT 'students=' || count(*) FROM students;
SELECT 'groups=' || count(*) FROM groups; SELECT 'groups=' || count(*) FROM groups;
SELECT 'history=' || count(*) FROM membership_history; SELECT 'history=' || count(*) FROM membership_history;
@@ -163,6 +169,8 @@ out, result, v13 = sys.argv[1], sys.argv[2], sys.argv[3]
required = { required = {
"audit_events=1", "audit_events=1",
"refresh_tokens=1", "refresh_tokens=1",
"refresh_absolute=1",
"audit_keyset_idx=1",
"students=1", "students=1",
"groups=1", "groups=1",
"history=1", "history=1",

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Полный LDD release gate: тесты, линтеры, upgrade, mutation, runtime и browser E2E. # Полный LDD release gate: тесты, линтеры, upgrade, mutation, runtime и browser E2E.
# Требует Docker, Java 21, Node.js/npm и установленный Chromium Playwright. # Требует Docker, Java 21, Node.js/npm и Chromium/Firefox/WebKit Playwright.
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."

81
scripts/legacy-assets.sh Executable file
View File

@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Инвентаризация legacy-активов (файлов без связи со статьёй) на работающем стенде.
# Пример:
# LMS_BASE_URL=https://lms.example.ru LMS_ADMIN_LOGIN=admin LMS_ADMIN_PASSWORD=... \
# ./scripts/legacy-assets.sh
set -euo pipefail
base_url="${LMS_BASE_URL:-http://localhost:8080}"
admin_login="${LMS_ADMIN_LOGIN:-}"
admin_password="${LMS_ADMIN_PASSWORD:-}"
if [[ -z "$admin_login" || -z "$admin_password" ]]; then
echo "Задайте LMS_ADMIN_LOGIN и LMS_ADMIN_PASSWORD." >&2
echo "Пример: LMS_BASE_URL=https://lms.example.ru LMS_ADMIN_LOGIN=admin LMS_ADMIN_PASSWORD=... $0" >&2
exit 2
fi
LMS_BASE_URL="${base_url%/}" \
LMS_ADMIN_LOGIN="$admin_login" \
LMS_ADMIN_PASSWORD="$admin_password" \
python3 - <<'PY'
import json
import os
import sys
import urllib.error
import urllib.request
base_url = os.environ["LMS_BASE_URL"]
admin_login = os.environ["LMS_ADMIN_LOGIN"]
admin_password = os.environ["LMS_ADMIN_PASSWORD"]
def request(method, path, payload=None, token=None):
body = None if payload is None else json.dumps(payload).encode("utf-8")
headers = {"Accept": "application/json"}
if body is not None:
headers["Content-Type"] = "application/json"
if token:
headers["Authorization"] = "Bearer " + token
req = urllib.request.Request(base_url + path, data=body, headers=headers, method=method)
try:
return urllib.request.urlopen(req, timeout=30)
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace").strip()
message = f"{method} {path}: HTTP {exc.code}"
if detail:
message += f": {detail}"
print(message, file=sys.stderr)
raise SystemExit(1)
except urllib.error.URLError as exc:
print(f"Не удалось подключиться к {base_url}: {exc.reason}", file=sys.stderr)
raise SystemExit(1)
with request("POST", "/api/v1/auth/login", {"login": admin_login, "password": admin_password}) as response:
login_body = response.read().decode("utf-8")
try:
token = json.loads(login_body)["token"]
except (ValueError, KeyError, TypeError):
print("Ответ /api/v1/auth/login не содержит token.", file=sys.stderr)
raise SystemExit(1)
with request("GET", "/api/v1/content/legacy-assets", token=token) as response:
try:
assets = json.load(response)
except ValueError:
print("Ответ /api/v1/content/legacy-assets не является JSON.", file=sys.stderr)
raise SystemExit(1)
if not isinstance(assets, list):
print("Неожиданный формат /api/v1/content/legacy-assets: ожидался список.", file=sys.stderr)
raise SystemExit(1)
for asset in assets:
if not isinstance(asset, str):
print(f"Пропущен нестроковый элемент: {asset!r}", file=sys.stderr)
continue
print(asset)
print(f"Всего legacy-активов: {len(assets)}", file=sys.stderr)
PY

View File

@@ -54,6 +54,16 @@ class LabController(
return labService.list(accessible) 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}") @GetMapping("/{id}")
fun detail( fun detail(
@PathVariable id: UUID, @PathVariable id: UUID,

View File

@@ -22,7 +22,7 @@ import java.time.OffsetDateTime
import java.time.ZoneId import java.time.ZoneId
import java.util.UUID import java.util.UUID
@Suppress("LongParameterList") @Suppress("LongParameterList", "TooManyFunctions")
@Service @Service
class LabService( class LabService(
private val labs: LabRepository, private val labs: LabRepository,
@@ -118,45 +118,25 @@ class LabService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun detail(labId: UUID): LabDetail { fun detail(labId: UUID): LabDetail {
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") } val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
val levels = labLevels.findByLabIdOrderByPosition(lab.id) return lab.toDetail(
return LabDetail( labLevels.findByLabIdOrderByPosition(lab.id),
id = lab.id.toString(), variantsRepo.findByLabIdOrderByNumber(lab.id),
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,
) )
} }
/** Все лабораторные курса с уровнями и вариантами: три запроса вместо 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 @Transactional
fun delete(labId: UUID) { fun delete(labId: UUID) {
if (!labs.existsById(labId)) { 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 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 = private fun Lab.toSummary(variants: List<Variant>): LabSummary =
LabSummary( LabSummary(
id = id.toString(), id = id.toString(),

View File

@@ -7,8 +7,12 @@ import java.util.UUID
interface LabLevelRepository : JpaRepository<LabLevel, UUID> { interface LabLevelRepository : JpaRepository<LabLevel, UUID> {
fun findByLabIdOrderByPosition(labId: UUID): List<LabLevel> fun findByLabIdOrderByPosition(labId: UUID): List<LabLevel>
fun findByLabIdInOrderByPosition(labIds: Collection<UUID>): List<LabLevel>
} }
interface VariantRepository : JpaRepository<Variant, UUID> { interface VariantRepository : JpaRepository<Variant, UUID> {
fun findByLabIdOrderByNumber(labId: UUID): List<Variant> 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(defaultValue = "100") limit: Int,
@RequestParam(required = false) action: String?, @RequestParam(required = false) action: String?,
@RequestParam(defaultValue = "0") page: Int, @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 courseId: String?,
val studentId: String?, val studentId: String?,
val details: 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.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository 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 import java.util.UUID
interface AuditEventRepository : JpaRepository<AuditEvent, UUID> { interface AuditEventRepository : JpaRepository<AuditEvent, UUID> {
@@ -13,4 +16,20 @@ interface AuditEventRepository : JpaRepository<AuditEvent, UUID> {
action: String, action: String,
pageable: Pageable, pageable: Pageable,
): List<AuditEvent> ): 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.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.BadRequestException import ru.ysu.lms.common.BadRequestException
import java.time.Instant
import java.util.Base64
import java.util.UUID import java.util.UUID
/** /**
@@ -39,16 +41,22 @@ class AuditService(
limit: Int = DEFAULT_LIMIT, limit: Int = DEFAULT_LIMIT,
action: String? = null, action: String? = null,
page: Int = 0, page: Int = 0,
cursor: String? = null,
): List<AuditEventDto> { ): List<AuditEventDto> {
val size = limit.coerceIn(1, MAX_LIMIT) 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 filter = action?.trim()?.takeIf { it.isNotEmpty() }
val found = val found =
if (filter == null) { if (cursor != null) {
events.findAllByOrderByOccurredAtDescIdDesc(pageable) val (beforeAt, beforeId) = decodeCursor(cursor)
events.findBefore(filter, beforeAt, beforeId, PageRequest.of(0, size))
} else { } 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) return found.map(::toDto)
} }
@@ -65,8 +73,30 @@ class AuditService(
courseId = event.courseId?.toString(), courseId = event.courseId?.toString(),
studentId = event.studentId?.toString(), studentId = event.studentId?.toString(),
details = event.details, 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( private fun requireText(
raw: String, raw: String,
field: String, field: String,
@@ -97,5 +127,6 @@ class AuditService(
const val ENTITY_ID_MAX = 128 const val ENTITY_ID_MAX = 128
const val ACTOR_LOGIN_MAX = 100 const val ACTOR_LOGIN_MAX = 100
const val DETAILS_MAX = 2000 const val DETAILS_MAX = 2000
const val INVALID_CURSOR = "Некорректный курсор аудита"
} }
} }

View File

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

View File

@@ -1,10 +1,21 @@
package ru.ysu.lms.identity.auth.refresh package ru.ysu.lms.identity.auth.refresh
import org.springframework.data.jpa.repository.JpaRepository 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 import java.util.UUID
interface RefreshTokenRepository : JpaRepository<RefreshToken, UUID> { interface RefreshTokenRepository : JpaRepository<RefreshToken, UUID> {
fun findByTokenHash(tokenHash: String): RefreshToken? fun findByTokenHash(tokenHash: String): RefreshToken?
fun findAllByAccountIdAndRevokedAtIsNull(accountId: UUID): List<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 package ru.ysu.lms.identity.auth.refresh
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Lazy import org.springframework.context.annotation.Lazy
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.Ids import ru.ysu.lms.common.Ids
import ru.ysu.lms.common.UnauthorizedException import ru.ysu.lms.common.UnauthorizedException
import ru.ysu.lms.identity.auth.AuthService import ru.ysu.lms.identity.auth.AuthService
@@ -23,6 +26,8 @@ data class RefreshTokenRotation(
/** /**
* Выдаёт, ротирует и отзывает refresh-токены. Access-токен живёт минуты, * Выдаёт, ротирует и отзывает refresh-токены. Access-токен живёт минуты,
* а сессия продлевается скользяще: каждый refresh возвращает новую пару. * а сессия продлевается скользяще: каждый refresh возвращает новую пару.
* Абсолютный срок (`absoluteExpiresAt`) при этом не продлевается: сессия
* живёт не дольше [absoluteTtl] с момента выдачи.
*/ */
@Service @Service
class RefreshTokenService( class RefreshTokenService(
@@ -30,7 +35,10 @@ class RefreshTokenService(
// AuthService сам зависит от RefreshTokenService (выдача при login), поэтому прокси. // AuthService сам зависит от RefreshTokenService (выдача при login), поэтому прокси.
@Lazy private val authService: AuthService, @Lazy private val authService: AuthService,
@Value("\${lms.auth.refresh-token-ttl:7d}") private val ttl: Duration, @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() private val random = SecureRandom()
fun issue(accountId: UUID): String { fun issue(accountId: UUID): String {
@@ -48,13 +56,16 @@ class RefreshTokenService(
fun rotate(raw: String): RefreshTokenRotation { fun rotate(raw: String): RefreshTokenRotation {
val token = activeTokenOrThrow(raw) val token = activeTokenOrThrow(raw)
val now = Instant.now() val now = Instant.now()
if (!token.absoluteExpiresAt.isAfter(now)) {
throw UnauthorizedException(SESSION_EXPIRED)
}
authService.ensureSessionAllowed(token.accountId) authService.ensureSessionAllowed(token.accountId)
val newId = Ids.v7() val newId = Ids.v7()
val newRaw = generateRaw() val newRaw = generateRaw()
token.revokedAt = now token.revokedAt = now
token.replacedBy = newId 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) return RefreshTokenRotation(token.accountId, newRaw)
} }
@@ -95,11 +106,32 @@ class RefreshTokenService(
tokens.saveAll(active) 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( private fun newToken(
accountId: UUID, accountId: UUID,
raw: String, raw: String,
now: Instant, now: Instant,
id: UUID = Ids.v7(), id: UUID = Ids.v7(),
absoluteExpiresAt: Instant = now.plus(absoluteTtl),
): RefreshToken = ): RefreshToken =
RefreshToken( RefreshToken(
id = id, id = id,
@@ -107,6 +139,7 @@ class RefreshTokenService(
tokenHash = hash(raw), tokenHash = hash(raw),
issuedAt = now, issuedAt = now,
expiresAt = now.plus(ttl), expiresAt = now.plus(ttl),
absoluteExpiresAt = absoluteExpiresAt,
) )
private fun generateRaw(): String { private fun generateRaw(): String {

View File

@@ -45,3 +45,8 @@ lms:
auth: auth:
# Скользящая сессия: каждый refresh продлевает её на этот срок. # Скользящая сессия: каждый refresh продлевает её на этот срок.
refresh-token-ttl: ${LMS_REFRESH_TOKEN_TTL:7d} 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.ChangePasswordRequest
import ru.ysu.lms.identity.auth.LoginRequest import ru.ysu.lms.identity.auth.LoginRequest
import ru.ysu.lms.identity.auth.RefreshTokenRequest 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.auth.refresh.RefreshTokenService
import ru.ysu.lms.identity.roster.CreateGroupRequest import ru.ysu.lms.identity.roster.CreateGroupRequest
import ru.ysu.lms.identity.roster.CreateStudentRequest 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.GroupService
import ru.ysu.lms.identity.roster.StudentLifecycleService import ru.ysu.lms.identity.roster.StudentLifecycleService
import java.time.Duration import java.time.Duration
import java.time.Instant
import java.util.UUID import java.util.UUID
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
@@ -62,6 +65,8 @@ class RefreshTokenIntegrationTest {
@Autowired lateinit var refreshTokens: RefreshTokenService @Autowired lateinit var refreshTokens: RefreshTokenService
@Autowired lateinit var refreshTokenRepository: RefreshTokenRepository
@Autowired lateinit var groupService: GroupService @Autowired lateinit var groupService: GroupService
@Autowired lateinit var lifecycle: StudentLifecycleService @Autowired lateinit var lifecycle: StudentLifecycleService
@@ -188,6 +193,93 @@ class RefreshTokenIntegrationTest {
.hasMessage(RefreshTokenService.SESSION_EXPIRED) .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 @Test
fun `logout revokes the refresh token idempotently`() { fun `logout revokes the refresh token idempotently`() {
val student = createStudent() val student = createStudent()
@@ -252,6 +344,23 @@ class RefreshTokenIntegrationTest {
.hasMessage("Группа закрыта") .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 { private fun createStudent(): StudentFixture {
val number = sequence.incrementAndGet() val number = sequence.incrementAndGet()
val group = val group =

View File

@@ -216,6 +216,65 @@ class AuditIntegrationTest {
assertTrue((first + second).intersect(third.toSet()).isEmpty()) 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 @Test
fun `negative page is clamped to the first page and page past the end is empty`() { fun `negative page is clamped to the first page and page past the end is empty`() {
repeat(3) { index -> repeat(3) { index ->
@@ -319,6 +378,42 @@ class AuditIntegrationTest {
assertTrue(auditEvents.findById(deleteEvent.id).isPresent) 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( private fun detailsOfPage(
limit: Int, limit: Int,
page: Int, page: Int,

View File

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

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()
}
}

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ class AuthPageController(
@GetMapping("/login") @GetMapping("/login")
fun loginPage( fun loginPage(
@RequestParam(required = false) expired: String?,
session: HttpSession, session: HttpSession,
model: Model, model: Model,
): String { ): String {
@@ -37,6 +38,9 @@ class AuthPageController(
session.removeAttribute("csrfError") session.removeAttribute("csrfError")
model.addAttribute("error", it) model.addAttribute("error", it)
} }
if (!expired.isNullOrBlank()) {
model.addAttribute("error", "Срок сессии истёк. Войдите заново.")
}
model.addAttribute("adminExists", api.adminExists()) model.addAttribute("adminExists", api.adminExists())
return "login" return "login"
} }
@@ -133,6 +137,7 @@ class AuthPageController(
val session = request.getSession(true) val session = request.getSession(true)
session.setAttribute(SessionKeys.TOKEN, auth.token) session.setAttribute(SessionKeys.TOKEN, auth.token)
auth.refreshToken?.let { session.setAttribute(SessionKeys.REFRESH_TOKEN, it) } auth.refreshToken?.let { session.setAttribute(SessionKeys.REFRESH_TOKEN, it) }
session.setAttribute(SessionKeys.AUTHENTICATED_AT, System.currentTimeMillis())
session.setAttribute( session.setAttribute(
SessionKeys.TOKEN_EXPIRES_AT, SessionKeys.TOKEN_EXPIRES_AT,
System.currentTimeMillis() + auth.accessTokenExpiresIn * 1000, 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.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpServletResponse
import jakarta.servlet.http.HttpSession import jakarta.servlet.http.HttpSession
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import org.springframework.web.servlet.HandlerInterceptor import org.springframework.web.servlet.HandlerInterceptor
import ru.ysu.lms.web.api.ApiClient import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.MeDto import ru.ysu.lms.web.api.MeDto
import java.time.Duration
/** /**
* Проверяет вход, поддерживает access-токен живым и периодически перепроверяет * Проверяет вход, поддерживает access-токен живым и периодически перепроверяет
@@ -19,7 +21,10 @@ import ru.ysu.lms.web.api.MeDto
class AuthInterceptor( class AuthInterceptor(
private val api: ApiClient, private val api: ApiClient,
private val tokenService: SessionTokenService, private val tokenService: SessionTokenService,
@Value("\${lms.session.absolute-timeout:30d}") absoluteTimeout: Duration = Duration.ofDays(30),
) : HandlerInterceptor { ) : HandlerInterceptor {
private val lifetime = SessionLifetime(absoluteTimeout)
override fun preHandle( override fun preHandle(
request: HttpServletRequest, request: HttpServletRequest,
response: HttpServletResponse, response: HttpServletResponse,
@@ -31,10 +36,14 @@ class AuthInterceptor(
response.sendRedirect("/login") response.sendRedirect("/login")
return false return false
} }
if (lifetime.isAbsoluteExpired(session)) {
expire(session, response, "/login?expired=absolute")
return false
}
if (!ensureFreshToken(session, user, response)) return false if (!ensureFreshToken(session, user, response)) return false
val refreshedUser = session.getAttribute(SessionKeys.USER) as? SessionUser ?: user val refreshedUser = session.getAttribute(SessionKeys.USER) as? SessionUser ?: user
if (!revalidate(session, refreshedUser, response)) return false return revalidate(session, refreshedUser, response) &&
return !redirectIfPasswordMustChange(session, refreshedUser, request, response) !redirectIfPasswordMustChange(session, refreshedUser, request, response)
} }
/** true — отправлен redirect на смену пароля; false — запрос можно продолжать. */ /** true — отправлен redirect на смену пароля; false — запрос можно продолжать. */
@@ -122,9 +131,10 @@ class AuthInterceptor(
private fun expire( private fun expire(
session: HttpSession, session: HttpSession,
response: HttpServletResponse, response: HttpServletResponse,
redirect: String = "/login?expired=1",
) { ) {
session.invalidate() session.invalidate()
response.sendRedirect("/login?expired=1") response.sendRedirect(redirect)
} }
private companion object { private companion object {

View File

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

View File

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

View File

@@ -66,14 +66,18 @@
</div> </div>
<nav class="flex flex-wrap items-center justify-between gap-3 mt-4" aria-label="Постраничная навигация"> <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"> <div class="flex items-center gap-2">
<a class="btn btn-secondary" <a class="btn btn-secondary"
th:if="${hasPrev}" th:if="${hasPrev}"
th:href="@{/admin/audit(action=${action},limit=${limit},page=${page - 1})}">Назад</a> 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" <a class="btn btn-secondary"
th:if="${hasNext}" 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> </div>
</nav> </nav>
</div> </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/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/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/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/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.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> <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"> <div th:if="${!adminExists}" class="alert alert-warning">
Аккаунт администратора ещё не создан. <a th:href="@{/setup}">Создать первый аккаунт</a> Аккаунт администратора ещё не создан. <a th:href="@{/setup}">Создать первый аккаунт</a>
</div> </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"> <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="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> <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", "action" to "GRADE_SET",
"limit" to 100, "limit" to 100,
"page" to 1, "page" to 1,
"nextCursor" to "cursor-1",
"hasNext" to true, "hasNext" to true,
"hasPrev" to true, "hasPrev" to true,
"isCursor" to false,
"actionOptions" to AuditViews.actionOptions("GRADE_SET"), "actionOptions" to AuditViews.actionOptions("GRADE_SET"),
), ),
) )
@@ -62,7 +64,8 @@ class AdminAuditTemplateTest {
assertThat(html).contains("action=GRADE_SET") assertThat(html).contains("action=GRADE_SET")
assertThat(html).contains("limit=100") assertThat(html).contains("limit=100")
assertThat(html).contains("page=0") assertThat(html).contains("page=0")
assertThat(html).contains("page=2") assertThat(html).contains("cursor=cursor-1")
assertThat(html).doesNotContain("page=2")
} }
@Test @Test
@@ -75,8 +78,10 @@ class AdminAuditTemplateTest {
"action" to "", "action" to "",
"limit" to 100, "limit" to 100,
"page" to 0, "page" to 0,
"nextCursor" to null,
"hasNext" to false, "hasNext" to false,
"hasPrev" to false, "hasPrev" to false,
"isCursor" to false,
"actionOptions" to AuditViews.actionOptions(null), "actionOptions" to AuditViews.actionOptions(null),
), ),
) )
@@ -88,6 +93,33 @@ class AdminAuditTemplateTest {
assertThat(html).doesNotContain(">Показать ещё</a>") 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 @Test
fun `keeps unknown action filter selectable`() { fun `keeps unknown action filter selectable`() {
val html = val html =
@@ -98,8 +130,10 @@ class AdminAuditTemplateTest {
"action" to "CUSTOM_ACTION", "action" to "CUSTOM_ACTION",
"limit" to 100, "limit" to 100,
"page" to 2, "page" to 2,
"nextCursor" to "cursor-9",
"hasNext" to true, "hasNext" to true,
"hasPrev" to true, "hasPrev" to true,
"isCursor" to false,
"actionOptions" to AuditViews.actionOptions("CUSTOM_ACTION"), "actionOptions" to AuditViews.actionOptions("CUSTOM_ACTION"),
), ),
) )
@@ -107,7 +141,8 @@ class AdminAuditTemplateTest {
assertThat(html).contains("value=\"CUSTOM_ACTION\"") assertThat(html).contains("value=\"CUSTOM_ACTION\"")
assertThat(html).contains("CUSTOM_ACTION") assertThat(html).contains("CUSTOM_ACTION")
assertThat(html).contains("page=1") 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> = 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 @Test
fun `listAudit defaults to first page without filter`() { fun `listAudit defaults to first page without filter`() {
val query = AtomicReference<String?>() val query = AtomicReference<String?>()
@@ -61,7 +86,8 @@ class ApiClientAuditTest {
"entityId": "g1", "entityId": "g1",
"courseId": "c1", "courseId": "c1",
"studentId": "s1", "studentId": "s1",
"details": "Оценка 5" "details": "Оценка 5",
"cursor": "next-cursor"
} }
] ]
""".trimIndent(), """.trimIndent(),

View File

@@ -23,6 +23,7 @@ class AuditDtoTest {
"courseId": "c1", "courseId": "c1",
"studentId": "s1", "studentId": "s1",
"details": "Оценка 5", "details": "Оценка 5",
"cursor": "next-cursor",
"unknownField": "ignored" "unknownField": "ignored"
}, },
{ {
@@ -53,6 +54,7 @@ class AuditDtoTest {
assertThat(events[0].courseId).isEqualTo("c1") assertThat(events[0].courseId).isEqualTo("c1")
assertThat(events[0].studentId).isEqualTo("s1") assertThat(events[0].studentId).isEqualTo("s1")
assertThat(events[0].details).isEqualTo("Оценка 5") assertThat(events[0].details).isEqualTo("Оценка 5")
assertThat(events[0].cursor).isEqualTo("next-cursor")
assertThat(events[1].id).isEqualTo("e2") assertThat(events[1].id).isEqualTo("e2")
assertThat(events[1].actorAccountId).isNull() assertThat(events[1].actorAccountId).isNull()
@@ -61,5 +63,6 @@ class AuditDtoTest {
assertThat(events[1].courseId).isNull() assertThat(events[1].courseId).isNull()
assertThat(events[1].studentId).isNull() assertThat(events[1].studentId).isNull()
assertThat(events[1].details).isEmpty() 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.ApiClient
import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.AuthResponseDto 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.changePassword
import ru.ysu.lms.web.api.login import ru.ysu.lms.web.api.login
import ru.ysu.lms.web.api.logout import ru.ysu.lms.web.api.logout
@@ -73,6 +74,33 @@ class AuthPageControllerTest {
assertThat((session.getAttribute(SessionKeys.USER) as SessionUser).login).isEqualTo("student") 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 @Test
fun `login without refresh token still creates the session`() { fun `login without refresh token still creates the session`() {
every { api.login("student", "secret") } returns every { api.login("student", "secret") } returns
@@ -96,6 +124,17 @@ class AuthPageControllerTest {
assertThat(session.getAttribute(SessionKeys.REFRESH_TOKEN)).isNull() 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 @Test
fun `logout revokes refresh token and invalidates session`() { fun `logout revokes refresh token and invalidates session`() {
every { api.logout("refresh") } just Runs 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.AuthResponseDto
import ru.ysu.lms.web.api.MeDto import ru.ysu.lms.web.api.MeDto
import ru.ysu.lms.web.api.refresh import ru.ysu.lms.web.api.refresh
import java.time.Duration
class AuthInterceptorTest { class AuthInterceptorTest {
@RestController @RestController
@@ -35,7 +36,7 @@ class AuthInterceptorTest {
private val mockMvc: MockMvc = private val mockMvc: MockMvc =
MockMvcBuilders MockMvcBuilders
.standaloneSetup(TestPage()) .standaloneSetup(TestPage())
.addInterceptors(AuthInterceptor(api, SessionTokenService(api))) .addInterceptors(AuthInterceptor(api, SessionTokenService(api), Duration.ofDays(30)))
.build() .build()
@BeforeEach @BeforeEach
@@ -141,6 +142,38 @@ class AuthInterceptorTest {
assertThat(user.mustChangePassword).isTrue() 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 @Test
fun `anonymous user is redirected to login`() { fun `anonymous user is redirected to login`() {
mockMvc mockMvc
@@ -233,10 +266,12 @@ class AuthInterceptorTest {
mustChangePassword: Boolean = false, mustChangePassword: Boolean = false,
expiresInMs: Long = 600_000L, expiresInMs: Long = 600_000L,
refreshToken: String? = REFRESH_TOKEN, refreshToken: String? = REFRESH_TOKEN,
authenticatedAt: Long = System.currentTimeMillis(),
): MockHttpSession = ): MockHttpSession =
MockHttpSession().apply { MockHttpSession().apply {
setAttribute(SessionKeys.TOKEN, TOKEN) setAttribute(SessionKeys.TOKEN, TOKEN)
setAttribute(SessionKeys.TOKEN_EXPIRES_AT, System.currentTimeMillis() + expiresInMs) setAttribute(SessionKeys.TOKEN_EXPIRES_AT, System.currentTimeMillis() + expiresInMs)
setAttribute(SessionKeys.AUTHENTICATED_AT, authenticatedAt)
if (refreshToken != null) setAttribute(SessionKeys.REFRESH_TOKEN, refreshToken) if (refreshToken != null) setAttribute(SessionKeys.REFRESH_TOKEN, refreshToken)
setAttribute( setAttribute(
SessionKeys.USER, SessionKeys.USER,

View File

@@ -8,12 +8,18 @@ import org.springframework.mock.web.MockHttpServletResponse
import java.time.Duration import java.time.Duration
class SessionCookieRefreshFilterTest { 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 @Test
fun `refreshes cookie max age on every existing session`() { fun `refreshes cookie max age on every existing session`() {
val request = MockHttpServletRequest() val request = MockHttpServletRequest()
request.getSession(true) request.getSession(true)!!.setAttribute(SessionKeys.AUTHENTICATED_AT, System.currentTimeMillis())
val response = MockHttpServletResponse() val response = MockHttpServletResponse()
filter.doFilter(request, response, MockFilterChain()) filter.doFilter(request, response, MockFilterChain())
@@ -25,6 +31,42 @@ class SessionCookieRefreshFilterTest {
assertThat(cookie).contains("SameSite=Lax") 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 @Test
fun `does not create cookie without session`() { fun `does not create cookie without session`() {
val response = MockHttpServletResponse() val response = MockHttpServletResponse()
@@ -33,4 +75,12 @@ class SessionCookieRefreshFilterTest {
assertThat(response.getHeader("Set-Cookie")).isNull() 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")
} }