feat(ldd): реестр утверждений, evidence gate и runtime-проверки

This commit is contained in:
2026-10-27 21:46:07 +03:00
parent 0ac75bcd5c
commit 4130a1af2f
8 changed files with 1400 additions and 1 deletions

View File

@@ -52,7 +52,10 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
```bash ```bash
./gradlew ktlintCheck detekt test # линтеры (строгий режим) + тесты ./gradlew ktlintCheck detekt test # линтеры (строгий режим) + тесты
./gradlew evidenceCheck # LDD: P0-claims должны иметь зелёные свидетельства
./gradlew ktlintFormat # автоформат Kotlin ./gradlew ktlintFormat # автоформат Kotlin
./scripts/ldd-runtime.sh # runtime-smoke полного стека на одноразовой БД
./scripts/ldd-migration-upgrade.sh # upgrade с master-схемы
``` ```
- `ktlint` отвечает за формат, `detekt` — за качество (сложность, вложенность, ошибки). - `ktlint` отвечает за формат, `detekt` — за качество (сложность, вложенность, ошибки).
@@ -61,7 +64,10 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
- Gradle daemon фиксируется на JDK 21 (`gradle/gradle-daemon-jvm.properties`): detekt 1.23 - Gradle daemon фиксируется на JDK 21 (`gradle/gradle-daemon-jvm.properties`): detekt 1.23
не работает на JDK 25. не работает на JDK 25.
- Тестовая инфраструктура — `libs:test-support` (JUnit 5, MockK, Testcontainers `postgres:18`). - Тестовая инфраструктура — `libs:test-support` (JUnit 5, MockK, Testcontainers `postgres:18`).
- CI merge gate — `.gitlab-ci.yml`: `./gradlew --no-daemon test ktlintCheck detekt` - **LDD** — реестр проверяемых утверждений `docs/evidence/claims.json`;
P0 без зелёного теста валит `./gradlew evidenceCheck`. Runtime- и
migration-свидетельства — отдельные скрипты из `scripts/`.
- CI merge gate — `.gitlab-ci.yml`: `./gradlew --no-daemon test ktlintCheck detekt evidenceCheck`
на push/MR. Runner должен разрешать privileged `docker:dind` для Testcontainers. на push/MR. Runner должен разрешать privileged `docker:dind` для Testcontainers.
## Запуск в Docker (для сервера) ## Запуск в Docker (для сервера)

34
build.gradle.kts Normal file
View File

@@ -0,0 +1,34 @@
import lms.evidence.EvidenceCheckTask
/**
* LDD evidence gate: каждое критичное утверждение из docs/evidence/claims.json
* должно ссылаться на реально прошедший тест. P0-утверждение без свидетельства
* валит сборку; P1-пробелы показываются в отчёте.
*
* Запуск: ./gradlew evidenceCheck
*/
val evidenceTestTasks =
listOf(
":libs:markdown:test",
":services:identity-service:test",
":services:academic-service:test",
":services:progress-service:test",
":web:test",
)
tasks.register<EvidenceCheckTask>("evidenceCheck") {
group = "verification"
description = "LDD: проверяет, что claims имеют актуальные зелёные свидетельства"
dependsOn(evidenceTestTasks)
claimsFile.set(layout.projectDirectory.file("docs/evidence/claims.json"))
runtimeFiles.from(
fileTree("build/evidence") {
include("*.json")
},
)
evidenceTestTasks.forEach { taskPath ->
val projectPath = taskPath.substringBeforeLast(':')
testResults.from(project(projectPath).tasks.named("test"))
}
reportFile.set(layout.buildDirectory.file("reports/evidence/index.md"))
}

View File

@@ -0,0 +1,171 @@
package lms.evidence
import groovy.json.JsonSlurper
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction
import java.time.Instant
import javax.xml.parsers.DocumentBuilderFactory
/**
* LDD evidence gate: проверяет, что каждое активное P0-утверждение имеет
* зелёное JUnit-свидетельство, а P1-пробелы видны в отчёте.
*/
abstract class EvidenceCheckTask : DefaultTask() {
@get:InputFile
abstract val claimsFile: RegularFileProperty
@get:InputFiles
abstract val runtimeFiles: ConfigurableFileCollection
@get:InputFiles
abstract val testResults: ConfigurableFileCollection
@get:OutputFile
abstract val reportFile: RegularFileProperty
@Suppress("UNCHECKED_CAST")
@TaskAction
fun check() {
val claims = parseClaims(claimsFile.get().asFile)
if (claims.isEmpty()) throw GradleException("LDD: claims.json не содержит claims")
data class Result(val key: String, val passed: Boolean)
val xmlFiles =
testResults.files.flatMap { candidate ->
if (candidate.isDirectory) {
candidate
.walkTopDown()
.filter { it.isFile && it.name.startsWith("TEST-") && it.extension == "xml" }
.toList()
} else {
listOf(candidate).filter { it.isFile && it.name.startsWith("TEST-") && it.extension == "xml" }
}
}
val results = mutableMapOf<String, Result>()
for (xmlFile in xmlFiles) {
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(xmlFile)
val testCases = document.getElementsByTagName("testcase")
for (index in 0 until testCases.length) {
val node = testCases.item(index) as org.w3c.dom.Element
val key = "${node.getAttribute("classname")}#${node.getAttribute("name")}"
val broken =
node.getElementsByTagName("failure").length > 0 ||
node.getElementsByTagName("error").length > 0 ||
node.getElementsByTagName("skipped").length > 0
results[key] = Result(key, !broken)
}
}
val runtimeChecks = parseRuntimeChecks(runtimeFiles.files)
val ids = HashSet<String>()
val gaps = mutableListOf<String>()
val p0Gaps = mutableListOf<String>()
val rows = mutableListOf<String>()
for (claim in claims) {
val id = claim["id"]?.toString().orEmpty().ifBlank { "?" }
if (!ids.add(id)) throw GradleException("LDD: дублирующийся claim id $id")
val criticality = claim["criticality"]?.toString().orEmpty().ifBlank { "P1" }
val status = claim["status"]?.toString().orEmpty().ifBlank { "active" }
val title = claim["title"]?.toString().orEmpty()
val evidence = stringList(claim["evidence"])
val runtime = stringList(claim["runtime"])
val missingTests = evidence.filter { results[it]?.passed != true }
val failedTests = evidence.filter { results[it]?.passed == false }
val runtimeMissing =
runtime.filter { idValue ->
val state = runtimeChecks[idValue]
state == null || state != "passed"
}
val proven =
status == "active" &&
(evidence.isNotEmpty() || runtime.isNotEmpty()) &&
missingTests.isEmpty() &&
failedTests.isEmpty() &&
(runtime.isEmpty() || runtimeMissing.isEmpty())
val reasons =
buildList {
if (status != "active") add("status=$status")
if (evidence.isEmpty() && runtime.isEmpty()) add("нет свидетельств")
missingTests.forEach { add("не найден/не прошёл: $it") }
failedTests.forEach { add("упал: $it") }
if (runtime.isNotEmpty() && runtimeChecks.isEmpty()) add("runtime не запускался")
runtimeMissing.forEach { add("runtime не прошёл: $it") }
}
if (!proven && status == "active" && criticality == "P0") {
p0Gaps += "$id: ${reasons.joinToString("; ")}"
}
if (!proven) gaps += "$id ($criticality): ${reasons.joinToString("; ")}"
rows +=
"| $id | $criticality | ${if (proven) "✅ proven" else "⚠️ gap"} | $title | " +
"${evidence.size} tests${if (runtime.isNotEmpty()) ", ${runtime.size} runtime" else ""} |"
}
reportFile.get().asFile.parentFile.mkdirs()
val report =
buildString {
appendLine("# LDD evidence report")
appendLine()
appendLine("Сформирован: ${Instant.now()}")
appendLine("Утверждений: ${claims.size}; proven: ${claims.size - gaps.size}; gaps: ${gaps.size}.")
appendLine()
appendLine("| Claim | Уровень | Статус | Формулировка | Свидетельства |")
appendLine("|---|---|---|---|---|")
rows.forEach { appendLine(it) }
appendLine()
if (gaps.isEmpty()) {
appendLine("Все активные claims подтверждены.")
} else {
appendLine("## Пробелы")
appendLine()
gaps.forEach { appendLine("- $it") }
}
appendLine()
appendLine("P0 без свидетельств: ${p0Gaps.size}.")
}
reportFile.get().asFile.writeText(report)
println(
"LDD evidence: claims=${claims.size}, proven=${claims.size - gaps.size}, " +
"gaps=${gaps.size}, p0Gaps=${p0Gaps.size}",
)
println("LDD report: ${reportFile.get().asFile.path}")
if (p0Gaps.isNotEmpty()) {
throw GradleException(
"LDD P0 evidence gate failed:\n" + p0Gaps.joinToString("\n") { " - $it" },
)
}
}
@Suppress("UNCHECKED_CAST")
private fun parseClaims(file: java.io.File): List<Map<String, Any?>> {
if (!file.isFile) throw GradleException("LDD: не найден ${file.path}")
val root = JsonSlurper().parse(file) as Map<String, Any?>
return (root["claims"] as? List<Map<String, Any?>>).orEmpty()
}
@Suppress("UNCHECKED_CAST")
private fun parseRuntimeChecks(files: Set<java.io.File>): Map<String, String> {
val checks = mutableMapOf<String, String>()
for (file in files.filter { it.isFile }) {
val root = JsonSlurper().parse(file) as Map<String, Any?>
val list = (root["checks"] as? List<Map<String, Any?>>).orEmpty()
for (check in list) {
val id = check["id"]?.toString().orEmpty()
if (id.isNotEmpty()) checks[id] = check["status"]?.toString().orEmpty()
}
}
return checks
}
private fun stringList(value: Any?): List<String> =
(value as? List<*>).orEmpty().mapNotNull { it?.toString()?.trim()?.takeIf(String::isNotEmpty) }.distinct()
}

76
docs/evidence/README.md Normal file
View File

@@ -0,0 +1,76 @@
# LDD: доказательное тестирование
Этот каталог — реестр утверждений о системе и свидетельств того, что они
выполняются. LDD здесь означает не «тесты вместо требований», а
**риск-ориентированную проверку**: требование → проверяемое утверждение →
независимый оракул → воспроизводимое свидетельство → допуск.
## Уровни
- **P0** — утверждение о безопасности, сохранности данных, доступах или
ключевом доменном правиле. P0 без зелёного свидетельства валит
`./gradlew evidenceCheck`.
- **P1** — важное поведение/UX/контракт. Пробел показывается в отчёте, но не
блокирует сборку.
- **runtime** — проверка на реально поднятом стеке. Выполняется отдельным
скриптом; в unit-CI может отсутствовать, но обязательна перед релизом.
## Реестр
`claims.json` содержит:
- `id` — стабильный идентификатор (`LDD-ACCESS-001`);
- `title` — короткая формулировка;
- `criticality` — `P0`/`P1`;
- `status` — `active` или `gap`;
- `requirement` — ссылка на ТЗ/отчёт;
- `claim` — проверяемое утверждение (что именно истинно);
- `oracle` — как это проверяется;
- `evidence` — список `fully.qualified.Class#test name()` из JUnit XML;
- `runtime` — идентификаторы проверок из `build/evidence/runtime.json`;
- `limitations` — что утверждение **не** покрывает.
## Запуск
```bash
# 1. Все тесты и линтеры
./gradlew test ktlintCheck detekt
# 2. Гейт свидетельств (P0 без evidence = fail)
./gradlew evidenceCheck
# 3. Полный runtime-smoke на одноразовой БД
./scripts/ldd-runtime.sh
# 4. Проверка upgrade с master-схемы
./scripts/ldd-migration-upgrade.sh
```
Отчёт: `build/reports/evidence/index.md`.
## Правила
1. Новое P0-требование без свидетельства не мержится.
2. Свидетельство должно быть воспроизводимым в CI, а не «проверил руками».
3. Тест должен падать при поломке соответствующей логики; для критичных
пакетов позже добавим mutation testing.
4. Один тест может подтверждать несколько claims, но claim не должен
опираться на тест, который проверяет только реализацию.
5. `limitations` обязателен: LDD не должен создавать иллюзию полного
доказательства.
6. При переименовании теста обновляется `claims.json`; устаревшая ссылка —
это gap, а не «забыли».
7. Runtime-свидетельства обязательны перед релизом; CI проверяет unit/
integration-слой.
## Текущий baseline
Первый прогон LDD выполнен 2026-10-27:
- 267 автоматических тестов, 0 failures/errors;
- 33 claims: P0 — доступы, повышение, жизненный цикл, профиль/скачивание,
материалы/активы, миграция V13, аудит; P1 — группы, авторство, UI,
контракты, импорт, копирование лаб, runtime.
- Runtime-smoke и master-upgrade вынесены в отдельные скрипты.
Результаты и найденные пробелы — в `baseline-2026-10-27.md`.

View File

@@ -0,0 +1,142 @@
# LDD baseline: 2026-10-27
Первый прогон доказательной проверки текущей функциональности LMS.
Код приложения в этом прогоне не менялся; добавлены реестр claims, gate
`evidenceCheck`, runtime- и migration-скрипты.
## 1. Что проверялось
Реестр: `docs/evidence/claims.json`, 31 утверждение.
| Критичность | Claims | Proven | Gaps |
|---|---:|---:|---:|
| P0 | 19 | 19 | 0 |
| P1 | 12 | 12 | 0 |
| **Итого** | **31** | **31** | **0** |
Домены:
- доступы: аноним/студент/преподаватель/админ, чужие курс/лаба/студент/группа,
внутренние API identity;
- повышение: dry-run, apply, идемпотентность, rollback, cancel;
- жизненный цикл: академ, возврат, отчисление, архив, восстановление, вход;
- профиль студента и скачивание сдач;
- материалы и активы;
- миграция V13 и полный upgrade с master-схемы;
- аудит: токен, admin-only, сохранность после удаления, representative-хуки;
- группы курса, авторство материалов, ведомость, UI/контракты, импорт, копирование лаб;
- runtime-smoke полного стека.
## 2. Команды и результаты
### 2.1. Автоматические тесты
```bash
./gradlew test --rerun-tasks
```
Результат: **267 тестов, 0 failures, 0 errors, 0 skipped**.
`ktlintCheck` и `detekt` — зелёные.
### 2.2. LDD gate
```bash
./gradlew evidenceCheck
```
Результат: **31/31 claims proven, P0 gaps = 0**.
Отчёт: `build/reports/evidence/index.md`.
Gate устроен так:
- каждый claim ссылается на конкретный JUnit-тест `Class#method`;
- P0 без зелёного теста валит сборку;
- P1-пробел показывается в отчёте;
- runtime-проверки читаются из `build/evidence/*.json` и тоже должны быть
`passed`, если claim на них ссылается.
### 2.3. Upgrade с master-схемы
```bash
./scripts/ldd-migration-upgrade.sh
```
Сценарий: одноразовая PostgreSQL 18, master identity `V1–V3`, academic
`V1–V5`, progress `V1–V9,V11,V12`, синтетические данные, затем ветка:
identity `V4–V10`, academic `V6–V12`, progress `V13`.
Проверено:
- `identity.audit_events` создана;
- `students`, `groups`, `membership_history` сохранились;
- `labs.course_id`, `chapters.course_id`, `article_assets` созданы;
- `grade_comments.author_account_id`, `lab_grades.updated_by_account_id` созданы;
- оценки и комментарии сохранились;
- повторное применение `V13` идемпотентно.
Результат: **PASS**, evidence: `build/evidence/migration.json`.
### 2.4. Runtime-smoke полного стека
```bash
./scripts/ldd-runtime.sh
```
Сценарий: одноразовая БД, `bootJar`, запуск identity/academic/progress/gateway/web,
реальные HTTP-вызовы через gateway.
Проверено:
- health всех пяти сервисов = `UP`;
- `adminExists=false` до setup, создание админа, `/auth/me` = `ADMIN`;
- создание группы, курса, преподавателя, привязка группы, создание лабы;
- преподаватель входит и видит свой курс, лабу и состав группы;
- чужой преподаватель получает `403` на курс, лабу и аудит;
- админ видит audit-события `COURSE_CREATE`, `TEACHER_CREATE`,
`COURSE_TEACHER_ASSIGN`, `COURSE_GROUP_ATTACH`, `LAB_CREATE`;
- web `/login` отдаёт `200`, публичное дерево материалов доступно, курсовая
материалы анонимно — `401`.
Результат: **PASS**, evidence: `build/evidence/runtime.json`.
Стек и одноразовая БД после прогона остановлены/удалены.
## 3. Что это доказывает
- Критичные P0-инварианты доступа, повышения, жизненного цикла, профиля,
материалов, миграции и аудита имеют зелёные автоматические свидетельства.
- Приложение собирается, мигрируется с master-схемы и поднимается полным
стеком на чистой БД.
- Ключевой доменный поток «админ → группа → курс → преподаватель → лаба»
проходит через реальные HTTP/gateway/БД, а не только через mock.
- Чужой преподаватель не получает доступ к курсу/лабе/аудиту на реальном стеке.
- Аудит пишется и читается; upgrade не теряет синтетические данные.
## 4. Чего это не доказывает
1. **Нет браузерного E2E.** Thymeleaf-тесты проверяют HTML, но не реальные
клики/JS/CSRF/редиректы в браузере.
2. **Нет нагрузочных и конкурентных испытаний.** Advisory locks и гонки
проверены кодом и unit/integration-тестами, но не под нагрузкой.
3. **Нет mutation testing.** Сила тестов измерена косвенно; следующий шаг —
PIT на `CourseAccessService`, `PromotionService`, `StudentProfileService`.
4. **Нет property-based/model-based проверки повышения.** Один сценарный тест
не покрывает все комбинации курсов, степеней и академ-переходов.
5. **Legacy-активы без `article_assets` остаются публичными.**
6. **JWT живёт до 30 дней.** Web перепроверяет статус, прямой API — нет.
7. **Ведущие нули номера группы нормализуются** (`01` → `1`).
8. **Нет пагинации/keyset** для части списков и N+1 в отдельных экранах.
9. **Runtime-smoke не входит в unit-CI**; он запускается отдельным скриптом
перед релизом.
## 5. Вывод
Текущая функциональность прошла первый LDD-контур: **P0 gaps = 0**,
полный стек и upgrade работают. Это достаточное основание продолжать
доработки, но не «формальное доказательство всей системы». Следующий цикл
LDD должен добавить:
1. mutation testing для критичных пакетов;
2. property-based тесты повышения и матрицы доступа;
3. браузерный E2E (Playwright) для ведомости, профиля и материалов;
4. нагрузочный smoke на чтение ведомости/профиля;
5. автоматический runtime-smoke в release-пайплайне.

463
docs/evidence/claims.json Normal file
View File

@@ -0,0 +1,463 @@
{
"version": 1,
"description": "LDD claims для текущей функциональности LMS. P0 без зелёного свидетельства валит ./gradlew evidenceCheck.",
"claims": [
{
"id": "LDD-ACCESS-001",
"title": "Преподаватель не получает доступ к чужому курсу, лабе, студенту или группе",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §5",
"claim": "Для любого преподавателя T и ресурса R чужого курса сервис возвращает 403/404 и не отдаёт данные R.",
"oracle": "HTTP-статус отказа и отсутствие данных в ответе",
"evidence": [
"ru.ysu.lms.academic.course.CourseAccessServiceTest#teacher cannot access a foreign course()",
"ru.ysu.lms.academic.course.CourseRosterServiceTest#foreign teacher is forbidden()",
"ru.ysu.lms.progress.TeacherWriteAccessIntegrationTest#teacher is forbidden to grade foreign lab()",
"ru.ysu.lms.progress.TeacherWriteAccessIntegrationTest#teacher is forbidden to grade student from another course()"
],
"limitations": "Проверены основные API-точки; UI-навигация не является границей безопасности."
},
{
"id": "LDD-ACCESS-002",
"title": "Студент видит только свои курсы, группу и опубликованные данные",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §5",
"claim": "Студент чужой группы не получает доступ к курсу, а в своём профиле видит только опубликованные оценки и комментарии.",
"oracle": "403 для чужого курса; отсутствие неопубликованных данных в ответе",
"evidence": [
"ru.ysu.lms.academic.course.CourseAccessServiceTest#student cannot access course of a foreign group()",
"ru.ysu.lms.progress.StudentProfileIntegrationTest#student sees only published grade and comments of own profile()",
"ru.ysu.lms.progress.StudentProfileIntegrationTest#teacher cannot read or download student from group outside course but admin can()"
],
"limitations": "Студенческий BFF-профиль не является отдельной страницей; проверен профильный API."
},
{
"id": "LDD-ACCESS-003",
"title": "Анонимный доступ ограничен глобальными материалами ALL",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §6",
"claim": "Аноним не читает курсовые и приватные материалы/активы; публичное дерево содержит только глобальные главы audience=ALL.",
"oracle": "401/404 для приватного; 200 только для ALL; состав публичного дерева",
"evidence": [
"ru.ysu.lms.academic.content.CourseContentIntegrationTest#anonymous cannot read course content()",
"ru.ysu.lms.academic.content.CourseContentIntegrationTest#public tree contains only global chapters with ALL audience()",
"ru.ysu.lms.academic.content.ContentAssetAccessIntegrationTest#course asset requires authentication()",
"ru.ysu.lms.academic.content.ContentAssetAccessIntegrationTest#published global ALL asset stays public()"
],
"limitations": "Legacy-активы без article_assets остаются публичными и вынесены в отдельный риск."
},
{
"id": "LDD-ACCESS-004",
"title": "Администратор сохраняет полный доступ",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §5",
"claim": "Администратор не ограничен курсовой областью и может читать/писать любой курс и профиль.",
"oracle": "Успешный доступ к чужому курсу/лабе/студенту для ADMIN",
"evidence": [
"ru.ysu.lms.academic.course.CourseAccessServiceTest#admin bypasses checks and has no course restriction()",
"ru.ysu.lms.academic.course.CourseAccessServiceTest#admin my courses contains every course()",
"ru.ysu.lms.progress.TeacherWriteAccessIntegrationTest#admin keeps access without course scope checks()"
],
"limitations": "Проверены сервисные проверки, не все административные UI-маршруты."
},
{
"id": "LDD-ACCESS-005",
"title": "Внутренние API identity защищены сервисным токеном и не раскрывают учётные данные",
"criticality": "P0",
"status": "active",
"requirement": "docs/13-stage3-report.md, §3",
"claim": "Без корректного X-Internal-Token внутренние API возвращают 403; ответы не содержат паролей, tempPassword и лишних данных.",
"oracle": "403 на неверный/пустой токен; отсутствие чувствительных полей в JSON",
"evidence": [
"ru.ysu.lms.identity.internal.InternalControllerIntegrationTest#invalid token is forbidden()",
"ru.ysu.lms.identity.internal.InternalControllerIntegrationTest#groups response exposes no credentials or students()",
"ru.ysu.lms.identity.audit.AuditIntegrationTest#invalid or missing internal token is forbidden and writes nothing()"
],
"limitations": "Проверены контекст студента, состав группы, список групп и аудит."
},
{
"id": "LDD-ACCESS-006",
"title": "Преподаватель управляет лабораторными только своего курса",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §5",
"claim": "TEACHER может создавать и изменять лабы/уровни/варианты своего курса, но не чужого; STUDENT не может писать.",
"oracle": "200 для своего курса; 403 для чужого; 403 для студента",
"evidence": [
"ru.ysu.lms.academic.lab.LabTeacherAccessIntegrationTest#teacher cannot update lab of foreign course()",
"ru.ysu.lms.academic.lab.LabTeacherAccessIntegrationTest#teacher can create lab in own course()",
"ru.ysu.lms.academic.lab.LabTeacherAccessIntegrationTest#student cannot update or create labs()"
],
"limitations": "Импорт ZIP проверен отдельными тестами парсера/копирования."
},
{
"id": "LDD-PROM-001",
"title": "Dry-run повышения не меняет данные и дедуплицируется по году",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §7",
"claim": "Построение dry-run создаёт только план; данные групп/студентов не меняются; повторный запуск за год не создаёт дубль.",
"oracle": "Снимок данных до/после и количество запусков",
"evidence": [
"ru.ysu.lms.identity.promotion.PromotionServiceIntegrationTest#dry run only plans and does not change data()",
"ru.ysu.lms.identity.promotion.PromotionServiceIntegrationTest#scheduled dry run guard respects date and deduplicates by year()"
],
"limitations": "Проверено на синтетических данных Testcontainers."
},
{
"id": "LDD-PROM-002",
"title": "Применение повышения переводит группы и студентов по правилам",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §7",
"claim": "Apply повышает курс групп, выпускает на пределе и переводит академических студентов в целевую группу.",
"oracle": "Группы, статусы, зачисления и membership_history после apply",
"evidence": [
"ru.ysu.lms.identity.promotion.PromotionServiceIntegrationTest#apply promotes groups graduates and moves academic students()"
],
"limitations": "Проверен один сценарий; граничные сочетания требуют property-based тестов."
},
{
"id": "LDD-PROM-003",
"title": "Повторное применение повышения идемпотентно",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §7",
"claim": "Повторный apply того же run не создаёт повторных изменений и истории.",
"oracle": "Состояние и количество history-записей после первого и второго apply",
"evidence": [
"ru.ysu.lms.identity.promotion.PromotionServiceIntegrationTest#repeated apply is idempotent()"
],
"limitations": "Конкурентный apply проверяется advisory lock, но отдельного нагрузочного теста нет."
},
{
"id": "LDD-PROM-004",
"title": "Откат повышения восстанавливает состояние и обнаруживает конфликт",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §7",
"claim": "Rollback возвращает before-state; при ручной правке после apply сообщает конфликт и не затирает данные.",
"oracle": "Снимок before/after и 409 при конфликте",
"evidence": [
"ru.ysu.lms.identity.promotion.PromotionServiceIntegrationTest#rollback restores data and removes created target group()",
"ru.ysu.lms.identity.promotion.PromotionServiceIntegrationTest#rollback reports manual conflict()"
],
"limitations": "Проверены группы и студенты; rollback файлов/аудита не применим."
},
{
"id": "LDD-LIFE-001",
"title": "Жизненный цикл студента пишет историю и переводит зачисление",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §8",
"claim": "Академ/возврат/отчисление/архив/восстановление меняют статусы, зачисление и membership_history по правилам.",
"oracle": "Статус student/account, группа зачисления, membership_history",
"evidence": [
"ru.ysu.lms.identity.roster.StudentLifecycleIntegrationTest#academic leave creates target cohort group and records history()",
"ru.ysu.lms.identity.roster.StudentLifecycleIntegrationTest#return from academic leave moves to requested active group()",
"ru.ysu.lms.identity.roster.StudentLifecycleIntegrationTest#expel blocks login and keeps current enrollment for history()",
"ru.ysu.lms.identity.roster.StudentLifecycleIntegrationTest#archive blocks login and restore returns student to active group()",
"ru.ysu.lms.identity.roster.StudentLifecycleIntegrationTest#restore returns an expelled student to active group()"
],
"limitations": "Проверены основные переходы; массовые операции не автоматизированы."
},
{
"id": "LDD-LIFE-002",
"title": "Вход закрыт для отчисленных, архивных и закрытых групп",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §1.3",
"claim": "EXPELLED/GRADUATED/ARCHIVED студенты и архивные аккаунты не проходят login; активный студент проходит.",
"oracle": "Unauthorized/403 при login и active=false в /auth/me",
"evidence": [
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#expelled student cannot login()",
"ru.ysu.lms.identity.roster.StudentLifecycleIntegrationTest#student in archived group cannot login()",
"ru.ysu.lms.identity.roster.StudentLifecycleIntegrationTest#student in active group can login()",
"ru.ysu.lms.identity.AuthServiceMeActiveTest#me is inactive for archived account()"
],
"limitations": "JWT с прежним сроком жизни остаётся остаточным риском."
},
{
"id": "LDD-PROFILE-001",
"title": "Профиль студента курса различает staff/student и публикацию",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §5.1",
"claim": "Преподаватель своего курса видит все лабы и неопубликованные данные; студент — только опубликованное; чужой преподаватель получает 403.",
"oracle": "Состав DTO профиля и HTTP-статус",
"evidence": [
"ru.ysu.lms.progress.StudentProfileIntegrationTest#teacher sees unpublished grade, authors and all submission attempts()",
"ru.ysu.lms.progress.StudentProfileIntegrationTest#student sees only published grade and comments of own profile()",
"ru.ysu.lms.progress.StudentProfileIntegrationTest#teacher of another course gets forbidden()"
],
"limitations": "Проверен API; UI-шаблон проверяется отдельно."
},
{
"id": "LDD-DOWNLOAD-001",
"title": "Скачивание сдачи ограничено курсом и студентом",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §5.1",
"claim": "Преподаватель скачивает только сдачи своего курса/своих студентов; сдача лабы другого курса отклоняется.",
"oracle": "ZIP для разрешённой сдачи; 403 для чужой лабы/студента",
"evidence": [
"ru.ysu.lms.progress.StudentProfileIntegrationTest#download rejects submission from another course lab and allows current course lab()",
"ru.ysu.lms.progress.TeacherLabPageAccessIntegrationTest#teacher downloads submission of own lab()",
"ru.ysu.lms.progress.TeacherLabPageAccessIntegrationTest#teacher is forbidden for foreign lab()"
],
"limitations": "Аудит скачивания проверяется отдельным claim."
},
{
"id": "LDD-CONTENT-001",
"title": "Курсовые материалы не утекают в публичное дерево",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §6",
"claim": "Курсовая глава недоступна через публичный article endpoint; студент видит только published; преподаватель видит hidden.",
"oracle": "404/отсутствие курсовой главы в public tree; состав courseTree",
"evidence": [
"ru.ysu.lms.academic.content.CourseContentIntegrationTest#course chapter is not exposed through public article endpoint()",
"ru.ysu.lms.academic.content.CourseContentIntegrationTest#student of course sees only published materials()",
"ru.ysu.lms.academic.content.CourseContentIntegrationTest#teacher of course sees published and hidden materials()"
],
"limitations": "Импорт и авторство проверяются отдельными тестами."
},
{
"id": "LDD-ASSET-001",
"title": "Видимость активов наследуется от статьи",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §6",
"claim": "Скрытый курсовой актив не отдаётся студенту, но доступен преподавателю; чужой курс получает 403; legacy без связи публичен.",
"oracle": "HTTP-статус и содержимое файла",
"evidence": [
"ru.ysu.lms.academic.content.ContentAssetAccessIntegrationTest#hidden course asset is not served to student but is served to teacher()",
"ru.ysu.lms.academic.content.ContentAssetAccessIntegrationTest#foreign teacher and student are forbidden for course asset()",
"ru.ysu.lms.academic.content.ContentAssetAccessIntegrationTest#legacy asset without article association stays public()"
],
"limitations": "Legacy-файлы без article_assets остаются публичными — отдельный остаточный риск."
},
{
"id": "LDD-MIG-001",
"title": "Миграция V13 идемпотентна",
"criticality": "P0",
"status": "active",
"requirement": "docs/16-stage6-report.md, §2.2",
"claim": "Повторное применение V13 не меняет схему и не падает.",
"oracle": "Повторный Flyway migrate и проверка колонок/индекса",
"evidence": [
"ru.ysu.lms.progress.migration.ProfileAuthorsMigrationTest#V13 profile authors migration is idempotent()"
],
"limitations": "Полный upgrade с master-схемы проверяется runtime-скриптом."
},
{
"id": "LDD-AUDIT-001",
"title": "Аудит защищён и доступен только администратору",
"criticality": "P0",
"status": "active",
"requirement": "docs/16-stage6-report.md, §2.3",
"claim": "Внутренняя запись требует X-Internal-Token; чтение /api/v1/audit требует ADMIN.",
"oracle": "403 без токена/для не-админа; 200 для админа",
"evidence": [
"ru.ysu.lms.identity.audit.AuditIntegrationTest#admin list requires admin role()",
"ru.ysu.lms.identity.audit.AuditIntegrationTest#invalid or missing internal token is forbidden and writes nothing()"
],
"limitations": "Проверены API; UI-страница проверяется отдельным шаблонным тестом."
},
{
"id": "LDD-AUDIT-002",
"title": "Аудит переживает удаление сущности и сохраняет поля",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §5.2",
"claim": "Событие аудита сохраняется после удаления студента/преподавателя и корректно отдаётся в списке.",
"oracle": "Наличие строки после delete; поля id/action/entity/occurredAt",
"evidence": [
"ru.ysu.lms.identity.audit.AuditIntegrationTest#identity hooks record teacher create and student delete that survives deletion()",
"ru.ysu.lms.identity.audit.AuditIntegrationTest#internal write is listed newest first with all fields()"
],
"limitations": "Проверены identity-хуки; cross-service хуки проверяются отдельными тестами."
},
{
"id": "LDD-ACCESS-007",
"title": "Преподаватель управляет группами своего курса",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §5",
"claim": "TEACHER привязывает/отвязывает активные группы только своего курса; чужой преподаватель и студент получают 403.",
"oracle": "200 и history-запись для своего курса; 403 для чужого",
"evidence": [
"ru.ysu.lms.academic.course.CourseGroupTeacherAccessTest#teacher sees available groups, attaches and detaches on own course()",
"ru.ysu.lms.academic.course.CourseGroupTeacherAccessTest#foreign teacher cannot list attach or detach groups()"
],
"limitations": "Проверено API; UI-форма — шаблонным тестом."
},
{
"id": "LDD-ACCESS-008",
"title": "Преподаватель авторствует материалы только своего курса",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §5",
"claim": "TEACHER создаёт/меняет/публикует главы, разделы и статьи своего курса; глобальные материалы — только ADMIN; чужой курс — 403.",
"oracle": "200 для своего курса; 403 для чужого; global routes только ADMIN",
"evidence": [
"ru.ysu.lms.academic.content.CourseContentAuthoringIntegrationTest#teacher creates chapter section and article in own course()",
"ru.ysu.lms.academic.content.CourseContentAuthoringIntegrationTest#foreign teacher cannot author course content()",
"ru.ysu.lms.academic.content.CourseContentAuthoringIntegrationTest#global chapter authoring stays admin only()"
],
"limitations": "Импорт ZIP остаётся админским; это осознанное ограничение."
},
{
"id": "LDD-ACCESS-009",
"title": "Ведомость преподавателя не показывает админские операции",
"criticality": "P1",
"status": "active",
"requirement": "docs/13-stage3-report.md, §4",
"claim": "В teacher-view скрыты пароли, аттестации и жизненный цикл; админский вид не меняется; админские URL закрыты интерсептором.",
"oracle": "Состав HTML и redirect для teacher",
"evidence": [
"ru.ysu.lms.web.admin.TeacherGradebookTemplateTest#teacher gradebook has no admin-only controls()",
"ru.ysu.lms.web.admin.TeacherGradebookTemplateTest#admin view is used when teacherView attribute is missing()",
"ru.ysu.lms.web.session.AdminInterceptorTest#teacher is redirected from admin-only group operations()"
],
"limitations": "Это UI-защита; серверные проверки — в LDD-ACCESS-001."
},
{
"id": "LDD-LIFE-003",
"title": "Удаление требует усиленного подтверждения",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §11",
"claim": "DELETE студента без совпадения confirm с login/ФИО отклоняется; корректный confirm удаляет.",
"oracle": "400 при неверном confirm; успешное удаление при верном",
"evidence": [
"ru.ysu.lms.identity.roster.StudentLifecycleIntegrationTest#delete requires confirm equal to login or full name()"
],
"limitations": "Проверен сервис; UI-диалог — шаблонным тестом."
},
{
"id": "LDD-PROM-005",
"title": "Отмена устаревшего dry-run разрешает перестроить план",
"criticality": "P1",
"status": "active",
"requirement": "docs/16-stage6-report.md, §2.3",
"claim": "Cancel переводит PLANNED dry-run в FAILED/CANCELLED; после этого можно построить новый план за год.",
"oracle": "Статус run и создание нового dry-run",
"evidence": [
"ru.ysu.lms.identity.promotion.PromotionServiceIntegrationTest#cancel marks planned dry run and allows rebuild()"
],
"limitations": "Конкурентный cancel/apply защищён advisory lock, отдельного теста нет."
},
{
"id": "LDD-IMPORT-001",
"title": "Импорт групп валидирует файл и не применяется частично",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §9",
"claim": "Ошибочные строки блокируют импорт целиком; корректный шаблон и дубли № распознаются.",
"oracle": "applied=false и отсутствие изменений при ошибках; отчёт парсера",
"evidence": [
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#does not apply import when there are row errors()",
"ru.ysu.lms.identity.roster.StudentImportParserTest#parses new template and splits full name()",
"ru.ysu.lms.identity.roster.StudentImportParserTest#reports duplicate student number()"
],
"limitations": "Старый формат не поддерживается; ведущие нули нормализуются."
},
{
"id": "LDD-LAB-002",
"title": "Копирование лабораторной не переносит прогресс и требует другой курс",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §10",
"claim": "Копия создаёт новые уровни/варианты в целевом курсе, не копирует оценки/сдачи; тот же/архивный курс отклоняется.",
"oracle": "Состав копии и отсутствие progress-строк; 400 для same/archived",
"evidence": [
"ru.ysu.lms.academic.lab.LabCopyIntegrationTest#copies lab with levels and variants into another course()",
"ru.ysu.lms.academic.lab.LabCopyIntegrationTest#copy rejects the source course as target()",
"ru.ysu.lms.academic.lab.LabCopyIntegrationTest#copy rejects an archived target course()"
],
"limitations": "Проверено на Testcontainers; UI-копирование — шаблонным тестом."
},
{
"id": "LDD-CONTRACT-001",
"title": "DTO-контракты web и сервисов совпадают",
"criticality": "P1",
"status": "active",
"requirement": "docs/09-teacher-student-profile.md",
"claim": "Ключевые JSON-контракты профиля, состава группы, аудита и повышения десериализуются web-слоем без потери полей.",
"oracle": "JsonIgnoreProperties + значения полей после десериализации",
"evidence": [
"ru.ysu.lms.web.profile.ProfileDtoJsonTest#deserializes profile contract()",
"ru.ysu.lms.web.api.CourseGroupRosterDtoTest#deserializes course group roster contract()",
"ru.ysu.lms.web.api.AuditDtoTest#deserializes audit event list with nulls()",
"ru.ysu.lms.web.api.PromotionDtoTest#deserializes promotion detail with item states and result()"
],
"limitations": "Это smoke-контракты, не consumer-driven contracts."
},
{
"id": "LDD-UI-001",
"title": "Ключевые страницы содержат нужные действия и не раскрывают лишнего",
"criticality": "P1",
"status": "active",
"requirement": "docs/16-stage6-report.md, §3.3",
"claim": "Шаблоны ведомости, материалов и аудита рендерят ожидаемые формы/ссылки и экранируют данные.",
"oracle": "Содержимое HTML и отсутствие запрещённых элементов",
"evidence": [
"ru.ysu.lms.web.teacher.TeacherCourseContentTemplateTest#content page renders create forms and full tree()",
"ru.ysu.lms.web.profile.StudentProfileTemplateTest#renders student profile with labs, comments and downloads()",
"ru.ysu.lms.web.admin.AdminAuditTemplateTest#renders audit events with labels and filter()"
],
"limitations": "Thymeleaf-тесты не заменяют браузерный E2E."
},
{
"id": "LDD-AUDIT-003",
"title": "Критичные действия progress пишут аудит",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §5.2",
"claim": "Оценка и публикация оценки создают audit-событие с actor из JWT и корректными идентификаторами.",
"oracle": "Вызов AuditClient с ожидаемыми action/entity/student/course",
"evidence": [
"ru.ysu.lms.progress.ProgressControllerAuditTest#grade write records audit entry for actor from jwt()",
"ru.ysu.lms.progress.ProgressControllerAuditTest#publication records student count()",
"ru.ysu.lms.identity.audit.AuditIntegrationTest#action filter and limit are applied()"
],
"limitations": "Проверены representative-хуки; остальные actions проверяются кодом и smoke."
},
{
"id": "LDD-MIG-002",
"title": "Upgrade с master-схемы применяет миграции ветки",
"criticality": "P1",
"status": "active",
"requirement": "docs/16-stage6-report.md, §2.2",
"claim": "Схема master + синтетические данные → миграции identity V4–V10, academic V6–V12, progress V13; V10 audit создаётся, V13 идемпотентна.",
"oracle": "Успешное применение и postflight-наличие таблиц/колонок/индексов",
"runtime": [
"migration.master_upgrade"
],
"limitations": "Запускается отдельным скриптом на одноразовой БД; не входит в unit-CI."
},
{
"id": "LDD-RUNTIME-001",
"title": "Полный стек поднимается и проходит E2E smoke",
"criticality": "P1",
"status": "active",
"requirement": "docs/16-stage6-report.md, §3.3",
"claim": "На чистой БД стартуют все сервисы, health=UP, admin setup/login работает, создаются группа/курс/преподаватель/лаба, чужой преподаватель получает 403, аудит доступен.",
"oracle": "HTTP health/status/JSON на реальном стеке",
"runtime": [
"runtime.stack_health",
"runtime.admin_setup_login",
"runtime.course_flow",
"runtime.foreign_teacher_denied",
"runtime.audit_visible",
"runtime.web_login_page"
],
"limitations": "Smoke не заменяет полный UI E2E и нагрузочные тесты."
}
]
}

195
scripts/ldd-migration-upgrade.sh Executable file
View File

@@ -0,0 +1,195 @@
#!/usr/bin/env bash
# LDD migration evidence: master-схема + данные -> миграции ветки.
# Одноразовая БД, рабочие данные не затрагиваются.
set -euo pipefail
cd "$(dirname "$0")/.."
DB="lms_ldd_mig_$(date -u +%H%M%S)_$$"
EVIDENCE="build/evidence/migration.json"
POSTGRES_WAS_RUNNING=0
FAILED=0
log() { echo ">> $*"; }
cleanup() {
local code=$?
docker exec -i lms-postgres psql -U lms -d postgres -v ON_ERROR_STOP=1 \
-c "DROP DATABASE IF EXISTS $DB;" >/dev/null 2>&1 || true
if ((POSTGRES_WAS_RUNNING == 0)); then
docker compose stop postgres >/dev/null 2>&1 || true
fi
exit $code
}
trap cleanup EXIT INT TERM
command -v docker >/dev/null 2>&1 || { echo "Нужен docker" >&2; exit 1; }
git rev-parse --verify master >/dev/null 2>&1 || { echo "Нужна ветка master в git" >&2; exit 1; }
if docker compose ps --status running --services 2>/dev/null | grep -qx postgres; then
POSTGRES_WAS_RUNNING=1
fi
docker compose up -d postgres >/dev/null
for _ in $(seq 1 40); do
docker exec lms-postgres pg_isready -U lms -d postgres >/dev/null 2>&1 && break
sleep 1
done
docker exec lms-postgres pg_isready -U lms -d postgres >/dev/null
docker exec -i lms-postgres psql -U lms -d postgres -v ON_ERROR_STOP=1 \
-c "CREATE DATABASE $DB;" >/dev/null
mkdir -p build/evidence
apply() {
docker exec -i lms-postgres psql -U lms -d "$DB" -v ON_ERROR_STOP=1 >/tmp/lms-ldd-migration.log
}
log "Разворачиваю master-схему и синтетические данные..."
{
git show master:services/identity-service/src/main/resources/db/migration/V1__init.sql
echo 'SET search_path TO identity;'
git show master:services/identity-service/src/main/resources/db/migration/V2__accounts.sql
git show master:services/identity-service/src/main/resources/db/migration/V3__login_attempts.sql
cat <<'SQL'
INSERT INTO accounts (id, login, password_hash, role, must_change_password) VALUES
('00000000-0000-7000-8000-000000001001','admin','x','ADMIN',false),
('00000000-0000-7000-8000-000000001002','s1','x','STUDENT',false);
INSERT INTO students (account_id, student_no, last_name, first_name) VALUES
('00000000-0000-7000-8000-000000001002','S1','Иванов','Иван');
INSERT INTO groups (id, name) VALUES ('00000000-0000-7000-8000-000000002001','ПИ-11БО');
INSERT INTO enrollments (group_id, student_id) VALUES
('00000000-0000-7000-8000-000000002001','00000000-0000-7000-8000-000000001002');
SQL
echo 'SET search_path TO academic;'
git show master:services/academic-service/src/main/resources/db/migration/V1__init.sql
git show master:services/academic-service/src/main/resources/db/migration/V2__schema.sql
git show master:services/academic-service/src/main/resources/db/migration/V3__seed.sql
git show master:services/academic-service/src/main/resources/db/migration/V4__content.sql
git show master:services/academic-service/src/main/resources/db/migration/V5__content_published.sql
cat <<'SQL'
INSERT INTO labs (id, number, title, semester_id) VALUES
('00000000-0000-7000-8000-000000003001',1,'ЛР 1','00000000-0000-7000-8000-000000000001');
INSERT INTO chapters (id, title, slug) VALUES
('00000000-0000-7000-8000-000000003002','Глава','osnovy-c');
SQL
echo 'SET search_path TO progress;'
git show master:services/progress-service/src/main/resources/db/migration/V1__init.sql
git show master:services/progress-service/src/main/resources/db/migration/V2__schema.sql
git show master:services/progress-service/src/main/resources/db/migration/V3__grades.sql
git show master:services/progress-service/src/main/resources/db/migration/V4__submissions.sql
git show master:services/progress-service/src/main/resources/db/migration/V5__defense_sessions.sql
git show master:services/progress-service/src/main/resources/db/migration/V6__grade_comments.sql
git show master:services/progress-service/src/main/resources/db/migration/V7__grade_publications.sql
git show master:services/progress-service/src/main/resources/db/migration/V8__grade_publication_students.sql
git show master:services/progress-service/src/main/resources/db/migration/V9__grade_general_comment.sql
git show master:services/progress-service/src/main/resources/db/migration/V11__attestations.sql
git show master:services/progress-service/src/main/resources/db/migration/V12__attestation_semester_and_publication.sql
cat <<'SQL'
INSERT INTO lab_grades (student_id, lab_id, score) VALUES
('00000000-0000-7000-8000-000000001002','00000000-0000-7000-8000-000000003001',4.5);
INSERT INTO grade_comments (student_id, lab_id, text) VALUES
('00000000-0000-7000-8000-000000001002','00000000-0000-7000-8000-000000003001','ok');
SQL
} | apply
log "Применяю миграции ветки..."
{
echo 'SET search_path TO identity;'
cat services/identity-service/src/main/resources/db/migration/V4__roles.sql
cat services/identity-service/src/main/resources/db/migration/V5__groups_cohorts.sql
cat services/identity-service/src/main/resources/db/migration/V6__student_statuses.sql
cat services/identity-service/src/main/resources/db/migration/V7__promotion.sql
cat services/identity-service/src/main/resources/db/migration/V8__membership_history.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
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/V7__labs_course.sql
cat services/academic-service/src/main/resources/db/migration/V8__chapters_course.sql
cat services/academic-service/src/main/resources/db/migration/V9__article_assets_indexes.sql
cat services/academic-service/src/main/resources/db/migration/V10__course_title_unique.sql
cat services/academic-service/src/main/resources/db/migration/V11__global_audience_all.sql
cat services/academic-service/src/main/resources/db/migration/V12__asset_links_backfill.sql
echo 'SET search_path TO progress;'
cat services/progress-service/src/main/resources/db/migration/V13__profile_authors.sql
} | apply
log "Проверяю postflight..."
CHECK_SQL=$(cat <<'SQL'
SET search_path TO identity;
SELECT 'audit_events=' || count(*) FROM information_schema.tables
WHERE table_schema='identity' AND table_name='audit_events';
SELECT 'students=' || count(*) FROM students;
SELECT 'groups=' || count(*) FROM groups;
SELECT 'history=' || count(*) FROM membership_history;
SET search_path TO academic;
SELECT 'labs_course_id=' || count(*) FROM information_schema.columns
WHERE table_schema='academic' AND table_name='labs' AND column_name='course_id';
SELECT 'chapters_course_id=' || count(*) FROM information_schema.columns
WHERE table_schema='academic' AND table_name='chapters' AND column_name='course_id';
SELECT 'article_assets=' || count(*) FROM information_schema.tables
WHERE table_schema='academic' AND table_name='article_assets';
SELECT 'labs=' || count(*) FROM labs;
SELECT 'chapters=' || count(*) FROM chapters;
SET search_path TO progress;
SELECT 'grade_author=' || count(*) FROM information_schema.columns
WHERE table_schema='progress' AND table_name='grade_comments' AND column_name='author_account_id';
SELECT 'grade_updated_by=' || count(*) FROM information_schema.columns
WHERE table_schema='progress' AND table_name='lab_grades' AND column_name='updated_by_account_id';
SELECT 'grades=' || count(*) FROM lab_grades;
SELECT 'comments=' || count(*) FROM grade_comments;
SQL
)
RESULT=$(docker exec -i lms-postgres psql -U lms -d "$DB" -At -v ON_ERROR_STOP=1 -c "$CHECK_SQL")
# V13 повторно: идемпотентность.
if { echo 'SET search_path TO progress;'; cat services/progress-service/src/main/resources/db/migration/V13__profile_authors.sql; } | apply; then
V13_TWICE="passed"
else
V13_TWICE="failed"
FAILED=1
fi
printf '%s\n' "$RESULT" | sed 's/^/ /'
python3 - "$EVIDENCE" "$RESULT" "$V13_TWICE" <<'PY'
import json
import sys
out, result, v13 = sys.argv[1], sys.argv[2], sys.argv[3]
required = {
"audit_events=1",
"students=1",
"groups=1",
"history=1",
"labs_course_id=1",
"chapters_course_id=1",
"article_assets=1",
"labs=1",
"chapters=1",
"grade_author=1",
"grade_updated_by=1",
"grades=1",
"comments=1",
}
lines = {line.strip() for line in result.splitlines() if line.strip()}
missing = sorted(required - lines)
failed = bool(missing) or v13 != "passed"
if missing:
status = "failed"
detail = "postflight не подтвердил: " + ", ".join(missing)
elif v13 != "passed":
status = "failed"
detail = "V13 не применилась повторно"
else:
status = "passed"
detail = "master->branch: все postflight-проверки и повторная V13 успешны"
checks = [{"id": "migration.master_upgrade", "status": status, "detail": detail}]
with open(out, "w", encoding="utf-8") as fh:
now = __import__("datetime").datetime.now(__import__("datetime").UTC).isoformat()
json.dump({"generatedAt": now, "checks": checks}, fh, ensure_ascii=False, indent=2)
print(("PASS " if not failed else "FAIL ") + "migration.master_upgrade: " + detail)
sys.exit(1 if failed else 0)
PY
log "Migration evidence: $EVIDENCE"

312
scripts/ldd-runtime.sh Executable file
View File

@@ -0,0 +1,312 @@
#!/usr/bin/env bash
# LDD runtime smoke: поднимает полный стек на одноразовой БД и проверяет
# ключевые сценарии через реальные HTTP-вызовы. Не трогает рабочую БД lms.
#
# ./scripts/ldd-runtime.sh
#
# Требования: docker compose v2, Java 21, curl, python3. Скрипт отказывается
# работать, если порты 8080/8081/8082/8083/8090 уже заняты.
set -euo pipefail
cd "$(dirname "$0")/.."
DB="lms_ldd_$(date -u +%H%M%S)_$$"
EVIDENCE="build/evidence/runtime.json"
POSTGRES_WAS_RUNNING=0
STACK_STARTED=0
log() { echo ">> $*"; }
cleanup() {
local code=$?
if ((STACK_STARTED)); then
log "Останавливаю тестовый стек..."
./run.sh stop >/dev/null 2>&1 || true
fi
docker exec -i lms-postgres psql -U lms -d postgres -v ON_ERROR_STOP=1 \
-c "DROP DATABASE IF EXISTS $DB;" >/dev/null 2>&1 || true
if ((POSTGRES_WAS_RUNNING == 0)); then
docker compose stop postgres >/dev/null 2>&1 || true
fi
exit $code
}
trap cleanup EXIT INT TERM
for port in 8080 8081 8082 8083 8090; do
if ss -ltn 2>/dev/null | grep -q ":$port "; then
echo "Порт $port занят. Остановите текущий стек (./run.sh stop) и повторите." >&2
exit 1
fi
done
command -v docker >/dev/null 2>&1 || { echo "Нужен docker" >&2; exit 1; }
command -v python3 >/dev/null 2>&1 || { echo "Нужен python3" >&2; exit 1; }
JAVA_BIN="${JAVA_BIN:-/usr/lib/jvm/java-21-openjdk/bin/java}"
[[ -x "$JAVA_BIN" ]] || { echo "Не найден Java 21: $JAVA_BIN" >&2; exit 1; }
if docker compose ps --status running --services 2>/dev/null | grep -qx postgres; then
POSTGRES_WAS_RUNNING=1
fi
log "Запускаю PostgreSQL..."
docker compose up -d postgres >/dev/null
for _ in $(seq 1 40); do
docker exec lms-postgres pg_isready -U lms -d postgres >/dev/null 2>&1 && break
sleep 1
done
docker exec lms-postgres pg_isready -U lms -d postgres >/dev/null
log "Создаю одноразовую БД $DB..."
docker exec -i lms-postgres psql -U lms -d postgres -v ON_ERROR_STOP=1 \
-c "CREATE DATABASE $DB;" >/dev/null
log "Собираю bootJar..."
./gradlew bootJar --console=plain >/tmp/lms-ldd-bootjar.log
log "Запускаю стек..."
export LMS_DB_URL="jdbc:postgresql://localhost:5432/$DB"
export LMS_DB_NAME="$DB"
export LMS_DB_USER="lms"
export LMS_DB_PASSWORD="lms"
export LMS_INTERNAL_TOKEN="ldd-internal-token"
export LMS_SESSION_COOKIE_SECURE="false"
export JAVA_BIN
STACK_STARTED=1
./run.sh start
mkdir -p "$(dirname "$EVIDENCE")"
log "Проверяю HTTP-сценарии..."
python3 - "$EVIDENCE" <<'PY'
import json
import sys
import urllib.error
import urllib.request
import uuid
out_path = sys.argv[1]
gateway = "http://localhost:8080"
web = "http://localhost:8090"
checks = []
failures = []
def record(check_id, status, detail):
checks.append({"id": check_id, "status": status, "detail": detail})
print(("PASS " if status == "passed" else "FAIL ") + check_id + ": " + detail)
if status != "passed":
failures.append(check_id)
def req(method, url, data=None, token=None):
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = "Bearer " + token
body = json.dumps(data).encode() if data is not None else None
request = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(request, timeout=30) as response:
raw = response.read().decode()
return response.status, json.loads(raw) if raw.strip().startswith(("{", "[")) else raw
except urllib.error.HTTPError as exc:
raw = exc.read().decode()
try:
parsed = json.loads(raw)
except Exception:
parsed = raw
return exc.code, parsed
def expect(check_id, method, url, expected, data=None, token=None, detail=""):
status, body = req(method, url, data=data, token=token)
if status != expected:
record(check_id, "failed", f"{detail or url}: ожидался {expected}, получен {status}: {body}")
raise AssertionError(check_id)
record(check_id, "passed", detail or f"{method} {url} -> {status}")
return body
try:
# 1. Health всех сервисов.
for name, port in [("identity", 8081), ("academic", 8082), ("progress", 8083), ("gateway", 8080), ("web", 8090)]:
status, body = req("GET", f"http://localhost:{port}/actuator/health")
if status != 200 or not isinstance(body, dict) or body.get("status") != "UP":
record("runtime.stack_health", "failed", f"{name}: {status} {body}")
raise AssertionError("stack_health")
record("runtime.stack_health", "passed", "identity/academic/progress/gateway/web health=UP")
# 2. Admin setup + login через gateway.
health = expect("runtime.admin_setup_login", "GET", gateway + "/api/v1/auth/health", 200)
if health.get("adminExists") is not False:
record("runtime.admin_setup_login", "failed", f"adminExists до setup: {health}")
raise AssertionError("adminExists")
admin = expect(
"runtime.admin_setup_login",
"POST",
gateway + "/api/v1/auth/setup",
200,
data={"login": "ldd-admin", "password": "ldd-password-123", "password2": "ldd-password-123"},
detail="setup admin",
)
admin_token = admin["token"]
me = expect("runtime.admin_setup_login", "GET", gateway + "/api/v1/auth/me", 200, token=admin_token)
if me.get("role") != "ADMIN" or not me.get("active", False):
record("runtime.admin_setup_login", "failed", f"/auth/me: {me}")
raise AssertionError("me")
# 3. Реальный доменный поток: группа -> курс -> преподаватель -> группа курса -> лаба.
suffix = uuid.uuid4().hex[:6]
group = expect(
"runtime.course_flow",
"POST",
gateway + "/api/v1/groups",
200,
data={"stream": "LDD" + suffix, "course": 1, "number": 1, "degree": "БО", "enrollmentYear": 2026},
token=admin_token,
detail="create group",
)
course = expect(
"runtime.course_flow",
"POST",
gateway + "/api/v1/courses",
200,
data={"title": "LDD runtime course " + suffix},
token=admin_token,
detail="create course",
)
teacher = expect(
"runtime.course_flow",
"POST",
gateway + "/api/v1/teachers",
200,
data={"login": "ldd-teacher-" + suffix, "password": "ldd-teacher-123"},
token=admin_token,
detail="create teacher",
)
expect(
"runtime.course_flow",
"POST",
gateway + "/api/v1/courses/" + course["id"] + "/teachers",
200,
data={"accountId": teacher["id"]},
token=admin_token,
detail="assign teacher",
)
expect(
"runtime.course_flow",
"POST",
gateway + "/api/v1/courses/" + course["id"] + "/groups",
200,
data={"groupId": group["id"]},
token=admin_token,
detail="attach group",
)
lab = expect(
"runtime.course_flow",
"POST",
gateway + "/api/v1/labs",
200,
data={
"number": 1,
"title": "LDD lab",
"status": "published",
"courseId": course["id"],
"levels": [{"code": "basic", "title": "Базовый", "position": 1}],
},
token=admin_token,
detail="create lab",
)
teacher_token = expect(
"runtime.course_flow",
"POST",
gateway + "/api/v1/auth/login",
200,
data={"login": "ldd-teacher-" + suffix, "password": "ldd-teacher-123"},
detail="teacher login",
)["token"]
mine = expect("runtime.course_flow", "GET", gateway + "/api/v1/courses/mine", 200, token=teacher_token)
if not any(item.get("id") == course["id"] for item in mine):
record("runtime.course_flow", "failed", "курс не найден в /courses/mine преподавателя")
raise AssertionError("mine")
labs = expect(
"runtime.course_flow",
"GET",
gateway + "/api/v1/labs?courseId=" + course["id"],
200,
token=teacher_token,
)
if not any(item.get("id") == lab["id"] for item in labs):
record("runtime.course_flow", "failed", "лаба не найдена у преподавателя")
raise AssertionError("lab list")
roster = expect(
"runtime.course_flow",
"GET",
gateway + "/api/v1/courses/" + course["id"] + "/groups/" + group["id"] + "/students",
200,
token=teacher_token,
)
if roster.get("groupId") != group["id"]:
record("runtime.course_flow", "failed", f"roster: {roster}")
raise AssertionError("roster")
record("runtime.course_flow", "passed", "группа/курс/преподаватель/лаба/roster доступны преподавателю")
# 4. Чужой преподаватель не получает доступ к курсу/лабе/аудиту.
outsider = expect(
"runtime.foreign_teacher_denied",
"POST",
gateway + "/api/v1/teachers",
200,
data={"login": "ldd-outsider-" + suffix, "password": "ldd-outsider-123"},
token=admin_token,
detail="create outsider",
)
outsider_token = expect(
"runtime.foreign_teacher_denied",
"POST",
gateway + "/api/v1/auth/login",
200,
data={"login": "ldd-outsider-" + suffix, "password": "ldd-outsider-123"},
detail="outsider login",
)["token"]
expect("runtime.foreign_teacher_denied", "GET", gateway + "/api/v1/courses/" + course["id"], 403, token=outsider_token, detail="foreign course 403")
expect("runtime.foreign_teacher_denied", "GET", gateway + "/api/v1/labs/" + lab["id"], 403, token=outsider_token, detail="foreign lab 403")
expect("runtime.foreign_teacher_denied", "GET", gateway + "/api/v1/audit", 403, token=outsider_token, detail="audit 403")
record("runtime.foreign_teacher_denied", "passed", "чужой преподаватель получает 403 на курс/лабу/аудит")
# 5. Аудит доступен админу и содержит ключевые события.
events = expect("runtime.audit_visible", "GET", gateway + "/api/v1/audit?limit=200", 200, token=admin_token)
actions = {event.get("action") for event in events}
required = {"COURSE_CREATE", "TEACHER_CREATE", "COURSE_TEACHER_ASSIGN", "COURSE_GROUP_ATTACH", "LAB_CREATE"}
absent = required - actions
if absent:
record("runtime.audit_visible", "failed", f"в аудите нет: {sorted(absent)}; есть: {sorted(actions)}")
raise AssertionError("audit actions")
record("runtime.audit_visible", "passed", f"аудит содержит {sorted(required)}")
# 6. Web-страница входа и публичное дерево.
status, _ = req("GET", web + "/login")
if status != 200:
record("runtime.web_login_page", "failed", f"/login -> {status}")
raise AssertionError("web login")
record("runtime.web_login_page", "passed", "web /login -> 200")
expect("runtime.web_login_page", "GET", gateway + "/api/v1/content/tree", 200, detail="public content tree")
expect("runtime.web_login_page", "GET", gateway + "/api/v1/courses/" + course["id"] + "/content/tree", 401, detail="anonymous course content 401")
except AssertionError:
pass
except Exception as exc:
record("runtime.unexpected", "failed", f"{type(exc).__name__}: {exc}")
with open(out_path, "w", encoding="utf-8") as fh:
now = __import__("datetime").datetime.now(__import__("datetime").UTC).isoformat()
json.dump({"generatedAt": now, "checks": checks}, fh, ensure_ascii=False, indent=2)
if failures:
print("Неуспешные runtime-проверки: " + ", ".join(failures), file=sys.stderr)
sys.exit(1)
print("Все runtime-проверки пройдены.")
PY
if [[ -f "$EVIDENCE" ]]; then
log "Runtime evidence: $EVIDENCE"
fi