Skip to content

Commit 5cd224a

Browse files
aittalamclaude
andcommitted
Add ssl-marked integration tests for HTTPS support
Four tests behind a new pytest 'ssl' marker (toggle with -m ssl / -m "not ssl"): - serve over TLS via --ssl-cert-file/--ssl-key-file and connect with a verifying client (self-signed cert generated with openssl, SAN for localhost/127.0.0.1) - plain-HTTP request to the TLS port is refused - download a tiny model (ggml-org/models stories260K, ~1 MiB) from Hugging Face over HTTPS into a fresh cache and serve it; the only test needing network access - offline negative test: --model-url against our own self-signed HTTPS server must fail with nothing written to the cache, proving the downloader enforces certificate verification (the rogue endpoint would otherwise happily serve a body) Also fixes a duplicate-row typo in the README marker table. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 96436d5 commit 5cd224a

3 files changed

Lines changed: 241 additions & 1 deletion

File tree

tests/integration/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,13 @@ Use `-m` to select test categories:
3131
| `cli` | CLI mode tests |
3232
| `tui` | TUI/chat mode tests |
3333
| `server` | Server mode tests |
34-
| `server` | Combined (TUI/chat + server) mode tests |
34+
| `combined` | Combined (TUI/chat + server) mode tests |
3535
| `multimodal` | Vision/image tests (requires multimodal model) |
3636
| `tool_calling` | Tool use tests (requires tool-capable model) |
3737
| `thinking` | Thinking model tests (QwQ, DeepSeek-R1, etc.) |
3838
| `gpu` | GPU acceleration tests |
3939
| `cpu` | CPU-only tests |
40+
| `ssl` | HTTPS/TLS tests: serving with `--ssl-cert-file`, model download over HTTPS, untrusted-cert rejection. Needs `openssl` in PATH to generate a test certificate; the download test needs network access (huggingface.co) |
4041

4142
Examples:
4243

@@ -49,6 +50,9 @@ Examples:
4950

5051
# Skip multimodal and tool_calling tests
5152
./run_tests.sh --executable ~/model.llamafile -m "not multimodal and not tool_calling"
53+
54+
# Run only the HTTPS/TLS tests (or skip them with -m "not ssl")
55+
./run_tests.sh --executable ./o/llamafile/llamafile --model model.gguf -m ssl
5256
```
5357

5458
## Options

tests/integration/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,5 @@ markers = [
2626
"tui: tests TUI mode",
2727
"cli: tests CLI mode",
2828
"combined: tests combined (TUI+server) mode",
29+
"ssl: tests HTTPS/TLS support (serving and model download; the download test needs network access)",
2930
]
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
"""HTTPS/TLS integration tests.
2+
3+
Covers the TLS support added via cpp-httplib's Mbed TLS backend:
4+
serving over HTTPS (--ssl-cert-file/--ssl-key-file), downloading models
5+
over HTTPS (-hf/--model-url), and rejection of untrusted certificates.
6+
7+
Toggle with the ``ssl`` marker: ``-m ssl`` / ``-m "not ssl"``.
8+
``test_download_model_over_https`` needs network access (huggingface.co);
9+
the other tests run fully offline.
10+
"""
11+
12+
import os
13+
import platform
14+
import shutil
15+
import subprocess
16+
17+
import pytest
18+
import requests
19+
20+
from utils.llamafile import LlamafileRunner
21+
22+
# Tiny model used for the network download test (~1 MiB)
23+
HF_TEST_REPO = "ggml-org/models"
24+
HF_TEST_FILE = "tinyllamas/stories260K.gguf"
25+
26+
OPENSSL_CNF = """\
27+
[req]
28+
distinguished_name = dn
29+
x509_extensions = v3_req
30+
prompt = no
31+
[dn]
32+
CN = localhost
33+
[v3_req]
34+
subjectAltName = DNS:localhost, IP:127.0.0.1
35+
"""
36+
37+
38+
@pytest.fixture(scope="module")
39+
def ssl_cert(tmp_path_factory):
40+
"""Generate a self-signed cert/key pair for localhost.
41+
42+
Returns (cert_path, key_path). Skips if no openssl binary is available.
43+
The SAN config file keeps this portable across OpenSSL and LibreSSL
44+
(macOS ships LibreSSL, where -addext support varies).
45+
"""
46+
if not shutil.which("openssl"):
47+
pytest.skip("openssl binary not available to generate a test certificate")
48+
49+
tmp = tmp_path_factory.mktemp("ssl")
50+
cnf = tmp / "openssl.cnf"
51+
cnf.write_text(OPENSSL_CNF)
52+
cert, key = tmp / "cert.pem", tmp / "key.pem"
53+
54+
subprocess.run(
55+
[
56+
"openssl", "req", "-x509", "-newkey", "rsa:2048", "-sha256",
57+
"-days", "2", "-nodes",
58+
"-keyout", str(key), "-out", str(cert),
59+
"-config", str(cnf),
60+
],
61+
check=True,
62+
capture_output=True,
63+
)
64+
return cert, key
65+
66+
67+
def wait_for_https_server(port, cert, timeout, poll_interval=1.0):
68+
"""Poll https://127.0.0.1:port/health until 200, verifying against cert."""
69+
import time
70+
71+
url = f"https://127.0.0.1:{port}/health"
72+
start = time.time()
73+
while time.time() - start < timeout:
74+
try:
75+
if requests.get(url, timeout=2, verify=str(cert)).status_code == 200:
76+
return True
77+
except requests.RequestException:
78+
pass
79+
time.sleep(poll_interval)
80+
return False
81+
82+
83+
def base_exe_args(executable):
84+
"""Mimic LlamafileRunner's sh-prefixed invocation for manual Popen."""
85+
if platform.system() != "Windows":
86+
return ["sh", os.path.abspath(executable)]
87+
return [os.path.abspath(executable)]
88+
89+
90+
def fresh_cache_env(cache_dir):
91+
"""Environment pointing the model download cache at a fresh directory."""
92+
env = os.environ.copy()
93+
env["XDG_CACHE_HOME"] = str(cache_dir)
94+
return env
95+
96+
97+
@pytest.mark.ssl
98+
@pytest.mark.server
99+
class TestHttpsServing:
100+
"""Serving over TLS with --ssl-cert-file/--ssl-key-file."""
101+
102+
def test_server_serves_https(self, llamafile, server_port, timeouts, ssl_cert):
103+
"""Server comes up over HTTPS and a verifying client can connect."""
104+
cert, key = ssl_cert
105+
proc = llamafile.start_server(
106+
port=server_port,
107+
extra_args=["--ssl-cert-file", str(cert), "--ssl-key-file", str(key)],
108+
)
109+
try:
110+
assert wait_for_https_server(
111+
server_port, cert, timeout=timeouts.server_ready
112+
), "Server did not become ready over HTTPS"
113+
114+
# Chain + hostname verification against our cert succeeds
115+
r = requests.get(
116+
f"https://127.0.0.1:{server_port}/props",
117+
timeout=timeouts.http_request,
118+
verify=str(cert),
119+
)
120+
assert r.status_code == 200
121+
assert "default_generation_settings" in r.text
122+
finally:
123+
proc.terminate()
124+
proc.wait()
125+
126+
def test_https_server_rejects_plaintext(
127+
self, llamafile, server_port, timeouts, ssl_cert
128+
):
129+
"""A plain-HTTP request to the TLS port must not get an HTTP 200."""
130+
cert, key = ssl_cert
131+
proc = llamafile.start_server(
132+
port=server_port,
133+
extra_args=["--ssl-cert-file", str(cert), "--ssl-key-file", str(key)],
134+
)
135+
try:
136+
assert wait_for_https_server(
137+
server_port, cert, timeout=timeouts.server_ready
138+
), "Server did not become ready over HTTPS"
139+
140+
with pytest.raises(requests.RequestException):
141+
requests.get(f"http://127.0.0.1:{server_port}/health", timeout=5)
142+
finally:
143+
proc.terminate()
144+
proc.wait()
145+
146+
147+
@pytest.mark.ssl
148+
class TestHttpsDownload:
149+
"""Model downloads over HTTPS (the client side of TLS support)."""
150+
151+
def test_download_model_over_https(
152+
self, executable, server_port, timeouts, tmp_path
153+
):
154+
"""Download a tiny model from Hugging Face over HTTPS and serve it.
155+
156+
Requires network access. Exercises the full client path including
157+
the hf.co -> CDN cross-host redirect. A fresh cache directory
158+
guarantees the download actually happens.
159+
"""
160+
cache = tmp_path / "cache"
161+
cache.mkdir()
162+
args = base_exe_args(executable) + [
163+
"--server",
164+
"--port", str(server_port),
165+
"--hf-repo", HF_TEST_REPO,
166+
"--hf-file", HF_TEST_FILE,
167+
]
168+
proc = subprocess.Popen(
169+
args,
170+
stdout=subprocess.DEVNULL,
171+
stderr=subprocess.DEVNULL,
172+
env=fresh_cache_env(cache),
173+
)
174+
try:
175+
assert LlamafileRunner.wait_for_server(
176+
server_port, timeout=timeouts.server_ready
177+
), "Server did not become ready (HTTPS download failed?)"
178+
179+
if platform.system() != "Windows":
180+
# The downloaded weights landed in our fresh cache
181+
ggufs = [
182+
p
183+
for p in cache.rglob("*")
184+
if p.is_file() and p.stat().st_size > 100_000
185+
]
186+
assert ggufs, "No downloaded model found in fresh cache"
187+
finally:
188+
proc.terminate()
189+
proc.wait()
190+
191+
def test_download_rejects_untrusted_cert(
192+
self, llamafile, executable, server_port, timeouts, ssl_cert, tmp_path
193+
):
194+
"""Downloading from a server with an untrusted (self-signed) cert fails.
195+
196+
Runs fully offline: our own HTTPS server plays the untrusted host.
197+
Its /health endpoint would happily serve a body, so if the client's
198+
certificate verification were broken the download would 'succeed'
199+
and leave a file in the cache; instead the TLS handshake must fail
200+
and the cache must stay empty.
201+
"""
202+
cert, key = ssl_cert
203+
rogue_port = server_port + 1
204+
server = llamafile.start_server(
205+
port=rogue_port,
206+
extra_args=["--ssl-cert-file", str(cert), "--ssl-key-file", str(key)],
207+
)
208+
try:
209+
assert wait_for_https_server(
210+
rogue_port, cert, timeout=timeouts.server_ready
211+
), "TLS server did not become ready"
212+
213+
cache = tmp_path / "cache"
214+
cache.mkdir()
215+
args = base_exe_args(executable) + [
216+
"--server",
217+
"--port", str(server_port),
218+
"--model-url", f"https://127.0.0.1:{rogue_port}/health",
219+
]
220+
client = subprocess.run(
221+
args,
222+
capture_output=True,
223+
text=True,
224+
env=fresh_cache_env(cache),
225+
timeout=timeouts.server_ready,
226+
)
227+
assert client.returncode != 0, "Download from untrusted cert host succeeded"
228+
leftovers = [p for p in cache.rglob("*") if p.is_file()]
229+
assert not leftovers, (
230+
"Client fetched data from a server with an untrusted certificate: "
231+
f"{leftovers}"
232+
)
233+
finally:
234+
server.terminate()
235+
server.wait()

0 commit comments

Comments
 (0)