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

@@ -51,7 +51,7 @@ class PrinterData(BaseModel):
) )
__table_args__ = ( __table_args__ = (
db.Index('idx_printer_windowsname', 'windowsname'), db.Index('idx_printerdata_windowsname', 'windowsname'),
) )
def __repr__(self): def __repr__(self):

32
requirements.in Normal file
View File

@@ -0,0 +1,32 @@
# Flask and extensions
flask>=3.0
flask-sqlalchemy>=3.1
flask-migrate>=4.0
flask-jwt-extended>=4.6
flask-cors>=4.0
flask-caching>=2.0
flask-marshmallow>=1.2
marshmallow-sqlalchemy>=0.29
# Database
mysql-connector-python>=8.0
pymysql>=1.1
# CLI and utilities
click>=8.1
python-dotenv>=1.0
tabulate>=0.9
# HTTP/API clients
requests>=2.31
# Security
werkzeug>=3.0
# Validation
email-validator>=2.0
# Testing
pytest>=7.0
pytest-flask>=1.2
pytest-cov>=4.0

View File

@@ -1,32 +1,119 @@
# Flask and extensions # This file was autogenerated by uv via the following command:
flask>=3.0 # uv pip compile requirements.in -o requirements.txt
flask-sqlalchemy>=3.1 alembic==1.18.4
flask-migrate>=4.0 # via flask-migrate
flask-jwt-extended>=4.6 blinker==1.9.0
flask-cors>=4.0 # via flask
flask-caching>=2.0 cachelib==0.13.0
flask-marshmallow>=1.2 # via flask-caching
marshmallow-sqlalchemy>=0.29 certifi==2026.4.22
# via requests
# Database charset-normalizer==3.4.7
mysql-connector-python>=8.0 # via requests
pymysql>=1.1 click==8.3.3
# via
# CLI and utilities # -r requirements.in
click>=8.1 # flask
python-dotenv>=1.0 coverage==7.13.5
tabulate>=0.9 # via pytest-cov
dnspython==2.8.0
# HTTP/API clients # via email-validator
requests>=2.31 email-validator==2.3.0
# via -r requirements.in
# Security flask==3.1.3
werkzeug>=3.0 # via
# -r requirements.in
# Validation # flask-caching
email-validator>=2.0 # flask-cors
# flask-jwt-extended
# Testing # flask-marshmallow
pytest>=7.0 # flask-migrate
pytest-flask>=1.2 # flask-sqlalchemy
pytest-cov>=4.0 # pytest-flask
flask-caching==2.4.0
# via -r requirements.in
flask-cors==6.0.2
# via -r requirements.in
flask-jwt-extended==4.7.3
# via -r requirements.in
flask-marshmallow==1.5.0
# via -r requirements.in
flask-migrate==4.1.0
# via -r requirements.in
flask-sqlalchemy==3.1.1
# via
# -r requirements.in
# flask-migrate
greenlet==3.5.0
# via sqlalchemy
idna==3.13
# via
# email-validator
# requests
iniconfig==2.3.0
# via pytest
itsdangerous==2.2.0
# via flask
jinja2==3.1.6
# via flask
mako==1.3.12
# via alembic
markupsafe==3.0.3
# via
# flask
# jinja2
# mako
# werkzeug
marshmallow==4.3.0
# via
# flask-marshmallow
# marshmallow-sqlalchemy
marshmallow-sqlalchemy==1.5.0
# via -r requirements.in
mysql-connector-python==9.7.0
# via -r requirements.in
packaging==26.2
# via pytest
pluggy==1.6.0
# via
# pytest
# pytest-cov
pygments==2.20.0
# via pytest
pyjwt==2.12.1
# via flask-jwt-extended
pymysql==1.1.3
# via -r requirements.in
pytest==9.0.3
# via
# -r requirements.in
# pytest-cov
# pytest-flask
pytest-cov==7.1.0
# via -r requirements.in
pytest-flask==1.3.0
# via -r requirements.in
python-dotenv==1.2.2
# via -r requirements.in
requests==2.33.1
# via -r requirements.in
sqlalchemy==2.0.49
# via
# alembic
# flask-sqlalchemy
# marshmallow-sqlalchemy
tabulate==0.10.0
# via -r requirements.in
typing-extensions==4.15.0
# via
# alembic
# sqlalchemy
urllib3==2.7.0
# via requests
werkzeug==3.1.8
# via
# -r requirements.in
# flask
# flask-cors
# flask-jwt-extended
# pytest-flask

View File

@@ -24,8 +24,13 @@ def create_app(config_name: str = None) -> Flask:
app = Flask(__name__, instance_relative_config=True) app = Flask(__name__, instance_relative_config=True)
# Load configuration config_class = config.get(config_name, config['default'])
app.config.from_object(config.get(config_name, config['default']))
# Production must validate its env-driven config before boot.
if config_name == 'production' and hasattr(config_class, 'validate'):
config_class.validate()
app.config.from_object(config_class)
# Load instance config if exists # Load instance config if exists
app.config.from_pyfile('config.py', silent=True) app.config.from_pyfile('config.py', silent=True)
@@ -60,7 +65,7 @@ def create_app(config_name: str = None) -> Flask:
def user_lookup_callback(_jwt_header, jwt_data): def user_lookup_callback(_jwt_header, jwt_data):
from .core.models import User from .core.models import User
identity = jwt_data["sub"] identity = jwt_data["sub"]
return User.query.get(int(identity)) return db.session.get(User, int(identity))
return app return app
@@ -187,11 +192,15 @@ def register_frontend_routes(app: Flask):
from .utils.responses import error_response, ErrorCodes from .utils.responses import error_response, ErrorCodes
return error_response(ErrorCodes.NOT_FOUND, 'API endpoint not found', http_code=404) return error_response(ErrorCodes.NOT_FOUND, 'API endpoint not found', http_code=404)
# Serve static assets # Try to serve a static asset directly. send_from_directory handles
if path and os.path.exists(os.path.join(frontend_dist, path)): # the safe-join + 404 itself; no explicit existence probe needed
return send_from_directory(frontend_dist, path) # (the probe was a path-traversal risk surface).
if path:
try:
return send_from_directory(frontend_dist, path)
except Exception:
pass
# Serve index.html for SPA routing
return send_from_directory(frontend_dist, 'index.html') return send_from_directory(frontend_dist, 'index.html')

View File

@@ -3,17 +3,33 @@
import os import os
from datetime import timedelta 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: class Config:
"""Base configuration.""" """Base configuration."""
# Flask
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production') SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
# SQLAlchemy
SQLALCHEMY_DATABASE_URI = os.environ.get( SQLALCHEMY_DATABASE_URI = os.environ.get(
'DATABASE_URL', 'DATABASE_URL',
'mysql+pymysql://root:password@localhost:3306/shopdb_flask' 'mysql+pymysql://root:password@localhost:3306/shopdb_flask',
) )
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ENGINE_OPTIONS = { SQLALCHEMY_ENGINE_OPTIONS = {
@@ -21,7 +37,6 @@ class Config:
'pool_recycle': 300, 'pool_recycle': 300,
} }
# JWT
JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'jwt-secret-key-change-in-production') JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'jwt-secret-key-change-in-production')
JWT_ACCESS_TOKEN_EXPIRES = timedelta( JWT_ACCESS_TOKEN_EXPIRES = timedelta(
seconds=int(os.environ.get('JWT_ACCESS_TOKEN_EXPIRES', 3600)) 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)) seconds=int(os.environ.get('JWT_REFRESH_TOKEN_EXPIRES', 2592000))
) )
# CORS CORS_ORIGINS = [
CORS_ORIGINS = os.environ.get('CORS_ORIGINS', '*').split(',') 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') LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO')
# Zabbix
ZABBIX_URL = os.environ.get('ZABBIX_URL', '') ZABBIX_URL = os.environ.get('ZABBIX_URL', '')
ZABBIX_TOKEN = os.environ.get('ZABBIX_TOKEN', '') ZABBIX_TOKEN = os.environ.get('ZABBIX_TOKEN', '')
# Cache
CACHE_TYPE = 'SimpleCache' CACHE_TYPE = 'SimpleCache'
CACHE_DEFAULT_TIMEOUT = 600 # 10 minutes CACHE_DEFAULT_TIMEOUT = 600
# Pagination
DEFAULT_PAGE_SIZE = 20 DEFAULT_PAGE_SIZE = 20
MAX_PAGE_SIZE = 100 MAX_PAGE_SIZE = 100
@@ -55,12 +69,10 @@ class DevelopmentConfig(Config):
DEBUG = True DEBUG = True
SQLALCHEMY_ECHO = True SQLALCHEMY_ECHO = True
# Use MySQL from DATABASE_URL
SQLALCHEMY_DATABASE_URI = os.environ.get( SQLALCHEMY_DATABASE_URI = os.environ.get(
'DATABASE_URL', '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): class TestingConfig(Config):
@@ -68,23 +80,70 @@ class TestingConfig(Config):
TESTING = True TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:' SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
SQLALCHEMY_ENGINE_OPTIONS = {
'connect_args': {'check_same_thread': False},
'poolclass': StaticPool,
}
JWT_ACCESS_TOKEN_EXPIRES = timedelta(seconds=5) JWT_ACCESS_TOKEN_EXPIRES = timedelta(seconds=5)
class ProductionConfig(Config): 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 DEBUG = False
SQLALCHEMY_ECHO = False SQLALCHEMY_ECHO = False
# Stricter security in production
JWT_COOKIE_SECURE = True JWT_COOKIE_SECURE = True
JWT_COOKIE_CSRF_PROTECT = 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 = { config = {
'development': DevelopmentConfig, 'development': DevelopmentConfig,
'testing': TestingConfig, 'testing': TestingConfig,
'production': ProductionConfig, 'production': ProductionConfig,
'default': DevelopmentConfig 'default': DevelopmentConfig,
} }

View File

@@ -1,51 +1,84 @@
"""Pytest configuration and fixtures.""" """Pytest configuration and fixtures for shopdb-flask.
Strategy: in-memory SQLite via StaticPool (configured in TestingConfig)
so the database is shared across the connection. Each test drops and
recreates the schema. Simple, totally isolated, fast enough for a small
schema. Switch to savepoint-per-test if test count grows past a few
hundred.
"""
import os
import pytest import pytest
from werkzeug.security import generate_password_hash
# Force testing config before any shopdb import touches the env.
os.environ['FLASK_ENV'] = 'testing'
from shopdb import create_app from shopdb import create_app
from shopdb.extensions import db as _db from shopdb.extensions import db as _db
@pytest.fixture(scope='session') @pytest.fixture(scope='session')
def app(): def app():
"""Create application for testing.""" """Create the Flask application for the test session."""
app = create_app('testing') application = create_app('testing')
return app return application
@pytest.fixture(scope='session')
def db(app):
"""Create database for testing."""
with app.app_context():
_db.create_all()
yield _db
_db.drop_all()
@pytest.fixture(scope='function') @pytest.fixture(scope='function')
def session(db): def db(app):
"""Create a new database session for a test.""" """Provide a fresh database per test. Drops and recreates schema each run."""
connection = db.engine.connect() with app.app_context():
transaction = connection.begin() _db.create_all()
yield _db
options = dict(bind=connection, binds={}) _db.session.remove()
session = db.create_scoped_session(options=options) _db.drop_all()
db.session = session
yield session
transaction.rollback()
connection.close()
session.remove()
@pytest.fixture @pytest.fixture
def client(app): def client(app):
"""Create test client.""" """Flask test client."""
return app.test_client() return app.test_client()
@pytest.fixture @pytest.fixture
def runner(app): def runner(app):
"""Create CLI runner.""" """Flask CLI test runner."""
return app.test_cli_runner() return app.test_cli_runner()
@pytest.fixture
def admin_user(db):
"""Create an admin user for authenticated tests.
The user has username 'testadmin' and password 'testpass'.
"""
from shopdb.core.models import User, Role
role = Role(rolename='admin', description='Administrator')
db.session.add(role)
db.session.flush()
user = User(
username='testadmin',
email='admin@test.local',
passwordhash=generate_password_hash('testpass'),
)
user.roles.append(role)
db.session.add(user)
db.session.commit()
return user
@pytest.fixture
def auth_headers(client, admin_user):
"""Log in as admin_user and return Authorization headers."""
response = client.post(
'/api/auth/login',
json={'username': 'testadmin', 'password': 'testpass'},
)
assert response.status_code == 200, f'Login failed: {response.get_json()}'
payload = response.get_json()
token = payload['data']['access_token']
return {'Authorization': f'Bearer {token}'}

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()

110
tests/test_smoke.py Normal file
View File

@@ -0,0 +1,110 @@
"""Smoke tests pinning the baseline behavior of shopdb-flask.
These eight tests are the safety net required before any structural
refactor proceeds. See `~/.claude/skills/pinning-flask-behavior.md`.
"""
import pytest
def test_app_factory_creates_app(app):
"""create_app('testing') returns a Flask app with TESTING=True."""
assert app is not None
assert app.config['TESTING'] is True
assert 'sqlite' in app.config['SQLALCHEMY_DATABASE_URI']
def test_login_with_valid_credentials_returns_tokens(client, admin_user):
"""POST /api/auth/login with valid creds returns access and refresh tokens."""
response = client.post(
'/api/auth/login',
json={'username': 'testadmin', 'password': 'testpass'},
)
assert response.status_code == 200
payload = response.get_json()
assert 'data' in payload
data = payload['data']
assert 'access_token' in data
assert 'refresh_token' in data
assert 'user' in data
assert data['user']['username'] == 'testadmin'
def test_login_with_invalid_credentials_returns_401(client, admin_user):
"""Wrong password returns 401 with the documented error envelope.
Pins the current shape: error info nested under `data.error` (not at
top level). The error_response docstring claims top-level `error` but
the implementation puts it under `data`. Pinned as-is until that
inconsistency is intentionally addressed.
"""
response = client.post(
'/api/auth/login',
json={'username': 'testadmin', 'password': 'wrongpassword'},
)
assert response.status_code == 401
payload = response.get_json()
assert payload['status'] == 'error'
assert payload['data']['error']['code'] == 'UNAUTHORIZED'
def test_login_with_missing_fields_returns_400(client):
"""Missing username or password returns 400 validation error."""
response = client.post('/api/auth/login', json={})
assert response.status_code == 400
def test_protected_route_requires_authentication(client, admin_user):
"""GET /api/users without a JWT returns 401."""
response = client.get('/api/users')
assert response.status_code == 401
def test_protected_route_works_with_jwt(client, auth_headers):
"""GET /api/users with a valid JWT returns 200."""
response = client.get('/api/users', headers=auth_headers)
assert response.status_code == 200
def test_paginated_response_shape(client, auth_headers):
"""A paginated list endpoint returns data plus pagination meta.
Uses /api/locations because it is a simple platform endpoint that
uses paginated_response. Pagination meta keys follow the naming
convention (lowercase concatenated): page, perpage, total,
totalpages, hasnext, hasprev.
"""
response = client.get('/api/locations', headers=auth_headers)
assert response.status_code == 200
payload = response.get_json()
assert 'data' in payload
assert isinstance(payload['data'], list)
assert 'meta' in payload
assert 'pagination' in payload['meta']
pagination = payload['meta']['pagination']
assert 'page' in pagination
assert 'perpage' in pagination
assert 'total' in pagination
assert 'totalpages' in pagination
def test_plugin_loader_discovers_bundled_plugins(app):
"""Plugin manager finds the six bundled plugins."""
from shopdb.plugins import plugin_manager
expected_plugins = {
'computers',
'equipment',
'network',
'notifications',
'printers',
'usb',
}
with app.app_context():
loader = plugin_manager.loader
discovered = set(loader.discover_plugins())
assert expected_plugins.issubset(discovered), (
f'Missing bundled plugins: {expected_plugins - discovered}'
)