From 4e1b0f0f69b3ccca82769893f4efdbca514b1276 Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Mon, 22 Jan 2024 17:07:49 -0500 Subject: [PATCH] improved QR reading, BBQr tests --- testing/conftest.py | 69 +++++++++---- testing/requirements.txt | 3 + testing/test_bbqr.py | 211 +++++++++++++++++++++++++++++---------- 3 files changed, 215 insertions(+), 68 deletions(-) diff --git a/testing/conftest.py b/testing/conftest.py index ab2af9c8..12e56af2 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -1,6 +1,6 @@ # (c) Copyright 2020 by Coinkite Inc. This file is covered by license found in COPYING-CC. # -import pytest, time, sys, random, re, ndef, os, glob, hashlib, json +import pytest, time, sys, random, re, ndef, os, glob, hashlib, json, pdb from subprocess import check_output from ckcc.protocol import CCProtocolPacker from helpers import B2A, U2SAT @@ -438,8 +438,14 @@ def cap_image(sim_exec, is_q1): fn = os.path.realpath(f'./debug/snap-{random.randint(1E6, 9E6)}.png') try: sim_exec(f"from glob import dis; dis.dis.save_snapshot({fn!r})") - time.sleep(0.250) # need this but I don't see why - rv = Image.open(fn) + while 1: + time.sleep(0.010) + try: + rv = Image.open(fn) + break + except: + # PIL parsing errors and FileNotFoundError + continue finally: os.remove(fn) return rv @@ -514,7 +520,7 @@ def qr_quality_check(): @pytest.fixture(scope='module') def cap_screen_qr(cap_image): - def doit(): + def doit(no_history=False): # NOTE: version=4 QR is pixel doubled to be 66x66 with 2 missing lines at bottom # LATER: not doing that anymore; v=3 doubled, all higher 1:1 pixels (tiny) global QR_HISTORY @@ -530,9 +536,10 @@ def cap_screen_qr(cap_image): orig_img = cap_image() - # document it - tname = os.environ.get('PYTEST_CURRENT_TEST') - QR_HISTORY.append( (tname, orig_img) ) + if not no_history: + # document it + tname = os.environ.get('PYTEST_CURRENT_TEST') + QR_HISTORY.append( (tname, orig_img) ) if orig_img.width == 128: # Mk3/4 - pull out just the QR, blow it up 16x @@ -543,18 +550,32 @@ def cap_screen_qr(cap_image): else: # Q1 - trim status line, convert to greyscale w, h = orig_img.size # 320x240 - img = orig_img.convert('L') + # - remove status bar (harmless) + # - and progress bar (does cause readability issues) + # - MAYBE: blow up the size, helps on fine QR. + # - but some are still unreadable!?!? + img = orig_img.crop( (0, 15, w, h-5) ).convert('L') + #w, h = img.size + #img = img.resize( (w*9, h*9)) img.save('debug/last-qr.png') #img.show() - # Important: w/h reversed in shape of NP array - np = numpy.array(img.getdata(), 'uint8').reshape(img.height, img.width) + # Above usually works @ zoom=1, but not always! + # - simulate what users do... move phone back and forth until it scans + oo = img + for zoom in range(1, 7): + if zoom > 1: + w, h = oo.size + img = oo.resize( (w*zoom, h*zoom) ) - scanner = zbar.Scanner() - for sym, value, *_ in scanner.scan(np): - assert sym == 'QR-Code', 'unexpected symbology: ' + sym - return value # bytes, could be binary + # Important: w/h reversed in shape of NP array + np = numpy.array(img.getdata(), 'uint8').reshape(img.height, img.width) + + scanner = zbar.Scanner() + for sym, value, *_ in scanner.scan(np): + assert sym == 'QR-Code', 'unexpected symbology: ' + sym + return value # bytes, could be binary # for debug, check debug/last-qr.png raise RuntimeError('qr code not found') @@ -1062,7 +1083,7 @@ def decode_with_bitcoind(bitcoind): return bitcoind.rpc.decoderawtransaction(B2A(raw_txn)) except ConnectionResetError: # bitcoind sleeps on us sometimes, give it another chance. - return bitcoind.decoderawtransaction(B2A(raw_txn)) + return bitcoind.rpc.decoderawtransaction(B2A(raw_txn)) return doit @@ -1074,10 +1095,10 @@ def decode_psbt_with_bitcoind(bitcoind): from base64 import b64encode try: - return bitcoind.decodepsbt(b64encode(raw_psbt).decode('ascii')) + return bitcoind.rpc.decodepsbt(b64encode(raw_psbt).decode('ascii')) except ConnectionResetError: # bitcoind sleeps on us sometimes, give it another chance. - return bitcoind.decodepsbt(b64encode(raw_psbt).decode('ascii')) + return bitcoind.rpc.decodepsbt(b64encode(raw_psbt).decode('ascii')) return doit @@ -1531,6 +1552,20 @@ def nfc_write(request, needs_nfc): except: return doit_usb +@pytest.fixture() +def scan_a_qr(sim_exec, is_q1): + if not is_q1: + raise pytest.xfail('needs scanner') + + def doit(qr): + assert isinstance(qr, str) + qr = qr.encode('ascii') + rv = sim_exec(f'glob.SCAN._q.put_nowait({qr!r})') + if 'Traceback' in rv: raise pytest.fail(rv) + + return doit + + def ccfile_wrap(recs): from struct import pack CC_FILE = bytes([0xE2, 0x43, 0x00, 0x01, 0x00, 0x00, 0x04, 0x00, 0x03]) diff --git a/testing/requirements.txt b/testing/requirements.txt index 99fc8d40..8923ecdb 100644 --- a/testing/requirements.txt +++ b/testing/requirements.txt @@ -20,3 +20,6 @@ pyscard==2.0.2 # BSMS library git+https://github.com/coinkite/bsms-bitcoin-secure-multisig-setup.git@master#egg=bsms-bitcoin-secure-multisig-setup +# BBQr library +git+https://github.com/coinkite/BBQr.git@master#egg=bbqr + diff --git a/testing/test_bbqr.py b/testing/test_bbqr.py index d9398439..040debfa 100644 --- a/testing/test_bbqr.py +++ b/testing/test_bbqr.py @@ -3,20 +3,79 @@ # BBQr and secure notes. # -import pytest, os, time +import pytest, os, time, random from helpers import B2A, prandom from binascii import b2a_hex, a2b_hex +from bbqr import split_qrs, join_qrs +from charcodes import KEY_QR -@pytest.mark.parametrize('size', [ 20, 990] ) -def XXX_test_show_bbqr_codes(size, sim_execfile, need_keypress, cap_screen_qr, sim_exec): - sim_exec('import main; main.BBQR_SIZE = %r; ' % size) - rv = sim_execfile('devtest/bbqr.py') - assert 'Error' not in rv - readback = cap_screen_qr() - assert readback == 'sdf' +# All tests in this file are exclusively meant for Q +# +@pytest.fixture(autouse=True) +def THIS_FILE_requires_q1(is_q1): + if not is_q1: + raise pytest.skip('Q1 only') + +@pytest.fixture +def readback_bbqr(need_keypress, cap_screen_qr, sim_exec): + def doit(): + num_parts = None + encoding, file_type = None, None + parts = {} + + for retries in range(1000): + #time.sleep(0.05) # not really sync'ed + try: + rb = cap_screen_qr(no_history=True).decode('ascii') + except RuntimeError: + time.sleep(0.1) + continue + + #print(rb[0:20]+'...') + + if len(rb) > 2 and rb[0:2] != 'B$': + # it sent a non-BBQr QR which isn't wrong.. but let caller decode + return 0, None, None, rb + + assert rb[0:2] == 'B$' + if not encoding: + encoding = rb[2] + else: + assert encoding == rb[2] + + if not file_type: + file_type = rb[3] + else: + assert file_type == rb[3] + + if num_parts is None: + num_parts = int(rb[4:6], 36) + assert num_parts >= 1 + else: + assert num_parts == int(rb[4:6], 36) + + part = int(rb[6:8], 36) + assert part < num_parts + + if part in parts: + assert parts[part] == rb + else: + parts[part] = rb + + if len(parts) >= num_parts: + break + + if len(parts) != num_parts: + # timed out + raise pytest.fail(f'Could not read all parts of BBQr: '\ + f'got {[parts.keys()]} of {num_parts}') + + return num_parts, encoding, file_type, parts + + return doit @pytest.fixture -def render_bbqr(need_keypress, cap_screen_qr, sim_exec): +def render_bbqr(need_keypress, cap_screen_qr, sim_exec, readback_bbqr): def doit(data=None, str_expr=None, file_type='B', msg=None, setup=''): assert data or str_expr @@ -35,47 +94,12 @@ def render_bbqr(need_keypress, cap_screen_qr, sim_exec): print(f"RESP: {resp}") assert 'error' not in resp.lower() - num_parts = None - encoding = None - parts = {} - for retries in range(1000): - time.sleep(0.005) # not really sync'ed - try: - rb = cap_screen_qr().decode('ascii') - except RuntimeError: - time.sleep(0.1) - continue - - print(rb[0:20]) - assert rb[0:2] == 'B$' - if not encoding: - encoding = rb[2] - else: - assert encoding == rb[2] - assert rb[3] == file_type - - if num_parts is None: - num_parts = int(rb[4:6], 36) - assert num_parts >= 1 - else: - assert num_parts == int(rb[4:6], 36) - part = int(rb[6:8], 36) - assert part < num_parts - - parts[part] = rb - - if len(parts) >= num_parts: - break + num_parts, encoding, rb_ft, parts = readback_bbqr() + assert rb_ft == file_type print(sim_exec(f'import main; main.TT.cancel()')) - - need_keypress('\r') need_keypress('0') # for menu to redraw - if len(parts) != num_parts: - # timed out - raise pytest.fail(f'Could not read all parts of BBQr: got {[parts.keys()]} of {num_parts}') - # we only can decode simple BBQr here assert encoding == 'H' body = a2b_hex(''.join(p[8:] for p in [parts[i] for i in range(num_parts)])) @@ -97,22 +121,107 @@ def test_show_bbqr_sizes(size, need_keypress, cap_screen_qr, sim_exec, render_bb assert len(data) == size assert data == 'a' * size -@pytest.mark.parametrize('src', [ 'rng', 'gpu'] ) + ft, data2 = join_qrs(parts.values()) + assert data2.decode('utf-8') == data + assert ft == 'U' + +@pytest.mark.parametrize('src', [ 'rng', 'gpu', 'bigger'] ) def test_show_bbqr_contents(src, need_keypress, cap_screen_qr, sim_exec, render_bbqr, load_shared_mod): args = dict(msg=f'Test {src}', file_type='B') if src == 'rng': args['data'] = expect = prandom(500) # limited by simulated USB path - elif src == 'gpu': + elif src in { 'gpu', 'bigger' }: args['setup'] = 'from gpu_binary import BINARY' - args['str_expr'] = '"BINARY"' cc_gpu_bin = load_shared_mod('cc_gpu_bin', '../shared/gpu_binary.py') - expect = cc_gpu_bin.BINARY + if src == 'gpu': + args['str_expr'] = 'BINARY' + expect = cc_gpu_bin.BINARY + elif src == 'bigger': + args['str_expr'] = 'BINARY*10' + expect = cc_gpu_bin.BINARY*10 data, parts = render_bbqr(**args) - assert len(parts) > 1 assert len(data) == len(expect) assert data == expect + ft, data2 = join_qrs(parts.values()) + assert data2 == data + assert ft == 'B' + +@pytest.mark.parametrize('size', [ 10 ] ) +@pytest.mark.parametrize('max_ver', [ 10, 20 ] ) +@pytest.mark.parametrize('encoding', '2HZ' ) +@pytest.mark.parametrize('partial', [False, True]) +def test_bbqr_psbt(size, encoding, max_ver, partial, + need_keypress, scan_a_qr, readback_bbqr, + cap_screen_qr, render_bbqr, goto_home, use_regtest, decode_psbt_with_bitcoind, + decode_with_bitcoind, fake_txn, dev, cap_story, start_sign, end_sign): + + num_in = size + num_out = size*10 + + def hack(psbt): + if partial: + # change first input to not be ours + pk = list(psbt.inputs[0].bip32_paths.keys())[0] + pp = psbt.inputs[0].bip32_paths[pk] + psbt.inputs[0].bip32_paths[pk] = b'what' + pp[4:] + + psbt = fake_txn(num_in, num_out, dev.master_xpub, psbt_hacker=hack) + open('debug/last.psbt', 'wb').write(psbt) + + goto_home() + need_keypress(KEY_QR) + + actual_vers, parts = split_qrs(psbt, 'P', max_version=max_ver, encoding=encoding) + # def split_qrs(raw, type_code, encoding=None, + # min_split=1, max_split=1295, min_version=5, max_version=40 + + random.shuffle(parts) + + for p in parts: + scan_a_qr(p) + time.sleep(4.0 / len(parts)) # just so we can watch + + for r in range(20): + title, story = cap_story() + if 'OK TO SEND' in title: + break + time.sleep(.1) + else: + raise pytest.fail('never saw it?') + + # approve it + need_keypress('y') + + time.sleep(.2) + + # expect signed txn back + num_parts, encoding, file_type, parts = readback_bbqr() + if num_parts == 0: + # not sent as BBQr .. assume Hex + rb = a2b_hex(parts) + file_type = 'P' if rb[0:4] == b'psbt' else 'T' + else: + assert file_type in 'TP' + _, rb = join_qrs(parts.values()) + + if file_type == 'T': + assert not partial + decoded = decode_with_bitcoind(rb) + elif file_type == 'P': + assert partial + assert rb[0:4] == b'psbt' + decoded = decode_psbt_with_bitcoind(rb) + assert not decoded['unknown'] + decoded = decoded['tx'] + + # just smoke test; syntax not content + assert len(decoded['vin']) == num_in + assert len(decoded['vout']) == num_out + + need_keypress('x') # back to menu + # EOF