Initial commit: Python SDK for Dragonchain (Prime)
Synchronous Python SDK modeled on prime-sdk-go. Provides DC1-HMAC-SHA256 auth, dataclass models, and resource clients for system, transaction, transaction-type, smart-contract, and block endpoints, plus a YAML credentials loader.
This commit is contained in:
223
tests/test_client.py
Normal file
223
tests/test_client.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""Offline tests for the Dragonchain Python SDK.
|
||||
|
||||
No network access required. The HMAC test independently recomputes the expected
|
||||
Authorization header to guard the byte-for-byte contract with the prime-node
|
||||
authorizer.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from prime_sdk import (
|
||||
DragonchainAPIError,
|
||||
DragonchainSDK,
|
||||
SmartContractCreateRequest,
|
||||
Transaction,
|
||||
TransactionCreateRequest,
|
||||
)
|
||||
from prime_sdk.client import Client
|
||||
from prime_sdk.credentials import load_config_from_string
|
||||
|
||||
PUBLIC_ID = "test-public-id"
|
||||
AUTH_KEY_ID = "test-key-id"
|
||||
AUTH_KEY = "test-secret-key"
|
||||
BASE_URL = "https://chains.dragonchain.com"
|
||||
|
||||
|
||||
def _expected_auth_header(method, path, timestamp, content_type, body: bytes) -> str:
|
||||
"""Recompute the expected header from scratch, independent of the SDK."""
|
||||
b64_content = base64.b64encode(hashlib.sha256(body).digest()).decode()
|
||||
message = "\n".join([method.upper(), path, PUBLIC_ID, timestamp, content_type, b64_content])
|
||||
digest = hmac.new(AUTH_KEY.encode(), message.encode(), hashlib.sha256).digest()
|
||||
return f"DC1-HMAC-SHA256 {AUTH_KEY_ID}:{base64.b64encode(digest).decode()}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# HMAC signing
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_hmac_message_format():
|
||||
c = Client(PUBLIC_ID, AUTH_KEY_ID, AUTH_KEY, BASE_URL)
|
||||
msg = c._hmac_message("post", "/api/v1/transaction", "1700000000", "application/json", b"hello")
|
||||
lines = msg.split("\n")
|
||||
assert len(lines) == 6
|
||||
assert lines[0] == "POST" # method uppercased
|
||||
assert lines[1] == "/api/v1/transaction"
|
||||
assert lines[2] == PUBLIC_ID
|
||||
assert lines[3] == "1700000000"
|
||||
assert lines[4] == "application/json"
|
||||
assert lines[5] == base64.b64encode(hashlib.sha256(b"hello").digest()).decode()
|
||||
assert not msg.endswith("\n") # no trailing newline
|
||||
|
||||
|
||||
def test_auth_header_matches_independent_computation():
|
||||
c = Client(PUBLIC_ID, AUTH_KEY_ID, AUTH_KEY, BASE_URL)
|
||||
body = b'{"txn_type":"x","payload":"y"}'
|
||||
got = c._generate_auth_header("POST", "/api/v1/transaction", "1700000000", "application/json", body)
|
||||
want = _expected_auth_header("POST", "/api/v1/transaction", "1700000000", "application/json", body)
|
||||
assert got == want
|
||||
|
||||
|
||||
def test_empty_body_signs_sha256_of_empty():
|
||||
c = Client(PUBLIC_ID, AUTH_KEY_ID, AUTH_KEY, BASE_URL)
|
||||
got = c._generate_auth_header("GET", "/api/v1/status", "1700000000", "", b"")
|
||||
want = _expected_auth_header("GET", "/api/v1/status", "1700000000", "", b"")
|
||||
assert got == want
|
||||
|
||||
|
||||
def test_base_url_trailing_slash_stripped():
|
||||
c = Client(PUBLIC_ID, AUTH_KEY_ID, AUTH_KEY, BASE_URL + "/")
|
||||
assert c.endpoint() == BASE_URL
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Request plumbing (with a fake session)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, status_code, text):
|
||||
self.status_code = status_code
|
||||
self.text = text
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, response):
|
||||
self.response = response
|
||||
self.calls = []
|
||||
|
||||
def request(self, method, url, data=None, headers=None, timeout=None):
|
||||
self.calls.append({"method": method, "url": url, "data": data, "headers": headers})
|
||||
return self.response
|
||||
|
||||
|
||||
def test_create_sends_correct_request_and_parses_response():
|
||||
sdk = DragonchainSDK(PUBLIC_ID, AUTH_KEY_ID, AUTH_KEY, BASE_URL)
|
||||
fake = _FakeSession(_FakeResponse(200, json.dumps({"transaction_id": "txn-123"})))
|
||||
sdk.get_client()._session = fake
|
||||
|
||||
resp = sdk.transaction.create(
|
||||
TransactionCreateRequest(txn_type="my-type", payload='{"a":1}', tag="t")
|
||||
)
|
||||
|
||||
assert resp.transaction_id == "txn-123"
|
||||
call = fake.calls[0]
|
||||
assert call["method"] == "POST"
|
||||
assert call["url"] == BASE_URL + "/api/v1/transaction"
|
||||
# headers present and consistent
|
||||
headers = call["headers"]
|
||||
assert headers["Dragonchain"] == PUBLIC_ID
|
||||
assert headers["Content-Type"] == "application/json"
|
||||
assert headers["Authorization"].startswith(f"DC1-HMAC-SHA256 {AUTH_KEY_ID}:")
|
||||
# the bytes sent are exactly the bytes that were signed
|
||||
sent = call["data"]
|
||||
expected_auth = _expected_auth_header(
|
||||
"POST", "/api/v1/transaction", headers["Timestamp"], "application/json", sent
|
||||
)
|
||||
assert headers["Authorization"] == expected_auth
|
||||
# omitempty: version dropped, tag kept
|
||||
body = json.loads(sent)
|
||||
assert body == {"txn_type": "my-type", "payload": '{"a":1}', "tag": "t"}
|
||||
|
||||
|
||||
def test_error_status_raises():
|
||||
sdk = DragonchainSDK(PUBLIC_ID, AUTH_KEY_ID, AUTH_KEY, BASE_URL)
|
||||
sdk.get_client()._session = _FakeSession(_FakeResponse(404, " not found "))
|
||||
with pytest.raises(DragonchainAPIError) as exc:
|
||||
sdk.transaction.get("missing")
|
||||
assert exc.value.status_code == 404
|
||||
assert exc.value.body == "not found"
|
||||
|
||||
|
||||
def test_get_sends_no_content_type_header():
|
||||
sdk = DragonchainSDK(PUBLIC_ID, AUTH_KEY_ID, AUTH_KEY, BASE_URL)
|
||||
fake = _FakeSession(_FakeResponse(200, json.dumps({"id": "chain", "level": 1})))
|
||||
sdk.get_client()._session = fake
|
||||
sdk.system.status()
|
||||
headers = fake.calls[0]["headers"]
|
||||
assert "Content-Type" not in headers
|
||||
assert fake.calls[0]["data"] == b""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Model (de)serialization
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_transaction_from_dict_nested():
|
||||
raw = {
|
||||
"version": "1",
|
||||
"header": {"txn_id": "abc", "block_id": "42", "txn_type": "t"},
|
||||
"proof": {"full": "ff", "stripped": "ss"},
|
||||
"payload": "{}",
|
||||
}
|
||||
txn = Transaction.from_dict(raw)
|
||||
assert txn.header.txn_id == "abc"
|
||||
assert txn.header.block_id == "42"
|
||||
assert txn.proof.full == "ff"
|
||||
assert txn.payload == "{}"
|
||||
|
||||
|
||||
def test_contract_create_request_omitempty():
|
||||
req = SmartContractCreateRequest(
|
||||
environment="python3.8",
|
||||
transaction_type="my-type",
|
||||
execution_order="parallel",
|
||||
)
|
||||
d = req.to_dict()
|
||||
assert d == {
|
||||
"environment": "python3.8",
|
||||
"transactionType": "my-type",
|
||||
"executionOrder": "parallel",
|
||||
}
|
||||
# remote False is dropped; envvars/secrets absent
|
||||
assert "remote" not in d
|
||||
assert "environmentVariables" not in d
|
||||
assert "secret" not in d
|
||||
|
||||
|
||||
def test_contract_create_request_with_optionals():
|
||||
req = SmartContractCreateRequest(
|
||||
environment="python3.8",
|
||||
transaction_type="my-type",
|
||||
execution_order="serial",
|
||||
environment_variables={"K": "V"},
|
||||
secrets={"S": "v"},
|
||||
remote=True,
|
||||
)
|
||||
d = req.to_dict()
|
||||
assert d["environmentVariables"] == {"K": "V"}
|
||||
assert d["secret"] == {"S": "v"}
|
||||
assert d["remote"] is True
|
||||
|
||||
|
||||
def test_credentials_round_trip():
|
||||
yaml_text = """
|
||||
default: pub-1
|
||||
chains:
|
||||
- name: alpha
|
||||
publicId: pub-1
|
||||
authKeyId: kid-1
|
||||
authKey: secret-1
|
||||
endpoint: https://chains.dragonchain.com
|
||||
- name: beta
|
||||
publicId: pub-2
|
||||
authKeyId: kid-2
|
||||
authKey: secret-2
|
||||
endpoint: https://dev-chains.dragonchain.com
|
||||
"""
|
||||
cfg = load_config_from_string(yaml_text)
|
||||
assert cfg.default == "pub-1"
|
||||
assert cfg.list_chains() == ["alpha", "beta"]
|
||||
default = cfg.get_default_chain()
|
||||
assert default is not None and default.auth_key_id == "kid-1"
|
||||
assert cfg.get_chain_by_public_id("pub-2").endpoint.endswith("dev-chains.dragonchain.com")
|
||||
with pytest.raises(ValueError):
|
||||
cfg.delete_chain("pub-1") # default
|
||||
cfg.delete_chain("pub-2")
|
||||
assert cfg.list_chains() == ["alpha"]
|
||||
Reference in New Issue
Block a user