furtka/tests/test_https.py
Daniel Maksymilian Syrnicki 663bd74572
Some checks failed
Build ISO / build-iso (push) Successful in 20m57s
CI / lint (push) Failing after 31s
CI / test (push) Successful in 36s
CI / validate-json (push) Successful in 23s
CI / markdown-links (push) Successful in 14s
feat(https): local HTTPS via Caddy tls internal + opt-in redirect toggle
Caddy now serves both :80 (plain HTTP, unchanged default) and :443 with
tls internal — it generates its own per-box root CA on first start,
stored under /var/lib/caddy/.local/share/caddy/pki/authorities/local/.
Users can download rootCA.crt at /rootCA.crt (served on both listeners)
and install it per-OS via the new /https-install/ guide.

Settings page grows a Local HTTPS card with CA fingerprint, download
button, reachability probe, and an opt-in "force HTTPS" toggle. The
toggle only unhides itself once the current browser already trusts the
cert, so enabling it can't lock the user out of the settings page.

Backend: GET /api/furtka/https/status and POST /api/furtka/https/force
in furtka.https. The force toggle drops a Caddy import snippet into
/etc/caddy/furtka.d/redirect.caddyfile and reloads Caddy; reload
failure rolls the snippet state back so a bad config can't wedge the
next service start.

updater._refresh_caddyfile() ensures /etc/caddy/furtka.d exists before
every reload so 26.3-alpha → 26.4-alpha self-updates don't trip on the
new glob import directive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 12:19:06 +02:00

166 lines
5.7 KiB
Python

"""Tests for furtka.https — fingerprint extraction + force-HTTPS toggle.
The fingerprint case uses a throwaway self-signed EC cert with a known
reference fingerprint (computed once via `openssl x509 -fingerprint
-sha256 -noout`) so we verify the PEM → DER → SHA256 path without a
runtime subprocess dependency. The toggle cases stub the caddy reload
so we assert the snippet file is written / removed and that reload
failures roll state back.
"""
import subprocess
import pytest
from furtka import https
# Self-signed test-only cert. Don't trust it anywhere; it's here because
# we need a real PEM whose fingerprint we can pre-compute.
_TEST_CERT_PEM = """-----BEGIN CERTIFICATE-----
MIIBjjCCATOgAwIBAgIUGIKx2BGMvNQwAcZvjwJiaJO1GvEwCgYIKoZIzj0EAwIw
HDEaMBgGA1UEAwwRRnVydGthIFRlc3QgTG9jYWwwHhcNMjYwNDE3MTAxNTMxWhcN
MzYwNDE0MTAxNTMxWjAcMRowGAYDVQQDDBFGdXJ0a2EgVGVzdCBMb2NhbDBZMBMG
ByqGSM49AgEGCCqGSM49AwEHA0IABIfWX2oVXrw+iv4lCcIIceoX24bvRdlEECB5
QoMYphmlOoI492tRCGHxA8eaIwIYqFn1DzBKBRSL0H3xcu+4Pg6jUzBRMB0GA1Ud
DgQWBBSMizCL5Kh+SLE5n12oKV05L9bJXjAfBgNVHSMEGDAWgBSMizCL5Kh+SLE5
n12oKV05L9bJXjAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0kAMEYCIQDp
6etGEuj7AGD5zzyzDSpmRiMEgBp1k6fVoLYW7N2K3AIhAK8khUp3gKPo4UqtWNK9
Cs/B0mzRy2MUPGdZ5QU6LoDz
-----END CERTIFICATE-----
"""
_TEST_CERT_FP_SHA256 = (
"40:A7:98:2E:8D:1F:4C:0D:9B:E6:87:ED:91:FA:6F:B1:"
"3D:8A:10:06:79:7C:08:A9:8F:AD:71:0C:B8:29:87:28"
)
def test_ca_fingerprint_matches_openssl(tmp_path):
cert = tmp_path / "root.crt"
cert.write_text(_TEST_CERT_PEM)
fp_hex = https._ca_fingerprint(cert)
assert fp_hex is not None
assert https._format_fingerprint(fp_hex) == _TEST_CERT_FP_SHA256
def test_ca_fingerprint_missing_file(tmp_path):
assert https._ca_fingerprint(tmp_path / "nope.crt") is None
def test_ca_fingerprint_no_pem_block(tmp_path):
garbage = tmp_path / "root.crt"
garbage.write_text("not a certificate")
assert https._ca_fingerprint(garbage) is None
def test_status_no_ca_no_snippet(tmp_path):
s = https.status(ca_path=tmp_path / "root.crt", snippet=tmp_path / "redirect.caddyfile")
assert s == {
"ca_available": False,
"fingerprint_sha256": None,
"force_https": False,
"ca_download_url": "/rootCA.crt",
}
def test_status_with_ca_and_snippet(tmp_path):
ca = tmp_path / "root.crt"
ca.write_text(_TEST_CERT_PEM)
snippet = tmp_path / "redirect.caddyfile"
snippet.write_text(https.REDIRECT_CONTENT)
s = https.status(ca_path=ca, snippet=snippet)
assert s["ca_available"] is True
assert s["fingerprint_sha256"] == _TEST_CERT_FP_SHA256
assert s["force_https"] is True
def test_set_force_enable_writes_snippet_and_reloads(tmp_path):
snippet_dir = tmp_path / "furtka.d"
snippet = snippet_dir / "redirect.caddyfile"
calls = []
def fake_reload():
calls.append("reload")
result = https.set_force_https(
True, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=fake_reload
)
assert result is True
assert snippet.read_text() == https.REDIRECT_CONTENT
assert calls == ["reload"]
def test_set_force_disable_removes_snippet(tmp_path):
snippet_dir = tmp_path / "furtka.d"
snippet_dir.mkdir()
snippet = snippet_dir / "redirect.caddyfile"
snippet.write_text(https.REDIRECT_CONTENT)
result = https.set_force_https(
False, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=lambda: None
)
assert result is False
assert not snippet.exists()
def test_set_force_disable_is_idempotent_when_already_off(tmp_path):
snippet_dir = tmp_path / "furtka.d"
snippet = snippet_dir / "redirect.caddyfile"
result = https.set_force_https(
False, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=lambda: None
)
assert result is False
assert not snippet.exists()
def test_reload_failure_rolls_back_enable(tmp_path):
snippet_dir = tmp_path / "furtka.d"
snippet = snippet_dir / "redirect.caddyfile"
def failing_reload():
raise subprocess.CalledProcessError(1, ["systemctl"], stderr="bad config")
with pytest.raises(https.HttpsError, match="caddy reload failed: bad config"):
https.set_force_https(
True, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=failing_reload
)
# Rollback: since snippet didn't exist before, it must not exist after.
assert not snippet.exists()
def test_reload_failure_rolls_back_disable(tmp_path):
snippet_dir = tmp_path / "furtka.d"
snippet_dir.mkdir()
snippet = snippet_dir / "redirect.caddyfile"
original = "redir https://{host}{uri} permanent\n# marker\n"
snippet.write_text(original)
def failing_reload():
raise subprocess.CalledProcessError(1, ["systemctl"], stderr="bad config")
with pytest.raises(https.HttpsError):
https.set_force_https(
False, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=failing_reload
)
# Rollback: snippet is restored to its exact prior contents.
assert snippet.read_text() == original
def test_systemctl_missing_raises_and_rolls_back(tmp_path):
snippet_dir = tmp_path / "furtka.d"
snippet = snippet_dir / "redirect.caddyfile"
def missing_systemctl():
raise FileNotFoundError(2, "No such file", "systemctl")
with pytest.raises(https.HttpsError, match="systemctl not available"):
https.set_force_https(
True, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=missing_systemctl
)
assert not snippet.exists()
def test_redirect_snippet_content_is_caddy_redir_directive():
# Lock the exact directive. A regression here silently stops the
# redirect from taking effect even though the file-swap looks fine.
assert https.REDIRECT_CONTENT.strip() == "redir https://{host}{uri} permanent"