Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion .sdkharness/tests/conformance/lock.bypass_governance
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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')

Expand Down Expand Up @@ -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(
Expand Down
23 changes: 22 additions & 1 deletion .sdkharness/tests/conformance/lock.per_file_retention
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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')

Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions changelog.d/+sdkharness-lock-refusal.infrastructure.md
Original file line number Diff line number Diff line change
@@ -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.
113 changes: 113 additions & 0 deletions test/unit/test_sdkharness_lock_refusal.py
Original file line number Diff line number Diff line change
@@ -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
Loading