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:
@@ -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 * * *
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -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 (для сервера)
|
||||||
|
|
||||||
|
|||||||
@@ -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. Резервное копирование
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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, после этого отчёта;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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` обязателен
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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, миграции, доступ, статусы, повышение |
|
||||||
|
|
||||||
## Кратко о системе
|
## Кратко о системе
|
||||||
|
|||||||
@@ -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. Повышение курса
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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. пакетно отдавать сдачи в ведомости, если появятся медленные курсы.
|
||||||
|
|||||||
@@ -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 только инвентаризирует."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
111
scripts/ci-preflight.sh
Executable 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 пройден."
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
81
scripts/legacy-assets.sh
Executable 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
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = "Некорректный курсор аудита"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 * * *}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Абсолютный срок жизни сессии: ротация refresh-токена продлевает скользящее
|
||||||
|
-- окно, но не может растянуть сессию дальше absolute_expires_at.
|
||||||
|
-- Бэкфилл существующих строк использует дефолтные 30 дней; если на стенде
|
||||||
|
-- LMS_SESSION_ABSOLUTE_TIMEOUT меньше, старые сессии доживают один раз до 30 дней.
|
||||||
|
ALTER TABLE identity.refresh_tokens ADD COLUMN absolute_expires_at timestamptz;
|
||||||
|
UPDATE identity.refresh_tokens SET absolute_expires_at = issued_at + interval '30 days' WHERE absolute_expires_at IS NULL;
|
||||||
|
ALTER TABLE identity.refresh_tokens ALTER COLUMN absolute_expires_at SET NOT NULL;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Индексы под keyset-пагинацию: сортировка (occurred_at DESC, id DESC) и та же
|
||||||
|
-- сортировка с фильтром по action.
|
||||||
|
CREATE INDEX audit_events_occurred_id_idx ON identity.audit_events (occurred_at DESC, id DESC);
|
||||||
|
CREATE INDEX audit_events_action_occurred_id_idx ON identity.audit_events (action, occurred_at DESC, id DESC);
|
||||||
@@ -25,6 +25,8 @@ import ru.ysu.lms.identity.auth.AuthService
|
|||||||
import ru.ysu.lms.identity.auth.ChangePasswordRequest
|
import ru.ysu.lms.identity.auth.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 =
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package ru.ysu.lms.web.admin
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpSession
|
||||||
|
import org.springframework.stereotype.Controller
|
||||||
|
import org.springframework.ui.Model
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import ru.ysu.lms.web.api.ApiClient
|
||||||
|
import ru.ysu.lms.web.api.ApiException
|
||||||
|
import ru.ysu.lms.web.api.legacyAssets
|
||||||
|
import ru.ysu.lms.web.session.SessionKeys
|
||||||
|
|
||||||
|
/** Инвентаризация медиафайлов, на которые больше не ссылается ни одна статья. */
|
||||||
|
@Controller
|
||||||
|
@RequestMapping("/admin/legacy-assets")
|
||||||
|
class LegacyAssetsController(
|
||||||
|
private val api: ApiClient,
|
||||||
|
) {
|
||||||
|
@GetMapping
|
||||||
|
fun list(
|
||||||
|
session: HttpSession,
|
||||||
|
model: Model,
|
||||||
|
): String {
|
||||||
|
val assets =
|
||||||
|
try {
|
||||||
|
api.legacyAssets(token(session)).sorted()
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
model.addAttribute("error", ex.message)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
model.addAttribute("assets", assets)
|
||||||
|
return "admin/legacy-assets"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun token(session: HttpSession): String =
|
||||||
|
session.getAttribute(SessionKeys.TOKEN) as? String
|
||||||
|
?: throw ApiException(401, "Сессия истекла, войдите заново")
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ fun ApiClient.listAudit(
|
|||||||
limit: Int = 100,
|
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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package ru.ysu.lms.web.session
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpSession
|
||||||
|
import java.time.Duration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Абсолютный срок жизни web-сессии. Sliding-timeout продлевается активностью,
|
||||||
|
* но после [absoluteTimeout] с момента входа пользователь обязан войти заново.
|
||||||
|
* Для сессий, созданных до появления [SessionKeys.AUTHENTICATED_AT], точкой
|
||||||
|
* отсчёта служит creationTime контейнера.
|
||||||
|
*/
|
||||||
|
class SessionLifetime(
|
||||||
|
private val absoluteTimeout: Duration,
|
||||||
|
) {
|
||||||
|
fun authenticatedAt(session: HttpSession): Long =
|
||||||
|
(session.getAttribute(SessionKeys.AUTHENTICATED_AT) as? Long)
|
||||||
|
?: session.creationTime
|
||||||
|
|
||||||
|
fun expiresAt(session: HttpSession): Long = authenticatedAt(session) + absoluteTimeout.toMillis()
|
||||||
|
|
||||||
|
/** Остаток абсолютного срока в миллисекундах; 0 — срок уже истёк. */
|
||||||
|
fun remainingMillis(
|
||||||
|
session: HttpSession,
|
||||||
|
now: Long = System.currentTimeMillis(),
|
||||||
|
): Long = (expiresAt(session) - now).coerceAtLeast(0)
|
||||||
|
|
||||||
|
fun isAbsoluteExpired(
|
||||||
|
session: HttpSession,
|
||||||
|
now: Long = System.currentTimeMillis(),
|
||||||
|
): Boolean = now >= expiresAt(session)
|
||||||
|
}
|
||||||
@@ -18,4 +18,5 @@ object SessionKeys {
|
|||||||
const val TOKEN_EXPIRES_AT = "lms.tokenExpiresAt"
|
const val 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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
48
web/src/main/resources/templates/admin/legacy-assets.html
Normal file
48
web/src/main/resources/templates/admin/legacy-assets.html
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
|
||||||
|
<head><title>Активы — LMS</title></head>
|
||||||
|
<body>
|
||||||
|
<div id="content">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title m-0">Активы</h1>
|
||||||
|
<p class="meta">
|
||||||
|
Медиафайлы, на которые не ссылается ни одна статья. Они не удалены и доступны
|
||||||
|
только администраторам: обычные пользователи получают для них 404.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-secondary" th:href="@{/admin/labs}">К лабораторным</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-flush">
|
||||||
|
<table class="tablekit">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Файл</th>
|
||||||
|
<th>Ссылка</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr th:each="asset : ${assets}">
|
||||||
|
<td class="font-mono text-break" th:text="${asset}">file.png</td>
|
||||||
|
<td>
|
||||||
|
<a th:href="@{/api/v1/content/assets/{name}(name=${asset})}"
|
||||||
|
th:text="${asset}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener">file.png</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr th:if="${#lists.isEmpty(assets)}">
|
||||||
|
<td colspan="2" class="empty">Бесхозных файлов нет.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="hint mt-4">
|
||||||
|
Такие файлы остаются после удаления статей или переименования картинок. Если файл больше
|
||||||
|
не нужен, его можно удалить на стороне academic-service.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -53,6 +53,7 @@
|
|||||||
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/courses}">Курсы</a></li>
|
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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> =
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package ru.ysu.lms.web.admin
|
||||||
|
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.mock.web.MockHttpServletRequest
|
||||||
|
import org.springframework.mock.web.MockHttpServletResponse
|
||||||
|
import org.springframework.mock.web.MockServletContext
|
||||||
|
import org.thymeleaf.context.WebContext
|
||||||
|
import org.thymeleaf.spring6.SpringTemplateEngine
|
||||||
|
import org.thymeleaf.templatemode.TemplateMode
|
||||||
|
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver
|
||||||
|
import org.thymeleaf.web.servlet.JakartaServletWebApplication
|
||||||
|
import ru.ysu.lms.web.session.SessionUser
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class LegacyAssetsTemplateTest {
|
||||||
|
private val engine =
|
||||||
|
SpringTemplateEngine().apply {
|
||||||
|
setTemplateResolver(
|
||||||
|
ClassLoaderTemplateResolver().apply {
|
||||||
|
prefix = "templates/"
|
||||||
|
suffix = ".html"
|
||||||
|
characterEncoding = "UTF-8"
|
||||||
|
templateMode = TemplateMode.HTML
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `renders orphan assets with links and admin-only explanation`() {
|
||||||
|
val html = render(listOf("old-picture.png", "unused-video.mp4"))
|
||||||
|
|
||||||
|
assertThat(html).contains("Активы")
|
||||||
|
assertThat(html).contains("old-picture.png")
|
||||||
|
assertThat(html).contains("unused-video.mp4")
|
||||||
|
assertThat(html).contains("href=\"/api/v1/content/assets/old-picture.png\"")
|
||||||
|
assertThat(html).contains("href=\"/api/v1/content/assets/unused-video.mp4\"")
|
||||||
|
assertThat(html).contains("не ссылается ни одна статья")
|
||||||
|
assertThat(html).contains("только администраторам")
|
||||||
|
assertThat(html).doesNotContain("Бесхозных файлов нет")
|
||||||
|
assertThat(html).contains(">Активы</a>")
|
||||||
|
assertThat(html).contains("href=\"/admin/labs\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `renders empty legacy assets state`() {
|
||||||
|
val html = render(emptyList())
|
||||||
|
|
||||||
|
assertThat(html).contains("Активы")
|
||||||
|
assertThat(html).contains("Бесхозных файлов нет")
|
||||||
|
assertThat(html).doesNotContain("/api/v1/content/assets/")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun render(assets: List<String>): String {
|
||||||
|
val servletContext = MockServletContext()
|
||||||
|
val application = JakartaServletWebApplication.buildApplication(servletContext)
|
||||||
|
val exchange =
|
||||||
|
application.buildExchange(
|
||||||
|
MockHttpServletRequest(servletContext),
|
||||||
|
MockHttpServletResponse(),
|
||||||
|
)
|
||||||
|
val context = WebContext(exchange, Locale.forLanguageTag("ru"))
|
||||||
|
context.setVariable("csrfToken", "csrf")
|
||||||
|
context.setVariable(
|
||||||
|
"currentUser",
|
||||||
|
SessionUser(
|
||||||
|
id = "admin-id",
|
||||||
|
login = "admin",
|
||||||
|
role = "ADMIN",
|
||||||
|
name = "Администратор",
|
||||||
|
mustChangePassword = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
context.setVariable("assets", assets)
|
||||||
|
return engine.process("admin/legacy-assets", context)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,31 @@ class ApiClientAuditTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `listAudit sends cursor for keyset pagination`() {
|
||||||
|
val query = AtomicReference<String?>()
|
||||||
|
val authorization = AtomicReference<String?>()
|
||||||
|
withAuditServer(query, authorization) { baseUrl ->
|
||||||
|
val events = ApiClient(baseUrl, ObjectMapper()).listAudit("session-token", cursor = "opaque-cursor")
|
||||||
|
|
||||||
|
assertThat(events).hasSize(1)
|
||||||
|
assertThat(events[0].cursor).isEqualTo("next-cursor")
|
||||||
|
assertThat(query.get()).contains("limit=100")
|
||||||
|
assertThat(query.get()).contains("cursor=opaque-cursor")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `listAudit omits blank cursor`() {
|
||||||
|
val query = AtomicReference<String?>()
|
||||||
|
val authorization = AtomicReference<String?>()
|
||||||
|
withAuditServer(query, authorization) { baseUrl ->
|
||||||
|
ApiClient(baseUrl, ObjectMapper()).listAudit("session-token", cursor = " ")
|
||||||
|
|
||||||
|
assertThat(query.get()).doesNotContain("cursor=")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@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(),
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user