diff --git a/.sdkharness/tests/conformance/lock.bypass_governance b/.sdkharness/tests/conformance/lock.bypass_governance index 257777a9..570f778b 100755 --- a/.sdkharness/tests/conformance/lock.bypass_governance +++ b/.sdkharness/tests/conformance/lock.bypass_governance @@ -18,6 +18,16 @@ delete is refused: 401 access_denied, "The delete was not allowed because Object Lock is enabled on the file." https://www.backblaze.com/apidocs/b2-delete-file-version +401 access_denied is accepted as the refusal. B2's raw-API contract test for this +exact call (test/integration/test_raw_api.py: `with pytest.raises(Unauthorized): +raw_api.delete_file_version(...)` on a governance-locked version, then the +bypassing delete succeeds) passes against real B2 with a 401, and b2sdk raises +Unauthorized (code access_denied) for it; the simulator's default profile answers +the same. A refusal for an unrelated reason is still a failure: an +InvalidAuthToken (bad or expired token), any other Unauthorized code, a 5xx or a +network error does not count. The exact error code and message are not verified +against real B2 (UNVERIFIED in the simulator ledger, docs/FIDELITY.md). + Written from bin/conformance/b2-sdk-python/files.upload, the reference check, and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository checkout; the API was taken from @@ -119,11 +129,15 @@ def step(name, action): raise Failure(name, name_of) from error -def refused(name, action, expected): +def refused(name, action, expected, *, access_denied_401=False): """Assert something is REFUSED, and record what refused it. `expected` is a tuple of exception class names. A different class -- or no exception at all -- is a Failure, because the refusal IS the claim here. + With `access_denied_401`, b2sdk's Unauthorized is also accepted, but only + when its code is `access_denied` (a 401 about Object Lock, as B2 answers a + delete without bypassGovernance); InvalidAuthToken and every other + Unauthorized code stay failures. """ try: action() @@ -133,6 +147,12 @@ def refused(name, action, expected): name_of = type(error).__name__ if name_of in expected: return name_of + if ( + access_denied_401 + and name_of == 'Unauthorized' + and getattr(error, 'code', None) == 'access_denied' + ): + return 'Unauthorized(access_denied)' raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error raise Failure(name, 'the call was permitted; the claim under test is that it is refused') @@ -269,6 +289,7 @@ def run(target: str) -> None: 'delete without bypass', lambda: bucket.delete_file_version(uploaded.id_, OBJECT_NAME), ('AccessDenied', 'RetentionWriteError', 'Conflict', 'BadRequest'), + access_denied_401=True, ) step( diff --git a/.sdkharness/tests/conformance/lock.per_file_retention b/.sdkharness/tests/conformance/lock.per_file_retention index d310bbd6..93f28b82 100755 --- a/.sdkharness/tests/conformance/lock.per_file_retention +++ b/.sdkharness/tests/conformance/lock.per_file_retention @@ -20,6 +20,16 @@ and bypassGovernance "Must be specified and set to true if deleting a file version protected by Object Lock governance mode retention settings." https://www.backblaze.com/apidocs/b2-delete-file-version +401 access_denied is accepted as the refusal. B2's raw-API contract test for this +exact call (test/integration/test_raw_api.py: `with pytest.raises(Unauthorized): +raw_api.delete_file_version(...)` on a governance-locked version, then the +bypassing delete succeeds) passes against real B2 with a 401, and b2sdk raises +Unauthorized (code access_denied) for it; the simulator's default profile answers +the same. A refusal for an unrelated reason is still a failure: an +InvalidAuthToken (bad or expired token), any other Unauthorized code, a 5xx or a +network error does not count. The exact error code and message are not verified +against real B2 (UNVERIFIED in the simulator ledger, docs/FIDELITY.md). + Written from bin/conformance/b2-sdk-python/files.upload, the reference check, and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository checkout; the API was taken from @@ -121,11 +131,15 @@ def step(name, action): raise Failure(name, name_of) from error -def refused(name, action, expected): +def refused(name, action, expected, *, access_denied_401=False): """Assert something is REFUSED, and record what refused it. `expected` is a tuple of exception class names. A different class -- or no exception at all -- is a Failure, because the refusal IS the claim here. + With `access_denied_401`, b2sdk's Unauthorized is also accepted, but only + when its code is `access_denied` (a 401 about Object Lock, as B2 answers a + delete without bypassGovernance); InvalidAuthToken and every other + Unauthorized code stay failures. """ try: action() @@ -135,6 +149,12 @@ def refused(name, action, expected): name_of = type(error).__name__ if name_of in expected: return name_of + if ( + access_denied_401 + and name_of == 'Unauthorized' + and getattr(error, 'code', None) == 'access_denied' + ): + return 'Unauthorized(access_denied)' raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error raise Failure(name, 'the call was permitted; the claim under test is that it is refused') @@ -278,6 +298,7 @@ def run(target: str) -> None: 'delete without bypass', lambda: bucket.delete_file_version(uploaded.id_, OBJECT_NAME), ('AccessDenied', 'RetentionWriteError', 'Conflict', 'BadRequest'), + access_denied_401=True, ) finally: teardown(api, bucket) diff --git a/changelog.d/+sdkharness-lock-refusal.infrastructure.md b/changelog.d/+sdkharness-lock-refusal.infrastructure.md new file mode 100644 index 00000000..b30c9f91 --- /dev/null +++ b/changelog.d/+sdkharness-lock-refusal.infrastructure.md @@ -0,0 +1 @@ +Repository-owned conformance checks `lock.bypass_governance` and `lock.per_file_retention` accept the 401 `access_denied` that B2 answers for a delete of a governance-locked version without bypass, and still fail if the delete is allowed or refused for an unrelated reason. diff --git a/test/unit/test_sdkharness_lock_refusal.py b/test/unit/test_sdkharness_lock_refusal.py new file mode 100644 index 00000000..5c6ebf45 --- /dev/null +++ b/test/unit/test_sdkharness_lock_refusal.py @@ -0,0 +1,113 @@ +###################################################################### +# +# File: test/unit/test_sdkharness_lock_refusal.py +# +# Copyright 2026 Backblaze Inc. All Rights Reserved. +# +# License https://www.backblaze.com/using_b2_code.html +# +###################################################################### +from __future__ import annotations + +from importlib.machinery import SourceFileLoader +from importlib.util import module_from_spec, spec_from_loader +from pathlib import Path + +import pytest + +CONFORMANCE = Path(__file__).parents[2] / '.sdkharness' / 'tests' / 'conformance' +LEAVES = ('lock.bypass_governance', 'lock.per_file_retention') +EXPECTED = ('AccessDenied', 'RetentionWriteError', 'Conflict', 'BadRequest') + + +def load_leaf(name): + loader = SourceFileLoader(f'sdkharness_leaf_{name.replace(".", "_")}', str(CONFORMANCE / name)) + spec = spec_from_loader(loader.name, loader) + module = module_from_spec(spec) + loader.exec_module(module) + return module + + +# `refused` classifies by class name, as the leaves do, so these stand-ins carry +# b2sdk's names. Unauthorized carries the `code` b2sdk reads from the response. +class Unauthorized(Exception): + def __init__(self, code): + super().__init__(code) + self.code = code + + +class InvalidAuthToken(Unauthorized): + pass + + +class ServiceError(Exception): + pass + + +def raising(error): + def action(): + raise error + + return action + + +@pytest.mark.parametrize('leaf', LEAVES) +def test_401_access_denied_counts_as_the_refusal(leaf): + module = load_leaf(leaf) + got = module.refused( + 'delete without bypass', + raising(Unauthorized('access_denied')), + EXPECTED, + access_denied_401=True, + ) + assert got == 'Unauthorized(access_denied)' + + +@pytest.mark.parametrize('leaf', LEAVES) +def test_the_old_refusal_classes_are_still_accepted(leaf): + module = load_leaf(leaf) + AccessDenied = type('AccessDenied', (Exception,), {}) + assert ( + module.refused( + 'delete without bypass', raising(AccessDenied()), EXPECTED, access_denied_401=True + ) + == 'AccessDenied' + ) + + +@pytest.mark.parametrize('leaf', LEAVES) +def test_an_allowed_operation_still_fails(leaf): + module = load_leaf(leaf) + with pytest.raises(module.Failure, match='permitted'): + module.refused('delete without bypass', lambda: None, EXPECTED, access_denied_401=True) + + +@pytest.mark.parametrize('leaf', LEAVES) +@pytest.mark.parametrize( + 'error', + [ + InvalidAuthToken('expired_auth_token'), + Unauthorized('bad_auth_token'), + Unauthorized('email_not_verified'), + ServiceError('503'), + KeyError('404'), + ], + ids=['bad-token', 'other-401-code', 'unverified-email', '5xx', 'not-found'], +) +def test_an_unrelated_refusal_still_fails(leaf, error): + module = load_leaf(leaf) + with pytest.raises(module.Failure, match='refused with'): + module.refused('delete without bypass', raising(error), EXPECTED, access_denied_401=True) + + +@pytest.mark.parametrize('leaf', LEAVES) +def test_unauthorized_is_not_accepted_unless_asked(leaf): + module = load_leaf(leaf) + with pytest.raises(module.Failure): + module.refused('delete without bypass', raising(Unauthorized('access_denied')), EXPECTED) + + +@pytest.mark.parametrize('leaf', LEAVES) +def test_the_leaf_asks_for_the_401_on_its_delete_without_bypass(leaf): + source = (CONFORMANCE / leaf).read_text() + assert 'access_denied_401=True' in source