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

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