diff --git a/default.nix b/default.nix index 3a47363302f17d5aa412692d43ce90609fb6783d..04193ed1586856a9b21d7403a0cde175071b3344 100644 --- a/default.nix +++ b/default.nix @@ -1,2 +1,2 @@ -{ pkgs ? import ./nixpkgs.nix { }, hypothesisProfile ? null, collectCoverage ? false, testSuite ? null }: -pkgs.python27Packages.zkapauthorizer.override { inherit hypothesisProfile collectCoverage testSuite; } +{ pkgs ? import ./nixpkgs.nix { }, hypothesisProfile ? null, collectCoverage ? false, testSuite ? null, trialArgs ? [] }: +pkgs.python27Packages.zkapauthorizer.override { inherit hypothesisProfile collectCoverage testSuite trialArgs; } diff --git a/docs/source/interface.rst b/docs/source/interface.rst index 261127a9e8f27f4f086f32e14b6a3a5a43197429..cabf775ffa7303759b286c003c14981a0c0f187d 100644 --- a/docs/source/interface.rst +++ b/docs/source/interface.rst @@ -48,3 +48,20 @@ The response is **OK** with ``application/json`` content-type response body like {"vouchers": [<voucher status object>, ...]} The elements of the list are objects like the one returned by issuing a **GET** to a child of this collection resource. + +``GET /storage-plugins/privatestorageio/zkapauthz-v1/unblinded-token`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This endpoint allows an external agent to retrieve unused unblinded tokens present in the node's database. +Unblinded tokens are returned in ascending text sorted order. +This endpoint accepts several query arguments: + + * limit: An integer limiting the number of unblinded tokens to retrieve. + * position: A string which can be compared against unblinded token values. + Only unblinded tokens which sort as great than this value are returned. + +This endpoint accepts no request body. + +The response is **OK** with ``application/json`` content-type response body like:: + + {"total": <integer>, "unblinded-tokens": [<unblinded token string>, ...]} diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py index 3d78b9ddaae7491b0e00cf153855c7d4157d0498..56dae0b1da1551bbce5d2bf47101747f28e41d74 100644 --- a/src/_zkapauthorizer/controller.py +++ b/src/_zkapauthorizer/controller.py @@ -128,6 +128,10 @@ class NonRedeemer(object): """ A ``NonRedeemer`` never tries to redeem vouchers for ZKAPs. """ + @classmethod + def make(cls, section_name, node_config, announcement, reactor): + return cls() + def random_tokens_for_voucher(self, voucher, count): # It doesn't matter because we're never going to try to redeem them. return list( @@ -385,9 +389,11 @@ class PaymentController(object): store = attr.ib() redeemer = attr.ib() - def redeem(self, voucher): + def redeem(self, voucher, num_tokens=100): """ :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 @@ -399,7 +405,7 @@ class PaymentController(object): # number of passes that can be constructed is still only the size of # the set of random tokens. self._log.info("Generating random tokens for a voucher ({voucher}).", voucher=voucher) - tokens = self.redeemer.random_tokens_for_voucher(Voucher(voucher), 100) + tokens = self.redeemer.random_tokens_for_voucher(Voucher(voucher), num_tokens) # Persist the voucher and tokens so they're available if we fail. self._log.info("Persistenting random tokens for a voucher ({voucher}).", voucher=voucher) @@ -413,6 +419,7 @@ class PaymentController(object): partial(self._redeemFailure, voucher), ) d.addErrback(partial(self._finalRedeemError, voucher)) + return d def _redeemSuccess(self, voucher, unblinded_tokens): """ @@ -443,6 +450,7 @@ def get_redeemer(plugin_name, node_config, announcement, reactor): _REDEEMERS = { + u"non": NonRedeemer.make, u"dummy": DummyRedeemer.make, u"ristretto": RistrettoRedeemer.make, } diff --git a/src/_zkapauthorizer/model.py b/src/_zkapauthorizer/model.py index 76a659da860617fd26dc39afaebe3b25be8cad3a..5fa84f84de2dc533925d94298d06b8a131b3ce87 100644 --- a/src/_zkapauthorizer/model.py +++ b/src/_zkapauthorizer/model.py @@ -340,6 +340,22 @@ class VoucherStore(object): in texts ) + @with_cursor + def backup(self, cursor): + """ + Read out all state necessary to recreate this database in the event it is + lost. + """ + cursor.execute( + """ + SELECT [token] FROM [unblinded-tokens] ORDER BY [token] + """, + ) + tokens = cursor.fetchall() + return { + u"unblinded-tokens": list(token for (token,) in tokens), + } + @attr.s(frozen=True) class UnblindedToken(object): diff --git a/src/_zkapauthorizer/resource.py b/src/_zkapauthorizer/resource.py index c724fbca870dabb0457688c2ce162b91290161fd..9d13a02193ee85703e4e1dcb5b53f00c706c2dae 100644 --- a/src/_zkapauthorizer/resource.py +++ b/src/_zkapauthorizer/resource.py @@ -20,10 +20,18 @@ vouchers for fresh tokens. In the future it should also allow users to read statistics about token usage. """ +from sys import ( + maxint, +) +from itertools import ( + islice, +) from json import ( loads, dumps, ) - +from zope.interface import ( + Attribute, +) from twisted.logger import ( Logger, ) @@ -31,6 +39,7 @@ from twisted.web.http import ( BAD_REQUEST, ) from twisted.web.resource import ( + IResource, ErrorPage, NoResource, Resource, @@ -42,9 +51,17 @@ from ._base64 import ( from .controller import ( PaymentController, - NonRedeemer, + get_redeemer, ) +class IZKAPRoot(IResource): + """ + The root of the resource tree of this plugin's client web presence. + """ + store = Attribute("The ``VoucherStore`` used by this resource tree.") + controller = Attribute("The ``PaymentController`` used by this resource tree.") + + def from_configuration(node_config, store, redeemer=None): """ Instantiate the plugin root resource using data from its configuration @@ -63,13 +80,20 @@ def from_configuration(node_config, store, redeemer=None): :param IRedeemer redeemer: The voucher redeemer to use. If ``None`` a sensible one is constructed. - :return IResource: The root of the resource hierarchy presented by the + :return IZKAPRoot: The root of the resource hierarchy presented by the client side of the plugin. """ if redeemer is None: - redeemer = NonRedeemer() + redeemer = get_redeemer( + u"privatestorageio-zkapauthz-v1", + node_config, + None, + None, + ) controller = PaymentController(store, redeemer) root = Resource() + root.store = store + root.controller = controller root.putChild( b"voucher", _VoucherCollection( @@ -77,9 +101,63 @@ def from_configuration(node_config, store, redeemer=None): controller, ), ) + root.putChild( + b"unblinded-token", + _UnblindedTokenCollection( + store, + controller, + ), + ) return root +def application_json(request): + """ + Set the given request's response content-type to ``application/json``. + + :param twisted.web.iweb.IRequest request: The request to modify. + """ + request.responseHeaders.setRawHeaders(u"content-type", [u"application/json"]) + + +class _UnblindedTokenCollection(Resource): + """ + This class implements inspection of unblinded tokens. Users **GET** this + resource to find out about unblinded tokens in the system. + """ + _log = Logger() + + def __init__(self, store, controller): + self._store = store + self._controller = controller + Resource.__init__(self) + + def render_GET(self, request): + """ + Retrieve some unblinded tokens and associated information. + """ + application_json(request) + state = self._store.backup() + unblinded_tokens = state[u"unblinded-tokens"] + + limit = request.args.get(b"limit", [None])[0] + if limit is not None: + limit = min(maxint, int(limit)) + + position = request.args.get(b"position", [b""])[0].decode("utf-8") + + return dumps({ + u"total": len(unblinded_tokens), + u"unblinded-tokens": list(islice(( + token + for token + in unblinded_tokens + if token > position + ), limit)), + }) + + + class _VoucherCollection(Resource): """ This class implements redemption of vouchers. Users **PUT** such numbers @@ -115,7 +193,7 @@ class _VoucherCollection(Resource): def render_GET(self, request): - request.responseHeaders.setRawHeaders(u"content-type", [u"application/json"]) + application_json(request) return dumps({ u"vouchers": list( voucher.marshal() @@ -172,7 +250,7 @@ class VoucherView(Resource): def render_GET(self, request): - request.responseHeaders.setRawHeaders(u"content-type", [u"application/json"]) + application_json(request) return self._voucher.to_json() diff --git a/src/_zkapauthorizer/tests/strategies.py b/src/_zkapauthorizer/tests/strategies.py index dc9e0fb63df2a7872291f3fcdbf527662ae81617..c9c7ec226b7df5be6a4065850fa3fe07edd2280a 100644 --- a/src/_zkapauthorizer/tests/strategies.py +++ b/src/_zkapauthorizer/tests/strategies.py @@ -133,16 +133,6 @@ def minimal_tahoe_configs(storage_client_plugins=None): ) -def tahoe_configs(): - """ - Build complete Tahoe-LAFS configurations including the zkapauthorizer - client plugin section. - """ - return minimal_tahoe_configs({ - u"privatestorageio-zkapauthz-v1": client_configurations(), - }) - - def node_nicknames(): """ Builds Tahoe-LAFS node nicknames. @@ -174,12 +164,41 @@ def server_configurations(signing_key_path): }) -def client_configurations(): +def client_ristrettoredeemer_configurations(): """ - Build configuration values for the client-side plugin. + Build Ristretto-using configuration values for the client-side plugin. """ return just({ u"ristretto-issuer-root-url": u"https://issuer.example.invalid/", + u"redeemer": u"ristretto", + }) + + +def client_dummyredeemer_configurations(): + """ + Build DummyRedeemer-using configuration values for the client-side plugin. + """ + return just({ + u"redeemer": u"dummy", + }) + + +def client_nonredeemer_configurations(): + """ + Build NonRedeemer-using configuration values for the client-side plugin. + """ + return just({ + u"redeemer": u"non", + }) + + +def tahoe_configs(zkapauthz_v1_configuration=client_dummyredeemer_configurations()): + """ + Build complete Tahoe-LAFS configurations including the zkapauthorizer + client plugin section. + """ + return minimal_tahoe_configs({ + u"privatestorageio-zkapauthz-v1": zkapauthz_v1_configuration, }) diff --git a/src/_zkapauthorizer/tests/test_client_resource.py b/src/_zkapauthorizer/tests/test_client_resource.py index 20047d3cf2b986112c386dd99e1cf782d87cb573..a73b7bb8a63b19bb3e7d0822692171ed0406cd47 100644 --- a/src/_zkapauthorizer/tests/test_client_resource.py +++ b/src/_zkapauthorizer/tests/test_client_resource.py @@ -44,9 +44,14 @@ from testtools import ( from testtools.matchers import ( MatchesStructure, MatchesAll, + AllMatch, + HasLength, + IsInstance, + ContainsDict, AfterPreprocessing, Equals, Always, + GreaterThan, ) from testtools.twistedsupport import ( CaptureTwistedLogs, @@ -106,6 +111,8 @@ from ..resource import ( from .strategies import ( tahoe_configs, + client_dummyredeemer_configurations, + client_nonredeemer_configurations, vouchers, requests, ) @@ -205,30 +212,203 @@ def root_from_config(config): ) -class VoucherTests(TestCase): +class ResourceTests(TestCase): """ - Tests relating to ``/voucher`` as implemented by the - ``_zkapauthorizer.resource`` module and its handling of - vouchers. + General tests for the resources exposed by the plugin. + """ + @given(tahoe_configs(), requests(just([u"unblinded-token"]) | just([u"voucher"]))) + def test_reachable(self, get_config, request): + """ + A resource is reachable at a child of the resource returned by + ``from_configuration``. + """ + tempdir = self.useFixture(TempDir()) + config = get_config(tempdir.join(b"tahoe"), b"tub.port") + root = root_from_config(config) + self.assertThat( + getChildForRequest(root, request), + Provides([IResource]), + ) + + +class UnblindedTokenTests(TestCase): + """ + Tests relating to ``/unblinded-token`` as implemented by the + ``_zkapauthorizer.resource`` module. """ def setUp(self): - super(VoucherTests, self).setUp() + super(UnblindedTokenTests, self).setUp() self.useFixture(CaptureTwistedLogs()) - @given(tahoe_configs(), requests(just([u"voucher"]))) - def test_reachable(self, get_config, request): + @given(tahoe_configs(), vouchers(), integers(min_value=0, max_value=100)) + def test_get(self, get_config, voucher, num_tokens): + """ + When the unblinded token collection receives a **GET**, the response is the + total number of unblinded tokens in the system and the unblinded tokens + themselves. + """ + tempdir = self.useFixture(TempDir()) + config = get_config(tempdir.join(b"tahoe"), b"tub.port") + root = root_from_config(config) + + if num_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. + self.assertThat( + redeeming, + succeeded(Always()), + ) + + agent = RequestTraversalAgent(root) + requesting = agent.request( + b"GET", + b"http://127.0.0.1/unblinded-token", + ) + self.addDetail( + u"requesting result", + text_content(u"{}".format(vars(requesting.result))), + ) + self.assertThat( + requesting, + 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): """ - A resource is reachable at the ``voucher`` child of a the resource - returned by ``from_configuration``. + When the unblinded token collection receives a **GET** with a **limit** + query argument, it returns no more unblinded tokens than indicated by + the limit. """ tempdir = self.useFixture(TempDir()) config = get_config(tempdir.join(b"tahoe"), b"tub.port") root = root_from_config(config) + + if num_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. + self.assertThat( + redeeming, + succeeded(Always()), + ) + + agent = RequestTraversalAgent(root) + requesting = agent.request( + b"GET", + b"http://127.0.0.1/unblinded-token?limit={}".format(limit), + ) + self.addDetail( + u"requesting result", + text_content(u"{}".format(vars(requesting.result))), + ) self.assertThat( - getChildForRequest(root, request), - Provides([IResource]), + requesting, + 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): + """ + When the unblinded token collection receives a **GET** with a **position** + query argument, it returns all unblinded tokens which sort greater + than the position and no others. + """ + tempdir = self.useFixture(TempDir()) + config = get_config(tempdir.join(b"tahoe"), b"tub.port") + root = root_from_config(config) + + if num_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. + self.assertThat( + redeeming, + succeeded(Always()), + ) + + agent = RequestTraversalAgent(root) + requesting = agent.request( + b"GET", + b"http://127.0.0.1/unblinded-token?position={}".format( + quote(position.encode("utf-8"), safe=b""), + ), + ) + self.addDetail( + u"requesting result", + text_content(u"{}".format(vars(requesting.result))), + ) + self.assertThat( + requesting, + succeeded_with_unblinded_tokens_with_matcher( + num_tokens, + AllMatch( + MatchesAll( + GreaterThan(position), + IsInstance(unicode), + ), + ), + ), + ) + + +def succeeded_with_unblinded_tokens_with_matcher(all_token_count, match_unblinded_tokens): + """ + :return: A matcher which matches a Deferred which fires with a response + like the one returned by the **unblinded-tokens** endpoint. + + :param int all_token_count: The expected value in the ``total`` field of + the response. + + :param match_unblinded_tokens: A matcher for the ``unblinded-tokens`` + field of the response. + """ + return succeeded( + MatchesAll( + ok_response(headers=application_json()), + AfterPreprocessing( + json_content, + succeeded( + ContainsDict({ + u"total": Equals(all_token_count), + u"unblinded-tokens": match_unblinded_tokens, + }), + ), + ), + ), + ) + +def succeeded_with_unblinded_tokens(all_token_count, returned_token_count): + """ + :return: A matcher which matches a Deferred which fires with a response + like the one returned by the **unblinded-tokens** endpoint. + + :param int all_token_count: The expected value in the ``total`` field of + the response. + + :param int returned_token_count: The expected number of tokens in the + ``unblinded-tokens`` field of the response. + """ + return succeeded_with_unblinded_tokens_with_matcher( + all_token_count, + MatchesAll( + HasLength(returned_token_count), + AllMatch(IsInstance(unicode)), ) + ) + + +class VoucherTests(TestCase): + """ + Tests relating to ``/voucher`` as implemented by the + ``_zkapauthorizer.resource`` module and its handling of + vouchers. + """ + def setUp(self): + super(VoucherTests, self).setUp() + self.useFixture(CaptureTwistedLogs()) @given(tahoe_configs(), vouchers()) @@ -343,14 +523,32 @@ class VoucherTests(TestCase): ), ) + @given(tahoe_configs(client_nonredeemer_configurations()), vouchers()) + def test_get_known_voucher_unredeemed(self, get_config, 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. + """ + return self._test_get_known_voucher(get_config, voucher, False) - @given(tahoe_configs(), vouchers()) - def test_get_known_voucher(self, get_config, voucher): + @given(tahoe_configs(client_dummyredeemer_configurations()), vouchers()) + def test_get_known_voucher_redeemed(self, get_config, 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. """ + return self._test_get_known_voucher(get_config, voucher, True) + + def _test_get_known_voucher(self, get_config, voucher, redeemed): + """ + 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. + """ tempdir = self.useFixture(TempDir()) config = get_config(tempdir.join(b"tahoe"), b"tub.port") root = root_from_config(config) @@ -390,7 +588,7 @@ class VoucherTests(TestCase): AfterPreprocessing( json_content, succeeded( - Equals(Voucher(voucher).marshal()), + Equals(Voucher(voucher, redeemed=redeemed).marshal()), ), ), ), @@ -446,7 +644,7 @@ class VoucherTests(TestCase): succeeded( Equals({ u"vouchers": list( - Voucher(voucher).marshal() + Voucher(voucher, redeemed=True).marshal() for voucher in vouchers ), diff --git a/zkapauthorizer.nix b/zkapauthorizer.nix index 462d8fbc23d692ccce570900ef4efc963e59c411..fb54924d419c80e94fcb45b4713d97f4fef5e4aa 100644 --- a/zkapauthorizer.nix +++ b/zkapauthorizer.nix @@ -4,10 +4,12 @@ , hypothesisProfile ? null , collectCoverage ? false , testSuite ? null +, trialArgs ? [] }: let hypothesisProfile' = if hypothesisProfile == null then "default" else hypothesisProfile; testSuite' = if testSuite == null then "_zkapauthorizer" else testSuite; + extraTrialArgs = builtins.concatStringsSep " " trialArgs; in buildPythonPackage rec { version = "0.0"; @@ -39,15 +41,14 @@ buildPythonPackage rec { treq ]; - - checkPhase = '' runHook preCheck + set -x "${pyflakes}/bin/pyflakes" src/_zkapauthorizer ZKAPAUTHORIZER_HYPOTHESIS_PROFILE=${hypothesisProfile'} python -m ${if collectCoverage then "coverage run --branch --source _zkapauthorizer,twisted.plugins.zkapauthorizer --module" else "" - } twisted.trial ${testSuite'} + } twisted.trial ${extraTrialArgs} ${testSuite'} runHook postCheck '';