From ea99a05249249fbd77272e141bc3bb55fba28386 Mon Sep 17 00:00:00 2001 From: Rusty Russell Date: Wed, 26 May 2021 15:17:01 +0930 Subject: [PATCH] pytest: add schema support for JSON responses. This adds our first (basic) schema, and sews support into pyln-testing so it will load schemas for any method for doc/schemas/{method}.schema.json. All JSON responses in a test run are checked against the schema (if any). Signed-off-by: Rusty Russell --- contrib/pyln-client/pyln/client/lightning.py | 1 + contrib/pyln-testing/pyln/testing/fixtures.py | 144 +++++++++++++++++- contrib/pyln-testing/pyln/testing/utils.py | 21 ++- contrib/pyln-testing/requirements.txt | 1 + doc/schemas/listpays.schema.json | 72 +++++++++ tests/fixtures.py | 2 +- 6 files changed, 237 insertions(+), 4 deletions(-) create mode 100644 doc/schemas/listpays.schema.json diff --git a/contrib/pyln-client/pyln/client/lightning.py b/contrib/pyln-client/pyln/client/lightning.py index 74858285c..f4e53f319 100644 --- a/contrib/pyln-client/pyln/client/lightning.py +++ b/contrib/pyln-client/pyln/client/lightning.py @@ -349,6 +349,7 @@ class UnixDomainSocketRpc(object): "enable": True }, }) + # FIXME: Notification schema support? _, buf = self._readobj(sock, buf) request = { diff --git a/contrib/pyln-testing/pyln/testing/fixtures.py b/contrib/pyln-testing/pyln/testing/fixtures.py index 6f1d96b0e..d4e25ffaf 100644 --- a/contrib/pyln-testing/pyln/testing/fixtures.py +++ b/contrib/pyln-testing/pyln/testing/fixtures.py @@ -1,13 +1,17 @@ from concurrent import futures from pyln.testing.db import SqliteDbProvider, PostgresDbProvider from pyln.testing.utils import NodeFactory, BitcoinD, ElementsD, env, DEVELOPER, LightningNode, TEST_DEBUG, Throttler +from pyln.client import Millisatoshi from typing import Dict +import json +import jsonschema # type: ignore import logging import os import pytest # type: ignore import re import shutil +import string import sys import tempfile @@ -202,8 +206,145 @@ def throttler(test_base_dir): yield Throttler(test_base_dir) +def _extra_validator(): + """JSON Schema validator with additions for our specialized types""" + def is_hex(checker, instance): + """Hex string""" + if not checker.is_type(instance, "string"): + return False + return all(c in string.hexdigits for c in instance) + + def is_u64(checker, instance): + """64-bit integer""" + if not checker.is_type(instance, "integer"): + return False + return instance >= 0 and instance < 2**64 + + def is_u32(checker, instance): + """32-bit integer""" + if not checker.is_type(instance, "integer"): + return False + return instance >= 0 and instance < 2**32 + + def is_u16(checker, instance): + """16-bit integer""" + if not checker.is_type(instance, "integer"): + return False + return instance >= 0 and instance < 2**16 + + def is_short_channel_id(checker, instance): + """Short channel id""" + if not checker.is_type(instance, "string"): + return False + parts = instance.split("x") + if len(parts) != 3: + return False + # May not be integers + try: + blocknum = int(parts[0]) + txnum = int(parts[1]) + outnum = int(parts[2]) + except ValueError: + return False + + # BOLT #7: + # ## Definition of `short_channel_id` + # + # The `short_channel_id` is the unique description of the funding transaction. + # It is constructed as follows: + # 1. the most significant 3 bytes: indicating the block height + # 2. the next 3 bytes: indicating the transaction index within the block + # 3. the least significant 2 bytes: indicating the output index that pays to the + # channel. + return (blocknum >= 0 and blocknum < 2**24 + and txnum >= 0 and txnum < 2**24 + and outnum >= 0 and outnum < 2**16) + + def is_pubkey(checker, instance): + """SEC1 encoded compressed pubkey""" + if not checker.is_type(instance, "hex"): + return False + if len(instance) != 66: + return False + return instance[0:2] == "02" or instance[0:2] == "03" + + def is_pubkey32(checker, instance): + """x-only BIP-340 public key""" + if not checker.is_type(instance, "hex"): + return False + if len(instance) != 64: + return False + return True + + def is_signature(checker, instance): + """DER encoded secp256k1 ECDSA signature""" + if not checker.is_type(instance, "hex"): + return False + if len(instance) > 72 * 2: + return False + return True + + def is_bip340sig(checker, instance): + """Hex encoded secp256k1 Schnorr signature""" + if not checker.is_type(instance, "hex"): + return False + if len(instance) != 64 * 2: + return False + return True + + def is_msat(checker, instance): + """String number ending in msat""" + return type(instance) is Millisatoshi + + def is_txid(checker, instance): + """Bitcoin transaction ID""" + if not checker.is_type(instance, "hex"): + return False + return len(instance) == 64 + + type_checker = jsonschema.Draft7Validator.TYPE_CHECKER.redefine_many({ + "hex": is_hex, + "u64": is_u64, + "u32": is_u32, + "u16": is_u16, + "pubkey": is_pubkey, + "msat": is_msat, + "txid": is_txid, + "signature": is_signature, + "bip340sig": is_bip340sig, + "pubkey32": is_pubkey32, + "short_channel_id": is_short_channel_id, + }) + + return jsonschema.validators.extend(jsonschema.Draft7Validator, + type_checker=type_checker) + + +def _load_schema(filename): + """Load the schema from @filename and create a validator for it""" + with open(filename, 'r') as f: + return _extra_validator()(json.load(f)) + + +@pytest.fixture(autouse=True) +def jsonschemas(): + """Load schema files if they exist""" + try: + schemafiles = os.listdir('doc/schemas') + except FileNotFoundError: + schemafiles = [] + + schemas = {} + for fname in schemafiles: + if not fname.endswith('.schema.json'): + continue + schemas[fname.rpartition('.schema')[0]] = _load_schema(os.path.join('doc/schemas', + fname)) + return schemas + + @pytest.fixture -def node_factory(request, directory, test_name, bitcoind, executor, db_provider, teardown_checks, node_cls, throttler): +def node_factory(request, directory, test_name, bitcoind, executor, db_provider, teardown_checks, node_cls, throttler, jsonschemas): nf = NodeFactory( request, test_name, @@ -213,6 +354,7 @@ def node_factory(request, directory, test_name, bitcoind, executor, db_provider, db_provider=db_provider, node_cls=node_cls, throttler=throttler, + jsonschemas=jsonschemas, ) yield nf diff --git a/contrib/pyln-testing/pyln/testing/utils.py b/contrib/pyln-testing/pyln/testing/utils.py index fbc6a2631..3fb3c7eaa 100644 --- a/contrib/pyln-testing/pyln/testing/utils.py +++ b/contrib/pyln-testing/pyln/testing/utils.py @@ -601,7 +601,17 @@ class PrettyPrintingLightningRpc(LightningRpc): eyes. It has some overhead since we re-serialize the request and result to json in order to pretty print it. + Also validates (optional) schemas for us. """ + def __init__(self, socket_path, executor=None, logger=logging, + patch_json=True, jsonschemas={}): + super().__init__( + socket_path, + executor, + logger, + patch_json, + ) + self.jsonschemas = jsonschemas def call(self, method, payload=None): id = self.next_id @@ -615,6 +625,10 @@ class PrettyPrintingLightningRpc(LightningRpc): "id": id, "result": res }, indent=2)) + + if method in self.jsonschemas: + self.jsonschemas[method].validate(res) + return res @@ -625,6 +639,7 @@ class LightningNode(object): allow_warning=False, allow_bad_gossip=False, db=None, port=None, disconnect=None, random_hsm=None, options=None, + jsonschemas={}, **kwargs): self.bitcoin = bitcoind self.executor = executor @@ -639,7 +654,7 @@ class LightningNode(object): self.rc = 0 socket_path = os.path.join(lightning_dir, TEST_NETWORK, "lightning-rpc").format(node_id) - self.rpc = PrettyPrintingLightningRpc(socket_path, self.executor) + self.rpc = PrettyPrintingLightningRpc(socket_path, self.executor, jsonschemas=jsonschemas) self.daemon = LightningD( lightning_dir, bitcoindproxy=bitcoind.get_proxy(), @@ -1196,7 +1211,7 @@ class NodeFactory(object): """A factory to setup and start `lightningd` daemons. """ def __init__(self, request, testname, bitcoind, executor, directory, - db_provider, node_cls, throttler): + db_provider, node_cls, throttler, jsonschemas): if request.node.get_closest_marker("slow_test") and SLOW_MACHINE: self.valgrind = False else: @@ -1211,6 +1226,7 @@ class NodeFactory(object): self.db_provider = db_provider self.node_cls = node_cls self.throttler = throttler + self.jsonschemas = jsonschemas def split_options(self, opts): """Split node options from cli options @@ -1289,6 +1305,7 @@ class NodeFactory(object): node = self.node_cls( node_id, lightning_dir, self.bitcoind, self.executor, self.valgrind, db=db, port=port, options=options, may_fail=may_fail or expect_fail, + jsonschemas=self.jsonschemas, **kwargs ) diff --git a/contrib/pyln-testing/requirements.txt b/contrib/pyln-testing/requirements.txt index 55031d6aa..1b9fcc647 100644 --- a/contrib/pyln-testing/requirements.txt +++ b/contrib/pyln-testing/requirements.txt @@ -9,3 +9,4 @@ pytest-timeout ~= 1.4.2 pytest-xdist ~= 2.2.0 pytest==6.1.* python-bitcoinlib==0.11.* +jsonschema==3.2.* diff --git a/doc/schemas/listpays.schema.json b/doc/schemas/listpays.schema.json new file mode 100644 index 000000000..15d0e685b --- /dev/null +++ b/doc/schemas/listpays.schema.json @@ -0,0 +1,72 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "pays": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "payment_hash": { + "type": "hex", + "description": "the hash of the *payment_preimage* which will prove payment", + "maxLength": 64, + "minLength": 64 + }, + "status": { + "type": "string", + "enum": [ "pending", "failed", "complete" ], + "description": "status of the payment" + }, + "destination": { + "type": "pubkey", + "description": "the final destination of the payment if known" + }, + "amount_msat": { + "type": "msat", + "description": "the amount the destination received, if known (**status** *complete* or *pending*)" + }, + "amount_sent_msat": { + "type": "msat", + "description": "the amount we actually sent, including fees (**status** *complete* or *pending*)" + }, + "created_at": { + "type": "u64", + "description": "the UNIX timestamp showing when this payment was initiated" + }, + "preimage": { + "type": "hex", + "description": "proof of payment, only if (and always if) **status** is *complete*", + "FIXME": "we should enforce the status/payment_preimage relation in the schema!", + "maxLength": 64, + "minLength": 64 + }, + "label": { + "type": "string", + "description": "the label, if given to sendpay" + }, + "bolt11": { + "type": "string", + "description": "the bolt11 string (if pay supplied one)" + }, + "bolt12": { + "type": "string", + "description": "the bolt12 string (if supplied for pay: **experimental-offers** only)." + }, + "erroronion": { + "type": "hex", + "description": "the error onion returned on failure, if any." + }, + "number_of_parts": { + "type": "u64", + "description": "the number of parts for a successful payment (only if more than one, and **status** is *complete*)." + } + }, + "required": [ "payment_hash", "status", "created_at" ] + } + } + }, + "required": [ "pays" ] +} diff --git a/tests/fixtures.py b/tests/fixtures.py index c5a9aefad..e1fdc1238 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -1,5 +1,5 @@ from utils import DEVELOPER, TEST_NETWORK # noqa: F401,F403 -from pyln.testing.fixtures import directory, test_base_dir, test_name, chainparams, node_factory, bitcoind, teardown_checks, throttler, db_provider, executor, setup_logging # noqa: F401,F403 +from pyln.testing.fixtures import directory, test_base_dir, test_name, chainparams, node_factory, bitcoind, teardown_checks, throttler, db_provider, executor, setup_logging, jsonschemas # noqa: F401,F403 from pyln.testing import utils from utils import COMPAT