Files
shopdb-flask/tests/test_security_config.py
cproudlock 5fa5160420 Apply skill-driven review fixes: security, hook isolation, tests, docs
Addresses findings from a 6-lens review against the project skills
(defining-asset-contract, enforcing-plugin-contract, hardening-flask-config,
integrating-plugin-hooks, pinning-flask-behavior, simplifying-python).

Security (hardening-flask-config):
- Load per-plugin COLLECTOR_API_KEY_<PLUGIN> from env in create_app. from_object
  only copies class attributes, so per-plugin keys (ADR-006) were dead in real
  deploys and silently fell back to the shared key.
- EMPLOYEE_DB_USER/PASSWORD no longer default to root/rootpassword (no safe
  default for a secret; unset fails loud). Documented in .env.example + DEPLOY.md.
- COLLECTOR_API_KEY + per-plugin + EMPLOYEE_DB_* added to .env.example/DEPLOY.md.

Hook isolation (integrating-plugin-hooks):
- collector _collector_plugins and dashboard get_navigation now re-raise in
  dev/test and log+isolate in prod, instead of silently swallowing a broken
  plugin hook.

Plugin loader (enforcing-plugin-contract):
- enable_plugin/install_plugin read dependencies+version from the manifest
  instead of instantiating the plugin class.
- _register_plugin_components rejects a second plugin claiming an already-used
  api_prefix (reset per app in init_app).

Tests (pinning-flask-behavior):
- test_identifiers.py: gauge/maintenance round-trip on computer/printer/network
  create+update; per-type seed yields the 12 identifier keys.
- contract tests for apply_collector_payload presence + schema-declarers-implement.
- security tests for per-plugin key env loading + no employee-db password default.

Docs/contract sync (defining-asset-contract):
- PLUGIN-HOOKS.md documents apply_collector_payload; stale 0.2.0 -> 0.3.0.
- ADR-006 documents apply_collector_payload + single-dispatch rationale.
- ADR-001 enumerates the expanded shopdb.api import surface.

Simplify (simplifying-python):
- De-duplicate the 21-entry settings defaults: shared build_default_settings()
  used by both the /settings/seed route and the CLI (were drifting copies).
- Remove dead AssetStatus import + redundant AssetType local import in computers
  plugin; comment the statusid=1 collector default.

153 tests pass (was 145), naming/style green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:25:52 -04:00

91 lines
3.6 KiB
Python

"""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()
def test_per_plugin_collector_key_loaded_from_env(monkeypatch):
"""COLLECTOR_API_KEY_<PLUGIN> is a dynamic env var; create_app must load it.
from_object only copies class attributes, so per-plugin keys (ADR-006)
would be invisible without the explicit env scan in create_app.
"""
from shopdb import create_app
monkeypatch.setenv('COLLECTOR_API_KEY_COMPUTERS', 'computers-secret')
app = create_app('testing')
assert app.config.get('COLLECTOR_API_KEY_COMPUTERS') == 'computers-secret'
def test_employee_db_password_has_no_default():
"""No safe default for the employee-DB password: unset means empty."""
if 'EMPLOYEE_DB_PASSWORD' not in os.environ:
from shopdb.config import Config
assert Config.EMPLOYEE_DB_PASSWORD == ''