Establishes the safety net required before any structural refactor. Tests (tests/): - conftest.py rewritten for Flask-SQLAlchemy 3.x (drop-recreate per test, StaticPool-shared in-memory SQLite, admin_user + auth_headers fixtures). Removes deprecated db.create_scoped_session pattern. - test_smoke.py: 8 baseline tests (app boot, JWT login valid+invalid, protected routes, paginated response shape, plugin auto-discovery). - test_security_config.py: 7 tests pinning ProductionConfig.validate failure modes (missing/dev SECRET_KEY, missing JWT_SECRET_KEY, missing DATABASE_URL, wildcard CORS, empty CORS) and one happy-path. Production hardening (shopdb/config.py, shopdb/__init__.py): - ProductionConfig.validate() raises ConfigError on missing or insecure SECRET_KEY, JWT_SECRET_KEY, DATABASE_URL, CORS_ORIGINS. No silent fallback to dev defaults in production. - create_app invokes validate() when config_name == 'production'. - CORS_ORIGINS default no longer wildcard; defaults to localhost Vite dev origin. - Drop os.path.exists probe in serve_frontend (path-traversal risk surface). send_from_directory handles safe-join + 404 itself. - Replace User.query.get with db.session.get (SQLAlchemy 2.0 API). TestingConfig (shopdb/config.py): - Add StaticPool + check_same_thread connect_args so SQLite in-memory is shared across the test session. Index dedup (plugins/printers/models/printer_extension.py): - Rename idx_printer_windowsname -> idx_printerdata_windowsname. Two model classes (Printer, PrinterData) declared the same index name; SQLite enforces global index uniqueness even across tables. Per CONTRIBUTING.md naming convention, indexes follow idx_<table>_<column>. Dependency pinning (requirements.in, requirements.txt): - requirements.in holds the loose source pins (the human-edited file). - requirements.txt is now a uv-compiled lockfile (every transitive dep pinned to an exact version). Reproducible builds. Run `uv pip compile requirements.in -o requirements.txt` to refresh. Test count: 0 -> 15 passing. All naming/style checks still green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
71 lines
2.8 KiB
Python
71 lines
2.8 KiB
Python
"""Tests pinning production-config validation behavior."""
|
|
|
|
import os
|
|
import pytest
|
|
|
|
from shopdb.config import ProductionConfig, ConfigError
|
|
|
|
|
|
@pytest.fixture
|
|
def clean_env(monkeypatch):
|
|
"""Clear all env vars that ProductionConfig.validate looks at."""
|
|
for key in ('SECRET_KEY', 'JWT_SECRET_KEY', 'DATABASE_URL', 'CORS_ORIGINS'):
|
|
monkeypatch.delenv(key, raising=False)
|
|
return monkeypatch
|
|
|
|
|
|
def test_production_validate_raises_on_missing_secret_key(clean_env):
|
|
"""Empty SECRET_KEY in production must fail loud at boot."""
|
|
with pytest.raises(ConfigError, match='SECRET_KEY'):
|
|
ProductionConfig.validate()
|
|
|
|
|
|
def test_production_validate_raises_on_dev_secret_key(clean_env):
|
|
"""The dev fallback must not be accepted in production."""
|
|
clean_env.setenv('SECRET_KEY', 'dev-secret-key-change-in-production')
|
|
with pytest.raises(ConfigError, match='SECRET_KEY'):
|
|
ProductionConfig.validate()
|
|
|
|
|
|
def test_production_validate_raises_on_missing_jwt_secret(clean_env):
|
|
"""Empty JWT_SECRET_KEY in production must fail loud at boot."""
|
|
clean_env.setenv('SECRET_KEY', 'a-real-strong-key')
|
|
with pytest.raises(ConfigError, match='JWT_SECRET_KEY'):
|
|
ProductionConfig.validate()
|
|
|
|
|
|
def test_production_validate_raises_on_missing_database_url(clean_env):
|
|
"""Production must not silently fall back to a localhost MySQL URL."""
|
|
clean_env.setenv('SECRET_KEY', 'a-real-strong-key')
|
|
clean_env.setenv('JWT_SECRET_KEY', 'another-strong-key')
|
|
with pytest.raises(ConfigError, match='DATABASE_URL'):
|
|
ProductionConfig.validate()
|
|
|
|
|
|
def test_production_validate_raises_on_wildcard_cors(clean_env):
|
|
"""CORS wildcard is rejected in production."""
|
|
clean_env.setenv('SECRET_KEY', 'a-real-strong-key')
|
|
clean_env.setenv('JWT_SECRET_KEY', 'another-strong-key')
|
|
clean_env.setenv('DATABASE_URL', 'mysql+pymysql://u:p@db/shopdb')
|
|
clean_env.setenv('CORS_ORIGINS', '*')
|
|
with pytest.raises(ConfigError, match='CORS_ORIGINS'):
|
|
ProductionConfig.validate()
|
|
|
|
|
|
def test_production_validate_raises_on_empty_cors(clean_env):
|
|
"""Empty CORS allowlist is rejected in production."""
|
|
clean_env.setenv('SECRET_KEY', 'a-real-strong-key')
|
|
clean_env.setenv('JWT_SECRET_KEY', 'another-strong-key')
|
|
clean_env.setenv('DATABASE_URL', 'mysql+pymysql://u:p@db/shopdb')
|
|
with pytest.raises(ConfigError, match='CORS_ORIGINS'):
|
|
ProductionConfig.validate()
|
|
|
|
|
|
def test_production_validate_passes_with_complete_config(clean_env):
|
|
"""All required env vars set with non-default values: validate passes."""
|
|
clean_env.setenv('SECRET_KEY', 'a-real-strong-key')
|
|
clean_env.setenv('JWT_SECRET_KEY', 'another-strong-key')
|
|
clean_env.setenv('DATABASE_URL', 'mysql+pymysql://u:p@db/shopdb')
|
|
clean_env.setenv('CORS_ORIGINS', 'https://shopdb.example.com')
|
|
ProductionConfig.validate()
|