All checks were successful
Publish to PyPI Registry / publish (release) Successful in 1m14s
proof-measure is a separate, public, unauthenticated Dragonchain service. Adds: - UnauthenticatedClient: HMAC-free transport mirroring Client (session injection, allow_redirects=False, from_dict decoding). - ProofMeasureClient: get_security / report / health; default base URL https://proof-measure.dragonchain.com. Standalone (ProofMeasureClient()) and via DragonchainSDK.proof_measure. - Proof-measure dataclass models (decimals as strings, timestamps as int). - .gitea/workflows/publish.yml: build + twine upload to the Gitea PyPI registry on release (the SDK had no publish workflow before). Also fixes 3 pre-existing failing tests: _FakeSession.request didn't accept the allow_redirects kwarg the client now passes (added by the prior redirect change), so the suite was red at HEAD.
297 lines
10 KiB
Python
297 lines
10 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, allow_redirects=None):
|
|
self.calls.append(
|
|
{
|
|
"method": method,
|
|
"url": url,
|
|
"data": data,
|
|
"headers": headers,
|
|
"allow_redirects": allow_redirects,
|
|
}
|
|
)
|
|
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"]
|