diff --git a/plugins/printers/models/printer_extension.py b/plugins/printers/models/printer_extension.py index 3a59258..12144b6 100644 --- a/plugins/printers/models/printer_extension.py +++ b/plugins/printers/models/printer_extension.py @@ -51,7 +51,7 @@ class PrinterData(BaseModel): ) __table_args__ = ( - db.Index('idx_printer_windowsname', 'windowsname'), + db.Index('idx_printerdata_windowsname', 'windowsname'), ) def __repr__(self): diff --git a/requirements.in b/requirements.in new file mode 100644 index 0000000..87eb7d1 --- /dev/null +++ b/requirements.in @@ -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 diff --git a/requirements.txt b/requirements.txt index 87eb7d1..4368c8c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,32 +1,119 @@ -# 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 +# This file was autogenerated by uv via the following command: +# uv pip compile requirements.in -o requirements.txt +alembic==1.18.4 + # via flask-migrate +blinker==1.9.0 + # via flask +cachelib==0.13.0 + # via flask-caching +certifi==2026.4.22 + # via requests +charset-normalizer==3.4.7 + # via requests +click==8.3.3 + # via + # -r requirements.in + # flask +coverage==7.13.5 + # via pytest-cov +dnspython==2.8.0 + # via email-validator +email-validator==2.3.0 + # via -r requirements.in +flask==3.1.3 + # via + # -r requirements.in + # flask-caching + # flask-cors + # flask-jwt-extended + # flask-marshmallow + # flask-migrate + # flask-sqlalchemy + # 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 diff --git a/shopdb/__init__.py b/shopdb/__init__.py index f6da5a0..1b08dd7 100644 --- a/shopdb/__init__.py +++ b/shopdb/__init__.py @@ -24,8 +24,13 @@ def create_app(config_name: str = None) -> Flask: app = Flask(__name__, instance_relative_config=True) - # Load configuration - app.config.from_object(config.get(config_name, config['default'])) + config_class = 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 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): from .core.models import User identity = jwt_data["sub"] - return User.query.get(int(identity)) + return db.session.get(User, int(identity)) return app @@ -187,11 +192,15 @@ def register_frontend_routes(app: Flask): from .utils.responses import error_response, ErrorCodes return error_response(ErrorCodes.NOT_FOUND, 'API endpoint not found', http_code=404) - # Serve static assets - if path and os.path.exists(os.path.join(frontend_dist, path)): - return send_from_directory(frontend_dist, path) + # Try to serve a static asset directly. send_from_directory handles + # the safe-join + 404 itself; no explicit existence probe needed + # (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') diff --git a/shopdb/config.py b/shopdb/config.py index 3dd4e46..27cce33 100644 --- a/shopdb/config.py +++ b/shopdb/config.py @@ -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, } diff --git a/tests/conftest.py b/tests/conftest.py index 2fc17d6..03639e1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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}'} diff --git a/tests/test_security_config.py b/tests/test_security_config.py new file mode 100644 index 0000000..2f35aff --- /dev/null +++ b/tests/test_security_config.py @@ -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() diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..d67613d --- /dev/null +++ b/tests/test_smoke.py @@ -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}' + )