From 8adaa4d6eadc40329030ddda1179318e5a00d3e5 Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 5 Oct 2026 12:29:17 +0300 Subject: [PATCH 1/2] test: prepare isolated PostgreSQL publication integration checks --- README.md | 4 +- app/version.py | 6 +- docs/postgresql-import-verification.md | 96 ++++++++ pyproject.toml | 2 +- tests/postgres/Dockerfile | 7 + tests/postgres/Dockerfile.dockerignore | 18 ++ tests/postgres/compose.yml | 36 +++ tests/postgres/init.sql | 6 + tests/test_api.py | 2 +- tests/test_publication_import_postgres.py | 280 ++++++++++++++++++++++ 10 files changed, 451 insertions(+), 6 deletions(-) create mode 100644 docs/postgresql-import-verification.md create mode 100644 tests/postgres/Dockerfile create mode 100644 tests/postgres/Dockerfile.dockerignore create mode 100644 tests/postgres/compose.yml create mode 100644 tests/postgres/init.sql create mode 100644 tests/test_publication_import_postgres.py diff --git a/README.md b/README.md index 552b27c..515d3fc 100644 --- a/README.md +++ b/README.md @@ -141,8 +141,10 @@ node --check app/static/admin.js Тесты с данными используют временную SQLite; отдельные API smoke-тесты запускают приложение с его текущей конфигурацией. Браузерная проверка: `pip install playwright`, `python -m playwright install chromium`, затем `python tests/browser_admin.py`. Она проверяет реальный рендеринг страниц, клавиатуру, диалог, фильтры и восстановление прогресса после ошибки. Скриншоты сохраняются во временную папку; путь выводится в конце. -Версия сервиса: `0.10.2`. Админка всегда показывает версии backend и frontend в footer. +Версия сервиса: `0.10.3`. Админка всегда показывает версии backend и frontend в footer. ## Разовый импорт публикаций Инструкция, минимальные права временной роли и ограниченный SSH-запуск: [docs/publication-import.md](docs/publication-import.md). По умолчанию импортер выполняет dry-run; production-применение требует отдельного решения владельца. + +Интеграционная проверка импорта на отдельном PostgreSQL: [docs/postgresql-import-verification.md](docs/postgresql-import-verification.md). diff --git a/app/version.py b/app/version.py index 5f2e035..50915db 100644 --- a/app/version.py +++ b/app/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.10.2" -FRONTEND_VERSION = "0.10.2" -BACKEND_VERSION = "0.10.2" +APP_VERSION = "0.10.3" +FRONTEND_VERSION = "0.10.3" +BACKEND_VERSION = "0.10.3" diff --git a/docs/postgresql-import-verification.md b/docs/postgresql-import-verification.md new file mode 100644 index 0000000..50d32ee --- /dev/null +++ b/docs/postgresql-import-verification.md @@ -0,0 +1,96 @@ +# Проверка импорта на изолированном PostgreSQL + +Запускается средствами проекта: pytest, SQLAlchemy, psycopg и отдельный Docker +Compose. Не использует production Compose, `.env`, SSH или данные сотрудников. +PostgreSQL 16 и runner работают в отдельной `internal`-сети без опубликованных +портов. Данные PostgreSQL находятся в tmpfs, без постоянных volumes. Базы: +`miem_import_test` и `miem_crawl_test`. Все сотрудники, статьи и credentials +синтетические; тестовые пароли непригодны для реальных систем. + +Dockerfile копирует только нужный код и requirements. Его отдельный dockerignore +исключает `.git`, `.env`, файлы ключей/credentials, CSV владельца, данные +`app/data` и остальные файлы workspace. Runtime не получает переменные хоста. +CLI subprocess дополнительно получает только явно заданные тестовые libpq +параметры; `.pgpass`/service-файлы отключены. Не изменяйте адрес `postgres-test` +или имена тестовых БД для подключения к существующему серверу. + +Синтетическая роль `import_test` создаётся только init-скриптом одноразового +тестового кластера. Fixture выдаёт ей документированные column grants и USAGE +sequence. Проверяется отсутствие CREATE схемы, INSERT сотрудников, DELETE +публикаций и sequence UPDATE. Это не создание временного доступа к рабочей БД. +Таблицы создаются моделью приложения и очищаются только в тестовых БД. + +HTTP заблокирован test fixture; входные профили полного обхода подставляются +локально. Это проверяет настоящий SQL, транзакции, savepoint и сериализацию JSON, +но не парсинг сайта ВШЭ. Fixtures SQLite и подмена psycopg для интеграционных +тестов не используются. + +## Что проверяется + +1. Dry-run: все строки всех таблиц и состояния sequences совпадают до/после. +2. Apply: одна вставка, одно обновление, пропуск отсутствующего сотрудника. +3. Повторный импорт: ноль INSERT/UPDATE, неизменные таблицы/sequences. +4. CHECK constraint тестовой БД вызывает ошибку третьего действия после UPDATE + и успешного INSERT; все строки возвращаются в исходное состояние. Sequence + может продвинуться при rollback — это ожидаемое поведение PostgreSQL. +5. Обновление только года без raw_data сохраняет существующий JSONB. +6. Поздние строки CSV дополняют метаданные; первое непустое значение сохраняется + при конфликте, отчёт содержит названия конфликтующих полей. Повтор идемпотентен. +7. Два последовательных crawl сохраняют CSV-статью, отсутствующую у ВШЭ; + появившаяся статья получает HSE ID без второй записи и потери метаданных. +8. Полный `run_crawl` обрабатывает двух сотрудников. Два HSE ID на одну CSV-запись + откатывают savepoint первого, сохраняя JSONB, авторов, ID, хеш и updated_at; + публикация второго сохраняется. Проверяются обе очередности ID и уже + установленная привязка; неоднозначность отражается в CrawlError. + +Всего 10 PostgreSQL-тестов (восьмой сценарий параметризован). В обычном +`python -m pytest -q` они пропускаются. Opt-in `MIEM_POSTGRES_INTEGRATION=1` +разрешён только с точным тестовым DATABASE_URL Compose-проекта. + +## Команды владельцу + +Выполняйте на локальной машине с Docker Engine/Compose v2 из корня репозитория +после независимого ревью. Команды явно выбирают локальный Unix socket и очищают +наследуемое окружение. Для Docker Desktop/Windows используйте Linux/WSL с +доступным локальным `/var/run/docker.sock`; не подменяйте его удалённым VPS. +Первый шаг: + +```sh +env -i PATH="$PATH" docker --host unix:///var/run/docker.sock version +``` + +Ожидается exit code 0 и секции `Client` и `Server`. `command not found`, +`permission denied` или только Client с ошибкой подключения означает, что +до запуска тестов нужно восстановить локальный Docker; не подключайтесь к VPS. + +Дальнейшие точные команды сохранены здесь для воспроизводимого запуска: + +```sh +env -i PATH="$PATH" docker --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration -f tests/postgres/compose.yml config --quiet +env -i PATH="$PATH" docker --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration -f tests/postgres/compose.yml build tests +env -i PATH="$PATH" docker --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration -f tests/postgres/compose.yml up --abort-on-container-exit --exit-code-from tests +``` + +Первые две команды проверяют Compose и собирают runner с уже используемыми +зависимостями. Сборка/скачивание образов требуют registry/PyPI, но тестовая +runtime-сеть не имеет внешнего доступа и запросов к ВШЭ нет. При успехе третьей +команды ожидаются `10 passed` и exit code 0. Фактическую совместимость образа, +column grants и PostgreSQL можно считать подтверждённой **только после такого +выполнения**, не по unit-тестам или статическому чтению файлов. + +После сохранения вывода тестов удалите именно этот тестовый проект: + +```sh +env -i PATH="$PATH" docker --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration -f tests/postgres/compose.yml down +``` + +Это уничтожит только синтетические данные в tmpfs тестового PostgreSQL. Не +используйте `docker system prune`, production Compose или удаление рабочих +volumes. Перед повторным прогоном выполните этот же `down`, чтобы следующий +кластер начинался с чистого init.sql; не запускайте два прогона одновременно. + +## Статус подготовки + +В среде подготовки Docker отсутствует; настоящие PostgreSQL-тесты не запускались. +Локально доступны только unit-тесты, проверка Python и статические проверки. +Production/VPS и доступ к рабочей БД не изменялись. diff --git a/pyproject.toml b/pyproject.toml index a7b6182..2b561c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "miem-workers" -version = "0.10.2" +version = "0.10.3" description = "MIEM employees parser, admin API, and web admin" requires-python = ">=3.11" dependencies = [ diff --git a/tests/postgres/Dockerfile b/tests/postgres/Dockerfile new file mode 100644 index 0000000..66e0033 --- /dev/null +++ b/tests/postgres/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN python -m pip install --no-cache-dir -r requirements.txt +COPY app ./app +COPY scripts/import_publications.py ./scripts/import_publications.py +COPY tests/conftest.py tests/test_publication_import_postgres.py ./tests/ diff --git a/tests/postgres/Dockerfile.dockerignore b/tests/postgres/Dockerfile.dockerignore new file mode 100644 index 0000000..2cd9d1b --- /dev/null +++ b/tests/postgres/Dockerfile.dockerignore @@ -0,0 +1,18 @@ +** +!requirements.txt +!app/ +!app/** +!scripts/ +!scripts/import_publications.py +!tests/ +!tests/conftest.py +!tests/test_publication_import_postgres.py +!tests/postgres/ +!tests/postgres/Dockerfile +!tests/postgres/Dockerfile.dockerignore +app/data/ +**/__pycache__/ +**/.env* +**/*credentials* +**/*.key +**/*.pem diff --git a/tests/postgres/compose.yml b/tests/postgres/compose.yml new file mode 100644 index 0000000..2ce692d --- /dev/null +++ b/tests/postgres/compose.yml @@ -0,0 +1,36 @@ +name: miem-publication-integration + +services: + postgres-test: + image: postgres:16-alpine + environment: + POSTGRES_USER: test_admin + POSTGRES_PASSWORD: isolated_test_admin + POSTGRES_DB: miem_import_test + tmpfs: + - /var/lib/postgresql/data + volumes: + - ./init.sql:/docker-entrypoint-initdb.d/10-test.sql:ro + healthcheck: + test: ["CMD", "pg_isready", "-h", "127.0.0.1", "-U", "test_admin", "-d", "miem_import_test"] + interval: 2s + timeout: 5s + retries: 30 + networks: [isolated] + + tests: + build: + context: ../.. + dockerfile: tests/postgres/Dockerfile + environment: + DATABASE_URL: postgresql+psycopg://test_admin:isolated_test_admin@postgres-test:5432/miem_import_test + MIEM_POSTGRES_INTEGRATION: "1" + command: ["python", "-m", "pytest", "-q", "tests/test_publication_import_postgres.py"] + depends_on: + postgres-test: + condition: service_healthy + networks: [isolated] + +networks: + isolated: + internal: true diff --git a/tests/postgres/init.sql b/tests/postgres/init.sql new file mode 100644 index 0000000..9e9a457 --- /dev/null +++ b/tests/postgres/init.sql @@ -0,0 +1,6 @@ +-- Synthetic credentials for this ephemeral, private test cluster only. +CREATE DATABASE miem_crawl_test OWNER test_admin; +CREATE ROLE import_test LOGIN PASSWORD 'isolated_test_import' + NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS + CONNECTION LIMIT 1; +GRANT CONNECT ON DATABASE miem_import_test, miem_crawl_test TO import_test; diff --git a/tests/test_api.py b/tests/test_api.py index 6ddf9f5..aae52be 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -18,7 +18,7 @@ def test_health_returns_versions(): response = TestClient(app).get("/api/health") assert response.status_code == 200 - assert response.json()["backend_version"] == "0.10.2" + assert response.json()["backend_version"] == "0.10.3" def test_api_employees_and_stats_require_admin_session(): diff --git a/tests/test_publication_import_postgres.py b/tests/test_publication_import_postgres.py new file mode 100644 index 0000000..a56cc74 --- /dev/null +++ b/tests/test_publication_import_postgres.py @@ -0,0 +1,280 @@ +"""Real PostgreSQL checks; opt in only inside tests/postgres/compose.yml.""" + +import copy +import csv +import json +import os +import subprocess +import sys +from contextlib import contextmanager +from pathlib import Path + +import pytest +import requests +from sqlalchemy import create_engine, select, text +from sqlalchemy.orm import Session + +from app.config import Settings +from app.db import Base +from app.models import CrawlError, CrawlRun, Employee, EmployeePublication +from app.services.crawler import _try_sync_employee_publications, run_crawl + + +ADMIN_URL = "postgresql+psycopg://test_admin:isolated_test_admin@postgres-test:5432/miem_import_test" +pytestmark = pytest.mark.skipif( + os.environ.get("MIEM_POSTGRES_INTEGRATION") != "1", reason="Requires isolated PostgreSQL Compose project" +) + + +@pytest.fixture(autouse=True) +def isolated_environment(monkeypatch): + assert os.environ.get("DATABASE_URL") == ADMIN_URL, "Use the dedicated test Compose project" + + def forbidden_request(*args, **kwargs): + raise AssertionError("External HTTP requests are forbidden in PostgreSQL integration tests") + + monkeypatch.setattr(requests.sessions.Session, "request", forbidden_request) + + +@contextmanager +def database(name): + assert name in {"miem_import_test", "miem_crawl_test"} + engine = create_engine(ADMIN_URL.replace("/miem_import_test", "/" + name)) + try: + with engine.connect() as connection: + assert connection.scalar(text("SELECT current_database()")) == name + Base.metadata.create_all(engine) + with engine.begin() as connection: + connection.execute(text("GRANT USAGE ON SCHEMA public TO import_test")) + connection.execute(text("GRANT SELECT (id, full_name) ON public.employees TO import_test")) + connection.execute(text("GRANT SELECT (id, employee_id, title, year, doi_url, raw_data) ON public.employee_publications TO import_test")) + connection.execute(text("GRANT INSERT (employee_id, title, year, doi_url, raw_data, source_hash, created_at, updated_at) ON public.employee_publications TO import_test")) + connection.execute(text("GRANT UPDATE (title, year, doi_url, raw_data, updated_at) ON public.employee_publications TO import_test")) + # Created from the actual model: not an assumed sequence name. + sequence = connection.scalar(text("SELECT pg_get_serial_sequence('public.employee_publications', 'id')")) + assert sequence == "public.employee_publications_id_seq" + connection.execute(text("GRANT USAGE ON SEQUENCE public.employee_publications_id_seq TO import_test")) + permissions = connection.execute(text("""SELECT + has_schema_privilege('import_test', 'public', 'CREATE'), + has_table_privilege('import_test', 'public.employees', 'INSERT'), + has_table_privilege('import_test', 'public.employee_publications', 'DELETE'), + has_sequence_privilege('import_test', 'public.employee_publications_id_seq', 'UPDATE') + """)).one() + assert not any(permissions) + with Session(engine) as session: + yield session, name + finally: + # Only the dedicated ephemeral test DB; never DROP DATABASE or roles. + Base.metadata.drop_all(engine) + engine.dispose() + + +@pytest.fixture +def pg_import_db(): + with database("miem_import_test") as db: + yield db + + +@pytest.fixture +def pg_crawl_db(): + with database("miem_crawl_test") as db: + yield db + + +def employee(session, name="Synthetic One", key="one"): + record = Employee(full_name=name, profile_key="staff:" + key, + canonical_url="https://synthetic.invalid/staff/" + key) + session.add(record) + session.commit() + return record + + +def article(title="Synthetic article", identifier="10.1234/test", year="2024", author="Synthetic One", **metadata): + return {"Название публикации": title, "Год": year, "DOI": identifier, + "Авторы НИУ ВШЭ": author, **metadata} + + +def write_csv(path, rows): + columns = list(dict.fromkeys(key for row in rows for key in row)) + with path.open("w", encoding="utf-8-sig", newline="") as output: + writer = csv.DictWriter(output, fieldnames=columns, delimiter=";") + writer.writeheader() + writer.writerows(rows) + + +def cli(tmp_path, db, rows, *, apply=False, expected_code=0): + _, name = db + source = tmp_path / "articles.csv" + report = tmp_path / "report.json" + write_csv(source, rows) + command = [sys.executable, str(Path(__file__).resolve().parents[1] / "scripts/import_publications.py"), + str(source), "--report", str(report)] + if apply: + command.append("--apply") + # Explicit whitelist: no production env, .env, pg_service or ~/.pgpass. + environment = {"PATH": os.environ["PATH"], "LANG": "C.UTF-8", "PGHOST": "postgres-test", + "PGPORT": "5432", "PGDATABASE": name, "PGUSER": "import_test", + "PGPASSWORD": "isolated_test_import", "PGPASSFILE": "/dev/null", + "PGSERVICEFILE": "/dev/null", "PGCONNECT_TIMEOUT": "5"} + result = subprocess.run(command, env=environment, cwd=tmp_path, capture_output=True, text=True, timeout=60) + assert result.returncode == expected_code, result.stdout + result.stderr + assert "Synthetic One" not in result.stdout and "isolated_test_import" not in result.stdout + return json.loads(report.read_text()) if report.exists() else None + + +def snapshot(session): + session.expire_all() + tables = {table.name: session.execute(select(table).order_by(*table.primary_key.columns)).all() + for table in Base.metadata.sorted_tables} + sequences = session.execute(text("SELECT sequencename, last_value FROM pg_sequences WHERE schemaname = 'public' ORDER BY sequencename")).all() + session.commit() + return tables, sequences + + +def test_postgres_dry_run_changes_neither_rows_nor_sequences(pg_import_db, tmp_path): + session, _ = pg_import_db + employee(session) + before = snapshot(session) + report = cli(tmp_path, pg_import_db, [article()]) + assert report["summary"]["counts"] == {"insert": 1, "update": 0, "skip": 0} + assert snapshot(session) == before + + +def test_postgres_apply_and_reimport(pg_import_db, tmp_path): + session, _ = pg_import_db + person = employee(session) + existing = EmployeePublication(employee_id=person.id, title="Existing", year=2024, + source_hash="existing", raw_data={"hse": "keep"}) + session.add(existing) + session.commit() + rows = [article(title="Existing"), article(title="New", identifier="10.1234/new"), + article(title="Unknown author", identifier="10.1234/unknown", author="Synthetic Missing")] + report = cli(tmp_path, pg_import_db, rows, apply=True) + assert report["summary"]["counts"] == {"insert": 1, "update": 1, "skip": 1} + session.expire_all() + assert session.query(Employee).count() == 1 + assert session.query(EmployeePublication).count() == 2 + assert existing.doi_url == "https://doi.org/10.1234/test" and existing.raw_data["hse"] == "keep" + before = snapshot(session) + repeated = cli(tmp_path, pg_import_db, rows, apply=True) + assert repeated["summary"]["counts"] == {"insert": 0, "update": 0, "skip": 3} + assert snapshot(session) == before + + +def test_postgres_apply_failure_rolls_back_update_and_insert(pg_import_db, tmp_path): + session, _ = pg_import_db + person = employee(session) + existing = EmployeePublication(employee_id=person.id, title="Existing", year=2024, + source_hash="existing", raw_data={"hse": "keep"}) + session.add(existing) + session.commit() + # Test-only constraint forces a real server error after update + successful INSERT. + session.execute(text("ALTER TABLE public.employee_publications ADD CONSTRAINT test_failure CHECK (title <> 'Reject')")) + session.commit() + before, _ = snapshot(session) + rows = [article(title="Existing"), article(title="Accepted", identifier="10.1234/accepted"), + article(title="Reject", identifier="10.1234/reject")] + assert cli(tmp_path, pg_import_db, rows, apply=True, expected_code=1) is None + after, _ = snapshot(session) + assert after == before # nextval may advance on rollback; rows must not change. + assert (tmp_path / "report.json.pending").exists() + + +def test_postgres_year_only_update_preserves_json(pg_import_db, tmp_path): + session, _ = pg_import_db + person = employee(session) + row = article() + raw = {"hse": {"keep": True}, "csv_enrichment": row.copy()} + record = EmployeePublication(employee_id=person.id, title=row["Название публикации"], + year=None, doi_url="https://doi.org/10.1234/test", + source_hash="existing", raw_data=raw) + session.add(record) + session.commit() + report = cli(tmp_path, pg_import_db, [row], apply=True) + session.expire_all() + assert report["summary"]["counts"]["update"] == 1 + assert record.year == 2024 and record.raw_data == raw + + +def test_postgres_late_csv_metadata_and_conflicts(pg_import_db, tmp_path): + session, _ = pg_import_db + employee(session) + rows = [article(), article(**{"EID (Scopus)": "late-id", "Квартиль scopus": "Q1"}), + article(**{"EID (Scopus)": "conflict-id", "UT (WoS)": "late-wos"})] + report = cli(tmp_path, pg_import_db, rows, apply=True) + session.expire_all() + assert session.query(EmployeePublication).count() == 1 + metadata = session.query(EmployeePublication).one().raw_data["csv_enrichment"] + assert metadata["EID (Scopus)"] == "late-id" and metadata["Квартиль scopus"] == "Q1" + assert metadata["UT (WoS)"] == "late-wos" + assert report["summary"]["metadata_conflicts"] == 1 + assert report["records"][2]["conflict_fields"] == ["EID (Scopus)"] + before = snapshot(session) + repeated = cli(tmp_path, pg_import_db, rows, apply=True) + assert repeated["summary"]["counts"]["insert"] == repeated["summary"]["counts"]["update"] == 0 + assert snapshot(session) == before + + +def test_postgres_crawler_preserves_import_and_adopts_hse_id(pg_crawl_db, tmp_path): + session, _ = pg_crawl_db + person = employee(session) + cli(tmp_path, pg_crawl_db, [article(), article(title="CSV only", identifier="10.1234/only")], apply=True) + session.expire_all() + ids = {p.title: p.id for p in session.query(EmployeePublication).all()} + run = CrawlRun(source_url="https://synthetic.invalid/persons", status="running") + session.add(run) + session.commit() + parsed = {"sections": [{"type": "publications", "publications": [ + {"publication_id": "hse-one", "title": "Synthetic article", "year": 2024, + "doi_url": "https://doi.org/10.1234/test", "raw_data": {"id": "hse-one"}}]}]} + for _ in range(2): + _try_sync_employee_publications(session, run, person, copy.deepcopy(parsed)) + session.commit() + session.expire_all() + assert session.query(EmployeePublication).count() == 2 + kept = session.get(EmployeePublication, ids["Synthetic article"]) + assert kept.publication_id == "hse-one" and "csv_enrichment" in kept.raw_data + assert session.get(EmployeePublication, ids["CSV only"]).raw_data["csv_enrichment"] + assert session.query(CrawlError).count() == 0 + + +@pytest.mark.parametrize("bound_id", [None, "hse-A"]) +@pytest.mark.parametrize("incoming_ids", [("hse-A", "hse-B"), ("hse-B", "hse-A")]) +def test_postgres_hse_id_conflict_does_not_block_other_employee(pg_crawl_db, tmp_path, monkeypatch, bound_id, incoming_ids): + session, _ = pg_crawl_db + first = employee(session) + second = employee(session, "Synthetic Two", "two") + cli(tmp_path, pg_crawl_db, [article(identifier="")], apply=True) + session.expire_all() + original = session.query(EmployeePublication).filter_by(employee_id=first.id).one() + original.publication_id = bound_id + original.authors = [{"id": "original-author"}] + session.commit() + fields = ("publication_id", "source_hash", "raw_data", "authors", "updated_at") + before = {field: copy.deepcopy(getattr(original, field)) for field in fields} + session.commit() + + def profile(person, publications): + return {"source_url": person.canonical_url, "profile_type": "staff", + "profile_id": person.profile_key.split(":")[1], "full_name": person.full_name, + "parser_version": "integration-test", "tabs": [], + "sections": [{"type": "publications", "publications": publications}]} + + profiles = { + first.canonical_url: profile(first, [{"publication_id": key, "title": "Synthetic article", "year": 2024, + "authors": [{"id": key}], "raw_data": {"id": key}} for key in incoming_ids]), + second.canonical_url: profile(second, [{"publication_id": "second-article", "title": "Other employee article", + "year": 2024, "raw_data": {"id": "second-article"}}]), + } + monkeypatch.setattr("app.services.crawler.collect_profile_links", lambda *a, **k: list(profiles)) + monkeypatch.setattr("app.services.crawler.parse_person_profile", lambda _, url, *a, **k: copy.deepcopy(profiles[url])) + settings = Settings(_env_file=None, database_url=ADMIN_URL.replace("miem_import_test", "miem_crawl_test"), + source_url="https://synthetic.invalid/persons", request_delay_seconds=0) + run = run_crawl(session, settings) + session.expire_all() + assert run.status == "completed" and run.parsed_count == 2 + kept = session.query(EmployeePublication).filter_by(employee_id=first.id).one() + assert {field: getattr(kept, field) for field in fields} == before + assert session.query(EmployeePublication).filter_by(employee_id=second.id).one().publication_id == "second-article" + error = session.query(CrawlError).filter_by(crawl_run_id=run.id).one() + assert "Ambiguous HSE IDs" in error.message -- 2.54.0 From 4c609cc7eacb34a0a8aa66ce27d23f09dc060fb8 Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 5 Oct 2026 12:48:06 +0300 Subject: [PATCH 2/2] test: prepare resource-limited isolated VPS PostgreSQL stand --- README.md | 4 +- app/version.py | 6 +- docs/postgresql-import-verification.md | 7 +- docs/postgresql-import-vps.md | 153 ++++++++++++++++++ pyproject.toml | 2 +- tests/postgres/Dockerfile.postgres | 2 + .../postgres/Dockerfile.postgres.dockerignore | 4 + tests/postgres/compose.vps.yml | 69 ++++++++ tests/test_api.py | 2 +- 9 files changed, 241 insertions(+), 8 deletions(-) create mode 100644 docs/postgresql-import-vps.md create mode 100644 tests/postgres/Dockerfile.postgres create mode 100644 tests/postgres/Dockerfile.postgres.dockerignore create mode 100644 tests/postgres/compose.vps.yml diff --git a/README.md b/README.md index 515d3fc..993cc14 100644 --- a/README.md +++ b/README.md @@ -141,10 +141,12 @@ node --check app/static/admin.js Тесты с данными используют временную SQLite; отдельные API smoke-тесты запускают приложение с его текущей конфигурацией. Браузерная проверка: `pip install playwright`, `python -m playwright install chromium`, затем `python tests/browser_admin.py`. Она проверяет реальный рендеринг страниц, клавиатуру, диалог, фильтры и восстановление прогресса после ошибки. Скриншоты сохраняются во временную папку; путь выводится в конце. -Версия сервиса: `0.10.3`. Админка всегда показывает версии backend и frontend в footer. +Версия сервиса: `0.10.4`. Админка всегда показывает версии backend и frontend в footer. ## Разовый импорт публикаций Инструкция, минимальные права временной роли и ограниченный SSH-запуск: [docs/publication-import.md](docs/publication-import.md). По умолчанию импортер выполняет dry-run; production-применение требует отдельного решения владельца. Интеграционная проверка импорта на отдельном PostgreSQL: [docs/postgresql-import-verification.md](docs/postgresql-import-verification.md). + +Отдельный тестовый стенд на VPS и read-only проверка ресурсов: [docs/postgresql-import-vps.md](docs/postgresql-import-vps.md). Стенд запускает только владелец после оценки ресурсов. diff --git a/app/version.py b/app/version.py index 50915db..77d9e11 100644 --- a/app/version.py +++ b/app/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.10.3" -FRONTEND_VERSION = "0.10.3" -BACKEND_VERSION = "0.10.3" +APP_VERSION = "0.10.4" +FRONTEND_VERSION = "0.10.4" +BACKEND_VERSION = "0.10.4" diff --git a/docs/postgresql-import-verification.md b/docs/postgresql-import-verification.md index 50d32ee..4aeec5c 100644 --- a/docs/postgresql-import-verification.md +++ b/docs/postgresql-import-verification.md @@ -52,7 +52,9 @@ HTTP заблокирован test fixture; входные профили пол Выполняйте на локальной машине с Docker Engine/Compose v2 из корня репозитория после независимого ревью. Команды явно выбирают локальный Unix socket и очищают наследуемое окружение. Для Docker Desktop/Windows используйте Linux/WSL с -доступным локальным `/var/run/docker.sock`; не подменяйте его удалённым VPS. +доступным локальным `/var/run/docker.sock`. Для отдельного стенда на VPS используйте +[отдельную инструкцию](postgresql-import-vps.md) и `compose.vps.yml`: этот локальный +Compose не предназначен для запуска на сервере с работающими сервисами. Первый шаг: ```sh @@ -61,7 +63,8 @@ env -i PATH="$PATH" docker --host unix:///var/run/docker.sock version Ожидается exit code 0 и секции `Client` и `Server`. `command not found`, `permission denied` или только Client с ошибкой подключения означает, что -до запуска тестов нужно восстановить локальный Docker; не подключайтесь к VPS. +до запуска этого варианта нужен локальный Docker. При его отсутствии сначала +выполните только read-only диагностику из инструкции VPS. Дальнейшие точные команды сохранены здесь для воспроизводимого запуска: diff --git a/docs/postgresql-import-vps.md b/docs/postgresql-import-vps.md new file mode 100644 index 0000000..e70d528 --- /dev/null +++ b/docs/postgresql-import-vps.md @@ -0,0 +1,153 @@ +# PostgreSQL-проверка на отдельном стенде VPS + +Команды выполняет владелец под root. Подготовка не подключается к VPS и не +запускает стенд. Не используйте production Compose/`.env`, Docker context, +volumes, сети, mounts или production credentials. Никаких production-ролей БД, +SSH-ключей и правил firewall эти команды не создают и не изменяют. + +## Сначала только read-only проверка + +Следующий блок не создаёт контейнеров, не скачивает образов и не меняет данные. +Docker CLI использует локальный Unix socket и очищенное окружение; конфигурация +CLI указывает на отдельный каталог, который здесь не создаётся. + +```sh +free -h +nproc +uptime +df -h / /var/lib/docker +env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock info --format 'CPUs={{.NCPU}} RAM_bytes={{.MemTotal}} cgroup={{.CgroupVersion}} DockerRootDir={{.DockerRootDir}} warnings={{json .Warnings}}' +env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose version +env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.PIDs}}' +env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock image inspect postgres:16-alpine python:3.12-slim miem-publication-integration-postgres:pr55 miem-publication-integration-runner:pr55 --format '{{.RepoTags}} id={{.Id}} revision={{index .Config.Labels "org.opencontainers.image.revision"}}' +``` + +Как читать результат: + +- `free`: нужна колонка **available**, а не только free. Предварительный запас — + не менее 2 GiB: стенд ограничен суммарно 1 GiB, ещё минимум 1 GiB остаётся + системе/сервисам. Это ориентир, решение зависит от их нагрузки и пиков. +- `nproc` и `uptime`: сравните число CPU и load average. Постоянный load на уровне + числа CPU или выше — основание отложить тест, а не останавливать сервисы. +- `df`: желательно не менее 4 GiB свободно для загрузки образов и запаса работы + Docker. Если DockerRootDir отличается от `/var/lib/docker`, отдельно выполните + `df -h` для показанного каталога. Не используйте prune для освобождения места. +- `stats`: показывает потребление уже работающих контейнеров, но не их пиковые + требования. Docker warnings о недоступных memory/swap/cgroup limits нужно + устранить до запуска; без действующих лимитов этот вариант не запускать. +- `image inspect`: существующие образы показывают tags, ID и revision. + `No such image` означает отсутствие образа, **не** разрешение на pull/build. + Для запуска нужны два образа `miem-publication-integration-*:pr55` с revision + проверенного commit PR #55. Base-образы нужны только для сборки, не для самого + запуска уже подготовленного стенда. + +Пришлите только этот диагностический вывод. Не присылайте `docker inspect` +production-контейнеров целиком, env, конфигурации подключения или секреты. +До оценки ресурсов и завершения независимого review остальные действия ниже +не выполнять. + +## Изоляция и лимиты подготовленного стенда + +Используется только `tests/postgres/compose.vps.yml`, не override production или +локального Compose. Project name фиксирован: +`miem-publication-integration-vps`. + +| Ресурс | Лимит / область | +|---|---| +| PostgreSQL | Отдельный контейнер `postgres-test`, 0.5 CPU, 512 MiB RAM, без дополнительного swap, 128 процессов | +| PostgreSQL настройки | shared_buffers 64 MiB, max_connections 12 | +| Test runner | 0.5 CPU, 512 MiB RAM, без дополнительного swap, 128 процессов; UID 65534, read-only rootfs, no-new-privileges, без capabilities | +| Данные PostgreSQL | Только named volume `miem-publication-integration-vps_pgdata`; local driver tmpfs до 256 MiB | +| Временные файлы runner | tmpfs `/tmp` до 128 MiB; входят в memory limit контейнера | +| Сеть | Только `miem-publication-integration-vps_isolated`, internal; опубликованных портов нет | +| Логи | json-file, до 5 MiB на контейнер, один файл | + +Volume с tmpfs выбран намеренно: отдельное имя для адресной очистки и предел +размера синтетических данных. Данные не сохраняются после остановки/размонтирования. +Образы содержат только код и синтетический init.sql. Host bind mounts, +Docker socket, production networks и env_file отсутствуют. Docker socket +доступен только CLI владельца на хосте, ни одному контейнеру он не передаётся. +Нет restart policy. Pull запрещён; ресурсы не создаются автоматически при +отсутствии заранее подготовленного образа. + +Лимиты процесса сборки/загрузки образов отличаются от runtime: Compose CPU/RAM +limits **не ограничивают Docker build**. Поэтому на VPS с рабочими сервисами +не выполняйте незапланированную сборку. Если готовых образов нет, отдельно +согласуйте место сборки и окно загрузки после read-only диагностики. + +## Подготовка образов после review (не запуск стенда) + +На отдельной согласованной машине сборки с Docker, из проверенной копии PR #55, +укажите полный SHA проверенного commit в переменной `TEST_COMMIT` (не секрет). +Сначала `git status --short` должен быть пустым, `git rev-parse HEAD` — совпадать +с TEST_COMMIT. Это не команды для немедленного выполнения на VPS. + +```sh +git status --short +git rev-parse HEAD +env -i PATH="$PATH" docker --host unix:///var/run/docker.sock build --label org.opencontainers.image.revision="$TEST_COMMIT" -t miem-publication-integration-runner:pr55 -f tests/postgres/Dockerfile . +env -i PATH="$PATH" docker --host unix:///var/run/docker.sock build --label org.opencontainers.image.revision="$TEST_COMMIT" -t miem-publication-integration-postgres:pr55 -f tests/postgres/Dockerfile.postgres tests/postgres +env -i PATH="$PATH" docker --host unix:///var/run/docker.sock image save --output miem-publication-integration-images.tar miem-publication-integration-postgres:pr55 miem-publication-integration-runner:pr55 +sha256sum miem-publication-integration-images.tar tests/postgres/compose.vps.yml +``` + +Dockerfile-specific ignore-файлы исключают секреты, реальные CSV и остальные +файлы workspace из обоих контекстов сборки. Передайте владельцу только архив +этих двух образов и `compose.vps.yml` с проверенными SHA256, без полного workspace +и `.env`. Транспорт выбирает владелец; инструкция не получает SSH/production +credentials. Base-образы могут скачиваться только на согласованной машине сборки. + +На VPS после оценки ресурсов/отдельного разрешения владельца: файлы размещаются +в `/root/miem-publication-integration`, Compose переименован в `compose.vps.yml`, +архив — в `images.tar`. Проверка checksum должна совпасть с источником. + +```sh +install -d -m 700 /root/miem-publication-integration /root/miem-publication-integration/docker-config +sha256sum /root/miem-publication-integration/images.tar /root/miem-publication-integration/compose.vps.yml +env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock image load --input /root/miem-publication-integration/images.tar +env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock image inspect miem-publication-integration-postgres:pr55 miem-publication-integration-runner:pr55 --format '{{.RepoTags}} id={{.Id}} revision={{index .Config.Labels "org.opencontainers.image.revision"}}' +env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration-vps -f /root/miem-publication-integration/compose.vps.yml config --quiet +``` + +Проверьте отсутствие уже существующих ресурсов с этим project name. Если они +есть, выясните их назначение; не запускайте второй стенд и не удаляйте чужие +ресурсы. Следующие команды только читают labels Docker Compose: + +```sh +env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock ps -a --filter label=com.docker.compose.project=miem-publication-integration-vps +env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock network ls --filter label=com.docker.compose.project=miem-publication-integration-vps +env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock volume ls --filter label=com.docker.compose.project=miem-publication-integration-vps +``` + +## Запуск и адресная очистка (пока не выполнять) + +Только после согласования владельцем ресурсов и проверенного состава стенда: + +```sh +env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration-vps -f /root/miem-publication-integration/compose.vps.yml up --no-build --pull never --abort-on-container-exit --exit-code-from tests +``` + +Ожидаемый успех — `10 passed`, exit code 0. Сохраните вывод/код выхода и логи: + +```sh +env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration-vps -f /root/miem-publication-integration/compose.vps.yml logs --no-color > /root/miem-publication-integration/test.log +``` + +**Следующая команда удаляет контейнеры, внутреннюю сеть и named volume именно +этого теста; все его синтетические данные будут потеряны.** Перед ней сохраните +отчёт. Она не затрагивает ресурсы других Compose-проектов: + +```sh +env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration-vps -f /root/miem-publication-integration/compose.vps.yml down --volumes +``` + +Не используйте `--remove-orphans`, `docker system prune`, общие rm/rmi/volume prune +или down рабочего проекта. Base-образы и образы теста оставьте: они могут быть +общими для других проверок. Архив и журнал также не удаляются этой командой. +Для подтверждения очистки повторите три read-only команды с project label: +в выводе должны остаться только заголовки. + +## Статус + +VPS-стенд не запускался, Docker/Compose-проверка не выполнялась в среде +подготовки из-за отсутствия Docker. PostgreSQL-интеграция ещё не подтверждена. diff --git a/pyproject.toml b/pyproject.toml index 2b561c8..7b49b83 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "miem-workers" -version = "0.10.3" +version = "0.10.4" description = "MIEM employees parser, admin API, and web admin" requires-python = ">=3.11" dependencies = [ diff --git a/tests/postgres/Dockerfile.postgres b/tests/postgres/Dockerfile.postgres new file mode 100644 index 0000000..741de43 --- /dev/null +++ b/tests/postgres/Dockerfile.postgres @@ -0,0 +1,2 @@ +FROM postgres:16-alpine +COPY init.sql /docker-entrypoint-initdb.d/10-test.sql diff --git a/tests/postgres/Dockerfile.postgres.dockerignore b/tests/postgres/Dockerfile.postgres.dockerignore new file mode 100644 index 0000000..05fb941 --- /dev/null +++ b/tests/postgres/Dockerfile.postgres.dockerignore @@ -0,0 +1,4 @@ +** +!init.sql +!Dockerfile.postgres +!Dockerfile.postgres.dockerignore diff --git a/tests/postgres/compose.vps.yml b/tests/postgres/compose.vps.yml new file mode 100644 index 0000000..0dc8b4f --- /dev/null +++ b/tests/postgres/compose.vps.yml @@ -0,0 +1,69 @@ +name: miem-publication-integration-vps + +services: + postgres-test: + image: miem-publication-integration-postgres:pr55 + pull_policy: never + environment: + POSTGRES_USER: test_admin + POSTGRES_PASSWORD: isolated_test_admin + POSTGRES_DB: miem_import_test + command: ["postgres", "-c", "shared_buffers=64MB", "-c", "max_connections=12"] + cpus: 0.5 + mem_limit: 512m + memswap_limit: 512m + pids_limit: 128 + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD", "pg_isready", "-h", "127.0.0.1", "-U", "test_admin", "-d", "miem_import_test"] + interval: 2s + timeout: 5s + retries: 30 + logging: + driver: json-file + options: + max-size: "5m" + max-file: "1" + networks: [isolated] + + tests: + image: miem-publication-integration-runner:pr55 + pull_policy: never + user: "65534:65534" + read_only: true + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] + cpus: 0.5 + mem_limit: 512m + memswap_limit: 512m + pids_limit: 128 + tmpfs: + - /tmp:size=128m,mode=1777 + environment: + DATABASE_URL: postgresql+psycopg://test_admin:isolated_test_admin@postgres-test:5432/miem_import_test + MIEM_POSTGRES_INTEGRATION: "1" + PYTHONDONTWRITEBYTECODE: "1" + PYTEST_ADDOPTS: "-p no:cacheprovider" + command: ["python", "-m", "pytest", "-q", "tests/test_publication_import_postgres.py"] + depends_on: + postgres-test: + condition: service_healthy + logging: + driver: json-file + options: + max-size: "5m" + max-file: "1" + networks: [isolated] + +networks: + isolated: + internal: true + +volumes: + pgdata: + driver: local + driver_opts: + type: tmpfs + device: tmpfs + o: size=256m diff --git a/tests/test_api.py b/tests/test_api.py index aae52be..e72b031 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -18,7 +18,7 @@ def test_health_returns_versions(): response = TestClient(app).get("/api/health") assert response.status_code == 200 - assert response.json()["backend_version"] == "0.10.3" + assert response.json()["backend_version"] == "0.10.4" def test_api_employees_and_stats_require_admin_session(): -- 2.54.0