167 lines
5.7 KiB
Python
167 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"
|