diff --git a/docs/source/interface.rst b/docs/source/interface.rst index d899f5451e189313737baeccaa11ff82fd9049db..e790bd827c6d3e92438980cb4158d081d54a3fd5 100644 --- a/docs/source/interface.rst +++ b/docs/source/interface.rst @@ -32,7 +32,11 @@ If the voucher is not known then the response is **NOT FOUND**. For any voucher which has previously been submitted, the response is **OK** with an ``application/json`` content-type response body like:: - {"value": <string>, "created": <iso8601 timestamp>, "redeemed": <boolean>, "token-count": <number>, "version": 1} + { "value": <string> + , "created": <iso8601 timestamp> + , "state": <state object> + , "version": 1 + } The ``value`` property merely indicates the voucher which was requested. The ``created`` property indicates when the voucher was first added to the node. @@ -42,6 +46,44 @@ The ``token-count`` property gives the number of blinded token signatures the cl if it has been redeemed. If it has not been redeemed then it is ``null``. +The ``state`` property is an object that gives more details about the current state of the voucher. +The following values are possible:: + + { "name": "pending" + } + +:: + + { "name": "redeeming" + , "started": <iso8601 timestamp> + } + +:: + + { "name": "redeemed" + , "finished": <iso8601 timestamp> + , "token-count": <number> + } + +:: + + { "name": "double-spend" + , "finished": <iso8601 timestamp> + } + +:: + + { "name": "unpaid" + , "finished": <iso8601 timestamp> + } + +:: + + { "name": "error" + "finished": <iso8601 timestamp> + , "details": <text> + } + The ``version`` property indicates the semantic version of the data being returned. When properties are removed or the meaning of a property is changed, the value of the ``version`` property will be incremented. diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py index 56dae0b1da1551bbce5d2bf47101747f28e41d74..1939478bbba918a83170d061818cc8a882047855 100644 --- a/src/_zkapauthorizer/controller.py +++ b/src/_zkapauthorizer/controller.py @@ -17,6 +17,13 @@ This module implements controllers (in the MVC sense) for the web interface for the client side of the storage plugin. """ +from sys import ( + exc_info, +) +from operator import ( + setitem, + delitem, +) from functools import ( partial, ) @@ -39,6 +46,7 @@ from twisted.python.url import ( from twisted.internet.defer import ( Deferred, succeed, + fail, inlineCallbacks, returnValue, ) @@ -59,9 +67,28 @@ from .model import ( UnblindedToken, Voucher, Pass, + Pending as model_Pending, + Unpaid as model_Unpaid, + Redeeming as model_Redeeming, + Error as model_Error, ) +class AlreadySpent(Exception): + """ + An attempt was made to redeem a voucher which has already been redeemed. + The redemption cannot succeed and should not be retried automatically. + """ + + +class Unpaid(Exception): + """ + An attempt was made to redeem a voucher which has not yet been paid for. + + The redemption attempt may be automatically retried at some point. + """ + + class IRedeemer(Interface): """ An ``IRedeemer`` can exchange a voucher for one or more passes. @@ -100,7 +127,10 @@ class IRedeemer(Interface): :return: A ``Deferred`` which fires with a list of ``UnblindedToken`` instances on successful redemption or which fails with any error - to allow a retry to be made at some future point. + to allow a retry to be made at some future point. It may also + fail with an ``AlreadySpent`` error to indicate the redemption + server considers the voucher to have been redeemed already and + will not allow it to be redeemed. """ def tokens_to_passes(message, unblinded_tokens): @@ -150,6 +180,79 @@ class NonRedeemer(object): ) +@implementer(IRedeemer) +@attr.s(frozen=True) +class ErrorRedeemer(object): + """ + An ``ErrorRedeemer`` immediately locally fails voucher redemption with a + configured error. + """ + details = attr.ib(validator=attr.validators.instance_of(unicode)) + + @classmethod + def make(cls, section_name, node_config, announcement, reactor): + details = node_config.get_config( + section=section_name, + option=u"details", + ).decode("ascii") + return cls(details) + + def random_tokens_for_voucher(self, voucher, count): + return dummy_random_tokens(voucher, count) + + def redeem(self, voucher, random_tokens): + return fail(Exception(self.details)) + + def tokens_to_passes(self, message, unblinded_tokens): + raise Exception( + "Cannot be called because no unblinded tokens are ever returned." + ) + + +@implementer(IRedeemer) +@attr.s +class DoubleSpendRedeemer(object): + """ + A ``DoubleSpendRedeemer`` pretends to try to redeem vouchers for ZKAPs but + always fails with an error indicating the voucher has already been spent. + """ + @classmethod + 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 redeem(self, voucher, random_tokens): + return fail(AlreadySpent(voucher)) + + +@implementer(IRedeemer) +@attr.s +class UnpaidRedeemer(object): + """ + An ``UnpaidRedeemer`` pretends to try to redeem vouchers for ZKAPs but + always fails with an error indicating the voucher has not been paid for. + """ + @classmethod + 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 redeem(self, voucher, random_tokens): + return fail(Unpaid(voucher)) + + +def dummy_random_tokens(voucher, count): + return list( + RandomToken(u"{}-{}".format(voucher.number, n)) + for n + in range(count) + ) + + @implementer(IRedeemer) @attr.s class DummyRedeemer(object): @@ -167,12 +270,7 @@ class DummyRedeemer(object): Generate some number of random tokens to submit along with a voucher for redemption. """ - # Dummy token generation. - return list( - RandomToken(u"{}-{}".format(voucher.number, n)) - for n - in range(count) - ) + return dummy_random_tokens(voucher, count) def redeem(self, voucher, random_tokens): """ @@ -297,6 +395,10 @@ class RistrettoRedeemer(object): self._log.failure("Parsing redeem response failed", response=response) raise + if result.get(u"failed", False): + if result.get(u"reason", None) == u"double-spend": + raise AlreadySpent(voucher) + self._log.info("Redeemed: {public-key} {proof} {signatures}", **result) marshaled_signed_tokens = result[u"signatures"] @@ -370,11 +472,13 @@ class PaymentController(object): The ``PaymentController`` coordinates the process of turning a voucher into a collection of ZKAPs: - 1. A voucher to be consumed is handed to the controller. - Once a voucher is handed over to the controller the controller takes all responsibility for it. + 1. A voucher to be consumed is handed to the controller. Once a voucher + is handed over to the controller the controller takes all + responsibility for it. - 2. The controller tells the data store to remember the voucher. - The data store provides durability for the voucher which represents an investment (ie, a purchase) on the part of the client. + 2. The controller tells the data store to remember the voucher. The + data store provides durability for the voucher which represents an + investment (ie, a purchase) on the part of the client. 3. The controller hands the voucher and some random tokens to a redeemer. In the future, this step will need to be retried in the case of failures. @@ -383,12 +487,28 @@ class PaymentController(object): pass construction), the controller hands them to the data store with the voucher. The data store marks the voucher as redeemed and stores the unblinded tokens for use by the storage client. + + :ivar dict[voucher, datetime] _active: A mapping from voucher identifiers + which currently have redemption attempts in progress to timestamps + when the attempt began. + + :ivar dict[voucher, datetime] _error: A mapping from voucher identifiers + which have recently failed with an unrecognized, transient error. + + :ivar dict[voucher, datetime] _unpaid: A mapping from voucher identifiers + which have recently failed a redemption attempt due to an unpaid + response from the redemption server to timestamps when the failure was + observed. """ _log = Logger() store = attr.ib() redeemer = attr.ib() + _error = attr.ib(default=attr.Factory(dict)) + _unpaid = attr.ib(default=attr.Factory(dict)) + _active = attr.ib(default=attr.Factory(dict)) + def redeem(self, voucher, num_tokens=100): """ :param unicode voucher: A voucher to redeem. @@ -413,7 +533,11 @@ class PaymentController(object): # Ask the redeemer to do the real task of redemption. self._log.info("Redeeming random tokens for a voucher ({voucher}).", voucher=voucher) - d = self.redeemer.redeem(Voucher(voucher), tokens) + d = bracket( + lambda: setitem(self._active, voucher, self.store.now()), + lambda: delitem(self._active, voucher), + lambda: self.redeemer.redeem(Voucher(voucher), tokens), + ) d.addCallbacks( partial(self._redeemSuccess, voucher), partial(self._redeemFailure, voucher), @@ -431,13 +555,59 @@ class PaymentController(object): self.store.insert_unblinded_tokens_for_voucher(voucher, unblinded_tokens) def _redeemFailure(self, voucher, reason): - self._log.failure("Redeeming random tokens for a voucher ({voucher}) failed.", reason, voucher=voucher) + if reason.check(AlreadySpent): + self._log.error( + "Voucher {voucher} reported as already spent during redemption.", + voucher=voucher, + ) + self.store.mark_voucher_double_spent(voucher) + elif reason.check(Unpaid): + self._log.error( + "Voucher {voucher} reported as not paid for during redemption.", + voucher=voucher, + ) + self._unpaid[voucher] = self.store.now() + else: + self._log.error( + "Redeeming random tokens for a voucher ({voucher}) failed: {reason}", + reason=reason, + voucher=voucher, + ) + self._error[voucher] = model_Error( + finished=self.store.now(), + details=reason.getErrorMessage().decode("utf-8", "replace"), + ) return None def _finalRedeemError(self, voucher, reason): self._log.failure("Redeeming random tokens for a voucher ({voucher}) encountered error.", reason, voucher=voucher) return None + def incorporate_transient_state(self, voucher): + """ + Create a new ``Voucher`` which represents the given voucher but which also + incorporates relevant transient state known to the controller. For + example, if a redemption attempt is current in progress, this is + incorporated. + """ + if isinstance(voucher.state, model_Pending): + if voucher.number in self._active: + return attr.evolve( + voucher, + state=model_Redeeming(started=self._active[voucher.number]), + ) + if voucher.number in self._unpaid: + return attr.evolve( + voucher, + state=model_Unpaid(finished=self._unpaid[voucher.number]), + ) + if voucher.number in self._error: + return attr.evolve( + voucher, + state=self._error[voucher.number], + ) + return voucher + def get_redeemer(plugin_name, node_config, announcement, reactor): section_name = u"storageclient.plugins.{}".format(plugin_name) @@ -452,5 +622,39 @@ def get_redeemer(plugin_name, node_config, announcement, reactor): _REDEEMERS = { u"non": NonRedeemer.make, u"dummy": DummyRedeemer.make, + u"double-spend": DoubleSpendRedeemer.make, + u"unpaid": UnpaidRedeemer.make, + u"error": ErrorRedeemer.make, u"ristretto": RistrettoRedeemer.make, } + + +@inlineCallbacks +def bracket(first, last, between): + """ + Invoke an action between two other actions. + + :param first: A no-argument function that may return a Deferred. It is + called first. + + :param last: A no-argument function that may return a Deferred. It is + called last. + + :param between: A no-argument function that may return a Deferred. It is + called after ``first`` is done and completes before ``last`` is called. + + :return Deferred: A ``Deferred`` which fires with the result of + ``between``. + """ + yield first() + try: + result = yield between() + except GeneratorExit: + raise + except: + info = exc_info() + yield last() + raise info[0], info[1], info[2] + else: + yield last() + returnValue(result) diff --git a/src/_zkapauthorizer/model.py b/src/_zkapauthorizer/model.py index 308947daf520c647c60959daab1341adbc020eff..8af6d83234719a7525b802a3c2d00173a6f55c48 100644 --- a/src/_zkapauthorizer/model.py +++ b/src/_zkapauthorizer/model.py @@ -119,13 +119,19 @@ def open_and_initialize(path, required_schema_version, connect=None): ) cursor.execute( + # A denormalized schema because, for now, it's simpler. :/ """ CREATE TABLE IF NOT EXISTS [vouchers] ( [number] text, [created] text, -- An ISO8601 date+time string. - [redeemed] num DEFAULT 0, -- 0 if the voucher has not been redeemed, 1 otherwise. - [token-count] num DEFAULT NULL, -- NULL if the voucher has not been redeemed, - -- a number of tokens received on its redemption otherwise. + [state] text DEFAULT "pending", -- pending, double-spend, redeemed + + [finished] text DEFAULT NULL, -- ISO8601 date+time string when + -- the current terminal state was entered. + + [token-count] num DEFAULT NULL, -- Set in the redeemed state to the number + -- of tokens received on this voucher's + -- redemption. PRIMARY KEY([number]) ) @@ -221,7 +227,7 @@ class VoucherStore(object): cursor.execute( """ SELECT - [number], [created], [redeemed], [token-count] + [number], [created], [state], [finished], [token-count] FROM [vouchers] WHERE @@ -279,7 +285,10 @@ class VoucherStore(object): """ cursor.execute( """ - SELECT [number], [created], [redeemed], [token-count] FROM [vouchers] + SELECT + [number], [created], [state], [finished], [token-count] + FROM + [vouchers] """, ) refs = cursor.fetchall() @@ -315,13 +324,52 @@ class VoucherStore(object): cursor.execute( """ UPDATE [vouchers] - SET [redeemed] = 1 + SET [state] = "redeemed" , [token-count] = ? + , [finished] = ? WHERE [number] = ? """, - (len(unblinded_tokens), voucher), + (len(unblinded_tokens), self.now(), voucher), ) + @with_cursor + def mark_voucher_double_spent(self, cursor, voucher): + """ + Mark a voucher as having failed redemption because it has already been + spent. + """ + cursor.execute( + """ + UPDATE [vouchers] + SET [state] = "double-spend" + , [finished] = ? + WHERE [number] = ? + AND [state] = "pending" + """, + (self.now(), voucher), + ) + if cursor.rowcount == 0: + # Was there no matching voucher or was it in the wrong state? + cursor.execute( + """ + SELECT [state] + FROM [vouchers] + WHERE [number] = ? + """, + (voucher,) + ) + rows = cursor.fetchall() + if len(rows) == 0: + raise ValueError("Voucher {} not found".format(voucher)) + else: + raise ValueError( + "Voucher {} in state {} cannot transition to double-spend".format( + voucher, + rows[0][0], + ), + ) + + @with_cursor def extract_unblinded_tokens(self, cursor, count): """ @@ -416,6 +464,88 @@ class RandomToken(object): token_value = attr.ib(validator=attr.validators.instance_of(unicode)) +@attr.s(frozen=True) +class Pending(object): + def to_json_v1(self): + return { + u"name": u"pending", + } + + +@attr.s(frozen=True) +class Redeeming(object): + """ + This is a non-persistent state in which a voucher exists when the database + state is **pending** but for which there is a redemption operation in + progress. + """ + started = attr.ib(validator=attr.validators.instance_of(datetime)) + + def to_json_v1(self): + return { + u"name": u"redeeming", + u"started": self.started.isoformat(), + } + + +@attr.s(frozen=True) +class Redeemed(object): + finished = attr.ib(validator=attr.validators.instance_of(datetime)) + token_count = attr.ib(validator=attr.validators.instance_of((int, long))) + + def to_json_v1(self): + return { + u"name": u"redeemed", + u"finished": self.finished.isoformat(), + u"token-count": self.token_count, + } + + +@attr.s(frozen=True) +class DoubleSpend(object): + finished = attr.ib(validator=attr.validators.instance_of(datetime)) + + def to_json_v1(self): + return { + u"name": u"double-spend", + u"finished": self.finished.isoformat(), + } + + +@attr.s(frozen=True) +class Unpaid(object): + """ + This is a non-persistent state in which a voucher exists when the database + state is **pending** but the most recent redemption attempt has failed due + to lack of payment. + """ + finished = attr.ib(validator=attr.validators.instance_of(datetime)) + + def to_json_v1(self): + return { + u"name": u"unpaid", + u"finished": self.finished.isoformat(), + } + + +@attr.s(frozen=True) +class Error(object): + """ + This is a non-persistent state in which a voucher exists when the database + state is **pending** but the most recent redemption attempt has failed due + to an error that is not handled by any other part of the system. + """ + finished = attr.ib(validator=attr.validators.instance_of(datetime)) + details = attr.ib(validator=attr.validators.instance_of(unicode)) + + def to_json_v1(self): + return { + u"name": u"error", + u"finished": self.finished.isoformat(), + u"details": self.details, + } + + @attr.s class Voucher(object): """ @@ -433,22 +563,38 @@ class Voucher(object): redeemed. """ number = attr.ib() - created = attr.ib(default=None, validator=attr.validators.optional(attr.validators.instance_of(datetime))) - redeemed = attr.ib(default=False, validator=attr.validators.instance_of(bool)) - token_count = attr.ib(default=None, validator=attr.validators.optional(attr.validators.instance_of((int, long)))) + created = attr.ib( + default=None, + validator=attr.validators.optional(attr.validators.instance_of(datetime)), + ) + state = attr.ib(default=Pending()) @classmethod def from_row(cls, row): + def state_from_row(state, row): + if state == u"pending": + return Pending() + if state == u"double-spend": + return DoubleSpend( + parse_datetime(row[0], delimiter=u" "), + ) + if state == u"redeemed": + return Redeemed( + parse_datetime(row[0], delimiter=u" "), + row[1], + ) + raise ValueError("Unknown voucher state {}".format(state)) + + number, created, state = row[:3] return cls( - row[0], + number, # All Python datetime-based date/time libraries fail to handle # leap seconds. This parse call might raise an exception of the # value represents a leap second. However, since we also use # Python to generate the data in the first place, it should never # represent a leap second... I hope. - parse_datetime(row[1], delimiter=u" "), - bool(row[2]), - row[3], + parse_datetime(created, delimiter=u" "), + state_from_row(state, row[3:]) ) @classmethod @@ -460,11 +606,39 @@ class Voucher(object): @classmethod def from_json_v1(cls, values): + state_json = values[u"state"] + state_name = state_json[u"name"] + if state_name == u"pending": + state = Pending() + elif state_name == u"redeeming": + state = Redeeming( + started=parse_datetime(state_json[u"started"]), + ) + elif state_name == u"double-spend": + state = DoubleSpend( + finished=parse_datetime(state_json[u"finished"]), + ) + elif state_name == u"redeemed": + state = Redeemed( + finished=parse_datetime(state_json[u"finished"]), + token_count=state_json[u"token-count"], + ) + elif state_name == u"unpaid": + state = Unpaid( + finished=parse_datetime(state_json[u"finished"]), + ) + elif state_name == u"error": + state = Error( + finished=parse_datetime(state_json[u"finished"]), + details=state_json[u"details"], + ) + else: + raise ValueError("Unrecognized state {!r}".format(state_json)) + return cls( number=values[u"number"], created=None if values[u"created"] is None else parse_datetime(values[u"created"]), - redeemed=values[u"redeemed"], - token_count=values[u"token-count"], + state=state, ) @@ -477,10 +651,10 @@ class Voucher(object): def to_json_v1(self): + state = self.state.to_json_v1() return { u"number": self.number, u"created": None if self.created is None else self.created.isoformat(), - u"redeemed": self.redeemed, - u"token-count": self.token_count, + u"state": state, u"version": 1, } diff --git a/src/_zkapauthorizer/resource.py b/src/_zkapauthorizer/resource.py index 9d13a02193ee85703e4e1dcb5b53f00c706c2dae..1601d7054ca7f74bbb6553f18e2da20d4ebb3551 100644 --- a/src/_zkapauthorizer/resource.py +++ b/src/_zkapauthorizer/resource.py @@ -211,7 +211,8 @@ class _VoucherCollection(Resource): voucher = self._store.get(voucher) except KeyError: return NoResource() - return VoucherView(voucher) + # TODO Apply the same treatment to the list result + return VoucherView(self._controller.incorporate_transient_state(voucher)) def is_syntactic_voucher(voucher): diff --git a/src/_zkapauthorizer/tests/strategies.py b/src/_zkapauthorizer/tests/strategies.py index 9f9e254f170517e1e06c932aca9db391c4aca517..223a7ec3089d576139e322eb362dccba4f77d044 100644 --- a/src/_zkapauthorizer/tests/strategies.py +++ b/src/_zkapauthorizer/tests/strategies.py @@ -26,7 +26,6 @@ from hypothesis.strategies import ( one_of, just, none, - booleans, binary, characters, text, @@ -57,6 +56,9 @@ from ..model import ( RandomToken, UnblindedToken, Voucher, + Pending, + DoubleSpend, + Redeemed, ) @@ -187,6 +189,24 @@ def client_dummyredeemer_configurations(): }) +def client_doublespendredeemer_configurations(): + """ + Build DoubleSpendRedeemer-using configuration values for the client-side plugin. + """ + return just({ + u"redeemer": u"double-spend", + }) + + +def client_unpaidredeemer_configurations(): + """ + Build UnpaidRedeemer-using configuration values for the client-side plugin. + """ + return just({ + u"redeemer": u"unpaid", + }) + + def client_nonredeemer_configurations(): """ Build NonRedeemer-using configuration values for the client-side plugin. @@ -196,6 +216,16 @@ def client_nonredeemer_configurations(): }) +def client_errorredeemer_configurations(details): + """ + Build ErrorRedeemer-using configuration values for the client-side plugin. + """ + return just({ + u"redeemer": u"error", + u"details": details, + }) + + def tahoe_configs(zkapauthz_v1_configuration=client_dummyredeemer_configurations()): """ Build complete Tahoe-LAFS configurations including the zkapauthorizer @@ -220,6 +250,24 @@ def vouchers(): ) +def voucher_states(): + """ + Build unicode strings giving states a Voucher can be in. + """ + return one_of( + just(Pending()), + builds( + DoubleSpend, + finished=datetimes(), + ), + builds( + Redeemed, + finished=datetimes(), + token_count=one_of(integers(min_value=1)), + ), + ) + + def voucher_objects(): """ Build ``Voucher`` instances. @@ -228,8 +276,7 @@ def voucher_objects(): Voucher, number=vouchers(), created=one_of(none(), datetimes()), - redeemed=booleans(), - token_count=one_of(none(), integers(min_value=1)), + state=voucher_states(), ) diff --git a/src/_zkapauthorizer/tests/test_client_resource.py b/src/_zkapauthorizer/tests/test_client_resource.py index 1df93936c70090dc31f36ac5db75b93b1365458f..22d2f544783374379d4ca340b2170d6d468f7d1b 100644 --- a/src/_zkapauthorizer/tests/test_client_resource.py +++ b/src/_zkapauthorizer/tests/test_client_resource.py @@ -53,7 +53,6 @@ from testtools.matchers import ( IsInstance, ContainsDict, AfterPreprocessing, - Is, Equals, Always, GreaterThan, @@ -113,6 +112,11 @@ from treq.testing import ( from ..model import ( Voucher, + Redeeming, + Redeemed, + DoubleSpend, + Unpaid, + Error, VoucherStore, memory_connect, ) @@ -122,8 +126,11 @@ from ..resource import ( from .strategies import ( tahoe_configs, + client_unpaidredeemer_configurations, + client_doublespendredeemer_configurations, client_dummyredeemer_configurations, client_nonredeemer_configurations, + client_errorredeemer_configurations, vouchers, requests, ) @@ -131,6 +138,8 @@ from .matchers import ( Provides, ) +TRANSIENT_ERROR = u"something went wrong, who knows what" + # Helper to work-around https://github.com/twisted/treq/issues/161 def uncooperator(started=True): return Cooperator( @@ -594,30 +603,124 @@ class VoucherTests(TestCase): ) @given(tahoe_configs(client_nonredeemer_configurations()), datetimes(), vouchers()) - def test_get_known_voucher_unredeemed(self, get_config, now, voucher): + def test_get_known_voucher_redeeming(self, get_config, now, voucher): """ When a voucher is first ``PUT`` and then later a ``GET`` is issued for the - same voucher then the response code is **OK** and details about the - voucher are included in a json-encoded response body. + same voucher then the response code is **OK** and details, including + those relevant to a voucher which is actively being redeemed, about + the voucher are included in a json-encoded response body. """ - return self._test_get_known_voucher(get_config, now, voucher, False) + return self._test_get_known_voucher( + get_config, + now, + voucher, + MatchesStructure( + number=Equals(voucher), + created=Equals(now), + state=Equals(Redeeming( + started=now, + )), + ), + ) @given(tahoe_configs(client_dummyredeemer_configurations()), datetimes(), vouchers()) def test_get_known_voucher_redeemed(self, get_config, now, voucher): """ When a voucher is first ``PUT`` and then later a ``GET`` is issued for the - same voucher then the response code is **OK** and details about the - voucher are included in a json-encoded response body. + same voucher then the response code is **OK** and details, including + those relevant to a voucher which has been redeemed, about the voucher + are included in a json-encoded response body. + """ + return self._test_get_known_voucher( + get_config, + now, + voucher, + MatchesStructure( + number=Equals(voucher), + created=Equals(now), + state=Equals(Redeemed( + finished=now, + # Value duplicated from PaymentController.redeem default. + # Should do this better. + token_count=100, + )), + ), + ) + + @given(tahoe_configs(client_doublespendredeemer_configurations()), datetimes(), vouchers()) + def test_get_known_voucher_doublespend(self, get_config, now, voucher): + """ + When a voucher is first ``PUT`` and then later a ``GET`` is issued for the + same voucher then the response code is **OK** and details, including + those relevant to a voucher which has failed redemption because it was + already redeemed, about the voucher are included in a json-encoded + response body. + """ + return self._test_get_known_voucher( + get_config, + now, + voucher, + MatchesStructure( + number=Equals(voucher), + created=Equals(now), + state=Equals(DoubleSpend( + finished=now, + )), + ), + ) + + @given(tahoe_configs(client_unpaidredeemer_configurations()), datetimes(), vouchers()) + def test_get_known_voucher_unpaid(self, get_config, now, voucher): + """ + When a voucher is first ``PUT`` and then later a ``GET`` is issued for the + same voucher then the response code is **OK** and details, including + those relevant to a voucher which has failed redemption because it has + not been paid for yet, about the voucher are included in a + json-encoded response body. """ - return self._test_get_known_voucher(get_config, now, voucher, True) + return self._test_get_known_voucher( + get_config, + now, + voucher, + MatchesStructure( + number=Equals(voucher), + created=Equals(now), + state=Equals(Unpaid( + finished=now, + )), + ), + ) - def _test_get_known_voucher(self, get_config, now, voucher, redeemed): + @given(tahoe_configs(client_errorredeemer_configurations(TRANSIENT_ERROR)), datetimes(), vouchers()) + def test_get_known_voucher_error(self, get_config, now, voucher): + """ + When a voucher is first ``PUT`` and then later a ``GET`` is issued for the + same voucher then the response code is **OK** and details, including + those relevant to a voucher which has failed redemption due to any + kind of transient conditions, about the voucher are included in a + json-encoded response body. + """ + return self._test_get_known_voucher( + get_config, + now, + voucher, + MatchesStructure( + number=Equals(voucher), + created=Equals(now), + state=Equals(Error( + finished=now, + details=TRANSIENT_ERROR, + )), + ), + ) + + def _test_get_known_voucher(self, get_config, now, voucher, voucher_matcher): """ Assert that a voucher that is ``PUT`` and then ``GET`` is represented in the JSON response. - :param bool redeemed: Whether the voucher is expected to be redeemed - or not in the response. + :param voucher_matcher: A matcher which matches the voucher expected + to be returned by the ``GET``. """ tempdir = self.useFixture(TempDir()) config = get_config(tempdir.join(b"tahoe"), b"tub.port") @@ -649,13 +752,6 @@ class VoucherTests(TestCase): ).decode("utf-8"), ).encode("ascii"), ) - if redeemed: - # Value duplicated from PaymentController.redeem default. Should - # do this better. - token_count_comparison = Equals(100) - else: - token_count_comparison = Is(None) - self.assertThat( getting, succeeded( @@ -666,12 +762,7 @@ class VoucherTests(TestCase): succeeded( AfterPreprocessing( Voucher.from_json, - MatchesStructure( - number=Equals(voucher), - created=Equals(now), - redeemed=Equals(redeemed), - token_count=token_count_comparison, - ), + voucher_matcher, ), ), ), @@ -731,11 +822,13 @@ class VoucherTests(TestCase): Voucher( voucher, created=now, - redeemed=True, - # Value duplicated from - # PaymentController.redeem default. - # Should do this better. - token_count=100, + state=Redeemed( + finished=now, + # Value duplicated from + # PaymentController.redeem + # default. Should do this better. + token_count=100, + ), ).marshal() for voucher in vouchers diff --git a/src/_zkapauthorizer/tests/test_controller.py b/src/_zkapauthorizer/tests/test_controller.py index 032ed0962b286bac7c7125452a1254c685599d72..31be5bf25240bcb663e260c297d3bfbc419522b4 100644 --- a/src/_zkapauthorizer/tests/test_controller.py +++ b/src/_zkapauthorizer/tests/test_controller.py @@ -43,6 +43,7 @@ from testtools.matchers import ( IsInstance, HasLength, AfterPreprocessing, + MatchesStructure, ) from testtools.twistedsupport import ( succeeded, @@ -75,6 +76,7 @@ from twisted.web.resource import ( ) from twisted.web.http import ( UNSUPPORTED_MEDIA_TYPE, + BAD_REQUEST, ) from treq.testing import ( StubTreq, @@ -94,20 +96,25 @@ from ..controller import ( IRedeemer, NonRedeemer, DummyRedeemer, + DoubleSpendRedeemer, RistrettoRedeemer, PaymentController, + AlreadySpent, ) from ..model import ( memory_connect, VoucherStore, - Voucher, UnblindedToken, + Pending, + DoubleSpend, + Redeemed, ) from .strategies import ( tahoe_configs, vouchers, + voucher_objects, ) from .matchers import ( Provides, @@ -140,12 +147,15 @@ class PaymentControllerTests(TestCase): persisted_voucher = store.get(voucher) self.assertThat( - persisted_voucher.redeemed, - Equals(False), + persisted_voucher.state, + Equals(Pending()), ) @given(tahoe_configs(), datetimes(), vouchers()) def test_redeemed_after_redeeming(self, get_config, now, voucher): + """ + A ``Voucher`` is marked as redeemed after ``IRedeemer.redeem`` succeeds. + """ tempdir = self.useFixture(TempDir()) store = VoucherStore.from_node_config( get_config( @@ -163,8 +173,42 @@ class PaymentControllerTests(TestCase): persisted_voucher = store.get(voucher) self.assertThat( - persisted_voucher.redeemed, - Equals(True), + persisted_voucher.state, + Equals(Redeemed( + finished=now, + token_count=100, + )), + ) + + @given(tahoe_configs(), datetimes(), vouchers()) + def test_double_spent_after_double_spend(self, get_config, now, voucher): + """ + A ``Voucher`` is marked as double-spent after ``IRedeemer.redeem`` fails + with ``AlreadySpent``. + """ + tempdir = self.useFixture(TempDir()) + store = VoucherStore.from_node_config( + get_config( + tempdir.join(b"node"), + b"tub.port", + ), + now=lambda: now, + connect=memory_connect, + ) + controller = PaymentController( + store, + DoubleSpendRedeemer(), + ) + controller.redeem(voucher) + + persisted_voucher = store.get(voucher) + self.assertThat( + persisted_voucher, + MatchesStructure( + state=Equals(DoubleSpend( + finished=now, + )), + ), ) @@ -184,7 +228,7 @@ class RistrettoRedeemerTests(TestCase): Provides([IRedeemer]), ) - @given(vouchers().map(Voucher), integers(min_value=1, max_value=100)) + @given(voucher_objects(), integers(min_value=1, max_value=100)) def test_good_ristretto_redemption(self, voucher, num_tokens): """ If the issuer returns a successful result then @@ -212,7 +256,33 @@ class RistrettoRedeemerTests(TestCase): ), ) - @given(vouchers().map(Voucher), integers(min_value=1, max_value=100)) + @given(voucher_objects(), integers(min_value=1, max_value=100)) + def test_redemption_denied_alreadyspent(self, voucher, num_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``. + """ + issuer = AlreadySpentRedemption() + treq = treq_for_loopback_ristretto(issuer) + redeemer = RistrettoRedeemer(treq, NOWHERE) + random_tokens = redeemer.random_tokens_for_voucher(voucher, num_tokens) + d = redeemer.redeem( + voucher, + random_tokens, + ) + self.assertThat( + d, + failed( + AfterPreprocessing( + lambda f: f.value, + IsInstance(AlreadySpent), + ), + ), + ) + + @given(voucher_objects(), integers(min_value=1, max_value=100)) def test_bad_ristretto_redemption(self, voucher, num_tokens): """ If the issuer returns a successful result with an invalid proof then @@ -245,7 +315,7 @@ class RistrettoRedeemerTests(TestCase): ), ) - @given(vouchers().map(Voucher), integers(min_value=1, max_value=100)) + @given(voucher_objects(), integers(min_value=1, max_value=100)) def test_ristretto_pass_construction(self, voucher, num_tokens): """ The passes constructed using unblinded tokens and messages pass the @@ -359,6 +429,20 @@ def stub_agent(): return _StubAgent() +class AlreadySpentRedemption(Resource): + """ + An ``AlreadySpentRedemption`` simulates the Ristretto redemption server + but always refuses to allow vouchers to be redeemed and reports an error + that the voucher has already been redeemed. + """ + def render_POST(self, request): + if request.requestHeaders.getRawHeaders(b"content-type") != ["application/json"]: + return bad_content_type(request) + + return bad_request(request, {u"failed": True, u"reason": u"double-spend"}) + + + class RistrettoRedemption(Resource): def __init__(self, signing_key): Resource.__init__(self) @@ -404,6 +488,13 @@ class RistrettoRedemption(Resource): }) +def bad_request(request, body_object): + request.setResponseCode(BAD_REQUEST) + request.setHeader(b"content-type", b"application/json") + request.write(dumps(body_object)) + return b"" + + def bad_content_type(request): return ErrorPage( UNSUPPORTED_MEDIA_TYPE, diff --git a/src/_zkapauthorizer/tests/test_model.py b/src/_zkapauthorizer/tests/test_model.py index 925f464fe664fe85d0a18ef0b16bd98f65d59ccc..7414c67103fa6cd31c361c63a32947d8615e8984 100644 --- a/src/_zkapauthorizer/tests/test_model.py +++ b/src/_zkapauthorizer/tests/test_model.py @@ -65,6 +65,9 @@ from ..model import ( StoreOpenError, VoucherStore, Voucher, + Pending, + DoubleSpend, + Redeemed, open_and_initialize, memory_connect, ) @@ -104,13 +107,7 @@ class VoucherStoreTests(TestCase): ``VoucherStore.get`` raises ``KeyError`` when called with a voucher not previously added to the store. """ - tempdir = self.useFixture(TempDir()) - config = get_config(tempdir.join(b"node"), b"tub.port") - store = VoucherStore.from_node_config( - config, - lambda: now, - memory_connect, - ) + store = store_for_test(self, get_config, lambda: now) self.assertThat( lambda: store.get(voucher), raises(KeyError), @@ -122,19 +119,13 @@ class VoucherStoreTests(TestCase): ``VoucherStore.get`` returns a ``Voucher`` representing a voucher previously added to the store with ``VoucherStore.add``. """ - tempdir = self.useFixture(TempDir()) - config = get_config(tempdir.join(b"node"), b"tub.port") - store = VoucherStore.from_node_config( - config, - lambda: now, - memory_connect, - ) + store = store_for_test(self, get_config, lambda: now) store.add(voucher, tokens) self.assertThat( store.get(voucher), MatchesStructure( number=Equals(voucher), - redeemed=Equals(False), + state=Equals(Pending()), created=Equals(now), ), ) @@ -145,13 +136,7 @@ class VoucherStoreTests(TestCase): More than one call to ``VoucherStore.add`` with the same argument results in the same state as a single call. """ - tempdir = self.useFixture(TempDir()) - config = get_config(tempdir.join(b"node"), b"tub.port") - store = VoucherStore.from_node_config( - config, - lambda: now, - memory_connect, - ) + store = store_for_test(self, get_config, lambda: now) store.add(voucher, tokens) store.add(voucher, []) self.assertThat( @@ -159,8 +144,7 @@ class VoucherStoreTests(TestCase): MatchesStructure( number=Equals(voucher), created=Equals(now), - redeemed=Equals(False), - token_count=Equals(None), + state=Equals(Pending()), ), ) @@ -171,15 +155,7 @@ class VoucherStoreTests(TestCase): ``VoucherStore.list`` returns a ``list`` containing a ``Voucher`` object for each voucher previously added. """ - tempdir = self.useFixture(TempDir()) - nodedir = tempdir.join(b"node") - config = get_config(nodedir, b"tub.port") - store = VoucherStore.from_node_config( - config, - lambda: now, - memory_connect, - ) - + store = store_for_test(self, get_config, lambda: now) for voucher in vouchers: store.add(voucher, []) @@ -238,6 +214,7 @@ class VoucherStoreTests(TestCase): If the underlying database file cannot be opened then ``VoucherStore.from_node_config`` raises ``StoreOpenError``. """ + tempdir = self.useFixture(TempDir()) nodedir = tempdir.join(b"node") @@ -282,13 +259,7 @@ class UnblindedTokenStoreTests(TestCase): """ Unblinded tokens that are added to the store can later be retrieved. """ - tempdir = self.useFixture(TempDir()) - config = get_config(tempdir.join(b"node"), b"tub.port") - store = VoucherStore.from_node_config( - config, - lambda: now, - memory_connect, - ) + store = store_for_test(self, get_config, lambda: now) store.insert_unblinded_tokens_for_voucher(voucher_value, tokens) retrieved_tokens = store.extract_unblinded_tokens(len(tokens)) self.expectThat(tokens, AfterPreprocessing(sorted, Equals(retrieved_tokens))) @@ -326,20 +297,123 @@ class UnblindedTokenStoreTests(TestCase): ), ) - tempdir = self.useFixture(TempDir()) - config = get_config(tempdir.join(b"node"), b"tub.port") - store = VoucherStore.from_node_config( - config, - lambda: now, - memory_connect, - ) + store = store_for_test(self, get_config, lambda: now) store.add(voucher_value, random) store.insert_unblinded_tokens_for_voucher(voucher_value, unblinded) loaded_voucher = store.get(voucher_value) self.assertThat( loaded_voucher, MatchesStructure( - redeemed=Equals(True), - token_count=Equals(num_tokens), + state=Equals(Redeemed( + finished=now, + token_count=num_tokens, + )), ), ) + + @given( + tahoe_configs(), + datetimes(), + vouchers(), + lists(random_tokens(), unique=True), + ) + def test_mark_vouchers_double_spent(self, get_config, now, voucher_value, random_tokens): + """ + A voucher which is reported as double-spent is marked in the database as + such. + """ + store = store_for_test(self, get_config, lambda: now) + store.add(voucher_value, random_tokens) + store.mark_voucher_double_spent(voucher_value) + voucher = store.get(voucher_value) + self.assertThat( + voucher, + MatchesStructure( + state=Equals(DoubleSpend( + finished=now, + )), + ), + ) + + @given( + tahoe_configs(), + datetimes(), + vouchers(), + integers(min_value=1, max_value=100), + data(), + ) + def test_mark_spent_vouchers_double_spent(self, get_config, now, voucher_value, num_tokens, data): + """ + A voucher which has already been spent cannot be marked as double-spent. + """ + random = data.draw( + lists( + random_tokens(), + min_size=num_tokens, + max_size=num_tokens, + unique=True, + ), + ) + unblinded = data.draw( + lists( + unblinded_tokens(), + min_size=num_tokens, + max_size=num_tokens, + unique=True, + ), + ) + store = store_for_test(self, get_config, lambda: now) + store.add(voucher_value, random) + store.insert_unblinded_tokens_for_voucher(voucher_value, unblinded) + try: + result = store.mark_voucher_double_spent(voucher_value) + except ValueError: + pass + except Exception as e: + self.fail("mark_voucher_double_spent raised the wrong exception: {}".format(e)) + else: + self.fail("mark_voucher_double_spent didn't raise, returned: {}".format(result)) + + @given( + tahoe_configs(), + datetimes(), + vouchers(), + ) + def test_mark_invalid_vouchers_double_spent(self, get_config, now, voucher_value): + """ + A voucher which is not known cannot be marked as double-spent. + """ + store = store_for_test(self, get_config, lambda: now) + try: + result = store.mark_voucher_double_spent(voucher_value) + except ValueError: + pass + except Exception as e: + self.fail("mark_voucher_double_spent raised the wrong exception: {}".format(e)) + else: + self.fail("mark_voucher_double_spent didn't raise, returned: {}".format(result)) + + + # TODO: Other error states and transient states + + +def store_for_test(testcase, get_config, get_now): + """ + Create a ``VoucherStore`` in a temporary directory associated with the + given test case. + + :param TestCase testcase: The test case for which to build the store. + :param get_config: A function like the one built by ``tahoe_configs``. + :param get_now: A no-argument callable that returns a datetime giving a + time to consider as "now". + + :return VoucherStore: A newly created temporary store. + """ + tempdir = testcase.useFixture(TempDir()) + config = get_config(tempdir.join(b"node"), b"tub.port") + store = VoucherStore.from_node_config( + config, + get_now, + memory_connect, + ) + return store diff --git a/zkapauthorizer.nix b/zkapauthorizer.nix index 77bbf6657c479b9daab7c133286c0c626efdb104..1a352e4285283753b614732062be8777706e1cf6 100644 --- a/zkapauthorizer.nix +++ b/zkapauthorizer.nix @@ -4,12 +4,12 @@ , hypothesisProfile ? null , collectCoverage ? false , testSuite ? null -, trialArgs ? [] +, trialArgs ? null }: let hypothesisProfile' = if hypothesisProfile == null then "default" else hypothesisProfile; testSuite' = if testSuite == null then "_zkapauthorizer" else testSuite; - extraTrialArgs = builtins.concatStringsSep " " trialArgs; + extraTrialArgs = builtins.concatStringsSep " " (if trialArgs == null then ["--rterrors" "--jobs=4" ] else trialArgs); in buildPythonPackage rec { version = "0.0";