Files
digiserver-v2/docs/tools/test_https_bootstrap.py
T
ske087 46602f1933 Sanitize codebase, reorganize docs, and add missing deploy files
Remove dead code identified in docs/SANITIZATION-REVIEW.md:
- app/blueprints/content_old.py and app/blueprints/playlist.py
- app/models/group.py, app/utils/nginx_config_reader.py
- orphaned templates (content_list, edit_content, upload_content,
  player_page) and the related group/Template references

Result: 6 blueprints, 82 routes, no dead modules or orphan templates.

Add files that deploy.sh and docker-entrypoint.sh already require but
which were never tracked:
- https_manager.py       (referenced by deploy.sh, migrate_network.sh,
                          docker-entrypoint.sh)
- Caddyfile.example      (seeded by deploy.sh; its absence aborts deploy)

Relocate generated Graphify artifacts from graphify-out/ to
docs/graphify-out/ (110 files, no content change) and archive the
superseded docs under docs/.

Ignore hygiene:
- ignore ad-hoc .env backups (.env.bak*) — they contain live secrets
- keep the pre-sanitization snapshots (docs/legacy code/,
  docs/old_code_documentation/) on disk but out of the repo

Fix .env.example: drop a duplicated config block, genericize the
hardcoded host IP, and document HOSTNAME_INTERNAL.
2026-09-11 12:18:34 +03:00

233 lines
8.6 KiB
Python

"""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://<ip> 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')