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

@@ -0,0 +1,70 @@
"""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()