Clean up repo structure

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Aaron D. Lee
2026-01-05 18:05:06 -05:00
parent 12c4b091fb
commit ac08011236
5 changed files with 2 additions and 1402 deletions

View File

@@ -1,785 +0,0 @@
"""
Stegasoo Tests (v4.0.0)
Tests for key generation, validation, encoding/decoding, output formats,
and channel key functionality.
Updated for v4.0.0:
- Same API as v3.2.0 (passphrase, no date_str)
- Channel key support for deployment/group isolation
- HEADER_OVERHEAD increased to 66 bytes (flags byte added)
- Python 3.12 recommended (3.13 not supported)
"""
import io
import pytest
from PIL import Image
import stegasoo
from stegasoo import (
decode,
decode_text,
encode,
generate_channel_key,
generate_credentials,
generate_passphrase,
generate_pin,
get_channel_fingerprint,
validate_channel_key,
validate_message,
validate_passphrase,
validate_pin,
)
from stegasoo.steganography import get_output_format
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture
def png_image():
"""Create a test PNG image."""
img = Image.new("RGB", (100, 100), color="red")
buf = io.BytesIO()
img.save(buf, format="PNG")
buf.seek(0)
return buf.getvalue()
@pytest.fixture
def large_png_image():
"""Create a larger test PNG image for DCT mode.
Uses noise instead of solid color to ensure DCT color mode works.
Solid colors cause coefficient drift during RGB conversion that
can exceed the quantization step and corrupt embedded data.
"""
import numpy as np
# Create random noise image (ensures varied Y channel values)
np.random.seed(42) # Reproducible
data = np.random.randint(0, 256, (400, 400, 3), dtype=np.uint8)
img = Image.fromarray(data, 'RGB')
buf = io.BytesIO()
img.save(buf, format="PNG")
buf.seek(0)
return buf.getvalue()
@pytest.fixture
def bmp_image():
"""Create a test BMP image."""
img = Image.new("RGB", (100, 100), color="blue")
buf = io.BytesIO()
img.save(buf, format="BMP")
buf.seek(0)
return buf.getvalue()
@pytest.fixture
def jpeg_image():
"""Create a test JPEG image."""
img = Image.new("RGB", (100, 100), color="green")
buf = io.BytesIO()
img.save(buf, format="JPEG")
buf.seek(0)
return buf.getvalue()
@pytest.fixture
def gif_image():
"""Create a test GIF image."""
img = Image.new("RGB", (100, 100), color="yellow")
buf = io.BytesIO()
img.save(buf, format="GIF")
buf.seek(0)
return buf.getvalue()
# =============================================================================
# Key Generation Tests (v3.2.0 Updated)
# =============================================================================
class TestKeygen:
"""Tests for key generation functions."""
def test_generate_pin_default(self):
"""Default PIN should be 6 digits, no leading zero."""
pin = generate_pin()
assert len(pin) == 6
assert pin.isdigit()
assert pin[0] != "0"
def test_generate_pin_lengths(self):
"""PIN generation should work for all valid lengths."""
for length in [6, 7, 8, 9]:
pin = generate_pin(length)
assert len(pin) == length
assert pin.isdigit()
def test_generate_passphrase_default(self):
"""Default passphrase should have 4 words (v3.2.0 change)."""
phrase = generate_passphrase()
words = phrase.split()
assert len(words) == 4 # Changed from 3 in v3.1.x
def test_generate_passphrase_custom_length(self):
"""Passphrase generation should work for custom lengths."""
for length in [3, 4, 5, 6, 8, 12]:
phrase = generate_passphrase(length)
words = phrase.split()
assert len(words) == length
def test_generate_credentials_pin_only(self):
"""PIN-only credentials should have single passphrase."""
creds = generate_credentials(use_pin=True, use_rsa=False)
assert creds.pin is not None
assert creds.rsa_key_pem is None
# v3.2.0: Single passphrase instead of 7 daily phrases
assert creds.passphrase is not None
assert isinstance(creds.passphrase, str)
assert " " in creds.passphrase # Should have multiple words
def test_generate_credentials_rsa_only(self):
"""RSA-only credentials should have single passphrase."""
creds = generate_credentials(use_pin=False, use_rsa=True)
assert creds.pin is None
assert creds.rsa_key_pem is not None
assert creds.passphrase is not None
def test_generate_credentials_both(self):
"""Both PIN and RSA should work together."""
creds = generate_credentials(use_pin=True, use_rsa=True)
assert creds.pin is not None
assert creds.rsa_key_pem is not None
assert creds.passphrase is not None
def test_generate_credentials_neither_fails(self):
"""Generating with neither PIN nor RSA should fail."""
with pytest.raises((ValueError, AssertionError)):
generate_credentials(use_pin=False, use_rsa=False)
def test_generate_credentials_custom_words(self):
"""Custom passphrase_words parameter should work."""
creds = generate_credentials(use_pin=True, passphrase_words=6)
words = creds.passphrase.split()
assert len(words) == 6
def test_generate_credentials_default_words(self):
"""Default should be 4 words (v3.2.0)."""
creds = generate_credentials(use_pin=True)
words = creds.passphrase.split()
assert len(words) == 4
def test_passphrase_entropy_calculation(self):
"""Passphrase entropy should be calculated correctly."""
creds = generate_credentials(use_pin=True, passphrase_words=4)
# 4 words × 11 bits/word = 44 bits
assert creds.passphrase_entropy == 44
def test_total_entropy_calculation(self):
"""Total entropy should sum all components."""
creds = generate_credentials(use_pin=True, use_rsa=False, passphrase_words=4)
# 44 bits (passphrase) + ~20 bits (PIN)
assert creds.total_entropy > 0
assert creds.total_entropy >= creds.passphrase_entropy
# =============================================================================
# Validation Tests (v3.2.0 Updated)
# =============================================================================
class TestValidation:
"""Tests for validation functions."""
def test_validate_pin_valid(self):
"""Valid PIN should pass validation."""
result = validate_pin("123456")
assert result.is_valid
def test_validate_pin_empty_ok(self):
"""Empty PIN should be valid (RSA key might be used instead)."""
result = validate_pin("")
assert result.is_valid
def test_validate_pin_too_short(self):
"""PIN shorter than 6 digits should fail."""
result = validate_pin("12345")
assert not result.is_valid
def test_validate_pin_too_long(self):
"""PIN longer than 9 digits should fail."""
result = validate_pin("1234567890")
assert not result.is_valid
def test_validate_pin_leading_zero(self):
"""PIN with leading zero should fail."""
result = validate_pin("012345")
assert not result.is_valid
def test_validate_pin_non_digits(self):
"""PIN with non-digit characters should fail."""
result = validate_pin("12345a")
assert not result.is_valid
def test_validate_message_valid(self):
"""Valid message should pass validation."""
result = validate_message("Hello, World!")
assert result.is_valid
def test_validate_message_empty(self):
"""Empty message should fail validation."""
result = validate_message("")
assert not result.is_valid
def test_validate_passphrase_valid(self):
"""Valid passphrase should pass validation."""
result = validate_passphrase("word1 word2 word3 word4")
assert result.is_valid
def test_validate_passphrase_empty(self):
"""Empty passphrase should fail validation."""
result = validate_passphrase("")
assert not result.is_valid
def test_validate_passphrase_short_warning(self):
"""Short passphrase should have warning but still be valid."""
result = validate_passphrase("word1 word2 word3") # Only 3 words
assert result.is_valid
assert result.warning is not None # Should warn about short passphrase
def test_validate_passphrase_recommended_no_warning(self):
"""Recommended length passphrase should have no warning."""
result = validate_passphrase("word1 word2 word3 word4") # 4 words
assert result.is_valid
# May or may not have warning depending on implementation
# =============================================================================
# Output Format Tests
# =============================================================================
class TestOutputFormat:
"""Tests for output format handling."""
def test_png_stays_png(self):
"""PNG input should produce PNG output."""
fmt, ext = get_output_format("PNG")
assert fmt == "PNG"
assert ext == "png"
def test_bmp_stays_bmp(self):
"""BMP input should produce BMP output."""
fmt, ext = get_output_format("BMP")
assert fmt == "BMP"
assert ext == "bmp"
def test_jpeg_becomes_png(self):
"""JPEG input should produce PNG output (lossless)."""
fmt, ext = get_output_format("JPEG")
assert fmt == "PNG"
assert ext == "png"
def test_gif_becomes_png(self):
"""GIF input should produce PNG output."""
fmt, ext = get_output_format("GIF")
assert fmt == "PNG"
assert ext == "png"
def test_none_becomes_png(self):
"""None format should default to PNG."""
fmt, ext = get_output_format(None)
assert fmt == "PNG"
assert ext == "png"
def test_unknown_becomes_png(self):
"""Unknown format should default to PNG."""
fmt, ext = get_output_format("UNKNOWN")
assert fmt == "PNG"
assert ext == "png"
# =============================================================================
# Header Overhead Test (v4.0.0)
# =============================================================================
class TestConstants:
"""Tests for constants and configuration."""
def test_header_overhead_value(self):
"""Header overhead should be 66 bytes (v4.0.0: added flags byte)."""
from stegasoo.steganography import HEADER_OVERHEAD
assert HEADER_OVERHEAD == 66
# =============================================================================
# Encode/Decode Tests (v4.0.0 Updated)
# =============================================================================
class TestEncodeDecode:
"""Tests for encoding and decoding functions."""
def test_encode_decode_roundtrip(self, png_image):
"""Full encode/decode cycle should work."""
message = "Secret message!"
passphrase = "apple forest thunder mountain" # 4 words
pin = "123456"
# v3.2.0: Use passphrase parameter, no date_str
result = encode(
message=message,
reference_photo=png_image,
carrier_image=png_image,
passphrase=passphrase,
pin=pin,
)
assert result.stego_image is not None
assert len(result.stego_image) > 0
assert result.filename.endswith(".png")
# v3.2.0: Use passphrase parameter, no date_str
decoded = decode(
stego_image=result.stego_image,
reference_photo=png_image,
passphrase=passphrase,
pin=pin,
)
assert decoded.message == message
def test_decode_text_roundtrip(self, png_image):
"""decode_text convenience function should work."""
message = "Secret message!"
passphrase = "apple forest thunder mountain"
pin = "123456"
result = encode(
message=message,
reference_photo=png_image,
carrier_image=png_image,
passphrase=passphrase,
pin=pin,
)
# decode_text returns string directly
decoded_text = decode_text(
stego_image=result.stego_image,
reference_photo=png_image,
passphrase=passphrase,
pin=pin,
)
assert decoded_text == message
def test_png_carrier_produces_png(self, png_image):
"""PNG carrier should produce PNG output."""
result = encode(
message="Test",
reference_photo=png_image,
carrier_image=png_image,
passphrase="test phrase here now",
pin="123456",
)
assert result.filename.endswith(".png")
def test_bmp_carrier_produces_bmp(self, bmp_image, png_image):
"""BMP carrier should produce BMP output."""
result = encode(
message="Test",
reference_photo=png_image,
carrier_image=bmp_image,
passphrase="test phrase here now",
pin="123456",
)
assert result.filename.endswith(".bmp")
def test_jpeg_carrier_produces_png(self, jpeg_image, png_image):
"""JPEG carrier should produce PNG output (lossless)."""
result = encode(
message="Test",
reference_photo=png_image,
carrier_image=jpeg_image,
passphrase="test phrase here now",
pin="123456",
)
assert result.filename.endswith(".png")
def test_bmp_roundtrip(self, bmp_image, png_image):
"""Full encode/decode cycle with BMP should work."""
message = "BMP test message!"
passphrase = "test phrase words here"
pin = "123456"
result = encode(
message=message,
reference_photo=png_image,
carrier_image=bmp_image,
passphrase=passphrase,
pin=pin,
)
assert result.filename.endswith(".bmp")
decoded = decode(
stego_image=result.stego_image,
reference_photo=png_image,
passphrase=passphrase,
pin=pin,
)
assert decoded.message == message
def test_wrong_pin_fails(self, png_image):
"""Wrong PIN should fail to decode."""
result = encode(
message="Secret",
reference_photo=png_image,
carrier_image=png_image,
passphrase="test phrase here now",
pin="123456",
)
with pytest.raises((stegasoo.DecryptionError, stegasoo.ExtractionError)):
decode(
stego_image=result.stego_image,
reference_photo=png_image,
passphrase="test phrase here now",
pin="654321", # Wrong PIN
)
def test_wrong_passphrase_fails(self, png_image):
"""Wrong passphrase should fail to decode."""
result = encode(
message="Secret",
reference_photo=png_image,
carrier_image=png_image,
passphrase="correct phrase here now",
pin="123456",
)
with pytest.raises((stegasoo.DecryptionError, stegasoo.ExtractionError)):
decode(
stego_image=result.stego_image,
reference_photo=png_image,
passphrase="wrong phrase here now", # Wrong passphrase
pin="123456",
)
def test_unicode_message(self, png_image):
"""Unicode messages should encode/decode correctly."""
message = "Hello, 世界! 🎉 Émojis and ümlauts"
passphrase = "unicode test phrase here"
pin = "123456"
result = encode(
message=message,
reference_photo=png_image,
carrier_image=png_image,
passphrase=passphrase,
pin=pin,
)
decoded = decode(
stego_image=result.stego_image,
reference_photo=png_image,
passphrase=passphrase,
pin=pin,
)
assert decoded.message == message
def test_filename_format(self, png_image):
"""Output filename should have random hex and date suffix."""
result = encode(
message="Test",
reference_photo=png_image,
carrier_image=png_image,
passphrase="test phrase here now",
pin="123456",
)
# Filename format: {random_hex}_{YYYYMMDD}.{ext}
# e.g., "a1b2c3d4_20251227.png"
import re
assert re.search(r"^[a-f0-9]{8}_\d{8}\.png$", result.filename)
# =============================================================================
# DCT Mode Tests (v3.2.0)
# =============================================================================
class TestDCTMode:
"""Tests for DCT steganography mode."""
@pytest.fixture
def skip_if_no_dct(self):
"""Skip test if DCT support not available."""
if not stegasoo.has_dct_support():
pytest.skip("DCT support not available (scipy not installed)")
def test_dct_encode_decode_roundtrip(self, large_png_image, skip_if_no_dct):
"""DCT mode encode/decode should work."""
message = "DCT test"
passphrase = "dct test phrase here"
pin = "123456"
result = encode(
message=message,
reference_photo=large_png_image,
carrier_image=large_png_image,
passphrase=passphrase,
pin=pin,
embed_mode="dct",
)
assert result.stego_image is not None
decoded = decode(
stego_image=result.stego_image,
reference_photo=large_png_image,
passphrase=passphrase,
pin=pin,
)
assert decoded.message == message
def test_dct_auto_detection(self, large_png_image, skip_if_no_dct):
"""Auto mode should detect DCT encoding."""
message = "Auto detect DCT"
passphrase = "auto detect test here"
pin = "123456"
result = encode(
message=message,
reference_photo=large_png_image,
carrier_image=large_png_image,
passphrase=passphrase,
pin=pin,
embed_mode="dct",
)
# Decode with auto mode (default)
decoded = decode(
stego_image=result.stego_image,
reference_photo=large_png_image,
passphrase=passphrase,
pin=pin,
embed_mode="auto",
)
assert decoded.message == message
# =============================================================================
# Version Tests
# =============================================================================
class TestVersion:
"""Tests for version information."""
def test_version_exists(self):
"""Version string should exist and be valid."""
assert hasattr(stegasoo, "__version__")
parts = stegasoo.__version__.split(".")
assert len(parts) >= 2
assert all(p.isdigit() for p in parts[:2])
def test_version_is_4_0_0(self):
"""Version should be 4.0.0 or higher."""
parts = stegasoo.__version__.split(".")
major = int(parts[0])
assert major >= 4
# =============================================================================
# Backward Compatibility Tests
# =============================================================================
class TestBackwardCompatibility:
"""Tests for backward compatibility handling."""
def test_old_day_phrase_parameter_raises(self, png_image):
"""Using old day_phrase parameter should raise TypeError."""
with pytest.raises(TypeError):
encode(
message="Test",
reference_photo=png_image,
carrier_image=png_image,
day_phrase="old style phrase", # Old parameter name
pin="123456",
)
def test_old_date_str_parameter_raises(self, png_image):
"""Using old date_str parameter should raise TypeError."""
with pytest.raises(TypeError):
encode(
message="Test",
reference_photo=png_image,
carrier_image=png_image,
passphrase="test phrase here now",
pin="123456",
date_str="2025-01-01", # Removed parameter
)
# =============================================================================
# Channel Key Tests (v4.0.0)
# =============================================================================
class TestChannelKey:
"""Tests for channel key functionality (v4.0.0)."""
def test_generate_channel_key_format(self):
"""Generated channel key should have correct format."""
key = generate_channel_key()
# Format: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX (8 groups of 4)
assert len(key) == 39
parts = key.split("-")
assert len(parts) == 8
for part in parts:
assert len(part) == 4
assert part.isalnum()
def test_validate_channel_key_valid(self):
"""Valid channel key should pass validation."""
key = generate_channel_key()
assert validate_channel_key(key)
def test_validate_channel_key_invalid(self):
"""Invalid channel key should fail validation."""
assert not validate_channel_key("")
assert not validate_channel_key("invalid")
assert not validate_channel_key("ABCD-1234") # Too short
def test_channel_fingerprint_format(self):
"""Channel fingerprint should mask middle sections."""
key = "ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456"
fingerprint = get_channel_fingerprint(key)
assert fingerprint is not None
# First and last groups visible, middle masked
assert fingerprint.startswith("ABCD-")
assert fingerprint.endswith("-3456")
assert "••••" in fingerprint
def test_encode_decode_with_channel_key(self, png_image):
"""Encode/decode should work with explicit channel key."""
message = "Secret with channel key!"
passphrase = "apple forest thunder mountain"
pin = "123456"
channel_key = generate_channel_key()
# Encode with channel key
result = encode(
message=message,
reference_photo=png_image,
carrier_image=png_image,
passphrase=passphrase,
pin=pin,
channel_key=channel_key,
)
assert result.stego_image is not None
# Decode with same channel key
decoded = decode(
stego_image=result.stego_image,
reference_photo=png_image,
passphrase=passphrase,
pin=pin,
channel_key=channel_key,
)
assert decoded.message == message
def test_decode_wrong_channel_key_fails(self, png_image):
"""Decoding with wrong channel key should fail."""
message = "Secret message"
passphrase = "apple forest thunder mountain"
pin = "123456"
channel_key1 = generate_channel_key()
channel_key2 = generate_channel_key()
# Encode with one channel key
result = encode(
message=message,
reference_photo=png_image,
carrier_image=png_image,
passphrase=passphrase,
pin=pin,
channel_key=channel_key1,
)
# Decode with different channel key should fail
with pytest.raises((stegasoo.DecryptionError, stegasoo.ExtractionError)):
decode(
stego_image=result.stego_image,
reference_photo=png_image,
passphrase=passphrase,
pin=pin,
channel_key=channel_key2,
)
def test_encode_decode_public_mode(self, png_image):
"""Encode/decode should work without channel key (public mode)."""
message = "Public message!"
passphrase = "apple forest thunder mountain"
pin = "123456"
# Encode without channel key (explicit public mode)
result = encode(
message=message,
reference_photo=png_image,
carrier_image=png_image,
passphrase=passphrase,
pin=pin,
channel_key="", # Explicit public mode
)
# Decode without channel key
decoded = decode(
stego_image=result.stego_image,
reference_photo=png_image,
passphrase=passphrase,
pin=pin,
channel_key="", # Explicit public mode
)
assert decoded.message == message
def test_channel_key_mismatch_public_vs_private(self, png_image):
"""Decoding public message with channel key should fail."""
message = "Public message"
passphrase = "apple forest thunder mountain"
pin = "123456"
# Encode without channel key (public)
result = encode(
message=message,
reference_photo=png_image,
carrier_image=png_image,
passphrase=passphrase,
pin=pin,
channel_key="", # Public mode
)
# Decode with channel key should fail
channel_key = generate_channel_key()
with pytest.raises((stegasoo.DecryptionError, stegasoo.ExtractionError)):
decode(
stego_image=result.stego_image,
reference_photo=png_image,
passphrase=passphrase,
pin=pin,
channel_key=channel_key,
)