""" Guards on deployment configuration that only bites in production. These are the settings whose failure mode is an intermittent 502 rather than a stack trace, so nothing else catches them drifting apart. """ import re from pathlib import Path import pytest ROOT = Path(__file__).resolve().parent.parent NGINX = (ROOT / 'scripts' / 'passkeeper-nginx.conf').read_text(encoding='utf-8') UNIT = (ROOT / 'scripts' / 'passkeeper.service').read_text(encoding='utf-8') @pytest.fixture(scope='module') def gunicorn_conf(): ns = {} exec(compile((ROOT / 'gunicorn.conf.py').read_text(encoding='utf-8'), 'gunicorn.conf.py', 'exec'), ns) return ns def _nginx_seconds(directive): m = re.search(rf'^\s*{directive}\s+(\d+)s;', NGINX, re.M) assert m, f'{directive} not found in passkeeper-nginx.conf' return int(m.group(1)) def test_nginx_gives_up_before_gunicorn_kills_the_worker(gunicorn_conf): """ The 502 invariant. If Gunicorn's timeout fires first the connection is severed mid-response and Nginx reports 502; if Nginx times out first the client gets a clean 504 instead. """ assert _nginx_seconds('proxy_read_timeout') < gunicorn_conf['timeout'] def test_gunicorn_holds_keepalive_longer_than_nginx(gunicorn_conf): """ Nginx must be the side that closes an idle upstream connection. If Gunicorn closes one as Nginx is reusing it, that request fails as a 502. """ assert gunicorn_conf['keepalive'] > _nginx_seconds('keepalive_timeout') def test_preload_app_is_disabled(gunicorn_conf): """ create_app() starts an APScheduler thread, and threads do not survive fork(). Under preload_app the scheduler would exist only in the arbiter, which serves no requests, so the cleanup job would silently never run. """ assert gunicorn_conf['preload_app'] is False def test_static_location_repeats_every_security_header(): """ Nginx drops ALL inherited add_header directives in any location that declares one of its own. /static/ sets Cache-Control, so without explicit copies every JS and CSS asset ships with no CSP, HSTS or X-Frame-Options. """ static = re.search(r'location /static/ \{(.*?)\n \}', NGINX, re.S) assert static, 'no /static/ location block found' body = static.group(1) for header in ('Strict-Transport-Security', 'X-Frame-Options', 'X-Content-Type-Options', 'Referrer-Policy', 'Permissions-Policy', 'Content-Security-Policy'): assert header in body, f'/static/ is missing {header}' def test_hibp_origin_is_allowed_in_every_csp(): """The security dashboard's breach check needs this origin in connect-src.""" policies = re.findall(r'connect-src[^;"]*', NGINX) assert policies, 'no connect-src directive found' for p in policies: assert 'https://api.pwnedpasswords.com' in p, p def test_unit_has_no_watchdog(): """ WatchdogSec without Type=notify meant systemd never received a keepalive, declared the service hung, and SIGKILLed it on a loop — a repeating window of 502s. Re-enabling it requires Type=notify AND NotifyAccess=main. """ active = [ln for ln in UNIT.splitlines() if ln.strip().startswith('WatchdogSec')] if active: assert 'Type=notify' in UNIT and 'NotifyAccess=main' in UNIT, ( 'WatchdogSec requires Type=notify + NotifyAccess=main or systemd ' 'will kill the service on a loop' ) def test_unit_reload_does_not_use_usr2(): """ USR2 forks a second master without retiring the first, leaving systemd's $MAINPID tracking a stale process. """ reload_line = next((ln for ln in UNIT.splitlines() if ln.strip().startswith('ExecReload=')), '') assert 'USR2' not in reload_line, reload_line def test_unit_loads_the_gunicorn_config_file(): assert 'gunicorn.conf.py' in UNIT, ( 'the unit no longer references gunicorn.conf.py, so its tuning is dead code' )