"""Tests for the veil's randomness mixing. The property that actually matters here is adversarial: a client that controls its entropy contribution completely must not be able to control, predict, or bias the outcome. Most of these tests attack that directly rather than just checking the happy path. """ import collections from app.entropy import ( contribution_bits, normalize_contribution, veil_float, veil_random, veil_seed, ) class TestNormalizeContribution: def test_parses_a_hex_digest(self): assert normalize_contribution("00ff10") == b"\x00\xff\x10" def test_empty_for_non_string_input(self): for junk in (None, 123, {"a": 1}, [1, 2], b"bytes"): assert normalize_contribution(junk) == b"" def test_empty_for_blank_string(self): assert normalize_contribution("") == b"" assert normalize_contribution(" ") == b"" def test_non_hex_still_contributes_rather_than_being_discarded(self): # A client with a different encoding shouldn't silently stop # contributing physical noise; mixing raw text is harmless. assert normalize_contribution("not-hex-at-all") != b"" def test_truncates_an_oversized_payload(self): huge = "ab" * 10_000 assert len(normalize_contribution(huge)) <= 512 def test_never_raises_on_hostile_input(self): for junk in ("zz", "0", "0x1234", "\x00\x01", "💀" * 50, "-1"): normalize_contribution(junk) # must not raise class TestVeilSeed: def test_returns_32_bytes(self): assert len(veil_seed("aabb")) == 32 def test_identical_input_produces_different_seeds(self): # THE core security property: the same client contribution must # NOT reproduce the same draw, or a seeker could replay a # contribution that once yielded a mythic entity. seeds = {veil_seed("deadbeef").hex() for _ in range(200)} assert len(seeds) == 200 def test_unpredictable_even_with_no_contribution_at_all(self): seeds = {veil_seed().hex() for _ in range(200)} assert len(seeds) == 200 def test_unpredictable_with_an_adversarially_degenerate_contribution(self): # All-zeros is the worst case a client can send. seeds = {veil_seed("00" * 32).hex() for _ in range(200)} assert len(seeds) == 200 def test_context_domain_separates_draws(self): # Two draws from one contribution must not be correlated, so # learning one (e.g. the visible rarity) reveals nothing about the # other (the hidden traits). a = {veil_seed("aabb", "entity").hex() for _ in range(100)} b = {veil_seed("aabb", "traits").hex() for _ in range(100)} assert not (a & b) class TestVeilRandom: def test_returns_a_usable_random_instance(self): rng = veil_random("aabb") assert 0.0 <= rng.random() < 1.0 assert rng.choice([1, 2, 3]) in (1, 2, 3) def test_successive_calls_are_independent(self): first = [veil_random("same").random() for _ in range(50)] assert len(set(first)) == 50 def test_client_cannot_force_a_repeated_outcome(self): # Simulates a seeker replaying one contribution to grind for a rare # result: the distribution must stay spread out. draws = [veil_random("c0ffee", "rarity").random() for _ in range(400)] assert len(set(draws)) == 400 buckets = collections.Counter(int(d * 4) for d in draws) # With 400 draws across 4 buckets, a client steering the result # would show up as a badly skewed histogram. for count in buckets.values(): assert 40 < count < 210 def test_output_is_roughly_uniform(self): draws = [veil_float() for _ in range(2000)] buckets = collections.Counter(int(d * 10) for d in draws) assert len(buckets) == 10 for count in buckets.values(): assert 120 < count < 290 # ~200 expected, generous bounds def test_mean_is_near_a_half(self): draws = [veil_float("aabb", "spread") for _ in range(2000)] assert 0.45 < sum(draws) / len(draws) < 0.55 class TestContributionBits: def test_counts_bits_of_real_contribution(self): assert contribution_bits("00ff") == 16 assert contribution_bits("") == 0 assert contribution_bits(None) == 0 def test_a_lying_client_cannot_inflate_beyond_the_cap(self): # Display-only, but it still must not become an unbounded number # driven by client input. assert contribution_bits("ab" * 10_000) <= 512 * 8