Шхуна не тонет: 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:
@@ -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():
|
||||
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():
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user