From a1110c8ecbcc514bcd7d41080753a2525d411eb6 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Wed, 29 Apr 2026 09:29:04 +0200 Subject: [PATCH] feat(safety+audit+deploy): consistency_check + audit log file sink + deploy script #2 Env switch safety: - mcp_common/environment.py: nuova consistency_check() che previene switch accidentali a mainnet. Solleva EnvironmentMismatchError se resolved=mainnet senza creds["environment"]="mainnet" esplicito, o se declared/resolved mismatch. Override via STRICT_MAINNET=false. - Wirato in app_factory.run_exchange_main al boot. - 6 nuovi test consistency. #3 Audit log persistence: - mcp_common/audit.py: TimedRotatingFileHandler aggiuntivo se env AUDIT_LOG_FILE settato. Rotation midnight UTC, retention 30gg default (AUDIT_LOG_BACKUP_DAYS). Format JSONL con SecretsFilter. - docker-compose.prod.yml: bind mount /var/log/cerbero-mcp + env AUDIT_LOG_FILE per i 4 servizi exchange (write endpoints). - 2 nuovi test file sink. #1 Deploy script: - scripts/deploy.sh: idempotente, fa docker login + clone/pull repo + copia secrets chmod 600 + crea .env + setup audit dir + pull image + up + smoke test pubblico HTTPS. - DEPLOYMENT.md aggiornato: sezioni 2 (script), 3 (safety mainnet), 4 (audit log query), renumber sezioni successive. Test: 488/488 verdi. Co-Authored-By: Claude Opus 4.7 (1M context) --- DEPLOYMENT.md | 113 +++++++++++- docker-compose.prod.yml | 6 + scripts/deploy.sh | 165 ++++++++++++++++++ services/common/src/mcp_common/app_factory.py | 11 +- services/common/src/mcp_common/audit.py | 61 ++++++- services/common/src/mcp_common/environment.py | 69 ++++++++ services/common/tests/test_app_factory.py | 33 +++- services/common/tests/test_audit.py | 57 ++++++ services/common/tests/test_environment.py | 75 +++++++- 9 files changed, 573 insertions(+), 17 deletions(-) create mode 100755 scripts/deploy.sh diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index af81d0a..fe0d4ad 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -62,7 +62,106 @@ Settings → Applications → Generate Token). Aggiungilo come secret a livello user (User Settings → Secrets → New Secret) con nome `REGISTRY_TOKEN`. Tutti i tuoi repo ereditano il secret automaticamente. -## 2. Setup iniziale del VPS +## 2. Deploy automatizzato (script) + +Il modo più rapido è `scripts/deploy.sh`, idempotente. Esegui sul VPS: + +```bash +# Prerequisiti +export GITEA_PAT="" +export GITEA_USER=adriano +mkdir -p ~/cerbero-secrets +# Copia (via scp dal posto sicuro) i secret in ~/cerbero-secrets/: +# deribit.json bybit.json hyperliquid.json alpaca.json +# macro.json sentiment.json core.token observer.token + +# Clone temporaneo solo per lo script +curl -sL -o /tmp/deploy.sh \ + https://git.tielogic.xyz/Adriano/Cerbero-mcp/raw/branch/main/scripts/deploy.sh +chmod +x /tmp/deploy.sh + +# Run (richiede sudo per /opt/cerbero-mcp e /var/log/cerbero-mcp) +/tmp/deploy.sh +``` + +Lo script esegue: docker login registry → clone/pull repo in +`/opt/cerbero-mcp` → copia secrets con `chmod 600` → genera `.env` +iniziale (testnet) → crea `/var/log/cerbero-mcp` con permessi `1000:1000` +→ pull image dal registry → `docker compose up -d` → smoke test pubblico. + +Per aggiornare in seguito: ri-esegui lo stesso script (preserva `.env`). + +## 3. Safety: switch testnet → mainnet + +`mcp_common.environment.consistency_check` (richiamato dal boot +`run_exchange_main`) PREVIENE switch accidentali: + +- Se l'ambiente risolto è **mainnet** ma il secret JSON corrispondente + non contiene `"environment": "mainnet"` esplicito → boot abort con + `EnvironmentMismatchError`. +- Se il secret dichiara un environment diverso da quello risolto (es. + `creds["environment"]="mainnet"` ma env var setta testnet) → boot abort. + +**Per passare a mainnet su un exchange specifico** (es. bybit): + +1. Edita `secrets/bybit.json`: aggiungi `"environment": "mainnet"`. +2. Modifica `.env`: `BYBIT_TESTNET=false`. +3. `docker compose -f docker-compose.prod.yml --env-file .env restart mcp-bybit`. + +Senza il flag esplicito nel secret, il container mcp-bybit fallirà al +boot e Watchtower NON aggiornerà su versioni con cred mainnet rotti. + +Override `STRICT_MAINNET=false` in `.env` permette mainnet senza la +conferma esplicita (downgrade safety, sconsigliato in produzione). + +## 4. Audit log persistente + +Tutti i write endpoint (`place_order`, `place_combo_order`, `cancel_*`, +`set_*`, `close_*`, `transfer_*`, `amend_*`, `switch_*`) emettono un +record JSON strutturato sul logger `mcp.audit`. + +**Sink**: +- stdout/stderr container (sempre, visibile via `docker logs`). +- File JSONL persistente su volume host: + `${AUDIT_LOG_DIR:-/var/log/cerbero-mcp}/.audit.jsonl`. + Rotation a mezzanotte UTC con retention `AUDIT_LOG_BACKUP_DAYS` + (default 30 giorni). + +**Esempio record**: + +```json +{ + "audit_event": "write_op", + "action": "place_order", + "exchange": "bybit", + "principal": "core", + "target": "BTCUSDT", + "payload": {"side": "Buy", "qty": 0.01, "price": 60000, "leverage": 3}, + "result": {"order_id": "abc123", "status": "submitted"} +} +``` + +**Query operative**: + +```bash +# Tutto l'audit log oggi +tail -f /var/log/cerbero-mcp/*.audit.jsonl + +# Solo place_order su bybit +jq -c 'select(.action=="place_order" and .exchange=="bybit")' \ + /var/log/cerbero-mcp/bybit.audit.jsonl + +# Errori +jq -c 'select(.error)' /var/log/cerbero-mcp/*.audit.jsonl + +# Operazioni di un principal +jq -c 'select(.principal=="core")' /var/log/cerbero-mcp/*.audit.jsonl +``` + +I secret (api_key, password) sono filtrati automaticamente da +`SecretsFilter` prima di arrivare al sink. + +## 5. Setup iniziale del VPS (manuale, alternativa allo script) **Pre-requisiti**: Docker Engine ≥ 24, `docker compose` plugin, accesso SSH sudo, dominio DNS A record `cerbero-mcp.tielogic.xyz` → IP del VPS, porte 80 @@ -138,7 +237,7 @@ docker compose -f docker-compose.prod.yml logs -f gateway Caddy chiede automaticamente il certificato Let's Encrypt al primo contatto su `https://cerbero-mcp.tielogic.xyz`. -## 3. Auto-update via Watchtower +## 6. Auto-update via Watchtower Watchtower (servizio `watchtower` nel compose) polla il registry ogni `WATCHTOWER_POLL_INTERVAL` secondi. Se trova un nuovo digest dietro al tag @@ -172,7 +271,7 @@ Rimuovi la label `com.centurylinklabs.watchtower.enable=true` per quel servizio nel compose (oppure imposta `=false`). Watchtower lo ignora ma continua a tenere aggiornati gli altri. -## 4. Rollback +## 7. Rollback ```bash # Trova lo SHA della versione precedente @@ -187,7 +286,7 @@ docker compose -f docker-compose.prod.yml --env-file .env up -d Watchtower NON downgraderà perché il digest del tag pin corrisponde a quello locale. -## 5. Smoke test post-deploy +## 8. Smoke test post-deploy ```bash # Da fuori VPS (laptop) @@ -204,7 +303,7 @@ curl -X POST https://cerbero-mcp.tielogic.xyz/mcp-deribit/tools/place_order \ GATEWAY=http://localhost bash tests/smoke/run.sh ``` -## 6. Sicurezza VPS +## 9. Sicurezza VPS - Firewall `ufw`: `allow 22, 80, 443`. Tutto il resto deny in. - `fail2ban` su SSH e (opz) sul log Caddy 401. @@ -214,7 +313,7 @@ GATEWAY=http://localhost bash tests/smoke/run.sh - Audit log in `docker compose logs | grep audit_event` — per produzione meglio redirezionare a syslog o a un servizio dedicato. -## 7. Note Traefik / reverse proxy davanti a Gitea +## 10. Note Traefik / reverse proxy davanti a Gitea Gitea è esposto via Traefik (ROOT_URL `https://git.tielogic.xyz`). Per il push di image Docker il reverse proxy deve consentire upload di body grossi (un @@ -234,7 +333,7 @@ http: Applicalo come middleware al router Gitea. -## 8. Aggiornamento del compose stesso (file YAML) +## 11. Aggiornamento del compose stesso (file YAML) Watchtower aggiorna le **image**, non il `docker-compose.prod.yml`. Se cambi struttura (nuovi servizi, nuove env var) devi: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 26de576..0902643 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -53,6 +53,8 @@ x-common-security: &common-security networks: [internal] labels: com.centurylinklabs.watchtower.enable: "true" + volumes: + - ${AUDIT_LOG_DIR:-/var/log/cerbero-mcp}:/var/log/cerbero-mcp:rw x-image-prefix: &image_prefix git.tielogic.xyz/adriano/cerbero-mcp @@ -103,6 +105,7 @@ services: OBSERVER_TOKEN_FILE: /run/secrets/observer_token DERIBIT_TESTNET: "${DERIBIT_TESTNET:-true}" ROOT_PATH: /mcp-deribit + AUDIT_LOG_FILE: /var/log/cerbero-mcp/deribit.audit.jsonl mcp-hyperliquid: image: ${IMAGE_PREFIX:-git.tielogic.xyz/adriano/cerbero-mcp}/mcp-hyperliquid:${IMAGE_TAG:-latest} @@ -118,6 +121,7 @@ services: OBSERVER_TOKEN_FILE: /run/secrets/observer_token HYPERLIQUID_TESTNET: "${HYPERLIQUID_TESTNET:-true}" ROOT_PATH: /mcp-hyperliquid + AUDIT_LOG_FILE: /var/log/cerbero-mcp/hyperliquid.audit.jsonl mcp-bybit: image: ${IMAGE_PREFIX:-git.tielogic.xyz/adriano/cerbero-mcp}/mcp-bybit:${IMAGE_TAG:-latest} @@ -133,6 +137,7 @@ services: OBSERVER_TOKEN_FILE: /run/secrets/observer_token BYBIT_TESTNET: "${BYBIT_TESTNET:-true}" ROOT_PATH: /mcp-bybit + AUDIT_LOG_FILE: /var/log/cerbero-mcp/bybit.audit.jsonl PORT: "9019" mcp-alpaca: @@ -149,6 +154,7 @@ services: OBSERVER_TOKEN_FILE: /run/secrets/observer_token ALPACA_PAPER: "${ALPACA_PAPER:-true}" ROOT_PATH: /mcp-alpaca + AUDIT_LOG_FILE: /var/log/cerbero-mcp/alpaca.audit.jsonl PORT: "9020" mcp-macro: diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..7bcdab7 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# Cerbero_mcp — deploy script per VPS produzione. +# +# Pre-requisiti sul VPS (NON gestiti da questo script): +# 1. Docker Engine ≥ 24 + plugin docker compose installati. +# 2. DNS A record `cerbero-mcp.tielogic.xyz` → IP del VPS. +# 3. Porte 80 e 443 aperte sul firewall (per ACME + traffico HTTPS). +# 4. PAT Gitea con scope `read:package`, salvato in env `$GITEA_PAT`. +# 5. Username Gitea in env `$GITEA_USER` (default: adriano). +# 6. Secret JSON exchange + token bearer disponibili in $SECRETS_SRC +# (default: ~/cerbero-secrets/), che lo script copierà in +# $DEPLOY_DIR/secrets/ con permessi 600. +# +# Idempotente: rieseguibile per aggiornamenti. + +set -euo pipefail + +DEPLOY_DIR="${DEPLOY_DIR:-/opt/cerbero-mcp}" +SECRETS_SRC="${SECRETS_SRC:-$HOME/cerbero-secrets}" +GITEA_USER="${GITEA_USER:-adriano}" +GITEA_REPO_URL="${GITEA_REPO_URL:-ssh://git@git.tielogic.xyz:222/Adriano/Cerbero-mcp.git}" +REGISTRY="${REGISTRY:-git.tielogic.xyz}" +DOMAIN="${DOMAIN:-cerbero-mcp.tielogic.xyz}" +AUDIT_LOG_DIR="${AUDIT_LOG_DIR:-/var/log/cerbero-mcp}" + +echo "=== Cerbero_mcp deploy → $DEPLOY_DIR (domain $DOMAIN) ===" + +# ────────────────────────────────────────────────────────────── +# 1. Verifica pre-requisiti +# ────────────────────────────────────────────────────────────── +command -v docker >/dev/null || { echo "FATAL: docker non installato"; exit 1; } +docker compose version >/dev/null || { echo "FATAL: docker compose plugin assente"; exit 1; } + +if [ -z "${GITEA_PAT:-}" ]; then + echo "FATAL: env GITEA_PAT non settata. Export del PAT con scope read:package prima." + exit 1 +fi + +if [ ! -d "$SECRETS_SRC" ]; then + echo "FATAL: secrets src dir $SECRETS_SRC non esiste." + echo " Atteso contenere: deribit.json bybit.json hyperliquid.json alpaca.json" + echo " macro.json sentiment.json core.token observer.token" + exit 1 +fi + +# Check DNS resolution (warning only, non blocca) +ip_resolved=$(getent hosts "$DOMAIN" | awk '{print $1}' | head -1 || true) +if [ -z "$ip_resolved" ]; then + echo "WARN: $DOMAIN non risolve via DNS — TLS Let's Encrypt fallirà finché DNS non propaga." +else + echo "DNS $DOMAIN → $ip_resolved" +fi + +# ────────────────────────────────────────────────────────────── +# 2. Login al container registry +# ────────────────────────────────────────────────────────────── +echo "=== docker login $REGISTRY ===" +echo "$GITEA_PAT" | docker login "$REGISTRY" -u "$GITEA_USER" --password-stdin + +# ────────────────────────────────────────────────────────────── +# 3. Setup dir + clone/pull repo +# ────────────────────────────────────────────────────────────── +sudo mkdir -p "$DEPLOY_DIR" +sudo chown "$USER:$USER" "$DEPLOY_DIR" + +if [ -d "$DEPLOY_DIR/.git" ]; then + echo "=== Aggiornamento repo $DEPLOY_DIR ===" + git -C "$DEPLOY_DIR" pull --ff-only +else + echo "=== Clone repo $GITEA_REPO_URL → $DEPLOY_DIR ===" + git clone "$GITEA_REPO_URL" "$DEPLOY_DIR" +fi + +cd "$DEPLOY_DIR" + +# ────────────────────────────────────────────────────────────── +# 4. Copia secrets con permessi 600 +# ────────────────────────────────────────────────────────────── +mkdir -p secrets +echo "=== Copia secrets da $SECRETS_SRC ===" +for f in deribit.json bybit.json hyperliquid.json alpaca.json macro.json sentiment.json core.token observer.token; do + if [ -f "$SECRETS_SRC/$f" ]; then + cp "$SECRETS_SRC/$f" "secrets/$f" + chmod 600 "secrets/$f" + echo " ok: secrets/$f" + else + echo " WARN: $SECRETS_SRC/$f assente — il servizio relativo fallirà al boot." + fi +done + +# ────────────────────────────────────────────────────────────── +# 5. Crea/aggiorna .env (preserva esistente) +# ────────────────────────────────────────────────────────────── +if [ ! -f .env ]; then + echo "=== Creazione .env iniziale (testnet di default) ===" + cat > .env <" +echo " Audit: tail -f $AUDIT_LOG_DIR/*.audit.jsonl" +echo " Restart: docker compose -f docker-compose.prod.yml --env-file .env restart " +echo " Stop: docker compose -f docker-compose.prod.yml --env-file .env down" diff --git a/services/common/src/mcp_common/app_factory.py b/services/common/src/mcp_common/app_factory.py index b9581c7..5ad0ab1 100644 --- a/services/common/src/mcp_common/app_factory.py +++ b/services/common/src/mcp_common/app_factory.py @@ -24,7 +24,11 @@ import uvicorn from mcp_common.auth import load_token_store_from_files from mcp_common.env_validation import fail_fast_if_missing, require_env, summarize -from mcp_common.environment import EnvironmentInfo, resolve_environment +from mcp_common.environment import ( + EnvironmentInfo, + consistency_check, + resolve_environment, +) from mcp_common.logging import configure_root_logging @@ -69,6 +73,11 @@ def run_exchange_main(spec: ExchangeAppSpec) -> None: default_base_url_testnet=spec.default_base_url_testnet, ) + # Safety: previene switch accidentali a mainnet senza conferma esplicita + # nel secret. Solleva EnvironmentMismatchError → boot abort se mismatch. + strict_mainnet = os.environ.get("STRICT_MAINNET", "true").lower() not in ("0", "false", "no") + consistency_check(env_info, creds, strict_mainnet=strict_mainnet) + client = spec.build_client(creds, env_info) token_store = load_token_store_from_files( diff --git a/services/common/src/mcp_common/audit.py b/services/common/src/mcp_common/audit.py index 4637906..a65c978 100644 --- a/services/common/src/mcp_common/audit.py +++ b/services/common/src/mcp_common/audit.py @@ -1,22 +1,68 @@ """Audit log strutturato per write endpoint MCP (place_order, cancel, set_*, close_*, transfer_*). Usa un logger dedicato `mcp.audit` su stream -JSON: in deployment può essere redirezionato a file/syslog/SIEM separato. +JSON. -Logica: -- `audit_write_op(principal, action, exchange, target, payload, result)` - emette UN record JSON per ogni operazione con esito (ok/error). -- Payload sensibile (api_key, secret) già filtrato dal SecretsFilter - globale; qui non si include creds. +Sink: +- stdout/stderr (sempre): tramite root JSON logger configurato da + `mcp_common.logging.configure_root_logging`. +- File JSONL persistente (opzionale): se env var `AUDIT_LOG_FILE` è + settata, aggiunge un `TimedRotatingFileHandler` che ruota a mezzanotte + con `AUDIT_LOG_BACKUP_DAYS` di retention (default 30). Una riga JSON + per record (formato `.jsonl`). + +Per VPS produzione: setta `AUDIT_LOG_FILE=/var/log/cerbero-mcp/.audit.jsonl` +con bind mount del volume `/var/log/cerbero-mcp` nel docker-compose. + +Payload sensibile (api_key, secret) già filtrato dal SecretsFilter +globale; qui non si include creds. """ from __future__ import annotations import logging +import os +from logging.handlers import TimedRotatingFileHandler from typing import Any from mcp_common.auth import Principal -from mcp_common.logging import get_json_logger +from mcp_common.logging import SecretsFilter, get_json_logger + +try: + from pythonjsonlogger.json import JsonFormatter as _JsonFormatter # noqa: N813 +except ImportError: + from pythonjsonlogger.jsonlogger import JsonFormatter as _JsonFormatter # noqa: N813 _logger = get_json_logger("mcp.audit", level=logging.INFO) +_file_handler_attached = False + + +def _configure_audit_sink() -> None: + """Aggiunge FileHandler al logger mcp.audit se AUDIT_LOG_FILE è settato. + Idempotente: chiamato la prima volta da audit_write_op, poi no-op. + """ + global _file_handler_attached + if _file_handler_attached: + return + + file_path = os.environ.get("AUDIT_LOG_FILE", "").strip() + if not file_path: + _file_handler_attached = True + return + + backup_days = int(os.environ.get("AUDIT_LOG_BACKUP_DAYS", "30")) + + os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True) + handler = TimedRotatingFileHandler( + file_path, + when="midnight", + interval=1, + backupCount=backup_days, + encoding="utf-8", + utc=True, + ) + handler.setFormatter(_JsonFormatter("%(asctime)s %(name)s %(levelname)s %(message)s")) + handler.addFilter(SecretsFilter()) + _logger.addHandler(handler) + _file_handler_attached = True def audit_write_op( @@ -40,6 +86,7 @@ def audit_write_op( result: output del client (order_id, status, ecc.). error: stringa errore se l'operazione ha fallito. """ + _configure_audit_sink() record: dict[str, Any] = { "audit_event": "write_op", "action": action, diff --git a/services/common/src/mcp_common/environment.py b/services/common/src/mcp_common/environment.py index 2a2dcee..47b8c89 100644 --- a/services/common/src/mcp_common/environment.py +++ b/services/common/src/mcp_common/environment.py @@ -1,18 +1,31 @@ """Resolver di ambiente (testnet/mainnet) per MCP exchange. Precedenza: env var > campo secret > default True (testnet). + +Safety: `consistency_check` previene switch accidentali a mainnet senza +conferma esplicita nel secret JSON. """ from __future__ import annotations +import logging import os from dataclasses import dataclass from typing import Literal +logger = logging.getLogger(__name__) + Environment = Literal["testnet", "mainnet"] Source = Literal["env", "credentials", "default"] TRUTHY = {"1", "true", "yes", "on"} +# Tokens nel base_url che indicano endpoint testnet (case-insensitive). +TESTNET_URL_HINTS = ("test", "testnet", "paper") + + +class EnvironmentMismatchError(RuntimeError): + """Boot abort: ambiente risolto non matcha conferma esplicita nel secret.""" + @dataclass(frozen=True) class EnvironmentInfo: @@ -67,3 +80,59 @@ def resolve_environment( env_value=env_value, base_url=base_url, ) + + +def consistency_check( + env_info: EnvironmentInfo, + creds: dict, + *, + strict_mainnet: bool = True, +) -> list[str]: + """Verifica coerenza environment risolto vs secret JSON. Restituisce + lista di warning string. Solleva EnvironmentMismatchError per mismatch + bloccanti. + + Regole: + - Se `creds["environment"]` è presente e DIVERSO da `env_info.environment`: + → raise EnvironmentMismatchError (declared vs resolved mismatch). + - Se `env_info.environment == "mainnet"` e `creds.get("environment") != + "mainnet"`: con `strict_mainnet=True` → raise (richiede conferma + esplicita). Con `strict_mainnet=False` → warning. + - Se `env_info.base_url` contiene token testnet ("test", "testnet", + "paper") ma `env_info.environment == "mainnet"` (o viceversa): warning + (URL/environment incoerenti). + """ + warnings: list[str] = [] + + declared = creds.get("environment") + if declared and declared != env_info.environment: + raise EnvironmentMismatchError( + f"{env_info.exchange}: secret declared environment={declared!r} " + f"but resolver resolved environment={env_info.environment!r}" + ) + + if env_info.environment == "mainnet" and declared != "mainnet": + msg = ( + f"{env_info.exchange}: resolved mainnet without explicit confirmation " + "in secret. Add `\"environment\": \"mainnet\"` to the credentials JSON." + ) + if strict_mainnet: + raise EnvironmentMismatchError(msg) + warnings.append(msg) + + url_lower = (env_info.base_url or "").lower() + has_test_hint = any(token in url_lower for token in TESTNET_URL_HINTS) + if env_info.environment == "mainnet" and has_test_hint: + warnings.append( + f"{env_info.exchange}: environment=mainnet but base_url contains " + f"testnet hint ({env_info.base_url!r})" + ) + if env_info.environment == "testnet" and not has_test_hint and url_lower: + warnings.append( + f"{env_info.exchange}: environment=testnet but base_url does not " + f"appear to be a testnet endpoint ({env_info.base_url!r})" + ) + + for w in warnings: + logger.warning("environment consistency: %s", w) + return warnings diff --git a/services/common/tests/test_app_factory.py b/services/common/tests/test_app_factory.py index 8277b62..2e1538f 100644 --- a/services/common/tests/test_app_factory.py +++ b/services/common/tests/test_app_factory.py @@ -3,6 +3,8 @@ from __future__ import annotations import json from unittest.mock import MagicMock, patch +import pytest + from mcp_common.app_factory import ExchangeAppSpec, run_exchange_main from mcp_common.environment import EnvironmentInfo @@ -75,7 +77,9 @@ def test_run_exchange_main_uses_default_port(tmp_path, monkeypatch): def test_run_exchange_main_env_var_overrides_creds(tmp_path, monkeypatch): creds_file = tmp_path / "creds.json" - creds_file.write_text(json.dumps({"testnet": True})) + # `environment: mainnet` esplicito perché env var override → mainnet + # e consistency_check richiede conferma per evitare switch accidentale. + creds_file.write_text(json.dumps({"testnet": True, "environment": "mainnet"})) monkeypatch.setenv("TESTEX_CREDENTIALS_FILE", str(creds_file)) monkeypatch.setenv("TESTEX_TESTNET", "false") @@ -95,6 +99,33 @@ def test_run_exchange_main_env_var_overrides_creds(tmp_path, monkeypatch): assert captured["env_info"].source == "env" +def test_run_exchange_main_aborts_on_mainnet_without_confirmation(tmp_path, monkeypatch): + """Mainnet senza creds['environment']='mainnet' → boot abort fail-fast.""" + from mcp_common.environment import EnvironmentMismatchError + creds_file = tmp_path / "creds.json" + creds_file.write_text(json.dumps({"testnet": False})) + monkeypatch.setenv("TESTEX_CREDENTIALS_FILE", str(creds_file)) + monkeypatch.delenv("TESTEX_TESTNET", raising=False) + monkeypatch.delenv("STRICT_MAINNET", raising=False) + + spec = _make_spec() + with pytest.raises(EnvironmentMismatchError): + with patch("mcp_common.app_factory.uvicorn.run"): + run_exchange_main(spec) + + +def test_run_exchange_main_strict_mainnet_disabled_via_env(tmp_path, monkeypatch): + """STRICT_MAINNET=false permette mainnet senza conferma (warning soltanto).""" + creds_file = tmp_path / "creds.json" + creds_file.write_text(json.dumps({"testnet": False})) + monkeypatch.setenv("TESTEX_CREDENTIALS_FILE", str(creds_file)) + monkeypatch.setenv("STRICT_MAINNET", "false") + + spec = _make_spec() + with patch("mcp_common.app_factory.uvicorn.run"): + run_exchange_main(spec) # non solleva + + def test_run_exchange_main_missing_creds_file_exits(monkeypatch): monkeypatch.delenv("TESTEX_CREDENTIALS_FILE", raising=False) diff --git a/services/common/tests/test_audit.py b/services/common/tests/test_audit.py index 7d0ff7c..df78ad8 100644 --- a/services/common/tests/test_audit.py +++ b/services/common/tests/test_audit.py @@ -95,3 +95,60 @@ def test_audit_write_op_no_principal(captured_records): ) rec = captured_records[0] assert rec.principal is None + + +def test_audit_write_op_writes_to_file_when_AUDIT_LOG_FILE_set(tmp_path, monkeypatch): + """Con env AUDIT_LOG_FILE settato, una riga JSON appare nel file.""" + import json + from mcp_common import audit as audit_mod + + audit_file = tmp_path / "audit.jsonl" + monkeypatch.setenv("AUDIT_LOG_FILE", str(audit_file)) + # Reset state idempotency flag così il test riesegue setup + audit_mod._file_handler_attached = False + # Pulisci handlers preesistenti dal logger (potrebbe avere file vecchio) + for h in list(audit_mod._logger.handlers): + from logging.handlers import TimedRotatingFileHandler + if isinstance(h, TimedRotatingFileHandler): + audit_mod._logger.removeHandler(h) + + audit_write_op( + principal=Principal("core", {"core"}), + action="place_order", + exchange="bybit", + target="BTCUSDT", + payload={"side": "Buy", "qty": 0.01}, + result={"order_id": "abc123", "status": "submitted"}, + ) + + # Forza flush dei file handler + for h in audit_mod._logger.handlers: + h.flush() + + assert audit_file.exists() + content = audit_file.read_text().strip() + assert content, "audit file empty" + record = json.loads(content.splitlines()[-1]) + assert record["audit_event"] == "write_op" + assert record["action"] == "place_order" + assert record["exchange"] == "bybit" + assert record["target"] == "BTCUSDT" + assert record["principal"] == "core" + + +def test_audit_no_file_when_env_unset(tmp_path, monkeypatch): + """Senza AUDIT_LOG_FILE, nessun file viene creato.""" + from mcp_common import audit as audit_mod + monkeypatch.delenv("AUDIT_LOG_FILE", raising=False) + audit_mod._file_handler_attached = False + + audit_write_op( + principal=Principal("core", {"core"}), + action="cancel_order", + exchange="bybit", + target="ord-1", + payload={}, + ) + # Niente file creato in tmp_path + files = list(tmp_path.iterdir()) + assert files == [] diff --git a/services/common/tests/test_environment.py b/services/common/tests/test_environment.py index 022eeca..7f83b8b 100644 --- a/services/common/tests/test_environment.py +++ b/services/common/tests/test_environment.py @@ -1,7 +1,11 @@ from __future__ import annotations import pytest -from mcp_common.environment import resolve_environment +from mcp_common.environment import ( + EnvironmentMismatchError, + consistency_check, + resolve_environment, +) def test_env_var_overrides_secret(monkeypatch): @@ -114,3 +118,72 @@ def test_alpaca_paper_flag_key(monkeypatch): ) assert info.environment == "mainnet" assert info.source == "credentials" + + +# ───────── consistency_check ───────── + + +def _info(env: str, exchange: str = "deribit") -> "EnvironmentInfo": + """Helper costruisce EnvironmentInfo per test.""" + from mcp_common.environment import EnvironmentInfo + return EnvironmentInfo( + exchange=exchange, + environment=env, + source="env", + env_value="false" if env == "mainnet" else "true", + base_url=f"https://api.{exchange}.com" if env == "mainnet" else f"https://test.{exchange}.com", + ) + + +def test_consistency_check_testnet_no_confirmation_ok(): + """Testnet senza conferma esplicita → ok, ritorna []. Default safe.""" + info = _info("testnet") + creds = {"api_key": "k", "api_secret": "s"} + warnings = consistency_check(info, creds) + assert warnings == [] + + +def test_consistency_check_mainnet_no_confirmation_raises(): + """Mainnet senza creds['environment']='mainnet' esplicito → fail-fast.""" + info = _info("mainnet") + creds = {"api_key": "k", "api_secret": "s"} + with pytest.raises(EnvironmentMismatchError, match="mainnet.*explicit confirmation"): + consistency_check(info, creds) + + +def test_consistency_check_mainnet_with_confirmation_ok(): + info = _info("mainnet") + creds = {"api_key": "k", "api_secret": "s", "environment": "mainnet"} + warnings = consistency_check(info, creds) + assert warnings == [] + + +def test_consistency_check_explicit_mismatch_raises(): + """Secret dichiara mainnet ma resolver risolve testnet → fail-fast.""" + info = _info("testnet") + creds = {"environment": "mainnet"} + with pytest.raises(EnvironmentMismatchError, match="declared.*resolved"): + consistency_check(info, creds) + + +def test_consistency_check_strict_mainnet_disabled(): + """Con strict_mainnet=False mainnet senza conferma logga warning ma non raise.""" + info = _info("mainnet") + creds = {"api_key": "k", "api_secret": "s"} + warnings = consistency_check(info, creds, strict_mainnet=False) + assert any("mainnet" in w for w in warnings) + + +def test_consistency_check_url_does_not_match_environment_warns(): + """Base URL contiene 'test' ma environment='mainnet' → warning.""" + from mcp_common.environment import EnvironmentInfo + info = EnvironmentInfo( + exchange="bybit", + environment="mainnet", + source="env", + env_value="false", + base_url="https://api-testnet.bybit.com", # url DICE testnet ma resolver MAINNET + ) + creds = {"environment": "mainnet"} + warnings = consistency_check(info, creds) + assert any("base_url" in w.lower() for w in warnings)