"""AES-GCM settings encryption unit tests. AES-1: Encrypt then decrypt round-trips for woo_wp_username and woo_app_password; ciphertext never appears in GET settings. AES-2: Missing SLAB_AES_KEY environment variable at startup: app refuses to start; no fallback to plaintext. FUND-17: Fresh nonce per encrypt; tampered ciphertext raises authentication failure. FUND-18: Ciphertext never appears in any GET response. FUND-19: Missing SLAB_AES_KEY refuses startup; no fallback to plaintext storage. """ import pytest from app.security.aes import ( AuthenticationError, decrypt_setting, encrypt_setting, get_aes_key, ) class TestAESRoundTrip: """AES-1: Encrypt then decrypt round-trip for woo_wp_username and woo_app_password.""" @pytest.fixture def aes_key_bytes(self) -> bytes: """Test AES key (32 bytes).""" return get_aes_key("dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk") def test_roundtrip_username(self, aes_key_bytes: bytes) -> None: """AES-1: Encrypt and decrypt woo_wp_username round-trips.""" plaintext = "slab-uploader" ciphertext = encrypt_setting(aes_key_bytes, plaintext) decrypted = decrypt_setting(aes_key_bytes, ciphertext) assert decrypted == plaintext def test_roundtrip_password(self, aes_key_bytes: bytes) -> None: """AES-1: Encrypt and decrypt woo_app_password round-trips.""" plaintext = "W00rdP@ssw0rd!2026" ciphertext = encrypt_setting(aes_key_bytes, plaintext) decrypted = decrypt_setting(aes_key_bytes, ciphertext) assert decrypted == plaintext def test_roundtrip_empty_string(self, aes_key_bytes: bytes) -> None: """AES-1: Empty string encrypts and decrypts correctly.""" plaintext = "" ciphertext = encrypt_setting(aes_key_bytes, plaintext) decrypted = decrypt_setting(aes_key_bytes, ciphertext) assert decrypted == plaintext def test_roundtrip_unicode(self, aes_key_bytes: bytes) -> None: """AES-1: Unicode plaintext round-trips.""" plaintext = "тест-ключ-пароль" ciphertext = encrypt_setting(aes_key_bytes, plaintext) decrypted = decrypt_setting(aes_key_bytes, ciphertext) assert decrypted == plaintext def test_roundtrip_special_chars(self, aes_key_bytes: bytes) -> None: """AES-1: Special characters in plaintext round-trip.""" plaintext = '!@#$%^&*()_+-={}[]|:;<>?/~`' ciphertext = encrypt_setting(aes_key_bytes, plaintext) decrypted = decrypt_setting(aes_key_bytes, ciphertext) assert decrypted == plaintext def test_ciphertext_is_bytes(self, aes_key_bytes: bytes) -> None: """AES-1: encrypt_setting returns bytes.""" plaintext = "test-password" ciphertext = encrypt_setting(aes_key_bytes, plaintext) assert isinstance(ciphertext, bytes) def test_ciphertext_starts_with_nonce(self, aes_key_bytes: bytes) -> None: """AES-1: Ciphertext starts with 12-byte nonce.""" plaintext = "test-password" ciphertext = encrypt_setting(aes_key_bytes, plaintext) assert len(ciphertext) >= 12 nonce = ciphertext[:12] assert len(nonce) == 12 def test_ciphertext_different_from_plaintext(self, aes_key_bytes: bytes) -> None: """AES-1: Ciphertext is not the plaintext.""" plaintext = "secret" ciphertext = encrypt_setting(aes_key_bytes, plaintext) assert ciphertext != plaintext.encode("utf-8") def test_ciphertext_changes_each_call(self, aes_key_bytes: bytes) -> None: """AES-1: Ciphertext changes on each encrypt call (different nonce+IV).""" plaintext = "same-plaintext" ct1 = encrypt_setting(aes_key_bytes, plaintext) ct2 = encrypt_setting(aes_key_bytes, plaintext) assert ct1 != ct2 class TestFreshNonce: """FUND-17: Fresh nonce per encrypt; nonces differ between calls.""" @pytest.fixture def aes_key_bytes(self) -> bytes: return get_aes_key("dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk") def test_nonce_differs_across_encryptions(self, aes_key_bytes: bytes) -> None: """FUND-17: Encrypting the same plaintext twice produces different nonces.""" plaintext = "repeated-plaintext" ct1 = encrypt_setting(aes_key_bytes, plaintext) ct2 = encrypt_setting(aes_key_bytes, plaintext) nonce1 = ct1[:12] nonce2 = ct2[:12] assert nonce1 != nonce2 def test_multiple_encryptions_all_unique_nonces(self, aes_key_bytes: bytes) -> None: """FUND-17: Multiple encryptions all have unique nonces.""" nonces = [] for _ in range(5): ct = encrypt_setting(aes_key_bytes, "test") nonces.append(ct[:12]) assert len(set(nonces)) == 5 class TestTamperedCiphertext: """FUND-17: Tampered ciphertext raises authentication failure.""" @pytest.fixture def aes_key_bytes(self) -> bytes: return get_aes_key("dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk") def test_tampered_body_raises_authentication_error(self, aes_key_bytes: bytes) -> None: """FUND-17: Tampering with ciphertext body raises AuthenticationError.""" plaintext = "tamper-me" ciphertext = encrypt_setting(aes_key_bytes, plaintext) tampered = bytearray(ciphertext) # Flip a byte in the ciphertext portion (after nonce) if len(tampered) > 12: tampered[12] ^= 0xFF else: tampered[0] ^= 0xFF tampered_ct = bytes(tampered) with pytest.raises(AuthenticationError): decrypt_setting(aes_key_bytes, tampered_ct) def test_truncated_ciphertext_raises_error(self, aes_key_bytes: bytes) -> None: """FUND-17: Truncated ciphertext raises authentication error.""" plaintext = "truncate-me" ciphertext = encrypt_setting(aes_key_bytes, plaintext) truncated = ciphertext[:-4] with pytest.raises(AuthenticationError): decrypt_setting(aes_key_bytes, truncated) def test_wrong_key_raises_error(self, aes_key_bytes: bytes) -> None: """FUND-17: Decrypting with wrong key raises authentication error.""" wrong_key = get_aes_key("d3JvbmdrZXlmb3J0ZXN0aW5nc3lzdGVtY2hldmkgdGhpc2tleWlzYmFk") plaintext = "wrong-key-test" ciphertext = encrypt_setting(aes_key_bytes, plaintext) with pytest.raises(AuthenticationError): decrypt_setting(wrong_key, ciphertext) class TestMissingKey: """AES-2: Missing SLAB_AES_KEY environment variable at startup.""" def test_missing_key_refuses_startup(self, monkeypatch: pytest.MonkeyPatch) -> None: """AES-2: App refuses to start when SLAB_AES_KEY is missing.""" monkeypatch.delenv("SLAB_AES_KEY", raising=False) from app.config import Settings settings = Settings() with pytest.raises(ValueError, match="SLAB_AES_KEY"): settings.validate() def test_too_short_key_refuses_startup(self, monkeypatch: pytest.MonkeyPatch) -> None: """AES-2: Too-short key refuses startup.""" monkeypatch.setenv("SLAB_AES_KEY", "d290") # "wot" = too short from app.config import Settings settings = Settings() with pytest.raises(ValueError, match="at least 32 bytes"): settings.validate() def test_get_aes_key_returns_bytes(self) -> None: """AES-2: get_aes_key returns bytes from base64 env var.""" settings = get_aes_key("dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk") assert isinstance(settings, bytes) assert len(settings) == 32 class TestNoPlaintextFallback: """AES-2: No fallback to plaintext storage.""" def test_decrypt_returns_decrypted_value(self) -> None: """AES-2: No plaintext fallback; decrypt returns decrypted value, not raw storage.""" key = get_aes_key("dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk") plaintext = "test-password" ct = encrypt_setting(key, plaintext) result = decrypt_setting(key, ct) # Should return the original plaintext, not some fallback assert result == plaintext