diff --git a/README.md b/README.md index 92815fb..33c7d66 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,10 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`. ```bash ./gradlew ktlintCheck detekt test # линтеры (строгий режим) + тесты +./gradlew evidenceCheck # LDD: P0-claims должны иметь зелёные свидетельства ./gradlew ktlintFormat # автоформат Kotlin +./scripts/ldd-runtime.sh # runtime-smoke полного стека на одноразовой БД +./scripts/ldd-migration-upgrade.sh # upgrade с master-схемы ``` - `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 не работает на JDK 25. - Тестовая инфраструктура — `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. ## Запуск в Docker (для сервера) diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..8f05f2f --- /dev/null +++ b/build.gradle.kts @@ -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("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")) +} diff --git a/buildSrc/src/main/kotlin/lms/evidence/EvidenceCheckTask.kt b/buildSrc/src/main/kotlin/lms/evidence/EvidenceCheckTask.kt new file mode 100644 index 0000000..11dfdd2 --- /dev/null +++ b/buildSrc/src/main/kotlin/lms/evidence/EvidenceCheckTask.kt @@ -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() + 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() + val gaps = mutableListOf() + val p0Gaps = mutableListOf() + val rows = mutableListOf() + + 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> { + if (!file.isFile) throw GradleException("LDD: не найден ${file.path}") + val root = JsonSlurper().parse(file) as Map + return (root["claims"] as? List>).orEmpty() + } + + @Suppress("UNCHECKED_CAST") + private fun parseRuntimeChecks(files: Set): Map { + val checks = mutableMapOf() + for (file in files.filter { it.isFile }) { + val root = JsonSlurper().parse(file) as Map + val list = (root["checks"] as? List>).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 = + (value as? List<*>).orEmpty().mapNotNull { it?.toString()?.trim()?.takeIf(String::isNotEmpty) }.distinct() +} diff --git a/docs/evidence/README.md b/docs/evidence/README.md new file mode 100644 index 0000000..9ead095 --- /dev/null +++ b/docs/evidence/README.md @@ -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`. diff --git a/docs/evidence/baseline-2026-10-27.md b/docs/evidence/baseline-2026-10-27.md new file mode 100644 index 0000000..99ba1b0 --- /dev/null +++ b/docs/evidence/baseline-2026-10-27.md @@ -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-пайплайне. diff --git a/docs/evidence/claims.json b/docs/evidence/claims.json new file mode 100644 index 0000000..667a222 --- /dev/null +++ b/docs/evidence/claims.json @@ -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 и нагрузочные тесты." + } + ] +} diff --git a/scripts/ldd-migration-upgrade.sh b/scripts/ldd-migration-upgrade.sh new file mode 100755 index 0000000..30105db --- /dev/null +++ b/scripts/ldd-migration-upgrade.sh @@ -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" diff --git a/scripts/ldd-runtime.sh b/scripts/ldd-runtime.sh new file mode 100755 index 0000000..353cc97 --- /dev/null +++ b/scripts/ldd-runtime.sh @@ -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