Tests cover: - Version info - Credential generation (passphrase, PIN, channel key) - Validation functions (passphrase, PIN, message, image) - LSB encode/decode roundtrip and failure cases - DCT encode/decode roundtrip and JPEG output - Channel key encode/decode and wrong key rejection - Compression of long messages - Edge cases: Unicode, special chars, minimum passphrase 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
457 lines
13 KiB
Python
457 lines
13 KiB
Python
"""
|
|
Stegasoo Library Unit Tests
|
|
|
|
Tests core functionality: encode/decode, LSB/DCT modes, channel keys, validation.
|
|
"""
|
|
|
|
import io
|
|
import pytest
|
|
from pathlib import Path
|
|
from PIL import Image
|
|
|
|
import stegasoo
|
|
from stegasoo import (
|
|
encode,
|
|
decode,
|
|
decode_text,
|
|
generate_passphrase,
|
|
generate_pin,
|
|
generate_channel_key,
|
|
validate_passphrase,
|
|
validate_pin,
|
|
validate_image,
|
|
validate_message,
|
|
has_dct_support,
|
|
EncodeResult,
|
|
DecodeResult,
|
|
ValidationError,
|
|
CapacityError,
|
|
)
|
|
|
|
# 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
|