diff --git a/src/_zkapauthorizer/resource.py b/src/_zkapauthorizer/resource.py index db317900a2e629a5937c8f3150b9694c1188dde6..20f1519d0196268b1886b0b3e9938c807886dbac 100644 --- a/src/_zkapauthorizer/resource.py +++ b/src/_zkapauthorizer/resource.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # Copyright 2019 PrivateStorage.io, LLC # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -39,6 +40,10 @@ from twisted.logger import ( ) from twisted.web.http import ( BAD_REQUEST, + NOT_ALLOWED, +) +from twisted.web.server import ( + NOT_DONE_YET, ) from twisted.web.resource import ( IResource, @@ -55,6 +60,12 @@ from ._base64 import ( urlsafe_b64decode, ) +from .storage_common import ( + required_passes, + get_configured_pass_value, + get_configured_lease_duration, +) + from .controller import ( PaymentController, get_redeemer, @@ -108,20 +119,27 @@ def from_configuration(node_config, store, redeemer=None, default_token_count=No controller = PaymentController(store, redeemer, default_token_count) root = create_private_tree( lambda: node_config.get_private_config(b"api_auth_token"), - authorizationless_resource_tree(store, controller), + authorizationless_resource_tree( + store, + controller, + get_configured_pass_value(node_config), + get_configured_lease_duration(node_config), + ), ) root.store = store root.controller = controller return root -def authorizationless_resource_tree(store, controller): +def authorizationless_resource_tree(store, controller, pass_value, lease_duration): """ Create the full ZKAPAuthorizer client plugin resource hierarchy with no authorization applied. :param VoucherStore store: The store to use. :param PaymentController controller: The payment controller to use. + :param int pass_value: The bytes component of the bytes×time value of a single pass. + :param int lease_duration: The number of seconds a lease will be valid. :return IResource: The root of the resource hierarchy. """ @@ -146,7 +164,7 @@ def authorizationless_resource_tree(store, controller): ) root.putChild( b"calculate-price", - _CalculatePrice(), + _CalculatePrice(pass_value, lease_duration), ) return root @@ -155,6 +173,86 @@ class _CalculatePrice(Resource): """ This resource exposes a storage price calculator. """ + allowedMethods = [b"POST"] + + render_HEAD = render_GET = None + + def __init__(self, pass_value, lease_period): + """ + :param pass_value: See ``authorizationless_resource_tree`` + :param lease_period: See ``authorizationless_resource_tree`` + """ + self._pass_value = pass_value + self._lease_period = lease_period + Resource.__init__(self) + + def render_POST(self, request): + """ + Calculate the price in ZKAPs to store or continue storing files specified + sizes. + """ + if wrong_content_type(request, u"application/json"): + return NOT_DONE_YET + + application_json(request) + payload = request.content.read() + try: + body_object = loads(payload) + except ValueError: + request.setResponseCode(BAD_REQUEST) + return dumps({ + "error": "could not parse request body", + }) + + try: + version = body_object[u"version"] + sizes = body_object[u"sizes"] + except (TypeError, KeyError): + request.setResponseCode(BAD_REQUEST) + return dumps({ + "error": "could not read `version` and `sizes` properties", + }) + + if version != 1: + request.setResponseCode(BAD_REQUEST) + return dumps({ + "error": "did not find required version number 1 in request", + }) + + if not isinstance(sizes, list) or not all(isinstance(size, (int, long)) and size >= 0 for size in sizes): + request.setResponseCode(BAD_REQUEST) + return dumps({ + "error": "did not find required positive integer sizes list in request", + }) + + application_json(request) + return dumps({ + u"price": required_passes(self._pass_value, sizes), + u"period": self._lease_period, + }) + + +def wrong_content_type(request, required_type): + """ + Check the content-type of a request and respond if it is incorrect. + + :param request: The request object to check. + + :param unicode required_type: The required content-type (eg + ``u"application/json"``). + + :return bool: ``True`` if the content-type is wrong and an error response + has been generated. ``False`` otherwise. + """ + actual_type = request.requestHeaders.getRawHeaders( + u"content-type", + [None], + )[0] + if actual_type != required_type: + request.setResponseCode(NOT_ALLOWED) + request.finish() + return True + return False def application_json(request): diff --git a/src/_zkapauthorizer/storage_common.py b/src/_zkapauthorizer/storage_common.py index 1f429dd50a924557d73950c458f071bd4e9f9721..e30149ce4797179f42be12ddf531be90b5dabe6c 100644 --- a/src/_zkapauthorizer/storage_common.py +++ b/src/_zkapauthorizer/storage_common.py @@ -88,6 +88,18 @@ def get_configured_pass_value(node_config): default=BYTES_PER_PASS, )) + +def get_configured_lease_duration(node_config): + """ + Just kidding. Lease duration is hard-coded. + + :return int: The number of seconds after which a newly acquired lease will + be valid. + """ + # See lots of places in Tahoe-LAFS, eg src/allmydata/storage/server.py + return 31 * 24 * 60 * 60 + + def required_passes(bytes_per_pass, share_sizes): """ Calculate the number of passes that are required to store ``stored_bytes`` diff --git a/src/_zkapauthorizer/tests/test_client_resource.py b/src/_zkapauthorizer/tests/test_client_resource.py index 4db2af522273a16d20b7a11370013af882117721..0bbc9bb6418d335d1f93bb5712ace73301024b70 100644 --- a/src/_zkapauthorizer/tests/test_client_resource.py +++ b/src/_zkapauthorizer/tests/test_client_resource.py @@ -88,6 +88,9 @@ from hypothesis.strategies import ( binary, text, datetimes, + builds, + tuples, + dictionaries, ) from twisted.python.filepath import ( @@ -106,7 +109,6 @@ from twisted.web.http import ( UNAUTHORIZED, NOT_FOUND, BAD_REQUEST, - NOT_ALLOWED, NOT_IMPLEMENTED, ) from twisted.web.http_headers import ( @@ -145,6 +147,8 @@ from ..resource import ( from ..storage_common import ( required_passes, + get_configured_pass_value, + get_configured_lease_duration, ) from .strategies import ( @@ -163,6 +167,7 @@ from .strategies import ( from .matchers import ( Provides, matches_response, + between, ) from .json import ( loads, @@ -271,7 +276,7 @@ def root_from_config(config, now): ) -def authorized_request(api_auth_token, agent, method, uri, data=None): +def authorized_request(api_auth_token, agent, method, uri, headers=None, data=None): """ Issue a request with the required token-based authorization header value. @@ -283,6 +288,10 @@ def authorized_request(api_auth_token, agent, method, uri, data=None): :param bytes uri: The URI for the request. + :param ({bytes: [bytes]})|None headers: If not ``None``, extra request + headers to include. The **Authorization** header will be overwritten + if it is present. + :param BytesIO|None data: If not ``None``, the request body. :return: A ``Deferred`` like the one returned by ``IAgent.request``. @@ -291,12 +300,18 @@ def authorized_request(api_auth_token, agent, method, uri, data=None): bodyProducer = None else: bodyProducer = FileBodyProducer(data, cooperator=uncooperator()) + if headers is None: + headers = Headers() + else: + headers = Headers(headers) + headers.setRawHeaders( + u"authorization", + [b"tahoe-lafs {}".format(api_auth_token)], + ) return agent.request( method, uri, - headers=Headers({ - "authorization": ["tahoe-lafs {}".format(api_auth_token)], - }), + headers=headers, bodyProducer=bodyProducer, ) @@ -1312,20 +1327,195 @@ class VoucherTests(TestCase): ) +def mime_types(blacklist=None): + """ + Build MIME types as b"major/minor" byte strings. + + :param set|None blacklist: If not ``None``, MIME types to exclude from the + result. + """ + if blacklist is None: + blacklist = set() + return tuples( + text(), + text(), + ).map( + b"/".join, + ).filter( + lambda content_type: content_type not in blacklist, + ) + + +@attr.s +class Request(object): + """ + Represent some of the parameters of an HTTP request. + """ + method = attr.ib() + headers = attr.ib() + data = attr.ib() + + +def bad_calculate_price_requests(): + """ + Build Request instances describing requests which are not allowed at the + ``/calculate-price`` endpoint. + """ + good_methods = just(b"POST") + bad_methods = sampled_from([ + b"GET", + b"HEAD", + b"PUT", + b"PATCH", + b"OPTIONS", + b"FOO", + ]) + + good_headers = just({b"content-type": [b"application/json"]}) + bad_headers = fixed_dictionaries({ + b"content-type": mime_types( + blacklist={b"application/json"}, + ).map( + lambda content_type: [content_type], + ), + }) + + good_version = just(1) + bad_version = one_of( + text(), + lists(integers()), + integers(max_value=0), + integers(min_value=2), + ) + + good_sizes = lists(integers(min_value=0)) + bad_sizes = one_of( + integers(), + text(), + lists(text(), min_size=1), + dictionaries(text(), text()), + lists(integers(max_value=-1), min_size=1), + ) + + good_data = fixed_dictionaries({ + u"version": good_version, + u"sizes": good_sizes, + }).map(dumps) + + bad_data_version = fixed_dictionaries({ + u"version": bad_version, + u"sizes": good_sizes, + }).map(dumps) + + bad_data_sizes = fixed_dictionaries({ + u"version": good_version, + u"sizes": bad_sizes, + }).map(dumps) + + bad_data_other = dictionaries( + text(), + integers(), + ).map(dumps) + + bad_data_junk = binary() + + good_fields = { + "method": good_methods, + "headers": good_headers, + "data": good_data, + } + + bad_choices = [ + ("method", bad_methods), + ("headers", bad_headers), + ("data", bad_data_version), + ("data", bad_data_sizes), + ("data", bad_data_other), + ("data", bad_data_junk), + ] + + def merge(fields, key, value): + fields = fields.copy() + fields[key] = value + return fields + + return sampled_from( + bad_choices, + ).flatmap( + lambda bad_choice: builds( + Request, + **merge(good_fields, *bad_choice) + ), + ) + + class CalculatePriceTests(TestCase): """ Tests relating to ``/calculate-price`` as implemented by the ``_zkapauthorizer.resource`` module. """ + url = b"http://127.0.0.1/calculate-price" + + @given( + tahoe_configs(), + api_auth_tokens(), + bad_calculate_price_requests(), + ) + def test_bad_request(self, get_config, api_auth_token, bad_request): + """ + When approached with: + + * a method other than POST + * a content-type other than **application/json** + * a request body which is not valid JSON + * a JSON request body without version and sizes properties + * a JSON request body without a version of 1 + * a JSON request body with other properties + * or a JSON request body with sizes other than a list of integers + + response code is not in the 200 range. + """ + config = get_config_with_api_token( + self.useFixture(TempDir()), + get_config, + api_auth_token, + ) + root = root_from_config(config, datetime.now) + agent = RequestTraversalAgent(root) + self.assertThat( + authorized_request( + api_auth_token, + agent, + bad_request.method, + self.url, + headers=bad_request.headers, + data=BytesIO(bad_request.data), + ), + succeeded( + matches_response( + code_matcher=MatchesAny( + # It is fine to signal client errors + between(400, 499), + # It is fine to say we didn't implement the request + # method (I guess - Twisted Web sort of forces it on + # us, I'd rather have NOT ALLOWED for this case + # instead...). We don't want INTERNAL SERVER ERROR + # though. + Equals(NOT_IMPLEMENTED), + ), + ), + ), + ) + @given( tahoe_configs(), api_auth_tokens(), - sampled_from([b"GET", b"PUT", b"PATCH", b"OPTIONS", b"FOO"]), + lists(integers(min_value=0)), ) - def test_wrong_method(self, get_config, api_auth_token, method): + def test_calculated_price(self, get_config, api_auth_token, sizes): """ - When approached with a method other than **POST** the response code is - METHOD NOT ALLOWED. + A well-formed request returns the price in ZKAPs as an integer and the + storage period (the minimum allowed) that they pay for. """ config = get_config_with_api_token( self.useFixture(TempDir()), @@ -1334,16 +1524,32 @@ class CalculatePriceTests(TestCase): ) root = root_from_config(config, datetime.now) agent = RequestTraversalAgent(root) + + expected_price = required_passes( + get_configured_pass_value(config), + sizes, + ) + self.assertThat( authorized_request( api_auth_token, agent, - method, - b"http://127.0.0.1/calculate-price", + b"POST", + self.url, + headers={b"content-type": [b"application/json"]}, + data=BytesIO(dumps({u"version": 1, u"sizes": sizes})), ), succeeded( matches_response( - code_matcher=MatchesAny(Equals(NOT_ALLOWED), Equals(NOT_IMPLEMENTED)), + code_matcher=Equals(OK), + headers_matcher=application_json(), + body_matcher=AfterPreprocessing( + loads, + Equals({ + u"price": expected_price, + u"period": get_configured_lease_duration(config), + }), + ), ), ), )