Files
prime-sdk-python/tests/test_client.py
Andrew Miller e4e49218d0 add get_interchain: trace a transaction/block to validator blocks + interchain anchors
New transaction.get_interchain and block.get_interchain call the prime-node
/api/v1/{transaction,block}/{id}/interchain endpoints, returning an
InterchainTrace {block_id, validator_blocks, interchain_transactions}. Adds
VerificationBlock / InterchainTransaction / InterchainTrace dataclasses with
from_dict, exports them, and a from_dict test.
2026-06-02 14:12:59 -04:00

289 lines
9.9 KiB
Python

"""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 (
Block,
DragonchainAPIError,
DragonchainSDK,
InterchainTrace,
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_block_from_dict_nested_header():
# Real server shape: id/prev/timestamp nested under "header" with camelCase
# keys; proof carries only "proof" on a trust-scheme chain.
raw = {
"version": "1",
"header": {
"blockId": "69569983",
"dcId": "chain-xyz",
"prevId": "69186326",
"prevProof": "MEUCIQ...",
"timestamp": "1780088135",
},
"proof": {"proof": "MEQCIF..."},
"transactions": ["{...}", "{...}", "{...}"],
}
blk = Block.from_dict(raw)
assert blk.version == "1"
assert blk.header.block_id == "69569983"
assert blk.header.dc_id == "chain-xyz"
assert blk.header.prev_id == "69186326"
assert blk.header.prev_proof == "MEUCIQ..."
assert blk.header.timestamp == "1780088135"
assert blk.proof.proof == "MEQCIF..."
assert blk.proof.scheme == "" # absent on trust-scheme chain
assert len(blk.transactions) == 3
def test_interchain_trace_from_dict_nested():
# Server shape: validatorBlocks + interchainTransactions arrays, camelCase.
raw = {
"blockId": "69636602",
"validatorBlocks": [
{
"version": "1",
"primeChainId": "zDYr",
"primeBlockId": "69636602",
"verifierPublicKey": "02c4...",
"verifierSignature": "MEUC...",
}
],
"interchainTransactions": [
{
"id": 19,
"chainId": "1",
"transHash": "0xd46e",
"validatorBlocks": ["69636602"],
"coveredPrimeChainIds": ["zDYr"],
}
],
}
trace = InterchainTrace.from_dict(raw)
assert trace.block_id == "69636602"
assert len(trace.validator_blocks) == 1
assert trace.validator_blocks[0].prime_block_id == "69636602"
assert trace.validator_blocks[0].verifier_public_key == "02c4..."
assert len(trace.interchain_transactions) == 1
assert trace.interchain_transactions[0].id == 19
assert trace.interchain_transactions[0].trans_hash == "0xd46e"
assert trace.interchain_transactions[0].chain_id == "1"
assert trace.interchain_transactions[0].validator_blocks == ["69636602"]
assert trace.interchain_transactions[0].covered_prime_chain_ids == ["zDYr"]
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"]