Подготовить skeleton лабораторной 2 по мессенджеру
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
## Лабораторная 2
|
||||||
|
|
||||||
|
Автор / группа: TODO
|
||||||
|
Целевая оценка (3/4/5): TODO
|
||||||
|
Трек 5, если заявлен: TODO
|
||||||
|
|
||||||
|
Что реализовано и что перенесено: TODO
|
||||||
|
Команды запуска из чистого clone: TODO
|
||||||
|
Результаты проверок и ссылка на REPORT.md: TODO
|
||||||
|
Ограничения / известные ошибки: TODO
|
||||||
|
Источники и использование ИИ: TODO
|
||||||
|
|
||||||
|
- [ ] Весь обязательный минимум текущей и предыдущих работ сохранён с объявленными переходами.
|
||||||
|
- [ ] Контракт и публичные тесты не ослаблены.
|
||||||
|
- [ ] make check и make test выполнены; приложен реальный вывод.
|
||||||
|
- [ ] Сценарии отказов/безопасности своего уровня воспроизведены.
|
||||||
|
- [ ] Все секреты и приватные ключи находятся вне Git; отчёт не содержит tokens.
|
||||||
|
- [ ] README задания сохранён, REPORT.md содержит инструкцию именно моего решения.
|
||||||
@@ -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.
|
||||||
+32
@@ -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.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Работа через fork и pull request
|
||||||
|
|
||||||
|
1. После публикации преподавателем сделайте fork репозитория **текущей** лабораторной в своём аккаунте и clone своего fork. URL преподаватель выдаёт отдельно.
|
||||||
|
2. Создайте ветку `solution/<group>-<surname>` от исходной ветки 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.
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Памятка преподавателю — лабораторная 2
|
||||||
|
|
||||||
|
## Что подготовлено
|
||||||
|
|
||||||
|
Язык реализации не задан. 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 в среде подготовки не был доступен. Это проверка материалов и тестовых драйверов, не приёмка будущих решений студентов.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -1,117 +1,105 @@
|
|||||||
# mtusi2026lab2
|
# Лабораторная 2. PostgreSQL, сессии и Redis/Valkey
|
||||||
|
|
||||||
***
|
Перенести состояние в постоянное хранилище и связать запросы с аутентифицированным пользователем. Подтверждённые сообщения и действующие сессии переживают перезапуск.
|
||||||
## С чего начать?
|
|
||||||
Для того, чтобы облегчить знакомство с сервисом GitFlic и первые шаги в нём, мы подготовили несколько рекомендаций.
|
|
||||||
Уже опытный пользователь? Отредактируйте данный **README** файл по своему усмотрению.
|
|
||||||
Не знаете что добавить в него? Перейдите в раздел `"Что должен содержать README файл"`, в котором описаны ключевые компоненты хорошего README файла.
|
|
||||||
|
|
||||||
## Добавьте свои файлы
|
Это **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)
|
||||||
|
|
||||||
```
|
## Откуда начинаем
|
||||||
git clone https://gitflic.ru/project/vmd/mtusi2026lab2.git
|
|
||||||
cd mtusi2026lab2
|
Перенесите собственный минимум lab1. Изменения API: password при регистрации, Basic для создания сессии, Bearer вместо X-User-Id для бизнес-запросов.
|
||||||
**добавьте первые файлы вашего проекта**
|
|
||||||
git add .
|
**Архитектура:** Клиент → API → PostgreSQL; на 4 появляется Redis ИЛИ Valkey для сессий/кэша.
|
||||||
git commit -m "Первый коммит"
|
|
||||||
git push -u origin master
|
## Что сделать
|
||||||
|
|
||||||
|
1. Опишите таблицы, ограничения, индексы и транзакционные границы до написания запросов. Добавьте версионируемые миграции.
|
||||||
|
2. Перенесите пользователей, чаты, членство и сообщения в PostgreSQL. Не используйте SQLite как замену PostgreSQL в принимаемом стенде.
|
||||||
|
3. Реализуйте регистрацию с password KDF, вход через Basic, opaque Bearer-сессию, expiry и logout. Действующие сессии тоже должны сохраняться.
|
||||||
|
4. Подготовьте единый локальный запуск API и БД (Compose рекомендуется уже здесь), подключите именованные volumes.
|
||||||
|
5. Пройдите smoke и тест полного перезапуска. На 4 добавьте Redis/Valkey с конкретной ролью и повторите эксперимент отказа.
|
||||||
|
|
||||||
|
## Оценка «3»: работающий минимум
|
||||||
|
|
||||||
|
- Минимум lab1 сохранён с объявленным переходом на новую аутентификацию. Все обязательные данные и ещё действующие сессии переживают рестарт API и хранилищ без удаления volumes.
|
||||||
|
- PostgreSQL содержит пользователей, чаты, участников и сообщения. Есть первичные/внешние ключи, уникальность username и миграции с нуля. Составная операция создания чата выполняется атомарно.
|
||||||
|
- Basic применяется только на POST /auth/sessions, остальные endpoint требуют Bearer. Есть срок жизни сессии и отзыв; X-User-Id не даёт доступ. Пароли уже хешируются password KDF.
|
||||||
|
- make test и make test-persistence ACTION_SCRIPT=scripts/restart.sh проходят. Миграции повторно запускаются без дублирования данных. Secrets/volumes не попадают в Git.
|
||||||
|
|
||||||
|
## Оценка «4»: уровень стажёра/джуна
|
||||||
|
|
||||||
|
Весь уровень 3 **и все** пункты ниже:
|
||||||
|
|
||||||
|
- Redis ИЛИ Valkey реально используется для сессий с TTL либо осмысленного кэша. Опишите источник истины, политику expiry и инвалидации. Если это единственное хранилище сессий, включите persistence, выдерживающее проверяемый рестарт; один TTL не сохраняет данные.
|
||||||
|
- Используются параметризованные запросы/безопасный ORM, ограниченный connection pool и таймауты. Проверены гонка регистрации одинакового username и rollback составной операции.
|
||||||
|
- Индекс истории обоснован запросом и EXPLAIN (ANALYZE, BUFFERS) на ≥10 000 сообщениях; пагинация не использует полный scan всей истории на каждую страницу.
|
||||||
|
- Есть автоматическая проверка expiry при коротком SESSION_TTL_SECONDS, logout, неверного пароля и недоступности Redis/БД. При потере сервиса приложение возвращает ограниченную ошибку/корректно обходит кэш, не принимает запрос от анонимного пользователя как доверенный.
|
||||||
|
|
||||||
|
## Оценка «5»: исследование и доказанный результат
|
||||||
|
|
||||||
|
Весь уровень 4 **и один законченный трек на выбор**. Приведите гипотезу, повторяемую методику, результаты и ограничения.
|
||||||
|
|
||||||
|
**Трек 1. Согласованность кэша.** Покажите на конкурентном сценарии stale read или cache stampede, реализуйте защиту и измерьте число SQL-запросов/latency до и после. Докажите read-after-write из контракта и восстановление после рестарта кэша; одной установки Redis недостаточно.
|
||||||
|
|
||||||
|
**Трек 2. План запроса и восстановление.** Исследуйте ≥100 000 сообщений с неравномерными размерами чатов, сравните ≥2 индекса/запроса и стоимость записи. Сделайте backup → восстановление в новое хранилище → сверку количества и выбранных сообщений. Обсудите время восстановления и границы потери данных.
|
||||||
|
|
||||||
|
## Как начать и проверить
|
||||||
|
|
||||||
|
```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.
|
||||||
```
|
```
|
||||||
cd existing_folder
|
|
||||||
git init
|
|
||||||
git remote add origin https://gitflic.ru/project/vmd/mtusi2026lab2.git
|
Примеры запросов доступны в `examples/requests.http`, примеры JSON — в `contracts/examples.json`. Для полной проверки схемы (по желанию локально; CI делает её автоматически):
|
||||||
git clone
|
|
||||||
**добавьте новые файлы**
|
```sh
|
||||||
git add .
|
python3 -m venv .venv
|
||||||
git commit -m "Новый коммит"
|
.venv/bin/python -m pip install -r requirements-dev.txt
|
||||||
git push -u origin master
|
make validate PYTHON=.venv/bin/python
|
||||||
```
|
```
|
||||||
***
|
|
||||||
|
|
||||||
|
Smoke не выставляет оценку автоматически. Проверку сохранности/отказов запускайте явно по [tests/README.md](tests/README.md), остальные доказательства приведите в отчёте.
|
||||||
|
|
||||||
# Что должен содержать README файл
|
## Сценарий защиты
|
||||||
|
|
||||||
|
1. Применить миграции к пустому volume, создать пользователей, войти, отправить сообщение.
|
||||||
|
2. Запустить persistence-сценарий: он держит токен только в памяти тестового процесса; action script перезапускает API и все используемые хранилища, сохраняя volumes.
|
||||||
|
3. После готовности тот же токен читает те же данные; после logout — 401. Повторить с отдельным коротким TTL для проверки expiry.
|
||||||
|
4. Для 4: показать роль Redis/Valkey, план SQL и ошибку зависимости. Для 5: выполнить выбранный эксперимент.
|
||||||
|
|
||||||
Прежде всего, стоит понимать, что `README.md` — это краткая документация. Это первое, что видит человек, который открывает репозиторий. Поэтому здесь важно дать достаточно информации о проекте и рассказать, что он из себя представляет.
|
## Что должно быть в PR
|
||||||
Ключевая информация, которую должен содержать README файл:
|
|
||||||
|
|
||||||
## Название и описание
|
Исходники, Dockerfile, миграции, compose.yaml либо эквивалентный воспроизводимый запуск, scripts/restart.sh, собственные тесты и отчёт.
|
||||||
Название проекта должно быть простым и понятным (чаще всего это одно слово).
|
|
||||||
Описание должно описывать основные функции проекта, включая его особенности и назначение.
|
|
||||||
Если у вашего проекта есть альтернативные проекты, то в описании можно перечислить ключевые отличия, которые выделяют ваш проект на фоне всех остальных.
|
|
||||||
|
|
||||||
## Установка и настройка
|
Заполните `REPORT.md` и [шаблон PR](.github/pull_request_template.md), укажите целевую оценку/трек. Не меняйте обязательный контракт и публичные tests ради зелёного результата. Сохраняйте возможности предыдущих лабораторных в пределах [объявленных переходов](COURSE.md).
|
||||||
Также в `README` файле рекомендуется перечислить необходимые инструкции для установки,
|
|
||||||
будь то использование пакетных менеджеров (например, `Homebrew` на MacOS или `apt` на Linux),
|
|
||||||
зависимости, которые могут понадобиться в ходе использования, а также шаги по их настройке.
|
|
||||||
|
|
||||||
## Совместная разработка
|
## Вопросы на защите
|
||||||
Можно добавить информацию о том, как принять участие в разработке вашего проекта, как стать непосредственным участником, правила оформления pull-requests и т.д.
|
|
||||||
|
|
||||||
## Контакты
|
- Что подтверждает COMMIT и какие гарантии зависят от настройки durability?
|
||||||
Ссылки на внешние ресурсы, такие как документация, блог, страница проекта в социальных сетях, сообщество проекта и т.д.
|
- Где хранятся сессии и что будет после flush/restart Redis?
|
||||||
|
- Чем аутентификация отличается от доступа к конкретному чату?
|
||||||
|
- Почему hash пароля и быстрый checksum имеют разные задачи?
|
||||||
|
|
||||||
## Статус проекта
|
## Первичные материалы
|
||||||
В данном разделе рекомендуется указывать, на какой стадии находится проект, активно разрабатывается или находится в стадии застоя.
|
|
||||||
Если же проект готов и во всю используется, можно указывать актуальную версию, а также последние изменения, которые были сделаны с момента предыдущего релиза.
|
|
||||||
|
|
||||||
***
|
- [PostgreSQL transactions](https://www.postgresql.org/docs/current/tutorial-transactions.html)
|
||||||
|
- [PostgreSQL EXPLAIN](https://www.postgresql.org/docs/current/using-explain.html)
|
||||||
# Полезные ссылки
|
- [Valkey persistence](https://valkey.io/topics/persistence/)
|
||||||
|
- [Redis persistence](https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/)
|
||||||
***
|
- [Basic authentication](https://www.rfc-editor.org/info/rfc7617/)
|
||||||
|
|
||||||
## Работа с проектом
|
|
||||||
|
|
||||||
- [ ] [Как создать проект](https://docs.gitflic.ru/project/project_create)
|
|
||||||
- [ ] [Как импортировать проект](https://docs.gitflic.ru/project/import_base)
|
|
||||||
- [ ] [Запросы на слияние](https://docs.gitflic.ru/project/merge_request)
|
|
||||||
- [ ] [Зеркалирование проекта](https://docs.gitflic.ru/project/mirror)
|
|
||||||
- [ ] [Импортировать проект с GitLab](https://docs.gitflic.ru/project/import)
|
|
||||||
|
|
||||||
## Команды
|
|
||||||
- [ ] [Создание команды](https://docs.gitflic.ru/team/create)
|
|
||||||
- [ ] [Обзор команды](https://docs.gitflic.ru/team/view)
|
|
||||||
- [ ] [Настройка команды](https://docs.gitflic.ru/team/settings)
|
|
||||||
|
|
||||||
## Реестр пакетов
|
|
||||||
- [ ] [Реестр пакетов](https://docs.gitflic.ru/registry/package)
|
|
||||||
- [ ] [PyPi](https://docs.gitflic.ru/registry/pypi_registry)
|
|
||||||
- [ ] [Generic](https://docs.gitflic.ru/registry/generic_registry)
|
|
||||||
- [ ] [Maven](https://docs.gitflic.ru/registry/maven_registry)
|
|
||||||
- [ ] [Docker](https://docs.gitflic.ru/registry/docker)
|
|
||||||
|
|
||||||
## Компании
|
|
||||||
- [ ] [Создание компании](https://docs.gitflic.ru/company/create)
|
|
||||||
- [ ] [Обзор компании](https://docs.gitflic.ru/company/view)
|
|
||||||
- [ ] [Тарифы и оплата](https://docs.gitflic.ru/company/price)
|
|
||||||
- [ ] [Запуск агента компании](https://docs.gitflic.ru/company/saas_runner_setup)
|
|
||||||
|
|
||||||
## CI/CD
|
|
||||||
- [ ] [Что такое GitFlic CI/CD](https://docs.gitflic.ru/cicd/introduction)
|
|
||||||
- [ ] [Задача (Job)](https://docs.gitflic.ru/cicd/job)
|
|
||||||
- [ ] [Конвейер (pipeline)](https://docs.gitflic.ru/cicd/pipeline)
|
|
||||||
- [ ] [Агенты](https://docs.gitflic.ru/cicd/agent)
|
|
||||||
- [ ] [Справочник для .yaml файла](https://docs.gitflic.ru/cicd/gitflic-ci-yaml)
|
|
||||||
|
|
||||||
## API
|
|
||||||
- [ ] [Введение в GitFlic API](https://docs.gitflic.ru/api/intro)
|
|
||||||
- [ ] [Методы для администратора](https://docs.gitflic.ru/api/admin)
|
|
||||||
- [ ] [Получение access токена](https://docs.gitflic.ru/api/access-token)
|
|
||||||
|
|
||||||
|
|
||||||
## Панель администратора
|
|
||||||
- [ ] [Панель администратора](https://docs.gitflic.ru/admin_panel/intro)
|
|
||||||
- [ ] [Панель управления](https://docs.gitflic.ru/admin_panel/dashboard)
|
|
||||||
- [ ] [Настройка LDAP](https://docs.gitflic.ru/admin_panel/ldap)
|
|
||||||
- [ ] [Ключевые настройки](https://docs.gitflic.ru/admin_panel/settings)
|
|
||||||
|
|
||||||
## Общая информация
|
|
||||||
- [ ] [Глоссарий](https://docs.gitflic.ru/common/gloss)
|
|
||||||
- [ ] [Права доступа ролей](https://docs.gitflic.ru/common/manage_roles)
|
|
||||||
- [ ] [Вебхуки](https://docs.gitflic.ru/common/webhook)
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Отчёт — лабораторная 2
|
||||||
|
|
||||||
|
- Автор, группа: TODO
|
||||||
|
- Целевая оценка; выбранный трек 5, если нужен: TODO
|
||||||
|
- Commit/PR: TODO после публикации
|
||||||
|
- Использованные источники, библиотеки и помощь ИИ: TODO
|
||||||
|
|
||||||
|
## Запуск из чистого clone
|
||||||
|
|
||||||
|
TODO: версии runtime/образов, prerequisites, команды создания локальных secrets/.env, build, миграций, запуска, ожидания готовности, тестов и остановки. Указать необходимые действия оператора. Не вставлять секреты. Отдельно указать безопасную остановку и команду намеренного удаления учебных данных.
|
||||||
|
|
||||||
|
## Архитектура и решения
|
||||||
|
|
||||||
|
TODO: схема процессов/хранилищ, источник истины, транзакционные границы, ограничения. Что перенесено из lab1, что изменено в этой работе. Соответствие переменных из .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: какие отказы выдерживаются и какие нет; что не реализовано; где учебное упрощение; какое улучшение подтверждено, а какое пока гипотеза.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# 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: 2
|
||||||
|
x-required-roles:
|
||||||
|
- api
|
||||||
|
- postgres
|
||||||
|
# TODO: implement real images/builds, network wiring, volumes, healthchecks,
|
||||||
|
# credentials and bootstrap. Details: contracts/infrastructure.json and README.md.
|
||||||
|
services: {}
|
||||||
@@ -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: <UUID существующего пользователя>`; отсутствие/неизвестный ID — 401. Это учебный выбор пользователя: любой клиент может выдать себя за другого.
|
||||||
|
|
||||||
|
**Lab2–7:** `POST /auth/sessions` принимает только `Authorization: Basic <base64(username:password)>`, без 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 <token>`. Истёкший/отозванный/неизвестный токен — 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, не бинарное содержимое.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"lab": 2,
|
||||||
|
"kind": "requirements, not runnable deployment",
|
||||||
|
"required_for_grade_3": [
|
||||||
|
{
|
||||||
|
"role": "api",
|
||||||
|
"count_min": 1,
|
||||||
|
"student_implements": true,
|
||||||
|
"persistent_local_state": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "postgres",
|
||||||
|
"purpose": "users/chats/membership/messages/metadata",
|
||||||
|
"persistent_volume": 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."
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
# Доказательства
|
||||||
|
|
||||||
|
Храните небольшие обезличенные результаты: команды и stdout проверок, JSON/CSV агрегатов нагрузки, EXPLAIN, dashboard JSON, текст разбора отказа. Укажите дату, commit, аппаратные лимиты и профиль нагрузки.
|
||||||
|
|
||||||
|
Не добавляйте .env, session tokens, cookies, приватные ключи, credentials, реальные сообщения, raw dumps и signed URLs. Для скриншотов скрывайте секреты. Большие логи/бинарные traces сохраняйте отдельно по правилам преподавателя; одного скриншота без методики недостаточно.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# 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}}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"text":"Привет, Боб!"}
|
||||||
|
|
||||||
|
### Read first page
|
||||||
|
GET {{baseUrl}}/api/v1/chats/{{chatId}}/messages?limit=50
|
||||||
|
Authorization: Bearer {{sessionToken}}
|
||||||
@@ -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-реплику.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"number": 2,
|
||||||
|
"title": "PostgreSQL, сессии и Redis/Valkey",
|
||||||
|
"contract_version": "1.2.0",
|
||||||
|
"default_base_url": "http://localhost:8080",
|
||||||
|
"grading": "3; 4 includes 3; 5 includes 4 plus one completed research track",
|
||||||
|
"scaffold_has_application": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Only for full OpenAPI/JSON Schema validation; HTTP tests use stdlib.
|
||||||
|
openapi-spec-validator==0.7.2
|
||||||
@@ -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()
|
||||||
Executable
+10
@@ -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
|
||||||
@@ -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.')
|
||||||
@@ -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.
|
||||||
+217
@@ -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()<deadline:
|
||||||
|
att = self.call('GET','/api/v1/attachments/'+att['id'],actor=actor,schema='Attachment')['json']
|
||||||
|
check(att['status']!='failed','attachment reached failed')
|
||||||
|
if att['status']=='ready':
|
||||||
|
return att
|
||||||
|
time.sleep(0.3)
|
||||||
|
raise Failure('attachment did not become ready within test deadline')
|
||||||
|
|
||||||
|
def arguments(description, configure=None):
|
||||||
|
import argparse
|
||||||
|
p = argparse.ArgumentParser(description=description)
|
||||||
|
p.add_argument('--base-url', default=None)
|
||||||
|
p.add_argument('--ca-file', default=None)
|
||||||
|
if configure:
|
||||||
|
configure(p)
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
def run(main):
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except (Failure,ValueError,OSError) as exc:
|
||||||
|
print('FAIL:', str(exc))
|
||||||
|
raise SystemExit(1)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Small closed-loop HTTP write benchmark; not an open-loop capacity proof."""
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from client import Client, Failure, LAB, arguments, check, run
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
def configure(p):
|
||||||
|
p.add_argument('--duration',type=float,default=30)
|
||||||
|
p.add_argument('--concurrency',type=int,default=4)
|
||||||
|
p.add_argument('--output',default=None,help='Optional JSON metrics, no session tokens')
|
||||||
|
args = arguments('Synthetic POST message load, closed loop; creates users/messages',configure)
|
||||||
|
check(1<=args.duration<=3600,'duration must be 1..3600 seconds')
|
||||||
|
check(1<=args.concurrency<=128,'concurrency must be 1..128')
|
||||||
|
c = Client(args.base_url,args.ca_file)
|
||||||
|
alice,bob,eve,chat = c.bootstrap()
|
||||||
|
barrier = threading.Barrier(args.concurrency)
|
||||||
|
def worker(worker_id):
|
||||||
|
local = Client(args.base_url,args.ca_file,timeout=5)
|
||||||
|
barrier.wait()
|
||||||
|
deadline = time.monotonic()+args.duration
|
||||||
|
latencies,errors = [],0
|
||||||
|
index = 0
|
||||||
|
while time.monotonic()<deadline:
|
||||||
|
start = time.monotonic()
|
||||||
|
try:
|
||||||
|
local.message(alice,chat,'load-%s-%s' % (worker_id,index))
|
||||||
|
latencies.append(time.monotonic()-start)
|
||||||
|
except Failure:
|
||||||
|
errors += 1
|
||||||
|
index += 1
|
||||||
|
return latencies,errors
|
||||||
|
started = time.monotonic()
|
||||||
|
with ThreadPoolExecutor(max_workers=args.concurrency) as pool:
|
||||||
|
results = list(pool.map(worker,range(args.concurrency)))
|
||||||
|
elapsed = time.monotonic()-started
|
||||||
|
latencies = sorted(x for values,_ in results for x in values)
|
||||||
|
errors = sum(n for _,n in results)
|
||||||
|
total = len(latencies)+errors
|
||||||
|
def percentile(p):
|
||||||
|
return round(1000*latencies[max(0,math.ceil(p*len(latencies))-1)],3) if latencies else None
|
||||||
|
report = {'lab':LAB,'workload':'closed-loop POST messages; no automatic retries','requested_duration_seconds':args.duration,'elapsed_seconds':round(elapsed,3),'concurrency':args.concurrency,'successful_requests':len(latencies),'failed_requests':errors,'error_ratio':round(errors/total,6) if total else None,'successful_rps':round(len(latencies)/elapsed,3),'successful_latency_ms':{'p50':percentile(.50),'p95':percentile(.95),'p99':percentile(.99)},'note':'Latency percentiles cover successes only. Timeouts/errors are counted separately; closed-loop has coordinated omission and client overhead.'}
|
||||||
|
text = json.dumps(report,ensure_ascii=False,indent=2)
|
||||||
|
if args.output:
|
||||||
|
dest = Path(args.output)
|
||||||
|
dest.parent.mkdir(parents=True,exist_ok=True)
|
||||||
|
dest.write_text(text+'\n')
|
||||||
|
print(text)
|
||||||
|
check(bool(latencies),'no successful writes')
|
||||||
|
# Errors are reported, not treated as a universal load-test threshold.
|
||||||
|
|
||||||
|
if __name__=='__main__':
|
||||||
|
run(main)
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Explicit operator-authored hook; tokens remain in memory, volumes are never removed by this test."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from urllib.parse import quote
|
||||||
|
from client import Client, Failure, LAB, arguments, check, run
|
||||||
|
|
||||||
|
|
||||||
|
def history(c, actor, chat):
|
||||||
|
path = '/api/v1/chats/'+chat['id']+'/messages?limit=100'
|
||||||
|
result = []
|
||||||
|
seen = set()
|
||||||
|
for _ in range(1000):
|
||||||
|
page = c.call('GET',path,actor=actor,schema='MessagePage')['json']
|
||||||
|
result.extend(page['items'])
|
||||||
|
cursor = page['next_cursor']
|
||||||
|
if cursor is None:
|
||||||
|
return result
|
||||||
|
check(cursor not in seen,'cursor cycle')
|
||||||
|
seen.add(cursor)
|
||||||
|
path = '/api/v1/chats/'+chat['id']+'/messages?limit=100&cursor='+quote(cursor,safe='')
|
||||||
|
raise Failure('history pagination exceeded safety bound')
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
def configure(p):
|
||||||
|
p.add_argument('--mode', choices=['persistence','failover'], required=True)
|
||||||
|
p.add_argument('--action-script',required=True,help='Executable script authored by student; restarts stack or stops ONE API replica')
|
||||||
|
args = arguments('Explicit restart/failover scenario; modifies only synthetic API data',configure)
|
||||||
|
check(LAB>=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()<deadline,'stack/session did not recover within 90 seconds')
|
||||||
|
time.sleep(0.5)
|
||||||
|
else:
|
||||||
|
first_recovery = None
|
||||||
|
key = uuid.uuid4().hex
|
||||||
|
text = 'Запись во время отказа ' + key
|
||||||
|
while time.monotonic()-started < 15:
|
||||||
|
code = proc.poll()
|
||||||
|
check(code is None or code==0,'stop-one hook failed (inspect your script locally)')
|
||||||
|
check(code is not None or time.monotonic()-started<10,'stop-one hook must return within 10 seconds')
|
||||||
|
try:
|
||||||
|
response = c.message(alice,chat,text,key=key)
|
||||||
|
msg = response['json']
|
||||||
|
if code==0:
|
||||||
|
if first_recovery is None:
|
||||||
|
first_recovery = time.monotonic()-started
|
||||||
|
after_instances.append(response['headers']['X-Instance-Id'])
|
||||||
|
successes.append(msg)
|
||||||
|
key = uuid.uuid4().hex
|
||||||
|
text = 'Запись во время отказа ' + key
|
||||||
|
except Failure:
|
||||||
|
failures += 1
|
||||||
|
# Keep SAME key/body after an uncertain POST result.
|
||||||
|
time.sleep(0.1)
|
||||||
|
check(proc.poll()==0,'stop-one hook did not finish successfully')
|
||||||
|
check(first_recovery is not None and first_recovery<=10,'writes did not recover within 10 seconds from hook start')
|
||||||
|
check(len(after_instances)>=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)
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user