"""Verify the env-driven HTTPS bootstrap in https_manager.py. Covers the behaviours the user asked for: 1. Both HOSTNAME_INTERNAL + HOST_IP set -> HTTPS configured at startup 2. Either missing -> NO-OP, HTTP fallback stays 3. Re-running with the same env -> idempotent 4. HTTPS_HTTP_FALLBACK=false -> redirect to the published port 5. Admin UI change after bootstrap -> overrides the env value Run: PYTHONPATH=$(pwd) ./.venv/bin/python docs/tools/test_https_bootstrap.py """ import importlib.util import os import shutil import tempfile # DATABASE_URL must be set BEFORE importing the app: ProductionConfig evaluates # it at class-definition (import) time. TMPDIR = tempfile.mkdtemp() DB_PATH = os.path.join(TMPDIR, 'bootstrap.db') os.environ['DATABASE_URL'] = f'sqlite:///{DB_PATH}' from app.app import create_app # noqa: E402 from app.extensions import db # noqa: E402 from app.models.https_config import HTTPSConfig # noqa: E402 REPO = '/home/scheianu/digiserver-v2' TARGET = os.path.join(TMPDIR, 'Caddyfile') PLACEHOLDER = ':80 {\n respond "placeholder"\n}\n' app = create_app('production') def reset_db(): """Drop and recreate the schema for a clean case.""" with app.app_context(): db.drop_all() db.create_all() def load_manager(): """Import https_manager with file writes/reloads redirected to TARGET.""" spec = importlib.util.spec_from_file_location( 'hm_boot', os.path.join(REPO, 'https_manager.py')) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) mod.CaddyConfigGenerator.write_caddyfile = staticmethod( lambda content, path=TARGET: (open(TARGET, 'w').write(content), True)[1]) mod.CaddyConfigGenerator.reload_caddy = staticmethod(lambda: True) return mod def reset_env(): for k in ('HOSTNAME_INTERNAL', 'HOST_IP', 'DOMAIN', 'SSL_EMAIL', 'HTTPS_PORT', 'HTTPS_HTTP_FALLBACK', 'HTTPS_VERIFY'): os.environ.pop(k, None) # No live Caddy in these unit tests, so skip the post-reload TLS probe. # The probe + automatic fallback are covered by # docs/tools/test_https_fallback.py. os.environ['HTTPS_VERIFY'] = 'false' def blocks(): return [ln.strip() for ln in open(TARGET).read().splitlines() if ln.strip().startswith(('http://', 'https://', ':80'))] def config(): with app.app_context(): return HTTPSConfig.get_config() print('=' * 68) print('CASE 1 — both vars set -> HTTPS configured at startup') print('=' * 68) reset_db(); reset_env() open(TARGET, 'w').write(PLACEHOLDER) os.environ.update(HOSTNAME_INTERNAL='digiserver', HOST_IP='192.168.0.152', SSL_EMAIL='admin@example.com') hm = load_manager() rc = hm.bootstrap_from_env(app) c = config() print('exit code:', rc, '| https_enabled:', c.https_enabled, '| domain:', repr(c.domain)) print('blocks:', blocks()) assert rc == 0 assert c.https_enabled is True assert not c.domain, 'empty domain must select the internal CA' assert 'tls internal' in open(TARGET).read() assert 'http://192.168.0.152' in open(TARGET).read() print('PASS: internal CA + HTTP fallback generated\n') print('=' * 68) print('CASE 2 — HOST_IP missing -> no-op, HTTP fallback remains') print('=' * 68) reset_db(); reset_env() open(TARGET, 'w').write(PLACEHOLDER) os.environ['HOSTNAME_INTERNAL'] = 'digiserver' # HOST_IP absent hm = load_manager() rc = hm.bootstrap_from_env(app) c = config() print('exit code:', rc, '| config:', 'none' if c is None else c.https_enabled) assert rc == 0, 'must not fail container startup' assert c is None or c.https_enabled is False, 'HTTPS must stay off' assert open(TARGET).read() == PLACEHOLDER, 'Caddyfile must be untouched' print('PASS: stayed on plain-HTTP fallback, Caddyfile untouched\n') print('=' * 68) print('CASE 3 — HOSTNAME_INTERNAL missing -> no-op') print('=' * 68) reset_db(); reset_env() os.environ['HOST_IP'] = '192.168.0.152' hm = load_manager() rc = hm.bootstrap_from_env(app) c = config() print('exit code:', rc, '| config:', 'none' if c is None else c.https_enabled) assert c is None or c.https_enabled is False print('PASS: no-op when hostname is missing\n') print('=' * 68) print('CASE 3b — neither var set (default fresh deploy) -> no-op') print('=' * 68) reset_db(); reset_env() hm = load_manager() rc = hm.bootstrap_from_env(app) assert config() is None print('exit code:', rc, '| https_config row: none') print('PASS: untouched config, HTTP fallback only\n') print('=' * 68) print('CASE 4 — idempotency (run twice)') print('=' * 68) reset_db(); reset_env() open(TARGET, 'w').write(PLACEHOLDER) os.environ.update(HOSTNAME_INTERNAL='digiserver', HOST_IP='192.168.0.152') hm = load_manager() hm.bootstrap_from_env(app) first = open(TARGET).read() hm.bootstrap_from_env(app) second = open(TARGET).read() with app.app_context(): n = HTTPSConfig.query.count() print('https_config rows:', n, '| Caddyfile identical:', first == second) assert n == 1, f'expected one config row, got {n}' assert first == second, 'Caddyfile changed on re-run' print('PASS: idempotent\n') print('=' * 68) print('CASE 5 — HTTPS_HTTP_FALLBACK=false -> redirect to the HTTPS URL') print('=' * 68) reset_db(); reset_env() open(TARGET, 'w').write(PLACEHOLDER) # HTTPS_PORT stays at its 443 default, so the redirect target must not carry # an explicit port (https://host, not https://host:443). os.environ.update(HOSTNAME_INTERNAL='digiserver', HOST_IP='192.168.0.152', HTTPS_HTTP_FALLBACK='false') hm = load_manager() hm.bootstrap_from_env(app) text = open(TARGET).read() print('blocks:', blocks()) assert 'redir https://192.168.0.152{uri} 301' in text, \ 'redirect should omit :443 when HTTPS_PORT is 443' print('PASS: redirect targets https:// with no redundant port\n') print('=' * 68) print('CASE 5b — non-standard HTTPS_PORT -> redirect includes the port') print('=' * 68) reset_db(); reset_env() open(TARGET, 'w').write(PLACEHOLDER) os.environ.update(HOSTNAME_INTERNAL='digiserver', HOST_IP='192.168.0.152', HTTPS_PORT='8443', HTTPS_HTTP_FALLBACK='false') hm = load_manager() hm.bootstrap_from_env(app) text = open(TARGET).read() print('blocks:', blocks()) assert 'redir https://192.168.0.152:8443{uri} 301' in text, \ 'redirect must include a non-standard port' print('PASS: redirect includes :8443\n') print('=' * 68) print('CASE 6 — admin change BEFORE restart is NOT clobbered by the env') print('=' * 68) reset_db(); reset_env() open(TARGET, 'w').write(PLACEHOLDER) os.environ.update(HOSTNAME_INTERNAL='digiserver', HOST_IP='192.168.0.152') hm = load_manager() hm.bootstrap_from_env(app) # startup bootstrap (env owns it) print('after bootstrap IP :', config().ip_address) # Simulate an admin changing the IP in the UI (updated_by = the username). hm._apply(app, https_enabled=True, hostname='digiserver', domain='', email=None, ip_address='10.0.0.99', port=443, http_fallback=True, verify=False) with app.app_context(): c = HTTPSConfig.get_config() c.updated_by = 'admin' # as admin.py would record it from app.extensions import db as _db _db.session.commit() print('after admin IP :', config().ip_address) # Next container start re-runs the bootstrap with the SAME env. rc = hm.bootstrap_from_env(app) print('bootstrap exit code:', rc) print('IP after restart :', config().ip_address) assert rc == 0 assert config().ip_address == '10.0.0.99', \ 'admin setting must survive an env bootstrap on restart' # The admin-set IP must be served (the hostname from before is still served too, # because the admin only changed the IP field). assert 'https://10.0.0.99 {' in open(TARGET).read(), \ 'admin IP should be present in the Caddyfile' assert 'https://192.168.0.152 {' not in open(TARGET).read(), \ 'the old env IP must no longer be served' print('PASS: admin-owned config is preserved across restarts\n') print('=' * 68) print('CASE 7 — env still owns config -> env change IS applied on restart') print('=' * 68) reset_db(); reset_env() open(TARGET, 'w').write(PLACEHOLDER) os.environ.update(HOSTNAME_INTERNAL='digiserver', HOST_IP='192.168.0.152') hm = load_manager() hm.bootstrap_from_env(app) print('first start IP :', config().ip_address) os.environ['HOST_IP'] = '10.20.30.40' # operator edits .env hm.bootstrap_from_env(app) print('second start IP:', config().ip_address) assert config().ip_address == '10.20.30.40', 'env change should apply' assert 'https://10.20.30.40' in open(TARGET).read() print('PASS: env changes still take effect while env owns the config\n') shutil.rmtree(TMPDIR, ignore_errors=True) print('ALL BOOTSTRAP CASES PASSED')