Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e ".[all]" pytest
- run: pytest tests/ -v
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ build/
.ruff_cache/
.env
.claude/
.mcpregistry_*
.smithery/
Empty file added tests/__init__.py
Empty file.
141 changes: 141 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""Tests for the Revettr client SDK."""
import ipaddress
import math
import pytest
from revettr.client import Revettr, RevettrPaymentRequired
from revettr.models import ScoreResponse, SignalScore


class TestInputValidation:
"""Test _validate_inputs defense-in-depth validation."""

def test_valid_domain(self):
Revettr._validate_inputs("example.com", None, None, "base", None, None, None)

def test_valid_url_domain(self):
# URL with long path should pass (hostname is short)
Revettr._validate_inputs("https://example.com/" + "a" * 300, None, None, "base", None, None, None)

def test_domain_too_long(self):
with pytest.raises(ValueError, match="253"):
Revettr._validate_inputs("a" * 254, None, None, "base", None, None, None)

def test_domain_whitespace(self):
with pytest.raises(ValueError, match="whitespace"):
Revettr._validate_inputs("exam ple.com", None, None, "base", None, None, None)

def test_valid_ip(self):
Revettr._validate_inputs(None, "104.18.28.72", None, "base", None, None, None)

def test_invalid_ip(self):
with pytest.raises(ValueError, match="Invalid IP"):
Revettr._validate_inputs(None, "999.999.999.999", None, "base", None, None, None)

def test_valid_wallet(self):
Revettr._validate_inputs(None, None, "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "base", None, None, None)

def test_invalid_wallet_short(self):
with pytest.raises(ValueError, match="Invalid EVM"):
Revettr._validate_inputs(None, None, "0xabc", "base", None, None, None)

def test_invalid_wallet_no_prefix(self):
with pytest.raises(ValueError, match="Invalid EVM"):
Revettr._validate_inputs(None, None, "d8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "base", None, None, None)

def test_company_name_too_long(self):
with pytest.raises(ValueError, match="200"):
Revettr._validate_inputs(None, None, None, "base", "x" * 201, None, None)

def test_amount_negative(self):
with pytest.raises(ValueError, match="greater than 0"):
Revettr._validate_inputs(None, None, None, "base", None, None, -1.0)

def test_amount_nan(self):
with pytest.raises(ValueError, match="finite"):
Revettr._validate_inputs(None, None, None, "base", None, None, float("nan"))

def test_amount_inf(self):
with pytest.raises(ValueError, match="finite"):
Revettr._validate_inputs(None, None, None, "base", None, None, float("inf"))

def test_amount_huge_int(self):
with pytest.raises(ValueError, match="too large"):
Revettr._validate_inputs(None, None, None, "base", None, None, 10**1000)

def test_valid_email(self):
Revettr._validate_inputs(None, None, None, "base", None, "test@example.com", None)

def test_invalid_email_no_at(self):
with pytest.raises(ValueError, match="Invalid email"):
Revettr._validate_inputs(None, None, None, "base", None, "notanemail", None)

def test_chain_empty(self):
with pytest.raises(ValueError, match="non-empty"):
Revettr._validate_inputs(None, None, None, " ", None, None, None)

def test_no_inputs_raises(self):
"""score() with no inputs should raise ValueError."""
client = Revettr()
with pytest.raises(ValueError, match="At least one"):
client.score()


class TestBaseUrlValidation:
"""Test HTTPS enforcement."""

def test_https_allowed(self):
client = Revettr(base_url="https://revettr.com")
assert client.base_url == "https://revettr.com"

def test_localhost_http_allowed(self):
client = Revettr(base_url="http://localhost:4021")
assert client.base_url == "http://localhost:4021"

def test_http_blocked(self):
with pytest.raises(ValueError, match="HTTPS"):
Revettr(base_url="http://evil.com")


class TestModels:
"""Test ScoreResponse and SignalScore."""

def test_score_response_from_dict(self):
data = {
"score": 85,
"tier": "low",
"confidence": 0.75,
"signals_checked": 3,
"flags": ["no_dmarc_record"],
"signal_scores": {
"domain": {"score": 90, "flags": ["no_dmarc_record"], "available": True, "details": {}}
},
"metadata": {"version": "0.1.0"},
}
resp = ScoreResponse.from_dict(data)
assert resp.score == 85
assert resp.tier == "low"
assert resp.confidence == 0.75
assert resp.flags == ["no_dmarc_record"]
assert "domain" in resp.signal_scores

def test_signal_score_defaults(self):
s = SignalScore(score=50)
assert s.flags == []
assert s.available is True
assert s.details == {}


class TestClientInit:
"""Test client initialization."""

def test_default_url(self):
client = Revettr()
assert client.base_url == "https://revettr.com"

def test_repr_no_secrets(self):
client = Revettr()
r = repr(client)
assert "revettr.com" in r
assert "x402=disabled" in r
# Should not contain any private key info
assert "0x" not in r
49 changes: 49 additions & 0 deletions tests/test_safe_x402.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Tests for SafeX402Client (import-only, since x402 deps are optional)."""
import pytest


def test_safe_x402_importable():
"""SafeX402Client should be importable when x402 deps are available."""
try:
from revettr.safe_x402 import SafeX402Client, PaymentBlocked
assert SafeX402Client is not None
assert PaymentBlocked is not None
except ImportError:
pytest.skip("x402 dependencies not installed")


def test_payment_blocked_exception():
"""PaymentBlocked should carry score, tier, and flags."""
try:
from revettr.safe_x402 import PaymentBlocked
except ImportError:
pytest.skip("x402 dependencies not installed")

exc = PaymentBlocked("https://evil.com", score=15, tier="critical", flags=["sanctions_exact_match"])
assert exc.score == 15
assert exc.tier == "critical"
assert exc.url == "https://evil.com"
assert "sanctions_exact_match" in exc.flags
assert "15/100" in str(exc)


def test_safe_x402_min_score_validation():
"""min_score must be 0-100."""
try:
from revettr.safe_x402 import SafeX402Client
except ImportError:
pytest.skip("x402 dependencies not installed")

with pytest.raises(ValueError, match="0-100"):
SafeX402Client(wallet_private_key="0x" + "a" * 64, min_score=150)


def test_safe_x402_on_fail_validation():
"""on_fail must be block/warn/log."""
try:
from revettr.safe_x402 import SafeX402Client
except ImportError:
pytest.skip("x402 dependencies not installed")

with pytest.raises(ValueError, match="on_fail"):
SafeX402Client(wallet_private_key="0x" + "a" * 64, on_fail="crash")
Loading