diff --git a/src/_zkapauthorizer/_plugin.py b/src/_zkapauthorizer/_plugin.py index 98eb8da61c3718281cbdf020637d0b6c151ff09f..1b8874e4a7a8353c7112906b2459a85f710efa60 100644 --- a/src/_zkapauthorizer/_plugin.py +++ b/src/_zkapauthorizer/_plugin.py @@ -185,7 +185,7 @@ class ZKAPAuthorizer(object): ) - def get_client_resource(self, node_config, default_token_count=None): + def get_client_resource(self, node_config, default_token_count=None, reactor=None): """ Get an ``IZKAPRoot`` for the given node configuration. @@ -197,12 +197,14 @@ class ZKAPAuthorizer(object): This is only used if a number of tokens isn't specified at the point of redemption. """ - from twisted.internet import reactor + if reactor is None: + from twisted.internet import reactor return resource_from_configuration( node_config, store=self._get_store(node_config), redeemer=self._get_redeemer(node_config, None, reactor), default_token_count=default_token_count, + clock=reactor, ) diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py index 96d70747cc45f31cebd2c67cc3bcd821c3977e54..15d8395080c10777a57cd227fa05e70ffdc301a5 100644 --- a/src/_zkapauthorizer/controller.py +++ b/src/_zkapauthorizer/controller.py @@ -712,6 +712,9 @@ class PaymentController(object): TODO: Retrieve this value from the PaymentServer or from the ZKAPAuthorizer configuration instead of just hard-coding a duplicate value in this implementation. + + :ivar IReactorTime _clock: The reactor to use for scheduling redemption + retries. """ _log = Logger() @@ -721,9 +724,7 @@ class PaymentController(object): num_redemption_groups = attr.ib(default=16) - _clock = attr.ib( - default=attr.Factory(partial(namedAny, "twisted.internet.reactor")), - ) + _clock = attr.ib(default=None) _error = attr.ib(default=attr.Factory(dict)) _unpaid = attr.ib(default=attr.Factory(dict)) @@ -735,6 +736,9 @@ class PaymentController(object): This is an initialization-time hook called by attrs. """ + if self._clock is None: + self._clock = namedAny("twisted.internet.reactor") + self._check_pending_vouchers() # Also start a time-based polling loop to retry redemption of vouchers # in retryable error states. diff --git a/src/_zkapauthorizer/foolscap.py b/src/_zkapauthorizer/foolscap.py index 3d69a5358f258f6fa2807e04dc0b0d8563a243ca..29ed94e7374c22b4a7534a9cc843a2db9970be79 100644 --- a/src/_zkapauthorizer/foolscap.py +++ b/src/_zkapauthorizer/foolscap.py @@ -31,6 +31,7 @@ from foolscap.api import ( DictOf, ListOf, Copyable, + RemoteCopy, ) from foolscap.remoteinterface import ( RemoteMethodSchema, @@ -44,7 +45,7 @@ from allmydata.interfaces import ( ) @attr.s -class ShareStat(Copyable): +class ShareStat(Copyable, RemoteCopy): """ Represent some metadata about a share. @@ -53,8 +54,16 @@ class ShareStat(Copyable): :ivar int lease_expiration: The POSIX timestamp of the time at which the lease on this share expires, or None if there is no lease. """ - size = attr.ib() - lease_expiration = attr.ib() + typeToCopy = copytype = "ShareStat" + + # To be a RemoteCopy it must be possible to instantiate this with no + # arguments. :/ So supply defaults for these attributes. + size = attr.ib(default=0) + lease_expiration = attr.ib(default=0) + + # The RemoteCopy interface + def setCopyableState(self, state): + self.__dict__ = state # The Foolscap convention seems to be to try to constrain inputs to valid diff --git a/src/_zkapauthorizer/resource.py b/src/_zkapauthorizer/resource.py index 96dc29dbaeeda4ed9ee2f04150d5651c85380af4..f8fa7a9dab92f86bba84ece5a21a0e1cfeb603e4 100644 --- a/src/_zkapauthorizer/resource.py +++ b/src/_zkapauthorizer/resource.py @@ -91,7 +91,13 @@ class IZKAPRoot(IResource): controller = Attribute("The ``PaymentController`` used by this resource tree.") -def from_configuration(node_config, store, redeemer=None, default_token_count=None): +def from_configuration( + node_config, + store, + redeemer=None, + default_token_count=None, + clock=None, +): """ Instantiate the plugin root resource using data from its configuration section, **storageclient.plugins.privatestorageio-zkapauthz-v1**, in the @@ -108,6 +114,10 @@ def from_configuration(node_config, store, redeemer=None, default_token_count=No :param IRedeemer redeemer: The voucher redeemer to use. If ``None`` a sensible one is constructed. + :param default_token_count: See ``PaymentController.default_token_count``. + + :param clock: See ``PaymentController._clock``. + :return IZKAPRoot: The root of the resource hierarchy presented by the client side of the plugin. """ @@ -120,7 +130,12 @@ def from_configuration(node_config, store, redeemer=None, default_token_count=No ) if default_token_count is None: default_token_count = NUM_TOKENS - controller = PaymentController(store, redeemer, default_token_count) + controller = PaymentController( + store, + redeemer, + default_token_count, + clock=clock, + ) calculator = PriceCalculator( get_configured_shares_needed(node_config), diff --git a/src/_zkapauthorizer/tests/fixtures.py b/src/_zkapauthorizer/tests/fixtures.py index 00be5b25283194c4a9454d6fa5314a6695b60650..35aadaea07c79020433806bdcf6ccea6cb2e4410 100644 --- a/src/_zkapauthorizer/tests/fixtures.py +++ b/src/_zkapauthorizer/tests/fixtures.py @@ -30,7 +30,9 @@ from fixtures import ( from twisted.python.filepath import ( FilePath, ) - +from twisted.internet.task import ( + Clock, +) from allmydata.storage.server import ( StorageServer, ) @@ -125,6 +127,7 @@ class ConfiglessMemoryVoucherStore(Fixture): # minimum token count requirement (can't have fewer tokens # than groups). num_redemption_groups=1, + clock=Clock(), ).redeem( voucher, ) diff --git a/src/_zkapauthorizer/tests/foolscap.py b/src/_zkapauthorizer/tests/foolscap.py index c9bb485a927b8884b131c3b3e4f6c36fe264aadf..35716998d69bb32c5066f244e285aececd0bf6ba 100644 --- a/src/_zkapauthorizer/tests/foolscap.py +++ b/src/_zkapauthorizer/tests/foolscap.py @@ -27,11 +27,19 @@ from zope.interface import ( import attr from twisted.internet.defer import ( - execute, + succeed, + fail, ) from foolscap.api import ( RemoteInterface, + Referenceable, + Copyable, + Any, +) +from foolscap.copyable import ( + ICopyable, + CopyableSlicer, ) from allmydata.interfaces import ( @@ -41,6 +49,11 @@ from allmydata.interfaces import ( class RIStub(RemoteInterface): pass + +class RIEcho(RemoteInterface): + def echo(argument=Any()): + return Any() + @implementer(RIStorageServer) class StubStorageServer(object): pass @@ -50,6 +63,18 @@ def get_anonymous_storage_server(): return StubStorageServer() +class BrokenCopyable(Copyable): + """ + I don't have a ``typeToCopy`` so I can't be serialized. + """ + + +@implementer(RIEcho) +class Echoer(Referenceable): + def remote_echo(self, argument): + return argument + + @attr.s class DummyReferenceable(object): _interface = attr.ib() @@ -103,18 +128,37 @@ class LocalRemote(object): """ Call the given method on the wrapped object, passing the given arguments. - Arguments are checked for conformance to the remote interface but the - return value is not (because I don't know how -exarkun). + Arguments and return are checked for conformance to the remote + interface but they are not actually serialized. :return Deferred: The result of the call on the wrapped object. """ - schema = self._referenceable.getInterface()[methname] - if self.check_args: - schema.checkAllArgs(args, kwargs, inbound=False) - # TODO: Figure out how to call checkResults on the result. - return execute( - self._referenceable.doRemoteCall, - methname, - args, - kwargs, - ) + try: + schema = self._referenceable.getInterface()[methname] + if self.check_args: + schema.checkAllArgs(args, kwargs, inbound=True) + _check_copyables(list(args) + kwargs.values()) + result = self._referenceable.doRemoteCall( + methname, + args, + kwargs, + ) + schema.checkResults(result, inbound=False) + _check_copyables([result]) + return succeed(result) + except: + return fail() + + +def _check_copyables(copyables): + """ + Check each object to see if it is a copyable and if it is make sure it can + be sliced. + """ + for obj in copyables: + if ICopyable.providedBy(obj): + list(CopyableSlicer(obj).slice(False, None)) + elif isinstance(obj, dict): + _check_copyables(obj.values()) + elif isinstance(obj, list): + _check_copyables(obj) diff --git a/src/_zkapauthorizer/tests/test_client_resource.py b/src/_zkapauthorizer/tests/test_client_resource.py index e13d4747e0c2c3246c4ce012b7da911df0970212..6b38da748c5e7ce8f541b72698f79c974cfab177 100644 --- a/src/_zkapauthorizer/tests/test_client_resource.py +++ b/src/_zkapauthorizer/tests/test_client_resource.py @@ -103,6 +103,7 @@ from twisted.internet.defer import ( ) from twisted.internet.task import ( Cooperator, + Clock, ) from twisted.web.http import ( OK, @@ -278,6 +279,7 @@ def root_from_config(config, now): memory_connect, ), default_token_count=NUM_TOKENS, + clock=Clock(), ) diff --git a/src/_zkapauthorizer/tests/test_controller.py b/src/_zkapauthorizer/tests/test_controller.py index bec922bb0863dea1caee2df78cd783e713894d11..25568d09803c21def1bf10a49d7b088e6bf00895 100644 --- a/src/_zkapauthorizer/tests/test_controller.py +++ b/src/_zkapauthorizer/tests/test_controller.py @@ -73,6 +73,9 @@ from twisted.python.url import ( from twisted.internet.defer import ( fail, ) +from twisted.internet.task import ( + Clock, +) from twisted.web.iweb import ( IAgent, ) @@ -228,6 +231,7 @@ class PaymentControllerTests(TestCase): store, DummyRedeemer(), default_token_count=100, + clock=Clock(), ) self.assertThat( @@ -263,6 +267,7 @@ class PaymentControllerTests(TestCase): store, NonRedeemer(), default_token_count=100, + clock=Clock(), ) self.assertThat( controller.redeem(voucher), @@ -299,6 +304,7 @@ class PaymentControllerTests(TestCase): # Require more success than we're going to get so it doesn't # finish. num_redemption_groups=counter, + clock=Clock(), ) self.assertThat( @@ -353,6 +359,7 @@ class PaymentControllerTests(TestCase): ), default_token_count=num_tokens, num_redemption_groups=num_redemption_groups, + clock=Clock(), ) self.assertThat( controller.redeem(voucher), @@ -378,6 +385,7 @@ class PaymentControllerTests(TestCase): # The number of redemption groups must not change for # redemption of a particular voucher. num_redemption_groups=num_redemption_groups, + clock=Clock(), ) first_try() @@ -412,6 +420,7 @@ class PaymentControllerTests(TestCase): redeemer, default_token_count=num_tokens, num_redemption_groups=num_redemption_groups, + clock=Clock(), ) self.assertThat( controller.redeem(voucher), @@ -435,6 +444,7 @@ class PaymentControllerTests(TestCase): store, DummyRedeemer(public_key), default_token_count=100, + clock=Clock(), ) self.assertThat( controller.redeem(voucher), @@ -462,6 +472,7 @@ class PaymentControllerTests(TestCase): store, DoubleSpendRedeemer(), default_token_count=100, + clock=Clock(), ) self.assertThat( controller.redeem(voucher), @@ -491,6 +502,7 @@ class PaymentControllerTests(TestCase): store, UnpaidRedeemer(), default_token_count=100, + clock=Clock(), ) self.assertThat( unpaid_controller.redeem(voucher), @@ -510,6 +522,7 @@ class PaymentControllerTests(TestCase): store, DummyRedeemer(), default_token_count=100, + clock=Clock(), ) self.assertThat( diff --git a/src/_zkapauthorizer/tests/test_foolscap.py b/src/_zkapauthorizer/tests/test_foolscap.py index 388cc116fe71613c28419f87cdf6e2c4777e68c7..5912b3531c32e8b896834a77f88d66469aea0d85 100644 --- a/src/_zkapauthorizer/tests/test_foolscap.py +++ b/src/_zkapauthorizer/tests/test_foolscap.py @@ -20,16 +20,36 @@ from __future__ import ( absolute_import, ) +from fixtures import ( + Fixture, +) from testtools import ( TestCase, ) from testtools.matchers import ( + Equals, MatchesAll, AfterPreprocessing, Always, IsInstance, ) +from testtools.twistedsupport import ( + succeeded, + failed, +) + +from twisted.trial.unittest import ( + TestCase as TrialTestCase, +) +from twisted.internet.defer import ( + inlineCallbacks, +) +from foolscap.api import ( + Violation, + RemoteInterface, + Any, +) from foolscap.furl import ( decode_furl, ) @@ -51,10 +71,27 @@ from hypothesis.strategies import ( from .foolscap import ( RIStub, + Echoer, LocalRemote, + BrokenCopyable, DummyReferenceable, ) +from ..foolscap import ( + ShareStat, +) + +class IHasSchema(RemoteInterface): + def method(arg=int): + return bytes + + def good_method(arg=int): + return None + + def whatever_method(arg=Any()): + return Any() + + def remote_reference(): tub = Tub() tub.setLocation("127.0.0.1:12345") @@ -95,3 +132,119 @@ class LocalRemoteTests(TestCase): ), ), ) + + def test_arg_schema(self): + """ + ``LocalRemote.callRemote`` returns a ``Deferred`` that fails with a + ``Violation`` if an parameter receives an argument which doesn't + conform to its schema. + """ + ref = LocalRemote(DummyReferenceable(IHasSchema)) + self.assertThat( + ref.callRemote("method", None), + failed( + AfterPreprocessing( + lambda f: f.type, + Equals(Violation), + ), + ), + ) + + def test_result_schema(self): + """ + ``LocalRemote.callRemote`` returns a ``Deferred`` that fails with a + ``Violation`` if a method returns an object which doesn't conform to + the method's result schema. + """ + ref = LocalRemote(DummyReferenceable(IHasSchema)) + self.assertThat( + ref.callRemote("method", 0), + failed( + AfterPreprocessing( + lambda f: f.type, + Equals(Violation), + ), + ), + ) + + def test_successful_method(self): + """ + ``LocalRemote.callRemote`` returns a ``Deferred`` that fires with the + remote method's result if the arguments and result conform to their + respective schemas. + """ + ref = LocalRemote(DummyReferenceable(IHasSchema)) + self.assertThat( + ref.callRemote("good_method", 0), + succeeded(Equals(None)), + ) + + def test_argument_serialization_failure(self): + """ + ``LocalRemote.callRemote`` returns a ``Deferred`` that fires with a + failure if an argument cannot be serialized. + """ + ref = LocalRemote(DummyReferenceable(IHasSchema)) + self.assertThat( + ref.callRemote("whatever_method", BrokenCopyable()), + failed(Always()), + ) + + def test_result_serialization_failure(self): + """ + ``LocalRemote.callRemote`` returns a ``Deferred`` that fires with a + failure if the method's result cannot be serialized. + """ + class BrokenResultReferenceable(DummyReferenceable): + def doRemoteCall(self, *a, **kw): + return BrokenCopyable() + + ref = LocalRemote(BrokenResultReferenceable(IHasSchema)) + self.assertThat( + ref.callRemote("whatever_method", None), + failed(Always()), + ) + + +class EchoerFixture(Fixture): + def __init__(self, reactor, tub_path): + self.reactor = reactor + self.tub = Tub() + self.tub.setLocation(b"tcp:0") + + def _setUp(self): + self.tub.startService() + self.furl = self.tub.registerReference(Echoer()) + + def _cleanUp(self): + return self.tub.stopService() + + +class SerializationTests(TrialTestCase): + """ + Tests for the serialization of types used in the Foolscap API. + """ + def test_sharestat(self): + """ + A ``ShareStat`` instance can be sent as an argument to and received in a + response from a Foolscap remote method call. + """ + return self._roundtrip_test(ShareStat(1, 2)) + + @inlineCallbacks + def _roundtrip_test(self, obj): + """ + Send ``obj`` over Foolscap and receive it back again, equal to itself. + """ + # Foolscap Tub implementation just uses the global reactor... + from twisted.internet import reactor + + # So sad. No Deferred support in testtools.TestCase or + # fixture.Fixture, no fixture support in + # twisted.trial.unittest.TestCase. + fx = EchoerFixture(reactor, self.mktemp()) + fx.setUp() + self.addCleanup(fx._cleanUp) + echoer = yield fx.tub.getReference(fx.furl) + received = yield echoer.callRemote("echo", obj) + self.assertEqual(obj, received) diff --git a/src/_zkapauthorizer/tests/test_plugin.py b/src/_zkapauthorizer/tests/test_plugin.py index 44c79af85c0a783ec4af01ae06cc44309686a000..e28f06be5ec06360346d5790c06df3293479425a 100644 --- a/src/_zkapauthorizer/tests/test_plugin.py +++ b/src/_zkapauthorizer/tests/test_plugin.py @@ -99,6 +99,9 @@ from twisted.plugin import ( from twisted.test.proto_helpers import ( StringTransport, ) +from twisted.internet.task import ( + Clock, +) from twisted.web.resource import ( IResource, ) @@ -483,6 +486,7 @@ class ClientPluginTests(TestCase): DummyRedeemer(), default_token_count=num_passes, num_redemption_groups=1, + clock=Clock(), ) # Get a token inserted into the store. redeeming = controller.redeem(voucher) @@ -543,7 +547,11 @@ class ClientResourceTests(TestCase): nodedir = tempdir.join(b"node") config = get_config(nodedir, b"tub.port") self.assertThat( - storage_server.get_client_resource(config, default_token_count=10), + storage_server.get_client_resource( + config, + default_token_count=10, + reactor=Clock(), + ), Provides([IResource]), )