"""Behavioral tests for app-owned security headers and enforced no-store. The contract under test: every non-passthrough response carries the four static security headers (nosniff, frame DENY, referrer policy, COOP, CSP) and an authoritative ``Cache-Control`` resolved three ways -- ``no-store`` for mutating methods and ``@no_store``-marked views (a caching directive on such a response is force-corrected and warned), ``no-cache`` when the view set none, and otherwise the view's own directive honored (how reference endpoints assert ``public, max-age``). Passthrough (``direct_passthrough``) responses are returned untouched so their byte stream is never consumed. """ import logging from flask import Flask, Response from flask.testing import FlaskClient from flask.views import MethodView from core.config import Config from core.hardening.security_headers import no_store, setup_security_headers class _Cfg(Config): pass def _client() -> FlaskClient: app = Flask(__name__) setup_security_headers(app, _Cfg) @app.get('/g') def g() -> str: return 'ok' @app.post('/p') def p() -> str: return 'ok' @app.post('/p-cached') def p_cached() -> Response: return Response('ok', headers={'Cache-Control': 'public, max-age=99999'}) @app.get('/secret') @no_store def secret() -> str: return 'ok' @app.get('/secret-cached') @no_store def secret_cached() -> Response: return Response('ok', headers={'Cache-Control': 'public, max-age=99999'}) class _NoStoreMethodView(MethodView): @no_store def get(self) -> Response: return Response('ok', headers={'Cache-Control': 'public, max-age=99999'}) app.add_url_rule('/mv-secret', view_func=_NoStoreMethodView.as_view('mv_secret')) @app.get('/stream') def stream() -> Response: return Response(b'binary', direct_passthrough=True) @app.get('/cached') def cached() -> Response: return Response('ok', headers={'Cache-Control': 'public, max-age=99999'}) return app.test_client() def test_default_headers_and_no_cache() -> None: """A plain GET carries the static security headers and a ``no-cache`` directive.""" headers = _client().get('/g').headers assert headers['X-Content-Type-Options'] == 'nosniff' assert headers['X-Frame-Options'] == 'DENY' assert headers['Referrer-Policy'] == 'strict-origin-when-cross-origin' assert headers['Cross-Origin-Opener-Policy'] == 'same-origin' assert ( headers['Content-Security-Policy'] == "default-src 'none'; frame-ancestors 'none'" ) assert headers['Cache-Control'] == 'no-cache' def test_mutating_method_and_marked_get_are_no_store() -> None: """Mutating methods and ``@no_store``-marked GETs are authoritatively ``no-store``.""" client = _client() assert client.post('/p').headers['Cache-Control'] == 'no-store' assert client.get('/secret').headers['Cache-Control'] == 'no-store' def test_passthrough_response_is_untouched() -> None: """A ``direct_passthrough`` response is returned without any security headers.""" headers = _client().get('/stream').headers assert 'X-Content-Type-Options' not in headers assert 'X-Frame-Options' not in headers assert 'Cache-Control' not in headers def test_non_sensitive_view_cache_control_is_honored() -> None: """A non-sensitive GET's own Cache-Control is honored, not stomped to no-cache. This is the reference-endpoint path: the cache_reference_data decorator sets ``public, max-age`` on a plain GET and the hook must leave it intact. """ assert _client().get('/cached').headers['Cache-Control'] == 'public, max-age=99999' def test_mutating_response_with_cache_directive_is_forced_no_store_and_warns( caplog, ) -> None: """A mutating response with a caching directive is forced to no-store and warns. It never raises: after_request runs after the write has committed. """ with caplog.at_level(logging.WARNING): resp = _client().post('/p-cached') assert resp.headers['Cache-Control'] == 'no-store' assert any('no-store' in r.message for r in caplog.records) def test_no_store_get_with_cache_directive_is_forced_no_store_and_warns( caplog, ) -> None: """The no-store invariant holds via the @no_store path, not only mutating methods. A @no_store GET that sets a caching directive is forced to no-store and warns. """ with caplog.at_level(logging.WARNING): resp = _client().get('/secret-cached') assert resp.headers['Cache-Control'] == 'no-store' assert any('no-store' in r.message for r in caplog.records) def test_no_store_on_methodview_get_is_forced_no_store(caplog) -> None: """@no_store must work on class-based MethodView routes, not only function views. MethodView is the dominant route style here (ItemView/ListView). The marker lands on the get() method while view_functions holds the as_view() closure, so the hook must resolve the method; the response is force-corrected to no-store and warns. """ with caplog.at_level(logging.WARNING): resp = _client().get('/mv-secret') assert resp.headers['Cache-Control'] == 'no-store' assert any('no-store' in r.message for r in caplog.records) def test_no_store_on_methodview_head_is_forced_no_store() -> None: """HEAD to a @no_store MethodView GET is also no-store. Flask runs get() for a HEAD when no head() is defined, so the marker must apply to HEAD too; otherwise HEAD would leak the get()'s caching directive. """ assert _client().head('/mv-secret').headers['Cache-Control'] == 'no-store' def test_non_sensitive_get_without_directive_is_no_cache() -> None: """A non-sensitive GET that sets no Cache-Control falls through to no-cache. This is the TTL=0 case, where cache_reference_data sets no directive. """ assert _client().get('/g').headers['Cache-Control'] == 'no-cache'