"""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