Шхуна не тонет: security, infra и доки на русском.
Безопасность довёл до ума — 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 Код готов к плаванию. Капитан может идти писать фронт.
This commit is contained in:
@@ -17,3 +17,6 @@ apps/api/.e2e.sqlite
|
|||||||
apps/api/.e2e-test.sqlite
|
apps/api/.e2e-test.sqlite
|
||||||
apps/api/data/secrets/install.env
|
apps/api/data/secrets/install.env
|
||||||
apps/api/data/secrets/install.meta.json
|
apps/api/data/secrets/install.meta.json
|
||||||
|
data/secrets/
|
||||||
|
apps/api/data/logs/
|
||||||
|
apps/api/data/compton_settings.json
|
||||||
|
|||||||
@@ -1,426 +1,112 @@
|
|||||||
# Compton Platform
|
# 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
|
```bash
|
||||||
# 1. Bootstrap install secrets — REQUIRED before the first docker compose up
|
python apps/api/scripts/bootstrap_install.py # один раз, до первого up
|
||||||
python apps/api/scripts/bootstrap_install.py
|
|
||||||
|
|
||||||
# 2. Build and start everything
|
|
||||||
docker compose --profile docker-web up -d --build
|
docker compose --profile docker-web up -d --build
|
||||||
|
curl http://localhost:8000/api/v1/health # Windows: curl.exe
|
||||||
# 3. Verify (Windows PowerShell: use curl.exe, not curl — it is an alias for Invoke-WebRequest)
|
|
||||||
curl.exe http://localhost:8000/api/v1/health
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
```bash
|
||||||
cp apps/api/.env.example apps/api/.env
|
pnpm --filter web test:ci # frontend, cov ≥ 85%
|
||||||
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%)
|
|
||||||
cd apps/api && python -m pytest --cov=app --cov-fail-under=90
|
cd apps/api && python -m pytest --cov=app --cov-fail-under=90
|
||||||
|
pnpm --filter web e2e # Playwright, API :8001
|
||||||
# E2E regression (Playwright §15.7, 16 critical scenarios)
|
|
||||||
pnpm --filter web e2e
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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`
|
Вся нормальная дока — в **`docs/`**, на русском, без водянистого README на 500 строк:
|
||||||
- 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`
|
|
||||||
|
|
||||||
## 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 |
|
## Работа с Git
|
||||||
| ------- | -------- |
|
|
||||||
| **`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 |
|
|
||||||
|
|
||||||
## 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 |
|
**Коммиты — только на русском.** Плохо: `fix`, `WIP`, `asdf`.
|
||||||
| ----- | --------- |
|
|
||||||
| **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) |
|
|
||||||
|
|
||||||
**Auth & API**
|
**Не коммитить:** `.env`, `install.env`, `node_modules/`, логи, кэши.
|
||||||
|
|
||||||
- Refresh token rotation and reuse-detection (family revoke)
|
**Не делать** `git push --force` на `main` без согласования.
|
||||||
- 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)
|
|
||||||
|
|
||||||
**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)
|
## План по ТЗ (§16)
|
||||||
- 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
|
|
||||||
|
|
||||||
**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`
|
### Фаза 1 — детализация
|
||||||
- `ENABLE_DOCS=true`
|
|
||||||
- `ENABLE_RATE_LIMIT=false`
|
|
||||||
- `COOKIE_SECURE=false`
|
|
||||||
- Default DB credentials (`user:pass`) or placeholder JWT secrets
|
|
||||||
- `DATABASE_URL` without `sslmode=require`
|
|
||||||
|
|
||||||
## 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
|
Критерии приёмки MVP — §17.1 ТЗ (k6 50 CCU, Lighthouse ≥85, E2E 10/10, coverage gates).
|
||||||
- [docs/security-checklist.md](docs/security-checklist.md) — pre-production security checklist
|
|
||||||
- [docs/TZ.md](docs/TZ.md) — full technical specification
|
|
||||||
|
|
||||||
## MVP Status (Phase 1)
|
---
|
||||||
|
|
||||||
| # | Feature | Status |
|
*Дальше — [docs/](docs/README.md). Там схемы, таблицы и вещи, которые не помещаются в README без страданий.*
|
||||||
| --- | ----------------------------- | ------ |
|
|
||||||
| 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 |
|
|
||||||
|
|||||||
@@ -40,3 +40,4 @@ DEMO_USER_PASSWORD=User1234
|
|||||||
DEMO_OPS_PASSWORD=OpsAdmin1234
|
DEMO_OPS_PASSWORD=OpsAdmin1234
|
||||||
# E2E only, never enable in production.
|
# E2E only, never enable in production.
|
||||||
ENABLE_TEST_ROUTES=false
|
ENABLE_TEST_ROUTES=false
|
||||||
|
SEED_DEMO_USERS=true
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -23,6 +23,7 @@ class Settings(BaseSettings):
|
|||||||
admin_initial_password: str = "Admin1234"
|
admin_initial_password: str = "Admin1234"
|
||||||
demo_user_password: str = "User1234"
|
demo_user_password: str = "User1234"
|
||||||
demo_ops_password: str = "OpsAdmin1234"
|
demo_ops_password: str = "OpsAdmin1234"
|
||||||
|
seed_demo_users: bool = True
|
||||||
smtp_host: str = "localhost"
|
smtp_host: str = "localhost"
|
||||||
smtp_port: int = 1025
|
smtp_port: int = 1025
|
||||||
smtp_user: str = ""
|
smtp_user: str = ""
|
||||||
|
|||||||
@@ -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:
|
def create_access_token(user_id: str, role: str, is_superuser: bool = False) -> str:
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.jwt_denylist import get_auth_epoch
|
||||||
|
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
payload = {
|
payload = {
|
||||||
"sub": user_id,
|
"sub": user_id,
|
||||||
"role": role,
|
"role": role,
|
||||||
"is_superuser": is_superuser,
|
"is_superuser": is_superuser,
|
||||||
|
"auth_epoch": get_auth_epoch(user_id),
|
||||||
"iat": int(now.timestamp()),
|
"iat": int(now.timestamp()),
|
||||||
"exp": int((now + timedelta(minutes=settings.jwt_access_ttl_min)).timestamp()),
|
"exp": int((now + timedelta(minutes=settings.jwt_access_ttl_min)).timestamp()),
|
||||||
"jti": generate_secret_token_hex(16),
|
"jti": generate_secret_token_hex(16),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
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.core.security import decode_access_token
|
||||||
from app.modules.users.repository import get_user_by_id
|
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")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="UNAUTHORIZED")
|
||||||
try:
|
try:
|
||||||
payload = decode_access_token(credentials.credentials)
|
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
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_TOKEN") from exc
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_TOKEN") from exc
|
||||||
user = get_user_by_id(payload["sub"])
|
user = get_user_by_id(payload["sub"])
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ from uuid import uuid4
|
|||||||
|
|
||||||
from app.core.crypto import generate_install_bundle
|
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_FILE = INSTALL_SECRETS_DIR / "install.env"
|
||||||
INSTALL_SECRETS_META_FILE = INSTALL_SECRETS_DIR / "install.meta.json"
|
INSTALL_SECRETS_META_FILE = INSTALL_SECRETS_DIR / "install.meta.json"
|
||||||
REQUIRED_KEYS = (
|
REQUIRED_KEYS = (
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -50,6 +50,7 @@ def run_seed(include_demo_pages: bool = True) -> None:
|
|||||||
is_superuser=True,
|
is_superuser=True,
|
||||||
status="active",
|
status="active",
|
||||||
)
|
)
|
||||||
|
if settings.seed_demo_users and settings.app_env.lower() != "production":
|
||||||
_ensure_user(
|
_ensure_user(
|
||||||
email="user@compton.example",
|
email="user@compton.example",
|
||||||
password=settings.demo_user_password,
|
password=settings.demo_user_password,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
|
|
||||||
from app.core.install_secrets import ensure_install_secrets
|
from app.core.install_secrets import ensure_install_secrets
|
||||||
from app.core.config import settings
|
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.app_settings import bootstrap_settings
|
||||||
from app.core.storage import ensure_bucket
|
from app.core.storage import ensure_bucket
|
||||||
from app.core import database as db_module
|
from app.core import database as db_module
|
||||||
@@ -50,6 +51,7 @@ def create_app() -> FastAPI:
|
|||||||
async def lifespan(_: FastAPI):
|
async def lifespan(_: FastAPI):
|
||||||
ensure_install_secrets()
|
ensure_install_secrets()
|
||||||
bootstrap_settings()
|
bootstrap_settings()
|
||||||
|
ensure_jwt_revocation_backend()
|
||||||
_assert_production_guards()
|
_assert_production_guards()
|
||||||
if settings.enable_test_routes:
|
if settings.enable_test_routes:
|
||||||
Base.metadata.create_all(db_module.engine)
|
Base.metadata.create_all(db_module.engine)
|
||||||
|
|||||||
@@ -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.audit_log import read_audit_events, write_audit_event
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.install_secrets import install_secrets_payload, reveal_install_secret
|
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.core.security import hash_password
|
||||||
from app.modules.admin.security_diagnostics import build_security_diagnostics_report
|
from app.modules.admin.security_diagnostics import build_security_diagnostics_report
|
||||||
from app.modules.auth.service import revoke_user_refresh_family
|
from app.modules.auth.service import revoke_user_refresh_family
|
||||||
@@ -63,6 +64,7 @@ def patch_user(
|
|||||||
if status:
|
if status:
|
||||||
target.status = status
|
target.status = status
|
||||||
if status == "blocked":
|
if status == "blocked":
|
||||||
|
bump_auth_epoch(target.id)
|
||||||
revoke_user_refresh_family(target.id)
|
revoke_user_refresh_family(target.id)
|
||||||
repository.update_user(target)
|
repository.update_user(target)
|
||||||
write_audit_event(
|
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")
|
raise ValueError("USER_NOT_FOUND")
|
||||||
target.password_hash = hash_password(password)
|
target.password_hash = hash_password(password)
|
||||||
repository.update_user(target)
|
repository.update_user(target)
|
||||||
|
bump_auth_epoch(target.id)
|
||||||
revoke_user_refresh_family(target.id)
|
revoke_user_refresh_family(target.id)
|
||||||
write_audit_event(
|
write_audit_event(
|
||||||
action="admin.user.reset_password",
|
action="admin.user.reset_password",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from app.core.datetime_utils import ensure_utc, utc_now
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
from app.core.config import settings
|
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.core.redis import check_rate_limit, client_ip
|
||||||
from app.modules.auth.schemas import (
|
from app.modules.auth.schemas import (
|
||||||
ForgotPasswordIn,
|
ForgotPasswordIn,
|
||||||
@@ -15,16 +16,17 @@ from app.modules.auth.schemas import (
|
|||||||
from app.modules.auth.service import (
|
from app.modules.auth.service import (
|
||||||
forgot_password,
|
forgot_password,
|
||||||
login,
|
login,
|
||||||
|
logout,
|
||||||
refresh,
|
refresh,
|
||||||
register,
|
register,
|
||||||
resend_verification,
|
resend_verification,
|
||||||
reset_password,
|
reset_password,
|
||||||
revoke_refresh_token,
|
|
||||||
verify_email_token,
|
verify_email_token,
|
||||||
)
|
)
|
||||||
from app.modules.users import repository
|
from app.modules.users import repository
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
optional_bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
def _allowed_origins() -> set[str]:
|
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)
|
check_rate_limit(f"refresh:{client_ip(request)}", limit=30, window_seconds=60)
|
||||||
try:
|
try:
|
||||||
access_token, new_refresh, user = refresh(refresh_token)
|
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")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_REFRESH")
|
||||||
_set_refresh_cookie(response, new_refresh)
|
_set_refresh_cookie(response, new_refresh)
|
||||||
return {
|
return {
|
||||||
@@ -156,11 +163,15 @@ async def refresh_route(request: Request, response: Response):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/logout")
|
@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)
|
_enforce_origin(request, require_header=True)
|
||||||
refresh_token = request.cookies.get("refresh_token")
|
refresh_token = request.cookies.get("refresh_token")
|
||||||
if refresh_token:
|
access_token = credentials.credentials if credentials else None
|
||||||
revoke_refresh_token(refresh_token)
|
logout(refresh_token, access_token)
|
||||||
response.delete_cookie(
|
response.delete_cookie(
|
||||||
"refresh_token",
|
"refresh_token",
|
||||||
path="/api/v1/auth",
|
path="/api/v1/auth",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from uuid import uuid4
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.datetime_utils import ensure_utc, utc_now
|
from app.core.datetime_utils import ensure_utc, utc_now
|
||||||
from app.core.email import send_template_email
|
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 (
|
from app.core.security import (
|
||||||
create_access_token,
|
create_access_token,
|
||||||
generate_opaque_token,
|
generate_opaque_token,
|
||||||
@@ -111,7 +112,7 @@ def resend_verification(email: str) -> None:
|
|||||||
|
|
||||||
def forgot_password(email: str) -> None:
|
def forgot_password(email: str) -> None:
|
||||||
user = repository.get_user_by_email(email)
|
user = repository.get_user_by_email(email)
|
||||||
if not user:
|
if not user or user.status == "blocked":
|
||||||
return
|
return
|
||||||
token = _issue_password_reset_token(user.id)
|
token = _issue_password_reset_token(user.id)
|
||||||
_send_password_reset_email(user, token)
|
_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)
|
user.password_hash = hash_password(new_password)
|
||||||
repository.update_user(user)
|
repository.update_user(user)
|
||||||
auth_repository.mark_password_reset_token_used(token_hash)
|
auth_repository.mark_password_reset_token_used(token_hash)
|
||||||
|
bump_auth_epoch(user.id)
|
||||||
revoke_user_refresh_family(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)
|
token_row = auth_repository.get_refresh_token(token_hash)
|
||||||
if not token_row:
|
if not token_row:
|
||||||
raise PermissionError("INVALID_REFRESH")
|
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:
|
if token_row.revoked_at is not None:
|
||||||
auth_repository.revoke_family_tokens(token_row.family_id)
|
auth_repository.revoke_family_tokens(token_row.family_id)
|
||||||
raise PermissionError("INVALID_REFRESH")
|
raise PermissionError("INVALID_REFRESH")
|
||||||
if ensure_utc(token_row.expires_at) < utc_now():
|
if ensure_utc(token_row.expires_at) < utc_now():
|
||||||
raise PermissionError("EXPIRED_REFRESH")
|
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)
|
auth_repository.revoke_refresh_token(token_hash)
|
||||||
new_refresh = issue_refresh_token(user.id, family_id=token_row.family_id)
|
new_refresh = issue_refresh_token(user.id, family_id=token_row.family_id)
|
||||||
access = create_access_token(user.id, user.role, user.is_superuser)
|
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)
|
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:
|
def revoke_user_refresh_family(user_id: str) -> None:
|
||||||
auth_repository.revoke_user_families(user_id)
|
auth_repository.revoke_user_families(user_id)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from app.core.jwt_denylist import bump_auth_epoch
|
||||||
from app.core.security import hash_password, verify_password
|
from app.core.security import hash_password, verify_password
|
||||||
from app.modules.auth.service import revoke_user_refresh_family
|
from app.modules.auth.service import revoke_user_refresh_family
|
||||||
from app.modules.media.service import upload_user_avatar
|
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")
|
raise ValueError("INVALID_CURRENT_PASSWORD")
|
||||||
user.password_hash = hash_password(new_password)
|
user.password_hash = hash_password(new_password)
|
||||||
repository.update_user(user)
|
repository.update_user(user)
|
||||||
|
bump_auth_epoch(user.id)
|
||||||
revoke_user_refresh_family(user.id)
|
revoke_user_refresh_family(user.id)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from app.core.install_secrets import ensure_install_secrets
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
INITIAL_REVISION = "20260711_0001"
|
INITIAL_REVISION = "20260711_0001"
|
||||||
|
HEAD_REVISION = "20260714_0005"
|
||||||
SCHEMA_TABLES = (
|
SCHEMA_TABLES = (
|
||||||
"content_pages",
|
"content_pages",
|
||||||
"email_verification_tokens",
|
"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"
|
"Local dev fix — deletes Docker DB data:\n"
|
||||||
" docker compose --profile docker-web down -v\n"
|
" docker compose --profile docker-web down -v\n"
|
||||||
" docker compose --profile docker-web up -d --build\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
|
raise RuntimeError(f"Database is unavailable: {detail}{hint}") from last_error
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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():
|
def test_password_denylist_blocks_common_password():
|
||||||
assert is_denied_password("password123")
|
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")
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
@@ -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():
|
def test_ensure_bucket_noop_in_memory():
|
||||||
ensure_bucket()
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
deleted = client.delete(f"/api/v1/admin/users/{user_id}", headers=_admin_headers(client))
|
||||||
assert deleted.status_code == 200
|
assert deleted.status_code == 200
|
||||||
assert deleted.json()["status"] == "deleted"
|
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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
from unittest.mock import patch
|
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 import repository
|
||||||
from app.modules.users.repository import create_user, get_user_by_email
|
from app.modules.users.repository import create_user, get_user_by_email
|
||||||
from app.core.security import hash_password
|
from app.core.security import hash_password
|
||||||
@@ -31,3 +37,52 @@ def test_last_admin_protected():
|
|||||||
assert False, "Expected last-admin protection"
|
assert False, "Expected last-admin protection"
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
assert str(exc) == "LAST_ADMIN_PROTECTED"
|
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"] == []
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -12,6 +12,31 @@ def _admin_headers(client) -> dict[str, str]:
|
|||||||
return {"Authorization": f"Bearer {token}"}
|
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):
|
def test_refresh_fails_for_blocked_user(client):
|
||||||
register_and_verify(client, "blocked-refresh@example.com")
|
register_and_verify(client, "blocked-refresh@example.com")
|
||||||
login = client.post(
|
login = client.post(
|
||||||
@@ -32,7 +57,8 @@ def test_refresh_fails_for_blocked_user(client):
|
|||||||
)
|
)
|
||||||
assert blocked.status_code == 200
|
assert blocked.status_code == 200
|
||||||
refresh = client.post("/api/v1/auth/refresh", headers={"Origin": "http://localhost:5173"})
|
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):
|
def test_refresh_requires_origin_header_when_cookie_present(client):
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from sqlalchemy import create_engine, inspect
|
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):
|
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())
|
tables = set(inspect(engine).get_table_names())
|
||||||
assert "users" in tables
|
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):
|
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())
|
tables = set(inspect(engine).get_table_names())
|
||||||
assert "refresh_tokens" in tables
|
assert "refresh_tokens" in tables
|
||||||
assert current_revision(engine) == "20260714_0004"
|
assert current_revision(engine) == HEAD_REVISION
|
||||||
assert INITIAL_REVISION == "20260711_0001"
|
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())
|
tables = set(inspect(engine).get_table_names())
|
||||||
assert "refresh_tokens" in tables
|
assert "refresh_tokens" in tables
|
||||||
assert current_revision(engine) == "20260714_0004"
|
assert current_revision(engine) == HEAD_REVISION
|
||||||
|
|||||||
@@ -2,6 +2,25 @@ import { expect, test } from "@playwright/test";
|
|||||||
import { API_URL, adminLogin, loginViaUi, registerVerifyLogin, uniqueEmail } from "../helpers/api";
|
import { API_URL, adminLogin, loginViaUi, registerVerifyLogin, uniqueEmail } from "../helpers/api";
|
||||||
|
|
||||||
test.describe("§15.7 scenarios 5 & 9: Admin users", () => {
|
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 }) => {
|
test("admin blocks user → blocked user cannot login", async ({ page, request }) => {
|
||||||
const email = uniqueEmail("e2e-block");
|
const email = uniqueEmail("e2e-block");
|
||||||
const session = await registerVerifyLogin(request, email);
|
const session = await registerVerifyLogin(request, email);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { PropsWithChildren } from "react";
|
|||||||
import { useAuth } from "@modules/auth";
|
import { useAuth } from "@modules/auth";
|
||||||
import { useAuthStore } from "@modules/auth/store/authStore";
|
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 bootstrapped = useAuthStore((state) => state.bootstrapped);
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { PropsWithChildren } from "react";
|
|||||||
import { useAuth } from "@modules/auth";
|
import { useAuth } from "@modules/auth";
|
||||||
import { useAuthStore } from "@modules/auth/store/authStore";
|
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 bootstrapped = useAuthStore((state) => state.bootstrapped);
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { PropsWithChildren } from "react";
|
|||||||
import { useAuth } from "@modules/auth";
|
import { useAuth } from "@modules/auth";
|
||||||
import { useAuthStore } from "@modules/auth/store/authStore";
|
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 bootstrapped = useAuthStore((state) => state.bootstrapped);
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
|
||||||
|
|||||||
@@ -43,9 +43,21 @@ vi.mock("@shared/api/client", () => ({
|
|||||||
}
|
}
|
||||||
return { data: { data: [], meta: { total: 0, page: 1, limit: 20 } } };
|
return { data: { data: [], meta: { total: 0, page: 1, limit: 20 } } };
|
||||||
}),
|
}),
|
||||||
patch: vi.fn(async () => ({
|
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" }
|
data: { id: "1", email: "u@example.com", role: "user", is_superuser: false, status: "blocked" }
|
||||||
})),
|
};
|
||||||
|
}),
|
||||||
post: vi.fn(async (url: string) => {
|
post: vi.fn(async (url: string) => {
|
||||||
if (url === "/api/v1/admin/users") {
|
if (url === "/api/v1/admin/users") {
|
||||||
return { data: { id: "1", email: "new@example.com", role: "user", is_superuser: false, status: "active" } };
|
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 resetAdminUserPassword("1", "Valid123A")).status).toBe("blocked");
|
||||||
expect((await deleteAdminUser("1")).status).toBe("deleted");
|
expect((await deleteAdminUser("1")).status).toBe("deleted");
|
||||||
expect((await getAdminSettings()).settings_path).toBe("data/compton_settings.json");
|
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 getAdminDiagnostics()).checks[0].status).toBe("ok");
|
||||||
expect((await getAdminActivityFeed()).events.length).toBe(1);
|
expect((await getAdminActivityFeed()).events.length).toBe(1);
|
||||||
expect((await postAdminUiActivity("click")).status).toBe("ok");
|
expect((await postAdminUiActivity("click")).status).toBe("ok");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
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 { describe, expect, it, vi } from "vitest";
|
||||||
import { AdminStats } from "./AdminStats";
|
import { AdminStats } from "./AdminStats";
|
||||||
|
|
||||||
@@ -23,9 +23,14 @@ describe("AdminStats", () => {
|
|||||||
expect(await screen.findByText("CPU")).toBeInTheDocument();
|
expect(await screen.findByText("CPU")).toBeInTheDocument();
|
||||||
expect(await screen.findByText("WESP")).toBeInTheDocument();
|
expect(await screen.findByText("WESP")).toBeInTheDocument();
|
||||||
expect(await screen.findByText("nx throughput (instant)")).toBeInTheDocument();
|
expect(await screen.findByText("nx throughput (instant)")).toBeInTheDocument();
|
||||||
expect(await screen.findByText("users")).toBeInTheDocument();
|
expect(await screen.findByText("registrations today")).toBeInTheDocument();
|
||||||
expect(await screen.findByText("10")).toBeInTheDocument();
|
expect((await screen.findAllByText("users")).length).toBeGreaterThanOrEqual(1);
|
||||||
expect(await screen.findByText("3")).toBeInTheDocument();
|
|
||||||
expect(await screen.findByText("2")).toBeInTheDocument();
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { authClient } from "@shared/api/client";
|
import { authClient, applyAuthHeader } from "@shared/api/client";
|
||||||
|
|
||||||
export interface LoginPayload {
|
export interface LoginPayload {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -38,8 +38,9 @@ export async function register(payload: RegisterPayload) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logout() {
|
export async function logout(accessToken?: string | null) {
|
||||||
await authClient.post("/api/v1/auth/logout");
|
const headers = accessToken ? applyAuthHeader({}) : {};
|
||||||
|
await authClient.post("/api/v1/auth/logout", undefined, { headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refresh() {
|
export async function refresh() {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function useAuth() {
|
|||||||
return data.user as AuthUser;
|
return data.user as AuthUser;
|
||||||
},
|
},
|
||||||
async logout() {
|
async logout() {
|
||||||
await apiLogout();
|
await apiLogout(accessToken);
|
||||||
clearSession();
|
clearSession();
|
||||||
},
|
},
|
||||||
async refreshSession() {
|
async refreshSession() {
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ apiClient.interceptors.response.use(
|
|||||||
useAuthStore.getState().clearSession();
|
useAuthStore.getState().clearSession();
|
||||||
return Promise.reject(error);
|
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 };
|
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||||
if (error.response?.status !== 401 || !originalRequest || originalRequest._retry) {
|
if (error.response?.status !== 401 || !originalRequest || originalRequest._retry) {
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export default defineConfig({
|
|||||||
exclude: ["e2e/**", "node_modules/**"],
|
exclude: ["e2e/**", "node_modules/**"],
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
setupFiles: ["src/__tests__/setup.ts"],
|
setupFiles: ["src/__tests__/setup.ts"],
|
||||||
|
testTimeout: 10_000,
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: "v8",
|
provider: "v8",
|
||||||
include: ["src/**/*.{ts,tsx}"],
|
include: ["src/**/*.{ts,tsx}"],
|
||||||
|
|||||||
@@ -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 не спасает совесть.
|
||||||
+196
@@ -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`.
|
||||||
+220
@@ -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`.
|
||||||
@@ -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.
|
|
||||||
+113
@@ -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`.
|
||||||
@@ -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`
|
|
||||||
@@ -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`).
|
|
||||||
@@ -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 на `/`
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+30
-3
@@ -1,4 +1,31 @@
|
|||||||
# Docker notes
|
# Docker deployment
|
||||||
|
|
||||||
`docker-compose.yml` is for local development.
|
Краткая справка. Подробный runbook: [docs/deploy.md](../../docs/deploy.md).
|
||||||
`docker-compose.test.yml` is for CI-like integration and e2e testing.
|
|
||||||
|
| Файл | Среда |
|
||||||
|
|------|-------|
|
||||||
|
| [`../../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.
|
||||||
|
|||||||
Executable
+23
@@ -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."
|
||||||
Executable
+23
@@ -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"
|
||||||
@@ -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:
|
||||||
@@ -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:
|
services:
|
||||||
web:
|
postgres:
|
||||||
image: compton/web:staging
|
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:
|
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:
|
nginx:
|
||||||
image: nginx:stable-alpine
|
image: nginx:stable-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
volumes:
|
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:
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import http from "k6/http";
|
import http from "k6/http";
|
||||||
import { check, sleep } from "k6";
|
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 = {
|
export const options = {
|
||||||
stages: [
|
stages: [
|
||||||
{ duration: "1m", target: 10 },
|
{ duration: "1m", target: 10 },
|
||||||
@@ -14,7 +18,41 @@ export const options = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function () {
|
export default function () {
|
||||||
const contentList = http.get("http://localhost:8000/api/v1/content/pages");
|
const roll = Math.random();
|
||||||
check(contentList, { "content list is 200": (r) => r.status === 200 });
|
|
||||||
|
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);
|
sleep(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+14
@@ -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"
|
||||||
Executable
+18
@@ -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"
|
||||||
Executable
+17
@@ -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."
|
||||||
Reference in New Issue
Block a user