Consolidate stegasoo and verisoo into soosef monorepo

Merge stegasoo (v4.3.0, steganography) and verisoo (v0.1.0, attestation)
as subpackages under soosef.stegasoo and soosef.verisoo. This eliminates
cross-repo coordination and enables atomic changes across the full stack.

- Copy stegasoo (34 modules) and verisoo (15 modules) into src/soosef/
- Convert all verisoo absolute imports to relative imports
- Rewire ~50 import sites across soosef code (cli, web, keystore, tests)
- Replace stegasoo/verisoo pip deps with inlined code + pip extras
  (stego-dct, stego-audio, attest, web, api, cli, fieldkit, all, dev)
- Add _availability.py for runtime feature detection
- Add unified FastAPI mount point at soosef.api
- Copy and adapt tests from both repos (155 pass, 1 skip)
- Drop standalone CLI/web frontends; keep FastAPI as optional modules
- Both source repos tagged pre-monorepo-consolidation on GitHub

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aaron D. Lee
2026-04-01 19:06:14 -04:00
parent c8dc9be011
commit e3bc1cce1f
91 changed files with 30573 additions and 62 deletions

View File

@@ -182,7 +182,7 @@ class TestRotateChannelKey:
assert oct(key_file.stat().st_mode & 0o777) == oct(0o600)
def test_archived_key_matches_old_fingerprint(self, tmp_path: Path):
from stegasoo.crypto import get_channel_fingerprint
from soosef.stegasoo.crypto import get_channel_fingerprint
ks = _make_manager(tmp_path)
ks.generate_channel_key()
@@ -197,7 +197,7 @@ class TestRotateChannelKey:
assert old_fp == result.old_fingerprint
def test_new_channel_key_active_after_rotation(self, tmp_path: Path):
from stegasoo.crypto import get_channel_fingerprint
from soosef.stegasoo.crypto import get_channel_fingerprint
ks = _make_manager(tmp_path)
ks.generate_channel_key()

681
tests/test_stegasoo.py Normal file
View File

@@ -0,0 +1,681 @@
"""
Stegasoo Library Unit Tests
Tests core functionality: encode/decode, LSB/DCT modes, channel keys, validation.
"""
import io
from pathlib import Path
import pytest
from PIL import Image
import soosef.stegasoo as stegasoo
from soosef.stegasoo import (
decode,
decode_text,
encode,
generate_channel_key,
generate_passphrase,
generate_pin,
has_dct_support,
validate_image,
validate_message,
validate_passphrase,
validate_pin,
)
# Test data paths
TEST_DATA = Path(__file__).parent.parent / "test_data"
CARRIER_PATH = TEST_DATA / "carrier.jpg"
REF_PATH = TEST_DATA / "ref.jpg"
# Test credentials
TEST_PASSPHRASE = "tower booty sunny windy toasty spicy"
TEST_PIN = "727643678"
TEST_MESSAGE = "Hello, Stegasoo!"
@pytest.fixture
def carrier_bytes():
"""Load carrier image as bytes."""
return CARRIER_PATH.read_bytes()
@pytest.fixture
def ref_bytes():
"""Load reference image as bytes."""
return REF_PATH.read_bytes()
@pytest.fixture
def small_image():
"""Create a small test image in memory."""
img = Image.new("RGB", (200, 200), color="blue")
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
class TestVersion:
"""Test version info."""
def test_version_exists(self):
assert hasattr(stegasoo, "__version__")
assert stegasoo.__version__
def test_version_format(self):
parts = stegasoo.__version__.split(".")
assert len(parts) >= 2
assert all(p.isdigit() for p in parts[:2])
class TestGeneration:
"""Test credential generation."""
def test_generate_passphrase_default(self):
passphrase = generate_passphrase()
words = passphrase.split()
assert len(words) == stegasoo.DEFAULT_PASSPHRASE_WORDS
def test_generate_passphrase_custom_length(self):
passphrase = generate_passphrase(words=8)
words = passphrase.split()
assert len(words) == 8
def test_generate_pin_default(self):
pin = generate_pin()
assert pin.isdigit()
assert len(pin) == 6 # Default is 6 digits
def test_generate_pin_custom_length(self):
pin = generate_pin(length=9)
assert pin.isdigit()
assert len(pin) == 9
def test_generate_channel_key(self):
key = generate_channel_key()
# Format: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX (39 chars)
assert len(key) == 39
assert key.count("-") == 7
class TestValidation:
"""Test validation functions."""
def test_validate_passphrase_valid(self):
result = validate_passphrase(TEST_PASSPHRASE)
assert result.is_valid
def test_validate_passphrase_too_short(self):
result = validate_passphrase("one two")
assert not result.is_valid
def test_validate_pin_valid(self):
result = validate_pin(TEST_PIN)
assert result.is_valid
def test_validate_pin_too_short(self):
result = validate_pin("123")
assert not result.is_valid
def test_validate_pin_non_numeric(self):
result = validate_pin("abc123")
assert not result.is_valid
def test_validate_message_valid(self):
result = validate_message("Hello world")
assert result.is_valid
def test_validate_message_empty(self):
result = validate_message("")
assert not result.is_valid
def test_validate_image_valid(self, carrier_bytes):
result = validate_image(carrier_bytes)
assert result.is_valid
def test_validate_image_invalid(self):
result = validate_image(b"not an image")
assert not result.is_valid
class TestLSBMode:
"""Test LSB (Least Significant Bit) encoding/decoding."""
def test_encode_decode_roundtrip(self, carrier_bytes, ref_bytes):
"""Basic encode/decode roundtrip."""
result = encode(
message=TEST_MESSAGE,
reference_photo=ref_bytes,
carrier_image=carrier_bytes,
passphrase=TEST_PASSPHRASE,
pin=TEST_PIN,
embed_mode="lsb",
)
assert result.stego_image
assert len(result.stego_image) > 0
decoded = decode(
stego_image=result.stego_image,
reference_photo=ref_bytes,
passphrase=TEST_PASSPHRASE,
pin=TEST_PIN,
embed_mode="lsb",
)
assert decoded.message == TEST_MESSAGE
def test_decode_text_helper(self, carrier_bytes, ref_bytes):
"""Test decode_text convenience function."""
result = encode(
message=TEST_MESSAGE,
reference_photo=ref_bytes,
carrier_image=carrier_bytes,
passphrase=TEST_PASSPHRASE,
pin=TEST_PIN,
embed_mode="lsb",
)
text = decode_text(
stego_image=result.stego_image,
reference_photo=ref_bytes,
passphrase=TEST_PASSPHRASE,
pin=TEST_PIN,
embed_mode="lsb",
)
assert text == TEST_MESSAGE
def test_wrong_passphrase_fails(self, carrier_bytes, ref_bytes):
"""Decoding with wrong passphrase should fail."""
result = encode(
message=TEST_MESSAGE,
reference_photo=ref_bytes,
carrier_image=carrier_bytes,
passphrase=TEST_PASSPHRASE,
pin=TEST_PIN,
embed_mode="lsb",
)
with pytest.raises(Exception):
decode(
stego_image=result.stego_image,
reference_photo=ref_bytes,
passphrase="wrong passphrase words here now",
pin=TEST_PIN,
embed_mode="lsb",
)
def test_wrong_pin_fails(self, carrier_bytes, ref_bytes):
"""Decoding with wrong PIN should fail."""
result = encode(
message=TEST_MESSAGE,
reference_photo=ref_bytes,
carrier_image=carrier_bytes,
passphrase=TEST_PASSPHRASE,
pin=TEST_PIN,
embed_mode="lsb",
)
with pytest.raises(Exception):
decode(
stego_image=result.stego_image,
reference_photo=ref_bytes,
passphrase=TEST_PASSPHRASE,
pin="999999999",
embed_mode="lsb",
)
def test_wrong_reference_fails(self, carrier_bytes, ref_bytes, small_image):
"""Decoding with wrong reference should fail."""
result = encode(
message=TEST_MESSAGE,
reference_photo=ref_bytes,
carrier_image=carrier_bytes,
passphrase=TEST_PASSPHRASE,
pin=TEST_PIN,
embed_mode="lsb",
)
with pytest.raises(Exception):
decode(
stego_image=result.stego_image,
reference_photo=small_image, # Wrong reference
passphrase=TEST_PASSPHRASE,
pin=TEST_PIN,
embed_mode="lsb",
)
class TestDCTMode:
"""Test DCT (Discrete Cosine Transform) encoding/decoding."""
@pytest.fixture(autouse=True)
def check_dct_support(self):
"""Skip DCT tests if not supported."""
if not has_dct_support():
pytest.skip("DCT support not available")
def test_encode_decode_roundtrip(self, carrier_bytes, ref_bytes):
"""Basic DCT encode/decode roundtrip."""
result = encode(
message=TEST_MESSAGE,
reference_photo=ref_bytes,
carrier_image=carrier_bytes,
passphrase=TEST_PASSPHRASE,
pin=TEST_PIN,
embed_mode="dct",
)
assert result.stego_image
decoded = decode(
stego_image=result.stego_image,
reference_photo=ref_bytes,
passphrase=TEST_PASSPHRASE,
pin=TEST_PIN,
embed_mode="dct",
)
assert decoded.message == TEST_MESSAGE
def test_dct_jpeg_output(self, carrier_bytes, ref_bytes):
"""Test DCT mode with JPEG output."""
result = encode(
message=TEST_MESSAGE,
reference_photo=ref_bytes,
carrier_image=carrier_bytes,
passphrase=TEST_PASSPHRASE,
pin=TEST_PIN,
embed_mode="dct",
dct_output_format="jpeg",
)
assert result.stego_image
# Verify it's JPEG by checking magic bytes
assert result.stego_image[:2] == b"\xff\xd8"
class TestChannelKey:
"""Test channel key functionality."""
def test_encode_with_channel_key(self, carrier_bytes, ref_bytes):
"""Encode with channel key."""
channel_key = generate_channel_key()
result = encode(
message=TEST_MESSAGE,
reference_photo=ref_bytes,
carrier_image=carrier_bytes,
passphrase=TEST_PASSPHRASE,
pin=TEST_PIN,
channel_key=channel_key,
embed_mode="lsb",
)
assert result.stego_image
# Decode with same channel key
decoded = decode(
stego_image=result.stego_image,
reference_photo=ref_bytes,
passphrase=TEST_PASSPHRASE,
pin=TEST_PIN,
channel_key=channel_key,
embed_mode="lsb",
)
assert decoded.message == TEST_MESSAGE
def test_wrong_channel_key_fails(self, carrier_bytes, ref_bytes):
"""Decoding with wrong channel key should fail."""
channel_key = generate_channel_key()
wrong_key = generate_channel_key()
result = encode(
message=TEST_MESSAGE,
reference_photo=ref_bytes,
carrier_image=carrier_bytes,
passphrase=TEST_PASSPHRASE,
pin=TEST_PIN,
channel_key=channel_key,
embed_mode="lsb",
)
with pytest.raises(Exception):
decode(
stego_image=result.stego_image,
reference_photo=ref_bytes,
passphrase=TEST_PASSPHRASE,
pin=TEST_PIN,
channel_key=wrong_key,
embed_mode="lsb",
)
class TestCompression:
"""Test message compression."""
def test_long_message_compresses(self, carrier_bytes, ref_bytes):
"""Long messages should be compressed."""
long_message = "A" * 1000
result = encode(
message=long_message,
reference_photo=ref_bytes,
carrier_image=carrier_bytes,
passphrase=TEST_PASSPHRASE,
pin=TEST_PIN,
embed_mode="lsb",
)
assert result.stego_image
decoded = decode(
stego_image=result.stego_image,
reference_photo=ref_bytes,
passphrase=TEST_PASSPHRASE,
pin=TEST_PIN,
embed_mode="lsb",
)
assert decoded.message == long_message
class TestEdgeCases:
"""Test edge cases and error handling."""
def test_unicode_message(self, carrier_bytes, ref_bytes):
"""Test encoding Unicode messages."""
unicode_msg = "Hello 🦖 Stegasoo! 日本語 émojis"
result = encode(
message=unicode_msg,
reference_photo=ref_bytes,
carrier_image=carrier_bytes,
passphrase=TEST_PASSPHRASE,
pin=TEST_PIN,
embed_mode="lsb",
)
assert result.stego_image
decoded = decode(
stego_image=result.stego_image,
reference_photo=ref_bytes,
passphrase=TEST_PASSPHRASE,
pin=TEST_PIN,
embed_mode="lsb",
)
assert decoded.message == unicode_msg
def test_minimum_passphrase(self, carrier_bytes, ref_bytes):
"""Test with minimum valid passphrase."""
min_passphrase = "one two three four" # 4 words minimum
result = encode(
message=TEST_MESSAGE,
reference_photo=ref_bytes,
carrier_image=carrier_bytes,
passphrase=min_passphrase,
pin=TEST_PIN,
embed_mode="lsb",
)
assert result.stego_image
def test_special_characters_in_message(self, carrier_bytes, ref_bytes):
"""Test special characters in message."""
special_msg = "Line1\nLine2\tTab\r\nCRLF"
result = encode(
message=special_msg,
reference_photo=ref_bytes,
carrier_image=carrier_bytes,
passphrase=TEST_PASSPHRASE,
pin=TEST_PIN,
embed_mode="lsb",
)
assert result.stego_image
decoded = decode(
stego_image=result.stego_image,
reference_photo=ref_bytes,
passphrase=TEST_PASSPHRASE,
pin=TEST_PIN,
embed_mode="lsb",
)
assert decoded.message == special_msg
# =============================================================================
# VIDEO STEGANOGRAPHY TESTS (v4.4.0)
# =============================================================================
@pytest.fixture
def test_video_bytes():
"""Create a minimal test video using ffmpeg.
Creates a 2-second test video with solid color frames.
Returns None if ffmpeg is not available.
"""
import shutil
import subprocess
import tempfile
if not shutil.which("ffmpeg"):
return None
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
output_path = f.name
try:
# Create a simple 2-second video with colored frames
# Using lavfi (libavfilter) to generate test pattern
result = subprocess.run(
[
"ffmpeg",
"-y",
"-f",
"lavfi",
"-i",
"color=c=blue:s=320x240:d=2:r=10",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-g",
"5", # GOP size - creates I-frames every 5 frames
output_path,
],
capture_output=True,
timeout=30,
)
if result.returncode != 0:
return None
with open(output_path, "rb") as f:
video_data = f.read()
return video_data
except Exception:
return None
finally:
import os
try:
os.unlink(output_path)
except OSError:
pass
class TestVideoSupport:
"""Test video steganography support detection."""
def test_video_support_flag_exists(self):
"""HAS_VIDEO_SUPPORT flag should exist."""
assert hasattr(stegasoo, "HAS_VIDEO_SUPPORT")
assert isinstance(stegasoo.HAS_VIDEO_SUPPORT, bool)
def test_video_constants_exist(self):
"""Video-related constants should exist."""
assert hasattr(stegasoo, "EMBED_MODE_VIDEO_LSB")
assert hasattr(stegasoo, "EMBED_MODE_VIDEO_AUTO")
@pytest.mark.skipif(
not stegasoo.HAS_VIDEO_SUPPORT,
reason="Video support not available (ffmpeg or dependencies missing)",
)
class TestVideoFormatDetection:
"""Test video format detection."""
def test_detect_video_format_mp4(self, test_video_bytes):
"""Should detect MP4 format from magic bytes."""
if test_video_bytes is None:
pytest.skip("Could not create test video")
from soosef.stegasoo import detect_video_format
fmt = detect_video_format(test_video_bytes)
assert fmt in ("mp4", "mov")
def test_detect_video_format_unknown(self):
"""Should return 'unknown' for non-video data."""
from soosef.stegasoo import detect_video_format
fmt = detect_video_format(b"not a video")
assert fmt == "unknown"
@pytest.mark.skipif(
not stegasoo.HAS_VIDEO_SUPPORT,
reason="Video support not available (ffmpeg or dependencies missing)",
)
class TestVideoInfo:
"""Test video metadata extraction."""
def test_get_video_info(self, test_video_bytes):
"""Should extract video metadata."""
if test_video_bytes is None:
pytest.skip("Could not create test video")
from soosef.stegasoo import get_video_info
info = get_video_info(test_video_bytes)
assert info.width == 320
assert info.height == 240
assert info.fps > 0
assert info.duration_seconds > 0
assert info.total_frames > 0
assert info.format in ("mp4", "mov")
def test_validate_video(self, test_video_bytes):
"""Should validate video data."""
if test_video_bytes is None:
pytest.skip("Could not create test video")
from soosef.stegasoo import validate_video
result = validate_video(test_video_bytes, check_duration=False)
assert result.is_valid
assert result.details.get("format") in ("mp4", "mov")
@pytest.mark.skipif(
not stegasoo.HAS_VIDEO_SUPPORT,
reason="Video support not available (ffmpeg or dependencies missing)",
)
class TestVideoCapacity:
"""Test video capacity calculation."""
def test_calculate_video_capacity(self, test_video_bytes):
"""Should calculate steganographic capacity."""
if test_video_bytes is None:
pytest.skip("Could not create test video")
from soosef.stegasoo import calculate_video_capacity
capacity_info = calculate_video_capacity(test_video_bytes)
assert capacity_info.total_frames > 0
assert capacity_info.i_frames > 0
assert capacity_info.usable_capacity_bytes > 0
assert capacity_info.embed_mode == "video_lsb"
assert capacity_info.resolution == (320, 240)
@pytest.mark.skipif(
not stegasoo.HAS_VIDEO_SUPPORT,
reason="Video support not available (ffmpeg or dependencies missing)",
)
class TestVideoEncodeDecode:
"""Test video steganography round-trip."""
def test_video_roundtrip(self, test_video_bytes, ref_bytes):
"""Test encoding and decoding a message in video."""
if test_video_bytes is None:
pytest.skip("Could not create test video")
from soosef.stegasoo import decode_video, encode_video
message = "Secret video message!"
# Encode
stego_video, stats = encode_video(
message=message,
reference_photo=ref_bytes,
carrier_video=test_video_bytes,
passphrase=TEST_PASSPHRASE,
pin=TEST_PIN,
)
assert stego_video
assert len(stego_video) > 0
assert stats.frames_modified > 0
assert stats.codec == "ffv1" # Should use lossless codec
# Decode
result = decode_video(
stego_video=stego_video,
reference_photo=ref_bytes,
passphrase=TEST_PASSPHRASE,
pin=TEST_PIN,
)
assert result.is_text
assert result.message == message
def test_video_wrong_passphrase_fails(self, test_video_bytes, ref_bytes):
"""Decoding with wrong passphrase should fail."""
if test_video_bytes is None:
pytest.skip("Could not create test video")
from soosef.stegasoo import decode_video, encode_video
message = "Secret video message!"
stego_video, _ = encode_video(
message=message,
reference_photo=ref_bytes,
carrier_video=test_video_bytes,
passphrase=TEST_PASSPHRASE,
pin=TEST_PIN,
)
with pytest.raises(Exception):
decode_video(
stego_video=stego_video,
reference_photo=ref_bytes,
passphrase="wrong passphrase words here",
pin=TEST_PIN,
)

View File

@@ -0,0 +1,862 @@
"""
Tests for Stegasoo audio steganography.
Tests cover:
- Audio LSB roundtrip (encode + decode)
- Audio spread spectrum roundtrip (v0 legacy + v2 per-channel)
- Wrong credentials fail to decode
- Capacity calculations (per-tier)
- Format detection
- Audio validation
- Per-channel stereo/multichannel embedding (v4.4.0)
- Chip tier roundtrips (v4.4.0)
- LFE channel skipping (v4.4.0)
- Backward compat: v0 decode from v2 code
- Header v2 build/parse roundtrip
- Round-robin bit distribution
"""
import io
from pathlib import Path
import numpy as np
import pytest
import soundfile as sf
from soosef.stegasoo.constants import AUDIO_ENABLED, EMBED_MODE_AUDIO_LSB, EMBED_MODE_AUDIO_SPREAD
from soosef.stegasoo.models import AudioCapacityInfo, AudioEmbedStats, AudioInfo
pytestmark = pytest.mark.skipif(not AUDIO_ENABLED, reason="Audio support disabled (STEGASOO_AUDIO)")
# Path to real test data files
_TEST_DATA = Path(__file__).parent.parent / "test_data"
_REFERENCE_PNG = _TEST_DATA / "reference.png"
_SPEECH_WAV = _TEST_DATA / "stupid_elitist_speech.wav"
# =============================================================================
# FIXTURES
# =============================================================================
@pytest.fixture
def carrier_wav() -> bytes:
"""Generate a small test WAV file (1 second, 44100 Hz, mono, 16-bit)."""
sample_rate = 44100
duration = 1.0
num_samples = int(sample_rate * duration)
t = np.linspace(0, duration, num_samples, endpoint=False)
samples = (np.sin(2 * np.pi * 440 * t) * 16000).astype(np.int16)
buf = io.BytesIO()
sf.write(buf, samples, sample_rate, format="WAV", subtype="PCM_16")
buf.seek(0)
return buf.read()
@pytest.fixture
def carrier_wav_stereo() -> bytes:
"""Generate a stereo test WAV file (5 seconds for spread spectrum capacity)."""
sample_rate = 44100
duration = 5.0
num_samples = int(sample_rate * duration)
t = np.linspace(0, duration, num_samples, endpoint=False)
left = (np.sin(2 * np.pi * 440 * t) * 16000).astype(np.int16)
right = (np.sin(2 * np.pi * 880 * t) * 16000).astype(np.int16)
samples = np.column_stack([left, right])
buf = io.BytesIO()
sf.write(buf, samples, sample_rate, format="WAV", subtype="PCM_16")
buf.seek(0)
return buf.read()
@pytest.fixture
def carrier_wav_long() -> bytes:
"""Generate a longer WAV (15 seconds) for spread spectrum tests."""
sample_rate = 44100
duration = 15.0
num_samples = int(sample_rate * duration)
t = np.linspace(0, duration, num_samples, endpoint=False)
samples = (
(np.sin(2 * np.pi * 440 * t) + np.sin(2 * np.pi * 880 * t) + np.sin(2 * np.pi * 1320 * t))
* 5000
).astype(np.int16)
buf = io.BytesIO()
sf.write(buf, samples, sample_rate, format="WAV", subtype="PCM_16")
buf.seek(0)
return buf.read()
@pytest.fixture
def carrier_wav_stereo_long() -> bytes:
"""Generate a stereo WAV (15 seconds) for per-channel spread tests."""
sample_rate = 48000
duration = 15.0
num_samples = int(sample_rate * duration)
t = np.linspace(0, duration, num_samples, endpoint=False)
left = (np.sin(2 * np.pi * 440 * t) * 10000).astype(np.float64) / 32768.0
right = (np.sin(2 * np.pi * 660 * t) * 10000).astype(np.float64) / 32768.0
samples = np.column_stack([left, right])
buf = io.BytesIO()
sf.write(buf, samples, sample_rate, format="WAV", subtype="PCM_16")
buf.seek(0)
return buf.read()
@pytest.fixture
def carrier_wav_5_1() -> bytes:
"""Generate a 6-channel (5.1) WAV for LFE skip tests."""
sample_rate = 48000
duration = 15.0
num_samples = int(sample_rate * duration)
t = np.linspace(0, duration, num_samples, endpoint=False)
# 6 channels with different frequencies
freqs = [440, 554, 660, 80, 880, 1100] # ch3 = LFE (low freq)
channels = []
for freq in freqs:
ch = (np.sin(2 * np.pi * freq * t) * 8000).astype(np.float64) / 32768.0
channels.append(ch)
samples = np.column_stack(channels)
buf = io.BytesIO()
sf.write(buf, samples, sample_rate, format="WAV", subtype="PCM_16")
buf.seek(0)
return buf.read()
@pytest.fixture
def carrier_wav_spread_integration() -> bytes:
"""Generate a very long WAV (150 seconds) for spread spectrum integration tests."""
sample_rate = 44100
duration = 150.0
num_samples = int(sample_rate * duration)
t = np.linspace(0, duration, num_samples, endpoint=False)
samples = (
(np.sin(2 * np.pi * 440 * t) + np.sin(2 * np.pi * 880 * t) + np.sin(2 * np.pi * 1320 * t))
* 5000
).astype(np.int16)
buf = io.BytesIO()
sf.write(buf, samples, sample_rate, format="WAV", subtype="PCM_16")
buf.seek(0)
return buf.read()
@pytest.fixture
def reference_photo() -> bytes:
"""Load real reference photo from test_data, or generate a small one."""
if _REFERENCE_PNG.exists():
return _REFERENCE_PNG.read_bytes()
from PIL import Image
img = Image.new("RGB", (100, 100), color=(128, 64, 32))
buf = io.BytesIO()
img.save(buf, "PNG")
buf.seek(0)
return buf.read()
@pytest.fixture
def speech_wav() -> bytes:
"""Load real speech WAV from test_data (48kHz mono, ~68s)."""
if not _SPEECH_WAV.exists():
pytest.skip("test_data/stupid_elitist_speech.wav not found")
return _SPEECH_WAV.read_bytes()
# =============================================================================
# AUDIO LSB TESTS
# =============================================================================
class TestAudioLSB:
"""Tests for audio LSB steganography."""
def test_calculate_capacity(self, carrier_wav):
from soosef.stegasoo.audio_steganography import calculate_audio_lsb_capacity
capacity = calculate_audio_lsb_capacity(carrier_wav)
assert capacity > 0
# 1 second at 44100 Hz mono should give ~5KB capacity at 1 bit/sample
assert capacity > 4000
def test_embed_extract_roundtrip(self, carrier_wav):
"""Test basic LSB embed/extract roundtrip."""
from soosef.stegasoo.audio_steganography import embed_in_audio_lsb, extract_from_audio_lsb
payload = b"Hello, audio steganography!"
key = b"\x42" * 32
stego_audio, stats = embed_in_audio_lsb(payload, carrier_wav, key)
assert isinstance(stats, AudioEmbedStats)
assert stats.embed_mode == EMBED_MODE_AUDIO_LSB
assert stats.bytes_embedded > 0
assert stats.samples_modified > 0
assert 0 < stats.capacity_used <= 1.0
extracted = extract_from_audio_lsb(stego_audio, key)
assert extracted is not None
assert extracted == payload
def test_embed_extract_stereo(self, carrier_wav_stereo):
"""Test LSB roundtrip with stereo audio."""
from soosef.stegasoo.audio_steganography import embed_in_audio_lsb, extract_from_audio_lsb
payload = b"Stereo test message"
key = b"\xAB" * 32
stego_audio, stats = embed_in_audio_lsb(payload, carrier_wav_stereo, key)
assert stats.channels == 2
extracted = extract_from_audio_lsb(stego_audio, key)
assert extracted == payload
def test_wrong_key_fails(self, carrier_wav):
"""Test that wrong key produces no valid extraction."""
from soosef.stegasoo.audio_steganography import embed_in_audio_lsb, extract_from_audio_lsb
payload = b"Secret message"
correct_key = b"\x42" * 32
wrong_key = b"\xFF" * 32
stego_audio, _ = embed_in_audio_lsb(payload, carrier_wav, correct_key)
extracted = extract_from_audio_lsb(stego_audio, wrong_key)
assert extracted is None or extracted != payload
def test_two_bits_per_sample(self, carrier_wav):
"""Test embedding with 2 bits per sample."""
from soosef.stegasoo.audio_steganography import embed_in_audio_lsb, extract_from_audio_lsb
payload = b"Two bits per sample test"
key = b"\x55" * 32
stego_audio, stats = embed_in_audio_lsb(payload, carrier_wav, key, bits_per_sample=2)
extracted = extract_from_audio_lsb(stego_audio, key, bits_per_sample=2)
assert extracted == payload
def test_generate_sample_indices(self):
"""Test deterministic sample index generation."""
from soosef.stegasoo.audio_steganography import generate_sample_indices
key = b"\x42" * 32
indices1 = generate_sample_indices(key, 10000, 100)
indices2 = generate_sample_indices(key, 10000, 100)
assert indices1 == indices2
assert all(0 <= i < 10000 for i in indices1)
assert len(set(indices1)) == len(indices1)
# =============================================================================
# AUDIO SPREAD SPECTRUM TESTS (v2 per-channel)
# =============================================================================
class TestAudioSpread:
"""Tests for audio spread spectrum steganography (v2 per-channel)."""
def test_calculate_capacity_default_tier(self, carrier_wav_long):
from soosef.stegasoo.spread_steganography import calculate_audio_spread_capacity
capacity = calculate_audio_spread_capacity(carrier_wav_long)
assert isinstance(capacity, AudioCapacityInfo)
assert capacity.usable_capacity_bytes > 0
assert capacity.embed_mode == EMBED_MODE_AUDIO_SPREAD
assert capacity.chip_tier == 2 # default
assert capacity.chip_length == 1024
def test_calculate_capacity_per_tier(self, carrier_wav_long):
"""Capacity should increase as chip length decreases."""
from soosef.stegasoo.spread_steganography import calculate_audio_spread_capacity
cap_lossless = calculate_audio_spread_capacity(carrier_wav_long, chip_tier=0)
cap_high = calculate_audio_spread_capacity(carrier_wav_long, chip_tier=1)
cap_low = calculate_audio_spread_capacity(carrier_wav_long, chip_tier=2)
assert cap_lossless.chip_length == 256
assert cap_high.chip_length == 512
assert cap_low.chip_length == 1024
# Smaller chip = more capacity
assert cap_lossless.usable_capacity_bytes > cap_high.usable_capacity_bytes
assert cap_high.usable_capacity_bytes > cap_low.usable_capacity_bytes
def test_spread_roundtrip_default_tier(self, carrier_wav_long):
"""Test spread spectrum embed/extract roundtrip (default tier 2)."""
from soosef.stegasoo.spread_steganography import (
embed_in_audio_spread,
extract_from_audio_spread,
)
payload = b"Spread test v2"
seed = b"\x42" * 32
stego_audio, stats = embed_in_audio_spread(payload, carrier_wav_long, seed)
assert isinstance(stats, AudioEmbedStats)
assert stats.embed_mode == EMBED_MODE_AUDIO_SPREAD
assert stats.chip_tier == 2
assert stats.chip_length == 1024
extracted = extract_from_audio_spread(stego_audio, seed)
assert extracted is not None
assert extracted == payload
def test_spread_roundtrip_tier_0(self, carrier_wav_long):
"""Test spread spectrum at tier 0 (chip=256, lossless)."""
from soosef.stegasoo.spread_steganography import (
embed_in_audio_spread,
extract_from_audio_spread,
)
payload = b"Lossless tier test with more data to embed for coverage"
seed = b"\x42" * 32
stego_audio, stats = embed_in_audio_spread(payload, carrier_wav_long, seed, chip_tier=0)
assert stats.chip_tier == 0
assert stats.chip_length == 256
extracted = extract_from_audio_spread(stego_audio, seed)
assert extracted is not None
assert extracted == payload
def test_spread_roundtrip_tier_1(self, carrier_wav_long):
"""Test spread spectrum at tier 1 (chip=512, high lossy)."""
from soosef.stegasoo.spread_steganography import (
embed_in_audio_spread,
extract_from_audio_spread,
)
payload = b"High lossy tier test"
seed = b"\x42" * 32
stego_audio, stats = embed_in_audio_spread(payload, carrier_wav_long, seed, chip_tier=1)
assert stats.chip_tier == 1
assert stats.chip_length == 512
extracted = extract_from_audio_spread(stego_audio, seed)
assert extracted is not None
assert extracted == payload
def test_wrong_seed_fails(self, carrier_wav_long):
"""Test that wrong seed produces no valid extraction."""
from soosef.stegasoo.spread_steganography import (
embed_in_audio_spread,
extract_from_audio_spread,
)
payload = b"Secret spread"
correct_seed = b"\x42" * 32
wrong_seed = b"\xFF" * 32
stego_audio, _ = embed_in_audio_spread(payload, carrier_wav_long, correct_seed)
extracted = extract_from_audio_spread(stego_audio, wrong_seed)
assert extracted is None or extracted != payload
def test_per_channel_stereo_roundtrip(self, carrier_wav_stereo_long):
"""Test that stereo per-channel embedding/extraction works."""
from soosef.stegasoo.spread_steganography import (
embed_in_audio_spread,
extract_from_audio_spread,
)
payload = b"Stereo per-channel test"
seed = b"\xAB" * 32
stego_audio, stats = embed_in_audio_spread(
payload, carrier_wav_stereo_long, seed, chip_tier=0
)
assert stats.channels == 2
assert stats.embeddable_channels == 2
extracted = extract_from_audio_spread(stego_audio, seed)
assert extracted is not None
assert extracted == payload
def test_per_channel_preserves_spatial_mix(self, carrier_wav_stereo_long):
"""Verify that per-channel embedding doesn't destroy the spatial mix.
The difference between left and right channels should be preserved
(not zeroed out as the old mono-broadcast approach would do).
"""
from soosef.stegasoo.spread_steganography import embed_in_audio_spread
payload = b"Spatial preservation test"
seed = b"\xCD" * 32
# Read original
orig_samples, _ = sf.read(io.BytesIO(carrier_wav_stereo_long), dtype="float64", always_2d=True)
orig_diff = orig_samples[:, 0] - orig_samples[:, 1]
# Embed
stego_bytes, _ = embed_in_audio_spread(
payload, carrier_wav_stereo_long, seed, chip_tier=0
)
# Read stego
stego_samples, _ = sf.read(io.BytesIO(stego_bytes), dtype="float64", always_2d=True)
stego_diff = stego_samples[:, 0] - stego_samples[:, 1]
# The channel difference should not be identical (embedding adds different
# noise per channel), but should be very close (embedding is subtle)
# With the old mono-broadcast approach, stego_diff would equal orig_diff
# exactly in unmodified regions but differ where data was embedded.
# With per-channel, both channels get independent modifications.
correlation = np.corrcoef(orig_diff, stego_diff)[0, 1]
assert correlation > 0.95, f"Spatial mix correlation too low: {correlation}"
def test_capacity_scales_with_channels(self, carrier_wav_long, carrier_wav_stereo_long):
"""Stereo should have roughly double the capacity of mono."""
from soosef.stegasoo.spread_steganography import calculate_audio_spread_capacity
mono_cap = calculate_audio_spread_capacity(carrier_wav_long, chip_tier=0)
stereo_cap = calculate_audio_spread_capacity(carrier_wav_stereo_long, chip_tier=0)
# Stereo should be ~1.5-2.2x mono (not exact because header is ch0 only
# and the files have slightly different durations/sample rates)
ratio = stereo_cap.usable_capacity_bytes / mono_cap.usable_capacity_bytes
assert ratio > 1.3, f"Stereo/mono capacity ratio too low: {ratio}"
def test_lfe_skip_5_1(self, carrier_wav_5_1):
"""LFE channel (index 3) should be unmodified in 6-channel audio."""
from soosef.stegasoo.spread_steganography import embed_in_audio_spread
payload = b"LFE skip test"
seed = b"\xEE" * 32
# Read original LFE channel
orig_samples, _ = sf.read(io.BytesIO(carrier_wav_5_1), dtype="float64", always_2d=True)
orig_lfe = orig_samples[:, 3].copy()
stego_bytes, stats = embed_in_audio_spread(
payload, carrier_wav_5_1, seed, chip_tier=0
)
assert stats.embeddable_channels == 5 # 6 channels - 1 LFE = 5
stego_samples, _ = sf.read(io.BytesIO(stego_bytes), dtype="float64", always_2d=True)
stego_lfe = stego_samples[:, 3]
# LFE channel should be completely unmodified
np.testing.assert_array_equal(orig_lfe, stego_lfe)
def test_lfe_skip_roundtrip(self, carrier_wav_5_1):
"""5.1 audio embed/extract roundtrip with LFE skipping."""
from soosef.stegasoo.spread_steganography import (
embed_in_audio_spread,
extract_from_audio_spread,
)
payload = b"5.1 surround test"
seed = b"\xEE" * 32
stego_bytes, stats = embed_in_audio_spread(
payload, carrier_wav_5_1, seed, chip_tier=0
)
assert stats.channels == 6
assert stats.embeddable_channels == 5
extracted = extract_from_audio_spread(stego_bytes, seed)
assert extracted is not None
assert extracted == payload
# =============================================================================
# HEADER V2 TESTS
# =============================================================================
class TestHeaderV2:
"""Tests for v2 header construction and parsing."""
def test_header_v2_build_parse_roundtrip(self):
from soosef.stegasoo.spread_steganography import _build_header_v2, _parse_header
data_length = 12345
chip_tier = 1
num_ch = 2
lfe_skipped = False
header = _build_header_v2(data_length, chip_tier, num_ch, lfe_skipped)
assert len(header) == 20
magic_valid, version, length, tier, nch, lfe = _parse_header(header)
assert magic_valid
assert version == 2
assert length == data_length
assert tier == chip_tier
assert nch == num_ch
assert lfe is False
def test_header_v2_with_lfe_flag(self):
from soosef.stegasoo.spread_steganography import _build_header_v2, _parse_header
header = _build_header_v2(999, 0, 5, lfe_skipped=True)
magic_valid, version, length, tier, nch, lfe = _parse_header(header)
assert magic_valid
assert version == 2
assert length == 999
assert tier == 0
assert nch == 5
assert lfe is True
def test_header_v0_build_parse(self):
from soosef.stegasoo.spread_steganography import _build_header_v0, _parse_header
header = _build_header_v0(4567)
assert len(header) == 16
magic_valid, version, length, tier, nch, lfe = _parse_header(header)
assert magic_valid
assert version == 0
assert length == 4567
assert tier is None
assert nch is None
def test_header_bad_magic(self):
from soosef.stegasoo.spread_steganography import _parse_header
bad_header = b"XXXX" + b"\x00" * 16
magic_valid, version, length, tier, nch, lfe = _parse_header(bad_header)
assert not magic_valid
# =============================================================================
# ROUND-ROBIN BIT DISTRIBUTION TESTS
# =============================================================================
class TestRoundRobin:
"""Tests for round-robin bit distribution."""
def test_distribute_and_collect_identity(self):
from soosef.stegasoo.spread_steganography import (
_collect_bits_round_robin,
_distribute_bits_round_robin,
)
bits = [1, 0, 1, 1, 0, 0, 1, 0, 1, 1]
for num_ch in [1, 2, 3, 4, 5]:
per_ch = _distribute_bits_round_robin(bits, num_ch)
assert len(per_ch) == num_ch
reassembled = _collect_bits_round_robin(per_ch)
assert reassembled == bits, f"Failed for {num_ch} channels"
def test_distribute_round_robin_ordering(self):
from soosef.stegasoo.spread_steganography import _distribute_bits_round_robin
bits = [0, 1, 2, 3, 4, 5] # using ints for clarity
per_ch = _distribute_bits_round_robin(bits, 3)
# ch0: bits 0, 3 ch1: bits 1, 4 ch2: bits 2, 5
assert per_ch[0] == [0, 3]
assert per_ch[1] == [1, 4]
assert per_ch[2] == [2, 5]
def test_distribute_uneven(self):
from soosef.stegasoo.spread_steganography import (
_collect_bits_round_robin,
_distribute_bits_round_robin,
)
bits = [0, 1, 2, 3, 4] # 5 bits across 3 channels
per_ch = _distribute_bits_round_robin(bits, 3)
assert per_ch[0] == [0, 3]
assert per_ch[1] == [1, 4]
assert per_ch[2] == [2]
reassembled = _collect_bits_round_robin(per_ch)
assert reassembled == bits
# =============================================================================
# CHANNEL MANAGEMENT TESTS
# =============================================================================
class TestChannelManagement:
"""Tests for embeddable channel selection."""
def test_mono(self):
from soosef.stegasoo.spread_steganography import _embeddable_channels
assert _embeddable_channels(1) == [0]
def test_stereo(self):
from soosef.stegasoo.spread_steganography import _embeddable_channels
assert _embeddable_channels(2) == [0, 1]
def test_5_1_skips_lfe(self):
from soosef.stegasoo.spread_steganography import _embeddable_channels
channels = _embeddable_channels(6)
assert channels == [0, 1, 2, 4, 5]
assert 3 not in channels # LFE skipped
def test_7_1_skips_lfe(self):
from soosef.stegasoo.spread_steganography import _embeddable_channels
channels = _embeddable_channels(8)
assert 3 not in channels
assert len(channels) == 7
def test_quad_no_skip(self):
from soosef.stegasoo.spread_steganography import _embeddable_channels
# 4 channels < 6, so no LFE skip
assert _embeddable_channels(4) == [0, 1, 2, 3]
# =============================================================================
# FORMAT DETECTION TESTS
# =============================================================================
class TestFormatDetection:
"""Tests for audio format detection."""
def test_detect_wav(self, carrier_wav):
from soosef.stegasoo.audio_utils import detect_audio_format
assert detect_audio_format(carrier_wav) == "wav"
def test_detect_unknown(self):
from soosef.stegasoo.audio_utils import detect_audio_format
assert detect_audio_format(b"not audio data") == "unknown"
def test_detect_empty(self):
from soosef.stegasoo.audio_utils import detect_audio_format
assert detect_audio_format(b"") == "unknown"
# =============================================================================
# AUDIO INFO TESTS
# =============================================================================
class TestAudioInfo:
"""Tests for audio info extraction."""
def test_get_wav_info(self, carrier_wav):
from soosef.stegasoo.audio_utils import get_audio_info
info = get_audio_info(carrier_wav)
assert isinstance(info, AudioInfo)
assert info.sample_rate == 44100
assert info.channels == 1
assert info.format == "wav"
assert abs(info.duration_seconds - 1.0) < 0.1
def test_get_stereo_info(self, carrier_wav_stereo):
from soosef.stegasoo.audio_utils import get_audio_info
info = get_audio_info(carrier_wav_stereo)
assert info.channels == 2
# =============================================================================
# VALIDATION TESTS
# =============================================================================
class TestAudioValidation:
"""Tests for audio validation."""
def test_validate_valid_audio(self, carrier_wav):
from soosef.stegasoo.audio_utils import validate_audio
result = validate_audio(carrier_wav)
assert result.is_valid
def test_validate_empty_audio(self):
from soosef.stegasoo.audio_utils import validate_audio
result = validate_audio(b"")
assert not result.is_valid
def test_validate_invalid_audio(self):
from soosef.stegasoo.audio_utils import validate_audio
result = validate_audio(b"not audio data at all")
assert not result.is_valid
def test_validate_audio_embed_mode(self):
from soosef.stegasoo.validation import validate_audio_embed_mode
assert validate_audio_embed_mode("audio_lsb").is_valid
assert validate_audio_embed_mode("audio_spread").is_valid
assert validate_audio_embed_mode("audio_auto").is_valid
assert not validate_audio_embed_mode("invalid").is_valid
# =============================================================================
# INTEGRATION TESTS
# =============================================================================
class TestIntegration:
"""End-to-end integration tests using encode_audio/decode_audio."""
def test_lsb_encode_decode(self, carrier_wav, reference_photo):
from soosef.stegasoo.decode import decode_audio
from soosef.stegasoo.encode import encode_audio
stego_audio, stats = encode_audio(
message="Hello from audio steganography!",
reference_photo=reference_photo,
carrier_audio=carrier_wav,
passphrase="test words here now",
pin="123456",
embed_mode="audio_lsb",
)
assert len(stego_audio) > 0
result = decode_audio(
stego_audio=stego_audio,
reference_photo=reference_photo,
passphrase="test words here now",
pin="123456",
embed_mode="audio_lsb",
)
assert result.is_text
assert result.message == "Hello from audio steganography!"
def test_lsb_wrong_credentials(self, carrier_wav, reference_photo):
from soosef.stegasoo.decode import decode_audio
from soosef.stegasoo.encode import encode_audio
stego_audio, _ = encode_audio(
message="Secret",
reference_photo=reference_photo,
carrier_audio=carrier_wav,
passphrase="correct horse battery staple",
pin="123456",
embed_mode="audio_lsb",
)
with pytest.raises(Exception):
decode_audio(
stego_audio=stego_audio,
reference_photo=reference_photo,
passphrase="wrong passphrase words here",
pin="654321",
embed_mode="audio_lsb",
)
def test_spread_encode_decode(self, carrier_wav_spread_integration, reference_photo):
"""Test full spread spectrum encode/decode pipeline."""
from soosef.stegasoo.decode import decode_audio
from soosef.stegasoo.encode import encode_audio
stego_audio, stats = encode_audio(
message="Spread integration test",
reference_photo=reference_photo,
carrier_audio=carrier_wav_spread_integration,
passphrase="test words here now",
pin="123456",
embed_mode="audio_spread",
)
result = decode_audio(
stego_audio=stego_audio,
reference_photo=reference_photo,
passphrase="test words here now",
pin="123456",
embed_mode="audio_spread",
)
assert result.message == "Spread integration test"
def test_spread_encode_decode_with_chip_tier(
self, carrier_wav_spread_integration, reference_photo
):
"""Test spread spectrum with explicit chip tier."""
from soosef.stegasoo.decode import decode_audio
from soosef.stegasoo.encode import encode_audio
stego_audio, stats = encode_audio(
message="Tier 0 integration",
reference_photo=reference_photo,
carrier_audio=carrier_wav_spread_integration,
passphrase="test words here now",
pin="123456",
embed_mode="audio_spread",
chip_tier=0,
)
assert stats.chip_tier == 0
assert stats.chip_length == 256
result = decode_audio(
stego_audio=stego_audio,
reference_photo=reference_photo,
passphrase="test words here now",
pin="123456",
embed_mode="audio_spread",
)
assert result.message == "Tier 0 integration"
def test_auto_detect_lsb(self, carrier_wav, reference_photo):
"""Test auto-detection finds LSB encoded audio."""
from soosef.stegasoo.decode import decode_audio
from soosef.stegasoo.encode import encode_audio
stego_audio, _ = encode_audio(
message="Auto-detect test",
reference_photo=reference_photo,
carrier_audio=carrier_wav,
passphrase="test words here now",
pin="123456",
embed_mode="audio_lsb",
)
result = decode_audio(
stego_audio=stego_audio,
reference_photo=reference_photo,
passphrase="test words here now",
pin="123456",
embed_mode="audio_auto",
)
assert result.message == "Auto-detect test"
def test_spread_with_real_speech(self, speech_wav, reference_photo):
"""Test spread spectrum with real speech audio from test_data."""
from soosef.stegasoo.decode import decode_audio
from soosef.stegasoo.encode import encode_audio
message = "Hidden in a speech about elitism"
stego_audio, stats = encode_audio(
message=message,
reference_photo=reference_photo,
carrier_audio=speech_wav,
passphrase="test words here now",
pin="123456",
embed_mode="audio_spread",
chip_tier=0, # lossless tier for max capacity
)
assert stats.chip_tier == 0
result = decode_audio(
stego_audio=stego_audio,
reference_photo=reference_photo,
passphrase="test words here now",
pin="123456",
embed_mode="audio_spread",
)
assert result.message == message

View File

@@ -0,0 +1,85 @@
"""Basic tests for image hashing."""
from io import BytesIO
import pytest
from PIL import Image
from soosef.verisoo.hashing import hash_image, perceptual_distance, is_same_image
def create_test_image(width: int = 100, height: int = 100, color: tuple = (255, 0, 0)) -> bytes:
"""Create a simple test image."""
img = Image.new("RGB", (width, height), color)
buffer = BytesIO()
img.save(buffer, format="PNG")
return buffer.getvalue()
class TestHashImage:
"""Tests for hash_image function."""
def test_hash_returns_all_components(self):
"""Hash should return sha256, phash, and dhash."""
image_data = create_test_image()
hashes = hash_image(image_data)
assert hashes.sha256
assert hashes.phash
assert hashes.dhash
assert len(hashes.sha256) == 64 # SHA-256 hex
def test_identical_images_same_hash(self):
"""Identical bytes should produce identical hashes."""
image_data = create_test_image()
hash1 = hash_image(image_data)
hash2 = hash_image(image_data)
assert hash1.sha256 == hash2.sha256
assert hash1.phash == hash2.phash
assert hash1.dhash == hash2.dhash
def test_different_images_different_hash(self):
"""Different images should produce different SHA-256."""
red = create_test_image(color=(255, 0, 0))
blue = create_test_image(color=(0, 0, 255))
hash_red = hash_image(red)
hash_blue = hash_image(blue)
assert hash_red.sha256 != hash_blue.sha256
class TestPerceptualDistance:
"""Tests for perceptual distance calculation."""
def test_identical_hashes_zero_distance(self):
"""Identical hashes should have zero distance."""
h = "0123456789abcdef"
assert perceptual_distance(h, h) == 0
def test_different_hashes_nonzero_distance(self):
"""Different hashes should have positive distance."""
h1 = "0000000000000000"
h2 = "0000000000000001"
assert perceptual_distance(h1, h2) == 1
def test_completely_different_max_distance(self):
"""Completely different hashes should have max distance."""
h1 = "0000000000000000"
h2 = "ffffffffffffffff"
assert perceptual_distance(h1, h2) == 64 # 16 hex chars = 64 bits
class TestIsSameImage:
"""Tests for image comparison."""
def test_exact_match(self):
"""Identical bytes should be exact match."""
image_data = create_test_image()
hash1 = hash_image(image_data)
hash2 = hash_image(image_data)
is_same, reason = is_same_image(hash1, hash2)
assert is_same
assert reason == "exact"