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:
@@ -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
|
||||
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.extensions import db as _db
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def app():
|
||||
"""Create application for testing."""
|
||||
app = create_app('testing')
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def db(app):
|
||||
"""Create database for testing."""
|
||||
with app.app_context():
|
||||
_db.create_all()
|
||||
yield _db
|
||||
_db.drop_all()
|
||||
"""Create the Flask application for the test session."""
|
||||
application = create_app('testing')
|
||||
return application
|
||||
|
||||
|
||||
@pytest.fixture(scope='function')
|
||||
def session(db):
|
||||
"""Create a new database session for a test."""
|
||||
connection = db.engine.connect()
|
||||
transaction = connection.begin()
|
||||
|
||||
options = dict(bind=connection, binds={})
|
||||
session = db.create_scoped_session(options=options)
|
||||
|
||||
db.session = session
|
||||
|
||||
yield session
|
||||
|
||||
transaction.rollback()
|
||||
connection.close()
|
||||
session.remove()
|
||||
def db(app):
|
||||
"""Provide a fresh database per test. Drops and recreates schema each run."""
|
||||
with app.app_context():
|
||||
_db.create_all()
|
||||
yield _db
|
||||
_db.session.remove()
|
||||
_db.drop_all()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""Create test client."""
|
||||
"""Flask test client."""
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner(app):
|
||||
"""Create CLI runner."""
|
||||
"""Flask CLI test 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