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>
150 lines
4.6 KiB
Python
150 lines
4.6 KiB
Python
"""Flask application configuration."""
|
|
|
|
import os
|
|
from datetime import timedelta
|
|
|
|
from sqlalchemy.pool import StaticPool
|
|
|
|
|
|
class ConfigError(Exception):
|
|
"""Raised when required configuration is missing or unsafe."""
|
|
|
|
|
|
def _required_env(varname):
|
|
"""Read an env var; raise ConfigError if missing or empty."""
|
|
value = os.environ.get(varname)
|
|
if not value:
|
|
raise ConfigError(
|
|
f'{varname} is required in production. Set it in the environment '
|
|
f'before starting the app. Insecure defaults are not permitted in '
|
|
f'ProductionConfig.'
|
|
)
|
|
return value
|
|
|
|
|
|
class Config:
|
|
"""Base configuration."""
|
|
|
|
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
|
|
|
|
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
|
'DATABASE_URL',
|
|
'mysql+pymysql://root:password@localhost:3306/shopdb_flask',
|
|
)
|
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
|
SQLALCHEMY_ENGINE_OPTIONS = {
|
|
'pool_pre_ping': True,
|
|
'pool_recycle': 300,
|
|
}
|
|
|
|
JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'jwt-secret-key-change-in-production')
|
|
JWT_ACCESS_TOKEN_EXPIRES = timedelta(
|
|
seconds=int(os.environ.get('JWT_ACCESS_TOKEN_EXPIRES', 3600))
|
|
)
|
|
JWT_REFRESH_TOKEN_EXPIRES = timedelta(
|
|
seconds=int(os.environ.get('JWT_REFRESH_TOKEN_EXPIRES', 2592000))
|
|
)
|
|
|
|
CORS_ORIGINS = [
|
|
origin.strip()
|
|
for origin in os.environ.get('CORS_ORIGINS', 'http://localhost:5173').split(',')
|
|
if origin.strip()
|
|
]
|
|
|
|
LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO')
|
|
|
|
ZABBIX_URL = os.environ.get('ZABBIX_URL', '')
|
|
ZABBIX_TOKEN = os.environ.get('ZABBIX_TOKEN', '')
|
|
|
|
CACHE_TYPE = 'SimpleCache'
|
|
CACHE_DEFAULT_TIMEOUT = 600
|
|
|
|
DEFAULT_PAGE_SIZE = 20
|
|
MAX_PAGE_SIZE = 100
|
|
|
|
|
|
class DevelopmentConfig(Config):
|
|
"""Development configuration."""
|
|
|
|
DEBUG = True
|
|
SQLALCHEMY_ECHO = True
|
|
|
|
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
|
'DATABASE_URL',
|
|
'mysql+pymysql://root:rootpassword@127.0.0.1:3306/shopdb_flask',
|
|
)
|
|
|
|
|
|
class TestingConfig(Config):
|
|
"""Testing configuration."""
|
|
|
|
TESTING = True
|
|
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
|
|
SQLALCHEMY_ENGINE_OPTIONS = {
|
|
'connect_args': {'check_same_thread': False},
|
|
'poolclass': StaticPool,
|
|
}
|
|
JWT_ACCESS_TOKEN_EXPIRES = timedelta(seconds=5)
|
|
|
|
|
|
class ProductionConfig(Config):
|
|
"""Production configuration.
|
|
|
|
Validation is deferred to validate() so that importing this class in a
|
|
non-production environment (tests, dev, tooling) does not raise.
|
|
create_app() invokes validate() when config_name == 'production' so
|
|
a misconfigured production deploy still fails loud at boot.
|
|
"""
|
|
|
|
DEBUG = False
|
|
SQLALCHEMY_ECHO = False
|
|
|
|
JWT_COOKIE_SECURE = True
|
|
JWT_COOKIE_CSRF_PROTECT = True
|
|
|
|
@classmethod
|
|
def validate(cls):
|
|
"""Verify production config is safe. Called from create_app."""
|
|
secret_key = os.environ.get('SECRET_KEY', '')
|
|
jwt_secret = os.environ.get('JWT_SECRET_KEY', '')
|
|
database_url = os.environ.get('DATABASE_URL', '')
|
|
cors_raw = os.environ.get('CORS_ORIGINS', '').strip()
|
|
|
|
insecure_defaults = {
|
|
'dev-secret-key-change-in-production',
|
|
'jwt-secret-key-change-in-production',
|
|
}
|
|
|
|
if not secret_key or secret_key in insecure_defaults:
|
|
raise ConfigError(
|
|
'SECRET_KEY is required in production and must not be the '
|
|
'development default. Set a strong random value in the '
|
|
'environment before starting the app.'
|
|
)
|
|
if not jwt_secret or jwt_secret in insecure_defaults:
|
|
raise ConfigError(
|
|
'JWT_SECRET_KEY is required in production and must not be '
|
|
'the development default. Set a strong random value in the '
|
|
'environment before starting the app.'
|
|
)
|
|
if not database_url:
|
|
raise ConfigError(
|
|
'DATABASE_URL is required in production. No fallback to a '
|
|
'development localhost URL is permitted.'
|
|
)
|
|
if not cors_raw or cors_raw == '*':
|
|
raise ConfigError(
|
|
'CORS_ORIGINS must be a comma-separated allowlist of '
|
|
'explicit origins in production. Wildcard "*" is not '
|
|
'permitted. Example: '
|
|
'CORS_ORIGINS=https://shopdb.example.com,https://shopdb-mirror.example.com'
|
|
)
|
|
|
|
|
|
config = {
|
|
'development': DevelopmentConfig,
|
|
'testing': TestingConfig,
|
|
'production': ProductionConfig,
|
|
'default': DevelopmentConfig,
|
|
}
|