From 17be569853a221e1fdd70a21b19a1a7bfbf40426 Mon Sep 17 00:00:00 2001
From: Jean-Paul Calderone <exarkun@twistedmatrix.com>
Date: Mon, 18 Nov 2019 11:54:16 -0500
Subject: [PATCH] represent the error state in the web interface too

---
 src/_zkapauthorizer/controller.py             | 52 +++++++++++++++++--
 src/_zkapauthorizer/model.py                  | 23 ++++++++
 src/_zkapauthorizer/tests/strategies.py       | 10 ++++
 .../tests/test_client_resource.py             | 27 ++++++++++
 4 files changed, 108 insertions(+), 4 deletions(-)

diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py
index fb3bd82..1939478 100644
--- a/src/_zkapauthorizer/controller.py
+++ b/src/_zkapauthorizer/controller.py
@@ -70,6 +70,7 @@ from .model import (
     Pending as model_Pending,
     Unpaid as model_Unpaid,
     Redeeming as model_Redeeming,
+    Error as model_Error,
 )
 
 
@@ -179,6 +180,35 @@ 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):
@@ -462,6 +492,9 @@ class PaymentController(object):
         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
@@ -472,6 +505,7 @@ class PaymentController(object):
     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))
 
@@ -534,11 +568,15 @@ class PaymentController(object):
             )
             self._unpaid[voucher] = self.store.now()
         else:
-            self._log.failure(
-                "Redeeming random tokens for a voucher ({voucher}) failed.",
-                reason,
+            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):
@@ -563,6 +601,11 @@ class PaymentController(object):
                     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
 
 
@@ -581,6 +624,7 @@ _REDEEMERS = {
     u"dummy": DummyRedeemer.make,
     u"double-spend": DoubleSpendRedeemer.make,
     u"unpaid": UnpaidRedeemer.make,
+    u"error": ErrorRedeemer.make,
     u"ristretto": RistrettoRedeemer.make,
 }
 
@@ -610,7 +654,7 @@ def bracket(first, last, between):
     except:
         info = exc_info()
         yield last()
-        raise info
+        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 4337dec..8af6d83 100644
--- a/src/_zkapauthorizer/model.py
+++ b/src/_zkapauthorizer/model.py
@@ -528,6 +528,24 @@ class Unpaid(object):
         }
 
 
+@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):
     """
@@ -609,6 +627,11 @@ class Voucher(object):
             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))
 
diff --git a/src/_zkapauthorizer/tests/strategies.py b/src/_zkapauthorizer/tests/strategies.py
index 9d1ff65..223a7ec 100644
--- a/src/_zkapauthorizer/tests/strategies.py
+++ b/src/_zkapauthorizer/tests/strategies.py
@@ -216,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
diff --git a/src/_zkapauthorizer/tests/test_client_resource.py b/src/_zkapauthorizer/tests/test_client_resource.py
index 7af34c1..22d2f54 100644
--- a/src/_zkapauthorizer/tests/test_client_resource.py
+++ b/src/_zkapauthorizer/tests/test_client_resource.py
@@ -116,6 +116,7 @@ from ..model import (
     Redeemed,
     DoubleSpend,
     Unpaid,
+    Error,
     VoucherStore,
     memory_connect,
 )
@@ -129,6 +130,7 @@ from .strategies import (
     client_doublespendredeemer_configurations,
     client_dummyredeemer_configurations,
     client_nonredeemer_configurations,
+    client_errorredeemer_configurations,
     vouchers,
     requests,
 )
@@ -136,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(
@@ -687,6 +691,29 @@ class VoucherTests(TestCase):
             ),
         )
 
+    @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
-- 
GitLab