commit 0ab3a341aa7ba32363ce43061d4ff02b1f2964af Author: Mikhail Verkovykh Date: Thu Sep 10 18:01:35 2026 +0300 Подготовить skeleton лабораторной 3 по мессенджеру diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bc16e55 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +.git +.github +.env +.env.* +!.env.example +secrets +certs +volumes +data +backups +*.key +*.pem +*.p12 +*.pfx +*.jks +.venv +__pycache__ +node_modules +evidence +.DS_Store diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d32b9be --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Copy to .env for your LOCAL synthetic test environment; never commit .env. +# These are logical interface suggestions; map actual framework names in REPORT.md. +APP_HOST=0.0.0.0 +APP_PORT=8080 +APP_ENV=lab +SESSION_TTL_SECONDS=3600 +# Choose credentials locally. File paths contain no secret values. +DATABASE_URL_FILE=/run/secrets/database_url +# Optional on 3, required use on 4 in lab2; Redis OR Valkey: +SESSION_STORE=postgres +CACHE_URL_FILE=/run/secrets/cache_url +API_REPLICAS=2 +IDEMPOTENCY_TTL_SECONDS=86400 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..e6e63eb --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,18 @@ +## Лабораторная 3 + +Автор / группа: TODO +Целевая оценка (3/4/5): TODO +Трек 5, если заявлен: TODO + +Что реализовано и что перенесено: TODO +Команды запуска из чистого clone: TODO +Результаты проверок и ссылка на REPORT.md: TODO +Ограничения / известные ошибки: TODO +Источники и использование ИИ: TODO + +- [ ] Весь обязательный минимум текущей и предыдущих работ сохранён с объявленными переходами. +- [ ] Контракт и публичные тесты не ослаблены. +- [ ] make check и make test выполнены; приложен реальный вывод. +- [ ] Сценарии отказов/безопасности своего уровня воспроизведены. +- [ ] Все секреты и приватные ключи находятся вне Git; отчёт не содержит tokens. +- [ ] README задания сохранён, REPORT.md содержит инструкцию именно моего решения. diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml new file mode 100644 index 0000000..11cf289 --- /dev/null +++ b/.github/workflows/contracts.yml @@ -0,0 +1,16 @@ +name: Skeleton contracts +on: [push, pull_request] +permissions: + contents: read +jobs: + contracts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python -m pip install -r requirements-dev.txt + - run: make check validate PYTHON=python +# This job validates the skeleton, not a student solution. +# Add build, readiness and make test in a separate application job. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b5a7189 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Local/private state +.env +.env.* +!.env.example +secrets/ +certs/private/ +*.key +*.p12 +*.pfx +*.jks +*.pem +*.tfstate* +volumes/ +data/ +backups/ +# Runtime/build artifacts +.venv/ +__pycache__/ +*.py[cod] +node_modules/ +dist/ +build/ +target/ +bin/ +obj/ +coverage/ +*.log +.DS_Store +.idea/ +.vscode/ +# Keep small, sanitized evidence summaries in evidence/ under version control. +# Inspect git diff --cached: ignore rules are not a secret scanner. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ee5533d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,19 @@ +# Работа через fork и pull request + +1. После публикации преподавателем сделайте fork репозитория **текущей** лабораторной в своём аккаунте и clone своего fork. URL преподаватель выдаёт отдельно. +2. Создайте ветку `solution/-` от исходной ветки skeleton. Ветка преподавателя и файлы контракта остаются основой проверки. +3. Для lab2–7 перенесите из своей предыдущей лабы только исходники, миграции, собственные тесты, Dockerfile и нужные конфигурации. Не копируйте `.git`, `.env`, credentials, volumes, `README.md`, `COURSE.md`, публичные `tests/` и `contracts/` поверх новой версии. Сначала сравните изменения API в `COURSE.md`. +4. Реализуйте задание, добавьте свою инструкцию запуска и заполните `REPORT.md`. Публичные проверки дополняйте отдельными тестами, не ослабляйте assertions. +5. Выполните `make check`, запустите свой стенд, затем `make test`. Выполните сценарии отказов текущей лабы. Их запуск всегда явный: scaffold сам не останавливает контейнеры. +6. Commit и push делайте в свой fork. Откройте PR в исходный репозиторий преподавателя, заголовок: `ЛР N — Группа — Фамилия — на 3/4/5`. Выберите правильную базовую ветку и заполните шаблон. +7. После замечаний обновляйте тот же PR. Чужие решения не переносите без указания источника и согласованных правил курса. + +Платформа может называть PR «merge request» — процесс тот же. `.github/` содержит необязательные удобства для GitHub; проверки локально от платформы не зависят. Инициализация и публикация исходных Git-репозиториев выполняются преподавателем отдельно. + +## Правила изменения skeleton + +Не удаляйте обязательные endpoint, поля, рубрику и публичные проверки. Изменение контракта согласуйте отдельным комментарием/PR; обход тестов не является решением. Код приложения размещайте в `src/` либо в принятой для языка структуре. Собственные тесты — `student-tests/` или штатный каталог фреймворка. Ссылки на результаты проверки и команды запуска должны переживать clone на другой машине. + +## CI + +Готовый GitHub workflow проверяет целостность контракта и синтаксис Python-проверок. Он **не запускает отсутствующее приложение** и **не подтверждает оценку**. Студент добавляет сборку своего приложения, запуск/готовность стенда и `make test` в отдельный job. Не используйте `pull_request_target` для запуска кода из fork, не выдавайте CI секреты или доступ к общему Docker-host. diff --git a/COURSE.md b/COURSE.md new file mode 100644 index 0000000..b02172b --- /dev/null +++ b/COURSE.md @@ -0,0 +1,53 @@ +# Сквозной проект: мессенджер под нагрузкой + +Семь самостоятельных репозиториев содержат задания и проверяемые контракты одной системы. Вы переносите собственную реализацию между лабораторными. Язык, фреймворк и библиотеки выбираете сами. Web UI, WebSocket, редактирование/удаление сообщений, публичный поиск пользователей и Kubernetes не входят в обязательный минимум: достаточно REST API. + +| Лаба | Новая инженерная задача | Что появляется в системе | +| --- | --- | --- | +| 1 | Контракт, процессы и контейнер | In-memory API, Dockerfile, HTTP smoke | +| 2 | Сохранность и идентификация | PostgreSQL, Basic → сессия; Redis/Valkey на 4 | +| 3 | Горизонтальное масштабирование | Балансировщик, ≥2 API-реплики, общие сессии, идемпотентность | +| 4 | Бинарные данные | Приватное S3-совместимое хранилище и вложения | +| 5 | Фоновая работа | Брокер, worker, состояния вложений и преобразование изображений | +| 6 | Диагностика | OTel Collector, хранилища сигналов, Grafana | +| 7 | Защита и эксплуатация | Argon2id, TLS/mTLS, OpenBao, минимальные привилегии | + +## Как оценивать + +Оценки накопительные **внутри лабораторной**: 4 = весь уровень 3 + весь уровень 4; 5 = весь уровень 4 + **один** полностью выполненный трек 5 на выбор. Несколько недоделанных треков не заменяют один завершённый. Условие «весь уровень» включает реализацию, проверку и объяснение на защите. Красивые схемы и дополнительные технологии не компенсируют неработающий обязательный сценарий. + +Для начала следующей лабы достаточно обязательного минимума предыдущей. Сохраняются предыдущие обязательные возможности, с явно описанными переходами API ниже. Не требуется сначала получить 5 за все ранние лабы. Можно исправлять свою базу по ходу курса; такие исправления выделяйте в PR. Реализация на 3 работоспособна, но не претендует на промышленную готовность. Даже 5 в lab7 — учебная доказанная защита в заявленной модели угроз, а не сертификат полной безопасности. + +На 3 студент самостоятельно собирает рабочую систему. На 4 показывает практики, ожидаемые от стажёра/джуна в команде: воспроизводимость, диагностику, обработку ошибок и проверки отказов. На 5 формулирует гипотезу, ставит эксперимент, приводит измерения и обсуждает границы решения — это предмет конкретной похвалы магистранту. + +## Контракт и совместимость + +`contracts/openapi.json` — OpenAPI 3.1.0, `contracts/README.md` — нормативная семантика. Контракт каждого репозитория самодостаточен; соседние папки на машине преподавателя не нужны. `tests/` — публичные проверки, а не эталонное решение. Контракт, рубрика и публичные тесты имеют приоритет над случайным поведением библиотек. При противоречии создайте вопрос преподавателю; не подгоняйте тест под приложение. + +Три объявленных изменения обязательного API: + +1. **1 → 2:** регистрация требует `password`; `X-User-Id` заменяется Bearer. Basic используется только при создании сессии. +2. **2 → 3:** `Idempotency-Key` обязателен для отправки сообщения; ответы API содержат `X-Instance-Id`. +3. **4 → 5:** загрузка вложения возвращает `202` вместо `201`, состояние становится асинхронным. Клиент опрашивает метаданные. Старое текстовое сообщение остаётся валидным: `attachment_ids` по умолчанию `[]`. + +У lab7 меняется транспорт на HTTPS, бизнес-эндпоинты остаются прежними. Внутреннее mTLS не заменяет пользовательскую сессию. Новые возможности на 4/5 добавляйте обратно совместимо, в отдельном `contracts/extensions.openapi.json` или документе; обязательный контракт сохраняйте. + +## Общая предметная модель + +Пользователь (`username`, `display_name`) состоит в чатах. Список участников задаётся при создании чата и в обязательном API не изменяется. В чате отправляются сообщения, с lab4 — со ссылками на вложения этого чата. Сервер назначает UUID и время. Порядок истории — `(created_at, id)` по возрастанию. Для защиты достаточно показать взаимодействие нескольких API-клиентов; фронтенд не требуется. + +С lab2 данные PostgreSQL — источник истины. Сессии могут храниться в PostgreSQL или Redis/Valkey, если выполняют контракт срока жизни и перезапуска. Файлы с lab4 живут в S3, метаданные — в PostgreSQL. Redis и Valkey — альтернативы: оба одновременно не нужны. Аналогично выбирается одно S3-хранилище, один брокер и один основной путь оркестрации. + +## Воспроизводимость и честные измерения + +- Укажите версии runtime и контейнерных образов, аппаратную конфигурацию, CPU/RAM-лимиты, команду запуска и время прогрева. Для образов используйте явный тег версии или digest, не `latest`. +- Все тестовые пользователи и изображения синтетические. `make test` создаёт данные: запускайте его на учебном стенде. Не публикуйте пароли, токены, приватные ключи, персональные данные или подписанные URL. +- Минимальный отчёт о нагрузке: длительность, конкуренция/интенсивность, успешные RPS, доля ошибок, p50/p95/p99, CPU/RAM, размер набора данных. Универсального проходного RPS для разных ноутбуков нет. +- Сравнивайте одинаковые запросы при одинаковых ресурсах. Успешный `/health/live` не доказывает сохранность сообщений, а HTTP `202` — окончание обработки. +- Один Docker-host не обеспечивает отказоустойчивость к потере этого host. Несколько API-процессов не делают единственную БД или LB высокодоступными. Отдельно называйте, какие отказы выдерживает эксперимент. + +## Что сдавать + +Исходники, Dockerfile, нужные манифесты и конфигурации, миграции, собственные тесты, заполненный `REPORT.md`, сведения об использовании внешнего кода и ИИ. Уметь объяснить и изменить своё решение на защите обязательно. Приложите команды и текстовые результаты; скриншоты — дополнение, а не единственное доказательство. + +PR должен собираться из чистого clone по инструкции автора. Секреты создаются отдельно; тестовые fixtures и открытые конфигурации хранятся в репозитории. Перенос кода между лабами описан в [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/Dockerfile.example b/Dockerfile.example new file mode 100644 index 0000000..e333a12 --- /dev/null +++ b/Dockerfile.example @@ -0,0 +1,8 @@ +# Teaching outline, not a runnable Dockerfile. Implement your own Dockerfile. +# 1. Choose a runtime/toolchain version and pin the base image tag/digest. +# 2. Copy dependency manifests first; install from the language lockfile. +# 3. Copy source and build. For compiled languages use a separate runtime stage. +# 4. Run the HTTP process as PID 1; listen on 0.0.0.0:8080. +# 5. Add non-root, signals and healthcheck according to the current rubric. +# 6. Pass runtime config/secrets at startup, never COPY .env or secret files. +# This file deliberately has no FROM/CMD: selecting/implementing them is the task. diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000..3015743 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,38 @@ +# Памятка преподавателю — лабораторная 3 + +## Что подготовлено + +Язык реализации не задан. README содержит накопительные уровни 3/4/5, сценарий защиты и вопросы. В каждой лабе собственная полная версия OpenAPI, поэтому fork не зависит от соседних каталогов. Публичный HTTP smoke не содержит реализации сервера; Dockerfile.example и compose.example.yaml (если есть) — задания/ориентиры, не готовый deployment. + +## Перед публикацией + +1. Выберите GitHub/GitLab и организацию, опубликуйте каждый каталог как отдельный template/skeleton repository, сообщите студентам URL и правила именования PR/MR. Эти материалы не публикуют репозитории автоматически. +2. Задайте default branch, защиту исходных материалов и правила приёма. `.github/` уже содержит шаблон PR и CI контракта; в GitLab перенесите эквивалентные настройки, локальные Makefile-команды одинаковы. +3. Сообщите, индивидуальная работа или командная, сроки, правила использования стороннего кода/ИИ и ресурсы демонстрационного стенда. Эти организационные решения намеренно не выдуманы в задании. +4. Уточните ограничения площадки (например доступна ли виртуализация/несколько узлов). Для 5 предусмотрено по два трека: один можно выполнить без настоящего multi-node cloud. Универсальный проходной RPS не установлен. + +## Что проверять + +| Проверка | Что подтверждает | Чего не подтверждает | +| --- | --- | --- | +| make check | Структура файлов, JSON, локальные refs, синтаксис Python | Реализация/запуск API | +| make validate | Полная OpenAPI 3.1, JSON Schema и примеры | Поведение сервера | +| make test | Один сквозной контрактный сценарий текущей лабы | Все ошибки, persistence, HA, security | +| test-persistence (lab2+) | Данные/прежний токен после вызова restart hook | Реальный перезапуск, если hook — no-op | +| test-failover (lab3+) | Смена обслуживающей реплики, сохранность, retry без дубля | Несколько физических узлов, HA DB/LB | +| test-security (lab7) | Доверенный HTTPS и отказ недоверенного CA | Внутреннее mTLS/identity/ACL/OpenBao | +| Защита и тесты студента | Реальность инфраструктуры и критерии выбранного уровня | Неограниченная промышленная безопасность | + +Проверяйте реальные контейнеры/логи процессов и данные до/после отказа: одного X-Instance-Id недостаточно. Для 4 требуются все пункты 3+4, для 5 — все пункты 3+4 и один **завершённый** трек 5. Сравнивайте подтверждённые результаты со сформулированными критериями, а не количество установленных сервисов. + +## Приём PR + +Проверка запускается на изолированном учебном окружении с синтетическими secrets, не на общей production БД. Студенческие Dockerfile/hooks/CI — выполняемый код: обычный fork workflow без секретов и без доступа к общему Docker socket. Не запускайте PR через pull_request_target с привилегиями преподавателя. + +Исходные README/контракт/публичные tests сохраняются, реализация переносится студентом между лабами через собственные исходники и миграции. Исправление общей спецификации публикуйте отдельным изменением, объясняя влияние на все затронутые лабораторные. + +## Проверка этой версии skeleton + +На 2026-09-10 выполнены структурные проверки и полная валидация всех семи OpenAPI/JSON Schema, проверка локальных Markdown-ссылок/YAML и декодирование PNG fixture. HTTP-клиенты проверены на временном изолированном тестовом стенде: позитивный smoke 1–7, отрицательные варианты неправильного sender, pagination, ACL, logout, bytes и пустого API; также пути persistence/failover/load и внешнего TLS. + +Временный стенд не включён в студенческие каталоги и не является эталонным решением. Реальное приложение, Compose, PostgreSQL/S3/broker и mTLS/OpenBao в skeleton отсутствуют: их работоспособность не заявляется. Docker CLI в среде подготовки не был доступен. Это проверка материалов и тестовых драйверов, не приёмка будущих решений студентов. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5d86d17 --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +PYTHON ?= python3 +BASE_URL ?= http://localhost:8080 +CA_FILE ?= +ACTION_SCRIPT ?= +TLS_ARGS = $(if $(CA_FILE),--ca-file "$(CA_FILE)",) + +.PHONY: help check validate test load test-persistence test-failover + +help: + @echo "check: offline structure; validate: full OpenAPI (requirements-dev.txt); test: running API; load: synthetic writes" + +check: + $(PYTHON) scripts/check_contract.py + +validate: + $(PYTHON) scripts/validate_openapi.py + +test: + $(PYTHON) tests/smoke.py --base-url "$(BASE_URL)" $(TLS_ARGS) + +load: + $(PYTHON) tests/load.py --base-url "$(BASE_URL)" $(TLS_ARGS) + +test-persistence: + @test -n "$(ACTION_SCRIPT)" || (echo "Set ACTION_SCRIPT to your implemented executable restart hook"; exit 2) + $(PYTHON) tests/resilience.py --mode persistence --action-script "$(ACTION_SCRIPT)" --base-url "$(BASE_URL)" $(TLS_ARGS) + +test-failover: + @test -n "$(ACTION_SCRIPT)" || (echo "Set ACTION_SCRIPT to your implemented executable stop-one hook"; exit 2) + $(PYTHON) tests/resilience.py --mode failover --action-script "$(ACTION_SCRIPT)" --base-url "$(BASE_URL)" $(TLS_ARGS) diff --git a/README.md b/README.md new file mode 100644 index 0000000..10381de --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# Лабораторная 3. Балансировка и отказ API-реплики + +Запустить несколько взаимозаменяемых экземпляров API за единой точкой входа и сохранить корректность при остановке одного из них. + +Это **skeleton**, а не готовый сервер. Здесь есть задание, контракт и публичные проверки. Реализацию приложения, Dockerfile и требуемую инфраструктуру пишет студент. Язык и framework свободные; UI не обязателен. + +[Карта курса](COURSE.md) · [Процесс fork/PR](CONTRIBUTING.md) · [OpenAPI](contracts/openapi.json) · [Семантика API](contracts/README.md) · [Проверки](tests/README.md) · [Отчёт](REPORT.md) · [Примеры JSON](contracts/examples.json) · [Памятка преподавателю](MAINTAINERS.md) + +## Откуда начинаем + +Нужен минимум lab2: PostgreSQL, общие действующие сессии, миграции. Добавляются X-Instance-Id и обязательный Idempotency-Key при отправке сообщения. + +**Архитектура:** Клиент → LB → API × 2+ → общие PostgreSQL и хранилище сессий. Compose или Swarm — на выбор. + +## Что сделать + +1. Выберите Compose + Nginx/HAProxy/Traefik либо Docker Swarm. Для выбранного варианта дайте одну последовательность запуска. +2. Уберите локальные сессии/другие данные, от которых зависит корректность, из API. Миграции выполняйте отдельно и согласованно, а не гонкой всех реплик. +3. Настройте балансировщик, проверки готовности, DNS/обновление backend-адресов и graceful shutdown. Не закрепляйте клиента sticky session как способ исправить раздельное состояние. +4. Реализуйте общую идемпотентность отправки сообщения. Проверьте одинаковый ключ одновременно через разные реплики. +5. Подготовьте явный action script, останавливающий одну выбранную API-реплику, и продемонстрируйте чтение/запись во время отказа. + +## Оценка «3»: работающий минимум + +- Весь стенд описан в compose.yaml или stack.yaml, запускается документированной командой. Не менее двух API-реплик, одна публичная точка входа, одна общая БД и общее хранилище сессий. +- Разные запросы одного пользователя обслуживаются разными репликами без повторного входа. Это видно по X-Instance-Id. Доступ через LB — основной проверяемый маршрут. +- Можно остановить любую одну API-реплику при работающей другой: не позднее 10 секунд продолжаются новые чтения/записи, подтверждённые сообщения доступны. Краткие ошибки/оборванные запросы во время обнаружения отказа измеряются и допустимы. +- Повтор POST сообщения с тем же Idempotency-Key не создаёт дубль, включая конкурентный повтор и переключение реплики. Неподтверждённую запись клиент может безопасно повторить с тем же ключом. +- make test, persistence и failover-сценарий проходят; для failover проверяется наличие ≥2 наблюдаемых реплик. Для Swarm сборка образа и его доступность узлам описаны отдельно. + +## Оценка «4»: уровень стажёра/джуна + +Весь уровень 3 **и все** пункты ниже: + +- Health/readiness, timeouts и прекращение приёма при SIGTERM настроены согласованно; LB исключает недоступный backend. Укажите период проверок и максимальное время обнаружения отказа. +- Последовательно остановите каждую API-реплику (восстанавливая предыдущую); соберите RPS, ошибки и время восстановления. Общая идемпотентность проверяется параллельными запросами с одним ключом. +- Контейнеры имеют CPU/RAM-лимиты, API не публикуют порты наружу в обход LB; секреты и хранилища общие только там, где нужно. Нет container_name, мешающего масштабированию одного Compose-service. +- Rolling update или эквивалентная поочерёдная замена проверена под нагрузкой. Миграции запускаются однократно и совместимы с работающей версией либо ограничение честно описано. + +## Оценка «5»: исследование и доказанный результат + +Весь уровень 4 **и один законченный трек на выбор**. Приведите гипотезу, повторяемую методику, результаты и ограничения. + +**Трек 1. Цена масштабирования.** При фиксированном суммарном CPU/RAM сравните 1/2/4 API-реплики на одном профиле нагрузки; затем проведите серию с ростом ресурсов. Найдите saturation point БД/LB/пула и покажите, почему рост реплик перестаёт помогать. Дайте исходные данные, графики и одно подтверждённое улучшение. + +**Трек 2. Отказ узла.** Расширьте стенд до нескольких узлов, покажите перенос API при потере узла и разберите оставшиеся SPOF (LB, DB, storage). Для заявленной защищённой зависимости покажите реальный failover и сохранность данных. Если DB не реплицирована, не заявляйте выживание её узла. + +## Как начать и проверить + +```sh +# Работает прямо в skeleton, Python 3.9+; приложение не запускается: +make check + +# После реализации запустите свой сервер/стенд по REPORT.md. +# По умолчанию тест использует http://localhost:8080. +make test +``` + +`.env.example`, `compose.example.yaml` и `contracts/infrastructure.json` описывают настройки и роли компонентов. **compose.example.yaml — список ролей с пустым services, не запускаемый стенд.** Реализуйте свой `compose.yaml`/`stack.yaml`, закрепите версии образов, заполните локальный `.env` и опишите bootstrap/миграции. Все зависимости поднимаются из этого репозитория; ссылка «у меня БД уже установлена» не заменяет воспроизводимость. + +```sh +# Compose-вариант после реализации: +docker compose --env-file .env config --quiet +docker compose --env-file .env up -d --build +# Дождитесь /health/ready; затем make test. +# Для Swarm укажите эквивалентные build/push/deploy-команды в REPORT.md. +``` + + +Примеры запросов доступны в `examples/requests.http`, примеры JSON — в `contracts/examples.json`. Для полной проверки схемы (по желанию локально; CI делает её автоматически): + +```sh +python3 -m venv .venv +.venv/bin/python -m pip install -r requirements-dev.txt +make validate PYTHON=.venv/bin/python +``` + +Smoke не выставляет оценку автоматически. Проверку сохранности/отказов запускайте явно по [tests/README.md](tests/README.md), остальные доказательства приведите в отчёте. + +## Сценарий защиты + +1. Запустить стенд и показать ≥2 разных X-Instance-Id через LB с одной сессией. +2. Запустить make test-failover ACTION_SCRIPT=scripts/stop-one.sh. Скрипт теста выполняет запросы во время action, учитывает ошибки и повторяет сообщения с исходным ключом. +3. Показать, какой контейнер действительно остановлен; заголовок сам по себе не доказательство разных процессов. Повторить для второй реплики после восстановления первой. +4. Проверить restart всего стенда с сохранением volumes и неизменным токеном. Для 4/5 — соответствующие эксперименты. + +## Что должно быть в PR + +compose.yaml или stack.yaml, конфигурация LB, scripts/restart.sh, scripts/stop-one.sh, Dockerfile, миграции, результаты отказов. + +Заполните `REPORT.md` и [шаблон PR](.github/pull_request_template.md), укажите целевую оценку/трек. Не меняйте обязательный контракт и публичные tests ради зелёного результата. Сохраняйте возможности предыдущих лабораторных в пределах [объявленных переходов](COURSE.md). + +## Вопросы на защите + +- Чем отказ процесса отличается от отказа узла? +- Почему depends_on/healthcheck сами по себе не являются балансировщиком? +- Какие POST можно повторять после timeout и что защищает Idempotency-Key? +- Что сломается при увеличении числа API, если сессии находятся в локальной памяти? + +## Первичные материалы + +- [Compose services](https://docs.docker.com/reference/compose-file/services/) +- [Swarm routing mesh](https://docs.docker.com/engine/swarm/ingress/) +- [Swarm services](https://docs.docker.com/engine/swarm/services/) diff --git a/REPORT.md b/REPORT.md new file mode 100644 index 0000000..c267900 --- /dev/null +++ b/REPORT.md @@ -0,0 +1,37 @@ +# Отчёт — лабораторная 3 + +- Автор, группа: TODO +- Целевая оценка; выбранный трек 5, если нужен: TODO +- Commit/PR: TODO после публикации +- Использованные источники, библиотеки и помощь ИИ: TODO + +## Запуск из чистого clone + +TODO: версии runtime/образов, prerequisites, команды создания локальных secrets/.env, build, миграций, запуска, ожидания готовности, тестов и остановки. Указать необходимые действия оператора. Не вставлять секреты. Отдельно указать безопасную остановку и команду намеренного удаления учебных данных. + +## Архитектура и решения + +TODO: схема процессов/хранилищ, источник истины, транзакционные границы, ограничения. Что перенесено из lab2, что изменено в этой работе. Соответствие переменных из .env.example реальной конфигурации. + +## Доказательства критериев + +| Критерий README | Команда/сценарий | Наблюдаемый результат | Файл доказательства | +| --- | --- | --- | --- | +| Публичный smoke | make test | TODO | TODO | +| Остальные пункты уровня 3 | TODO | TODO | TODO | +| Все пункты уровня 4, если заявлен | TODO | TODO | TODO | +| Выбранный трек 5, если заявлен | TODO | TODO | TODO | + +TODO: добавить отдельную строку для каждого заявленного критерия. PASS без команды и наблюдения не является доказательством. Проверка инфраструктурного hook требует подтверждения, какие реальные процессы/хранилища перезапущены. + +## Эксперименты и отказы + +TODO: гипотеза, hardware/лимиты, версии, объём данных, профиль нагрузки, concurrency/arrival rate, длительность/прогрев, повторы, успешные RPS, ошибки, p50/p95/p99, CPU/RAM. Сохранить raw summary без tokens и объяснить ограничения измерения. + +| Воздействие | Что ожидаем | Что наблюдали | Время восстановления / потеря данных | +| --- | --- | --- | --- | +| TODO | TODO | TODO | TODO | + +## Ограничения и следующие шаги + +TODO: какие отказы выдерживаются и какие нет; что не реализовано; где учебное упрощение; какое улучшение подтверждено, а какое пока гипотеза. diff --git a/compose.example.yaml b/compose.example.yaml new file mode 100644 index 0000000..dd4d387 --- /dev/null +++ b/compose.example.yaml @@ -0,0 +1,11 @@ +# Topology worksheet, deliberately NOT a runnable stack (no application supplied). +# Implement compose.yaml or stack.yaml; do not submit this empty file as deployment. +# Service names below are logical roles, not imposed container names. +x-lab-number: 3 +x-required-roles: + - api + - postgres + - load_balancer +# TODO: implement real images/builds, network wiring, volumes, healthchecks, +# credentials and bootstrap. Details: contracts/infrastructure.json and README.md. +services: {} diff --git a/contracts/README.md b/contracts/README.md new file mode 100644 index 0000000..8c58b32 --- /dev/null +++ b/contracts/README.md @@ -0,0 +1,61 @@ +# Семантика API + +Этот текст дополняет [openapi.json](openapi.json). Обязательный API соответствует уровню 3 текущей лабораторной. Факультативный crop или rate limiting не становятся обязательными только из-за наличия допустимого имени варианта или кода ошибки в схеме. + +## Формат и ошибки + +- JSON — UTF-8, `Content-Type: application/json`. UUID — строка; время — RFC 3339 UTC с `Z`, назначается сервером. Часы не считаются уникальным ID. +- Каждый ответ API, включая ошибки и health, содержит непустой `X-Request-Id`. Тело ошибки: `{"error":{"code":"invalid_request","message":"...","request_id":"..."}}`; `request_id` совпадает с заголовком. Сообщение ошибки не раскрывает stack trace, SQL, credentials или наличие чужого объекта. +- Обязательные строки не состоят только из пробельных символов. `username` строго `[a-z0-9_]{3,32}`, уникален; `display_name` — 1–100 Unicode code points. JSON с лишними полями или неверными типами — `400`; malformed JSON также `400`. +- `400 invalid_request` — неверные поля, UUID, cursor, limit; `401 unauthorized` — нет действительной идентификации/сессии; `404 not_found` — объекта нет или он чужой; `409 conflict` — username занят, повторный ключ с другим сообщением, объект ещё не готов; `413 payload_too_large`; `415 unsupported_media_type`; `429 rate_limited`; `503 unavailable`. Неожиданное исключение — `500 internal_error` без внутренних деталей. +- Сначала аутентификация, затем доступ к конкретному ресурсу. Любой аутентифицированный участник чата может читать его историю и вложения и писать в него; посторонний получает `404`. Списки содержат только доступные объекты. Не используйте `403` для раскрытия существования чужого чата. +- `GET /health/live` проверяет только жизнь процесса, возвращает `200 {"status":"ok"}`. `GET /health/ready` возвращает 200, если приложение может безопасно обслуживать обязательный синхронный путь; иначе 503 в общем формате ошибки. Какие зависимости критичны, зафиксируйте в отчёте; недоступность Grafana не должна останавливать API. + +## Пользователи, чаты и сообщения + +- `POST /users` публичен; возвращает `201 User`. Пароль/его hash и сессионные данные никогда не входят в User. +- `POST /chats` принимает title и явный список существующих `member_ids`, включая вызывающего. Дубликаты, неизвестные участники и отсутствие вызывающего — 400. Чат создаётся атомарно. GET списка чатов сортируется по `(created_at, id)` по возрастанию. +- Отправитель сообщения выводится из текущей идентификации, поле `sender_id` в запросе не принимается. Текст — до 4000 Unicode code points. Нельзя отправить пустой/пробельный текст без вложений. +- История использует keyset cursor: `(created_at, id)`, по возрастанию; `limit` — целое 1–100, по умолчанию 50. Ответ `{"items":[...],"next_cursor":null}`. Непустой cursor — непрозрачная для клиента строка; последний элемент предыдущей страницы не повторяется. Cursor привязан к ресурсу/запросу: от другого чата — 400. Изменять `limit` при продолжении можно. +- `next_cursor` равен null, если на момент чтения следующей записи нет. Гарантируется отсутствие повторов при обходе неизменной истории; snapshot изоляция между несколькими HTTP-запросами не требуется. При конкурентных вставках опишите выбранную семантику и ограничения часов. +- Подтверждённое сообщение видно следующему чтению через любую API-реплику. Допускается кэш, но он не должен возвращать устаревшую историю после успешной записи. Удаление и изменение участников в обязательный API не входят. + +## Идентификация и сессии + +**Lab1:** защищённые endpoint требуют `X-User-Id: `; отсутствие/неизвестный ID — 401. Это учебный выбор пользователя: любой клиент может выдать себя за другого. + +**Lab2–7:** `POST /auth/sessions` принимает только `Authorization: Basic `, без JSON body. Username ограничен ASCII; пароль кодируется UTF-8, 12–128 code points, двоеточие в пароле допустимо. Неверные credentials — одинаковый 401 и `WWW-Authenticate: Basic realm="messenger", charset="UTF-8"`. + +Вход возвращает `201 {"token":"...","token_type":"Bearer","expires_at":"...Z","user":{...}}`. Токен — непрозрачное случайное значение, не username/UUID и не обязательный JWT; энтропия не менее 256 бит. Храните серверную сессию с expiry; TTL задаётся `SESSION_TTL_SECONDS`, по умолчанию 3600 (в тесте истечения можно 2). В обычном smoke TTL должен быть не менее 300. + +Все защищённые endpoint принимают только `Authorization: Bearer `. Истёкший/отозванный/неизвестный токен — 401 с `WWW-Authenticate: Bearer realm="messenger"`. Basic на бизнес-endpoint — 401. `X-User-Id` с lab2 игнорируется: он не меняет владельца сессии и сам по себе не даёт доступ. `DELETE /auth/sessions/current` отзывает текущий токен и возвращает 204 без тела; повтор с отозванным токеном — 401. + +Пользователи, чаты, сообщения и ещё действительные сессии переживают перезапуск приложения и используемых хранилищ с сохранёнными volumes. TTL не продлевается из-за рестарта. Пароли уже с lab2 хешируются библиотечным password KDF (Argon2id, scrypt или bcrypt с учётом ограничений библиотеки), не SHA-256 и не plaintext. Если bcrypt не поддерживает весь диапазон UTF-8 паролей без усечения, выберите другую библиотечную схему. В lab7 обязателен Argon2id. + +Basic — это кодирование, не шифрование. HTTP lab1–6 допускается только для локального изолированного стенда с синтетическими данными; для удалённой демонстрации нужен TLS. Lab7 использует HTTPS обязательно. См. [RFC 7617](https://www.rfc-editor.org/info/rfc7617/). + +## Балансировка и повторная отправка (с lab3) + +- Каждый ответ, созданный API, содержит `X-Instance-Id`; значение стабильно для жизни процесса. Ошибки соединения, созданные самим LB, могут не иметь этого заголовка. В lab7 используйте случайный alias реплики без раскрытия hostname/IP. +- Для POST сообщения обязателен `Idempotency-Key` (ASCII `[A-Za-z0-9._:-]{1,128}`). Нет/неверный ключ — 400. Область уникальности: текущий пользователь + HTTP-метод + путь чата + ключ. +- При повторе того же валидного JSON в течение 24 часов сервер возвращает тот же код 201 и то же Message. Порядок ключей JSON и отсутствующий `attachment_ids` вместо `[]` семантически равны. Другой body при том же ключе — 409. Разные пользователи/чаты могут использовать одинаковый ключ независимо. +- Одновременные повторы через разные реплики создают ровно одно сообщение. Повтор снова проверяет действительность сессии и доступ. Транзиентные 5xx не фиксируются как окончательный результат; побочный эффект и запись результата должны быть атомарны. +- Ключ обязателен только для сообщений. Самостоятельный retry загрузки файла или создания чата может создать ещё один объект; не добавляйте прозрачные повторы этих POST без расширения контракта. + +## Вложения (с lab4) + +- `POST /chats/{chat_id}/attachments?filename=photo.png`: сырые bytes одного PNG/JPEG; `Content-Type: image/png` или `image/jpeg`. Не multipart. Максимум **10 MiB = 10 485 760 байт**. MIME проверяется по содержимому; заведомо повреждённые/чужие форматы — 415, превышение bytes — 413. Не более 40 миллионов декодированных пикселей (превышение — 413). Устанавливайте лимит до полной распаковки изображения. +- `filename` — отображаемое имя (1–255 code points). Не используйте его как путь/ключ S3. Имя с `/`, `\`, NUL или управляющими символами — 400. Bucket приватен; object key генерирует сервер. Оригинал возвращается побайтно, его lowercase SHA-256 и размер соответствуют исходному файлу. +- Lab4 возвращает `201 Attachment` со `status=ready`, `variants=[]`, `error_code=null` только после сохранения оригинала и метаданных. Lab5–7 возвращают `202 Attachment` со снимком `status=queued`; готовность определяется последующим GET метаданных. Быстрый worker может закончить до первого GET — это нормально. +- Файл заранее привязан к чату. В POST сообщения добавляется `attachment_ids` (0–10 уникальных ID, по умолчанию []). Можно отправить `text=""` с хотя бы одним вложением. Все вложения должны принадлежать этому чату; чужое/неизвестное — 404. Любой участник может ссылаться на доступное вложение чата. Failed-вложение прикреплять нельзя (409); queued/processing с lab5 можно. +- `GET /attachments/{id}` и `/content` доступны только участникам чата. Download возвращает байты с истинным Content-Type, через авторизованный API, **без redirect** в обязательном контракте. Приватный bucket сам по себе не заменяет эту проверку. `variant` по умолчанию original. Для queued/processing content — 409; для failed — 409. Отсутствующий вариант ready-объекта — 404. +- В ready-метаданных `error_code=null`. В failed — краткий машинный код (например `invalid_image`, `processing_failed`, `retry_exhausted`), без stack trace. `size_bytes`, `sha256`, `content_type` всегда описывают оригинал; у вариантов отдельные поля. Публичный API не раскрывает ключи бакета, внутренние адреса и credentials. +- Прямой upload/download по временной подписи — факультативное расширение. Его дополняют ограничение срока/размера, уникальный ключ и подтверждение реально загруженного объекта; он не заменяет стандартный endpoint. Особенности подписанных URL: [S3](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html). + +## Фоновая обработка (с lab5) + +До 202 все байты приняты в **устойчивый staging**, а намерение обработать файл надёжно сохранено. HTTP-передача клиента не исчезает и не становится мгновенной. Staging — приватный S3-prefix либо общий persistent volume, доступный worker; локальная память/API ephemeral disk не подходит. Worker асинхронно переносит/публикует оригинал в конечный S3-prefix и создаёт варианты; если staging уже в S3, опишите эту границу честно. + +Обязательный thumbnail: JPEG, изображение вписано в 256×256 с сохранением пропорций, без увеличения маленьких исходников, EXIF orientation применяется; alpha компонуется на белый. Размеры округляются вниз, минимум 1 px. Fixture 320×200 должен дать 256×160. Статус ready ставится после сохранения оригинала и thumbnail. Crop на 4: JPEG, центрированный квадрат 128×128 (для маленьких исходников допускается увеличение). Optimized на 4: WebP, вписан в 1280×1280 без увеличения, параметры качества и удаления EXIF документируются; не обещайте уменьшение каждого маленького файла. + +Допустимые состояния и события описаны в `jobs.schema.json` и `attachment-states.md`. Доставка — at least once; обработка идемпотентна, повторы не размножают варианты. Финальные ошибки наблюдаемы, бесконечный немой retry недопустим. Обязательный smoke ждёт ready до 60 секунд; это бюджет локального smoke для одного файла при свободном worker, не универсальный production SLA. В очередь идут ссылки/ID, не бинарное содержимое. diff --git a/contracts/examples.json b/contracts/examples.json new file mode 100644 index 0000000..eaa4fcd --- /dev/null +++ b/contracts/examples.json @@ -0,0 +1,58 @@ +{ + "CreateUser": { + "username": "alice", + "display_name": "Алиса", + "password": "Example-only-not-a-real-secret-123" + }, + "User": { + "id": "11111111-1111-4111-8111-111111111111", + "username": "alice", + "display_name": "Алиса", + "created_at": "2026-09-01T12:00:00Z" + }, + "CreateChat": { + "title": "Проект по ВНП", + "member_ids": [ + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222" + ] + }, + "Chat": { + "title": "Проект по ВНП", + "member_ids": [ + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222" + ], + "id": "33333333-3333-4333-8333-333333333333", + "created_at": "2026-09-01T12:00:00Z" + }, + "CreateMessage": { + "text": "Привет, Боб!" + }, + "Message": { + "id": "44444444-4444-4444-8444-444444444444", + "chat_id": "33333333-3333-4333-8333-333333333333", + "sender_id": "11111111-1111-4111-8111-111111111111", + "text": "Привет, Боб!", + "created_at": "2026-09-01T12:00:00Z" + }, + "MessagePage": { + "items": [ + { + "id": "44444444-4444-4444-8444-444444444444", + "chat_id": "33333333-3333-4333-8333-333333333333", + "sender_id": "11111111-1111-4111-8111-111111111111", + "text": "Привет, Боб!", + "created_at": "2026-09-01T12:00:00Z" + } + ], + "next_cursor": null + }, + "Error": { + "error": { + "code": "not_found", + "message": "Объект не найден", + "request_id": "example-request-id" + } + } +} diff --git a/contracts/infrastructure.json b/contracts/infrastructure.json new file mode 100644 index 0000000..28439b8 --- /dev/null +++ b/contracts/infrastructure.json @@ -0,0 +1,36 @@ +{ + "lab": 3, + "kind": "requirements, not runnable deployment", + "required_for_grade_3": [ + { + "role": "api", + "count_min": 2, + "student_implements": true, + "persistent_local_state": false + }, + { + "role": "postgres", + "purpose": "users/chats/membership/messages/metadata", + "persistent_volume": true + }, + { + "role": "load_balancer", + "public_entrypoint": true, + "backend_readiness": true + } + ], + "optional_components": [ + { + "role": "redis_or_valkey", + "required_for_lab2_grade4": true, + "note": "Choose one; if only store of sessions, configure durable persistence." + } + ], + "rules": [ + "Choose Compose or Swarm for lab3+; do not need both.", + "Never publish internal API replica ports in bypass of LB from lab3.", + "Versions/digests must be fixed in implementation.", + "Do not remove volumes in restart/failover checks.", + "Document service names, healthchecks, bootstrap/migrations and credentials." + ] +} diff --git a/contracts/openapi.json b/contracts/openapi.json new file mode 100644 index 0000000..da4d1cc --- /dev/null +++ b/contracts/openapi.json @@ -0,0 +1,2528 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "MTUSI Messenger — лабораторная 3", + "version": "1.3.0", + "description": "Нормативный минимум на 3. Семантика: contracts/README.md. Расширения на 4/5 не должны менять этот API. Наличие кода ошибки в схеме не требует реализовать факультативную причину его выдачи." + }, + "servers": [ + { + "url": "http://localhost:8080", + "description": "Локальный учебный стенд" + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "paths": { + "/health/live": { + "get": { + "operationId": "liveness", + "summary": "Процесс жив", + "responses": { + "200": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Health" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [] + } + }, + "/health/ready": { + "get": { + "operationId": "readiness", + "summary": "Готов принимать трафик", + "responses": { + "200": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Health" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [] + } + }, + "/api/v1/users": { + "post": { + "operationId": "registerUser", + "summary": "Создать пользователя", + "responses": { + "201": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUser" + } + } + } + } + } + }, + "/api/v1/users/me": { + "get": { + "operationId": "currentUser", + "summary": "Текущий пользователь", + "responses": { + "200": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Объект отсутствует или недоступен", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/v1/chats": { + "post": { + "operationId": "createChat", + "summary": "Создать чат; вызывающий должен входить в member_ids", + "responses": { + "201": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Chat" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Объект отсутствует или недоступен", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateChat" + } + } + } + } + }, + "get": { + "operationId": "listChats", + "summary": "Чаты текущего пользователя", + "responses": { + "200": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatPage" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Объект отсутствует или недоступен", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string", + "minLength": 1 + } + } + ] + } + }, + "/api/v1/chats/{chat_id}": { + "get": { + "operationId": "getChat", + "summary": "Получить доступный чат", + "responses": { + "200": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Chat" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Объект отсутствует или недоступен", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "chat_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ] + } + }, + "/api/v1/chats/{chat_id}/messages": { + "post": { + "operationId": "createMessage", + "summary": "Отправить сообщение", + "responses": { + "201": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Message" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Объект отсутствует или недоступен", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "chat_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$" + }, + "description": "Обязателен с lab3. Область: пользователь + метод + путь + ключ; хранить результат не менее 24 ч. Повтор того же JSON возвращает исходный 201 и то же сообщение, другой JSON — 409." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateMessage" + } + } + } + } + }, + "get": { + "operationId": "listMessages", + "summary": "Сообщения по (created_at, id) по возрастанию", + "responses": { + "200": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessagePage" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Объект отсутствует или недоступен", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "chat_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string", + "minLength": 1 + } + } + ] + } + }, + "/api/v1/auth/sessions": { + "post": { + "operationId": "createSession", + "summary": "Вход: Basic username:password; выдаёт сессию", + "responses": { + "201": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "basicAuth": [] + } + ] + } + }, + "/api/v1/auth/sessions/current": { + "delete": { + "operationId": "revokeSession", + "summary": "Отозвать текущую сессию", + "responses": { + "204": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "basicAuth": { + "type": "http", + "scheme": "basic" + }, + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "opaque session token" + } + }, + "schemas": { + "Error": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "invalid_request", + "unauthorized", + "not_found", + "conflict", + "payload_too_large", + "unsupported_media_type", + "rate_limited", + "unavailable", + "internal_error" + ] + }, + "message": { + "type": "string", + "minLength": 1 + }, + "request_id": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "code", + "message", + "request_id" + ], + "additionalProperties": false + } + }, + "required": [ + "error" + ], + "additionalProperties": false, + "examples": [ + { + "error": { + "code": "not_found", + "message": "Объект не найден", + "request_id": "example-request-id" + } + } + ] + }, + "Health": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ok" + ] + } + }, + "required": [ + "status" + ], + "additionalProperties": false + }, + "User": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "username": { + "type": "string", + "pattern": "^[a-z0-9_]{3,32}$" + }, + "display_name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "username", + "display_name", + "created_at" + ], + "additionalProperties": false, + "examples": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "username": "alice", + "display_name": "Алиса", + "created_at": "2026-09-01T12:00:00Z" + } + ] + }, + "CreateUser": { + "type": "object", + "properties": { + "username": { + "type": "string", + "pattern": "^[a-z0-9_]{3,32}$" + }, + "display_name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "password": { + "type": "string", + "minLength": 12, + "maxLength": 128, + "writeOnly": true + } + }, + "required": [ + "username", + "display_name", + "password" + ], + "additionalProperties": false, + "examples": [ + { + "username": "alice", + "display_name": "Алиса", + "password": "Example-only-not-a-real-secret-123" + } + ] + }, + "Chat": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "member_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "minItems": 1, + "maxItems": 100, + "uniqueItems": true + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "title", + "member_ids", + "created_at" + ], + "additionalProperties": false, + "examples": [ + { + "title": "Проект по ВНП", + "member_ids": [ + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222" + ], + "id": "33333333-3333-4333-8333-333333333333", + "created_at": "2026-09-01T12:00:00Z" + } + ] + }, + "CreateChat": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "member_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "minItems": 1, + "maxItems": 100, + "uniqueItems": true + } + }, + "required": [ + "title", + "member_ids" + ], + "additionalProperties": false, + "examples": [ + { + "title": "Проект по ВНП", + "member_ids": [ + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222" + ] + } + ] + }, + "Message": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "chat_id": { + "type": "string", + "format": "uuid" + }, + "sender_id": { + "type": "string", + "format": "uuid" + }, + "text": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "chat_id", + "sender_id", + "text", + "created_at" + ], + "additionalProperties": false, + "examples": [ + { + "id": "44444444-4444-4444-8444-444444444444", + "chat_id": "33333333-3333-4333-8333-333333333333", + "sender_id": "11111111-1111-4111-8111-111111111111", + "text": "Привет, Боб!", + "created_at": "2026-09-01T12:00:00Z" + } + ] + }, + "CreateMessage": { + "type": "object", + "properties": { + "text": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + } + }, + "required": [ + "text" + ], + "additionalProperties": false, + "examples": [ + { + "text": "Привет, Боб!" + } + ] + }, + "ChatPage": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Chat" + } + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "items", + "next_cursor" + ], + "additionalProperties": false + }, + "MessagePage": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Message" + } + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "items", + "next_cursor" + ], + "additionalProperties": false, + "examples": [ + { + "items": [ + { + "id": "44444444-4444-4444-8444-444444444444", + "chat_id": "33333333-3333-4333-8333-333333333333", + "sender_id": "11111111-1111-4111-8111-111111111111", + "text": "Привет, Боб!", + "created_at": "2026-09-01T12:00:00Z" + } + ], + "next_cursor": null + } + ] + }, + "Session": { + "type": "object", + "properties": { + "token": { + "type": "string", + "minLength": 32 + }, + "token_type": { + "type": "string", + "const": "Bearer" + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "user": { + "$ref": "#/components/schemas/User" + } + }, + "required": [ + "token", + "token_type", + "expires_at", + "user" + ], + "additionalProperties": false + } + } + } +} diff --git a/evidence/README.md b/evidence/README.md new file mode 100644 index 0000000..8df7001 --- /dev/null +++ b/evidence/README.md @@ -0,0 +1,5 @@ +# Доказательства + +Храните небольшие обезличенные результаты: команды и stdout проверок, JSON/CSV агрегатов нагрузки, EXPLAIN, dashboard JSON, текст разбора отказа. Укажите дату, commit, аппаратные лимиты и профиль нагрузки. + +Не добавляйте .env, session tokens, cookies, приватные ключи, credentials, реальные сообщения, raw dumps и signed URLs. Для скриншотов скрывайте секреты. Большие логи/бинарные traces сохраняйте отдельно по правилам преподавателя; одного скриншота без методики недостаточно. diff --git a/examples/requests.http b/examples/requests.http new file mode 100644 index 0000000..492f938 --- /dev/null +++ b/examples/requests.http @@ -0,0 +1,46 @@ +# Optional HTTP-client worksheet; no extension is required by the course. +# Replace IDs/token locally from real responses; do not commit filled-in credentials. +# Canonical API: contracts/openapi.json and contracts/README.md. +@baseUrl = http://localhost:8080 +@aliceId = REPLACE_WITH_CREATED_ALICE_UUID +@bobId = REPLACE_WITH_CREATED_BOB_UUID +@chatId = REPLACE_WITH_CREATED_CHAT_UUID +@sessionToken = REPLACE_LOCALLY_DO_NOT_COMMIT + +### Liveness +GET {{baseUrl}}/health/live + +### Register Alice (repeat with username bob/display_name Боб) +POST {{baseUrl}}/api/v1/users +Content-Type: application/json + +{ + "username": "alice", + "display_name": "Алиса", + "password": "Example-only-not-a-real-secret-123" +} + +### Create session: generate the Base64 value locally, do not commit credentials +# Base64 of UTF-8 username:password; only this endpoint accepts Basic. +POST {{baseUrl}}/api/v1/auth/sessions +Authorization: Basic REPLACE_WITH_LOCAL_BASE64_CREDENTIAL + + +### Create chat; both UUIDs must be real registered users +POST {{baseUrl}}/api/v1/chats +Authorization: Bearer {{sessionToken}} +Content-Type: application/json + +{"title":"Проект по ВНП","member_ids":["{{aliceId}}","{{bobId}}"]} + +### Send message +POST {{baseUrl}}/api/v1/chats/{{chatId}}/messages +Authorization: Bearer {{sessionToken}} +Idempotency-Key: example-message-001 +Content-Type: application/json + +{"text":"Привет, Боб!"} + +### Read first page +GET {{baseUrl}}/api/v1/chats/{{chatId}}/messages?limit=50 +Authorization: Bearer {{sessionToken}} diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..5a39e86 --- /dev/null +++ b/infra/README.md @@ -0,0 +1,7 @@ +# Инфраструктура студента + +Здесь разместите конфигурации выбранных сервисов; основную топологию — в корневом compose.yaml/stack.yaml. `compose.example.yaml` — только worksheet ролей и намеренно не запускается. `contracts/infrastructure.json` задаёт минимальные роли, не готовые образы/пароли. + +В REPORT.md сопоставьте роль → service name → версия/digest → health/readiness → volume → сеть/порт → способ передачи credentials. Приведите bootstrap (schema, bucket, broker topology, а с lab6 provisioning) и безопасное повторение этих команд. + +Приложение строится из исходников. Не используйте заранее существующий образ преподавательского сервера вместо своей реализации. Restart hook перезапускает все соответствующие stores с volumes; failover hook останавливает только заданную API-реплику. diff --git a/lab.json b/lab.json new file mode 100644 index 0000000..57bb9fb --- /dev/null +++ b/lab.json @@ -0,0 +1,8 @@ +{ + "number": 3, + "title": "Балансировка и отказ API-реплики", + "contract_version": "1.3.0", + "default_base_url": "http://localhost:8080", + "grading": "3; 4 includes 3; 5 includes 4 plus one completed research track", + "scaffold_has_application": false +} diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..6b103dd --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +# Only for full OpenAPI/JSON Schema validation; HTTP tests use stdlib. +openapi-spec-validator==0.7.2 diff --git a/scripts/check_contract.py b/scripts/check_contract.py new file mode 100644 index 0000000..dcb34df --- /dev/null +++ b/scripts/check_contract.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Offline structural checks; full schema validation is a separate target.""" +import ast +import json +from pathlib import Path +import re + +ROOT = Path(__file__).resolve().parents[1] + +def main(): + lab = json.loads((ROOT/'lab.json').read_text())['number'] + spec = json.loads((ROOT/'contracts/openapi.json').read_text()) + assert spec['openapi']=='3.1.0' + assert spec['info']['version']=='1.%d.0'%lab + operation_ids = set() + def walk(value): + if isinstance(value,dict): + if '$ref' in value: + pointer = value['$ref'] + assert pointer.startswith('#/'), 'unexpected external reference' + target = spec + for part in pointer[2:].split('/'): + target = target[part.replace('~1','/').replace('~0','~')] + for v in value.values(): + walk(v) + elif isinstance(value,list): + for v in value: + walk(v) + walk(spec) + for route,methods in spec['paths'].items(): + placeholders = set(re.findall(r'\{([^}]+)\}',route)) + for method,op in methods.items(): + assert op['operationId'] not in operation_ids, 'duplicate operationId' + operation_ids.add(op['operationId']) + params = {p['name'] for p in op.get('parameters',[]) if p['in']=='path' and p.get('required')} + assert placeholders==params, 'path parameter mismatch: '+route + for response in op['responses'].values(): + assert 'X-Request-Id' in response['headers'] + if lab>=3: + assert 'X-Instance-Id' in response['headers'] + assert ('/api/v1/auth/sessions' in spec['paths']) == (lab>=2) + assert ('/api/v1/attachments/{attachment_id}' in spec['paths']) == (lab>=4) + for file in list((ROOT/'tests').glob('*.py'))+list((ROOT/'scripts').glob('*.py')): + ast.parse(file.read_text(),filename=str(file)) + for file in ROOT.rglob('*.json'): + if any(part in {'.git','.venv','node_modules','vendor','output'} for part in file.parts): + continue + json.loads(file.read_text()) + for required in ('README.md','COURSE.md','CONTRIBUTING.md','REPORT.md','contracts/README.md','tests/smoke.py','tests/README.md'): + assert (ROOT/required).is_file(), 'missing '+required + print('PASS: offline skeleton structure, local refs and Python syntax; application NOT tested.') + +if __name__=='__main__': + main() diff --git a/scripts/restart.example.sh b/scripts/restart.example.sh new file mode 100755 index 0000000..967e951 --- /dev/null +++ b/scripts/restart.example.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu +# TODO: copy to restart.sh and implement using YOUR compose/stack services. +# Restart API and ALL used state stores (DB, sessions, S3, broker as applicable). +# Preserve persistent volumes and schema/data; never down -v / flush / re-seed. +# Allow operator unseal if required in lab7; do not print credentials. +# Exit 0 only after the intended action; test waits for API readiness separately. +printf '%s +' 'TODO: implement restart.sh for your infrastructure' >&2 +exit 2 diff --git a/scripts/stop-one.example.sh b/scripts/stop-one.example.sh new file mode 100755 index 0000000..ae149e8 --- /dev/null +++ b/scripts/stop-one.example.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu +# TODO: copy to stop-one.sh, map TARGET_INSTANCE_ID to ONE API container/task. +# Stop that instance and leave it stopped; no stop of DB/LB/volumes. +# Return quickly (within 10 s); the test sends requests during this script. +# Restore the replica explicitly after the test. Never print credentials. +: "${TARGET_INSTANCE_ID:?test provides target API instance alias}" +printf '%s +' 'TODO: implement stop-one.sh for your infrastructure' >&2 +exit 2 diff --git a/scripts/validate_openapi.py b/scripts/validate_openapi.py new file mode 100644 index 0000000..1dfe4a9 --- /dev/null +++ b/scripts/validate_openapi.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Full OpenAPI and JSON Schema checks; install requirements-dev.txt first.""" +import json +from pathlib import Path +from openapi_spec_validator import validate +from jsonschema import Draft202012Validator + +root = Path(__file__).resolve().parents[1] +spec = json.loads((root/'contracts/openapi.json').read_text()) +validate(spec) +from referencing import Registry, Resource +# The base URI resolves local #/components references inside examples. +resource = dict(spec, **{'$schema': 'https://json-schema.org/draft/2020-12/schema'}) +registry = Registry().with_resource('urn:mtusi:openapi', Resource.from_contents(resource)) +for name, example in json.loads((root/'contracts/examples.json').read_text()).items(): + schema = {'$ref': 'urn:mtusi:openapi#/components/schemas/' + name} + Draft202012Validator(schema, registry=registry).validate(example) +for path in (root/'contracts').glob('*.schema.json'): + schema = json.loads(path.read_text()) + Draft202012Validator.check_schema(schema) + for example in schema.get('examples',[]): + Draft202012Validator(schema).validate(example) +print('PASS: full OpenAPI 3.1 and JSON Schema validation; application NOT tested.') diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..0dfa3f8 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,57 @@ +# Публичные проверки + +Python **3.9+**, стандартная библиотека. Сервер уже должен быть запущен; проверки не создают готовое приложение и сами не управляют Docker. `make check` можно выполнить на исходном skeleton, `make test` — только после реализации API. Полная OpenAPI-валидация отдельно: `make validate` после установки `requirements-dev.txt` в venv. + +```sh +make check +make test BASE_URL=http://localhost:8080 +# без make: +python3 tests/smoke.py --base-url http://localhost:8080 +``` + +`smoke.py` выполняет **один сквозной сценарий**, автоматически выбирая профиль из `lab.json`: health → три пользователя → вход с lab2 → чат → сообщения → пагинация → ошибки доступа → идемпотентность с lab3 → файл с lab4 → thumbnail с lab5 → logout. Он проверяет JSON по используемому подмножеству схемы, коды/headers, Unicode, SHA-256 оригинала и фактические JPEG-размеры thumbnail. Это публичный минимум, не полный fuzz/security/load suite. + +Каждый запуск создаёт уникальных синтетических пользователей, чат и сообщения. Данные автоматически не удаляются: в API курса нет delete. Для чистого повтора используйте отдельный тестовый стенд/volume и осознанный сброс своего окружения. Session tokens хранятся только в памяти процесса и не печатаются/не сохраняются. + +## Проверка сохранности, lab2+ + +Скопируйте `scripts/restart.example.sh` в `scripts/restart.sh`, реализуйте перезапуск **API и всех используемых хранилищ** с сохранением volumes и задайте executable bit. Скрипт может опираться на ваш compose.yaml или stack.yaml. Он не должен удалять volumes, повторно seed-ить БД или менять тестовые данные. + +```sh +chmod +x scripts/restart.sh +make test-persistence ACTION_SCRIPT=scripts/restart.sh +``` + +Тест создаёт данные и сессию, запускает указанный файл без shell interpolation, ожидает готовности и проверяет те же данные **с прежним токеном**. В lab4+ также проверяется исходный файл. Hook может работать до 90 секунд, готовность затем ожидается до 90 секунд. TTL сессии для этого теста — минимум 300 секунд. Успех не доказывает, что hook действительно перезапустил БД: приложите команды/состояния контейнеров до и после. + +## Проверка отказа, lab3+ + +Реализуйте `scripts/stop-one.sh` на основе примера. `TARGET_INSTANCE_ID` в окружении содержит alias обслужившей тест API-реплики: сопоставьте его своему контейнеру/Swarm task и остановите именно его. Скрипт должен быстро вернуть 0 и **оставить эту реплику остановленной**; восстановление выполняйте отдельно. Swarm может создать новую task с другим ID — это допустимо. + +```sh +chmod +x scripts/stop-one.sh +make test-failover ACTION_SCRIPT=scripts/stop-one.sh +``` + +Перед отказом тест наблюдает ≥2 API-реплики; 15 секунд отправляет сообщения параллельно выполнению hook, повторяет неопределённый POST с тем же ключом, проверяет восстановление записи за ≤10 секунд, отсутствие остановленной реплики в последних ответах, сохранность подтверждённых сообщений и отсутствие дублей. Ошибки переходного периода учитываются. Затем восстановите реплику и повторите для другой. Скрипт — проверка одного отказа API, не гарантия HA БД/LB/host. Вывод hook скрыт, чтобы не утекли секреты; отлаживайте его отдельно с безопасным выводом. + +## Нагрузка + +```sh +python3 tests/load.py --duration 30 --concurrency 4 --output evidence/load.json +``` + +Это простой **closed-loop** генератор POST сообщений; он ограничен производительностью клиента и страдает coordinated omission. Он выдаёт successful RPS, ошибки и p50/p95/p99 **только успешных запросов**. Во время нагрузки нет прозрачных retry; timeout мог скрыть совершённую запись, поэтому это throughput HTTP-подтверждений, не точный счётчик COMMIT. Изменение размера истории входит в профиль. Для серьёзного исследования используйте k6/Locust/wrk либо свой обоснованный генератор, а не выводите максимальную пропускную способность из одного запуска этого скрипта. Для нагрузки pipeline изображений нужен отдельный сценарий студента. + +## HTTPS, lab7 + +```sh +make test BASE_URL=https://localhost:8443 CA_FILE=/absolute/path/to/ca.crt +make test-security BASE_URL=https://localhost:8443 CA_FILE=/absolute/path/to/ca.crt +``` + +Все скрипты принимают `--ca-file`/`CA_FILE` и проверяют серверный сертификат и hostname. `--insecure` отсутствует. TLS-тест сначала проверяет рабочее доверенное соединение, затем намеренно пустой trust store и именно ошибку проверки сертификата; network timeout не засчитывается как правильный отказ. **Внутреннее mTLS, identity/authorization, OpenBao policy и hardening проверяют отдельные тесты студента** по trust matrix. Клиентский сертификат внешнему учебному REST-клиенту не требуется: mTLS находится на внутренних связях. + +## Что ещё остаётся доказать + +Smoke не доказывает persistence, число реальных контейнеров, durability брокера, outbox, отсутствие утечек/уязвимостей, полноту telemetry или выполнение уровня 4/5. Конкурентные/нагрузочные/негативные проверки своего решения добавляйте отдельно. Список защиты текущей лабы находится в README.md. Проверки рассчитаны на localhost-стенд, не на production. diff --git a/tests/client.py b/tests/client.py new file mode 100644 index 0000000..f0499a2 --- /dev/null +++ b/tests/client.py @@ -0,0 +1,217 @@ +"""HTTP helpers for public black-box tests; Python 3.9+, standard library only.""" +import base64 +import datetime as dt +import hashlib +import json +import os +from pathlib import Path +import re +import ssl +import time +import urllib.error +import urllib.request +import uuid + +ROOT = Path(__file__).resolve().parents[1] +LAB = json.loads((ROOT / 'lab.json').read_text())['number'] +SPEC = json.loads((ROOT / 'contracts/openapi.json').read_text()) + +class Failure(AssertionError): + pass + +def check(condition, message): + if not condition: + raise Failure(message) + +def timestamp(value): + check(isinstance(value, str) and value.endswith('Z'), 'timestamp must be UTC ending in Z') + try: + return dt.datetime.fromisoformat(value[:-1] + '+00:00') + except ValueError as exc: + raise Failure('invalid RFC3339 timestamp') from exc + +def validate(value, schema, where='response'): + """Validate the JSON Schema subset used by the supplied contract, not arbitrary OpenAPI.""" + if '$ref' in schema: + target = SPEC + for part in schema['$ref'].split('/')[1:]: + target = target[part] + return validate(value, target, where) + kinds = schema.get('type', []) + if isinstance(kinds, str): + kinds = [kinds] + matches = {'null':value is None, 'boolean':isinstance(value,bool), 'integer':isinstance(value,int) and not isinstance(value,bool), 'number':isinstance(value,(int,float)) and not isinstance(value,bool), 'string':isinstance(value,str), 'array':isinstance(value,list), 'object':isinstance(value,dict)} + if kinds: + check(any(matches.get(k,False) for k in kinds), where + ': incorrect type') + if 'enum' in schema: + check(value in schema['enum'], where + ': invalid enum') + if 'const' in schema: + check(value == schema['const'], where + ': invalid const') + if isinstance(value,dict): + props = schema.get('properties',{}) + check(all(k in value for k in schema.get('required',[])), where + ': missing required fields') + if schema.get('additionalProperties') is False: + check(not set(value).difference(props), where + ': unexpected fields') + for key, sub in props.items(): + if key in value: + validate(value[key], sub, where + '.' + key) + if isinstance(value,list): + check(len(value) >= schema.get('minItems',0), where + ': too few items') + check(len(value) <= schema.get('maxItems',float('inf')), where + ': too many items') + if schema.get('uniqueItems'): + encoded = [json.dumps(x,sort_keys=True) for x in value] + check(len(encoded)==len(set(encoded)), where + ': duplicate items') + if 'items' in schema: + for item in value: + validate(item, schema['items'], where + '[]') + if isinstance(value,str): + check(len(value) >= schema.get('minLength',0), where + ': too short') + check(len(value) <= schema.get('maxLength',float('inf')), where + ': too long') + if 'pattern' in schema: + check(re.search(schema['pattern'], value) is not None, where + ': pattern mismatch') + if schema.get('format') == 'uuid': + try: + uuid.UUID(value) + except ValueError as exc: + raise Failure(where + ': invalid UUID') from exc + if schema.get('format') == 'date-time': + timestamp(value) + if isinstance(value,(int,float)) and not isinstance(value,bool): + check(value >= schema.get('minimum',-float('inf')), where + ': below minimum') + check(value <= schema.get('maximum',float('inf')), where + ': above maximum') + if 'anyOf' in schema: + for alternative in schema['anyOf']: + try: + validate(value, alternative, where) + break + except Failure: + pass + else: + raise Failure(where + ': no anyOf branch matches') + +def shape(value, name): + validate(value, SPEC['components']['schemas'][name]) + return value + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + +class Client: + def __init__(self, base_url=None, ca_file=None, timeout=10): + self.base = (base_url or os.environ.get('BASE_URL') or ('https://localhost:8443' if LAB==7 else 'http://localhost:8080')).rstrip('/') + check(self.base.startswith(('http://','https://')), 'BASE_URL must be HTTP(S)') + check(LAB != 7 or self.base.startswith('https://'), 'lab7 requires HTTPS') + self.timeout = timeout + context = ssl.create_default_context(cafile=ca_file or os.environ.get('CA_FILE') or None) + self.opener = urllib.request.build_opener(NoRedirect(), urllib.request.HTTPSHandler(context=context)) + + def call(self, method, path, expected=200, body=None, actor=None, headers=None, raw=None, schema=None): + h = {'Accept':'application/json'} + if actor: + h['X-User-Id' if LAB==1 else 'Authorization'] = actor['id'] if LAB==1 else 'Bearer ' + actor['token'] + if body is not None: + raw = json.dumps(body,ensure_ascii=False).encode('utf-8') + h['Content-Type'] = 'application/json' + h.update(headers or {}) + req = urllib.request.Request(self.base + path, data=raw, headers=h, method=method) + start = time.monotonic() + try: + response = self.opener.open(req, timeout=self.timeout) + except urllib.error.HTTPError as error: + response = error + except (urllib.error.URLError,TimeoutError,OSError) as exc: + # Do not print URL query, headers, request body, or credentials. + raise Failure(method + ' ' + path.split('?')[0] + ': connection/TLS failure (' + type(exc).__name__ + ')') from None + with response: + status = response.code + rh = response.headers + data = response.read(16*1024*1024 + 1) + check(len(data) <= 16*1024*1024, 'response exceeds test safety limit') + allowed = [expected] if isinstance(expected,int) else expected + check(status in allowed, method + ' ' + path.split('?')[0] + ': expected ' + str(allowed) + ', received ' + str(status)) + check(bool(rh.get('X-Request-Id')), 'missing X-Request-Id') + if LAB>=3: + check(bool(rh.get('X-Instance-Id')), 'missing X-Instance-Id') + payload = None + if schema or status>=400: + check(rh.get_content_type()=='application/json', 'expected application/json') + try: + payload = json.loads(data) + except (ValueError,UnicodeError): + raise Failure('invalid response JSON') from None + shape(payload, 'Error' if status>=400 else schema) + if status>=400: + check(payload['error']['request_id']==rh.get('X-Request-Id'),'error request_id differs from header') + if status==401 and LAB>=2: + scheme = 'Basic' if path=='/api/v1/auth/sessions' else 'Bearer' + check(rh.get('WWW-Authenticate','').lower().startswith(scheme.lower()), 'incorrect WWW-Authenticate challenge') + if status==204: + check(not data, '204 must not contain body') + return {'status':status,'headers':rh,'json':payload,'bytes':data,'elapsed':time.monotonic()-start} + + def user(self, label): + username = 'u_' + label + '_' + uuid.uuid4().hex[:12] + password = 'T3st-' + uuid.uuid4().hex + body = {'username':username,'display_name':'Студент ' + label} + if LAB>=2: + body['password'] = password + user = self.call('POST','/api/v1/users',201,body=body,schema='User')['json'] + check(user['username']==username,'username changed') + actor = {'id':user['id'],'user':user,'registration':body} + if LAB>=2: + credential = base64.b64encode((username+':'+password).encode()).decode() + session = self.call('POST','/api/v1/auth/sessions',201,headers={'Authorization':'Basic '+credential},schema='Session')['json'] + check(session['user']==user,'session user mismatch') + check(timestamp(session['expires_at']) > dt.datetime.now(dt.timezone.utc),'session already expired') + actor['token'] = session['token'] + return actor + + def bootstrap(self): + self.call('GET','/health/live',schema='Health') + self.call('GET','/health/ready',schema='Health') + alice, bob, eve = (self.user(label) for label in ('alice','bob','eve')) + chat = self.call('POST','/api/v1/chats',201,actor=alice,body={'title':'Контрактный тест','member_ids':[alice['id'],bob['id']]},schema='Chat')['json'] + check(set(chat['member_ids'])=={alice['id'],bob['id']},'chat member mismatch') + return alice,bob,eve,chat + + def message(self, actor, chat, text, key=None, attachments=None): + body = {'text':text} + if attachments is not None: + body['attachment_ids'] = attachments + headers = {'Idempotency-Key':key or uuid.uuid4().hex} if LAB>=3 else {} + return self.call('POST','/api/v1/chats/'+chat['id']+'/messages',201,actor=actor,body=body,headers=headers,schema='Message') + + def attachment(self, actor, chat): + content = (ROOT / 'tests/fixtures/sample.png').read_bytes() + att = self.call('POST','/api/v1/chats/'+chat['id']+'/attachments?filename=sample.png',201 if LAB==4 else 202,actor=actor,raw=content,headers={'Content-Type':'image/png'},schema='Attachment')['json'] + check(att['status']==('ready' if LAB==4 else 'queued'),'incorrect upload status') + check(att['chat_id']==chat['id'] and att['owner_id']==actor['id'],'attachment ownership mismatch') + check(att['sha256']==hashlib.sha256(content).hexdigest() and att['size_bytes']==len(content),'original checksum/size mismatch') + return att,content + + def wait_ready(self, actor, att, wait_seconds=60): + deadline = time.monotonic()+wait_seconds + while time.monotonic()=2,'persistence requires lab2+') + check(args.mode!='failover' or LAB>=3,'failover requires lab3+') + script = Path(args.action_script).expanduser().resolve() + check(script.is_file() and os.access(script,os.X_OK),'action script must exist and be executable (chmod +x)') + check('.example.' not in script.name,'copy and implement the example hook first') + c = Client(args.base_url,args.ca_file,timeout=2) + alice,bob,eve,chat = c.bootstrap() + sent = c.message(alice,chat,'Сохранить при отказе') + original = sent['json'] + target = sent['headers'].get('X-Instance-Id','') + attachment = content = None + if LAB>=4: + attachment,content = c.attachment(alice,chat) + if LAB>=5: + attachment = c.wait_ready(alice,attachment) + env = dict(os.environ) + env['TARGET_INSTANCE_ID'] = target + observed = set() + if args.mode=='failover': + for _ in range(40): + observed.add(c.call('GET','/api/v1/users/me',actor=alice,schema='User')['headers']['X-Instance-Id']) + check(len(observed)>=2,'less than two API instances observed before failure; verify LB/session affinity') + print('Executing explicit '+args.mode+' hook; hook output suppressed to avoid leaking secrets.') + # No shell interpolation and no Docker commands in this test. + proc = subprocess.Popen([str(script)],env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) + started = time.monotonic() + successes,failures,after_instances = [],0,[] + try: + if args.mode=='persistence': + try: + code = proc.wait(timeout=90) + except subprocess.TimeoutExpired: + raise Failure('restart hook exceeded 90 seconds') from None + check(code==0,'restart hook failed (inspect your script locally)') + deadline = time.monotonic()+90 + while True: + try: + c.call('GET','/health/ready',schema='Health') + c.call('GET','/api/v1/users/me',actor=alice,schema='User') + break + except Failure: + check(time.monotonic()=5,'too few successful writes after stop') + check(target not in after_instances[-5:],'target replica still serves requests; hook must leave it stopped') + print(json.dumps({'observed_instances_before':len(observed),'successful_writes':len(successes),'failed_attempts':failures,'first_recovery_seconds':round(first_recovery,3)},ensure_ascii=False)) + # Existing token, no re-login. Read all confirmations and reject duplicates. + messages = history(c,bob,chat) + ids = [m['id'] for m in messages] + check(len(ids)==len(set(ids)),'duplicated message IDs in history') + check(any(m==original for m in messages),'confirmed pre-failure message lost or altered') + for msg in successes: + check(any(m==msg for m in messages),'confirmed in-flight message lost or altered') + # Bodies are unique in this scenario: an uncertain request may exist once, never twice. + texts = [m['text'] for m in messages] + check(len(texts)==len(set(texts)),'uncertain retry created duplicate message') + if attachment: + c.call('GET','/api/v1/attachments/'+attachment['id'],actor=bob,schema='Attachment') + downloaded = c.call('GET','/api/v1/attachments/'+attachment['id']+'/content',actor=bob) + check(downloaded['bytes']==content,'attachment lost or changed across failure') + print('PASS: '+args.mode+' preserved confirmed data and existing session. Hook scope needs operator evidence.') + finally: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + +if __name__=='__main__': + run(main) diff --git a/tests/smoke.py b/tests/smoke.py new file mode 100644 index 0000000..4cbfa55 --- /dev/null +++ b/tests/smoke.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""One complete public acceptance scenario, cumulative by lab number.""" +import base64 +import hashlib +import uuid +from client import Client, LAB, arguments, check, run, timestamp + + +def main(): + args = arguments('Messenger public smoke; creates synthetic users and messages') + c = Client(args.base_url,args.ca_file) + alice,bob,eve,chat = c.bootstrap() + path = '/api/v1/chats/'+chat['id'] + c.call('POST','/api/v1/users',409,body=alice['registration']) + c.call('GET','/api/v1/users/me',401) + c.call('GET',path,404,actor=eve) + me = c.call('GET','/api/v1/users/me',actor=alice,schema='User')['json'] + check(me==alice['user'],'current user mismatch') + listed = c.call('GET','/api/v1/chats?limit=100',actor=bob,schema='ChatPage')['json'] + check(any(x['id']==chat['id'] for x in listed['items']),'member cannot list chat') + hidden = c.call('GET','/api/v1/chats?limit=100',actor=eve,schema='ChatPage')['json'] + check(all(x['id']!=chat['id'] for x in hidden['items']),'chat leaks to outsider') + key = uuid.uuid4().hex + first = c.message(alice,chat,'Привет, мир 👋',key=key)['json'] + second = c.message(bob,chat,'Сообщение Боба')['json'] + check(first['sender_id']==alice['id'] and second['sender_id']==bob['id'],'sender was not derived from actor') + check(first['chat_id']==second['chat_id']==chat['id'],'wrong chat in message') + check(first['text']=='Привет, мир 👋' and second['text']=='Сообщение Боба','message text changed') + page1 = c.call('GET',path+'/messages?limit=1',actor=bob,schema='MessagePage')['json'] + check(len(page1['items'])==1 and page1['next_cursor'],'first page must have one item and cursor') + from urllib.parse import quote + page2 = c.call('GET',path+'/messages?limit=1&cursor='+quote(page1['next_cursor'],safe=''),actor=alice,schema='MessagePage')['json'] + check(len(page2['items'])==1 and page2['next_cursor'] is None,'second page must finish history') + ordered = sorted([first,second],key=lambda x:(timestamp(x['created_at']),uuid.UUID(x['id']).int)) + check(page1['items']+page2['items']==ordered,'pagination lost, duplicated or reordered a message') + h = {'Idempotency-Key':uuid.uuid4().hex} if LAB>=3 else {} + c.call('POST',path+'/messages',400,actor=alice,body={'text':''},headers=h) + c.call('POST',path+'/messages',404,actor=eve,body={'text':'чужое'},headers=h) + c.call('GET',path+'/messages?limit=0',400,actor=alice) + if LAB>=2: + bad = base64.b64encode((alice['registration']['username']+':wrong-password').encode()).decode() + c.call('POST','/api/v1/auth/sessions',401,headers={'Authorization':'Basic '+bad}) + good = base64.b64encode((alice['registration']['username']+':'+alice['registration']['password']).encode()).decode() + c.call('GET','/api/v1/users/me',401,headers={'Authorization':'Basic '+good}) + c.call('GET','/api/v1/users/me',401,headers={'X-User-Id':alice['id']}) + me = c.call('GET','/api/v1/users/me',actor=alice,headers={'X-User-Id':eve['id']},schema='User')['json'] + check(me['id']==alice['id'],'X-User-Id overrode bearer identity') + if LAB>=3: + replay = c.message(alice,chat,first['text'],key=key)['json'] + check(replay==first,'idempotency replay differs') + c.call('POST',path+'/messages',409,actor=alice,body={'text':'другой body'},headers={'Idempotency-Key':key}) + c.call('POST',path+'/messages',400,actor=alice,body={'text':'без ключа'}) + if LAB>=4: + att,content = c.attachment(alice,chat) + # A queued attachment must already be linkable to its own chat. + attached = c.message(alice,chat,'',attachments=[att['id']])['json'] + check(attached['attachment_ids']==[att['id']],'message lost attachment') + ready = c.wait_ready(bob,att) if LAB>=5 else att + check(ready['error_code'] is None,'ready attachment has an error') + download = c.call('GET','/api/v1/attachments/'+att['id']+'/content',actor=bob) + check(download['bytes']==content,'download differs from original') + check(download['headers'].get_content_type()=='image/png','original content type mismatch') + for suffix in ('','/content'): + c.call('GET','/api/v1/attachments/'+att['id']+suffix,404,actor=eve) + if LAB>=5: + variants = [x for x in ready['variants'] if x['name']=='thumbnail'] + check(len(variants)==1,'expected exactly one thumbnail') + v = variants[0] + check((v['width'],v['height'])==(256,160),'thumbnail metadata has wrong dimensions') + thumb = c.call('GET','/api/v1/attachments/'+att['id']+'/content?variant=thumbnail',actor=bob) + check(thumb['headers'].get_content_type()=='image/jpeg','thumbnail content type mismatch') + check(len(thumb['bytes'])==v['size_bytes'] and hashlib.sha256(thumb['bytes']).hexdigest()==v['sha256'],'thumbnail checksum mismatch') + from image_probe import jpeg_size + check(jpeg_size(thumb['bytes'])==(256,160),'actual JPEG dimensions mismatch') + if LAB>=2: + c.call('DELETE','/api/v1/auth/sessions/current',204,actor=alice) + c.call('GET','/api/v1/users/me',401,actor=alice) + c.call('DELETE','/api/v1/auth/sessions/current',401,actor=alice) + print('PASS: lab%d public API scenario; infrastructure and grading evidence remain separate.' % LAB) + +if __name__=='__main__': + run(main)