Phase 1: pytest baseline, production hardening, pinned requirements

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>
This commit is contained in:
cproudlock
2026-05-08 14:48:19 -04:00
parent d6725c08e0
commit 2d1bb83c3b
8 changed files with 487 additions and 87 deletions

View File

@@ -3,17 +3,33 @@
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."""
# Flask
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
# SQLAlchemy
SQLALCHEMY_DATABASE_URI = os.environ.get(
'DATABASE_URL',
'mysql+pymysql://root:password@localhost:3306/shopdb_flask'
'mysql+pymysql://root:password@localhost:3306/shopdb_flask',
)
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ENGINE_OPTIONS = {
@@ -21,7 +37,6 @@ class Config:
'pool_recycle': 300,
}
# JWT
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))
@@ -30,21 +45,20 @@ class Config:
seconds=int(os.environ.get('JWT_REFRESH_TOKEN_EXPIRES', 2592000))
)
# CORS
CORS_ORIGINS = os.environ.get('CORS_ORIGINS', '*').split(',')
CORS_ORIGINS = [
origin.strip()
for origin in os.environ.get('CORS_ORIGINS', 'http://localhost:5173').split(',')
if origin.strip()
]
# Logging
LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO')
# Zabbix
ZABBIX_URL = os.environ.get('ZABBIX_URL', '')
ZABBIX_TOKEN = os.environ.get('ZABBIX_TOKEN', '')
# Cache
CACHE_TYPE = 'SimpleCache'
CACHE_DEFAULT_TIMEOUT = 600 # 10 minutes
CACHE_DEFAULT_TIMEOUT = 600
# Pagination
DEFAULT_PAGE_SIZE = 20
MAX_PAGE_SIZE = 100
@@ -55,12 +69,10 @@ class DevelopmentConfig(Config):
DEBUG = True
SQLALCHEMY_ECHO = True
# Use MySQL from DATABASE_URL
SQLALCHEMY_DATABASE_URI = os.environ.get(
'DATABASE_URL',
'mysql+pymysql://root:rootpassword@127.0.0.1:3306/shopdb_flask'
'mysql+pymysql://root:rootpassword@127.0.0.1:3306/shopdb_flask',
)
# Keep pool options from base Config for MySQL
class TestingConfig(Config):
@@ -68,23 +80,70 @@ class TestingConfig(Config):
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."""
"""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
# Stricter security in production
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
'default': DevelopmentConfig,
}