"""Tests for furtka.https — fingerprint extraction + HTTPS toggle. Since 26.15-alpha the toggle writes/removes TWO snippets atomically: - The top-level HTTPS listener snippet (enables :443 + tls internal) - The :80-scoped redirect snippet (forces HTTP → HTTPS) 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 both snippet files are written / removed together and that reload failures roll BOTH 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 _paths(tmp_path): """Return the four paths the toggle touches, in a dict for kwargs spreading. Keeps each test's fixture boilerplate small.""" return { "snippet_dir": tmp_path / "furtka.d", "snippet": tmp_path / "furtka.d" / "redirect.caddyfile", "https_snippet_dir": tmp_path / "furtka-https.d", "https_snippet": tmp_path / "furtka-https.d" / "https.caddyfile", "hostname_file": tmp_path / "etc_hostname", } def _prepare_hostname(tmp_path, value="testbox"): (tmp_path / "etc_hostname").write_text(f"{value}\n") 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", https_snippet=tmp_path / "https.caddyfile") assert s == { "ca_available": False, "fingerprint_sha256": None, "force_https": False, "ca_download_url": "/rootCA.crt", } def test_status_with_ca_and_https_snippet(tmp_path): ca = tmp_path / "root.crt" ca.write_text(_TEST_CERT_PEM) https_snip = tmp_path / "https.caddyfile" https_snip.write_text("furtka.local, furtka {\n\ttls internal\n\timport furtka_routes\n}\n") s = https.status(ca_path=ca, https_snippet=https_snip) assert s["ca_available"] is True assert s["fingerprint_sha256"] == _TEST_CERT_FP_SHA256 assert s["force_https"] is True def test_status_force_reflects_https_snippet_not_redirect(tmp_path): """Authoritative signal for "HTTPS is on" is the listener snippet — a lone redirect without a :443 listener wouldn't actually serve HTTPS, so the status must NOT report it as on. Locks 26.15 semantic.""" ca = tmp_path / "root.crt" ca.write_text(_TEST_CERT_PEM) s = https.status(ca_path=ca, https_snippet=tmp_path / "does-not-exist.caddyfile") assert s["force_https"] is False def test_set_force_enable_writes_both_snippets_and_reloads(tmp_path): _prepare_hostname(tmp_path) p = _paths(tmp_path) calls = [] def fake_reload(): calls.append("reload") result = https.set_force_https(True, reload_caddy=fake_reload, **p) assert result is True assert p["snippet"].read_text() == https.REDIRECT_CONTENT written = p["https_snippet"].read_text() assert "testbox.local, testbox" in written assert "tls internal" in written assert "import furtka_routes" in written assert calls == ["reload"] def test_set_force_uses_fallback_hostname_when_file_missing(tmp_path): # No /etc/hostname → fall back to 'furtka' so Caddy gets a parseable # block instead of an empty hostname that would fail config load. p = _paths(tmp_path) result = https.set_force_https(True, reload_caddy=lambda: None, **p) assert result is True assert "furtka.local, furtka" in p["https_snippet"].read_text() def test_set_force_disable_removes_both_snippets(tmp_path): _prepare_hostname(tmp_path) p = _paths(tmp_path) p["snippet_dir"].mkdir() p["https_snippet_dir"].mkdir() p["snippet"].write_text(https.REDIRECT_CONTENT) p["https_snippet"].write_text("furtka.local { tls internal }\n") result = https.set_force_https(False, reload_caddy=lambda: None, **p) assert result is False assert not p["snippet"].exists() assert not p["https_snippet"].exists() def test_set_force_disable_is_idempotent_when_already_off(tmp_path): p = _paths(tmp_path) result = https.set_force_https(False, reload_caddy=lambda: None, **p) assert result is False assert not p["snippet"].exists() assert not p["https_snippet"].exists() def test_reload_failure_rolls_back_enable(tmp_path): _prepare_hostname(tmp_path) p = _paths(tmp_path) 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, reload_caddy=failing_reload, **p) # Rollback: since neither snippet existed before, neither exists after. assert not p["snippet"].exists() assert not p["https_snippet"].exists() def test_reload_failure_rolls_back_disable(tmp_path): _prepare_hostname(tmp_path) p = _paths(tmp_path) p["snippet_dir"].mkdir() p["https_snippet_dir"].mkdir() original_redirect = "redir https://{host}{uri} permanent\n# marker\n" original_https = "# old https block\nfurtka.local { tls internal }\n" p["snippet"].write_text(original_redirect) p["https_snippet"].write_text(original_https) def failing_reload(): raise subprocess.CalledProcessError(1, ["systemctl"], stderr="bad config") with pytest.raises(https.HttpsError): https.set_force_https(False, reload_caddy=failing_reload, **p) # Rollback: both snippets are restored to their exact prior contents. assert p["snippet"].read_text() == original_redirect assert p["https_snippet"].read_text() == original_https def test_systemctl_missing_raises_and_rolls_back(tmp_path): _prepare_hostname(tmp_path) p = _paths(tmp_path) def missing_systemctl(): raise FileNotFoundError(2, "No such file", "systemctl") with pytest.raises(https.HttpsError, match="systemctl not available"): https.set_force_https(True, reload_caddy=missing_systemctl, **p) assert not p["snippet"].exists() assert not p["https_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" def test_https_snippet_content_has_tls_internal_and_routes(tmp_path): # Lock the shape of the opt-in HTTPS listener block. Caddy parses # this verbatim — changing the shape without updating the test # risks shipping a silently-broken Caddyfile import. s = https._https_snippet_content("mybox") assert "mybox.local, mybox {" in s assert "\ttls internal" in s assert "\timport furtka_routes" in s assert s.endswith("}\n")