""" 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.""" img = Image.new('RGB', (400, 400), color='blue') 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 )