From 33f1b6e6d6a87fe1a91a27ddbf534bfd03732164 Mon Sep 17 00:00:00 2001 From: scarreras Date: Mon, 2 Nov 2026 11:55:10 -0300 Subject: [PATCH] fix: report "Checksum matches" only after download verification; strengthen unit tests - `file download` prints "Checksum matches" after the data has been saved and verified, and not at all with --skip-hash-verification (previously it was printed before the download and even when verification was skipped). - Unit-test harness: the autouse _TqdmCloser.__exit__ mock returned a truthy MagicMock, which swallowed every exception raised by a command that uses a progress bar. It now returns None; two tests that relied on the swallowing suppress the expected KeyError explicitly. - New or strengthened unit tests: rm by file id (hide marker and version), incremental upload, --destination-server-side-encryption on the wire, unbound-stream --part-size, sync thread options, sdkharness health and conformance negative paths, camelCase rejection in v4, download-file-by-name flags, rm progress, arg-parser and sdkharness file-lifecycle coverage. Co-Authored-By: Claude Sonnet 5.5 --- b2/_internal/console_tool.py | 8 +- ...ecksum-matches-after-verification.fixed.md | 1 + ...-closer-swallowed-errors.infrastructure.md | 1 + test/unit/conftest.py | 3 +- test/unit/console_tool/test_download_file.py | 55 +++++++- .../test_upload_unbound_stream.py | 44 ++++++ test/unit/test_arg_parser.py | 19 +++ test/unit/test_console_tool.py | 133 ++++++++++++++++-- test/unit/test_sdkharness_conformance.py | 48 +++++++ test/unit/test_sdkharness_file_lifecycle.py | 70 +++++++++ test/unit/test_sdkharness_health.py | 38 +++++ 11 files changed, 406 insertions(+), 14 deletions(-) create mode 100644 changelog.d/+download-checksum-matches-after-verification.fixed.md create mode 100644 changelog.d/+unit-tests-tqdm-closer-swallowed-errors.infrastructure.md diff --git a/b2/_internal/console_tool.py b/b2/_internal/console_tool.py index 889d26ec5..0e2a45a86 100644 --- a/b2/_internal/console_tool.py +++ b/b2/_internal/console_tool.py @@ -1898,8 +1898,6 @@ def _print_download_info( self._print_file_attribute(label, attr_value) for name in sorted(download_version.file_info): self._print_file_attribute(f'INFO {name}', download_version.file_info[name]) - if download_version.content_sha1 != 'none': - self._print('Checksum matches') return 0 @classmethod @@ -2041,6 +2039,12 @@ def _run(self, args): progress_listener.change_description(output_filepath.name) downloaded_file.save_to(output_filepath) + # only claim a match once the bytes were actually hashed and compared + if ( + not args.skip_hash_verification + and downloaded_file.download_version.content_sha1 != 'none' + ): + self._print('Checksum matches') self._print('Download finished') return 0 diff --git a/changelog.d/+download-checksum-matches-after-verification.fixed.md b/changelog.d/+download-checksum-matches-after-verification.fixed.md new file mode 100644 index 000000000..b69a060f4 --- /dev/null +++ b/changelog.d/+download-checksum-matches-after-verification.fixed.md @@ -0,0 +1 @@ +Print `Checksum matches` after a download has been verified, and not at all when `--skip-hash-verification` is used. diff --git a/changelog.d/+unit-tests-tqdm-closer-swallowed-errors.infrastructure.md b/changelog.d/+unit-tests-tqdm-closer-swallowed-errors.infrastructure.md new file mode 100644 index 000000000..3e52e3995 --- /dev/null +++ b/changelog.d/+unit-tests-tqdm-closer-swallowed-errors.infrastructure.md @@ -0,0 +1 @@ +Unit tests no longer swallow exceptions raised by commands that use the progress bar (the `_TqdmCloser.__exit__` mock returned a truthy value). diff --git a/test/unit/conftest.py b/test/unit/conftest.py index 7f46ea133..2a7dde415 100644 --- a/test/unit/conftest.py +++ b/test/unit/conftest.py @@ -87,7 +87,8 @@ def bg_executor(): @pytest.fixture(autouse=True) def disable_tqdm_closer_cleanup(): - with mock.patch.object(_TqdmCloser, '__exit__'): + # return_value=None: a bare MagicMock returns a truthy MagicMock, which would swallow every exception + with mock.patch.object(_TqdmCloser, '__exit__', return_value=None): yield diff --git a/test/unit/console_tool/test_download_file.py b/test/unit/console_tool/test_download_file.py index 63c2fec76..7c8ab1e72 100644 --- a/test/unit/console_tool/test_download_file.py +++ b/test/unit/console_tool/test_download_file.py @@ -9,8 +9,10 @@ ###################################################################### import os import pathlib +from unittest import mock import pytest +from b2sdk.v2.exception import ChecksumMismatch from test.helpers import skip_on_windows @@ -88,13 +90,12 @@ def test_download_file_by_name(b2_cli, local_file, uploaded_file, tmp_path, flag b2_cli.run( [ 'download-file-by-name', + flag, uploaded_file['bucket'], uploaded_file['fileName'], str(output_path), ], - expected_stdout=EXPECTED_STDOUT_DOWNLOAD.format( - output_path=pathlib.Path(output_path).resolve() - ), + expected_stdout=expected_stdout.format(output_path=pathlib.Path(output_path).resolve()), expected_stderr='WARNING: `download-file-by-name` command is deprecated. Use `file download` instead.\n', ) assert output_path.read_text() == uploaded_file['content'] @@ -283,3 +284,51 @@ def test_download_file_by_uri__explicit_file_target_allows_unsupported_server_na ) assert output_path.read_text() == 'hello world' + + +def test_download_file__skipped_hash_verification_is_not_reported_as_matching( + b2_cli, uploaded_file, tmp_path +): + output_path = tmp_path / 'output.txt' + + b2_cli.run( + ['file', 'download', '--skip-hash-verification', 'b2id://9999', str(output_path)], + expected_stdout=None, + expected_part_of_stdout='Download finished', + unexpected_part_of_stdout='Checksum matches', + ) + assert output_path.read_text() == uploaded_file['content'] + + +def test_download_file__checksum_mismatch_is_not_reported_as_matching( + b2_cli, uploaded_file, tmp_path +): + output_path = tmp_path / 'output.txt' + + with mock.patch( + 'b2sdk.v2.DownloadedFile.save_to', + side_effect=ChecksumMismatch(checksum_type='sha1', expected='a' * 40, actual='b' * 40), + ): + b2_cli.run( + ['file', 'download', '--no-progress', 'b2id://9999', str(output_path)], + expected_stdout=None, + expected_stderr='ERROR: sha1 checksum mismatch -- bad data\n', + expected_status=1, + unexpected_part_of_stdout='Checksum matches', + ) + + +def test_download_file__error_is_reported_when_progress_is_enabled(b2_cli, uploaded_file, tmp_path): + """A failing download must give status 1 and an ERROR line also with the default progress bar""" + output_path = tmp_path / 'output.txt' + + with mock.patch( + 'b2sdk.v2.DownloadedFile.save_to', + side_effect=ChecksumMismatch(checksum_type='sha1', expected='a' * 40, actual='b' * 40), + ): + b2_cli.run( + ['file', 'download', 'b2id://9999', str(output_path)], + expected_stdout=None, + expected_stderr='ERROR: sha1 checksum mismatch -- bad data\n', + expected_status=1, + ) diff --git a/test/unit/console_tool/test_upload_unbound_stream.py b/test/unit/console_tool/test_upload_unbound_stream.py index 020397ec6..b08a40e5a 100644 --- a/test/unit/console_tool/test_upload_unbound_stream.py +++ b/test/unit/console_tool/test_upload_unbound_stream.py @@ -7,7 +7,9 @@ # License https://www.backblaze.com/using_b2_code.html # ###################################################################### +import inspect import os +from unittest import mock from b2sdk.v3 import DEFAULT_MIN_PART_SIZE @@ -110,6 +112,48 @@ def test_upload_unbound_stream__with_part_size_options( writer.result(timeout=1) +@skip_on_windows +def test_upload_unbound_stream__part_size_option_is_applied( + b2_cli, bucket, tmpdir, mock_stdin, bg_executor +): + """--part-size must change the size of the uploaded parts (and the upload must be multipart)""" + part_size = ( + DEFAULT_MIN_PART_SIZE + 1024 + ) # differs from the default, so a dropped flag is visible + expected_size = part_size * 2 + 500 + + fifo_file = tmpdir.join('fifo_file.txt') + os.mkfifo(str(fifo_file)) + writer = bg_executor.submit(lambda: fifo_file.write('x' * expected_size)) + + with mock.patch.object( + b2_cli.raw_simulator, 'upload_part', wraps=b2_cli.raw_simulator.upload_part + ) as upload_part: + b2_cli.run( + [ + 'upload-unbound-stream', + '--min-part-size', + str(DEFAULT_MIN_PART_SIZE), + '--part-size', + str(part_size), + '--no-progress', + 'my-bucket', + str(fifo_file), + 'named_pipe.txt', + ], + expected_part_of_stdout='URL by file name:', + expected_stderr=UUS_DEPRECATION_WARNING, + ) + writer.result(timeout=1) + + signature = inspect.signature(b2_cli.raw_simulator.upload_part) + part_lengths = sorted( + signature.bind(*call.args, **call.kwargs).arguments['content_length'] + for call in upload_part.call_args_list + ) + assert part_lengths == sorted([part_size, part_size, 500]) + + def test_upload_unbound_stream__regular_file(b2_cli, bucket, tmpdir): """Test upload_unbound_stream regular file support""" content = 'stdin input' diff --git a/test/unit/test_arg_parser.py b/test/unit/test_arg_parser.py index 64aeecd84..6cf8ef627 100644 --- a/test/unit/test_arg_parser.py +++ b/test/unit/test_arg_parser.py @@ -96,3 +96,22 @@ def test_help_in_non_utf8_terminal(command_name: str, monkeypatch): pytest.fail( f'Failed to encode help message for command "{command_name}" on a non-UTF-8 terminal: {e}' ) + + +@pytest.mark.parametrize('command_name', COMMAND_NAMES_CLASSES_MAPPING) +def test_real_command_help_in_non_utf8_terminal(command_name: str, monkeypatch, capsys): + """Render each command's real parser help (options and subcommands), not only its docstring.""" + from b2._internal.console_tool import ConsoleTool + + with capsys.disabled(): + monkeypatch.setattr(sys, 'stdout', _ASCIIEncodedStream(sys.stdout)) + monkeypatch.setattr(sys, 'stderr', _ASCIIEncodedStream(sys.stderr)) + argv = command_name.split() + try: + ConsoleTool(sys.stdout, sys.stderr).run_command([*argv, '--help']) + except SystemExit: + pass # argparse exits after printing help + except UnicodeEncodeError as e: + pytest.fail( + f'Failed to encode the real help of "{command_name}" on a non-UTF-8 terminal: {e}' + ) diff --git a/test/unit/test_console_tool.py b/test/unit/test_console_tool.py index 29d6e3704..e7f29103d 100644 --- a/test/unit/test_console_tool.py +++ b/test/unit/test_console_tool.py @@ -8,6 +8,8 @@ # ###################################################################### +import contextlib +import inspect import json import os import pathlib @@ -25,9 +27,12 @@ ALL_CAPABILITIES, B2Api, B2HttpApiConfig, + DownloadManager, ProgressReport, RawSimulator, StubAccountInfo, + Synchronizer, + UploadManager, UploadSourceBytes, fix_windows_path_limit, ) @@ -343,8 +348,12 @@ def test_camel_case_supported_in_v3(self): @pytest.mark.apiver(from_ver=4) def test_camel_case_not_supported_in_v4(self): self._authorize_account() + # a valid value, so that only the unknown option name can make the command fail self._run_command( - ['bucket', 'create', 'my-bucket', '--bucketInfo', 'allPrivate'], '', '', 2 + ['bucket', 'create', 'my-bucket', '--bucketInfo', '{"xxx": "123"}', 'allPrivate'], + '', + '', + 2, ) self._run_command( [ @@ -1250,10 +1259,18 @@ def test_rm_fileid_v4(self): ['file', 'hide', 'b2://my-bucket/file1.txt'], ) + # Both the hide marker (9999) and the uploaded version (9998) exist + _, stdout, _ = self._run_command(['ls', '--json', '--versions', 'b2://my-bucket']) + assert sorted(entry['fileId'] for entry in json.loads(stdout)) == ['9998', '9999'] + # Delete one file version self._run_command(['rm', 'b2id://9998']) + _, stdout, _ = self._run_command(['ls', '--json', '--versions', 'b2://my-bucket']) + assert [entry['fileId'] for entry in json.loads(stdout)] == ['9999'] + # Delete one file version self._run_command(['rm', 'b2id://9999']) + self._run_command(['ls', '--json', '--versions', 'b2://my-bucket'], '[]\n', '', 0) def test_hide_file_legacy_syntax(self): self._authorize_account() @@ -1468,6 +1485,44 @@ def test_files(self): expected_json_in_stdout=expected_json, ) + def test_upload_destination_sse_is_sent_to_the_server(self): + # B2 buckets always default to SSE-B2, so the stored result alone cannot show whether + # the CLI forwarded the flag; check the request that reaches the server instead. + self._authorize_account() + self._run_command(['bucket', 'create', 'my-bucket', 'allPublic'], 'bucket_0\n', '', 0) + + with TempDir() as temp_dir: + local_file = self._make_local_file(temp_dir, 'file1.txt') + upload_cmd = ['file', 'upload', '--no-progress', 'my-bucket', local_file] + + with mock.patch.object( + self.raw_simulator, 'upload_file', wraps=self.raw_simulator.upload_file + ) as upload_file: + self._run_command( + [*upload_cmd, 'plain.txt'], + expected_part_of_stdout='plain.txt', + remove_version=True, + ) + self._run_command( + [ + *upload_cmd[:3], + '--destination-server-side-encryption=SSE-B2', + *upload_cmd[3:], + 'encrypted.txt', + ], + expected_part_of_stdout='encrypted.txt', + remove_version=True, + ) + + signature = inspect.signature(self.raw_simulator.upload_file) + plain_sse, encrypted_sse = ( + signature.bind(*call.args, **call.kwargs).arguments.get('server_side_encryption') + for call in upload_file.call_args_list + ) + assert plain_sse is None + assert encrypted_sse.mode.value == 'SSE-B2' + assert encrypted_sse.algorithm.value == 'AES256' + def test_files_encrypted(self): self._authorize_account() self._run_command(['bucket', 'create', 'my-bucket', 'allPublic'], 'bucket_0\n', '', 0) @@ -1899,7 +1954,21 @@ def test_upload_incremental(self): with open(file_path, 'ab') as f: f.write(b'*' * min_part_size) - self._run_command(incremental_upload_params) + with ( + mock.patch.object( + self.raw_simulator, 'copy_part', wraps=self.raw_simulator.copy_part + ) as copy_part, + mock.patch.object( + self.raw_simulator, 'upload_part', wraps=self.raw_simulator.upload_part + ) as upload_part, + ): + self._run_command(incremental_upload_params) + # the unchanged prefix is copied server-side, only the appended tail is uploaded + assert copy_part.call_count >= 1 + assert [call.kwargs['bytes_range'] for call in copy_part.call_args_list] == [ + (0, file_size - 1) + ] + assert [call.args[3] for call in upload_part.call_args_list] == [min_part_size] downloaded_path = pathlib.Path(temp_dir) / 'out.txt' self._run_command( @@ -2578,6 +2647,42 @@ def test_sync_upload_threads(self): def test_sync_many_thread_options(self): self._test_sync_threads(sync_threads=1, download_threads=1, upload_threads=1) + def _assert_sync_thread_pools(self, expected, **options): + """expected is (sync, download, upload) number of workers actually configured""" + sync_workers, download_workers, upload_workers = expected + with ( + mock.patch( + 'b2._internal.console_tool.Synchronizer', wraps=Synchronizer + ) as synchronizer, + mock.patch.object( + DownloadManager, 'set_thread_pool_size', autospec=True + ) as set_download_pool, + mock.patch.object( + UploadManager, 'set_thread_pool_size', autospec=True + ) as set_upload_pool, + ): + self._test_sync_threads(**options) + assert synchronizer.call_args.args[0] == sync_workers + assert set_download_pool.call_args.args[1] == download_workers + assert set_upload_pool.call_args.args[1] == upload_workers + + def test_sync_threads_reaches_all_pools(self): + self._assert_sync_thread_pools((3, 3, 3), threads=3) + + def test_sync_sync_threads_reaches_synchronizer_only(self): + self._assert_sync_thread_pools((2, 10, 10), sync_threads=2) + + def test_sync_download_threads_reaches_download_pool_only(self): + self._assert_sync_thread_pools((10, 4, 10), download_threads=4) + + def test_sync_upload_threads_reaches_upload_pool_only(self): + self._assert_sync_thread_pools((10, 10, 5), upload_threads=5) + + def test_sync_many_thread_options_reach_their_pools(self): + self._assert_sync_thread_pools( + (2, 4, 5), sync_threads=2, download_threads=4, upload_threads=5 + ) + def test_sync_threads_and_upload_threads(self): # Using --threads is exclusive with other options with self.assertRaises(ValueError): @@ -2945,7 +3050,9 @@ def test_passing_api_parameters(self): ) args = [str(val) for val in chain.from_iterable(params.items()) if val] - console_tool.run_command(command + args) + # the file ids do not exist; only the settings applied to the api are checked below + with contextlib.suppress(KeyError): + console_tool.run_command(command + args) download_manager = console_tool.api.services.download_manager assert download_manager.write_buffer_size == params['--write-buffer-size'] @@ -2978,7 +3085,8 @@ def test_passing_api_parameters_with_auth_env_vars(self): mock.MagicMock(), mock.MagicMock(), ) - console_tool.run_command(command) + with contextlib.suppress(KeyError): # the file id does not exist + console_tool.run_command(command) download_manager = console_tool.api.services.download_manager assert download_manager.write_buffer_size == 123 @@ -3571,11 +3679,20 @@ def test_rm_queue_size_and_number_of_threads(self): self._run_command(['ls', '--recursive', *self.b2_uri_args('my-bucket')], '') def test_rm_progress(self): - expected_in_stdout = ' count: 4/4 ' - self._run_command( - ['rm', '--recursive', '--with-wildcard', *self.b2_uri_args('my-bucket', '*.csv')], - expected_part_of_stdout=expected_in_stdout, + _, stdout, _ = self._run_command( + [ + 'rm', + '--recursive', + '--with-wildcard', + '--threads', + '1', + *self.b2_uri_args('my-bucket', '*.csv'), + ], + expected_part_of_stdout=' count: 4/4 ', ) + # progress is reported after every removed file, not only once at the end + for done in range(1, 5): + assert f' count: {done}/' in stdout, f'no progress report for {done} removed files' expected_stdout = """ a/test.tsv diff --git a/test/unit/test_sdkharness_conformance.py b/test/unit/test_sdkharness_conformance.py index 7e39de64b..673f3a5ff 100644 --- a/test/unit/test_sdkharness_conformance.py +++ b/test/unit/test_sdkharness_conformance.py @@ -117,6 +117,54 @@ def fake_run(step: str, command: list[str], environment: dict[str, str]): assert calls == ['authenticate', 'upload', 'metadata', 'download', 'cleanup'] +UPLOAD_PAYLOAD = (b'sdkharness-files-upload-' * 45)[:1024] +UPLOAD_PAYLOAD_SHA1 = '58bf8c78eae9c113e67cbeb7933824cb6cf2073e' # literal on purpose: not derived from the checker's own formula + + +@pytest.mark.parametrize( + ('defect', 'failed_step'), + [ + ('wrong size', 'metadata'), + ('wrong sha1', 'metadata'), + ('wrong downloaded bytes', 'round trip'), + ], +) +def test_upload_contract_fails_on_metadata_or_round_trip_mismatch(tmp_path, defect, failed_step): + check = load_check() + object_name = 'sdkharness-conformance/fixed.bin' + calls: list[str] = [] + + def fake_run(step: str, command: list[str], _environment: dict[str, str]): + calls.append(step) + if step == 'metadata': + return json.dumps( + [ + { + 'fileId': '4_zfixture', + 'fileName': object_name, + 'size': 1023 if defect == 'wrong size' else 1024, + 'contentSha1': '0' * 40 if defect == 'wrong sha1' else UPLOAD_PAYLOAD_SHA1, + } + ] + ) + if step == 'download': + body = b'x' * 1024 if defect == 'wrong downloaded bytes' else UPLOAD_PAYLOAD + Path(command[-1]).write_bytes(body) + return '' + + with pytest.raises(check.CheckFailure) as raised: + check.run_check( + simulator_environment(), + cli_prefix=['repository-b2'], + run_command=fake_run, + object_name=object_name, + scratch_root=tmp_path, + ) + + assert raised.value.step == failed_step + assert calls[-1] == 'cleanup' + + def test_failure_still_attempts_cleanup(tmp_path): check = load_check() calls: list[str] = [] diff --git a/test/unit/test_sdkharness_file_lifecycle.py b/test/unit/test_sdkharness_file_lifecycle.py index f9d2f66c4..c708217f7 100644 --- a/test/unit/test_sdkharness_file_lifecycle.py +++ b/test/unit/test_sdkharness_file_lifecycle.py @@ -297,6 +297,76 @@ def test_files_hide_fails_when_the_hide_marker_is_not_the_newest_version(tmp_pat assert 'newest version reports action' in caught.value.detail +class FakeVersionsCli: + """Just enough CLI to run files.delete_version: two uploads, version listing, rm, download by id.""" + + def __init__(self, delete_wrong_version: bool = False, corrupt_survivor: bool = False) -> None: + self.delete_wrong_version = delete_wrong_version + self.corrupt_survivor = corrupt_survivor + self.versions: list[dict] = [] + self.payloads: dict[str, bytes] = {} + + def __call__(self, step: str, command: list[str], _environment: dict[str, str]) -> str: + import hashlib + + args = command[1:] + if args[:2] == ['file', 'upload']: + payload = Path(args[-2]).read_bytes() + file_id = f'id-{len(self.versions) + 1}' + self.payloads[file_id] = payload + self.versions.append( + { + 'fileId': file_id, + 'fileName': args[-1], + 'action': 'upload', + 'size': len(payload), + 'contentSha1': hashlib.sha1(payload).hexdigest(), + 'uploadTimestamp': 100 + len(self.versions), + } + ) + elif args[0] == 'rm': + target = args[-1].removeprefix('b2id://') + if self.delete_wrong_version: + target = 'id-1' if target == 'id-2' else 'id-2' + self.versions = [item for item in self.versions if item['fileId'] != target] + elif args[:2] == ['file', 'download']: + payload = self.payloads[args[-2].removeprefix('b2id://')] + Path(args[-1]).write_bytes(b'corrupt' if self.corrupt_survivor else payload) + elif args[0] == 'ls' and '--versions' in args: + return json.dumps(self.versions) + return '' + + +def run_delete_version(check, tmp_path, cli): + check.run_check( + simulator_environment('files.delete_version'), + cli_prefix=['repository-b2'], + run_command=cli, + object_prefix='sdkharness-conformance/fixed', + scratch_root=tmp_path, + ) + + +def test_files_delete_version_passes_when_only_the_newer_version_is_removed(tmp_path): + run_delete_version(load_check(), tmp_path, FakeVersionsCli()) + + +def test_files_delete_version_fails_when_the_wrong_version_is_removed(tmp_path): + check = load_check() + with pytest.raises(check.CheckFailure) as caught: + run_delete_version(check, tmp_path, FakeVersionsCli(delete_wrong_version=True)) + assert caught.value.step == 'delete version' + assert caught.value.detail == 'the wrong version remained after deletion' + + +def test_files_delete_version_fails_when_the_survivor_bytes_changed(tmp_path): + check = load_check() + with pytest.raises(check.CheckFailure) as caught: + run_delete_version(check, tmp_path, FakeVersionsCli(corrupt_survivor=True)) + assert caught.value.step == 'delete version' + assert caught.value.detail == 'surviving version bytes changed' + + def forbidden_run(step, _command, _environment): # pragma: no cover - must not run raise AssertionError(f'a non-simulator credential reached the CLI at {step!r}') diff --git a/test/unit/test_sdkharness_health.py b/test/unit/test_sdkharness_health.py index 59612f93b..2e1af25e3 100644 --- a/test/unit/test_sdkharness_health.py +++ b/test/unit/test_sdkharness_health.py @@ -131,6 +131,44 @@ def fake_run(step: str, command: list[str], environment: dict[str, str]): assert all(command[0] == 'repository-b2' for _, command in calls) +@pytest.mark.parametrize( + ('defect', 'failed_step'), + [ + ('wrong download bytes', 'verify bytes'), + ('missing from listing', 'list'), + ('still listed after delete', 'confirm gone'), + ], +) +def test_golden_path_fails_when_the_service_misbehaves(tmp_path, defect, failed_step): + health = load_health_check() + object_name = 'sdkharness-health-check/fixed.txt' + calls: list[str] = [] + + def fake_run(step: str, command: list[str], _environment: dict[str, str]): + calls.append(step) + if step == 'download': + payload = b'not what was uploaded' if defect == 'wrong download bytes' else None + Path(command[-1]).write_bytes(payload or health.payload_for(object_name)) + if step == 'list': + return '' if defect == 'missing from listing' else object_name + if step == 'confirm gone': + return object_name if defect == 'still listed after delete' else '' + return '' + + with pytest.raises(health.CheckFailure) as raised: + health.run_health( + simulator_environment(), + cli_prefix=['repository-b2'], + run_command=fake_run, + object_name=object_name, + scratch_root=tmp_path, + ) + + assert raised.value.step == failed_step + if failed_step != 'confirm gone': + assert calls[-1] == 'cleanup' + + def test_golden_path_attempts_cleanup_after_a_failure(tmp_path): health = load_health_check() calls: list[str] = []