From 12c983c0fc4e2c16d701a80b66ba296179a568a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=B2=D0=BB=D0=B0=D0=B4?= Date: Wed, 15 Jul 2026 00:06:13 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A8=D1=85=D1=83=D0=BD=D0=B0=20=D0=BD=D0=B5?= =?UTF-8?q?=20=D1=82=D0=BE=D0=BD=D0=B5=D1=82:=20security,=20infra=20=D0=B8?= =?UTF-8?q?=20=D0=B4=D0=BE=D0=BA=D0=B8=20=D0=BD=D0=B0=20=D1=80=D1=83=D1=81?= =?UTF-8?q?=D1=81=D0=BA=D0=BE=D0=BC.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Безопасность довёл до ума — Cursor-генерацию переписал руками. IDOR закрыл, CSRF задушил, refresh rotation теперь как надо. HSTS на staging, ENABLE_DOCS=false, install.env recovery протестил. Backend: - jwt_denylist + auth_epoch: мгновенный revoke access JWT (logout/block/reset) - auth/admin/users: bump epoch, logout с Bearer, forgot_password skip для blocked - install_secrets: путь всегда apps/api/data/secrets/ (bootstrap из корня не ломает Docker) - seed: SEED_DEMO_USERS=false на prod/staging - тесты: jwt revoke, integration, coverage gate 90% Frontend: - logout шлёт Bearer, обработка TOKEN_REVOKED - guards TypeScript fix - E2E: blocked user → 401 сразу после block Infra: - staging/prod compose, TLS nginx, deploy-скрипты - k6 §17.2, backup/health/smoke scripts Docs: - docs/ на русском: project, security, deploy, release (старые md слили) - README короткий + план ТЗ + стандартные логины dev Код готов к плаванию. Капитан может идти писать фронт. --- .gitignore | 3 + README.md | 472 +++--------------- apps/api/.env.example | 1 + apps/api/.env.production.example | 22 + apps/api/app/core/config.py | 1 + apps/api/app/core/crypto.py | 2 + apps/api/app/core/dependencies.py | 7 + apps/api/app/core/install_secrets.py | 3 +- apps/api/app/core/jwt_denylist.py | 108 ++++ apps/api/app/db/seed.py | 29 +- apps/api/app/main.py | 2 + apps/api/app/modules/admin/service.py | 3 + apps/api/app/modules/auth/router.py | 25 +- apps/api/app/modules/auth/service.py | 26 +- apps/api/app/modules/users/service.py | 2 + apps/api/scripts/docker_entrypoint.py | 3 +- apps/api/tests/core/test_app_settings.py | 63 +++ apps/api/tests/core/test_audit_log.py | 19 + apps/api/tests/core/test_dependencies.py | 68 +++ apps/api/tests/core/test_email.py | 29 ++ apps/api/tests/core/test_install_secrets.py | 115 +++++ apps/api/tests/core/test_jwt_denylist.py | 119 +++++ apps/api/tests/core/test_main.py | 9 + apps/api/tests/core/test_password_denylist.py | 12 +- apps/api/tests/core/test_production_guards.py | 86 ++++ apps/api/tests/core/test_redis.py | 32 ++ apps/api/tests/core/test_storage.py | 11 +- .../tests/modules/admin/test_admin_router.py | 36 ++ .../admin/test_security_diagnostics.py | 10 + apps/api/tests/modules/admin/test_service.py | 57 ++- .../auth/test_jwt_revocation_integration.py | 35 ++ .../modules/auth/test_logout_and_forgot.py | 77 +++ .../modules/auth/test_refresh_security.py | 28 +- .../modules/auth/test_service_refresh.py | 40 ++ apps/api/tests/modules/test/test_router.py | 38 ++ .../tests/scripts/test_docker_entrypoint.py | 8 +- apps/web/e2e/admin/admin-users.spec.ts | 19 + apps/web/src/app/router/guards/AdminGuard.tsx | 2 +- apps/web/src/app/router/guards/AuthGuard.tsx | 2 +- apps/web/src/app/router/guards/GuestGuard.tsx | 2 +- .../src/modules/admin/api/adminApi.test.ts | 20 +- .../admin/components/AdminStats.test.tsx | 15 +- apps/web/src/modules/auth/api/authApi.ts | 7 +- apps/web/src/modules/auth/hooks/useAuth.ts | 2 +- apps/web/src/shared/api/client.ts | 4 + apps/web/vite.config.ts | 1 + docs/README.md | 41 ++ docs/deploy.md | 196 ++++++++ docs/project.md | 220 ++++++++ docs/release-regression-checklist.md | 14 - docs/release.md | 113 +++++ docs/secrets-recovery.md | 30 -- docs/security-checklist.md | 20 - docs/security.md | 173 +++++++ infra/docker/.env.production.example | 13 + infra/docker/.env.staging.example | 12 + infra/docker/README.md | 33 +- infra/docker/deploy-prod.sh | 23 + infra/docker/deploy-staging.sh | 23 + infra/docker/docker-compose.prod.yml | 99 ++++ infra/docker/docker-compose.staging.yml | 82 ++- infra/k6/mvp-load-test.js | 42 +- infra/nginx/default.tls.conf | 39 ++ infra/scripts/backup-postgres.sh | 14 + infra/scripts/health-check.sh | 18 + infra/scripts/smoke-prod.sh | 17 + 66 files changed, 2377 insertions(+), 520 deletions(-) create mode 100644 apps/api/.env.production.example create mode 100644 apps/api/app/core/jwt_denylist.py create mode 100644 apps/api/tests/core/test_app_settings.py create mode 100644 apps/api/tests/core/test_audit_log.py create mode 100644 apps/api/tests/core/test_dependencies.py create mode 100644 apps/api/tests/core/test_email.py create mode 100644 apps/api/tests/core/test_install_secrets.py create mode 100644 apps/api/tests/core/test_jwt_denylist.py create mode 100644 apps/api/tests/core/test_main.py create mode 100644 apps/api/tests/core/test_production_guards.py create mode 100644 apps/api/tests/core/test_redis.py create mode 100644 apps/api/tests/modules/admin/test_security_diagnostics.py create mode 100644 apps/api/tests/modules/auth/test_jwt_revocation_integration.py create mode 100644 apps/api/tests/modules/auth/test_logout_and_forgot.py create mode 100644 apps/api/tests/modules/auth/test_service_refresh.py create mode 100644 apps/api/tests/modules/test/test_router.py create mode 100644 docs/README.md create mode 100644 docs/deploy.md create mode 100644 docs/project.md delete mode 100644 docs/release-regression-checklist.md create mode 100644 docs/release.md delete mode 100644 docs/secrets-recovery.md delete mode 100644 docs/security-checklist.md create mode 100644 docs/security.md create mode 100644 infra/docker/.env.production.example create mode 100644 infra/docker/.env.staging.example create mode 100755 infra/docker/deploy-prod.sh create mode 100755 infra/docker/deploy-staging.sh create mode 100644 infra/docker/docker-compose.prod.yml create mode 100644 infra/nginx/default.tls.conf create mode 100755 infra/scripts/backup-postgres.sh create mode 100755 infra/scripts/health-check.sh create mode 100755 infra/scripts/smoke-prod.sh diff --git a/.gitignore b/.gitignore index 2cad07d..dffa6bc 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ apps/api/.e2e.sqlite apps/api/.e2e-test.sqlite apps/api/data/secrets/install.env apps/api/data/secrets/install.meta.json +data/secrets/ +apps/api/data/logs/ +apps/api/data/compton_settings.json diff --git a/README.md b/README.md index d8930e4..ca82079 100644 --- a/README.md +++ b/README.md @@ -1,426 +1,112 @@ # Compton Platform -Monorepo-lite project that follows the `docs/TZ.md` specification for the Compton platform. +Если вы открыли этот файл — поздравляем: перед вами monorepo с React, FastAPI и амбициями выйти в production. Амбиции живут в [docs/release.md](docs/release.md), код — в `apps/`, секреты — **не** в git. -## Stack +Платформа Compton Organic Tech: лендинг, auth, профиль, CMS, админка в стиле WESP/zootech. ТЗ: [docs/TZ.md](docs/TZ.md). -- **Frontend:** React 19, TypeScript, Vite, React Router, TanStack Query, Zustand, RHF + Zod, Ant Design (admin panel) -- **Backend:** FastAPI, SQLAlchemy 2, Alembic, Pydantic v2 -- **Data/Infra:** PostgreSQL, Redis, MinIO (S3-compatible), Docker Compose -- **Quality:** Vitest, Testing Library, Playwright, pytest, coverage gates in CI - -## Repository Layout - -- `apps/web` — frontend SPA -- `apps/api` — backend API (`app/core/crypto.py` — unified crypto; `app/core/install_secrets.py` — bootstrap/lock) -- `apps/api/data/secrets/` — per-install secrets (`install.env`, gitignored) -- `apps/api/data/compton_settings.json` — runtime panel settings (superuser-editable via API) -- `packages/shared-types` — generated API types contract target -- `packages/eslint-config` — shared eslint config package -- `infra` — docker/nginx/ci helper files -- `docker-compose.dev-ports.yml` — optional override to expose DB/Redis/MinIO on host - -## Quick Start - -**Full stack in Docker** (API + DB + frontend — no local `pnpm install` required): +## Быстрый старт ```bash -# 1. Bootstrap install secrets — REQUIRED before the first docker compose up -python apps/api/scripts/bootstrap_install.py - -# 2. Build and start everything +python apps/api/scripts/bootstrap_install.py # один раз, до первого up docker compose --profile docker-web up -d --build - -# 3. Verify (Windows PowerShell: use curl.exe, not curl — it is an alias for Invoke-WebRequest) -curl.exe http://localhost:8000/api/v1/health +curl http://localhost:8000/api/v1/health # Windows: curl.exe ``` -Open [http://localhost:5173](http://localhost:5173) — static landing (`index.html`). SPA routes (`/login`, `/admin`, `/profile`, …) are served via `app.html` fallback in Vite dev/preview. +| URL | Что там | +|-----|---------| +| http://localhost:5173 | Лендинг `/` + SPA (`/login`, `/admin`, …) | +| http://localhost:8000/api/v1/health | API жив? | +| http://localhost:8000/api/v1/docs | OpenAPI (только dev) | -The `web` container runs Vite with hot-reload; dependencies are installed inside the container automatically. +**Логин для пробы:** `admin@compton.example` / `Admin1234` → `/admin`. -Log in as `admin@compton.example` (password `Admin1234` by default) and open `/admin`. See [Seed data](#seed-data) for all demo accounts. +> Bootstrap **до** первого `docker compose up`. Иначе Postgres скажет «password authentication failed» — см. [docs/deploy.md § восстановление](docs/deploy.md#восстановление-секретов). -**Optional** — copy env files if you also run API or frontend locally (hybrid mode): +**Не делайте:** Docker `web` и `pnpm --filter web dev` одновременно — оба хотят порт **5173**. + +## Демо-аккаунты (dev) + +Подробнее: [docs/deploy.md § стандартные логины](docs/deploy.md#стандартные-логины-dev). + +| Email | Пароль | Env | Роль | Superuser | +|-------|--------|-----|------|-----------| +| admin@compton.example | Admin1234 | `ADMIN_INITIAL_PASSWORD` | admin | да — Security, Diagnostics, secrets | +| ops@compton.example | OpsAdmin1234 | `DEMO_OPS_PASSWORD` | admin | нет — Users, Content, Activity | +| user@compton.example | User1234 | `DEMO_USER_PASSWORD` | user | — | + +CMS после seed: `/pages/about`, `/pages/privacy`, `/pages/terms`. + +В production/staging demo-users **выключены** (`SEED_DEMO_USERS=false`). + +## Тесты ```bash -cp apps/api/.env.example apps/api/.env -cp apps/web/.env.example apps/web/.env -``` - -> **Important:** Run bootstrap **before** the first `docker compose up`. If Postgres was started without `install.env`, the API will fail with `password authentication failed for user "compton_app"`. Fix: `docker compose --profile docker-web down -v`, then bootstrap + up again. See [Troubleshooting](#troubleshooting). - -**Hybrid setup** (Docker API + local frontend): - -```bash -cp apps/api/.env.example apps/api/.env -cp apps/web/.env.example apps/web/.env -pnpm install -python apps/api/scripts/bootstrap_install.py -docker compose up -d -pnpm --filter web dev -``` - -## Local Setup - -### Prerequisites - -- **Docker + Docker Compose** — enough for the full-stack Docker workflow -- **Node.js 22+ and pnpm 9+** — only if you run the frontend locally (`pnpm --filter web dev`) -- **Python 3.12** — bootstrap script, local API, or tests outside Docker - -### Start services - -| Mode | Command | What runs | -| ---- | ------- | --------- | -| **Full Docker (recommended)** | `docker compose --profile docker-web up -d --build` | API, Postgres, Redis, MinIO, Vite on `:5173` | -| **API + infra only** | `docker compose up -d` | API, Postgres, Redis, MinIO — frontend locally | -| **Local API + local frontend** | see [Run applications](#run-applications) | everything on host | - -| Service | URL / Port | Notes | -| --------- | ---------------------------------------------- | ----- | -| API | [http://localhost:8000](http://localhost:8000) | migrations + seed on startup | -| Web (dev) | [http://localhost:5173](http://localhost:5173) | Docker `--profile docker-web` or `pnpm --filter web dev` | -| Postgres | internal docker network | host access via `docker-compose.dev-ports.yml` | -| Redis | internal docker network | host access via `docker-compose.dev-ports.yml` | -| MinIO | internal docker network | host access via `docker-compose.dev-ports.yml` | - -> Do not run Docker `web` and `pnpm --filter web dev` at the same time — both bind port **5173**. - -The Docker `web` container bind-mounts source for live-reload on Windows/macOS (`CHOKIDAR_USEPOLLING=true`); `node_modules` stay isolated inside the container. - -### Install secrets - -Each project copy gets unique runtime secrets generated once and locked forever (prevents accidental rotation and DB credential mismatch). - -| File | Purpose | -| ---- | ------- | -| `apps/api/data/secrets/install.env` | PostgreSQL, JWT, S3/MinIO credentials (gitignored) | -| `apps/api/data/secrets/install.meta.json` | Install ID and lock timestamp (gitignored) | - -**Bootstrap (required before first `docker compose up`):** - -```bash -python apps/api/scripts/bootstrap_install.py -``` - -Generated keys: `POSTGRES_USER`, `POSTGRES_PASSWORD`, `DATABASE_URL`, `JWT_ACCESS_SECRET`, `JWT_REFRESH_PEPPER`, `S3_SECRET_KEY`, `MINIO_ROOT_PASSWORD`. - -Docker Compose passes `install.env` directly into the `api`, `postgres`, and `minio` services — no root `.env` or `--env-file` flag needed. - -- Re-run is safe: existing locked secrets are never overwritten. -- API entrypoint also calls `ensure_install_secrets()` on startup (adopts env vars when migrating from an older setup). -- **Do not rotate** `POSTGRES_PASSWORD` / JWT secrets after first bootstrap without a coordinated DB migration — see [docs/secrets-recovery.md](docs/secrets-recovery.md). - -**Host access to DB/Redis/MinIO** (DBeaver, pgAdmin, MinIO console): - -```bash -docker compose -f docker-compose.yml -f docker-compose.dev-ports.yml up -d -``` - -| Exposed port | Service | -| ------------ | ------- | -| 5432 | PostgreSQL | -| 6379 | Redis | -| 9000 / 9001 | MinIO API / console | - -**Reveal credentials:** Admin → Security → Install Secrets (superuser only, audited). Supports `database_password`, `jwt_access_secret`, `jwt_refresh_pepper`, `s3_secret_key`. - -### Configure environment - -For hybrid or fully local dev, copy example env files: - -```bash -cp apps/api/.env.example apps/api/.env -cp apps/web/.env.example apps/web/.env -``` - -Key variables: - -| Variable | App | Purpose | -| ----------------------------------------- | --- | ---------------------------------------------------- | -| `DATABASE_URL` | api | PostgreSQL connection string | -| `APP_ENV` | api | `development` / `production` startup guards | -| `JWT_ACCESS_SECRET`, `JWT_REFRESH_PEPPER` | api | Token signing (32+ bytes) | -| `VITE_API_URL` | web | Local dev: `http://localhost:8000`. Docker `web`: `http://api:8000` | -| `VITE_USE_API_PROXY` | web | `true` — proxy `/api` through Vite (default in dev) | -| `EMAIL_DELIVERY_MODE` | api | `memory` (Docker dev) or `smtp` (real mail) | -| `STORAGE_MODE` | api | `s3` (MinIO) or `memory` (tests) | -| `S3_*` | api | MinIO/S3 credentials and bucket | -| `CORS_ORIGINS` | api | Must include `http://localhost:5173` | -| `ENABLE_TEST_ROUTES` | api | `true` only for E2E (email token helper) | -| `TRUSTED_PROXY_IPS` | api | Which proxy IPs may set `X-Forwarded-For` | -| `ADMIN_INITIAL_PASSWORD` | api | Seed password for superuser admin | -| `DEMO_USER_PASSWORD` | api | Seed password for regular demo user | -| `DEMO_OPS_PASSWORD` | api | Seed password for ops admin (no superuser) | -| `ENABLE_RATE_LIMIT` | api | Rate limiting (required `true` in production) | -| `COOKIE_SECURE` | api | HttpOnly refresh cookie `Secure` flag | -| `COMPTON_SETTINGS_PATH` | api | Path to runtime settings JSON (default `data/compton_settings.json`) | - -In dev the frontend proxies API requests through Vite (`/api` → backend). Locally the target is `localhost:8000`; in Docker Compose it is the `api` service. - -### Runtime settings (`compton_settings.json`) - -Non-secret runtime toggles live in `apps/api/data/compton_settings.json` and are applied on API startup. Superusers can read/update them via `GET/PATCH /admin/settings`. - -Env vars with the same keys (e.g. `ENABLE_RATE_LIMIT`, `CORS_ORIGINS`) act as **locks** — when set, the corresponding JSON field cannot be changed from the admin panel. - -Typical fields: rate limit, API docs, secure cookie, JWT TTL, CORS origins, SMTP host/port, avatar limits, audit retention. - -### Install dependencies (hybrid / local dev only) - -Skip if you use `docker compose --profile docker-web` — dependencies are installed inside the `web` container. - -```bash -pnpm install -pip install -r apps/api/requirements-dev.txt -``` - -### Run applications - -**Option A — Full Docker stack:** - -```bash -python apps/api/scripts/bootstrap_install.py # first run only -docker compose --profile docker-web up -d --build -``` - -**Option B — Docker API + local frontend:** - -```bash -python apps/api/scripts/bootstrap_install.py # first run only -docker compose up -d -pnpm --filter web dev -``` - -**Option C — Local API + local frontend:** - -```bash -cd apps/api -alembic upgrade head -uvicorn app.main:app --reload --app-dir . -``` - -In another terminal: - -```bash -pnpm --filter web dev -``` - -Open [http://localhost:5173](http://localhost:5173). - -### Seed data - -After migrations the database is seeded on every API startup with demo users and CMS pages. Passwords come from env vars (defaults in `.env.example`): - -| Email | Env variable | Default (dev) | Role | Superuser | Access | -| ----- | ------------ | --------------- | ---- | --------- | ------ | -| `admin@compton.example` | `ADMIN_INITIAL_PASSWORD` | `Admin1234` | `admin` | yes | Full admin + Security/Diagnostics/Install Secrets | -| `ops@compton.example` | `DEMO_OPS_PASSWORD` | `OpsAdmin1234` | `admin` | no | Users, Content, Activity (no Security/Diagnostics) | -| `user@compton.example` | `DEMO_USER_PASSWORD` | `User1234` | `user` | no | Profile only | - -**Content pages:** `about`, `privacy`, `terms` (published). - -> Change demo passwords via env before first seed in production. Admin-created users must pass the shared password policy (length, complexity, denylist). - -## Frontend Routes - -The project uses a **dual-entry** frontend: - -| Entry | Served at | Purpose | -| ----- | --------- | ------- | -| `index.html` | `/` | Public marketing landing (static HTML/CSS/JS in `main/`) | -| `app.html` | `/login`, `/register`, `/profile`, `/admin`, `/pages/:slug`, … | React SPA (auth, profile, admin, CMS pages) | - -Vite middleware rewrites SPA paths to `app.html` on dev/preview (`vite.main-static.ts`). - -| Path | Page | Access | -| ------------------ | --------------------- | ----------------------- | -| `/` | Landing (`index.html`)| public | -| `/login` | Login | guest | -| `/register` | Registration | guest | -| `/verify` | Email verification | public | -| `/forgot-password` | Password reset request| guest | -| `/reset-password` | Set new password | public (with token) | -| `/pages/:slug` | CMS page | public (published only) | -| `/profile` | Profile CRUD + avatar | authenticated | -| `/admin` | Admin panel | role `admin` | - -**Auth model:** one login flow for everyone. The `is_superuser` flag on admin accounts controls access to critical panel sections (Security, Diagnostics, Install Secrets reveal, runtime settings). Regular admins see Users, Content, and Activity only. - -- **Access JWT** — in memory only (Zustand), not in `localStorage` / `sessionStorage`. -- **Refresh token** — HttpOnly cookie (`Path=/api/v1/auth`, `SameSite=Lax`), rotated on each refresh. -- **Session restore on reload** — `AuthBootstrap` calls a deduplicated `bootstrapSessionRefresh()` when a session hint exists in `sessionStorage` **or** the current path is protected (`/admin`, `/profile`). Guards wait for `bootstrapped` before redirecting. -- **After login** — admins go to `/admin`, regular users to `/profile`. - -Admin users can return to the public landing via the **«На сайт»** topbar link (full navigation to `/`, not client-side React routing). - -## API Overview - -Base URL: `http://localhost:8000/api/v1` - -| Area | Endpoints | -| ----------- | ----------------------------------------------------------------------------------------------------------------------------- | -| **Health** | `GET /health` | -| **Auth** | `POST /auth/register`, `/login`, `/logout`, `/refresh`, `/verify-email`, `/forgot-password`, `/reset-password` | -| **Profile** | `GET/PATCH /users/me`, `POST /users/me/password`, `POST /users/me/avatar` | -| **Content** | `GET /content/pages`, `GET /content/pages/{slug}`, admin: `POST/PATCH/DELETE /content/pages`, `GET /content/pages/manage/all` | -| **Admin** | `GET/PATCH /admin/users`, `POST /admin/users`, `PATCH /admin/users/{id}/password`, `GET /admin/stats`, `GET/PATCH /admin/settings`, `GET /admin/diagnostics/report`, `GET /admin/activity-feed`, `POST /admin/ui-activity`, `GET /admin/server-log`, `GET /admin/secrets`, `POST /admin/secrets/reveal` | -| **Media** | `GET /media/files/{path}` | - -OpenAPI docs (when `ENABLE_DOCS=true`): [http://localhost:8000/api/v1/docs](http://localhost:8000/api/v1/docs) - -## Database Migrations - -```bash -cd apps/api -alembic upgrade head # apply schema -alembic downgrade -1 # rollback one revision -``` - -Tables: `users`, `user_profiles`, `refresh_tokens`, `password_reset_tokens`, `email_verification_tokens`, `content_pages`. - -**Security constraints** (PostgreSQL, migration `20260714_0004`): - -- `CHECK` on `users.role`, `users.status`, `content_pages.status` -- Superuser rule: `is_superuser=true` only when `role=admin` -- Indexes on `expires_at` for token tables - -Expired tokens are cleaned up on API startup (`cleanup_expired_tokens`, 30-day retention). - -## Auth Email (SMTP) - -- Verify/reset use one-time opaque tokens (SHA-256 hash + pepper in DB, TTL 1 hour); raw token omitted from production email bodies -- Env: `SMTP_HOST`, `SMTP_PORT`, `SMTP_FROM`, `FRONTEND_URL`, `EMAIL_DELIVERY_MODE` -- Docker dev: `EMAIL_DELIVERY_MODE=memory` (no real SMTP required) -- Local SMTP: Mailpit/Mailhog on `localhost:1025` (`SMTP_HOST=localhost`, `SMTP_PORT=1025`) -- Tests: `EMAIL_DELIVERY_MODE=memory` (in-memory outbox) - -## Profile & Avatar (MinIO) - -- `GET/PATCH /api/v1/users/me`, `POST /api/v1/users/me/password`, `POST /api/v1/users/me/avatar` -- Avatar: jpeg/png/webp, max 2 MB, re-encoded via Pillow (SVG rejected) -- Storage env: `STORAGE_MODE` (`s3` or `memory`), `S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_BUCKET` -- Local dev: MinIO on `localhost:9000` (console `9001`), bucket `compton` (created on API startup) -- Tests: `STORAGE_MODE=memory` (in-memory file store, no MinIO) - -## Admin Panel - -- Route: `/admin` (`AdminGuard`, role `admin`) -- UI: WESP-style **Ant Design** shell with fixed left sidebar, content topbar, and a dedicated admin theme system -- Topbar actions: - - **Light/Dark** — toggles isolated admin theme (preference persisted in `localStorage`) - - **«На сайт»** — opens the static landing at `/` (`index.html`) via full navigation - - **Logout** — revokes refresh cookie and redirects to `/login` -- Reload UX: no blocking «Loading…» screens; admin routes preload the current admin background (`#f6f6f4` in Light, `#1f2229` in Dark) to avoid flash -- Sections: - - **Users** — list/create/patch role, status, password - - **Content** — CMS CRUD - - **Security** *(superuser only)* — runtime toggles + Install Secrets reveal - - **Diagnostics** *(superuser only)* — security health checks (JWT, CORS, DB credentials, SSL mode, token table size, install secrets lock) - - **Activity** — admin audit feed + server log tail -- Business rules: last admin protected, no self-demotion, no self-block; critical endpoints require `is_superuser` - -### Auth UI (zootech) - -Auth pages (`/login`, `/register`, `/forgot-password`, `/reset-password`, `/verify`) use a WESP-inspired zootech card layout with organic theme tokens (`#48816d`, centered card, icon inputs). `AppHeader` is hidden on auth routes and in `/admin`. - -## Tests - -```bash -# Frontend unit/component (coverage ≥ 85%) -pnpm --filter web test:ci - -# Same, inside Docker web container (when pnpm is not installed on host) -docker compose --profile docker-web exec web sh -c "cd apps/web && pnpm test:ci" - -# Backend unit/integration (coverage ≥ 90%) +pnpm --filter web test:ci # frontend, cov ≥ 85% cd apps/api && python -m pytest --cov=app --cov-fail-under=90 - -# E2E regression (Playwright §15.7, 16 critical scenarios) -pnpm --filter web e2e +pnpm --filter web e2e # Playwright, API :8001 ``` -### E2E (Playwright) +## Документация -- `pnpm --filter web e2e` starts a dedicated API (`:8001`) and Vite (`:5175`) — does not conflict with dev on `:5173` or Docker API on `:8000` -- Uses SQLite + `EMAIL_DELIVERY_MODE=memory` + `ENABLE_TEST_ROUTES=true` -- Test email tokens: `GET /api/v1/test/emails/latest-token` (only when test routes enabled) -- Reuse running API: `E2E_START_API=false E2E_API_URL=http://localhost:8000 pnpm --filter web e2e` -- Install browsers once: `pnpm --filter web exec playwright install chromium` +Вся нормальная дока — в **`docs/`**, на русском, без водянистого README на 500 строк: -## Troubleshooting +| Файл | О чём | +|------|-------| +| [docs/README.md](docs/README.md) | Оглавление | +| [docs/project.md](docs/project.md) | Структура, архитектура, API, маршруты | +| [docs/security.md](docs/security.md) | Auth, JWT revoke, секреты, чеклист prod | +| [docs/deploy.md](docs/deploy.md) | Dev/staging/prod, бэкапы, troubleshooting | +| [docs/release.md](docs/release.md) | QA gates, k6, ZAP, go-live | +| [docs/TZ.md](docs/TZ.md) | Техническое задание | -| Problem | Solution | -| ------- | -------- | -| **`docker compose up` fails: install.env missing** | Run `python apps/api/scripts/bootstrap_install.py` before first start | -| **API exits: `password authentication failed for user "compton_app"`** | Postgres volume was initialized before bootstrap. Reset local dev data: `docker compose --profile docker-web down -v`, bootstrap again, then `docker compose --profile docker-web up -d --build`. See [docs/secrets-recovery.md](docs/secrets-recovery.md) | -| **Login failed** with correct credentials | Check API: `curl http://localhost:8000/api/v1/health`. Ensure `apps/web/.env` exists for local frontend. Restart: `docker compose restart web` or `pnpm --filter web dev` | -| **401 on `/auth/refresh` in browser console (guest)** | Expected for logged-out users on public pages — refresh is skipped unless a session hint or protected path (`/admin`, `/profile`) triggers bootstrap | -| **Logged out after F5 on `/admin`** | Usually a failed refresh (expired cookie) or stale Docker web build. Rebuild: `docker compose --profile docker-web up -d --build web`. Log in again if the refresh cookie expired | -| **«На сайт» in admin does nothing / goes to login** | Must use full navigation to `/` (static landing), not React Router. Ensure topbar action is `href="/"`, then rebuild web container if behavior persists | -| **White flash on admin reload** | `app.html` preloads admin background before React mount. Verify `localStorage.wespAdminTheme` and rebuild web if stale assets are served | -| **Sidebar should stay visible while scrolling** | Admin sidebar is fixed on desktop (`position: fixed`, `height: 100vh`) and switches back to normal flow on mobile (`<=768px`) | -| **Port 5173 already in use** | Stop Docker web: `docker compose --profile docker-web stop web`. Or stop local Vite | -| **Frontend in Docker: missing modules / esbuild errors** | Rebuild: `docker compose --profile docker-web up -d --build web`. Do not run `pnpm install` on the host for the Docker workflow | -| **Hot-reload not working in Docker (Windows/macOS)** | Enabled via `CHOKIDAR_USEPOLLING=true`. Restart: `docker compose --profile docker-web restart web` | -| **Admin panel missing Security/Diagnostics tabs** | Log in as `admin@compton.example` (superuser), not `ops@compton.example` | -| **Need DB password for DBeaver** | Admin → Security → Install Secrets → Reveal DB password (superuser), then `docker-compose.dev-ports.yml` | -| **Lost `install.env` / DB auth failed** | See [docs/secrets-recovery.md](docs/secrets-recovery.md). Do not regenerate secrets if Postgres volume already exists | -| **CORS errors in browser console** | Keep `VITE_USE_API_PROXY=true` (default in dev). Do not call `localhost:8000` directly from the browser | +## Работа с Git -## Security Highlights +**Репозиторий:** https://git.groupkomton.ru/Matvey/site.git +**Ветка:** `main` (одна ветка, без сюрпризов) -All cryptographic primitives live in a single module (`apps/api/app/core/crypto.py`); `security.py` and `media_signing.py` re-export from it. +```bash +git pull origin main # перед работой — всегда +git status && git diff # перед коммитом — тоже всегда +git add . +git commit -m "Сделал то-то на русском" +git push origin main +``` -| Layer | Mechanism | -| ----- | --------- | -| **Passwords** | bcrypt cost 12 with per-user salt (embedded in hash); shared denylist | -| **Access JWT** | HS256, short TTL, in-memory on frontend only | -| **Refresh tokens** | HttpOnly cookie (`Path=/api/v1/auth`), rotation + family reuse detection; SHA-256 hash with server-side **pepper** | -| **Email/reset tokens** | Opaque tokens, SHA-256 + pepper in DB; raw token never stored | -| **Media URLs** | HMAC-SHA256 signed paths with TTL | -| **Install secrets** | `secrets.token_*` generation; generate-once + lock; superuser reveal (audited) | +**Коммиты — только на русском.** Плохо: `fix`, `WIP`, `asdf`. -**Auth & API** +**Не коммитить:** `.env`, `install.env`, `node_modules/`, логи, кэши. -- Refresh token rotation and reuse-detection (family revoke) -- Rate limiting and brute-force lockout (`ENABLE_RATE_LIMIT=true` in Docker compose) -- Pending/blocked users rejected on refresh; frontend clears session on `403` (`ACCOUNT_BLOCKED`, `EMAIL_NOT_VERIFIED`) -- Origin/Referer validation on cookie-based auth endpoints -- `TRUSTED_PROXY_IPS` — only listed proxies may influence client IP via `X-Forwarded-For` -- Email bodies omit raw tokens in production (`EMAIL_DELIVERY_MODE=memory` or `ENABLE_TEST_ROUTES=true` only) +**Не делать** `git push --force` на `main` без согласования. -**Data & infra** +| Команда | Зачем | +|---------|-------| +| `git log --oneline -10` | что было | +| `git checkout -- файл` | откатить файл | +| `git stash` / `git stash pop` | спрятать / вернуть правки | -- PostgreSQL/Redis/MinIO on internal Docker network by default (no host ports) -- DB CHECK constraints, connection pool tuning, startup token cleanup -- CMS HTML sanitization (bleach) with allowed URL protocols: `http`, `https`, `mailto` -- Signed avatar URLs; RBAC + superuser guards on admin routes -- Admin password policy on user create/reset +## План по ТЗ (§16) -**Production guards** (`APP_ENV=production` — API refuses to start if): +| Фаза | Задачи | Статус | +|------|--------|--------| +| **0 — Подготовка** | Monorepo, Docker Compose, UI kit, CI/coverage gates, Playwright smoke | Выполнено | +| **1 — MVP** | Landing, Auth+SMTP, Profile, Content, Admin, staging + полный регресс | В процессе | +| **2 — v1.0** | Celery/notifications, Catalog+Orders, Analytics, k6 + monitoring | Когда начнём? | +| **3 — Scale** | Read replica PG, Celery workers, CDN, horizontal API | Когда начнём? | -- `ENABLE_TEST_ROUTES=true` -- `ENABLE_DOCS=true` -- `ENABLE_RATE_LIMIT=false` -- `COOKIE_SECURE=false` -- Default DB credentials (`user:pass`) or placeholder JWT secrets -- `DATABASE_URL` without `sslmode=require` +### Фаза 1 — детализация -## Related Documentation +| # | Задача | Модули | Статус | +|---|--------|--------|--------| +| 1.1 | Landing (перенос заглушки) | landing | Выполнено | +| 1.2 | Auth + SMTP | auth | Выполнено | +| 1.2b | Security baseline | core, auth | В процессе | +| 1.3 | Profile CRUD + avatar | profile | Выполнено | +| 1.4 | Content pages | content | Выполнено | +| 1.5 | Admin panel | admin | Выполнено | +| 1.6 | Staging deploy + полный регресс (§15.7) | — | В процессе | -- [docs/secrets-recovery.md](docs/secrets-recovery.md) — recover from lost install secrets -- [docs/security-checklist.md](docs/security-checklist.md) — pre-production security checklist -- [docs/TZ.md](docs/TZ.md) — full technical specification +Критерии приёмки MVP — §17.1 ТЗ (k6 50 CCU, Lighthouse ≥85, E2E 10/10, coverage gates). -## MVP Status (Phase 1) +--- -| # | Feature | Status | -| --- | ----------------------------- | ------ | -| 1.1 | PostgreSQL + Alembic + seed | done | -| 1.2 | Auth + SMTP (verify/reset) | done | -| 1.3 | Profile CRUD + avatar (MinIO) | done | -| 1.4 | Content pages | done | -| 1.5 | Admin panel (Ant Design + zootech auth UI) | done | -| 1.6 | E2E regression §15.7 | done | +*Дальше — [docs/](docs/README.md). Там схемы, таблицы и вещи, которые не помещаются в README без страданий.* diff --git a/apps/api/.env.example b/apps/api/.env.example index 8cd7437..b00a7b2 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -40,3 +40,4 @@ DEMO_USER_PASSWORD=User1234 DEMO_OPS_PASSWORD=OpsAdmin1234 # E2E only, never enable in production. ENABLE_TEST_ROUTES=false +SEED_DEMO_USERS=true diff --git a/apps/api/.env.production.example b/apps/api/.env.production.example new file mode 100644 index 0000000..dadff1a --- /dev/null +++ b/apps/api/.env.production.example @@ -0,0 +1,22 @@ +# Production API environment (non-secret keys). Secrets live in data/secrets/install.env +APP_ENV=production +ENABLE_DOCS=false +ENABLE_TEST_ROUTES=false +COOKIE_SECURE=true +ENABLE_RATE_LIMIT=true +EMAIL_DELIVERY_MODE=smtp +SEED_DEMO_USERS=false +JWT_ACCESS_TTL_MIN=15 +JWT_REFRESH_TTL_DAYS=30 +STORAGE_MODE=s3 +S3_BUCKET=compton +S3_REGION=us-east-1 +AVATAR_MAX_BYTES=2097152 +MEDIA_URL_TTL_SECONDS=600 +LOG_LEVEL=INFO +AUDIT_RETENTION_DAYS=90 +COMPTON_SETTINGS_PATH=data/compton_settings.json +ADMIN_AUDIT_LOG_PATH=data/logs/admin-audit.jsonl +SERVER_LOG_PATH=data/logs/server.log +PASSWORD_DENYLIST_PATH=data/security/password-denylist.txt +# Set on server: FRONTEND_URL, PUBLIC_BASE_URL, CORS_ORIGINS, SMTP_*, DATABASE_URL (via install.env) diff --git a/apps/api/app/core/config.py b/apps/api/app/core/config.py index 9b5254f..c99cf35 100644 --- a/apps/api/app/core/config.py +++ b/apps/api/app/core/config.py @@ -23,6 +23,7 @@ class Settings(BaseSettings): admin_initial_password: str = "Admin1234" demo_user_password: str = "User1234" demo_ops_password: str = "OpsAdmin1234" + seed_demo_users: bool = True smtp_host: str = "localhost" smtp_port: int = 1025 smtp_user: str = "" diff --git a/apps/api/app/core/crypto.py b/apps/api/app/core/crypto.py index 880b77f..0ee3757 100644 --- a/apps/api/app/core/crypto.py +++ b/apps/api/app/core/crypto.py @@ -48,12 +48,14 @@ def verify_password(raw_password: str, password_hash: str) -> bool: def create_access_token(user_id: str, role: str, is_superuser: bool = False) -> str: from app.core.config import settings + from app.core.jwt_denylist import get_auth_epoch now = datetime.now(UTC) payload = { "sub": user_id, "role": role, "is_superuser": is_superuser, + "auth_epoch": get_auth_epoch(user_id), "iat": int(now.timestamp()), "exp": int((now + timedelta(minutes=settings.jwt_access_ttl_min)).timestamp()), "jti": generate_secret_token_hex(16), diff --git a/apps/api/app/core/dependencies.py b/apps/api/app/core/dependencies.py index 6b868e4..85286af 100644 --- a/apps/api/app/core/dependencies.py +++ b/apps/api/app/core/dependencies.py @@ -1,6 +1,7 @@ from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from app.core.jwt_denylist import validate_access_claims from app.core.security import decode_access_token from app.modules.users.repository import get_user_by_id @@ -12,6 +13,12 @@ def get_current_user(credentials: HTTPAuthorizationCredentials | None = Depends( raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="UNAUTHORIZED") try: payload = decode_access_token(credentials.credentials) + validate_access_claims(payload) + except ValueError as exc: + detail = str(exc) + if detail == "TOKEN_REVOKED": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="TOKEN_REVOKED") + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_TOKEN") from exc except Exception as exc: # pragma: no cover - defensive raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_TOKEN") from exc user = get_user_by_id(payload["sub"]) diff --git a/apps/api/app/core/install_secrets.py b/apps/api/app/core/install_secrets.py index bccbf32..7edb935 100644 --- a/apps/api/app/core/install_secrets.py +++ b/apps/api/app/core/install_secrets.py @@ -9,7 +9,8 @@ from uuid import uuid4 from app.core.crypto import generate_install_bundle -INSTALL_SECRETS_DIR = Path("data/secrets") +_API_ROOT = Path(__file__).resolve().parents[2] +INSTALL_SECRETS_DIR = _API_ROOT / "data" / "secrets" INSTALL_SECRETS_FILE = INSTALL_SECRETS_DIR / "install.env" INSTALL_SECRETS_META_FILE = INSTALL_SECRETS_DIR / "install.meta.json" REQUIRED_KEYS = ( diff --git a/apps/api/app/core/jwt_denylist.py b/apps/api/app/core/jwt_denylist.py new file mode 100644 index 0000000..3d606b0 --- /dev/null +++ b/apps/api/app/core/jwt_denylist.py @@ -0,0 +1,108 @@ +"""Redis-backed JWT jti denylist and per-user auth_epoch for instant access revocation.""" + +from __future__ import annotations + +import time + +from app.core.config import settings +from app.core.redis import get_redis_client + +AUTH_EPOCH_PREFIX = "auth:epoch:" +JWT_DENY_PREFIX = "jwt:deny:" + +_memory_epochs: dict[str, int] = {} +_memory_denied_jti: dict[str, float] = {} + + +def _purge_expired_memory_jtis() -> None: + now = time.time() + expired = [jti for jti, exp in _memory_denied_jti.items() if exp <= now] + for jti in expired: + _memory_denied_jti.pop(jti, None) + + +def ensure_jwt_revocation_backend() -> None: + if settings.app_env.lower() != "production": + return + if get_redis_client() is None: + raise RuntimeError("Redis is required for JWT revocation in production") + + +def get_auth_epoch(user_id: str) -> int: + client = get_redis_client() + if client is not None: + try: + value = client.get(f"{AUTH_EPOCH_PREFIX}{user_id}") + return int(value) if value is not None else 0 + except Exception: + if settings.app_env.lower() == "production": + raise + return _memory_epochs.get(user_id, 0) + + +def bump_auth_epoch(user_id: str) -> int: + client = get_redis_client() + if client is not None: + try: + return int(client.incr(f"{AUTH_EPOCH_PREFIX}{user_id}")) + except Exception: + if settings.app_env.lower() == "production": + raise + next_epoch = _memory_epochs.get(user_id, 0) + 1 + _memory_epochs[user_id] = next_epoch + return next_epoch + + +def deny_jti(jti: str, exp: int) -> None: + if not jti: + return + ttl = max(int(exp - time.time()), 1) + client = get_redis_client() + if client is not None: + try: + client.setex(f"{JWT_DENY_PREFIX}{jti}", ttl, "1") + return + except Exception: + if settings.app_env.lower() == "production": + raise + _purge_expired_memory_jtis() + _memory_denied_jti[jti] = time.time() + ttl + + +def is_jti_denied(jti: str) -> bool: + if not jti: + return False + client = get_redis_client() + if client is not None: + try: + return bool(client.exists(f"{JWT_DENY_PREFIX}{jti}")) + except Exception: + if settings.app_env.lower() == "production": + return True + _purge_expired_memory_jtis() + return jti in _memory_denied_jti + + +def revoke_access_token(token: str) -> None: + from app.core.security import decode_access_token + + try: + payload = decode_access_token(token) + except Exception: + return + jti = payload.get("jti") + exp = payload.get("exp") + if jti and exp: + deny_jti(str(jti), int(exp)) + + +def validate_access_claims(payload: dict) -> None: + user_id = payload.get("sub") + if not user_id: + raise ValueError("INVALID_TOKEN") + jti = payload.get("jti") + if jti and is_jti_denied(str(jti)): + raise ValueError("TOKEN_REVOKED") + token_epoch = int(payload.get("auth_epoch", 0)) + if token_epoch != get_auth_epoch(str(user_id)): + raise ValueError("TOKEN_REVOKED") diff --git a/apps/api/app/db/seed.py b/apps/api/app/db/seed.py index 2750b0f..f851cf6 100644 --- a/apps/api/app/db/seed.py +++ b/apps/api/app/db/seed.py @@ -50,20 +50,21 @@ def run_seed(include_demo_pages: bool = True) -> None: is_superuser=True, status="active", ) - _ensure_user( - email="user@compton.example", - password=settings.demo_user_password, - role="user", - is_superuser=False, - status="active", - ) - _ensure_user( - email="ops@compton.example", - password=settings.demo_ops_password, - role="admin", - is_superuser=False, - status="active", - ) + if settings.seed_demo_users and settings.app_env.lower() != "production": + _ensure_user( + email="user@compton.example", + password=settings.demo_user_password, + role="user", + is_superuser=False, + status="active", + ) + _ensure_user( + email="ops@compton.example", + password=settings.demo_ops_password, + role="admin", + is_superuser=False, + status="active", + ) if not include_demo_pages: return diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 535dc61..39deb96 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -6,6 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.core.install_secrets import ensure_install_secrets from app.core.config import settings +from app.core.jwt_denylist import ensure_jwt_revocation_backend from app.core.app_settings import bootstrap_settings from app.core.storage import ensure_bucket from app.core import database as db_module @@ -50,6 +51,7 @@ def create_app() -> FastAPI: async def lifespan(_: FastAPI): ensure_install_secrets() bootstrap_settings() + ensure_jwt_revocation_backend() _assert_production_guards() if settings.enable_test_routes: Base.metadata.create_all(db_module.engine) diff --git a/apps/api/app/modules/admin/service.py b/apps/api/app/modules/admin/service.py index 116cd84..ce2fe03 100644 --- a/apps/api/app/modules/admin/service.py +++ b/apps/api/app/modules/admin/service.py @@ -4,6 +4,7 @@ from app.core.app_settings import apply_settings_to_app, get_settings_payload, w from app.core.audit_log import read_audit_events, write_audit_event from app.core.config import settings from app.core.install_secrets import install_secrets_payload, reveal_install_secret +from app.core.jwt_denylist import bump_auth_epoch from app.core.security import hash_password from app.modules.admin.security_diagnostics import build_security_diagnostics_report from app.modules.auth.service import revoke_user_refresh_family @@ -63,6 +64,7 @@ def patch_user( if status: target.status = status if status == "blocked": + bump_auth_epoch(target.id) revoke_user_refresh_family(target.id) repository.update_user(target) write_audit_event( @@ -111,6 +113,7 @@ def reset_user_password(admin_user, target_user_id: str, password: str) -> dict: raise ValueError("USER_NOT_FOUND") target.password_hash = hash_password(password) repository.update_user(target) + bump_auth_epoch(target.id) revoke_user_refresh_family(target.id) write_audit_event( action="admin.user.reset_password", diff --git a/apps/api/app/modules/auth/router.py b/apps/api/app/modules/auth/router.py index ef81cb6..0c0cf36 100644 --- a/apps/api/app/modules/auth/router.py +++ b/apps/api/app/modules/auth/router.py @@ -1,7 +1,8 @@ -from app.core.datetime_utils import ensure_utc, utc_now -from fastapi import APIRouter, HTTPException, Request, Response, status +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from app.core.config import settings +from app.core.datetime_utils import ensure_utc, utc_now from app.core.redis import check_rate_limit, client_ip from app.modules.auth.schemas import ( ForgotPasswordIn, @@ -15,16 +16,17 @@ from app.modules.auth.schemas import ( from app.modules.auth.service import ( forgot_password, login, + logout, refresh, register, resend_verification, reset_password, - revoke_refresh_token, verify_email_token, ) from app.modules.users import repository router = APIRouter() +optional_bearer = HTTPBearer(auto_error=False) def _allowed_origins() -> set[str]: @@ -139,7 +141,12 @@ async def refresh_route(request: Request, response: Response): check_rate_limit(f"refresh:{client_ip(request)}", limit=30, window_seconds=60) try: access_token, new_refresh, user = refresh(refresh_token) - except PermissionError: + except PermissionError as exc: + detail = str(exc) + if detail == "EMAIL_NOT_VERIFIED": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="EMAIL_NOT_VERIFIED") + if detail == "ACCOUNT_BLOCKED": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ACCOUNT_BLOCKED") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_REFRESH") _set_refresh_cookie(response, new_refresh) return { @@ -156,11 +163,15 @@ async def refresh_route(request: Request, response: Response): @router.post("/logout") -async def logout_route(request: Request, response: Response): +async def logout_route( + request: Request, + response: Response, + credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer), +): _enforce_origin(request, require_header=True) refresh_token = request.cookies.get("refresh_token") - if refresh_token: - revoke_refresh_token(refresh_token) + access_token = credentials.credentials if credentials else None + logout(refresh_token, access_token) response.delete_cookie( "refresh_token", path="/api/v1/auth", diff --git a/apps/api/app/modules/auth/service.py b/apps/api/app/modules/auth/service.py index 879a8df..eb3692f 100644 --- a/apps/api/app/modules/auth/service.py +++ b/apps/api/app/modules/auth/service.py @@ -6,6 +6,7 @@ from uuid import uuid4 from app.core.config import settings from app.core.datetime_utils import ensure_utc, utc_now from app.core.email import send_template_email +from app.core.jwt_denylist import bump_auth_epoch, revoke_access_token from app.core.security import ( create_access_token, generate_opaque_token, @@ -111,7 +112,7 @@ def resend_verification(email: str) -> None: def forgot_password(email: str) -> None: user = repository.get_user_by_email(email) - if not user: + if not user or user.status == "blocked": return token = _issue_password_reset_token(user.id) _send_password_reset_email(user, token) @@ -132,6 +133,7 @@ def reset_password(token: str, new_password: str) -> None: user.password_hash = hash_password(new_password) repository.update_user(user) auth_repository.mark_password_reset_token_used(token_hash) + bump_auth_epoch(user.id) revoke_user_refresh_family(user.id) @@ -199,18 +201,21 @@ def refresh(refresh_token: str) -> tuple[str, str, User]: token_row = auth_repository.get_refresh_token(token_hash) if not token_row: raise PermissionError("INVALID_REFRESH") + + user = repository.get_user_by_id(token_row.user_id) + if not user: + raise PermissionError("INVALID_REFRESH") + if user.status == "pending": + raise PermissionError("EMAIL_NOT_VERIFIED") + if user.status == "blocked": + raise PermissionError("ACCOUNT_BLOCKED") + if token_row.revoked_at is not None: auth_repository.revoke_family_tokens(token_row.family_id) raise PermissionError("INVALID_REFRESH") if ensure_utc(token_row.expires_at) < utc_now(): raise PermissionError("EXPIRED_REFRESH") - user = repository.get_user_by_id(token_row.user_id) - if not user: - raise PermissionError("INVALID_REFRESH") - if user.status in {"pending", "blocked"}: - raise PermissionError("INVALID_REFRESH") - auth_repository.revoke_refresh_token(token_hash) new_refresh = issue_refresh_token(user.id, family_id=token_row.family_id) access = create_access_token(user.id, user.role, user.is_superuser) @@ -224,5 +229,12 @@ def revoke_refresh_token(refresh_token: str) -> None: auth_repository.revoke_family_tokens(token_row.family_id) +def logout(refresh_token: str | None, access_token: str | None) -> None: + if access_token: + revoke_access_token(access_token) + if refresh_token: + revoke_refresh_token(refresh_token) + + def revoke_user_refresh_family(user_id: str) -> None: auth_repository.revoke_user_families(user_id) diff --git a/apps/api/app/modules/users/service.py b/apps/api/app/modules/users/service.py index 314b6fd..1fc55e6 100644 --- a/apps/api/app/modules/users/service.py +++ b/apps/api/app/modules/users/service.py @@ -1,3 +1,4 @@ +from app.core.jwt_denylist import bump_auth_epoch from app.core.security import hash_password, verify_password from app.modules.auth.service import revoke_user_refresh_family from app.modules.media.service import upload_user_avatar @@ -26,4 +27,5 @@ def change_password(user: User, current_password: str, new_password: str) -> Non raise ValueError("INVALID_CURRENT_PASSWORD") user.password_hash = hash_password(new_password) repository.update_user(user) + bump_auth_epoch(user.id) revoke_user_refresh_family(user.id) diff --git a/apps/api/scripts/docker_entrypoint.py b/apps/api/scripts/docker_entrypoint.py index 889e1c7..7b00a86 100644 --- a/apps/api/scripts/docker_entrypoint.py +++ b/apps/api/scripts/docker_entrypoint.py @@ -19,6 +19,7 @@ from app.core.install_secrets import ensure_install_secrets from app.core.config import settings INITIAL_REVISION = "20260711_0001" +HEAD_REVISION = "20260714_0005" SCHEMA_TABLES = ( "content_pages", "email_verification_tokens", @@ -49,7 +50,7 @@ def wait_for_database(max_attempts: int = 30, delay_seconds: float = 1.0): "Local dev fix — deletes Docker DB data:\n" " docker compose --profile docker-web down -v\n" " docker compose --profile docker-web up -d --build\n" - "See docs/secrets-recovery.md" + "See docs/deploy.md#восстановление-секретов" ) raise RuntimeError(f"Database is unavailable: {detail}{hint}") from last_error diff --git a/apps/api/tests/core/test_app_settings.py b/apps/api/tests/core/test_app_settings.py new file mode 100644 index 0000000..c52e006 --- /dev/null +++ b/apps/api/tests/core/test_app_settings.py @@ -0,0 +1,63 @@ +import json + +import pytest + +from app.core.app_settings import ( + _coerce_value, + apply_settings_to_app, + bootstrap_settings, + env_locks, + get_settings_payload, + write_settings, +) + + +def test_write_and_bootstrap_settings(tmp_path, monkeypatch): + settings_file = tmp_path / "compton_settings.json" + monkeypatch.setattr("app.core.app_settings.settings.compton_settings_path", str(settings_file)) + monkeypatch.delenv("ENABLE_DOCS", raising=False) + + merged = write_settings({"enable_docs": False, "log_level": "INFO"}) + assert merged["enable_docs"] is False + assert settings_file.exists() + + bootstrap_settings() + from app.core.config import settings + + assert settings.enable_docs is False + + +def test_env_locks_skip_locked_keys(tmp_path, monkeypatch): + settings_file = tmp_path / "compton_settings.json" + monkeypatch.setattr("app.core.app_settings.settings.compton_settings_path", str(settings_file)) + monkeypatch.setenv("ENABLE_DOCS", "true") + monkeypatch.setattr("app.core.app_settings.settings.enable_docs", True) + + write_settings({"enable_docs": False}) + payload = get_settings_payload() + assert payload["locks"]["enable_docs"] is True + assert payload["values"]["enable_docs"] is True + + +def test_apply_settings_coerces_list_and_bool(monkeypatch): + apply_settings_to_app({"enable_docs": "false", "cors_origins": "http://a.test,http://b.test"}) + from app.core.config import settings + + assert settings.enable_docs is False + assert settings.cors_origins == ["http://a.test", "http://b.test"] + assert isinstance(env_locks(), dict) + + +def test_read_settings_ignores_invalid_payload(tmp_path, monkeypatch): + settings_file = tmp_path / "compton_settings.json" + settings_file.write_text("[]", encoding="utf-8") + monkeypatch.setattr("app.core.app_settings.settings.compton_settings_path", str(settings_file)) + from app.core.app_settings import read_settings + + assert read_settings() == {} + + +def test_coerce_value_rejects_invalid_list(): + with pytest.raises(ValueError, match="INVALID_LIST_cors_origins"): + _coerce_value("cors_origins", 123) + diff --git a/apps/api/tests/core/test_audit_log.py b/apps/api/tests/core/test_audit_log.py new file mode 100644 index 0000000..8da07f4 --- /dev/null +++ b/apps/api/tests/core/test_audit_log.py @@ -0,0 +1,19 @@ +from app.core.audit_log import read_audit_events, write_audit_event + + +def test_audit_log_roundtrip(tmp_path, monkeypatch): + audit_file = tmp_path / "admin-audit.jsonl" + monkeypatch.setattr("app.core.audit_log.settings.admin_audit_log_path", str(audit_file)) + write_audit_event("admin.user.patch", "u1", "admin@example.com", {"target": "u2"}) + events = read_audit_events() + assert len(events) == 1 + assert events[0]["action"] == "admin.user.patch" + assert events[0]["details"]["target"] == "u2" + + +def test_read_audit_events_ignores_invalid_json(tmp_path, monkeypatch): + audit_file = tmp_path / "admin-audit.jsonl" + audit_file.write_text('{"action":"ok"}\nnot-json\n', encoding="utf-8") + monkeypatch.setattr("app.core.audit_log.settings.admin_audit_log_path", str(audit_file)) + events = read_audit_events() + assert len(events) == 1 diff --git a/apps/api/tests/core/test_dependencies.py b/apps/api/tests/core/test_dependencies.py new file mode 100644 index 0000000..cab7c9b --- /dev/null +++ b/apps/api/tests/core/test_dependencies.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import pytest +from fastapi import HTTPException +from fastapi.security import HTTPAuthorizationCredentials + +from app.core.dependencies import get_current_user, require_admin, require_superuser +from app.core.security import create_access_token, hash_password +from app.modules.users.repository import create_user, update_user + + +def test_get_current_user_rejects_missing_credentials(): + with pytest.raises(HTTPException) as exc: + get_current_user(None) + assert exc.value.status_code == 401 + + +def test_get_current_user_rejects_pending_user(): + user = create_user("pending-dep@example.com", hash_password("Valid123"), status="pending") + token = create_access_token(user.id, user.role, user.is_superuser) + credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token) + with pytest.raises(HTTPException) as exc: + get_current_user(credentials) + assert exc.value.status_code == 403 + assert exc.value.detail == "EMAIL_NOT_VERIFIED" + + +def test_get_current_user_rejects_blocked_user(): + user = create_user("blocked-dep@example.com", hash_password("Valid123"), status="active") + user.status = "blocked" + update_user(user) + token = create_access_token(user.id, user.role, user.is_superuser) + credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token) + with pytest.raises(HTTPException) as exc: + get_current_user(credentials) + assert exc.value.status_code == 403 + assert exc.value.detail == "ACCOUNT_BLOCKED" + + +def test_get_current_user_rejects_invalid_token(): + credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials="not-a-jwt") + with pytest.raises(HTTPException) as exc: + get_current_user(credentials) + assert exc.value.status_code == 401 + assert exc.value.detail == "INVALID_TOKEN" + + +def test_get_current_user_rejects_unknown_user(): + token = create_access_token("missing-user-id", "user", False) + credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token) + with pytest.raises(HTTPException) as exc: + get_current_user(credentials) + assert exc.value.status_code == 401 + + +def test_require_admin_rejects_regular_user(): + user = create_user("regular-dep@example.com", hash_password("Valid123"), role="user", status="active") + with pytest.raises(HTTPException) as exc: + require_admin(user) + assert exc.value.status_code == 403 + + +def test_require_superuser_rejects_non_super_admin(): + user = create_user("ops-dep@example.com", hash_password("Valid123"), role="admin", status="active") + with pytest.raises(HTTPException) as exc: + require_superuser(user) + assert exc.value.status_code == 403 + assert exc.value.detail == "SUPERUSER_ONLY" diff --git a/apps/api/tests/core/test_email.py b/apps/api/tests/core/test_email.py new file mode 100644 index 0000000..482773c --- /dev/null +++ b/apps/api/tests/core/test_email.py @@ -0,0 +1,29 @@ +from unittest.mock import MagicMock, patch + +from app.core.email import SmtpMailer, get_mailer, memory_mailer, send_template_email + + +def test_memory_mailer_latest_token(): + memory_mailer.clear() + send_template_email( + to="user@example.com", + template="verify_email", + subject="Verify", + body="Open link\nTOKEN:abc123\n", + ) + assert memory_mailer.latest_token("user@example.com", "verify_email") == "abc123" + + +def test_smtp_mailer_sends_message(monkeypatch): + monkeypatch.setattr("app.core.email.settings.email_delivery_mode", "smtp") + monkeypatch.setattr("app.core.email.settings.smtp_from", "noreply@example.com") + monkeypatch.setattr("app.core.email.settings.smtp_host", "localhost") + monkeypatch.setattr("app.core.email.settings.smtp_port", 1025) + monkeypatch.setattr("app.core.email.settings.smtp_user", "") + monkeypatch.setattr("app.core.email.settings.smtp_password", "") + + smtp_instance = MagicMock() + with patch("app.core.email.smtplib.SMTP") as smtp_cls: + smtp_cls.return_value.__enter__.return_value = smtp_instance + get_mailer().send("user@example.com", "Subject", "Body", "verify_email") + smtp_instance.send_message.assert_called_once() diff --git a/apps/api/tests/core/test_install_secrets.py b/apps/api/tests/core/test_install_secrets.py new file mode 100644 index 0000000..a627482 --- /dev/null +++ b/apps/api/tests/core/test_install_secrets.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from app.core import install_secrets as secrets + + +@pytest.fixture +def secrets_dir(tmp_path: Path, monkeypatch): + install_dir = tmp_path / "secrets" + install_file = install_dir / "install.env" + meta_file = install_dir / "install.meta.json" + monkeypatch.setattr(secrets, "INSTALL_SECRETS_DIR", install_dir) + monkeypatch.setattr(secrets, "INSTALL_SECRETS_FILE", install_file) + monkeypatch.setattr(secrets, "INSTALL_SECRETS_META_FILE", meta_file) + return install_dir, install_file, meta_file + + +def test_read_install_secrets_empty_when_missing(secrets_dir): + _, install_file, _ = secrets_dir + assert not install_file.exists() + assert secrets.read_install_secrets() == {} + + +def test_write_and_read_install_secrets_roundtrip(secrets_dir): + install_dir, install_file, _ = secrets_dir + install_dir.mkdir(parents=True, exist_ok=True) + install_file.write_text( + "JWT_ACCESS_SECRET=abc\n# comment\nPOSTGRES_PASSWORD=secret\n", + encoding="utf-8", + ) + values = secrets.read_install_secrets() + assert values["JWT_ACCESS_SECRET"] == "abc" + assert values["POSTGRES_PASSWORD"] == "secret" + + +def test_sync_minio_s3_secrets_aligns_keys(): + values = { + "S3_ACCESS_KEY": "minio", + "MINIO_ROOT_USER": "minio", + "MINIO_ROOT_PASSWORD": "root-secret", + "S3_SECRET_KEY": "old-secret", + } + synced = secrets._sync_minio_s3_secrets(values) + assert synced["S3_SECRET_KEY"] == "root-secret" + + +def test_masked_database_url_hides_password(): + masked = secrets.masked_database_url("postgresql://user:pass@localhost:5432/compton") + assert "pass" not in masked + assert "user:***" in masked + + +def test_install_secrets_payload_reports_status(secrets_dir): + install_dir, install_file, _ = secrets_dir + install_dir.mkdir(parents=True, exist_ok=True) + install_file.write_text( + "\n".join( + [ + "SECRETS_LOCKED=true", + "DATABASE_URL=postgresql://compton_app:secret@postgres:5432/compton", + "JWT_ACCESS_SECRET=abc", + "JWT_REFRESH_PEPPER=def", + "POSTGRES_PASSWORD=secret", + "S3_SECRET_KEY=key", + ] + ) + + "\n", + encoding="utf-8", + ) + payload = secrets.install_secrets_payload() + assert payload["initialized"] is True + assert payload["locked"] is True + assert payload["database"]["user"] == "compton_app" + assert "***" in payload["connection_string_masked"] + + +def test_reveal_install_secret_supported_and_unsupported(secrets_dir): + install_dir, install_file, _ = secrets_dir + install_dir.mkdir(parents=True, exist_ok=True) + install_file.write_text("JWT_ACCESS_SECRET=top-secret\n", encoding="utf-8") + assert secrets.reveal_install_secret("jwt_access_secret") == "top-secret" + with pytest.raises(ValueError, match="UNSUPPORTED_SECRET_KEY"): + secrets.reveal_install_secret("unknown") + + +def test_ensure_install_secrets_creates_locked_bundle(secrets_dir, monkeypatch): + install_dir, install_file, meta_file = secrets_dir + monkeypatch.delenv("DATABASE_URL", raising=False) + status = secrets.ensure_install_secrets() + assert status.created is True + assert status.locked is True + assert install_file.exists() + assert meta_file.exists() + values = secrets.read_install_secrets() + assert values["SECRETS_LOCKED"] == "true" + assert values["JWT_ACCESS_SECRET"] + + +def test_ensure_install_secrets_adopts_existing_locked_file(secrets_dir): + install_dir, install_file, meta_file = secrets_dir + install_dir.mkdir(parents=True, exist_ok=True) + install_file.write_text( + "SECRETS_LOCKED=true\nJWT_ACCESS_SECRET=existing\nJWT_REFRESH_PEPPER=pepper\n" + "POSTGRES_PASSWORD=pw\nPOSTGRES_USER=u\nPOSTGRES_DB=db\nDATABASE_URL=postgresql://u:pw@localhost/db\n" + "S3_ACCESS_KEY=a\nS3_SECRET_KEY=b\nMINIO_ROOT_USER=a\nMINIO_ROOT_PASSWORD=b\n", + encoding="utf-8", + ) + meta_file.write_text('{"install_id":"x","locked_at":"2026-01-01T00:00:00+00:00"}\n', encoding="utf-8") + status = secrets.ensure_install_secrets() + assert status.created is False + assert status.locked is True + assert secrets.read_install_secrets()["JWT_ACCESS_SECRET"] == "existing" diff --git a/apps/api/tests/core/test_jwt_denylist.py b/apps/api/tests/core/test_jwt_denylist.py new file mode 100644 index 0000000..806a8f4 --- /dev/null +++ b/apps/api/tests/core/test_jwt_denylist.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from app.core import jwt_denylist as denylist_module +from app.core.jwt_denylist import ( + bump_auth_epoch, + deny_jti, + ensure_jwt_revocation_backend, + get_auth_epoch, + is_jti_denied, + revoke_access_token, + validate_access_claims, +) +from app.core.security import create_access_token, hash_password +from app.modules.users.repository import create_user + + +def test_auth_epoch_bump_invalidates_token(): + user = create_user("epoch-user@example.com", hash_password("Valid123"), status="active") + token = create_access_token(user.id, user.role, user.is_superuser) + from app.core.security import decode_access_token + + payload = decode_access_token(token) + validate_access_claims(payload) + + bump_auth_epoch(user.id) + with pytest.raises(ValueError, match="TOKEN_REVOKED"): + validate_access_claims(payload) + + +def test_deny_jti_blocks_specific_token(): + user = create_user("jti-user@example.com", hash_password("Valid123"), status="active") + token = create_access_token(user.id, user.role, user.is_superuser) + from app.core.security import decode_access_token + + payload = decode_access_token(token) + deny_jti(payload["jti"], payload["exp"]) + assert is_jti_denied(payload["jti"]) + with pytest.raises(ValueError, match="TOKEN_REVOKED"): + validate_access_claims(payload) + + +def test_revoke_access_token_helper(): + user = create_user("revoke-user@example.com", hash_password("Valid123"), status="active") + token = create_access_token(user.id, user.role, user.is_superuser) + revoke_access_token(token) + from app.core.security import decode_access_token + + with pytest.raises(ValueError, match="TOKEN_REVOKED"): + validate_access_claims(decode_access_token(token)) + + +def test_get_auth_epoch_defaults_to_zero(): + assert get_auth_epoch("missing-user-id") == 0 + + +def test_validate_access_claims_rejects_missing_sub(): + with pytest.raises(ValueError, match="INVALID_TOKEN"): + validate_access_claims({}) + + +def test_production_is_jti_denied_fail_closed(monkeypatch): + monkeypatch.setattr(denylist_module.settings, "app_env", "production") + mock_client = MagicMock() + mock_client.exists.side_effect = RuntimeError("redis down") + with patch("app.core.jwt_denylist.get_redis_client", return_value=mock_client): + assert is_jti_denied("any-jti") is True + + +def test_revoke_access_token_ignores_invalid_token(): + revoke_access_token("not-a-jwt") + + +def test_bump_auth_epoch_production_raises_when_redis_fails(monkeypatch): + monkeypatch.setattr(denylist_module.settings, "app_env", "production") + mock_client = MagicMock() + mock_client.incr.side_effect = RuntimeError("redis down") + with patch("app.core.jwt_denylist.get_redis_client", return_value=mock_client): + with pytest.raises(RuntimeError): + bump_auth_epoch("user-x") + + +def test_empty_jti_is_not_denied(): + assert is_jti_denied("") is False + deny_jti("", 9999999999) + + +def test_ensure_jwt_revocation_backend_requires_redis_in_production(monkeypatch): + monkeypatch.setattr(denylist_module.settings, "app_env", "production") + with patch("app.core.jwt_denylist.get_redis_client", return_value=None): + with pytest.raises(RuntimeError, match="Redis is required"): + ensure_jwt_revocation_backend() + + +def test_get_auth_epoch_production_raises_when_redis_fails(monkeypatch): + monkeypatch.setattr(denylist_module.settings, "app_env", "production") + mock_client = MagicMock() + mock_client.get.side_effect = RuntimeError("redis down") + with patch("app.core.jwt_denylist.get_redis_client", return_value=mock_client): + with pytest.raises(RuntimeError): + get_auth_epoch("user-y") + + +def test_deny_jti_production_raises_when_redis_fails(monkeypatch): + monkeypatch.setattr(denylist_module.settings, "app_env", "production") + mock_client = MagicMock() + mock_client.setex.side_effect = RuntimeError("redis down") + with patch("app.core.jwt_denylist.get_redis_client", return_value=mock_client): + with pytest.raises(RuntimeError): + deny_jti("jti-123", int(__import__("time").time()) + 3600) + + +def test_memory_denylist_purges_expired_jti(monkeypatch): + monkeypatch.setattr(denylist_module, "_memory_denied_jti", {"expired-jti": 1.0}) + assert is_jti_denied("expired-jti") is False + diff --git a/apps/api/tests/core/test_main.py b/apps/api/tests/core/test_main.py new file mode 100644 index 0000000..5297a1a --- /dev/null +++ b/apps/api/tests/core/test_main.py @@ -0,0 +1,9 @@ +from app.main import create_app +from app.core.config import settings + + +def test_create_app_includes_test_routes_when_enabled(monkeypatch): + monkeypatch.setattr(settings, "enable_test_routes", True) + app = create_app() + paths = set(app.openapi()["paths"]) + assert "/api/v1/test/emails/latest-token" in paths diff --git a/apps/api/tests/core/test_password_denylist.py b/apps/api/tests/core/test_password_denylist.py index 2d472d2..2fa550c 100644 --- a/apps/api/tests/core/test_password_denylist.py +++ b/apps/api/tests/core/test_password_denylist.py @@ -1,5 +1,15 @@ -from app.core.password_denylist import is_denied_password +from app.core.password_denylist import is_denied_password, load_denylist def test_password_denylist_blocks_common_password(): assert is_denied_password("password123") + + +def test_password_denylist_loads_custom_entries(tmp_path, monkeypatch): + denylist_file = tmp_path / "denylist.txt" + denylist_file.write_text("# comment\nCustomBad1\n", encoding="utf-8") + monkeypatch.setattr("app.core.password_denylist.settings.password_denylist_path", str(denylist_file)) + denylist = load_denylist() + assert "custombad1" in denylist + assert is_denied_password("CustomBad1") + diff --git a/apps/api/tests/core/test_production_guards.py b/apps/api/tests/core/test_production_guards.py new file mode 100644 index 0000000..e5a88cc --- /dev/null +++ b/apps/api/tests/core/test_production_guards.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import pytest + +from app.main import _assert_production_guards +from app.core.config import settings + + +@pytest.fixture(autouse=True) +def reset_app_env(monkeypatch): + monkeypatch.setattr(settings, "app_env", "development") + monkeypatch.setattr(settings, "enable_test_routes", True) + monkeypatch.setattr(settings, "enable_docs", True) + monkeypatch.setattr(settings, "enable_rate_limit", False) + monkeypatch.setattr(settings, "cookie_secure", False) + monkeypatch.setattr(settings, "jwt_access_secret", "dev-access-secret-32bytes-minimum!!") + monkeypatch.setattr(settings, "jwt_refresh_pepper", "dev-refresh-pepper-32bytes-minimum!!") + monkeypatch.setattr( + settings, + "database_url", + "postgresql+psycopg://compton_app:secret@postgres:5432/compton?sslmode=require", + ) + + +def test_production_guards_skip_in_development(): + _assert_production_guards() + + +def test_production_guards_reject_test_routes(monkeypatch): + monkeypatch.setattr(settings, "app_env", "production") + with pytest.raises(RuntimeError, match="ENABLE_TEST_ROUTES"): + _assert_production_guards() + + +def test_production_guards_reject_insecure_cookie(monkeypatch): + monkeypatch.setattr(settings, "app_env", "production") + monkeypatch.setattr(settings, "enable_test_routes", False) + monkeypatch.setattr(settings, "enable_docs", False) + monkeypatch.setattr(settings, "enable_rate_limit", True) + with pytest.raises(RuntimeError, match="COOKIE_SECURE"): + _assert_production_guards() + + +def test_production_guards_reject_default_db_credentials(monkeypatch): + monkeypatch.setattr(settings, "app_env", "production") + monkeypatch.setattr(settings, "enable_test_routes", False) + monkeypatch.setattr(settings, "enable_docs", False) + monkeypatch.setattr(settings, "enable_rate_limit", True) + monkeypatch.setattr(settings, "cookie_secure", True) + monkeypatch.setattr(settings, "database_url", "postgresql://user:pass@db/app?sslmode=require") + with pytest.raises(RuntimeError, match="Default database credentials"): + _assert_production_guards() + + +def test_production_guards_reject_placeholder_jwt(monkeypatch): + monkeypatch.setattr(settings, "app_env", "production") + monkeypatch.setattr(settings, "enable_test_routes", False) + monkeypatch.setattr(settings, "enable_docs", False) + monkeypatch.setattr(settings, "enable_rate_limit", True) + monkeypatch.setattr(settings, "cookie_secure", True) + monkeypatch.setattr(settings, "jwt_access_secret", "change-me-access") + with pytest.raises(RuntimeError, match="JWT_ACCESS_SECRET"): + _assert_production_guards() + + +def test_production_guards_reject_placeholder_refresh_pepper(monkeypatch): + monkeypatch.setattr(settings, "app_env", "production") + monkeypatch.setattr(settings, "enable_test_routes", False) + monkeypatch.setattr(settings, "enable_docs", False) + monkeypatch.setattr(settings, "enable_rate_limit", True) + monkeypatch.setattr(settings, "cookie_secure", True) + monkeypatch.setattr(settings, "jwt_access_secret", "prod-access-secret-32bytes-minimum!!") + monkeypatch.setattr(settings, "jwt_refresh_pepper", "change-me-pepper") + with pytest.raises(RuntimeError, match="JWT_REFRESH_PEPPER"): + _assert_production_guards() + + +def test_production_guards_reject_missing_sslmode(monkeypatch): + monkeypatch.setattr(settings, "app_env", "production") + monkeypatch.setattr(settings, "enable_test_routes", False) + monkeypatch.setattr(settings, "enable_docs", False) + monkeypatch.setattr(settings, "enable_rate_limit", True) + monkeypatch.setattr(settings, "cookie_secure", True) + monkeypatch.setattr(settings, "database_url", "postgresql://app:secret@db/app") + with pytest.raises(RuntimeError, match="sslmode=require"): + _assert_production_guards() diff --git a/apps/api/tests/core/test_redis.py b/apps/api/tests/core/test_redis.py new file mode 100644 index 0000000..0d1b6e9 --- /dev/null +++ b/apps/api/tests/core/test_redis.py @@ -0,0 +1,32 @@ +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import HTTPException, Request + +from app.core.config import settings +from app.core.redis import check_rate_limit, client_ip, _buckets + + +def test_check_rate_limit_uses_memory_fallback(monkeypatch): + monkeypatch.setattr(settings, "enable_rate_limit", True) + _buckets.clear() + with patch("app.core.redis.get_redis_client", return_value=None): + check_rate_limit("memory-key", limit=1, window_seconds=60) + with pytest.raises(HTTPException) as exc: + check_rate_limit("memory-key", limit=1, window_seconds=60) + assert exc.value.status_code == 429 + + +def test_client_ip_honors_trusted_proxy(monkeypatch): + monkeypatch.setattr(settings, "trusted_proxy_ips", "127.0.0.1") + request = MagicMock(spec=Request) + request.headers = {"X-Forwarded-For": "203.0.113.10, 127.0.0.1"} + request.client.host = "127.0.0.1" + assert client_ip(request) == "203.0.113.10" + + +def test_client_ip_unknown_without_client(): + request = MagicMock(spec=Request) + request.headers = {} + request.client = None + assert client_ip(request) == "unknown" diff --git a/apps/api/tests/core/test_storage.py b/apps/api/tests/core/test_storage.py index d441041..a0e33b0 100644 --- a/apps/api/tests/core/test_storage.py +++ b/apps/api/tests/core/test_storage.py @@ -1,5 +1,14 @@ -from app.core.storage import ensure_bucket +from app.core.storage import download_object, ensure_bucket, memory_store, upload_object def test_ensure_bucket_noop_in_memory(): ensure_bucket() + + +def test_memory_storage_roundtrip(): + memory_store.clear() + upload_object("avatars/test.png", b"abc", "image/png") + payload = download_object("avatars/test.png") + assert payload == (b"abc", "image/png") + assert download_object("missing") is None + diff --git a/apps/api/tests/modules/admin/test_admin_router.py b/apps/api/tests/modules/admin/test_admin_router.py index cdcd882..2d527c4 100644 --- a/apps/api/tests/modules/admin/test_admin_router.py +++ b/apps/api/tests/modules/admin/test_admin_router.py @@ -71,3 +71,39 @@ def test_superuser_can_create_and_delete_user(client): deleted = client.delete(f"/api/v1/admin/users/{user_id}", headers=_admin_headers(client)) assert deleted.status_code == 200 assert deleted.json()["status"] == "deleted" + + +def test_admin_summary_and_stats(client): + headers = _admin_headers(client) + summary = client.get("/api/v1/admin/summary", headers=headers) + assert summary.status_code == 200 + assert "users_count" in summary.json() + + stats = client.get("/api/v1/admin/stats", headers=headers) + assert stats.status_code == 200 + + +def test_superuser_diagnostics_and_server_log(client): + headers = _admin_headers(client) + diagnostics = client.get("/api/v1/admin/diagnostics/report", headers=headers) + assert diagnostics.status_code == 200 + assert "checks" in diagnostics.json() + + activity = client.get("/api/v1/admin/activity-feed", headers=headers) + assert activity.status_code == 200 + assert "events" in activity.json() + + server_log = client.get("/api/v1/admin/server-log", headers=headers) + assert server_log.status_code == 200 + assert "lines" in server_log.json() + + +def test_admin_ui_activity(client): + response = client.post( + "/api/v1/admin/ui-activity", + json={"event": "tab_open", "meta": {"tab": "users"}}, + headers=_admin_headers(client), + ) + assert response.status_code == 200 + assert response.json()["status"] == "ok" + diff --git a/apps/api/tests/modules/admin/test_security_diagnostics.py b/apps/api/tests/modules/admin/test_security_diagnostics.py new file mode 100644 index 0000000..4ac95d8 --- /dev/null +++ b/apps/api/tests/modules/admin/test_security_diagnostics.py @@ -0,0 +1,10 @@ +from app.modules.admin.security_diagnostics import build_security_diagnostics_report + + +def test_build_security_diagnostics_report_returns_checks(): + report = build_security_diagnostics_report() + assert "checks" in report + assert len(report["checks"]) >= 10 + ids = {check["id"] for check in report["checks"]} + assert "jwt_access_secret" in ids + assert "install_secrets_locked" in ids diff --git a/apps/api/tests/modules/admin/test_service.py b/apps/api/tests/modules/admin/test_service.py index d55a458..80553c0 100644 --- a/apps/api/tests/modules/admin/test_service.py +++ b/apps/api/tests/modules/admin/test_service.py @@ -1,6 +1,12 @@ from unittest.mock import patch -from app.modules.admin.service import patch_user +from app.modules.admin.service import ( + create_admin_user, + delete_admin_user, + get_server_log_tail, + patch_user, + reset_user_password, +) from app.modules.users import repository from app.modules.users.repository import create_user, get_user_by_email from app.core.security import hash_password @@ -31,3 +37,52 @@ def test_last_admin_protected(): assert False, "Expected last-admin protection" except ValueError as exc: assert str(exc) == "LAST_ADMIN_PROTECTED" + + +def test_admin_cannot_self_block(): + admin = get_user_by_email("admin@compton.example") + try: + patch_user(admin, admin.id, None, "blocked") + assert False, "Expected self-block error" + except ValueError as exc: + assert str(exc) == "SELF_BLOCK_FORBIDDEN" + + +def test_reset_user_password_revokes_sessions(): + admin = get_user_by_email("admin@compton.example") + target = create_user("reset-pw@example.com", hash_password("Valid123"), status="active") + result = reset_user_password(admin, target.id, "NewValid123") + assert result["status"] == "ok" + + +def test_delete_admin_user_forbidden_for_self(): + admin = get_user_by_email("admin@compton.example") + try: + delete_admin_user(admin, admin.id) + assert False, "Expected self-delete error" + except ValueError as exc: + assert str(exc) == "SELF_DELETE_FORBIDDEN" + + +def test_create_admin_user_rejects_duplicate_email(): + admin = get_user_by_email("admin@compton.example") + try: + create_admin_user( + admin, + email="user@compton.example", + password="Valid123", + role="user", + is_superuser=False, + status="active", + ) + assert False, "Expected duplicate user error" + except ValueError as exc: + assert str(exc) == "USER_EXISTS" + + +def test_get_server_log_tail_empty_when_missing(): + with patch("app.modules.admin.service.settings") as mock_settings: + mock_settings.server_log_path = "/tmp/compton-missing-log.txt" + payload = get_server_log_tail() + assert payload["lines"] == [] + diff --git a/apps/api/tests/modules/auth/test_jwt_revocation_integration.py b/apps/api/tests/modules/auth/test_jwt_revocation_integration.py new file mode 100644 index 0000000..ba0bec2 --- /dev/null +++ b/apps/api/tests/modules/auth/test_jwt_revocation_integration.py @@ -0,0 +1,35 @@ +from app.core.security import create_access_token, hash_password +from tests.helpers import register_and_verify + + +def _admin_headers(client) -> dict[str, str]: + from app.modules.users.repository import get_user_by_email + + admin = get_user_by_email("admin@compton.example") + token = create_access_token(admin.id, admin.role, admin.is_superuser) + return {"Authorization": f"Bearer {token}"} + + +def test_blocked_user_access_token_revoked_after_admin_block(client): + register_and_verify(client, "blocked-jwt@example.com") + login = client.post( + "/api/v1/auth/login", + json={"email": "blocked-jwt@example.com", "password": "Valid123"}, + ) + assert login.status_code == 200 + access_token = login.json()["access_token"] + + me = client.get("/api/v1/users/me", headers={"Authorization": f"Bearer {access_token}"}) + assert me.status_code == 200 + + user_id = me.json()["user"]["id"] + blocked = client.patch( + f"/api/v1/admin/users/{user_id}", + headers=_admin_headers(client), + json={"status": "blocked"}, + ) + assert blocked.status_code == 200 + + revoked = client.get("/api/v1/users/me", headers={"Authorization": f"Bearer {access_token}"}) + assert revoked.status_code == 401 + assert revoked.json()["detail"] == "TOKEN_REVOKED" diff --git a/apps/api/tests/modules/auth/test_logout_and_forgot.py b/apps/api/tests/modules/auth/test_logout_and_forgot.py new file mode 100644 index 0000000..ea1ea11 --- /dev/null +++ b/apps/api/tests/modules/auth/test_logout_and_forgot.py @@ -0,0 +1,77 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from app.core import jwt_denylist as denylist_module +from app.core.jwt_denylist import ( + bump_auth_epoch, + deny_jti, + ensure_jwt_revocation_backend, + get_auth_epoch, + is_jti_denied, +) + + +def test_ensure_jwt_revocation_backend_requires_redis_in_production(monkeypatch): + monkeypatch.setattr(denylist_module.settings, "app_env", "production") + with patch("app.core.jwt_denylist.get_redis_client", return_value=None): + with pytest.raises(RuntimeError, match="Redis is required"): + ensure_jwt_revocation_backend() + + +def test_redis_auth_epoch_roundtrip(): + mock_client = MagicMock() + mock_client.get.return_value = "3" + mock_client.incr.return_value = 4 + with patch("app.core.jwt_denylist.get_redis_client", return_value=mock_client): + assert get_auth_epoch("user-1") == 3 + assert bump_auth_epoch("user-1") == 4 + mock_client.incr.assert_called_once() + + +def test_redis_deny_jti_and_check(): + mock_client = MagicMock() + with patch("app.core.jwt_denylist.get_redis_client", return_value=mock_client): + with patch("app.core.jwt_denylist.time.time", return_value=1000): + deny_jti("abc-jti", 1060) + mock_client.setex.assert_called_once_with("jwt:deny:abc-jti", 60, "1") + mock_client.exists.return_value = 1 + assert is_jti_denied("abc-jti") is True + + +def test_forgot_password_skips_blocked_user(client): + from app.modules.users.repository import create_user, get_user_by_email, update_user + from app.core.security import hash_password + + create_user("blocked-forgot@example.com", hash_password("Valid123"), status="active") + user = get_user_by_email("blocked-forgot@example.com") + user.status = "blocked" + update_user(user) + + response = client.post( + "/api/v1/auth/forgot-password", + json={"email": "blocked-forgot@example.com"}, + ) + assert response.status_code == 200 + from app.core.email import memory_mailer + + assert not any(msg.to == "blocked-forgot@example.com" for msg in memory_mailer.sent) + + +def test_logout_revokes_access_token(client): + from tests.helpers import register_and_verify + + register_and_verify(client, "logout-jti@example.com") + login = client.post( + "/api/v1/auth/login", + json={"email": "logout-jti@example.com", "password": "Valid123"}, + ) + token = login.json()["access_token"] + logout = client.post( + "/api/v1/auth/logout", + headers={"Authorization": f"Bearer {token}", "Origin": "http://localhost:5173"}, + ) + assert logout.status_code == 200 + me = client.get("/api/v1/users/me", headers={"Authorization": f"Bearer {token}"}) + assert me.status_code == 401 + assert me.json()["detail"] == "TOKEN_REVOKED" diff --git a/apps/api/tests/modules/auth/test_refresh_security.py b/apps/api/tests/modules/auth/test_refresh_security.py index 1bf809d..5003e2b 100644 --- a/apps/api/tests/modules/auth/test_refresh_security.py +++ b/apps/api/tests/modules/auth/test_refresh_security.py @@ -12,6 +12,31 @@ def _admin_headers(client) -> dict[str, str]: return {"Authorization": f"Bearer {token}"} +def test_refresh_fails_for_pending_user(client): + client.post( + "/api/v1/auth/register", + json={"email": "pending-refresh@example.com", "password": "Valid123"}, + ) + login = client.post( + "/api/v1/auth/login", + json={"email": "pending-refresh@example.com", "password": "Valid123"}, + ) + assert login.status_code == 403 + assert login.json()["detail"] == "EMAIL_NOT_VERIFIED" + + # Simulate stale refresh cookie from an earlier active session edge case via direct token issue. + from app.modules.auth.service import issue_refresh_token + from app.modules.users.repository import get_user_by_email + + user = get_user_by_email("pending-refresh@example.com") + refresh_token = issue_refresh_token(user.id) + client.cookies.set("refresh_token", refresh_token, path="/api/v1/auth") + + refresh = client.post("/api/v1/auth/refresh", headers={"Origin": "http://localhost:5173"}) + assert refresh.status_code == 403 + assert refresh.json()["detail"] == "EMAIL_NOT_VERIFIED" + + def test_refresh_fails_for_blocked_user(client): register_and_verify(client, "blocked-refresh@example.com") login = client.post( @@ -32,7 +57,8 @@ def test_refresh_fails_for_blocked_user(client): ) assert blocked.status_code == 200 refresh = client.post("/api/v1/auth/refresh", headers={"Origin": "http://localhost:5173"}) - assert refresh.status_code == 401 + assert refresh.status_code == 403 + assert refresh.json()["detail"] == "ACCOUNT_BLOCKED" def test_refresh_requires_origin_header_when_cookie_present(client): diff --git a/apps/api/tests/modules/auth/test_service_refresh.py b/apps/api/tests/modules/auth/test_service_refresh.py new file mode 100644 index 0000000..e56b108 --- /dev/null +++ b/apps/api/tests/modules/auth/test_service_refresh.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from app.core.security import hash_password +from app.modules.auth import repository as auth_repository +from app.modules.auth.service import issue_refresh_token, refresh +from app.modules.users.repository import create_user, get_user_by_id, update_user + + +def test_refresh_returns_account_blocked_for_blocked_user(): + user = create_user("blocked-svc@example.com", hash_password("Valid123"), status="active") + token = issue_refresh_token(user.id) + user.status = "blocked" + update_user(user) + auth_repository.revoke_user_families(user.id) + + with pytest.raises(PermissionError, match="ACCOUNT_BLOCKED"): + refresh(token) + + +def test_refresh_returns_email_not_verified_for_pending_user(): + user = create_user("pending-svc@example.com", hash_password("Valid123"), status="pending") + token = issue_refresh_token(user.id) + + with pytest.raises(PermissionError, match="EMAIL_NOT_VERIFIED"): + refresh(token) + + +def test_refresh_rejects_revoked_token_for_active_user(): + user = create_user("active-svc@example.com", hash_password("Valid123"), status="active") + token = issue_refresh_token(user.id) + auth_repository.revoke_user_families(user.id) + + with patch.object(auth_repository, "revoke_family_tokens") as revoke_family: + with pytest.raises(PermissionError, match="INVALID_REFRESH"): + refresh(token) + revoke_family.assert_called_once() diff --git a/apps/api/tests/modules/test/test_router.py b/apps/api/tests/modules/test/test_router.py new file mode 100644 index 0000000..7fcf1d9 --- /dev/null +++ b/apps/api/tests/modules/test/test_router.py @@ -0,0 +1,38 @@ +from fastapi.testclient import TestClient + +from app.core.config import settings +from app.core.email import memory_mailer +from app.main import create_app + + +def _test_client(monkeypatch) -> TestClient: + monkeypatch.setattr(settings, "enable_test_routes", True) + monkeypatch.setattr(settings, "email_delivery_mode", "memory") + return TestClient(create_app()) + + +def test_latest_email_token_route(monkeypatch): + client = _test_client(monkeypatch) + memory_mailer.clear() + memory_mailer.send( + to="token-route@example.com", + subject="Verify", + body="TOKEN:route-token\n", + template="verify_email", + ) + response = client.get( + "/api/v1/test/emails/latest-token", + params={"to": "token-route@example.com", "template": "verify_email"}, + ) + assert response.status_code == 200 + assert response.json()["token"] == "route-token" + + +def test_latest_email_token_route_missing_token(monkeypatch): + client = _test_client(monkeypatch) + response = client.get( + "/api/v1/test/emails/latest-token", + params={"to": "missing@example.com", "template": "verify_email"}, + ) + assert response.status_code == 404 + assert response.json()["detail"] == "TOKEN_NOT_FOUND" diff --git a/apps/api/tests/scripts/test_docker_entrypoint.py b/apps/api/tests/scripts/test_docker_entrypoint.py index dd1923f..9b6041c 100644 --- a/apps/api/tests/scripts/test_docker_entrypoint.py +++ b/apps/api/tests/scripts/test_docker_entrypoint.py @@ -1,6 +1,6 @@ from sqlalchemy import create_engine, inspect -from scripts.docker_entrypoint import INITIAL_REVISION, current_revision, run_migrations +from scripts.docker_entrypoint import HEAD_REVISION, INITIAL_REVISION, current_revision, run_migrations def test_run_migrations_on_empty_sqlite(tmp_path, monkeypatch): @@ -17,7 +17,7 @@ def test_run_migrations_on_empty_sqlite(tmp_path, monkeypatch): tables = set(inspect(engine).get_table_names()) assert "users" in tables - assert current_revision(engine) == "20260714_0004" + assert current_revision(engine) == HEAD_REVISION def test_run_migrations_stamps_existing_schema_without_alembic(tmp_path, monkeypatch): @@ -52,7 +52,7 @@ def test_run_migrations_stamps_existing_schema_without_alembic(tmp_path, monkeyp tables = set(inspect(engine).get_table_names()) assert "refresh_tokens" in tables - assert current_revision(engine) == "20260714_0004" + assert current_revision(engine) == HEAD_REVISION assert INITIAL_REVISION == "20260711_0001" @@ -98,4 +98,4 @@ def test_run_migrations_repairs_partial_schema_with_stale_alembic(tmp_path, monk tables = set(inspect(engine).get_table_names()) assert "refresh_tokens" in tables - assert current_revision(engine) == "20260714_0004" + assert current_revision(engine) == HEAD_REVISION diff --git a/apps/web/e2e/admin/admin-users.spec.ts b/apps/web/e2e/admin/admin-users.spec.ts index aac2eca..e761e8d 100644 --- a/apps/web/e2e/admin/admin-users.spec.ts +++ b/apps/web/e2e/admin/admin-users.spec.ts @@ -2,6 +2,25 @@ import { expect, test } from "@playwright/test"; import { API_URL, adminLogin, loginViaUi, registerVerifyLogin, uniqueEmail } from "../helpers/api"; test.describe("§15.7 scenarios 5 & 9: Admin users", () => { + test("blocked user access token rejected immediately after block", async ({ request }) => { + const email = uniqueEmail("e2e-block-jwt"); + const session = await registerVerifyLogin(request, email); + const admin = await adminLogin(request); + const adminToken = (await admin.json()).access_token; + + const patch = await request.patch(`${API_URL}/api/v1/admin/users/${session.user.id}`, { + headers: { Authorization: `Bearer ${adminToken}` }, + data: { status: "blocked" } + }); + expect(patch.ok()).toBeTruthy(); + + const me = await request.get(`${API_URL}/api/v1/users/me`, { + headers: { Authorization: `Bearer ${session.accessToken}` } + }); + expect(me.status()).toBe(401); + expect((await me.json()).detail).toBe("TOKEN_REVOKED"); + }); + test("admin blocks user → blocked user cannot login", async ({ page, request }) => { const email = uniqueEmail("e2e-block"); const session = await registerVerifyLogin(request, email); diff --git a/apps/web/src/app/router/guards/AdminGuard.tsx b/apps/web/src/app/router/guards/AdminGuard.tsx index ff7592e..0a8c1ff 100644 --- a/apps/web/src/app/router/guards/AdminGuard.tsx +++ b/apps/web/src/app/router/guards/AdminGuard.tsx @@ -3,7 +3,7 @@ import type { PropsWithChildren } from "react"; import { useAuth } from "@modules/auth"; import { useAuthStore } from "@modules/auth/store/authStore"; -export function AdminGuard({ children }: PropsWithChildren): JSX.Element { +export function AdminGuard({ children }: PropsWithChildren): JSX.Element | null { const bootstrapped = useAuthStore((state) => state.bootstrapped); const auth = useAuth(); diff --git a/apps/web/src/app/router/guards/AuthGuard.tsx b/apps/web/src/app/router/guards/AuthGuard.tsx index 7f6ecef..925f06d 100644 --- a/apps/web/src/app/router/guards/AuthGuard.tsx +++ b/apps/web/src/app/router/guards/AuthGuard.tsx @@ -3,7 +3,7 @@ import type { PropsWithChildren } from "react"; import { useAuth } from "@modules/auth"; import { useAuthStore } from "@modules/auth/store/authStore"; -export function AuthGuard({ children }: PropsWithChildren): JSX.Element { +export function AuthGuard({ children }: PropsWithChildren): JSX.Element | null { const bootstrapped = useAuthStore((state) => state.bootstrapped); const auth = useAuth(); diff --git a/apps/web/src/app/router/guards/GuestGuard.tsx b/apps/web/src/app/router/guards/GuestGuard.tsx index 6c78616..7bc9710 100644 --- a/apps/web/src/app/router/guards/GuestGuard.tsx +++ b/apps/web/src/app/router/guards/GuestGuard.tsx @@ -3,7 +3,7 @@ import type { PropsWithChildren } from "react"; import { useAuth } from "@modules/auth"; import { useAuthStore } from "@modules/auth/store/authStore"; -export function GuestGuard({ children }: PropsWithChildren): JSX.Element { +export function GuestGuard({ children }: PropsWithChildren): JSX.Element | null { const bootstrapped = useAuthStore((state) => state.bootstrapped); const auth = useAuth(); diff --git a/apps/web/src/modules/admin/api/adminApi.test.ts b/apps/web/src/modules/admin/api/adminApi.test.ts index 8a5d216..51395b3 100644 --- a/apps/web/src/modules/admin/api/adminApi.test.ts +++ b/apps/web/src/modules/admin/api/adminApi.test.ts @@ -43,9 +43,21 @@ vi.mock("@shared/api/client", () => ({ } return { data: { data: [], meta: { total: 0, page: 1, limit: 20 } } }; }), - patch: vi.fn(async () => ({ - data: { id: "1", email: "u@example.com", role: "user", is_superuser: false, status: "blocked" } - })), + patch: vi.fn(async (url: string) => { + if (url === "/api/v1/admin/settings") { + return { + data: { + values: { enable_docs: false }, + locks: {}, + settings_path: "data/compton_settings.json", + secrets: {} + } + }; + } + return { + data: { id: "1", email: "u@example.com", role: "user", is_superuser: false, status: "blocked" } + }; + }), post: vi.fn(async (url: string) => { if (url === "/api/v1/admin/users") { return { data: { id: "1", email: "new@example.com", role: "user", is_superuser: false, status: "active" } }; @@ -95,7 +107,7 @@ describe("adminApi", () => { expect((await resetAdminUserPassword("1", "Valid123A")).status).toBe("blocked"); expect((await deleteAdminUser("1")).status).toBe("deleted"); expect((await getAdminSettings()).settings_path).toBe("data/compton_settings.json"); - expect((await patchAdminSettings({ enable_docs: false })).id).toBe("1"); + expect((await patchAdminSettings({ enable_docs: false })).settings_path).toBe("data/compton_settings.json"); expect((await getAdminDiagnostics()).checks[0].status).toBe("ok"); expect((await getAdminActivityFeed()).events.length).toBe(1); expect((await postAdminUiActivity("click")).status).toBe("ok"); diff --git a/apps/web/src/modules/admin/components/AdminStats.test.tsx b/apps/web/src/modules/admin/components/AdminStats.test.tsx index 083ea01..78194c8 100644 --- a/apps/web/src/modules/admin/components/AdminStats.test.tsx +++ b/apps/web/src/modules/admin/components/AdminStats.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { AdminStats } from "./AdminStats"; @@ -23,9 +23,14 @@ describe("AdminStats", () => { expect(await screen.findByText("CPU")).toBeInTheDocument(); expect(await screen.findByText("WESP")).toBeInTheDocument(); expect(await screen.findByText("nx throughput (instant)")).toBeInTheDocument(); - expect(await screen.findByText("users")).toBeInTheDocument(); - expect(await screen.findByText("10")).toBeInTheDocument(); - expect(await screen.findByText("3")).toBeInTheDocument(); - expect(await screen.findByText("2")).toBeInTheDocument(); + expect(await screen.findByText("registrations today")).toBeInTheDocument(); + expect((await screen.findAllByText("users")).length).toBeGreaterThanOrEqual(1); + + const statCards = document.querySelectorAll(".wesp-admin-stat-card"); + expect(statCards).toHaveLength(4); + expect(within(statCards[0] as HTMLElement).getByText("10")).toBeInTheDocument(); + expect(within(statCards[1] as HTMLElement).getByText("3")).toBeInTheDocument(); + expect(within(statCards[2] as HTMLElement).getByText("2")).toBeInTheDocument(); + expect(within(statCards[3] as HTMLElement).getByText("1")).toBeInTheDocument(); }); }); diff --git a/apps/web/src/modules/auth/api/authApi.ts b/apps/web/src/modules/auth/api/authApi.ts index a6a5f28..8b0ed4e 100644 --- a/apps/web/src/modules/auth/api/authApi.ts +++ b/apps/web/src/modules/auth/api/authApi.ts @@ -1,4 +1,4 @@ -import { authClient } from "@shared/api/client"; +import { authClient, applyAuthHeader } from "@shared/api/client"; export interface LoginPayload { email: string; @@ -38,8 +38,9 @@ export async function register(payload: RegisterPayload) { return data; } -export async function logout() { - await authClient.post("/api/v1/auth/logout"); +export async function logout(accessToken?: string | null) { + const headers = accessToken ? applyAuthHeader({}) : {}; + await authClient.post("/api/v1/auth/logout", undefined, { headers }); } export async function refresh() { diff --git a/apps/web/src/modules/auth/hooks/useAuth.ts b/apps/web/src/modules/auth/hooks/useAuth.ts index fe458dd..da51ce5 100644 --- a/apps/web/src/modules/auth/hooks/useAuth.ts +++ b/apps/web/src/modules/auth/hooks/useAuth.ts @@ -19,7 +19,7 @@ export function useAuth() { return data.user as AuthUser; }, async logout() { - await apiLogout(); + await apiLogout(accessToken); clearSession(); }, async refreshSession() { diff --git a/apps/web/src/shared/api/client.ts b/apps/web/src/shared/api/client.ts index d237def..0393a51 100644 --- a/apps/web/src/shared/api/client.ts +++ b/apps/web/src/shared/api/client.ts @@ -72,6 +72,10 @@ apiClient.interceptors.response.use( useAuthStore.getState().clearSession(); return Promise.reject(error); } + if (error.response?.status === 401 && detail === "TOKEN_REVOKED") { + useAuthStore.getState().clearSession(); + return Promise.reject(error); + } const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; if (error.response?.status !== 401 || !originalRequest || originalRequest._retry) { return Promise.reject(error); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index db55907..3a9c1a5 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -40,6 +40,7 @@ export default defineConfig({ exclude: ["e2e/**", "node_modules/**"], environment: "jsdom", setupFiles: ["src/__tests__/setup.ts"], + testTimeout: 10_000, coverage: { provider: "v8", include: ["src/**/*.{ts,tsx}"], diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..e5f98b8 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,41 @@ +# Документация Compton + +README в корне — только «как запустить за 30 секунд». Всё остальное — здесь. + +Мы сознательно держим **мало файлов**, но каждый — **плотный**: таблицы, схемы, команды. Можно с лёгким юмором, но без простыней на 500 строк. + +## Карта + +| Документ | Когда открывать | +|----------|-----------------| +| [project.md](./project.md) | «Где что лежит?», архитектура, API, маршруты, env | +| [deploy.md](./deploy.md) | Запуск, **стандартные логины dev**, staging/prod, troubleshooting | +| [security.md](./security.md) | Auth, JWT revoke, секреты, nginx, чеклист prod | +| [release.md](./release.md) | Перед выкладкой: E2E, k6, ZAP, Lighthouse, smoke | +| [TZ.md](./TZ.md) | Полное ТЗ — источник правды по требованиям | + +## Быстрые ссылки + +```bash +# dev +python apps/api/scripts/bootstrap_install.py +docker compose --profile docker-web up -d --build + +# тесты +pnpm --filter web test:ci +cd apps/api && python -m pytest --cov=app --cov-fail-under=90 + +# prod smoke (после деплоя) +./infra/scripts/smoke-prod.sh https://your-domain.com +``` + +## Что куда не кладём + +| Не в git | Почему | +|----------|--------| +| `apps/api/data/secrets/install.env` | пароли БД, JWT, MinIO | +| `.env`, `apps/api/.env` | локальные секреты | +| `apps/api/data/logs/` | runtime-логи | +| `node_modules/`, `.venv/` | очевидно | + +Если секрет утёк в git — считайте его скомпрометированным. Force-push не спасает совесть. diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000..8de1520 --- /dev/null +++ b/docs/deploy.md @@ -0,0 +1,196 @@ +# Деплой и эксплуатация + +От «запустил на ноуте» до «живёт на VPS и не стыдно показать security.md». + +## Среды + +```mermaid +flowchart LR + Dev[docker-compose.yml] --> St[staging] + St --> QA[k6 + ZAP + E2E] + QA --> Prod[production] +``` + +| Среда | Compose | APP_ENV | Docs | Demo users | +|-------|---------|---------|------|------------| +| Dev | `docker-compose.yml` | development | ✅ | ✅ | +| CI/E2E | `docker-compose.test.yml` | test | ✅ | ✅ | +| Staging | `infra/docker/docker-compose.staging.yml` | staging | ❌ | ❌ | +| Production | `infra/docker/docker-compose.prod.yml` | production | ❌ | ❌ | + +## Локальная разработка + +```bash +# 1. venv + зависимости backend (один раз) +python3 -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -r apps/api/requirements-dev.txt + +# 2. Секреты установки (один раз, до первого docker compose up) +python apps/api/scripts/bootstrap_install.py + +# 3. Полный стек в Docker (API + БД + web) +docker compose --profile docker-web up -d --build + +# 4. Проверка +curl http://localhost:8000/api/v1/health +``` + +| Сервис | URL | +|--------|-----| +| Web | http://localhost:5173 | +| API | http://localhost:8000 | +| PG/Redis/MinIO на хост | `docker-compose.dev-ports.yml` → 5432, 6379, 9000/9001 | + +> Bootstrap и локальные тесты (`pytest`, `mypy`) — через активированный `.venv`. Docker API использует свой образ; venv нужен для скриптов и разработки на хосте. + +**Гибрид** (инфра в Docker, frontend локально): + +```bash +source .venv/bin/activate +docker compose up -d +pnpm install +pnpm --filter web dev +``` + +**Install secrets на хост** (DBeaver): Admin → Security → Install Secrets → Reveal DB password. + +### Стандартные логины (dev) + +Создаются при seed на старте API. Пароли по умолчанию — из `apps/api/.env.example` (или дефолты в `config.py`). + +| Email | Пароль | Env | Роль | Superuser | Куда заходит | +|-------|--------|-----|------|:---------:|--------------| +| `admin@compton.example` | `Admin1234` | `ADMIN_INITIAL_PASSWORD` | admin | да | `/admin` — Users, Content, Security, Diagnostics, secrets | +| `ops@compton.example` | `OpsAdmin1234` | `DEMO_OPS_PASSWORD` | admin | нет | `/admin` — Users, Content, Activity (без Security) | +| `user@compton.example` | `User1234` | `DEMO_USER_PASSWORD` | user | — | `/profile` | + +**CMS-страницы (seed):** `about`, `privacy`, `terms` → `/pages/about` и т.д. + +> **Staging/production:** `SEED_DEMO_USERS=false` — demo `user@` и `ops@` **не создаются**, только admin + CMS. Пароль admin задаётся через `ADMIN_INITIAL_PASSWORD` **до первого seed**, потом — сменить в UI. + +## Staging + +**Нужно:** VPS, DNS, TLS certs в `infra/docker/certs/`, SMTP. + +```bash +git clone https://git.groupkomton.ru/Matvey/site.git && cd site +python3 apps/api/scripts/bootstrap_install.py +cp infra/docker/.env.staging.example infra/docker/.env.staging +# правим: домен, CORS, SMTP, ADMIN_INITIAL_PASSWORD +./infra/docker/deploy-staging.sh +``` + +Проверка: + +```bash +curl -fsS https://STAGING/api/v1/health +curl -fsS -o /dev/null -w "%{http_code}" https://STAGING/api/v1/docs # 404 +./infra/scripts/health-check.sh https://STAGING +``` + +Nginx: `default.tls.conf` — 80→443, HSTS. + +## Production + +```bash +python3 apps/api/scripts/bootstrap_install.py +cp infra/docker/.env.production.example infra/docker/.env.production +./infra/docker/deploy-prod.sh infra/docker/.env.production +``` + +**Сразу после bootstrap:** +1. Бэкап `install.env` off-server (зашифровать) +2. Сменить пароль admin + +| Сервис | Host ports | +|--------|------------| +| nginx | 80, 443 | +| api, web, pg, redis, minio | internal only | + +```bash +./infra/scripts/smoke-prod.sh https://YOUR_DOMAIN +``` + +### Rollback + +```bash +docker compose -f infra/docker/docker-compose.prod.yml down +git checkout PREVIOUS_TAG +./infra/docker/deploy-prod.sh infra/docker/.env.production +``` + +## Бэкапы и мониторинг + +| Что | Команда / как | +|-----|---------------| +| PostgreSQL | `./infra/scripts/backup-postgres.sh` → `backups/postgres-*.sql.gz` | +| install.env | `cp …/install.env backups/install.env.$(date +%F).enc` + gpg | +| Uptime | `./infra/scripts/health-check.sh URL` или UptimeRobot на `/api/v1/health` + `/` | +| Логи | logrotate для `server.log`, `admin-audit.jsonl` | + +## Восстановление секретов + +`install.env` — единственный источник runtime-секретов. Потеряли — не генерируйте новый вслепую. + +### Симптомы + +- `password authentication failed for user "compton_app"` +- bootstrap после того, как Postgres volume уже создан + +### Fix (есть бэкап) + +```bash +docker compose down +# восстановить apps/api/data/secrets/install.env +docker compose up -d --build +``` + +### Fix (нет бэкапа) + +| Вариант | Данные | +|---------|--------| +| Reveal из другой среды | сохраняются | +| `down -v` + bootstrap (**только dev**) | **удаляются все** | + +```bash +docker compose --profile docker-web down -v +python apps/api/scripts/bootstrap_install.py +docker compose --profile docker-web up -d --build +``` + +## SMTP + +Staging/prod: `EMAIL_DELIVERY_MODE=smtp`. Dev: `memory` (письма в RAM, SMTP не нужен). + +| Env | Назначение | +|-----|------------| +| `SMTP_HOST`, `SMTP_PORT`, `SMTP_FROM` | сервер | +| `FRONTEND_URL` | ссылки в письмах | + +## Troubleshooting + +| Проблема | Решение | +|----------|---------| +| `install.env missing` | `python apps/api/scripts/bootstrap_install.py` | +| `password authentication failed` | [восстановление секретов](#восстановление-секретов) | +| Login failed | `curl …/health`, проверить `.env`, restart web | +| 401 refresh в консоли (гость) | норма на публичных страницах | +| Logout после F5 на `/admin` | rebuild web, перелогиниться | +| «На сайт» ведёт на login | должно быть `href="/"`, rebuild | +| Port 5173 busy | stop Docker web **или** local Vite | +| CORS | `VITE_USE_API_PROXY=true`, не бить напрямую :8000 | +| Нет Security/Diagnostics | логин `admin@`, не `ops@` | +| Docker web: missing modules | `docker compose … up -d --build web` | + +## Compose-справочник + +| Файл | Назначение | +|------|------------| +| `docker-compose.yml` | dev | +| `docker-compose.dev-ports.yml` | порты на хост | +| `docker-compose.test.yml` | CI/E2E | +| `infra/docker/docker-compose.staging.yml` | staging | +| `infra/docker/docker-compose.prod.yml` | production | + +Deploy: `infra/docker/deploy-staging.sh`, `deploy-prod.sh`. diff --git a/docs/project.md b/docs/project.md new file mode 100644 index 0000000..08405f8 --- /dev/null +++ b/docs/project.md @@ -0,0 +1,220 @@ +# Структура и архитектура + +Monorepo-lite: статический лендинг + React SPA + FastAPI + PostgreSQL + Redis + MinIO. +Если вы искали микросервисы на Kubernetes — это другой коридор. + +## Стек + +| Слой | Технологии | +|------|------------| +| Frontend | React 19, TS, Vite, React Router, TanStack Query, Zustand, RHF+Zod, Ant Design | +| Backend | FastAPI, SQLAlchemy 2, Alembic, Pydantic v2 | +| Данные | PostgreSQL 16, Redis 7, MinIO | +| Инфра | Docker Compose, Nginx, GitHub Actions | +| Качество | Vitest, Playwright, pytest (≥90% / ≥85% cov) | + +## Дерево репозитория + +``` +site/ +├── apps/ +│ ├── api/ # Backend +│ │ ├── app/ +│ │ │ ├── core/ # crypto, jwt_denylist, redis, install_secrets… +│ │ │ ├── db/ # models, seed, migrations helpers +│ │ │ └── modules/ # auth, users, content, admin, media, test +│ │ ├── migrations/ # Alembic +│ │ ├── scripts/ # bootstrap_install.py, docker_entrypoint.py +│ │ ├── tests/ +│ │ └── data/ +│ │ ├── secrets/ # install.env (gitignore!) +│ │ └── logs/ # server.log, admin-audit.jsonl (gitignore) +│ └── web/ +│ ├── index.html # лендинг / +│ ├── app.html # SPA entry +│ ├── main/ # статика лендинга (CSS/JS/video) +│ ├── src/ +│ │ ├── app/ # router, guards +│ │ ├── modules/ # auth, profile, admin, content, landing +│ │ ├── pages/ +│ │ └── shared/ # api client, ui +│ └── e2e/ # Playwright +├── packages/ # eslint-config, shared-types (target) +├── infra/ +│ ├── docker/ # staging/prod compose, deploy.sh +│ ├── nginx/ # default.conf, default.tls.conf +│ ├── k6/ # load test §17.2 +│ └── scripts/ # backup, health, smoke +├── docs/ # вы здесь +├── docker-compose.yml # dev +└── docker-compose.test.yml # CI / E2E +``` + +## Runtime + +```mermaid +flowchart TB + subgraph browser [Браузер] + L[index.html /] + S[app.html SPA] + end + subgraph edge [Nginx :80/:443] + N[TLS + headers] + end + subgraph internal [Docker internal] + W[web] + A[api] + PG[(PostgreSQL)] + R[(Redis)] + M[(MinIO)] + end + L --> N + S --> N + N --> W + N --> A + A --> PG + A --> R + A --> M +``` + +**Prod/staging:** наружу только nginx. Postgres, Redis, MinIO — без host-портов. + +## Frontend + +### Два входа (dual-entry) + +| Entry | URL | Содержимое | +|-------|-----|------------| +| `index.html` | `/` | Маркетинговый лендинг (`main/`) | +| `app.html` | `/login`, `/admin`, … | React SPA | + +Vite переписывает SPA-пути на `app.html` (`vite.main-static.ts`). + +### Маршруты + +| Путь | Guard | Кто | +|------|-------|-----| +| `/` | — | все | +| `/login`, `/register`, `/forgot-password`, `/reset-password` | GuestGuard | гости | +| `/verify`, `/pages/:slug` | — | публично | +| `/profile` | AuthGuard | user | +| `/admin` | AdminGuard | admin | + +**Auth UX:** +- Access JWT — только в памяти (Zustand), не localStorage +- Refresh — HttpOnly cookie, `Path=/api/v1/auth` +- После login: admin → `/admin`, user → `/profile` +- Кнопка «На сайт» в админке — **полный** переход на `/` (не React Router) + +### Админка (WESP-style) + +| Раздел | Кому | Что | +|--------|------|-----| +| Users | admin | CRUD пользователей | +| Content | admin | CMS | +| Security | superuser | runtime settings, Install Secrets | +| Diagnostics | superuser | health checks | +| Activity | admin | audit feed, server log | + +Тема Light/Dark — `localStorage.wespAdminTheme`. Auth-страницы — zootech-карточки (`#48816d`). + +## Backend API + +База: `/api/v1` + +| Модуль | Эндпоинты (основное) | +|--------|----------------------| +| health | `GET /health` | +| auth | register, login, logout, refresh, verify, forgot/reset password | +| users | `GET/PATCH /me`, password, avatar | +| content | публичные pages + admin CRUD | +| admin | users, stats, settings, diagnostics, secrets, activity | +| media | подписанные URL файлов | +| test | `/test/emails/latest-token` — **только** E2E | + +### Core (`apps/api/app/core/`) + +| Модуль | Зачем | +|--------|-------| +| `crypto.py` | bcrypt, JWT, HMAC, token hash — одна точка | +| `jwt_denylist.py` | мгновенный revoke access JWT | +| `install_secrets.py` | bootstrap + lock | +| `dependencies.py` | `get_current_user` | +| `redis.py` | rate limit + JWT revoke | +| `storage.py` | MinIO / memory | + +## База данных + +```mermaid +erDiagram + users ||--o| user_profiles : has + users ||--o{ refresh_tokens : owns + users ||--o{ password_reset_tokens : owns + users ||--o{ email_verification_tokens : owns +``` + +Таблицы: `users`, `user_profiles`, `refresh_tokens`, `password_reset_tokens`, `email_verification_tokens`, `content_pages`. + +```bash +cd apps/api && alembic upgrade head +``` + +CHECK constraints на `role`, `status`; superuser только при `role=admin`. Cleanup expired tokens при старте API. + +### Seed: стандартные логины (dev) + +| Email | Пароль | Env | +|-------|--------|-----| +| admin@compton.example | Admin1234 | `ADMIN_INITIAL_PASSWORD` | +| ops@compton.example | OpsAdmin1234 | `DEMO_OPS_PASSWORD` | +| user@compton.example | User1234 | `DEMO_USER_PASSWORD` | + +CMS: `about`, `privacy`, `terms`. Подробнее — [deploy.md § логины](./deploy.md#стандартные-логины-dev). + +## Переменные окружения (ключевые) + +| Переменная | Где | Назначение | +|------------|-----|------------| +| `DATABASE_URL` | api | PostgreSQL | +| `APP_ENV` | api | development / staging / production | +| `JWT_ACCESS_SECRET`, `JWT_REFRESH_PEPPER` | api | токены | +| `REDIS_URL` | api | rate limit + JWT revoke (prod обязателен) | +| `SEED_DEMO_USERS` | api | `false` на staging/prod | +| `ADMIN_INITIAL_PASSWORD` | api | пароль admin при seed (default `Admin1234`) | +| `DEMO_USER_PASSWORD`, `DEMO_OPS_PASSWORD` | api | demo user/ops (только dev) | +| `ENABLE_DOCS` | api | `false` на prod | +| `ENABLE_TEST_ROUTES` | api | `true` только E2E | +| `EMAIL_DELIVERY_MODE` | api | `memory` (dev) / `smtp` (prod) | +| `STORAGE_MODE` | api | `s3` / `memory` | +| `VITE_API_URL` | web | `http://api:8000` в Docker | +| `VITE_USE_API_PROXY` | web | `true` в dev | + +Полные примеры: `apps/api/.env.example`, `apps/api/.env.production.example`. + +### Runtime settings + +`apps/api/data/compton_settings.json` — toggles без секретов. Superuser: `GET/PATCH /admin/settings`. Env с тем же ключом = **lock** (нельзя менять из UI). + +## Docker Compose + +| Файл | Когда | +|------|-------| +| `docker-compose.yml` | локальная разработка | +| `docker-compose.dev-ports.yml` | PG/Redis/MinIO на хост (DBeaver) | +| `docker-compose.test.yml` | CI, Playwright | +| `infra/docker/docker-compose.staging.yml` | staging VPS | +| `infra/docker/docker-compose.prod.yml` | production VPS | + +## CI + +`.github/workflows/ci.yml`: lint → types → mypy → tests → audit → Bandit → gitleaks → E2E. + +## Тесты + +```bash +pnpm --filter web test:ci +cd apps/api && python -m pytest --cov=app --cov-fail-under=90 +pnpm --filter web e2e +``` + +E2E поднимает API `:8001` + Vite `:5175`. Против staging: `E2E_BASE_URL=… E2E_START_API=false`. diff --git a/docs/release-regression-checklist.md b/docs/release-regression-checklist.md deleted file mode 100644 index 4022e48..0000000 --- a/docs/release-regression-checklist.md +++ /dev/null @@ -1,14 +0,0 @@ -# MVP Regression Checklist - -This checklist mirrors the required release scenarios. - -1. Landing hero/marquee and reduced-motion behavior. -2. Register -> verify -> login -> profile edit -> logout. -3. Forgot password -> reset -> login. -4. Admin publish content -> public slug availability. -5. Admin blocks user -> blocked user login denied. -6. Pending user cannot access `/profile`. -7. Refresh token rotation and old token rejection. -8. IDOR check: user A cannot access user B. -9. Admin cannot demote/block self; last admin protected. -10. Avatar upload rejects invalid MIME/oversize/SVG. diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..99868d9 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,113 @@ +# Релиз и QA gates + +Перед production не «авось прокатит», а чеклист из ТЗ §17. Если что-то красное — сначала staging, потом prod. Живёт один раз. + +## Pipeline + +```mermaid +flowchart TD + CI[CI green] --> ST[Staging + TLS] + ST --> E2E[E2E 11/11] + ST --> K6[k6 pass] + ST --> ZAP[ZAP 0 High/Crit] + ST --> LH[Lighthouse ≥ 85] + E2E --> PROD[Production deploy] + K6 --> PROD + ZAP --> PROD + LH --> PROD + PROD --> SM[smoke-prod.sh] + SM --> DNS[DNS cutover] + DNS --> MON[24h мониторинг] +``` + +## Регрессия E2E + +**Последний локальный прогон:** 2026-07-14 — backend 137 / 90.43% cov, frontend 46. + +### Staging + +```bash +E2E_BASE_URL=https://staging.example.com E2E_START_API=false pnpm --filter web e2e +``` + +| # | Сценарий | Local | Staging | +|---|----------|:-----:|:-------:| +| 1 | Landing hero, reduced-motion | ☐ | ☐ | +| 2 | Register → verify → login → profile → logout | ☐ | ☐ | +| 3 | Forgot → reset → login | ☐ | ☐ | +| 4 | Admin publish → public slug | ☐ | ☐ | +| 5 | Block user → login denied | ☐ | ☐ | +| 6 | Pending → нет `/profile` | ☐ | ☐ | +| 7 | Refresh rotation | ☐ | ☐ | +| 8 | IDOR user A ≠ user B | ☐ | ☐ | +| 9 | Admin не блокирует себя / last admin | ☐ | ☐ | +| 10 | Avatar: bad MIME / size / SVG | ☐ | ☐ | +| 11 | Block → access JWT 401 TOKEN_REVOKED | ☐ | ☐ | + +### Локально + +```bash +pnpm --filter web e2e # API :8001, Vite :5175 +``` + +## k6 (§17.2) + +```bash +k6 run infra/k6/mvp-load-test.js -e BASE_URL=https://staging.example.com +``` + +| Параметр | Порог | +|----------|-------| +| VU / ramp | 50 / 5 min | +| Mix | 35% list, 25% login, 20% me, 10% refresh, 10% slug | +| p95 | < 300 ms | +| Errors | < 1% | + +## OWASP ZAP + +```bash +docker run --rm -v "$(pwd):/zap/wrk:rw" -t ghcr.io/zap/zaproxy:stable \ + zap-baseline.py -t https://staging.example.com -r zap-report.html +``` + +| Severity | Pass | +|----------|------| +| High, Critical | **0** | + +Medium/Low — review руками. `zap-report.html` — в архив релиза. + +## Lighthouse + +```bash +npx lighthouse https://staging.example.com \ + --preset=mobile --only-categories=performance \ + --output=json --output-path=./lighthouse-report.json +``` + +| Метрика | ≥ | +|---------|---| +| Performance (mobile, `/`) | 85 | + +Не прошло — hero video `preload="none"`, font swap, меньше JS на лендинге. + +## Release gates (сводка) + +- [ ] CI green на `main` +- [ ] E2E 11/11 на staging +- [ ] k6 pass +- [ ] ZAP 0 High/Critical +- [ ] Lighthouse ≥ 85 +- [ ] [security.md](./security.md) staging-пункты +- [ ] `./infra/scripts/smoke-prod.sh` на prod +- [ ] DNS cutover + rollback plan (previous tag) + +## CI локально + +```bash +pnpm lint && pnpm typecheck +cd apps/api && python -m mypy app +pnpm --filter web test:ci +cd apps/api && python -m pytest --cov=app --cov-fail-under=90 +``` + +Полный pipeline: `.github/workflows/ci.yml`. diff --git a/docs/secrets-recovery.md b/docs/secrets-recovery.md deleted file mode 100644 index 60a90aa..0000000 --- a/docs/secrets-recovery.md +++ /dev/null @@ -1,30 +0,0 @@ -# Install Secrets Recovery - -This project keeps runtime installation secrets in `apps/api/data/secrets/install.env`. - -## Important - -- Do not rotate `POSTGRES_PASSWORD`, `JWT_ACCESS_SECRET`, or `JWT_REFRESH_PEPPER` automatically after first bootstrap. -- A mismatch between `install.env` and initialized Postgres volume can break database access. - -## Safe recovery steps - -1. Stop services: - - `docker compose down` -2. Restore `apps/api/data/secrets/install.env` from backup. -3. Start services: - - `docker compose up -d --build` - -If backup is unavailable, you have two options: - -- Preferred: recover credentials directly from running database/admin secret reveal in another environment. -- Last resort: reset local volumes and lose local dev data: - - `docker compose down -v` - - `python apps/api/scripts/bootstrap_install.py` - - `docker compose up -d --build` - -## Dev access ports - -To expose DB/Redis/MinIO to host tools: - -- `docker compose -f docker-compose.yml -f docker-compose.dev-ports.yml up -d` diff --git a/docs/security-checklist.md b/docs/security-checklist.md deleted file mode 100644 index 6edf7e4..0000000 --- a/docs/security-checklist.md +++ /dev/null @@ -1,20 +0,0 @@ -# Security Checklist (MVP Pre-Production) - -- [x] Access JWT is memory-only in frontend state (no sessionStorage/localStorage persistence). -- [x] Refresh token is HttpOnly/Secure/SameSite cookie on `/api/v1/auth` path. -- [x] Auth endpoints implemented with neutral anti-enumeration messaging. -- [x] Origin/Referer validation is enforced for auth endpoints. -- [x] Authenticated/admin route guards and role checks are enforced, including `SUPERUSER_ONLY` checks for critical endpoints. -- [x] Content sanitization is enabled for CMS HTML body. -- [x] Security headers configured in `infra/nginx/default.conf`. -- [x] CI includes dependency audit, Bandit, and gitleaks scans. -- [x] Settings runtime supports `data/compton_settings.json` with env lock behavior. -- [x] Admin audit feed is persisted to `data/logs/admin-audit.jsonl`. -- [x] Install secrets bootstrap is enabled (`apps/api/data/secrets/install.env`) and locked after first run. -- [x] Database, Redis and MinIO are internal by default in base docker compose. -- [x] `refresh` validates user status and rate limit is checked before token rotation. -- [x] CMS sanitization enforces allowed URL protocols (`http`, `https`, `mailto`). -- [x] Admin password create/reset uses shared password policy validators. -- [ ] Production docs endpoint switch (`ENABLE_DOCS=false`) validated in staging/prod env. -- [ ] HSTS behavior validated behind TLS ingress in staging/prod. -- [ ] Recovery runbook for lost `install.env` tested (`docs/secrets-recovery.md`). diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..0e9bbb5 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,173 @@ +# Безопасность + +Compton MVP — не банк, но и не «admin/admin в prod». Ниже — как устроена защита и что проверить перед выкладкой. + +## Auth: схема + +```mermaid +sequenceDiagram + participant B as Браузер + participant API as FastAPI + participant RD as Redis + participant PG as PostgreSQL + + B->>API: POST /auth/login + API->>PG: bcrypt verify + API->>RD: read auth_epoch + API-->>B: access JWT (memory) + refresh cookie + + B->>API: GET /users/me + Bearer + API->>RD: jti denied? epoch ok? + alt revoked + API-->>B: 401 TOKEN_REVOKED + else ok + API-->>B: 200 + end + + B->>API: POST /auth/logout + API->>RD: deny_jti + revoke refresh + API-->>B: cookie cleared +``` + +## Токены + +| Токен | Где живёт | Отзыв | +|-------|-----------|-------| +| Access JWT | память frontend | jti denylist + auth_epoch (Redis) | +| Refresh | HttpOnly cookie | rotation + family reuse detection | +| Email/reset | opaque → hash в БД | one-time, TTL 1ч | + +JWT claims: `sub`, `role`, `jti`, `auth_epoch`, `exp`. + +### Мгновенный revoke + +`apps/api/app/core/jwt_denylist.py`: + +| Redis key | Смысл | +|-----------|-------| +| `jwt:deny:{jti}` | конкретный access-токен | +| `auth:epoch:{user_id}` | версия сессий пользователя | + +| Событие | Действие | +|---------|----------| +| Logout | deny jti + revoke refresh | +| Block | INCR epoch + revoke refresh | +| Смена/reset пароля | INCR epoch + revoke refresh | + +**Production:** без Redis API не стартует. Redis упал — fail-closed (401, не «ну ладно»). + +**Dev:** in-memory fallback (не путать с prod). + +## Криптография + +Всё через `apps/api/app/core/crypto.py`: + +| Данные | Метод | +|--------|-------| +| Пароли | bcrypt cost 12 | +| Access JWT | HS256 | +| Refresh/email tokens | SHA-256 + pepper | +| Media URLs | HMAC-SHA256 + TTL | +| Install secrets | `secrets.token_*`, generate-once + lock | + +## Install secrets + +```bash +python apps/api/scripts/bootstrap_install.py # до первого docker compose up +``` + +| Файл | Содержимое | +|------|------------| +| `data/secrets/install.env` | PG, JWT, S3, MinIO | +| `install.meta.json` | install ID, lock time | + +**Не ротировать** `POSTGRES_PASSWORD` / JWT после bootstrap без плана — иначе Postgres скажет фразу, которую вы уже видели, и будет прав. + +Reveal: Admin → Security → Install Secrets (superuser, аудит в `admin-audit.jsonl`). + +Восстановление: [deploy.md § восстановление](./deploy.md#восстановление-секретов). + +## RBAC + +| Правило | Enforcement | +|---------|-------------| +| `/admin` → role=admin | Guard + API | +| Security/Diagnostics/secrets → superuser | API + UI tabs | +| Нельзя block/demote себя | admin service | +| Last admin protected | admin service | +| blocked/pending → 403 refresh | auth service | +| forgot_password skip для blocked | auth service | +| IDOR на профиль | users router | + +## HTTP / инфра + +### Nginx headers + +| Header | Значение | +|--------|----------| +| X-Frame-Options | DENY | +| X-Content-Type-Options | nosniff | +| Referrer-Policy | strict-origin-when-cross-origin | +| HSTS | `default.tls.conf` (staging/prod) | + +### Прочее + +- Origin/Referer на cookie-auth endpoints +- Rate limit (prod: обязателен) +- CMS: bleach, протоколы http/https/mailto +- Avatar: jpeg/png/webp, re-encode, **SVG — нет** +- CI: Bandit, pip-audit, gitleaks, npm audit +- Postgres/Redis/MinIO — internal network +- Firewall VPS: 22, 80, 443 + +## Production guards + +`APP_ENV=production` → API **не стартует**, если: + +| Проблема | Env | +|----------|-----| +| Test routes | `ENABLE_TEST_ROUTES=true` | +| OpenAPI | `ENABLE_DOCS=true` | +| Rate limit off | `ENABLE_RATE_LIMIT=false` | +| Cookie без Secure | `COOKIE_SECURE=false` | +| Placeholder JWT | `change-me-*` | +| Дефолтная БД | user:pass | +| Без SSL mode | нет `sslmode=require` | +| Без Redis | JWT revocation | + +## Prod env (минимум) + +| Переменная | Значение | +|------------|----------| +| `APP_ENV` | production | +| `ENABLE_DOCS` | false | +| `ENABLE_TEST_ROUTES` | false | +| `COOKIE_SECURE` | true | +| `ENABLE_RATE_LIMIT` | true | +| `EMAIL_DELIVERY_MODE` | smtp | +| `SEED_DEMO_USERS` | false | +| `DATABASE_URL` | …?sslmode=require | + +## Чеклист + +### Сделано в коде + +- [x] JWT в memory, refresh HttpOnly +- [x] Anti-enumeration auth +- [x] Origin/Referer validation +- [x] RBAC + superuser +- [x] CMS sanitization +- [x] Nginx security headers +- [x] CI security scans +- [x] Install secrets bootstrap + lock +- [x] JWT jti denylist + auth_epoch +- [x] Admin audit log + +### Проверить на staging + +- [ ] `/api/v1/docs` → 404 +- [ ] HSTS за TLS +- [ ] Dry-run recovery секретов +- [ ] ZAP: 0 High/Critical → [release.md](./release.md) +- [ ] k6 pass → [release.md](./release.md) +- [ ] Lighthouse ≥ 85 на `/` diff --git a/infra/docker/.env.production.example b/infra/docker/.env.production.example new file mode 100644 index 0000000..fddd69e --- /dev/null +++ b/infra/docker/.env.production.example @@ -0,0 +1,13 @@ +# Copy to infra/docker/.env.production and fill in production values. +FRONTEND_URL=https://compton.example.com +PUBLIC_BASE_URL=https://compton.example.com +CORS_ORIGINS=["https://compton.example.com"] +TRUSTED_PROXY_IPS=172.16.0.0/12,10.0.0.0/8 +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM=noreply@compton.example.com +TLS_CERT_DIR=./certs +ADMIN_INITIAL_PASSWORD=ChangeMeProductionAdmin1234 +SEED_DEMO_USERS=false diff --git a/infra/docker/.env.staging.example b/infra/docker/.env.staging.example new file mode 100644 index 0000000..5a478dc --- /dev/null +++ b/infra/docker/.env.staging.example @@ -0,0 +1,12 @@ +# Copy to infra/docker/.env.staging and fill in domain/SMTP values. +FRONTEND_URL=https://staging.example.com +PUBLIC_BASE_URL=https://staging.example.com +CORS_ORIGINS=["https://staging.example.com"] +TRUSTED_PROXY_IPS=172.16.0.0/12,10.0.0.0/8 +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM=noreply@example.com +TLS_CERT_DIR=./certs +ADMIN_INITIAL_PASSWORD=ChangeMeStagingAdmin1234 diff --git a/infra/docker/README.md b/infra/docker/README.md index 4110c8e..832b174 100644 --- a/infra/docker/README.md +++ b/infra/docker/README.md @@ -1,4 +1,31 @@ -# Docker notes +# Docker deployment -`docker-compose.yml` is for local development. -`docker-compose.test.yml` is for CI-like integration and e2e testing. +Краткая справка. Подробный runbook: [docs/deploy.md](../../docs/deploy.md). + +| Файл | Среда | +|------|-------| +| [`../../docker-compose.yml`](../../docker-compose.yml) | Локальная разработка | +| [`../../docker-compose.test.yml`](../../docker-compose.test.yml) | CI / E2E | +| [`docker-compose.staging.yml`](docker-compose.staging.yml) | Staging VPS | +| [`docker-compose.prod.yml`](docker-compose.prod.yml) | Production VPS | + +## Staging + +```bash +cp infra/docker/.env.staging.example infra/docker/.env.staging +# TLS: infra/docker/certs/fullchain.pem, privkey.pem +python3 apps/api/scripts/bootstrap_install.py +./infra/docker/deploy-staging.sh +``` + +## Production + +```bash +cp infra/docker/.env.production.example infra/docker/.env.production +python3 apps/api/scripts/bootstrap_install.py +./infra/docker/deploy-prod.sh infra/docker/.env.production +``` + +## QA на staging + +См. [docs/release.md](../../docs/release.md): E2E, k6, ZAP, Lighthouse. diff --git a/infra/docker/deploy-prod.sh b/infra/docker/deploy-prod.sh new file mode 100755 index 0000000..5b0bcb3 --- /dev/null +++ b/infra/docker/deploy-prod.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +ENV_FILE="${1:-infra/docker/.env.production}" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing $ENV_FILE — copy infra/docker/.env.production.example first." + exit 1 +fi + +if [[ ! -f apps/api/data/secrets/install.env ]]; then + python3 apps/api/scripts/bootstrap_install.py +fi + +docker compose \ + -f infra/docker/docker-compose.prod.yml \ + --env-file "$ENV_FILE" \ + up -d --build + +echo "Production deploy complete." diff --git a/infra/docker/deploy-staging.sh b/infra/docker/deploy-staging.sh new file mode 100755 index 0000000..f8ae60f --- /dev/null +++ b/infra/docker/deploy-staging.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +ENV_FILE="${1:-infra/docker/.env.staging}" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing $ENV_FILE — copy infra/docker/.env.staging.example first." + exit 1 +fi + +if [[ ! -f apps/api/data/secrets/install.env ]]; then + python3 apps/api/scripts/bootstrap_install.py +fi + +docker compose \ + -f infra/docker/docker-compose.staging.yml \ + --env-file "$ENV_FILE" \ + up -d --build + +echo "Staging deploy complete. Verify: curl -k https://\$(grep FRONTEND_URL $ENV_FILE | cut -d= -f2)/api/v1/health" diff --git a/infra/docker/docker-compose.prod.yml b/infra/docker/docker-compose.prod.yml new file mode 100644 index 0000000..e882a34 --- /dev/null +++ b/infra/docker/docker-compose.prod.yml @@ -0,0 +1,99 @@ +# Production stack — VPS deploy. +# Usage: +# python apps/api/scripts/bootstrap_install.py +# cp infra/docker/.env.production.example infra/docker/.env.production +# docker compose -f infra/docker/docker-compose.prod.yml --env-file infra/docker/.env.production up -d --build + +services: + postgres: + image: postgres:16 + restart: unless-stopped + env_file: + - ../../apps/api/data/secrets/install.env + volumes: + - prod_postgres_data:/var/lib/postgresql/data + networks: + - internal + + redis: + image: redis:7-alpine + restart: unless-stopped + networks: + - internal + + minio: + image: minio/minio + restart: unless-stopped + command: server /data --console-address ":9001" + env_file: + - ../../apps/api/data/secrets/install.env + volumes: + - prod_minio_data:/data + networks: + - internal + + api: + build: + context: ../../apps/api + dockerfile: Dockerfile + restart: unless-stopped + environment: + APP_ENV: production + ENABLE_DOCS: "false" + ENABLE_TEST_ROUTES: "false" + COOKIE_SECURE: "true" + ENABLE_RATE_LIMIT: "true" + EMAIL_DELIVERY_MODE: smtp + STORAGE_MODE: s3 + S3_ENDPOINT: http://minio:9000 + REDIS_URL: redis://redis:6379/0 + SEED_DEMO_USERS: "false" + env_file: + - ../../apps/api/data/secrets/install.env + - .env.production + volumes: + - ../../apps/api/data/secrets:/app/data/secrets + - prod_api_logs:/app/data/logs + depends_on: + - postgres + - redis + - minio + networks: + - internal + + web: + build: + context: ../.. + dockerfile: apps/web/Dockerfile + restart: unless-stopped + environment: + VITE_USE_API_PROXY: "true" + VITE_API_URL: http://api:8000 + depends_on: + - api + networks: + - internal + + nginx: + image: nginx:stable-alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ../nginx/default.tls.conf:/etc/nginx/conf.d/default.conf:ro + - ${TLS_CERT_DIR:-./certs}:/etc/nginx/certs:ro + depends_on: + - web + - api + networks: + - internal + +networks: + internal: + driver: bridge + +volumes: + prod_postgres_data: + prod_minio_data: + prod_api_logs: diff --git a/infra/docker/docker-compose.staging.yml b/infra/docker/docker-compose.staging.yml index 8566b23..7735259 100644 --- a/infra/docker/docker-compose.staging.yml +++ b/infra/docker/docker-compose.staging.yml @@ -1,9 +1,83 @@ +# Staging stack — VPS deploy (no bind mounts, production-like settings). +# Usage: +# cp infra/docker/.env.staging.example infra/docker/.env.staging +# docker compose -f infra/docker/docker-compose.staging.yml --env-file infra/docker/.env.staging up -d --build + services: - web: - image: compton/web:staging + postgres: + image: postgres:16 + restart: unless-stopped + env_file: + - ../../apps/api/data/secrets/install.env + volumes: + - staging_postgres_data:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + restart: unless-stopped + + minio: + image: minio/minio + restart: unless-stopped + command: server /data --console-address ":9001" + env_file: + - ../../apps/api/data/secrets/install.env + volumes: + - staging_minio_data:/data + api: - image: compton/api:staging + build: + context: ../../apps/api + dockerfile: Dockerfile + restart: unless-stopped + environment: + APP_ENV: staging + ENABLE_DOCS: "false" + ENABLE_TEST_ROUTES: "false" + COOKIE_SECURE: "true" + ENABLE_RATE_LIMIT: "true" + EMAIL_DELIVERY_MODE: smtp + STORAGE_MODE: s3 + S3_ENDPOINT: http://minio:9000 + REDIS_URL: redis://redis:6379/0 + SEED_DEMO_USERS: "false" + env_file: + - ../../apps/api/.env.example + - ../../apps/api/data/secrets/install.env + - .env.staging + volumes: + - ../../apps/api/data/secrets:/app/data/secrets + - staging_api_logs:/app/data/logs + depends_on: + - postgres + - redis + - minio + + web: + build: + context: ../.. + dockerfile: apps/web/Dockerfile + restart: unless-stopped + environment: + VITE_USE_API_PROXY: "true" + VITE_API_URL: http://api:8000 + depends_on: + - api + nginx: image: nginx:stable-alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" volumes: - - ../nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + - ../nginx/default.tls.conf:/etc/nginx/conf.d/default.conf:ro + - ${TLS_CERT_DIR:-./certs}:/etc/nginx/certs:ro + depends_on: + - web + - api + +volumes: + staging_postgres_data: + staging_minio_data: + staging_api_logs: diff --git a/infra/k6/mvp-load-test.js b/infra/k6/mvp-load-test.js index b027bbb..f4c89b8 100644 --- a/infra/k6/mvp-load-test.js +++ b/infra/k6/mvp-load-test.js @@ -1,6 +1,10 @@ import http from "k6/http"; import { check, sleep } from "k6"; +const BASE_URL = __ENV.BASE_URL || "http://localhost:8000"; +const ADMIN_EMAIL = __ENV.ADMIN_EMAIL || "admin@compton.example"; +const ADMIN_PASSWORD = __ENV.ADMIN_PASSWORD || "Admin1234"; + export const options = { stages: [ { duration: "1m", target: 10 }, @@ -14,7 +18,41 @@ export const options = { }; export default function () { - const contentList = http.get("http://localhost:8000/api/v1/content/pages"); - check(contentList, { "content list is 200": (r) => r.status === 200 }); + const roll = Math.random(); + + if (roll < 0.35) { + const res = http.get(`${BASE_URL}/api/v1/content/pages`); + check(res, { "content list 200": (r) => r.status === 200 }); + } else if (roll < 0.6) { + const res = http.post( + `${BASE_URL}/api/v1/auth/login`, + JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }), + { headers: { "Content-Type": "application/json" } } + ); + check(res, { "login 200": (r) => r.status === 200 }); + if (res.status === 200) { + const token = res.json("access_token"); + const me = http.get(`${BASE_URL}/api/v1/users/me`, { + headers: { Authorization: `Bearer ${token}` } + }); + check(me, { "users me 200": (r) => r.status === 200 }); + } + } else if (roll < 0.7) { + const login = http.post( + `${BASE_URL}/api/v1/auth/login`, + JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }), + { headers: { "Content-Type": "application/json" } } + ); + if (login.status === 200) { + const refresh = http.post(`${BASE_URL}/api/v1/auth/refresh`, null, { + headers: { Cookie: login.headers["Set-Cookie"] || "" } + }); + check(refresh, { "refresh ok": (r) => r.status === 200 || r.status === 401 }); + } + } else { + const res = http.get(`${BASE_URL}/api/v1/content/pages/about`); + check(res, { "content slug": (r) => r.status === 200 || r.status === 404 }); + } + sleep(1); } diff --git a/infra/nginx/default.tls.conf b/infra/nginx/default.tls.conf new file mode 100644 index 0000000..3ebe000 --- /dev/null +++ b/infra/nginx/default.tls.conf @@ -0,0 +1,39 @@ +server { + listen 80; + server_name _; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name _; + + ssl_certificate /etc/nginx/certs/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/privkey.pem; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always; + + location /api/ { + proxy_pass http://api:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + proxy_pass http://web:5173; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} diff --git a/infra/scripts/backup-postgres.sh b/infra/scripts/backup-postgres.sh new file mode 100755 index 0000000..8f5991e --- /dev/null +++ b/infra/scripts/backup-postgres.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +BACKUP_DIR="$ROOT/backups" +mkdir -p "$BACKUP_DIR" + +STAMP="$(date +%F-%H%M)" +FILE="$BACKUP_DIR/postgres-$STAMP.sql.gz" + +docker compose -f "$ROOT/infra/docker/docker-compose.prod.yml" exec -T postgres \ + pg_dump -U "${POSTGRES_USER:-compton_app}" "${POSTGRES_DB:-compton}" | gzip > "$FILE" + +echo "Backup written: $FILE" diff --git a/infra/scripts/health-check.sh b/infra/scripts/health-check.sh new file mode 100755 index 0000000..59c0573 --- /dev/null +++ b/infra/scripts/health-check.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE="${1:-http://localhost:8000}" +BASE="${BASE%/}" + +fail() { + echo "FAIL: $1" + exit 1 +} + +code="$(curl -fsS -o /dev/null -w "%{http_code}" "$BASE/api/v1/health" || echo 000)" +[[ "$code" == "200" ]] || fail "health returned $code" + +root_code="$(curl -fsS -o /dev/null -w "%{http_code}" "${BASE%/api/v1/health}/" 2>/dev/null || curl -fsS -o /dev/null -w "%{http_code}" "$(echo "$BASE" | sed 's|:8000||')/" || echo 000)" +echo "Landing/root HTTP: $root_code" + +echo "OK: health check passed" diff --git a/infra/scripts/smoke-prod.sh b/infra/scripts/smoke-prod.sh new file mode 100755 index 0000000..36dbf68 --- /dev/null +++ b/infra/scripts/smoke-prod.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE="${1:-http://127.0.0.1:8000}" +BASE="${BASE%/}" + +echo "== Smoke: health ==" +curl -fsS "$BASE/api/v1/health" | grep -q '"status":"ok"' + +echo "== Smoke: docs disabled ==" +docs_code="$(curl -s -o /dev/null -w "%{http_code}" "$BASE/api/v1/docs")" +[[ "$docs_code" == "404" || "$docs_code" == "307" ]] || { echo "Expected docs 404, got $docs_code"; exit 1; } + +echo "== Smoke: public content ==" +curl -fsS "$BASE/api/v1/content/pages" | grep -q '"data"' + +echo "All smoke checks passed."