diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py index dc9db3bd8983c5bb6d2d9cdc0edad1506846f12a..77436a8bdebbbcd1c2b79631f8f614bdcf023009 100644 --- a/src/_zkapauthorizer/controller.py +++ b/src/_zkapauthorizer/controller.py @@ -41,6 +41,10 @@ from base64 import ( b64encode, b64decode, ) +from hashlib import ( + sha256, +) + import attr from zope.interface import ( @@ -133,7 +137,7 @@ class IRedeemer(Interface): """ An ``IRedeemer`` can exchange a voucher for one or more passes. """ - def random_tokens_for_voucher(voucher, count): + def random_tokens_for_voucher(voucher, counter, count): """ Generate a number of random tokens to use in the redemption process for the given voucher. @@ -141,6 +145,8 @@ class IRedeemer(Interface): :param Voucher voucher: The voucher the tokens will be associated with. + :param int counter: See ``redeemWithCounter``. + :param int count: The number of random tokens to generate. :return list[RandomToken]: The generated tokens. Random tokens must @@ -199,6 +205,34 @@ class IRedeemer(Interface): """ +@attr.s +@implementer(IRedeemer) +class IndexedRedeemer(object): + """ + A ``IndexedRedeemer`` delegates redemption to a redeemer chosen to + correspond to the redemption counter given. + """ + _log = Logger() + + redeemers = attr.ib() + + def random_tokens_for_voucher(self, voucher, counter, count): + return dummy_random_tokens(voucher, counter, count) + + def redeemWithCounter(self, voucher, counter, random_tokens): + self._log.info( + "IndexedRedeemer redeeming {voucher}[{counter}] using {delegate}.", + voucher=voucher, + counter=counter, + delegate=self.redeemers[counter], + ) + return self.redeemers[counter].redeemWithCounter( + voucher, + counter, + random_tokens, + ) + + @implementer(IRedeemer) class NonRedeemer(object): """ @@ -208,8 +242,8 @@ class NonRedeemer(object): def make(cls, section_name, node_config, announcement, reactor): return cls() - def random_tokens_for_voucher(self, voucher, count): - return dummy_random_tokens(voucher, count) + def random_tokens_for_voucher(self, voucher, counter, count): + return dummy_random_tokens(voucher, counter, count) def redeemWithCounter(self, voucher, counter, random_tokens): # Don't try to redeem them. @@ -238,8 +272,8 @@ class ErrorRedeemer(object): ).decode("ascii") return cls(details) - def random_tokens_for_voucher(self, voucher, count): - return dummy_random_tokens(voucher, count) + def random_tokens_for_voucher(self, voucher, counter, count): + return dummy_random_tokens(voucher, counter, count) def redeemWithCounter(self, voucher, counter, random_tokens): return fail(Exception(self.details)) @@ -261,8 +295,8 @@ class DoubleSpendRedeemer(object): def make(cls, section_name, node_config, announcement, reactor): return cls() - def random_tokens_for_voucher(self, voucher, count): - return dummy_random_tokens(voucher, count) + def random_tokens_for_voucher(self, voucher, counter, count): + return dummy_random_tokens(voucher, counter, count) def redeemWithCounter(self, voucher, counter, random_tokens): return fail(AlreadySpent(voucher)) @@ -279,21 +313,39 @@ class UnpaidRedeemer(object): def make(cls, section_name, node_config, announcement, reactor): return cls() - def random_tokens_for_voucher(self, voucher, count): - return dummy_random_tokens(voucher, count) + def random_tokens_for_voucher(self, voucher, counter, count): + return dummy_random_tokens(voucher, counter, count) def redeemWithCounter(self, voucher, counter, random_tokens): return fail(Unpaid(voucher)) -def dummy_random_tokens(voucher, count): +@implementer(IRedeemer) +@attr.s +class RecordingRedeemer(object): + """ + A ``CountingRedeemer`` delegates redemption logic to another object but + records all redemption attempts. + """ + original = attr.ib() + redemptions = attr.ib(default=attr.Factory(list)) + + def random_tokens_for_voucher(self, voucher, counter, count): + return dummy_random_tokens(voucher, counter, count) + + def redeemWithCounter(self, voucher, counter, random_tokens): + self.redemptions.append((voucher, counter, random_tokens)) + return self.original.redeemWithCounter(voucher, counter, random_tokens) + + +def dummy_random_tokens(voucher, counter, count): v = urlsafe_b64decode(voucher.number.encode("ascii")) def dummy_random_token(n): return RandomToken( # Padding is 96 (random token length) - 32 (decoded voucher - # length) + # length) - 4 (fixed-width counter) b64encode( - v + u"{:0>64}".format(n).encode("ascii"), + v + u"{:0>4}{:0>60}".format(counter, n).encode("ascii"), ).decode("ascii"), ) return list( @@ -317,12 +369,12 @@ class DummyRedeemer(object): def make(cls, section_name, node_config, announcement, reactor): return cls() - def random_tokens_for_voucher(self, voucher, count): + def random_tokens_for_voucher(self, voucher, counter, count): """ Generate some number of random tokens to submit along with a voucher for redemption. """ - return dummy_random_tokens(voucher, count) + return dummy_random_tokens(voucher, counter, count) def redeemWithCounter(self, voucher, counter, random_tokens): """ @@ -352,11 +404,16 @@ class DummyRedeemer(object): def tokens_to_passes(self, message, unblinded_tokens): def token_to_pass(token): - # Smear the unblinded token value across the two new values we - # need. - bs = b64decode(token.unblinded_token.encode("ascii")) - preimage = bs[:48] + b"x" * 16 - signature = bs[48:] + b"y" * 16 + # Generate distinct strings based on the unblinded token which we + # can include in the resulting Pass. This ensures the pass values + # will be unique if and only if the unblinded tokens were unique + # (barring improbable hash collisions). + token_digest = sha256( + token.unblinded_token.encode("ascii") + ).hexdigest().encode("ascii") + + preimage = b"preimage-" + token_digest[len(b"preimage-"):] + signature = b"signature-" + token_digest[len(b"signature-"):] return Pass( b64encode(preimage).decode("ascii"), b64encode(signature).decode("ascii"), @@ -437,7 +494,7 @@ class RistrettoRedeemer(object): URL.from_text(configured_issuer), ) - def random_tokens_for_voucher(self, voucher, count): + def random_tokens_for_voucher(self, voucher, counter, count): return list( RandomToken( challenge_bypass_ristretto.RandomToken.create().encode_base64().decode("ascii"), @@ -561,6 +618,41 @@ class RistrettoRedeemer(object): return passes +def token_count_for_group(num_groups, total_tokens, group_number): + """ + Determine a number of tokens to retrieve for a particular group out of an + overall redemption attempt. + + :param int num_groups: The total number of groups the tokens will be + divided into. + + :param int total_tokens: The total number of tokens to divide up. + + :param int group_number: The particular group for which to determine a + token count. + + :return int: A number of tokens to redeem in this group. + """ + if total_tokens < num_groups: + raise ValueError( + "Cannot distribute {} tokens among {} groups coherently.".format( + total_tokens, + num_groups, + ), + ) + if group_number >= num_groups or group_number < 0: + raise ValueError( + "Group number {} is out of valid range [0..{})".format( + group_number, + num_groups, + ), + ) + group_size, remainder = divmod(total_tokens, num_groups) + if group_number < remainder: + return group_size + 1 + return group_size + + @attr.s class PaymentController(object): """ @@ -598,6 +690,15 @@ class PaymentController(object): which have recently failed a redemption attempt due to an unpaid response from the redemption server to timestamps when the failure was observed. + + :ivar int num_redemption_groups: The number of groups into which to divide + tokens during the redemption process, with each group being redeemed + separately from the rest. This value needs to agree with the value + the PaymentServer is configured with. + + TODO: Retrieve this value from the PaymentServer or from the + ZKAPAuthorizer configuration instead of just hard-coding a duplicate + value in this implementation. """ _log = Logger() @@ -605,6 +706,8 @@ class PaymentController(object): redeemer = attr.ib() default_token_count = attr.ib() + num_redemption_groups = attr.ib(default=16) + _clock = attr.ib( default=attr.Factory(partial(namedAny, "twisted.internet.reactor")), ) @@ -663,12 +766,89 @@ class PaymentController(object): voucher=voucher.number, ) + def _get_random_tokens_for_voucher(self, voucher, counter, num_tokens): + """ + Generate or load random tokens for a redemption attempt of a voucher. + """ + def get_tokens(): + self._log.info( + "Generating random tokens for a voucher ({voucher}).", + voucher=voucher, + ) + return self.redeemer.random_tokens_for_voucher( + Voucher(voucher), + counter, + num_tokens, + ) + + return self.store.add(voucher, counter, get_tokens) + + @inlineCallbacks + def redeem(self, voucher, num_tokens=None): + """ + :param unicode voucher: A voucher to redeem. + + :param int num_tokens: A number of tokens to redeem. + """ + if num_tokens is None: + num_tokens = self.default_token_count + + # Try to get an existing voucher object for the given number. + try: + voucher_obj = self.store.get(voucher) + except KeyError: + # This is our first time dealing with this number. + counter_start = 0 + else: + # Determine the starting point from the state. + if voucher_obj.state.should_start_redemption(): + counter_start = voucher_obj.state.counter + else: + raise ValueError( + "Cannot redeem voucher in state {}.".format( + voucher_obj.state, + ), + ) + + self._log.info( + "Starting redemption of {voucher}[{start}..{end}]", + voucher=voucher, + start=counter_start, + end=self.num_redemption_groups, + ) + for counter in range(counter_start, self.num_redemption_groups): + # Pre-generate the random tokens to use when redeeming the voucher. + # These are persisted with the voucher so the redemption can be made + # idempotent. We don't want to lose the value if we fail after the + # server deems the voucher redeemed but before we persist the result. + # With a stable set of tokens, we can re-submit them and the server + # can re-sign them without fear of issuing excess passes. Whether the + # server signs a given set of random tokens once or many times, the + # number of passes that can be constructed is still only the size of + # the set of random tokens. + token_count = token_count_for_group(self.num_redemption_groups, num_tokens, counter) + tokens = self._get_random_tokens_for_voucher(voucher, counter, token_count) + + # Reload state before each iteration. We expect it to change each time. + voucher_obj = self.store.get(voucher) + + succeeded = yield self._perform_redeem(voucher_obj, counter, tokens) + if not succeeded: + self._log.info( + "Temporarily suspending redemption of {voucher} after non-success result.", + voucher=voucher, + ) + break + def _perform_redeem(self, voucher, counter, random_tokens): """ Use the redeemer to redeem the given voucher and random tokens. This will not persist the voucher or random tokens but it will persist the result. + + :return Deferred[bool]: A ``Deferred`` firing with ``True`` if and + only if redemption succeeds. """ if not isinstance(voucher.state, model_Pending): raise ValueError( @@ -692,50 +872,13 @@ class PaymentController(object): lambda: self.redeemer.redeemWithCounter(voucher, counter, random_tokens), ) d.addCallbacks( - partial(self._redeemSuccess, voucher.number), - partial(self._redeemFailure, voucher.number), + partial(self._redeem_success, voucher.number, counter), + partial(self._redeem_failure, voucher.number), ) - d.addErrback(partial(self._finalRedeemError, voucher.number)) + d.addErrback(partial(self._final_redeem_error, voucher.number)) return d - def _get_random_tokens_for_voucher(self, voucher, num_tokens): - """ - Generate or load random tokens for a redemption attempt of a voucher. - """ - def get_tokens(): - self._log.info("Generating random tokens for a voucher ({voucher}).", voucher=voucher) - return self.redeemer.random_tokens_for_voucher(Voucher(voucher), num_tokens) - - return self.store.add(voucher, get_tokens) - - def redeem(self, voucher, num_tokens=None): - """ - :param unicode voucher: A voucher to redeem. - - :param int num_tokens: A number of tokens to redeem. - """ - # Pre-generate the random tokens to use when redeeming the voucher. - # These are persisted with the voucher so the redemption can be made - # idempotent. We don't want to lose the value if we fail after the - # server deems the voucher redeemed but before we persist the result. - # With a stable set of tokens, we can re-submit them and the server - # can re-sign them without fear of issuing excess passes. Whether the - # server signs a given set of random tokens once or many times, the - # number of passes that can be constructed is still only the size of - # the set of random tokens. - if num_tokens is None: - num_tokens = self.default_token_count - tokens = self._get_random_tokens_for_voucher(voucher, num_tokens) - # TODO: Actually count up from the voucher's current counter value to - # maxCounter instead of only passing 0 here. Starting at 0 is fine - # for a new voucher but if we partially redeemed a voucher on a - # previous run and this call comes from `_check_pending_vouchers` then - # we should skip any already-redeemed counter values. - # - # https://github.com/PrivateStorageio/ZKAPAuthorizer/issues/124 - return self._perform_redeem(self.store.get(voucher), 0, tokens) - - def _redeemSuccess(self, voucher, result): + def _redeem_success(self, voucher, counter, result): """ Update the database state to reflect that a voucher was redeemed and to store the resulting unblinded tokens (which can be used to construct @@ -749,9 +892,11 @@ class PaymentController(object): voucher, result.public_key, result.unblinded_tokens, + completed=(counter + 1 == self.num_redemption_groups), ) + return True - def _redeemFailure(self, voucher, reason): + def _redeem_failure(self, voucher, reason): if reason.check(AlreadySpent): self._log.error( "Voucher {voucher} reported as already spent during redemption.", @@ -774,11 +919,15 @@ class PaymentController(object): finished=self.store.now(), details=reason.getErrorMessage().decode("utf-8", "replace"), ) - return None + return False - def _finalRedeemError(self, voucher, reason): - self._log.failure("Redeeming random tokens for a voucher ({voucher}) encountered error.", reason, voucher=voucher) - return None + def _final_redeem_error(self, voucher, reason): + self._log.failure( + "Redeeming random tokens for a voucher ({voucher}) encountered error.", + reason, + voucher=voucher, + ) + return False def get_voucher(self, number): return self.incorporate_transient_state( diff --git a/src/_zkapauthorizer/model.py b/src/_zkapauthorizer/model.py index 5e391c52c8575f4ecac9a7af0828cba811a22eef..baff206c28c14931f9d403d47b4938cccb756b10 100644 --- a/src/_zkapauthorizer/model.py +++ b/src/_zkapauthorizer/model.py @@ -232,12 +232,17 @@ class VoucherStore(object): return Voucher.from_row(refs[0]) @with_cursor - def add(self, cursor, voucher, get_tokens): + def add(self, cursor, voucher, counter, get_tokens): """ - Add a new voucher and associated random tokens to the database. If a - voucher with the given text value is already present, do nothing. + Add random tokens associated with a voucher (possibly new, possibly + existing) to the database. If the (voucher, counter) pair is already + present, do nothing. - :param unicode voucher: The text value of a voucher to add. + :param unicode voucher: The text value of a voucher with which to + associate the tokens. + + :param int counter: The redemption counter for the given voucher with + which to associate the tokens. :param list[RandomToken]: The tokens to add alongside the voucher. """ @@ -250,16 +255,17 @@ class VoucherStore(object): """ SELECT ([text]) FROM [tokens] - WHERE [voucher] = ? + WHERE [voucher] = ? AND [counter] = ? """, - (voucher,), + (voucher, counter), ) rows = cursor.fetchall() if len(rows) > 0: self._log.info( - "Loaded {count} random tokens for a voucher ({voucher}).", + "Loaded {count} random tokens for a voucher ({voucher}[{counter}]).", count=len(rows), voucher=voucher, + counter=counter, ) tokens = list( RandomToken(token_value) @@ -269,9 +275,10 @@ class VoucherStore(object): else: tokens = get_tokens() self._log.info( - "Persisting {count} random tokens for a voucher ({voucher}).", + "Persisting {count} random tokens for a voucher ({voucher}[{counter}]).", count=len(tokens), voucher=voucher, + counter=counter, ) cursor.execute( """ @@ -281,10 +288,10 @@ class VoucherStore(object): ) cursor.executemany( """ - INSERT INTO [tokens] ([voucher], [text]) VALUES (?, ?) + INSERT INTO [tokens] ([voucher], [counter], [text]) VALUES (?, ?, ?) """, list( - (voucher, token.token_value) + (voucher, counter, token.token_value) for token in tokens ), @@ -341,7 +348,7 @@ class VoucherStore(object): self._insert_unblinded_tokens(cursor, unblinded_tokens) @with_cursor - def insert_unblinded_tokens_for_voucher(self, cursor, voucher, public_key, unblinded_tokens): + def insert_unblinded_tokens_for_voucher(self, cursor, voucher, public_key, unblinded_tokens, completed): """ Store some unblinded tokens received from redemption of a voucher. @@ -354,15 +361,23 @@ class VoucherStore(object): :param list[UnblindedToken] unblinded_tokens: The unblinded tokens to store. + + :param bool completed: ``True`` if redemption of this voucher is now + complete, ``False`` otherwise. """ - voucher_state = u"redeemed" + if completed: + voucher_state = u"redeemed" + else: + voucher_state = u"pending" + cursor.execute( """ UPDATE [vouchers] SET [state] = ? - , [token-count] = ? + , [token-count] = COALESCE([token-count], 0) + ? , [finished] = ? , [public-key] = ? + , [counter] = [counter] + 1 WHERE [number] = ? """, ( @@ -864,9 +879,7 @@ class Voucher(object): def from_row(cls, row): def state_from_row(state, row): if state == u"pending": - # TODO: The 0 here should be row[3] but I can't write a test - # to prove it yet. - return Pending(counter=0) + return Pending(counter=row[3]) if state == u"double-spend": return DoubleSpend( parse_datetime(row[0], delimiter=u" "), diff --git a/src/_zkapauthorizer/schema.py b/src/_zkapauthorizer/schema.py index e3a37eb2023cfe890fb08a7500fdd519e9335bd0..4fa33b13ae1f1001816e269bfd0b49ded10951bb 100644 --- a/src/_zkapauthorizer/schema.py +++ b/src/_zkapauthorizer/schema.py @@ -138,4 +138,12 @@ _UPGRADES = { ALTER TABLE [vouchers] ADD COLUMN [counter] integer DEFAULT 0 """, ], + + 3: [ + """ + -- Reference to the counter these tokens go with. + ALTER TABLE [tokens] ADD COLUMN [counter] integer NOT NULL DEFAULT 0 + """, + + ], } diff --git a/src/_zkapauthorizer/tests/matchers.py b/src/_zkapauthorizer/tests/matchers.py index 09afa8dad01347c3ae08536525fcfca134790728..6c7ab457c04c6971965779bb4445517decf9e933 100644 --- a/src/_zkapauthorizer/tests/matchers.py +++ b/src/_zkapauthorizer/tests/matchers.py @@ -104,19 +104,27 @@ class _Returns(Matcher): return "Returns({})".format(self.result_matcher) +def greater_or_equal(v): + """ + Matches a value greater than or equal to ``v``. + """ + return MatchesAny(GreaterThan(v), Equals(v)) + + +def lesser_or_equal(v): + """ + Matches a value less than or equal to ``v``. + """ + return MatchesAny(LessThan(v), Equals(v)) + + def between(low, high): """ Matches a value in the range [low, high]. """ return MatchesAll( - MatchesAny( - Equals(low), - GreaterThan(low), - ), - MatchesAny( - Equals(high), - LessThan(high), - ), + greater_or_equal(low), + lesser_or_equal(high), ) diff --git a/src/_zkapauthorizer/tests/strategies.py b/src/_zkapauthorizer/tests/strategies.py index 5faf8e74004357f64c009619da005465cd531515..a438fa27ae10bd937f6e30421297da297fc753f6 100644 --- a/src/_zkapauthorizer/tests/strategies.py +++ b/src/_zkapauthorizer/tests/strategies.py @@ -359,6 +359,19 @@ def voucher_counters(): ) +def redemption_group_counts(): + """ + Build integers which can represent the number of groups in the redemption + process. + """ + return integers( + min_value=1, + # Make this similar to the max_value of voucher_counters since those + # counters count through the groups. + max_value=256, + ) + + def byte_strings(label, length, entropy): """ Build byte strings of the given length with at most the given amount of diff --git a/src/_zkapauthorizer/tests/test_client_resource.py b/src/_zkapauthorizer/tests/test_client_resource.py index e2f8934e2ca7a551302cd7f751f12e6589a804c6..a6a96f9c82a4b9615a58b11a68445c094a92506a 100644 --- a/src/_zkapauthorizer/tests/test_client_resource.py +++ b/src/_zkapauthorizer/tests/test_client_resource.py @@ -154,7 +154,7 @@ from .matchers import ( ) # A small number of tokens to work with in the tests. -NUM_TOKENS = 10 +NUM_TOKENS = 100 TRANSIENT_ERROR = u"something went wrong, who knows what" @@ -295,6 +295,21 @@ class ResourceTests(TestCase): ) +def maybe_extra_tokens(): + """ + Build either ``None`` or a small integer for use in determining a number + of additional tokens to create in some tests. + """ + # We might want to have some unblinded tokens or we might not. + return one_of( + just(None), + # If we do, we can't have fewer than the number of redemption groups + # which we don't know until we're further inside the test. So supply + # an amount to add to that, in the case where we have tokens at all. + integers(min_value=0, max_value=100), + ) + + class UnblindedTokenTests(TestCase): """ Tests relating to ``/unblinded-token`` as implemented by the @@ -353,8 +368,12 @@ class UnblindedTokenTests(TestCase): )), ) - @given(tahoe_configs(), vouchers(), integers(min_value=0, max_value=100)) - def test_get(self, get_config, voucher, num_tokens): + @given( + tahoe_configs(), + vouchers(), + maybe_extra_tokens(), + ) + def test_get(self, get_config, voucher, extra_tokens): """ When the unblinded token collection receives a **GET**, the response is the total number of unblinded tokens in the system, the unblinded @@ -364,8 +383,10 @@ class UnblindedTokenTests(TestCase): tempdir = self.useFixture(TempDir()) config = get_config(tempdir.join(b"tahoe"), b"tub.port") root = root_from_config(config, datetime.now) - - if num_tokens: + if extra_tokens is None: + num_tokens = 0 + else: + num_tokens = root.controller.num_redemption_groups + extra_tokens # Put in a number of tokens with which to test. redeeming = root.controller.redeem(voucher, num_tokens) # Make sure the operation completed before proceeding. @@ -388,8 +409,8 @@ class UnblindedTokenTests(TestCase): succeeded_with_unblinded_tokens(num_tokens, num_tokens), ) - @given(tahoe_configs(), vouchers(), integers(min_value=0, max_value=100), integers(min_value=0)) - def test_get_limit(self, get_config, voucher, num_tokens, limit): + @given(tahoe_configs(), vouchers(), maybe_extra_tokens(), integers(min_value=0)) + def test_get_limit(self, get_config, voucher, extra_tokens, limit): """ When the unblinded token collection receives a **GET** with a **limit** query argument, it returns no more unblinded tokens than indicated by @@ -399,7 +420,10 @@ class UnblindedTokenTests(TestCase): config = get_config(tempdir.join(b"tahoe"), b"tub.port") root = root_from_config(config, datetime.now) - if num_tokens: + if extra_tokens is None: + num_tokens = 0 + else: + num_tokens = root.controller.num_redemption_groups + extra_tokens # Put in a number of tokens with which to test. redeeming = root.controller.redeem(voucher, num_tokens) # Make sure the operation completed before proceeding. @@ -419,11 +443,14 @@ class UnblindedTokenTests(TestCase): ) self.assertThat( requesting, - succeeded_with_unblinded_tokens(num_tokens, min(num_tokens, limit)), + succeeded_with_unblinded_tokens( + num_tokens, + min(num_tokens, limit), + ), ) - @given(tahoe_configs(), vouchers(), integers(min_value=0, max_value=100), text(max_size=64)) - def test_get_position(self, get_config, voucher, num_tokens, position): + @given(tahoe_configs(), vouchers(), maybe_extra_tokens(), text(max_size=64)) + def test_get_position(self, get_config, voucher, extra_tokens, position): """ When the unblinded token collection receives a **GET** with a **position** query argument, it returns all unblinded tokens which sort greater @@ -433,7 +460,10 @@ class UnblindedTokenTests(TestCase): config = get_config(tempdir.join(b"tahoe"), b"tub.port") root = root_from_config(config, datetime.now) - if num_tokens: + if extra_tokens is None: + num_tokens = 0 + else: + num_tokens = root.controller.num_redemption_groups + extra_tokens # Put in a number of tokens with which to test. redeeming = root.controller.redeem(voucher, num_tokens) # Make sure the operation completed before proceeding. @@ -467,8 +497,8 @@ class UnblindedTokenTests(TestCase): ), ) - @given(tahoe_configs(), vouchers(), integers(min_value=1, max_value=100)) - def test_get_order_matches_use_order(self, get_config, voucher, num_tokens): + @given(tahoe_configs(), vouchers(), integers(min_value=0, max_value=100)) + def test_get_order_matches_use_order(self, get_config, voucher, extra_tokens): """ The first unblinded token returned in a response to a **GET** request is the first token to be used to authorize a storage request. @@ -499,6 +529,8 @@ class UnblindedTokenTests(TestCase): config = get_config(tempdir.join(b"tahoe"), b"tub.port") root = root_from_config(config, datetime.now) + num_tokens = root.controller.num_redemption_groups + extra_tokens + # Put in a number of tokens with which to test. redeeming = root.controller.redeem(voucher, num_tokens) # Make sure the operation completed before proceeding. diff --git a/src/_zkapauthorizer/tests/test_controller.py b/src/_zkapauthorizer/tests/test_controller.py index 9d97ce8b75054deac17e99272ad30ddbeba4996d..77d7e90abccd49ee5fb07e9afc148689844377c5 100644 --- a/src/_zkapauthorizer/tests/test_controller.py +++ b/src/_zkapauthorizer/tests/test_controller.py @@ -18,6 +18,7 @@ Tests for ``_zkapauthorizer.controller``. from __future__ import ( absolute_import, + division, ) from json import ( @@ -41,6 +42,7 @@ from testtools.content import ( text_content, ) from testtools.matchers import ( + Always, Equals, MatchesAll, AllMatch, @@ -51,11 +53,13 @@ from testtools.matchers import ( ) from testtools.twistedsupport import ( succeeded, + has_no_result, failed, ) from hypothesis import ( given, + assume, ) from hypothesis.strategies import ( integers, @@ -104,9 +108,12 @@ from ..controller import ( DoubleSpendRedeemer, UnpaidRedeemer, RistrettoRedeemer, + IndexedRedeemer, + RecordingRedeemer, PaymentController, AlreadySpent, Unpaid, + token_count_for_group, ) from ..model import ( @@ -123,20 +130,126 @@ from .strategies import ( vouchers, voucher_objects, voucher_counters, + redemption_group_counts, dummy_ristretto_keys, clocks, ) from .matchers import ( Provides, + raises, + between, ) from .fixtures import ( TemporaryVoucherStore, ) + +class TokenCountForGroupTests(TestCase): + """ + Tests for ``token_count_for_group``. + """ + @given( + integers(), + integers(), + integers(), + ) + def test_out_of_bounds(self, num_groups, total_tokens, group_number): + """ + If there are not enough tokens so that each group gets at least one or if + the indicated group number does properly identify a group from the + range then ``ValueError`` is raised. + """ + assume( + group_number < 0 or + group_number >= num_groups or + total_tokens < num_groups + ) + self.assertThat( + lambda: token_count_for_group(num_groups, total_tokens, group_number), + raises(ValueError), + ) + + @given( + redemption_group_counts(), + integers(min_value=0), + ) + def test_sum(self, num_groups, extra_tokens): + """ + The sum of the token count for all groups equals the requested total + tokens. + """ + total_tokens = num_groups + extra_tokens + self.assertThat( + sum( + token_count_for_group(num_groups, total_tokens, group_number) + for group_number + in range(num_groups) + ), + Equals(total_tokens), + ) + + @given( + redemption_group_counts(), + integers(min_value=0), + ) + def test_well_distributed(self, num_groups, extra_tokens): + """ + Tokens are distributed roughly evenly across all group numbers. + """ + total_tokens = num_groups + extra_tokens + + lower_bound = total_tokens // num_groups + upper_bound = total_tokens // num_groups + 1 + + self.assertThat( + list( + token_count_for_group(num_groups, total_tokens, group_number) + for group_number + in range(num_groups) + ), + AllMatch(between(lower_bound, upper_bound)), + ) + + class PaymentControllerTests(TestCase): """ Tests for ``PaymentController``. """ + @given(tahoe_configs(), datetimes(), vouchers()) + def test_should_not_redeem(self, get_config, now, voucher): + """ + ``PaymentController.redeem`` raises ``ValueError`` if passed a voucher in + a state when redemption should not be started. + """ + store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store + controller = PaymentController( + store, + DummyRedeemer(), + default_token_count=100, + ) + + self.assertThat( + controller.redeem(voucher), + succeeded(Always()), + ) + + # Sanity check. It should be redeemed now. + voucher_obj = controller.get_voucher(voucher) + self.assertThat( + voucher_obj.state.should_start_redemption(), + Equals(False), + ) + + self.assertThat( + controller.redeem(voucher), + failed( + AfterPreprocessing( + lambda f: f.type, + Equals(ValueError), + ), + ), + ) + @given(tahoe_configs(), datetimes(), vouchers()) def test_not_redeemed_while_redeeming(self, get_config, now, voucher): """ @@ -147,9 +260,12 @@ class PaymentControllerTests(TestCase): controller = PaymentController( store, NonRedeemer(), - default_token_count=10, + default_token_count=100, + ) + self.assertThat( + controller.redeem(voucher), + has_no_result(), ) - controller.redeem(voucher) persisted_voucher = store.get(voucher) self.assertThat( @@ -157,29 +273,157 @@ class PaymentControllerTests(TestCase): Equals(model_Pending(counter=0)), ) - @given(tahoe_configs(), datetimes(), vouchers()) - def test_redeeming(self, get_config, now, voucher): + @given(tahoe_configs(), datetimes(), vouchers(), voucher_counters()) + def test_redeeming(self, get_config, now, voucher, num_successes): """ A ``Voucher`` is marked redeeming while ``IRedeemer.redeem`` is actively - working on redeeming it. + working on redeeming it with a counter value that reflects the number + of successful partial redemptions so far completed. """ + # The voucher counter can be zero (no tries yet succeeded). We want + # at least *one* run through so we'll bump this up to be sure we get + # that. + counter = num_successes + 1 + redeemer = IndexedRedeemer( + [DummyRedeemer()] * num_successes + + [NonRedeemer()], + ) store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store controller = PaymentController( store, - NonRedeemer(), - default_token_count=10, + redeemer, + # This will give us one ZKAP per attempt. + default_token_count=counter, + # Require more success than we're going to get so it doesn't + # finish. + num_redemption_groups=counter, + ) + + self.assertThat( + controller.redeem(voucher), + has_no_result(), ) - controller.redeem(voucher) controller_voucher = controller.get_voucher(voucher) self.assertThat( controller_voucher.state, Equals(model_Redeeming( started=now, - counter=0, + counter=num_successes, )), ) + @given( + tahoe_configs(), + datetimes(), + vouchers(), + voucher_counters(), + voucher_counters().map(lambda v: v + 1), + ) + def test_restart_redeeming(self, get_config, now, voucher, before_restart, after_restart): + """ + If some redemption groups for a voucher have succeeded but the process is + interrupted, redemption begins at the first incomplete redemption + group when it resumes. + + :parm int before_restart: The number of redemption groups which will + be allowed to succeed before making the redeemer hang. Redemption + will then be required to begin again from only database state. + + :param int after_restart: The number of redemption groups which will + be required to succeed after restarting the process. + """ + # Divide redemption into some groups that will succeed before a + # restart and some that must succeed after a restart. + num_redemption_groups = before_restart + after_restart + # Give it enough tokens so each group can have one. + num_tokens = num_redemption_groups + + store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store + + def first_try(): + controller = PaymentController( + store, + # It will let `before_restart` attempts succeed before hanging. + IndexedRedeemer( + [DummyRedeemer()] * before_restart + + [NonRedeemer()] * after_restart, + ), + default_token_count=num_tokens, + num_redemption_groups=num_redemption_groups, + ) + self.assertThat( + controller.redeem(voucher), + has_no_result(), + ) + + def second_try(): + # The controller will find the voucher in the voucher store and + # restart redemption on its own. + return PaymentController( + store, + # It will succeed only for the higher counter values which did + # not succeed or did not get started on the first try. + IndexedRedeemer( + [NonRedeemer()] * before_restart + + [DummyRedeemer()] * after_restart, + ), + # TODO: It shouldn't need a default token count. It should + # respect whatever was given on the first redemption attempt. + # + # https://github.com/PrivateStorageio/ZKAPAuthorizer/issues/93 + default_token_count=num_tokens, + # The number of redemption groups must not change for + # redemption of a particular voucher. + num_redemption_groups=num_redemption_groups, + ) + + first_try() + controller = second_try() + + persisted_voucher = controller.get_voucher(voucher) + self.assertThat( + persisted_voucher.state, + Equals( + model_Redeemed( + finished=now, + token_count=num_tokens, + public_key=None, + ), + ), + ) + + + @given(tahoe_configs(), datetimes(), vouchers(), voucher_counters(), integers(min_value=0, max_value=100)) + def test_stop_redeeming_on_error(self, get_config, now, voucher, counter, extra_tokens): + """ + If an error is encountered on one of the redemption attempts performed by + ``IRedeemer.redeem``, the effort is suspended until the normal retry + logic activates. + """ + num_redemption_groups = counter + 1 + num_tokens = num_redemption_groups + extra_tokens + redeemer = RecordingRedeemer(UnpaidRedeemer()) + + store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store + controller = PaymentController( + store, + redeemer, + default_token_count=num_tokens, + num_redemption_groups=num_redemption_groups, + ) + self.assertThat( + controller.redeem(voucher), + succeeded(Always()), + ) + self.assertThat( + redeemer.redemptions, + AfterPreprocessing( + len, + Equals(1), + ), + ) + @given(tahoe_configs(), dummy_ristretto_keys(), datetimes(), vouchers()) def test_redeemed_after_redeeming(self, get_config, public_key, now, voucher): """ @@ -189,16 +433,19 @@ class PaymentControllerTests(TestCase): controller = PaymentController( store, DummyRedeemer(public_key), - default_token_count=10, + default_token_count=100, + ) + self.assertThat( + controller.redeem(voucher), + succeeded(Always()), ) - controller.redeem(voucher) persisted_voucher = store.get(voucher) self.assertThat( persisted_voucher.state, Equals(model_Redeemed( finished=now, - token_count=10, + token_count=100, public_key=public_key, )), ) @@ -213,9 +460,12 @@ class PaymentControllerTests(TestCase): controller = PaymentController( store, DoubleSpendRedeemer(), - default_token_count=10, + default_token_count=100, + ) + self.assertThat( + controller.redeem(voucher), + succeeded(Always()), ) - controller.redeem(voucher) persisted_voucher = store.get(voucher) self.assertThat( @@ -239,9 +489,12 @@ class PaymentControllerTests(TestCase): unpaid_controller = PaymentController( store, UnpaidRedeemer(), - default_token_count=10, + default_token_count=100, + ) + self.assertThat( + unpaid_controller.redeem(voucher), + succeeded(Always()), ) - unpaid_controller.redeem(voucher) # Make sure we got where we wanted. self.assertThat( @@ -255,7 +508,7 @@ class PaymentControllerTests(TestCase): success_controller = PaymentController( store, DummyRedeemer(), - default_token_count=10, + default_token_count=100, ) self.assertThat( @@ -284,10 +537,13 @@ class PaymentControllerTests(TestCase): controller = PaymentController( store, UnpaidRedeemer(), - default_token_count=10, + default_token_count=100, clock=clock, ) - controller.redeem(voucher) + self.assertThat( + controller.redeem(voucher), + succeeded(Always()), + ) # It fails this time. self.assertThat( controller.get_voucher(voucher).state, @@ -343,7 +599,7 @@ class RistrettoRedeemerTests(TestCase): issuer = RistrettoRedemption(signing_key) treq = treq_for_loopback_ristretto(issuer) redeemer = RistrettoRedeemer(treq, NOWHERE) - random_tokens = redeemer.random_tokens_for_voucher(voucher, num_tokens) + random_tokens = redeemer.random_tokens_for_voucher(voucher, counter, num_tokens) d = redeemer.redeemWithCounter( voucher, counter, @@ -366,18 +622,19 @@ class RistrettoRedeemerTests(TestCase): ), ) - @given(voucher_objects(), voucher_counters(), integers(min_value=1, max_value=100)) - def test_redemption_denied_alreadyspent(self, voucher, counter, num_tokens): + @given(voucher_objects(), voucher_counters(), integers(min_value=0, max_value=100)) + def test_redemption_denied_alreadyspent(self, voucher, counter, extra_tokens): """ If the issuer declines to allow the voucher to be redeemed and gives a reason that the voucher has already been spent, ``RistrettoRedeem`` returns a ``Deferred`` that fires with a ``Failure`` wrapping ``AlreadySpent``. """ + num_tokens = counter + extra_tokens issuer = AlreadySpentRedemption() treq = treq_for_loopback_ristretto(issuer) redeemer = RistrettoRedeemer(treq, NOWHERE) - random_tokens = redeemer.random_tokens_for_voucher(voucher, num_tokens) + random_tokens = redeemer.random_tokens_for_voucher(voucher, counter, num_tokens) d = redeemer.redeemWithCounter( voucher, counter, @@ -393,18 +650,19 @@ class RistrettoRedeemerTests(TestCase): ), ) - @given(voucher_objects(), voucher_counters(), integers(min_value=1, max_value=100)) - def test_redemption_denied_unpaid(self, voucher, counter, num_tokens): + @given(voucher_objects(), voucher_counters(), integers(min_value=0, max_value=100)) + def test_redemption_denied_unpaid(self, voucher, counter, extra_tokens): """ If the issuer declines to allow the voucher to be redeemed and gives a reason that the voucher has not been paid for, ``RistrettoRedeem`` returns a ``Deferred`` that fires with a ``Failure`` wrapping ``Unpaid``. """ + num_tokens = counter + extra_tokens issuer = UnpaidRedemption() treq = treq_for_loopback_ristretto(issuer) redeemer = RistrettoRedeemer(treq, NOWHERE) - random_tokens = redeemer.random_tokens_for_voucher(voucher, num_tokens) + random_tokens = redeemer.random_tokens_for_voucher(voucher, counter, num_tokens) d = redeemer.redeemWithCounter( voucher, counter, @@ -420,13 +678,14 @@ class RistrettoRedeemerTests(TestCase): ), ) - @given(voucher_objects(), voucher_counters(), integers(min_value=1, max_value=100)) - def test_bad_ristretto_redemption(self, voucher, counter, num_tokens): + @given(voucher_objects(), voucher_counters(), integers(min_value=0, max_value=100)) + def test_bad_ristretto_redemption(self, voucher, counter, extra_tokens): """ If the issuer returns a successful result with an invalid proof then ``RistrettoRedeemer.redeem`` returns a ``Deferred`` that fires with a ``Failure`` wrapping ``SecurityException``. """ + num_tokens = counter + extra_tokens signing_key = random_signing_key() issuer = RistrettoRedemption(signing_key) @@ -437,7 +696,7 @@ class RistrettoRedeemerTests(TestCase): treq = treq_for_loopback_ristretto(issuer) redeemer = RistrettoRedeemer(treq, NOWHERE) - random_tokens = redeemer.random_tokens_for_voucher(voucher, num_tokens) + random_tokens = redeemer.random_tokens_for_voucher(voucher, counter, num_tokens) d = redeemer.redeemWithCounter( voucher, counter, @@ -454,20 +713,20 @@ class RistrettoRedeemerTests(TestCase): ), ) - @given(voucher_objects(), voucher_counters(), integers(min_value=1, max_value=100)) - def test_ristretto_pass_construction(self, voucher, counter, num_tokens): + @given(voucher_objects(), voucher_counters(), integers(min_value=0, max_value=100)) + def test_ristretto_pass_construction(self, voucher, counter, extra_tokens): """ The passes constructed using unblinded tokens and messages pass the Ristretto verification check. """ + num_tokens = counter + extra_tokens message = b"hello world" - signing_key = random_signing_key() issuer = RistrettoRedemption(signing_key) treq = treq_for_loopback_ristretto(issuer) redeemer = RistrettoRedeemer(treq, NOWHERE) - random_tokens = redeemer.random_tokens_for_voucher(voucher, num_tokens) + random_tokens = redeemer.random_tokens_for_voucher(voucher, counter, num_tokens) d = redeemer.redeemWithCounter( voucher, counter, diff --git a/src/_zkapauthorizer/tests/test_model.py b/src/_zkapauthorizer/tests/test_model.py index 13d3dcf85e4192b1b2417f6a0a67117166f980af..5bd3e3145d12b90a67ef27ce4aaa7dc382864ef4 100644 --- a/src/_zkapauthorizer/tests/test_model.py +++ b/src/_zkapauthorizer/tests/test_model.py @@ -57,6 +57,7 @@ from hypothesis import ( from hypothesis.strategies import ( data, + booleans, lists, tuples, datetimes, @@ -84,6 +85,7 @@ from .strategies import ( tahoe_configs, vouchers, voucher_objects, + voucher_counters, random_tokens, unblinded_tokens, posix_safe_datetimes, @@ -120,7 +122,7 @@ class VoucherStoreTests(TestCase): previously added to the store with ``VoucherStore.add``. """ store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store - store.add(voucher, lambda: tokens) + store.add(voucher, 0, lambda: tokens) self.assertThat( store.get(voucher), MatchesStructure( @@ -130,6 +132,39 @@ class VoucherStoreTests(TestCase): ), ) + @given( + tahoe_configs(), + vouchers(), + lists(voucher_counters(), unique=True, min_size=2, max_size=2), + lists(random_tokens(), min_size=2, unique=True), + datetimes(), + ) + def test_add_with_distinct_counters(self, get_config, voucher, counters, tokens, now): + """ + ``VoucherStore.add`` adds new tokens to the store when passed the same + voucher but a different counter value. + """ + counter_a = counters[0] + counter_b = counters[1] + tokens_a = tokens[:len(tokens) / 2] + tokens_b = tokens[len(tokens) / 2:] + + store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store + added_tokens_a = store.add(voucher, counter_a, lambda: tokens_a) + added_tokens_b = store.add(voucher, counter_b, lambda: tokens_b) + + self.assertThat( + store.get(voucher), + MatchesStructure( + number=Equals(voucher), + state=Equals(Pending(counter=0)), + created=Equals(now), + ), + ) + + self.assertThat(tokens_a, Equals(added_tokens_a)) + self.assertThat(tokens_b, Equals(added_tokens_b)) + @given(tahoe_configs(), vouchers(), datetimes(), lists(random_tokens(), unique=True)) def test_add_idempotent(self, get_config, voucher, now, tokens): """ @@ -137,8 +172,8 @@ class VoucherStoreTests(TestCase): in the same state as a single call. """ store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store - first_tokens = store.add(voucher, lambda: tokens) - second_tokens = store.add(voucher, lambda: []) + first_tokens = store.add(voucher, 0, lambda: tokens) + second_tokens = store.add(voucher, 0, lambda: []) self.assertThat( store.get(voucher), MatchesStructure( @@ -164,7 +199,7 @@ class VoucherStoreTests(TestCase): """ store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store for voucher in vouchers: - store.add(voucher, lambda: []) + store.add(voucher, 0, lambda: []) self.assertThat( store.list(), @@ -302,11 +337,12 @@ class VoucherStoreTests(TestCase): # Put some tokens in it that we can backup and extract random_tokens, unblinded_tokens = paired_tokens(data, integers(min_value=1, max_value=5)) - store.add(voucher_value, lambda: random_tokens) + store.add(voucher_value, 0, lambda: random_tokens) store.insert_unblinded_tokens_for_voucher( voucher_value, public_key, unblinded_tokens, + completed=data.draw(booleans()), ) backed_up_tokens = store.backup()[u"unblinded-tokens"] @@ -450,8 +486,9 @@ class UnblindedTokenStoreTests(TestCase): vouchers(), dummy_ristretto_keys(), lists(unblinded_tokens(), unique=True), + booleans(), ) - def test_unblinded_tokens_without_voucher(self, get_config, now, voucher_value, public_key, unblinded_tokens): + def test_unblinded_tokens_without_voucher(self, get_config, now, voucher_value, public_key, unblinded_tokens, completed): """ Unblinded tokens for a voucher which has not been added to the store cannot be inserted. """ @@ -461,6 +498,7 @@ class UnblindedTokenStoreTests(TestCase): voucher_value, public_key, unblinded_tokens, + completed, ), raises(ValueError), ) @@ -470,16 +508,17 @@ class UnblindedTokenStoreTests(TestCase): datetimes(), vouchers(), dummy_ristretto_keys(), + booleans(), data(), ) - def test_unblinded_tokens_round_trip(self, get_config, now, voucher_value, public_key, data): + def test_unblinded_tokens_round_trip(self, get_config, now, voucher_value, public_key, completed, data): """ Unblinded tokens that are added to the store can later be retrieved. """ random_tokens, unblinded_tokens = paired_tokens(data) store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store - store.add(voucher_value, lambda: random_tokens) - store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded_tokens) + store.add(voucher_value, 0, lambda: random_tokens) + store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded_tokens, completed) retrieved_tokens = store.extract_unblinded_tokens(len(random_tokens)) self.expectThat( @@ -524,8 +563,8 @@ class UnblindedTokenStoreTests(TestCase): ) store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store - store.add(voucher_value, lambda: random) - store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded) + store.add(voucher_value, 0, lambda: random) + store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded, completed=True) loaded_voucher = store.get(voucher_value) self.assertThat( loaded_voucher, @@ -550,7 +589,7 @@ class UnblindedTokenStoreTests(TestCase): such. """ store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store - store.add(voucher_value, lambda: random_tokens) + store.add(voucher_value, 0, lambda: random_tokens) store.mark_voucher_double_spent(voucher_value) voucher = store.get(voucher_value) self.assertThat( @@ -591,8 +630,8 @@ class UnblindedTokenStoreTests(TestCase): ), ) store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store - store.add(voucher_value, lambda: random) - store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded) + store.add(voucher_value, 0, lambda: random) + store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded, completed=True) self.assertThat( lambda: store.mark_voucher_double_spent(voucher_value), raises(ValueError), @@ -618,11 +657,12 @@ class UnblindedTokenStoreTests(TestCase): datetimes(), vouchers(), dummy_ristretto_keys(), + booleans(), integers(min_value=1, max_value=100), integers(min_value=1), data(), ) - def test_not_enough_unblinded_tokens(self, get_config, now, voucher_value, public_key, num_tokens, extra, data): + def test_not_enough_unblinded_tokens(self, get_config, now, voucher_value, public_key, completed, num_tokens, extra, data): """ ``extract_unblinded_tokens`` raises ``NotEnoughTokens`` if ``count`` is greater than the number of unblinded tokens in the store. @@ -644,8 +684,8 @@ class UnblindedTokenStoreTests(TestCase): ), ) store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store - store.add(voucher_value, lambda: random) - store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded) + store.add(voucher_value, 0, lambda: random) + store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded, completed) self.assertThat( lambda: store.extract_unblinded_tokens(num_tokens + extra), diff --git a/src/_zkapauthorizer/tests/test_plugin.py b/src/_zkapauthorizer/tests/test_plugin.py index 41a532dce7c3b1a69199b4a2cea5cc488065c896..ebd714863a3ab95a590826698001ba4cac469965 100644 --- a/src/_zkapauthorizer/tests/test_plugin.py +++ b/src/_zkapauthorizer/tests/test_plugin.py @@ -439,12 +439,16 @@ class ClientPluginTests(TestCase): ) store = VoucherStore.from_node_config(node_config, lambda: now) + # Give it enough for the allocate_buckets call below. expected_pass_cost = required_passes(store.pass_value, [size] * len(sharenums)) + # And few enough redemption groups given the number of tokens. + num_redemption_groups = expected_pass_cost + controller = PaymentController( store, DummyRedeemer(), - # Give it enough for the allocate_buckets call below. - expected_pass_cost, + default_token_count=expected_pass_cost, + num_redemption_groups=num_redemption_groups, ) # Get a token inserted into the store. redeeming = controller.redeem(voucher)