From 3a81cab627289bbdfd7c386ce2b24e217cdc4efa Mon Sep 17 00:00:00 2001
From: Jean-Paul Calderone <exarkun@twistedmatrix.com>
Date: Fri, 15 Nov 2019 10:48:40 -0500
Subject: [PATCH] Teach PaymentController what to do on AlreadySpent errors

---
 src/_zkapauthorizer/controller.py            | 55 +++++++++++++++++---
 src/_zkapauthorizer/tests/test_controller.py | 34 ++++++++++++
 2 files changed, 81 insertions(+), 8 deletions(-)

diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py
index 56dae0b..b1c4d4b 100644
--- a/src/_zkapauthorizer/controller.py
+++ b/src/_zkapauthorizer/controller.py
@@ -39,6 +39,7 @@ from twisted.python.url import (
 from twisted.internet.defer import (
     Deferred,
     succeed,
+    fail,
     inlineCallbacks,
     returnValue,
 )
@@ -62,6 +63,13 @@ from .model import (
 )
 
 
+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 IRedeemer(Interface):
     """
     An ``IRedeemer`` can exchange a voucher for one or more passes.
@@ -100,7 +108,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 +161,28 @@ class NonRedeemer(object):
         )
 
 
+@implementer(IRedeemer)
+@attr.s
+class DoubleSpentRedeemer(object):
+    """
+    A ``DoubleSpentRedeemer`` pretends to try to redeem vouchers for ZKAPs but
+    always fails with an error indicating the voucher has already been spent.
+    """
+    def random_tokens_for_voucher(self, voucher, count):
+        return dummy_random_tokens(voucher, count)
+
+    def redeem(self, voucher, random_tokens):
+        return fail(AlreadySpent(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 +200,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):
         """
@@ -431,7 +459,18 @@ 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)
+        else:
+            self._log.failure(
+                "Redeeming random tokens for a voucher ({voucher}) failed.",
+                reason,
+                voucher=voucher,
+            )
         return None
 
     def _finalRedeemError(self, voucher, reason):
diff --git a/src/_zkapauthorizer/tests/test_controller.py b/src/_zkapauthorizer/tests/test_controller.py
index bb72c66..17876ca 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,
@@ -94,6 +95,7 @@ from ..controller import (
     IRedeemer,
     NonRedeemer,
     DummyRedeemer,
+    DoubleSpentRedeemer,
     RistrettoRedeemer,
     PaymentController,
 )
@@ -146,6 +148,9 @@ class PaymentControllerTests(TestCase):
 
     @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(
@@ -167,6 +172,35 @@ class PaymentControllerTests(TestCase):
             Equals(u"redeemed"),
         )
 
+    @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,
+            DoubleSpentRedeemer(),
+        )
+        controller.redeem(voucher)
+
+        persisted_voucher = store.get(voucher)
+        self.assertThat(
+            persisted_voucher,
+            MatchesStructure(
+                state=Equals(u"double-spend"),
+            ),
+        )
+
 
 NOWHERE = URL.from_text(u"https://127.0.0.1/")
 
-- 
GitLab