Add test suite and switch API to lifespan handler

test_entropy.py covers the HMAC-DRBG (determinism, reseed, length,
reseed limit), health checks (random passes, frozen/all-ones/short/
long-run rejected), a bit-balance smoke test on DRBG output, and the
API endpoints including auth gating. All 16 tests pass without a
camera (producer is stubbed).

Switch api.py from the deprecated @app.on_event('startup') to the
lifespan async context manager. Add pytest to requirements.txt.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Radek
2026-09-02 11:51:55 +01:00
parent 1b99573b65
commit d121b0f0f6
3 changed files with 155 additions and 4 deletions
+7 -4
View File
@@ -13,6 +13,7 @@ Endpoints:
import os import os
import threading import threading
import time import time
from contextlib import asynccontextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import FastAPI, Header, HTTPException from fastapi import FastAPI, Header, HTTPException
@@ -29,8 +30,6 @@ API_KEY = os.environ.get("ENTROPY_API_KEY", "")
NUM_BYTES = BITS // 8 NUM_BYTES = BITS // 8
app = FastAPI(title="Entropy-RNG Service", version="1.0")
class LatestBlob: class LatestBlob:
"""Thread-safe holder for the most recent entropy blob.""" """Thread-safe holder for the most recent entropy blob."""
@@ -76,10 +75,14 @@ def producer_loop():
time.sleep(INTERVAL) time.sleep(INTERVAL)
@app.on_event("startup") @asynccontextmanager
def _start_producer(): async def lifespan(app):
t = threading.Thread(target=producer_loop, daemon=True) t = threading.Thread(target=producer_loop, daemon=True)
t.start() t.start()
yield
app = FastAPI(title="Entropy-RNG Service", version="1.0", lifespan=lifespan)
class EntropyResponse(BaseModel): class EntropyResponse(BaseModel):
+2
View File
@@ -3,3 +3,5 @@ opencv-python>=4.8,<5
# FastAPI stack for the entropy service (added in the API stage) # FastAPI stack for the entropy service (added in the API stage)
fastapi>=0.110 fastapi>=0.110
uvicorn[standard]>=0.29 uvicorn[standard]>=0.29
# Test dependencies
pytest>=8.0
+146
View File
@@ -0,0 +1,146 @@
"""Tests for the entropy source: DRBG, health checks, and API.
Run with: pytest -q
These tests stub the camera so they run without hardware.
"""
import os
import pytest
from fastapi.testclient import TestClient
import entropy
import health
# --- HMAC-DRBG ---
def test_drbg_deterministic_same_seed():
a = entropy.HMACDRBG(b"seed-A").generate(32)
b = entropy.HMACDRBG(b"seed-A").generate(32)
assert a == b
def test_drbg_different_seed_different_output():
a = entropy.HMACDRBG(b"seed-A").generate(32)
b = entropy.HMACDRBG(b"seed-B").generate(32)
assert a != b
def test_drbg_reseed_changes_output():
d = entropy.HMACDRBG(b"seed")
first = d.generate(32)
d.reseed(b"new-entropy")
second = d.generate(32)
assert first != second
def test_drbg_output_length():
d = entropy.HMACDRBG(b"seed")
assert len(d.generate(1)) == 1
assert len(d.generate(64)) == 64
assert len(d.generate(1000)) == 1000
def test_drbg_requires_reseed_after_limit():
d = entropy.HMACDRBG(b"seed")
for _ in range(10001):
d.generate(1)
with pytest.raises(RuntimeError, match="reseed"):
d.generate(1)
# --- Health checks ---
def test_health_passes_random_data():
health.check(os.urandom(2500))
def test_health_rejects_frozen_frame():
with pytest.raises(health.HealthCheckError):
health.check(b"\x00" * 2500)
def test_health_rejects_all_ones():
with pytest.raises(health.HealthCheckError):
health.check(b"\xff" * 2500)
def test_health_rejects_short_sample():
with pytest.raises(health.HealthCheckError, match="too short"):
health.check(os.urandom(100))
def test_health_long_run_rejected():
# 2500 bytes with a single long run of zeros embedded
data = bytearray(os.urandom(2500))
data[1000:1000 + 30] = b"\x00" * 30 # 240-bit run of zeros
with pytest.raises(health.HealthCheckError, match="long run"):
health.check(bytes(data))
# --- Statistical smoke test on DRBG output ---
def test_drbg_output_is_balanced():
"""Generated bits should be roughly 50/50 over a large sample."""
d = entropy.HMACDRBG(b"seed")
out = d.generate(2500)
ones = sum(bin(byte).count("1") for byte in out)
total = len(out) * 8
# Expect ~50%; allow 45-55% to avoid flakiness
ratio = ones / total
assert 0.45 <= ratio <= 0.55, f"bit balance {ratio:.3f} outside [0.45, 0.55]"
# --- API ---
@pytest.fixture
def client(monkeypatch):
# Disable the real producer thread; we inject blobs manually
monkeypatch.setattr("api.producer_loop", lambda: None)
import api
api.latest.set(b"\x00" * 32)
return TestClient(api.app)
def test_api_healthz(client):
r = client.get("/healthz")
assert r.status_code == 200
assert r.json() == {"status": "ok"}
def test_api_root(client):
r = client.get("/")
assert r.status_code == 200
body = r.json()
assert body["service"] == "entropy-rng"
assert body["bits"] == 256
def test_api_entropy_returns_blob(client):
r = client.get("/entropy")
assert r.status_code == 200
body = r.json()
assert body["hex"] == "00" * 32
assert body["bits"] == 256
assert "ts" in body
def test_api_entropy_requires_key_when_set(client, monkeypatch):
import api
monkeypatch.setattr(api, "API_KEY", "secret")
# No key
r = client.get("/entropy")
assert r.status_code == 401
# Wrong key
r = client.get("/entropy", headers={"X-API-Key": "wrong"})
assert r.status_code == 401
# Right key
r = client.get("/entropy", headers={"X-API-Key": "secret"})
assert r.status_code == 200
def test_api_healthz_open_even_with_auth(client, monkeypatch):
import api
monkeypatch.setattr(api, "API_KEY", "secret")
r = client.get("/healthz")
assert r.status_code == 200