#!/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="${LDD_EVIDENCE_FILE:-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:-$(command -v java || true)}" [[ -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 concurrent.futures import json import os import statistics import subprocess import sys import time 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", ) student = expect( "runtime.course_flow", "POST", gateway + "/api/v1/groups/" + group["id"] + "/students", 200, data={ "studentNo": "ldd-student-" + suffix, "lastName": "Тестов", "firstName": "Студент", "middleName": "", "basis": "бюджет", "note": "", }, token=admin_token, detail="create student", ) teacher_auth = expect( "runtime.course_flow", "POST", gateway + "/api/v1/auth/login", 200, data={"login": "ldd-teacher-" + suffix, "password": "ldd-teacher-123"}, detail="teacher login", ) teacher_token = teacher_auth["token"] teacher_refresh = teacher_auth["refreshToken"] 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 доступны преподавателю") # 3a. Refresh/logout через gateway: без access-токена, с ротацией. refreshed = expect( "runtime.auth_refresh", "POST", gateway + "/api/v1/auth/refresh", 200, data={"refreshToken": teacher_refresh}, detail="refresh via gateway", ) expect( "runtime.auth_refresh", "GET", gateway + "/api/v1/courses/mine", 200, token=refreshed["token"], detail="new access works", ) expect( "runtime.auth_refresh", "POST", gateway + "/api/v1/auth/logout", 204, data={"refreshToken": refreshed["refreshToken"]}, detail="logout via gateway", ) expect( "runtime.auth_refresh", "POST", gateway + "/api/v1/auth/refresh", 401, data={"refreshToken": refreshed["refreshToken"]}, detail="revoked refresh rejected", ) teacher_token = refreshed["token"] record("runtime.auth_refresh", "passed", "refresh ротируется, новый access работает, logout отзывает refresh") # 3b. Масштабный seed для release-нагрузки: 100 групп / 1000 студентов. if os.environ.get("LDD_SCALE") == "1": scale_sql = f""" WITH nums AS (SELECT g FROM generate_series(1, 1000) g), acc AS ( INSERT INTO identity.accounts (id, login, password_hash, role, must_change_password) SELECT uuidv7(), 'scale-s-' || g, 'x', 'STUDENT', false FROM nums RETURNING id, login ) INSERT INTO identity.students (account_id, student_no, last_name, first_name) SELECT id, 'SCALE-' || lpad(split_part(login, '-', 3), 4, '0'), 'Студентов', 'Студент' FROM acc; INSERT INTO identity.groups (id, name, stream, course, number, degree, enrollment_year, status) SELECT uuidv7(), 'SCALE-' || g || 'БО', 'SCALE', 1, g, 'БО', 2026, 'ACTIVE' FROM generate_series(1, 100) g; INSERT INTO academic.course_groups (id, course_id, group_id, added_at) SELECT uuidv7(), '{course["id"]}', id, now() FROM identity.groups WHERE stream = 'SCALE'; WITH g AS ( SELECT id, row_number() OVER (ORDER BY name) rn FROM identity.groups WHERE stream = 'SCALE' ), s AS ( SELECT account_id, row_number() OVER (ORDER BY student_no) rn FROM identity.students WHERE student_no LIKE 'SCALE-%' ) INSERT INTO identity.enrollments (id, group_id, student_id) SELECT uuidv7(), g.id, s.account_id FROM g JOIN s ON ((s.rn - 1) / 10) + 1 = g.rn; """ subprocess.run( ["docker", "exec", "-i", "lms-postgres", "psql", "-U", "lms", "-d", os.environ["LMS_DB_NAME"], "-v", "ON_ERROR_STOP=1"], input=scale_sql, text=True, check=True, stdout=subprocess.DEVNULL, ) record("runtime.scale_seed", "passed", "100 групп / 1000 студентов") # 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)}") # 5b. Нагрузочный smoke чтения: курс, лабы, профиль и аудит. smoke_only = os.environ.get("LDD_SMOKE") == "1" load_paths = [ (gateway + "/api/v1/courses/" + course["id"], teacher_token), (gateway + "/api/v1/labs?courseId=" + course["id"], teacher_token), (gateway + "/api/v1/courses/" + course["id"] + "/students/" + student["id"] + "/profile", teacher_token), (gateway + "/api/v1/courses/" + course["id"] + "/groups", teacher_token), (gateway + "/api/v1/audit?limit=100", admin_token), ] def timed_get(path, token): started = time.perf_counter() status, _ = req("GET", path, token=token) return status, time.perf_counter() - started if not smoke_only: latencies = [] errors = 0 with concurrent.futures.ThreadPoolExecutor(max_workers=10) as pool: futures = [ pool.submit(timed_get, path, token) for _ in range(25) for path, token in load_paths ] for future in concurrent.futures.as_completed(futures): status, latency = future.result() latencies.append(latency) if status != 200: errors += 1 latencies.sort() p95 = latencies[min(len(latencies) - 1, int(len(latencies) * 0.95))] average = statistics.mean(latencies) load_ok = errors == 0 and p95 < 2.0 record( "runtime.load_gradebook_profile", "passed" if load_ok else "failed", f"requests={len(latencies)}, errors={errors}, avg={average:.3f}s, p95={p95:.3f}s", ) if not load_ok: raise AssertionError("load smoke") # 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