""" 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' ) def test_api_rate_limit_is_not_absurdly_tight(): """ api_limit was 60r/m — 1 req/s for the entire API. A vault page load fires several /api/* calls, so normal use produced spurious 429s. The units are easy to misread, which is exactly why this is pinned. """ m = re.search(r'zone=api_limit:\S+\s+rate=(\d+)r/([sm]);', NGINX) assert m, 'api_limit zone not found' per_second = int(m.group(1)) / (1 if m.group(2) == 's' else 60) assert per_second >= 5, ( f'api_limit is {per_second:.2f} req/s — too tight for normal vault use' ) def test_auth_rate_limit_stays_tight(): """The brute-force surface must NOT be widened along with api_limit.""" m = re.search(r'zone=auth_limit:\S+\s+rate=(\d+)r/([sm]);', NGINX) assert m, 'auth_limit zone not found' per_minute = int(m.group(1)) * (60 if m.group(2) == 's' else 1) assert per_minute <= 60, f'auth_limit is {per_minute} req/min — too permissive' # -- Extension packaging ----------------------------------------------------- EXT = ROOT / 'extension' @pytest.mark.parametrize('manifest_name', ['manifest.json', 'manifest.firefox.json']) def test_manifest_loads_psl_before_content_script(manifest_name): """ content.js calls PkPsl at match time. If psl.js is missing from the manifest the matcher silently falls back to exact-hostname equality, quietly losing every subdomain match. """ import json manifest = json.loads((EXT / manifest_name).read_text(encoding='utf-8')) blocks = [cs for cs in manifest.get('content_scripts', []) if any('content/content.js' in f for f in cs['js'])] assert blocks, f'{manifest_name} has no content.js block' for cs in blocks: assert 'shared/psl.js' in cs['js'], f'{manifest_name}: psl.js not loaded' assert cs['js'].index('shared/psl.js') < cs['js'].index('content/content.js'), \ f'{manifest_name}: psl.js must load BEFORE content.js' def test_popup_html_loads_psl(): # Compare the parsed