test: cover Back and failure paths for SD card and QR loading
What changed, and why it matters
This commit only adds and updates automated tests. It does not change any production code, so it cannot introduce a security vulnerability or directly fix one in the shipped firmware. The tests verify that pressing 'Back' or cancelling a camera/SD-card picker does not incorrectly show a 'Failed to load' error message, while real failures still do show the error.
No security action required. Treat as normal test-coverage improvement. If reviewing a related bug, inspect the production code paths these tests cover to confirm the behavior matches the tests.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The diff is entirely within the tests/ tree. It adds a helper assert_not_flashed() and new test cases across addresses, home, sign_message_ui, wallet_descriptor, encryption_ui, login, utils, and wallet_settings test modules. These tests mock QRCodeCapture and SD-card utilities to assert that cancellation/back paths return MENU_CONTINUE without flashing ‘Failed to load’, whereas actual read errors still flash that message. No source files under src/krux are modified.
Changed components
tests/pages/__init__.pytests/pages/home_pages/test_addresses.pytests/pages/home_pages/test_home.pytests/pages/home_pages/test_sign_message_ui.pytests/pages/home_pages/test_wallet_descriptor.pytests/pages/test_encryption_ui.pytests/pages/test_login.pytests/pages/test_utils.pytests/pages/test_wallet_settings.pyInspect captured patch +297 / −8
### tests/pages/__init__.py
@@ -30,3 +30,11 @@ def create_ctx(mocker, btn_seq, wallet=None, printer=None, touch_seq=None):
current_index=mocker.MagicMock(side_effect=touch_seq)
)
return ctx
+
+
+def assert_not_flashed(ctx, text):
+ """Asserts `text` was never flashed on the mocked display"""
+ assert not any(
+ call.args and call.args[0] == text
+ for call in ctx.display.flash_text.call_args_list
+ ), ("unexpected flash: %s" % text)
### tests/pages/home_pages/test_addresses.py
@@ -1,4 +1,5 @@
import pytest
+from .. import assert_not_flashed
from .test_home import tdata, create_ctx
# fortdata.SINGLESIG_ACTION_KEY_TEST_P2WPKH
@@ -1179,3 +1180,40 @@ def test_list_change_addresses(mocker, m5stickv, tdata):
# assert the number of calls to wait_for_button
assert ctx.input.wait_for_button.call_count == len(sequence_buttons)
+
+
+def test_scan_address_camera_back_is_silent(mocker, m5stickv, tdata):
+ from krux.pages.home_pages.addresses import Addresses
+ from krux.pages import MENU_CONTINUE
+ from krux.pages.qr_capture import QRCodeCapture
+ from krux.wallet import Wallet
+
+ wallet = Wallet(tdata.SINGLESIG_12_WORD_KEY)
+ ctx = create_ctx(mocker, [], wallet, None)
+ # user left the QR scanner without scanning anything
+ mocker.patch.object(QRCodeCapture, "qr_capture_loop", new=lambda self: (None, None))
+
+ assert Addresses(ctx).scan_address() == MENU_CONTINUE
+
+ # "Back" is not a failure - it must not flash an error
+ assert_not_flashed(ctx, "Failed to load")
+
+
+def test_scan_address_wrong_qr_format_still_fails(mocker, m5stickv, tdata):
+ from krux.pages.home_pages.addresses import Addresses
+ from krux.pages import MENU_CONTINUE
+ from krux.pages.qr_capture import QRCodeCapture
+ from krux.qr import FORMAT_PMOFN
+ from krux.wallet import Wallet
+
+ wallet = Wallet(tdata.SINGLESIG_12_WORD_KEY)
+ ctx = create_ctx(mocker, [], wallet, None)
+ mocker.patch.object(
+ QRCodeCapture, "qr_capture_loop", new=lambda self: ("an address", FORMAT_PMOFN)
+ )
+
+ assert Addresses(ctx).scan_address() == MENU_CONTINUE
+
+ assert any(
+ c.args[0] == "Failed to load" for c in ctx.display.flash_text.call_args_list
+ )
### tests/pages/home_pages/test_home.py
@@ -1,6 +1,6 @@
import pytest
from ...shared_mocks import MockPrinter
-from .. import create_ctx
+from .. import create_ctx, assert_not_flashed
from ...test_psbt import tdata as psbt_tdata
@@ -654,6 +654,9 @@ def test_load_sign_message_menu(mocker, amigo):
assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE)
+ # "Back" is not a failure - it must not flash an error
+ assert_not_flashed(ctx, "Failed to load")
+
def test_load_sign_psbt_menu(mocker, amigo, tdata):
from krux.pages.home_pages.home import Home
@@ -682,6 +685,9 @@ def test_load_sign_psbt_menu(mocker, amigo, tdata):
assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE)
+ # "Back" is not a failure - it must not flash an error
+ assert_not_flashed(ctx, "Failed to load")
+
# assert that some methods where called, but not all
for _method in [
("_pre_load_psbt_warn", "assert_called_once"),
@@ -695,6 +701,33 @@ def test_load_sign_psbt_menu(mocker, amigo, tdata):
getattr(home_method, _method[1])()
+def test_load_sign_psbt_sd_back_is_silent(mocker, amigo, tdata):
+ from krux.pages.home_pages.home import Home
+ from krux.pages import MENU_CONTINUE
+ from krux.wallet import Wallet
+ from krux.input import BUTTON_ENTER, BUTTON_PAGE
+
+ BTN_SEQUENCE = [
+ BUTTON_PAGE, # Go to "Load from SD card"
+ BUTTON_ENTER, # Select "Load from SD card"
+ ]
+
+ wallet = Wallet(tdata.SINGLESIG_12_WORD_KEY)
+ ctx = create_ctx(mocker, BTN_SEQUENCE, wallet)
+ home = Home(ctx)
+ mocker.patch.object(home, "has_sd_card", new=lambda: True)
+ mock_utils = mocker.patch("krux.pages.utils.Utils")
+ # user backed out of the SD file browser
+ mock_utils.return_value.load_file.return_value = ("", None)
+
+ assert home.sign_psbt() == MENU_CONTINUE
+
+ assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE)
+
+ # "Back" is not a failure - it must not flash an error
+ assert_not_flashed(ctx, "Failed to load")
+
+
def DISABLEDtest_sign_psbt_fails_on_decrypt_kef_key_error(mocker, m5stickv, tdata):
from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV
from krux.pages.home_pages.home import Home
@@ -1951,3 +1984,25 @@ def unverified_input_amounts(self):
mocker.spy(ctx.display, "draw_centered_text")
assert home._unverified_amounts_psbt_warn(FakeSigner(False)) is True
assert ctx.display.draw_centered_text.call_count == 0
+
+
+def test_load_sign_psbt_camera_back_is_silent(mocker, amigo, tdata):
+ from krux.pages.home_pages.home import Home
+ from krux.pages import MENU_CONTINUE
+ from krux.pages.qr_capture import QRCodeCapture
+ from krux.wallet import Wallet
+ from krux.input import BUTTON_ENTER
+
+ BTN_SEQUENCE = [BUTTON_ENTER] # Select "Load from camera"
+
+ wallet = Wallet(tdata.SINGLESIG_12_WORD_KEY)
+ ctx = create_ctx(mocker, BTN_SEQUENCE, wallet)
+ # user left the QR scanner without scanning anything
+ mocker.patch.object(QRCodeCapture, "qr_capture_loop", new=lambda self: (None, None))
+
+ assert Home(ctx).sign_psbt() == MENU_CONTINUE
+
+ assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE)
+
+ # "Back" is not a failure - it must not flash an error
+ assert_not_flashed(ctx, "Failed to load")
### tests/pages/home_pages/test_sign_message_ui.py
@@ -1,5 +1,5 @@
from ...shared_mocks import MockPrinter, get_mock_open
-from .. import create_ctx
+from .. import create_ctx, assert_not_flashed
from .test_home import tdata
@@ -595,3 +595,49 @@ def test_load_from_sd_card(mocker, m5stickv, tdata):
sign_msg._export_to_qr.assert_called()
assert ctx.input.wait_for_button.call_count == len(btn_seq)
+
+
+def test_sign_message_sd_back_is_silent(mocker, m5stickv, tdata):
+ from krux.pages.home_pages.sign_message_ui import SignMessage
+ from krux.pages import MENU_CONTINUE
+ from krux.wallet import Wallet
+ from krux.input import BUTTON_ENTER, BUTTON_PAGE
+
+ btn_seq = [
+ BUTTON_PAGE, # Go to "Load from SD card"
+ BUTTON_ENTER, # Select "Load from SD card"
+ ]
+
+ wallet = Wallet(tdata.SINGLESIG_SIGNING_KEY)
+ ctx = create_ctx(mocker, btn_seq, wallet)
+ sign_msg = SignMessage(ctx)
+ mocker.patch.object(sign_msg, "has_sd_card", new=lambda: True)
+ # user backed out of the SD file browser
+ mocker.patch.object(sign_msg, "load_file", return_value=("", None))
+
+ assert sign_msg.sign_message() == MENU_CONTINUE
+ assert ctx.input.wait_for_button.call_count == len(btn_seq)
+
+ # "Back" is not a failure - it must not flash an error
+ assert_not_flashed(ctx, "Failed to load")
+
+
+def test_sign_message_camera_back_is_silent(mocker, m5stickv, tdata):
+ from krux.pages.home_pages.sign_message_ui import SignMessage
+ from krux.pages import MENU_CONTINUE
+ from krux.pages.qr_capture import QRCodeCapture
+ from krux.wallet import Wallet
+ from krux.input import BUTTON_ENTER
+
+ btn_seq = [BUTTON_ENTER] # Select "Load from camera"
+
+ wallet = Wallet(tdata.SINGLESIG_SIGNING_KEY)
+ ctx = create_ctx(mocker, btn_seq, wallet)
+ # user left the QR scanner without scanning anything
+ mocker.patch.object(QRCodeCapture, "qr_capture_loop", new=lambda self: (None, None))
+
+ assert SignMessage(ctx).sign_message() == MENU_CONTINUE
+ assert ctx.input.wait_for_button.call_count == len(btn_seq)
+
+ # "Back" is not a failure - it must not flash an error
+ assert_not_flashed(ctx, "Failed to load")
### tests/pages/home_pages/test_wallet_descriptor.py
@@ -1,6 +1,7 @@
import pytest
from ...shared_mocks import MockPrinter
-from .test_home import tdata, create_ctx
+from .. import create_ctx, assert_not_flashed
+from .test_home import tdata
from ...test_wallet import tdata as wallet_tdata
@@ -115,7 +116,10 @@ def test_wallet(mocker, m5stickv, tdata):
# Mock SD card descriptor loading
if case[4][:3] == [BUTTON_ENTER, BUTTON_PAGE, BUTTON_ENTER]:
mock_utils = mocker.patch("krux.pages.utils.Utils")
- mock_utils.return_value.load_file.return_value = (None, case[2])
+ mock_utils.return_value.load_file.return_value = (
+ "descriptor.txt",
+ case[2],
+ )
wallet_descriptor.wallet()
@@ -138,6 +142,33 @@ def test_wallet(mocker, m5stickv, tdata):
assert ctx.input.wait_for_button.call_count == len(case[4])
+def test_wallet_load_sd_back_is_silent(mocker, m5stickv, tdata):
+ from krux.pages.home_pages.wallet_descriptor import WalletDescriptor
+ from krux.wallet import Wallet
+ from krux.input import BUTTON_ENTER, BUTTON_PAGE
+ from krux.pages import MENU_CONTINUE
+
+ btn_seq = [
+ BUTTON_ENTER, # confirm load
+ BUTTON_PAGE, # go to "Load from SD card"
+ BUTTON_ENTER, # select "Load from SD card"
+ ]
+
+ wallet = Wallet(tdata.SINGLESIG_12_WORD_KEY)
+ ctx = create_ctx(mocker, btn_seq, wallet)
+ wallet_descriptor = WalletDescriptor(ctx)
+ mocker.patch.object(wallet_descriptor, "has_sd_card", return_value=True)
+ mock_utils = mocker.patch("krux.pages.utils.Utils")
+ # user backed out of the SD file browser
+ mock_utils.return_value.load_file.return_value = ("", None)
+
+ assert wallet_descriptor.wallet() == MENU_CONTINUE
+ assert ctx.input.wait_for_button.call_count == len(btn_seq)
+
+ # "Back" is not a failure - it must not flash an error
+ assert_not_flashed(ctx, "Failed to load")
+
+
def test_wallet_load_fails_on_decrypt_kef_key_error(mocker, m5stickv, tdata):
from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV
from krux.pages.home_pages.wallet_descriptor import WalletDescriptor
@@ -920,3 +951,27 @@ def test_combined_policy_and_network_mismatch_accept(mocker, m5stickv, tdata):
# Verify wallet was loaded
assert ctx.wallet.is_loaded()
assert ctx.input.wait_for_button.call_count == len(btn_seq)
+
+
+def test_wallet_load_camera_back_is_silent(mocker, m5stickv, tdata):
+ from krux.pages.home_pages.wallet_descriptor import WalletDescriptor
+ from krux.pages import MENU_CONTINUE
+ from krux.pages.qr_capture import QRCodeCapture
+ from krux.wallet import Wallet
+ from krux.input import BUTTON_ENTER
+
+ btn_seq = [
+ BUTTON_ENTER, # confirm load
+ BUTTON_ENTER, # select "Load from camera"
+ ]
+
+ wallet = Wallet(tdata.SINGLESIG_12_WORD_KEY)
+ ctx = create_ctx(mocker, btn_seq, wallet)
+ # user left the QR scanner without scanning anything
+ mocker.patch.object(QRCodeCapture, "qr_capture_loop", new=lambda self: (None, None))
+
+ assert WalletDescriptor(ctx).wallet() == MENU_CONTINUE
+ assert ctx.input.wait_for_button.call_count == len(btn_seq)
+
+ # "Back" is not a failure - it must not flash an error
+ assert_not_flashed(ctx, "Failed to load")
### tests/pages/test_encryption_ui.py
@@ -1,6 +1,6 @@
import pytest
from unittest.mock import patch
-from . import create_ctx
+from . import create_ctx, assert_not_flashed
TEST_KEY = "test key"
CBC_WORDS = "dog guitar hotel random owner gadget salute riot patrol work advice panic erode leader pass cross section laundry elder asset soul scale immune scatter"
@@ -1898,3 +1898,23 @@ def test_kefenvelope_unseal_ui(m5stickv, mocker):
with pytest.raises(KeyError, match="Failed to decrypt"):
page.unseal_ui()
assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE)
+
+
+def test_encryption_key_camera_back_is_silent(m5stickv, mocker):
+ from krux.pages.encryption_ui import EncryptionKey
+ from krux.input import BUTTON_ENTER, BUTTON_PAGE
+ from krux.pages.qr_capture import QRCodeCapture
+
+ BTN_SEQUENCE = [
+ BUTTON_PAGE, # move to QR code key
+ BUTTON_ENTER, # choose QR code key
+ ]
+ ctx = create_ctx(mocker, BTN_SEQUENCE)
+ # user left the QR scanner without scanning anything
+ mocker.patch.object(QRCodeCapture, "qr_capture_loop", new=lambda self: (None, None))
+
+ assert EncryptionKey(ctx).encryption_key() is None
+ assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE)
+
+ # "Back" is not a failure - it must not flash an error
+ assert_not_flashed(ctx, "Failed to load")
### tests/pages/test_login.py
@@ -1,5 +1,5 @@
import pytest
-from . import create_ctx
+from . import create_ctx, assert_not_flashed
@pytest.fixture
@@ -1907,3 +1907,41 @@ def test_load_default_wallet(mocker, amigo):
assert ctx.wallet.key.policy_type == case[3]
assert ctx.wallet.key.script_type == case[4]
assert ctx.wallet.key.derivation == case[5]
+
+
+def test_load_key_from_qr_code_camera_back_is_silent(m5stickv, mocker):
+ from krux.pages.login import Login
+ from krux.pages import MENU_CONTINUE
+ from krux.pages.qr_capture import QRCodeCapture
+
+ ctx = create_ctx(mocker, [])
+ # user left the QR scanner without scanning anything
+ mocker.patch.object(QRCodeCapture, "qr_capture_loop", new=lambda self: (None, None))
+
+ assert Login(ctx).load_key_from_qr_code() == MENU_CONTINUE
+
+ # "Back" is not a failure - it must not flash an error
+ assert_not_flashed(ctx, "Failed to load")
+
+
+def test_load_key_from_tiny_seed_image_back_is_silent(m5stickv, mocker):
+ from krux.pages.login import Login
+ from krux.pages import MENU_CONTINUE
+ from krux.input import BUTTON_ENTER
+
+ BTN_SEQUENCE = [
+ BUTTON_ENTER, # 12 words
+ BUTTON_ENTER, # Proceed?
+ ]
+ # user left the scanner without capturing a seed
+ mocker.patch(
+ "krux.pages.tiny_seed.TinyScanner.scanner",
+ new=mocker.MagicMock(return_value=None),
+ )
+ ctx = create_ctx(mocker, BTN_SEQUENCE)
+
+ assert Login(ctx).load_key_from_tiny_seed_image() == MENU_CONTINUE
+ assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE)
+
+ # "Back" is not a failure - it must not flash an error
+ assert_not_flashed(ctx, "Failed to load")
### tests/pages/test_utils.py
@@ -49,3 +49,29 @@ def test_load_file_with_no_sd(m5stickv, mocker):
assert file_name == ""
assert data is None
+ # an empty filename means "cancelled" to callers, so a missing card must
+ # report itself here instead of failing silently
+ ctx.display.flash_text.assert_called_once()
+ assert ctx.display.flash_text.call_args.args[0] == "SD card not detected."
+
+
+def test_load_file_with_sd_read_error(m5stickv, mocker):
+ from krux.pages.utils import Utils
+
+ mocker.patch(
+ "krux.sd_card.SDHandler.dir_exists",
+ mocker.MagicMock(return_value=True),
+ )
+ mocker.patch(
+ # first entry is has_sd_card()'s hot-plug probe, second is load_file's
+ "krux.sd_card.SDHandler.__enter__",
+ mocker.MagicMock(side_effect=[None, OSError]),
+ )
+ ctx = create_ctx(mocker, None)
+ utils = Utils(ctx)
+ file_name, data = utils.load_file(prompt=False)
+
+ assert file_name == ""
+ assert data is None
+ # same rule: a read that blew up must report itself, not look like a cancel
+ assert ctx.display.flash_text.call_args.args[0] == "Failed to load"
### tests/pages/test_wallet_settings.py
@@ -1,4 +1,4 @@
-from . import create_ctx
+from . import create_ctx, assert_not_flashed
from .home_pages.test_home import tdata
import pytest
@@ -77,7 +77,7 @@ def test_qr_passphrase_too_long(m5stickv, mocker):
qr_capturer.assert_called_once()
-def test_qr_passphrase_fail(m5stickv, mocker):
+def test_qr_passphrase_camera_back_is_silent(m5stickv, mocker):
from krux.pages.wallet_settings import PassphraseEditor, MENU_CONTINUE
from krux.pages.qr_capture import QRCodeCapture
@@ -92,6 +92,9 @@ def test_qr_passphrase_fail(m5stickv, mocker):
assert test_passphrase == MENU_CONTINUE
qr_capturer.assert_called_once()
+ # "Back" is not a failure - it must not flash an error
+ assert_not_flashed(ctx, "Failed to load")
+
def test_qr_passphrase_fails_on_decrypt_kef_key_error(mocker, m5stickv, tdata):
from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREVWhy this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.