CI / Python lint (flake8) (push) Has been cancelled
CI / Python syntax check (push) Has been cancelled
CI / Alembic migration chain (push) Has been cancelled
CI / JavaScript syntax check (push) Has been cancelled
CI / Pytest (push) Has been cancelled
CI / Build extension zip (push) Has been cancelled
110 lines
4.0 KiB
Python
110 lines
4.0 KiB
Python
"""
|
|
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'
|
|
)
|