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.
224 lines
7.6 KiB
Python
224 lines
7.6 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 (
|
|
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"]
|