From d9f6e6dddb543584ad941a31decd66eb0f2419b9 Mon Sep 17 00:00:00 2001 From: dolepee Date: Sat, 7 Feb 2026 14:59:50 +0000 Subject: [PATCH] test: add Python unit tests and CI workflow for chainmail --- .github/workflows/python-tests.yml | 30 +++++ .gitignore | 1 + requirements.txt | 2 + tests/__init__.py | 0 tests/test_chainlink.py | 205 +++++++++++++++++++++++++++++ tests/test_chainmail.py | 167 +++++++++++++++++++++++ 6 files changed, 405 insertions(+) create mode 100644 .github/workflows/python-tests.yml create mode 100644 tests/__init__.py create mode 100644 tests/test_chainlink.py create mode 100644 tests/test_chainmail.py diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 0000000..f76ca62 --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,30 @@ +name: Python Tests + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run tests + run: pytest tests/ -v diff --git a/.gitignore b/.gitignore index 22dc033..14c24bf 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ lib/* .venv/* __pycache__/* .idea +tests/__pycache__/ diff --git a/requirements.txt b/requirements.txt index b5b3823..6dfced1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -59,3 +59,5 @@ web3==6.15.1 websockets==12.0 Werkzeug==3.0.1 yarl==1.9.4 +pytest==8.0.2 +pytest-mock==3.12.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_chainlink.py b/tests/test_chainlink.py new file mode 100644 index 0000000..1e3ac41 --- /dev/null +++ b/tests/test_chainlink.py @@ -0,0 +1,205 @@ +# Copyright (c) 2024, Circle Internet Financial, LTD. All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for chainlink.py — pure/utility functions only.""" + +import sys +from io import StringIO +from unittest.mock import MagicMock, mock_open, patch + +import pytest + +# Mock gnupg before importing chainlink (which imports chainmail) +mock_gpg_instance = MagicMock() +mock_gnupg = MagicMock() +mock_gnupg.GPG.return_value = mock_gpg_instance +sys.modules["gnupg"] = mock_gnupg + +CONFIG = { + "pgp": {"bin": "/usr/bin/gpg"}, + "chainmail": {"hostname": "https://chainmail.example.com"}, + "chainlink": { + "contract_file": "src/Chainmail.sol", + "register_email_address": "registerEmailAddress(bytes32,address,bytes32)", + "register_email_message": "registerEmailMessage(bytes32)", + "email_address_info": "emailAddressInfo(bytes32)", + "verify_email_message": "verifyEmailMessage(bytes32,bytes32)", + "local_env_file": ".chainmail_env", + }, + "local_key_file": "keys.yaml", +} + +KEY = { + "testnet_account": {"private_key": "0xabc"}, + "testnet_sender": {"private_key": "0xdef", "address": "0x123"}, + "rpc_url": "http://localhost:8545", + "etherscan_api_key": "", +} + +_yaml_returns = [CONFIG, CONFIG, KEY] +_yaml_call_count = 0 + +_original_open = open + + +def _selective_open(filename, *args, **kwargs): + """Only intercept config/key file opens, let everything else through.""" + if isinstance(filename, str) and ( + filename.endswith("config.yaml") or filename.endswith("keys.yaml") + ): + return StringIO("mocked") + return _original_open(filename, *args, **kwargs) + + +def _yaml_side_effect(*args, **kwargs): + global _yaml_call_count + idx = min(_yaml_call_count, len(_yaml_returns) - 1) + _yaml_call_count += 1 + return _yaml_returns[idx] + + +with patch("builtins.open", _selective_open): + with patch("yaml.safe_load", side_effect=_yaml_side_effect): + import chainlink + + +class TestHashEmailAddress: + """Tests for hash_email_address()""" + + def test_deterministic(self): + h1 = chainlink.hash_email_address("user@example.com") + h2 = chainlink.hash_email_address("user@example.com") + assert h1 == h2 + + def test_case_insensitive(self): + h1 = chainlink.hash_email_address("User@Example.COM") + h2 = chainlink.hash_email_address("user@example.com") + assert h1 == h2 + + def test_returns_hex_string(self): + h = chainlink.hash_email_address("test@test.com") + assert isinstance(h, str) + assert len(h) == 64 + int(h, 16) # should not raise + + +class TestHash: + """Tests for hash()""" + + def test_deterministic(self): + h1 = chainlink.hash("hello") + h2 = chainlink.hash("hello") + assert h1 == h2 + + def test_different_inputs(self): + h1 = chainlink.hash("hello") + h2 = chainlink.hash("world") + assert h1 != h2 + + def test_returns_64_char_hex(self): + h = chainlink.hash("test") + assert len(h) == 64 + int(h, 16) + + +class TestHashMessage: + """Tests for hash_message()""" + + def test_strips_whitespace(self): + h1 = chainlink.hash_message("hello world") + h2 = chainlink.hash_message("hello world") + assert h1 == h2 + + def test_strips_leading_trailing(self): + h1 = chainlink.hash_message(" hello world ") + h2 = chainlink.hash_message("hello world") + assert h1 == h2 + + def test_normalizes_newlines_and_tabs(self): + h1 = chainlink.hash_message("hello\n\t\tworld") + h2 = chainlink.hash_message("hello world") + assert h1 == h2 + + +class TestParseCastSendOutput: + """Tests for parse_cast_send_output()""" + + SAMPLE_OUTPUT = ( + "blockHash 0xabc123\n" + "blockNumber 42\n" + "contract_address \n" + "cumulativeGasUsed 21000\n" + "effectiveGasPrice 1000000000\n" + "gasUsed 21000\n" + "logs [Log { ... }]\n" + "logsBloom 0x00\n" + "root \n" + "status 1\n" + "transactionHash 0xdef456\n" + "transactionIndex 0\n" + "type 2\n" + ) + + def test_parses_success(self): + parsed = chainlink.parse_cast_send_output(self.SAMPLE_OUTPUT) + assert parsed["success"] is True + assert parsed["blockNumber"] == 42 + assert parsed["transactionHash"] == "0xdef456" + + def test_parses_failure_empty_logs(self): + output = self.SAMPLE_OUTPUT.replace("[Log { ... }]", "[]") + parsed = chainlink.parse_cast_send_output(output) + assert parsed["success"] is False + + +class TestIsCastAndSendSucceed: + """Tests for is_cast_and_send_succeed()""" + + def test_success(self): + output = ( + "blockHash 0xabc\n" + "blockNumber 1\n" + "cumulativeGasUsed 1\n" + "effectiveGasPrice 1\n" + "gasUsed 1\n" + "logs [Log]\n" + "logsBloom 0x\n" + "root \n" + "status 1\n" + "transactionHash 0x1\n" + "transactionIndex 0\n" + "type 2\n" + ) + assert chainlink.is_cast_and_send_succeed(output) is True + + def test_failure(self): + output = ( + "blockHash 0xabc\n" + "blockNumber 1\n" + "cumulativeGasUsed 1\n" + "effectiveGasPrice 1\n" + "gasUsed 1\n" + "logs []\n" + "logsBloom 0x\n" + "root \n" + "status 1\n" + "transactionHash 0x1\n" + "transactionIndex 0\n" + "type 2\n" + ) + assert chainlink.is_cast_and_send_succeed(output) is False + + +class TestGetChainmailAddress: + """Tests for get_chainmail_address()""" + + def test_missing_file(self): + with patch("os.path.exists", return_value=False): + assert chainlink.get_chainmail_address() == "" + + def test_exists(self): + with patch("os.path.exists", return_value=True): + with patch("builtins.open", mock_open()): + with patch("yaml.safe_load", return_value={"contract_address": "0xABC"}): + assert chainlink.get_chainmail_address() == "0xABC" diff --git a/tests/test_chainmail.py b/tests/test_chainmail.py new file mode 100644 index 0000000..708754f --- /dev/null +++ b/tests/test_chainmail.py @@ -0,0 +1,167 @@ +# Copyright (c) 2024, Circle Internet Financial, LTD. All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for chainmail.py""" + +import base64 +import sys +import zlib +from unittest.mock import MagicMock, patch + +import pytest + +# Mock gnupg and config before importing chainmail +mock_gpg_instance = MagicMock() +mock_gnupg = MagicMock() +mock_gnupg.GPG.return_value = mock_gpg_instance +sys.modules["gnupg"] = mock_gnupg + +CONFIG = { + "pgp": {"bin": "/usr/bin/gpg"}, + "chainmail": {"hostname": "https://chainmail.example.com"}, +} + +with patch("builtins.open", MagicMock()): + with patch("yaml.safe_load", return_value=CONFIG): + import chainmail + + +class TestGetContentString: + """Tests for get_content_string()""" + + def test_valid_roundtrip(self): + original = "Hello, this is a PGP signed message." + compressed = zlib.compress(original.encode("utf-8")) + encoded = base64.b64encode(compressed).decode("utf-8") + assert chainmail.get_content_string(encoded) == original + + def test_unicode_content(self): + original = "Héllo wörld 🌍" + compressed = zlib.compress(original.encode("utf-8")) + encoded = base64.b64encode(compressed).decode("utf-8") + assert chainmail.get_content_string(encoded) == original + + def test_empty_string(self): + original = "" + compressed = zlib.compress(original.encode("utf-8")) + encoded = base64.b64encode(compressed).decode("utf-8") + assert chainmail.get_content_string(encoded) == original + + def test_invalid_base64_returns_error(self): + assert chainmail.get_content_string("not-valid-base64!!!") == "FAILED TO PARSE CONTENT" + + def test_invalid_zlib_returns_error(self): + # Valid base64 but not valid zlib + encoded = base64.b64encode(b"not compressed data").decode("utf-8") + assert chainmail.get_content_string(encoded) == "FAILED TO PARSE CONTENT" + + +class TestGetVerificationUrl: + """Tests for get_verification_url()""" + + def test_returns_url_with_hostname(self): + content = "test message" + url = chainmail.get_verification_url(content) + assert url.startswith("https://chainmail.example.com/verify?") + + def test_url_contains_encoded_content_param(self): + content = "test message" + url = chainmail.get_verification_url(content) + assert "encoded_content=" in url + + def test_roundtrip_with_get_content_string(self): + """Verify that get_verification_url and get_content_string are inverses.""" + original = "This is a signed PGP message with special chars: <>&" + url = chainmail.get_verification_url(original) + # Extract encoded_content param + from urllib.parse import parse_qs, urlparse + + parsed = urlparse(url) + params = parse_qs(parsed.query) + encoded_content = params["encoded_content"][0] + assert chainmail.get_content_string(encoded_content) == original + + +class TestModifySignatureHeader: + """Tests for modify_signature_header()""" + + def test_replaces_header(self): + content = "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\nHello" + old = "Hash: SHA256" + new = "Hash: SHA256\nComment: Verified by Chainmail" + result = chainmail.modify_signature_header(content, old, new) + assert "Comment: Verified by Chainmail" in result + assert result.count("Hash: SHA256") == 1 # old replaced, new present + + def test_no_match_returns_unchanged(self): + content = "some content" + result = chainmail.modify_signature_header(content, "not found", "replacement") + assert result == content + + def test_replaces_only_first_occurrence(self): + # str.replace replaces all occurrences + content = "AAA BBB AAA" + result = chainmail.modify_signature_header(content, "AAA", "CCC") + assert result == "CCC BBB CCC" + + +class TestPGPSignMessage: + """Tests for PGP_sign_message() with mocked GPG""" + + def test_calls_gpg_sign(self): + mock_sign = MagicMock(return_value=MagicMock(__str__=lambda self: "signed-output")) + with patch.object(chainmail, "GPG") as mock_gpg: + mock_gpg.sign = mock_sign + result = chainmail.PGP_sign_message("data", "FINGERPRINT", "passphrase") + mock_sign.assert_called_with( + "data", keyid="FINGERPRINT", passphrase="passphrase", clearsign=True + ) + assert result == "signed-output" + + +class TestVerifySignature: + """Tests for verify_signature() with mocked GPG""" + + def test_calls_gpg_verify(self): + with patch.object(chainmail, "GPG") as mock_gpg: + mock_gpg.verify.return_value = True + result = chainmail.verify_signature("signed data") + mock_gpg.verify.assert_called_with("signed data") + assert result is True + + +class TestEmailSendMessage: + """Tests for email_send_message()""" + + def test_returns_true(self): + assert chainmail.email_send_message( + email_to="a@b.com", + email_from="c@d.com", + email_subject="test", + email_body="body", + cc_sender=False, + ) is True + + +class TestSendEmail: + """Tests for send_email() integration""" + + def test_send_email_returns_modified_content(self): + signed_text = "-----BEGIN PGP SIGNATURE-----\nSigned body with $FINGERPRINT note" + chainmail.GPG = MagicMock() + chainmail.GPG.sign.return_value = MagicMock(__str__=lambda self: signed_text) + result = chainmail.send_email( + email_to="to@example.com", + email_from="from@example.com", + email_subject="Subject", + email_body="Hello", + cc_sender=False, + fingerprint="ABC123", + passphrase="pass", + fingerprint_note="\nFingerprint: $FINGERPRINT", + pgp_signature_start="-----BEGIN PGP SIGNATURE-----", + new_pgp_signature_start="-----BEGIN PGP SIGNATURE-----\nVersion: Chainmail", + ) + assert "Verify:" in result + assert "Version: Chainmail" in result