"""Tests for the composition root: validate_config fails fast, init_hardening wires everything. The contract under test: a bad RATELIMIT_MODE / RATELIMIT_DEFAULT / RATELIMIT_STORAGE_URI raises at validate_config (before any middleware installs), and init_hardening(app, config) leaves every piece of the kit active on a bare Flask app -- proxyfix, the leak-free error handlers, the body limit, security headers, compression, and rate limiting. """ import pytest from flask import Flask, jsonify from werkzeug.exceptions import TooManyRequests from werkzeug.middleware.proxy_fix import ProxyFix from core.config import Config from core.hardening import init_hardening, validate_config from core.hardening.breaker import CircuitBreakerError class _ValidCfg(Config): RATELIMIT_MODE = 'shadow' RATELIMIT_STORAGE_URI = 'memory://' RATELIMIT_DEFAULT = '1000/minute' class _BadModeCfg(_ValidCfg): RATELIMIT_MODE = 'off' class _BadLimitCfg(_ValidCfg): RATELIMIT_DEFAULT = 'garbage' class _EmptyLimitCfg(_ValidCfg): RATELIMIT_DEFAULT = '' class _SchemelessStorageCfg(_ValidCfg): RATELIMIT_STORAGE_URI = 'localhost' def test_validate_config_passes_on_valid_config(): """A well-formed config raises nothing.""" validate_config(_ValidCfg) def test_validate_config_rejects_bad_mode(): """An unrecognized RATELIMIT_MODE raises, naming the field.""" with pytest.raises(ValueError, match='RATELIMIT_MODE'): validate_config(_BadModeCfg) def test_validate_config_rejects_unparseable_limit_string(): """A malformed RATELIMIT_DEFAULT raises, naming the field.""" with pytest.raises(ValueError, match='RATELIMIT_DEFAULT'): validate_config(_BadLimitCfg) def test_validate_config_rejects_empty_limit_string(): """An empty RATELIMIT_DEFAULT raises, naming the field.""" with pytest.raises(ValueError, match='RATELIMIT_DEFAULT'): validate_config(_EmptyLimitCfg) def test_validate_config_rejects_schemeless_storage_uri(): """A RATELIMIT_STORAGE_URI with no scheme raises, naming the field.""" with pytest.raises(ValueError, match='RATELIMIT_STORAGE_URI'): validate_config(_SchemelessStorageCfg) def _build_app(config: type[Config] = _ValidCfg) -> Flask: app = Flask(__name__) app.config.from_object( config ) # MAX_CONTENT_LENGTH etc. -- mirrors create_app's own order init_hardening(app, config) @app.get('/hello') def hello(): return jsonify({'ok': True}) @app.get('/breaker-open') def breaker_open(): raise CircuitBreakerError('some-resource') return app def test_init_hardening_raises_on_bad_config_before_installing_anything(): """A bad config fails at init_hardening, not later at request time.""" with pytest.raises(ValueError, match='RATELIMIT_MODE'): init_hardening(Flask(__name__), _BadModeCfg) def test_init_hardening_installs_proxyfix(): """The app's wsgi_app is wrapped in ProxyFix.""" app = _build_app() assert isinstance(app.wsgi_app, ProxyFix) def test_init_hardening_installs_security_headers(): """A real response carries the app-owned security headers.""" client = _build_app().test_client() res = client.get('/hello') assert res.headers['X-Content-Type-Options'] == 'nosniff' assert res.headers['X-Frame-Options'] == 'DENY' def test_init_hardening_installs_body_limit(): """MAX_CONTENT_LENGTH from config is loaded onto the app.""" app = _build_app() assert app.config['MAX_CONTENT_LENGTH'] == Config.MAX_CONTENT_LENGTH def test_init_hardening_installs_compression(): """flask-compress config defaults are set, with streams excluded.""" app = _build_app() assert app.config.get('COMPRESS_STREAMS') is False def test_init_hardening_installs_rate_limiting(): """Rate-limit headers on a response prove the before/after_request hooks are wired.""" client = _build_app().test_client() res = client.get('/hello') assert 'X-RateLimit-Limit' in res.headers def test_init_hardening_registers_leak_free_breaker_handler(): """A CircuitBreakerError renders the generic 503, not an unhandled-exception 500.""" client = _build_app().test_client() res = client.get('/breaker-open') assert res.status_code == 503 assert b'some-resource' not in res.data def test_init_hardening_registers_leak_free_rate_limit_handler(): """A bare TooManyRequests renders the generic 429 via the hardening handler.""" app = _build_app() @app.get('/breach') def breach(): raise TooManyRequests res = app.test_client().get('/breach') assert res.status_code == 429