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:
@@ -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
32
requirements.in
Normal 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
|
||||||
151
requirements.txt
151
requirements.txt
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}'}
|
||||||
|
|||||||
70
tests/test_security_config.py
Normal file
70
tests/test_security_config.py
Normal 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
110
tests/test_smoke.py
Normal 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}'
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user