diff --git a/docs/source/interface.rst b/docs/source/interface.rst index 5231e7265a355117ccd1bc0af68084ba1ef6b66e..5911d12a30ecbf19324d0cdeee9696a77c27fd7e 100644 --- a/docs/source/interface.rst +++ b/docs/source/interface.rst @@ -137,7 +137,7 @@ The response is **OK** with ``application/json`` content-type response body like 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`` +``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. @@ -165,7 +165,7 @@ If it has run, * ``when``: associated with an ISO8601 datetime string giving the approximate time the process ran * ``count``: associated with a number giving the number of passes which would need to be spent to renew leases on all stored objects seen during the lease maintenance activity -``POST /storage-plugins/privatestorageio/zkapauthz-v1/unblinded-token`` +``POST /storage-plugins/privatestorageio-zkapauthz-v1/unblinded-token`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This endpoint allows an external agent to insert new unblinded tokens into the node's database. @@ -180,3 +180,34 @@ The request body must be ``application/json`` encoded and contain an object like The response is **OK** with ``application/json`` content-type response body like:: { } + +``POST /storage-plugins/privatestorageio-zkapauthz-v1/calculate-price`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This endpoint allows an agent to calculate the number of ZKAPs it will cost to store a collection of files of specified sizes. +This is intended as the basis for tools which aid in user understanding of the cost of their actions. + +The request body must be ``application/json`` encoded and contain an object like:: + + { "version": 1 + , "sizes: [ <integer>, ... ] + } + +The ``version`` property must currently be **1**. +The ``sizes`` property is a list of integers giving file sizes in bytes. + +The response is **OK** with ``application/json`` content-type response body like:: + + { "price": <integer>, "period": <integer> } + +The ``price`` property gives the number of ZKAPs which would have to be spent to store files of the given sizes. +The ``period`` property gives the number of seconds those files would be stored by spending that number of ZKAPs. + +The price obtained this way is valid in two scenarios. +First, +the case where none of the files have been uploaded yet. +In this case uploading the files and storing them for **period** seconds will cost **price** ZKAPs. +Second, +the case where the files have already been uploaded but their leases need to be renewed. +In this case, renewing the leases so they last until **period** seconds after the current time will cost **price** ZKAPs. +Note that in this case any lease time currently remaining on any files has no bearing on the calculated price. diff --git a/setup.cfg b/setup.cfg index dfc4960747ba2d57bea9b12deff72f59371cd743..42dd1649cf7c1b05292f9e6f17647bec69143f59 100644 --- a/setup.cfg +++ b/setup.cfg @@ -42,6 +42,7 @@ install_requires = Twisted[tls,conch]>=18.4.0 tahoe-lafs==1.14.0 treq + pyutil [versioneer] VCS = git diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py index 7dc5146ddd15a11a8837a8b9af7b928069bc58b7..96d70747cc45f31cebd2c67cc3bcd821c3977e54 100644 --- a/src/_zkapauthorizer/controller.py +++ b/src/_zkapauthorizer/controller.py @@ -458,7 +458,9 @@ class IssuerConfigurationMismatch(Exception): ZKAPs will not be usable and new ones must be obtained. """ def __str__(self): - return "Announced issuer ({}) disagrees with configured issuer ({}).".format(self.args) + return "Announced issuer ({}) disagrees with configured issuer ({}).".format( + *self.args + ) @implementer(IRedeemer) diff --git a/src/_zkapauthorizer/pricecalculator.py b/src/_zkapauthorizer/pricecalculator.py new file mode 100644 index 0000000000000000000000000000000000000000..007ec9cdf212839ddabbac9555308b29fb158483 --- /dev/null +++ b/src/_zkapauthorizer/pricecalculator.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# Copyright 2020 PrivateStorage.io, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Calculate the price, in ZKAPs, for storing files. + +The underlying storage system operates only on individual shares. Thus, it +*does not* use this file-oriented calculator. However, for end-users, +file-oriented pricing is often more helpful. This calculator builds on the +share-oriented price calculation to present file-oriented price information. + +It accounts for erasure encoding data expansion. It does not account for the +real state of the storage system (e.g., if some data is *already* stored then +storing it "again" is essentially free but this will not be reflected by this +calculator). +""" + +import attr + +from .storage_common import ( + required_passes, + share_size_for_data, +) + +@attr.s +class PriceCalculator(object): + """ + :ivar int _shares_needed: The number of shares which are required to + reconstruct the original data. + + :ivar int _shares_total: The total number of shares which will be + produced in the erasure encoding process. + + :ivar int _pass_value: The bytes component of the bytes×time value of a + single pass. + """ + _shares_needed = attr.ib() + _shares_total = attr.ib() + _pass_value = attr.ib() + + def calculate(self, sizes): + """ + Calculate the price to store data of the given sizes for one lease + period. + + :param [int] sizes: The sizes of the individual data items in bytes. + + :return int: The number of ZKAPs required. + """ + share_sizes = ( + share_size_for_data(self._shares_needed, size) + for size + in sizes + ) + all_required_passes = ( + required_passes(self._pass_value, [share_size] * self._shares_total) + for share_size + in share_sizes + ) + price = sum(all_required_passes, 0) + return price diff --git a/src/_zkapauthorizer/resource.py b/src/_zkapauthorizer/resource.py index 10b6907fc0b94135617c7812957f1efcbf0e555d..96dc29dbaeeda4ed9ee2f04150d5651c85380af4 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"); @@ -40,6 +41,9 @@ from twisted.logger import ( from twisted.web.http import ( BAD_REQUEST, ) +from twisted.web.server import ( + NOT_DONE_YET, +) from twisted.web.resource import ( IResource, ErrorPage, @@ -55,6 +59,17 @@ from ._base64 import ( urlsafe_b64decode, ) +from .storage_common import ( + get_configured_shares_needed, + get_configured_shares_total, + get_configured_pass_value, + get_configured_lease_duration, +) + +from .pricecalculator import ( + PriceCalculator, +) + from .controller import ( PaymentController, get_redeemer, @@ -106,16 +121,35 @@ 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) + + calculator = PriceCalculator( + get_configured_shares_needed(node_config), + get_configured_shares_total(node_config), + get_configured_pass_value(node_config), + ) + calculate_price = _CalculatePrice( + calculator, + get_configured_lease_duration(node_config), + ) + root = create_private_tree( lambda: node_config.get_private_config(b"api_auth_token"), - authorizationless_resource_tree(store, controller), + authorizationless_resource_tree( + store, + controller, + calculate_price, + ), ) root.store = store root.controller = controller return root -def authorizationless_resource_tree(store, controller): +def authorizationless_resource_tree( + store, + controller, + calculate_price, +): """ Create the full ZKAPAuthorizer client plugin resource hierarchy with no authorization applied. @@ -123,6 +157,8 @@ def authorizationless_resource_tree(store, controller): :param VoucherStore store: The store to use. :param PaymentController controller: The payment controller to use. + :param IResource calculate_price: The resource for the price calculation endpoint. + :return IResource: The root of the resource hierarchy. """ root = Resource() @@ -144,9 +180,108 @@ def authorizationless_resource_tree(store, controller): b"version", _ProjectVersion(), ) + root.putChild( + b"calculate-price", + calculate_price, + ) return root +class _CalculatePrice(Resource): + """ + This resource exposes a storage price calculator. + """ + allowedMethods = [b"POST"] + + render_HEAD = render_GET = None + + def __init__(self, price_calculator, lease_period): + """ + :param _PriceCalculator price_calculator: The object which can actually + calculate storage prices. + + :param lease_period: See ``authorizationless_resource_tree`` + """ + self._price_calculator = price_calculator + 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) + + price = self._price_calculator.calculate(sizes) + return dumps({ + u"price": price, + 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(BAD_REQUEST) + request.finish() + return True + return False + + def application_json(request): """ Set the given request's response content-type to ``application/json``. diff --git a/src/_zkapauthorizer/storage_common.py b/src/_zkapauthorizer/storage_common.py index 1f429dd50a924557d73950c458f071bd4e9f9721..dd9a9f49e15bd542aa70cbbbdaadedfde5c64bbb 100644 --- a/src/_zkapauthorizer/storage_common.py +++ b/src/_zkapauthorizer/storage_common.py @@ -34,6 +34,10 @@ from .eliot import ( MUTABLE_PASSES_REQUIRED, ) +from pyutil.mathutil import ( + div_ceil, +) + @attr.s(frozen=True) class MorePassesRequired(Exception): """ @@ -73,6 +77,34 @@ slot_testv_and_readv_and_writev_message = _message_maker(u"slot_testv_and_readv_ # submitted. BYTES_PER_PASS = 1024 * 1024 +def get_configured_shares_needed(node_config): + """ + Determine the configured-specified value of "needed" shares (``k``). + + If no value is explicitly configured, the Tahoe-LAFS default (as best as + we know it) is returned. + """ + return int(node_config.get_config( + section=u"client", + option=u"shares.needed", + default=3, + )) + + +def get_configured_shares_total(node_config): + """ + Determine the configured-specified value of "total" shares (``N``). + + If no value is explicitly configured, the Tahoe-LAFS default (as best as + we know it) is returned. + """ + return int(node_config.get_config( + section=u"client", + option=u"shares.total", + default=10, + )) + + def get_configured_pass_value(node_config): """ Determine the configuration-specified value of a single ZKAP. @@ -88,10 +120,22 @@ 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`` - for one lease period. + Calculate the number of passes that are required to store shares of the + given sizes for one lease period. :param int bytes_per_pass: The number of bytes the storage of which for one lease period one pass covers. @@ -114,6 +158,23 @@ def required_passes(bytes_per_pass, share_sizes): return result +def share_size_for_data(shares_needed, datasize): + """ + Calculate the size of a single erasure encoding share for data of the + given size and with the given level of redundancy. + + :param int shares_needed: The number of shares (``k``) from the erasure + encoding process which are required to reconstruct original data of + the indicated size. + + :param int datasize: The size of the data to consider, in bytes. + + :return int: The size of a single erasure encoding share for the given + inputs. + """ + return div_ceil(datasize, shares_needed) + + def has_writes(tw_vectors): """ :param tw_vectors: See diff --git a/src/_zkapauthorizer/tests/strategies.py b/src/_zkapauthorizer/tests/strategies.py index 025260d8be245dd8c703ed30429a12c20cb6ba24..4384cacb8d946d8408cd3e4f5e3bb8f9f8f835a9 100644 --- a/src/_zkapauthorizer/tests/strategies.py +++ b/src/_zkapauthorizer/tests/strategies.py @@ -122,10 +122,36 @@ def _config_string_from_sections(divided_sections): )) -def tahoe_config_texts(storage_client_plugins): +def tahoe_config_texts(storage_client_plugins, shares): """ Build the text of complete Tahoe-LAFS configurations for a node. + + :param storage_client_plugins: A dictionary with storage client plugin + names as keys. + + :param shares: A strategy to build erasure encoding parameters. These are + built as a three-tuple giving (needed, total, happy). Each element + may be an integer or None to leave it unconfigured (and rely on the + default). """ + def merge_shares(shares, the_rest): + for (k, v) in zip(("needed", "happy", "total"), shares): + if v is not None: + the_rest["shares." + k] = u"{}".format(v) + return the_rest + + client_section = builds( + merge_shares, + shares, + fixed_dictionaries( + { + "storage.plugins": just( + u",".join(storage_client_plugins.keys()), + ), + }, + ), + ) + return builds( lambda *sections: _config_string_from_sections( sections, @@ -144,26 +170,23 @@ def tahoe_config_texts(storage_client_plugins): "nickname": node_nicknames(), }, ), - "client": fixed_dictionaries( - { - "storage.plugins": just( - u",".join(storage_client_plugins.keys()), - ), - }, - ), + "client": client_section, }, ), ) -def minimal_tahoe_configs(storage_client_plugins=None): +def minimal_tahoe_configs(storage_client_plugins=None, shares=just((None, None, None))): """ Build complete Tahoe-LAFS configurations for a node. + + :param shares: See ``tahoe_config_texts``. """ if storage_client_plugins is None: storage_client_plugins = {} return tahoe_config_texts( storage_client_plugins, + shares, ).map( lambda config_text: lambda basedir, portnumfile: config_from_string( basedir, @@ -287,14 +310,33 @@ def client_errorredeemer_configurations(details): }) -def tahoe_configs(zkapauthz_v1_configuration=client_dummyredeemer_configurations()): +def tahoe_configs( + zkapauthz_v1_configuration=client_dummyredeemer_configurations(), + shares=just((None, None, None)), +): """ Build complete Tahoe-LAFS configurations including the zkapauthorizer client plugin section. + + :param shares: See ``tahoe_config_texts``. """ return minimal_tahoe_configs({ u"privatestorageio-zkapauthz-v1": zkapauthz_v1_configuration, - }) + }, shares) + + +def share_parameters(): + """ + Build three-tuples of integers giving usable k, happy, N parameters to + Tahoe-LAFS' erasure encoding process. + """ + return lists( + integers(min_value=1, max_value=255), + min_size=3, + max_size=3, + ).map( + sorted, + ) def vouchers(): diff --git a/src/_zkapauthorizer/tests/test_client_resource.py b/src/_zkapauthorizer/tests/test_client_resource.py index 511e015ffa24489bc8c553626843b558acd558bd..e13d4747e0c2c3246c4ce012b7da911df0970212 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,6 +109,7 @@ from twisted.web.http import ( UNAUTHORIZED, NOT_FOUND, BAD_REQUEST, + NOT_IMPLEMENTED, ) from twisted.web.http_headers import ( Headers, @@ -141,8 +145,14 @@ from ..resource import ( from_configuration, ) +from ..pricecalculator import ( + PriceCalculator, +) + from ..storage_common import ( required_passes, + get_configured_pass_value, + get_configured_lease_duration, ) from .strategies import ( @@ -157,10 +167,12 @@ from .strategies import ( requests, request_paths, api_auth_tokens, + share_parameters, ) from .matchers import ( Provides, matches_response, + between, ) from .json import ( loads, @@ -269,7 +281,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. @@ -281,6 +293,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``. @@ -289,12 +305,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, ) @@ -1310,6 +1332,245 @@ 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( + # Make the share encoding parameters easily accessible without going + # through the Tahoe-LAFS configuration. + share_parameters().flatmap( + lambda params: tuples( + just(params), + tahoe_configs(shares=just(params)), + ), + ), + api_auth_tokens(), + lists(integers(min_value=0)), + ) + def test_calculated_price(self, encoding_params_and_get_config, api_auth_token, sizes): + """ + A well-formed request returns the price in ZKAPs as an integer and the + storage period (the minimum allowed) that they pay for. + """ + encoding_params, get_config = encoding_params_and_get_config + shares_needed, shares_happy, shares_total = encoding_params + + config = get_config_with_api_token( + self.useFixture(TempDir()), + get_config, + api_auth_token, + ) + root = root_from_config(config, datetime.now) + agent = RequestTraversalAgent(root) + + expected_price = PriceCalculator( + shares_needed=shares_needed, + shares_total=shares_total, + pass_value=get_configured_pass_value(config), + ).calculate(sizes) + + self.assertThat( + authorized_request( + api_auth_token, + agent, + 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=Equals(OK), + headers_matcher=application_json(), + body_matcher=AfterPreprocessing( + loads, + Equals({ + u"price": expected_price, + u"period": get_configured_lease_duration(config), + }), + ), + ), + ), + ) + + def application_json(): return AfterPreprocessing( lambda h: h.getRawHeaders(u"content-type"), diff --git a/src/_zkapauthorizer/tests/test_pricecalculator.py b/src/_zkapauthorizer/tests/test_pricecalculator.py new file mode 100644 index 0000000000000000000000000000000000000000..25eaa40d09102b66ffbb592d590497c33d1c8517 --- /dev/null +++ b/src/_zkapauthorizer/tests/test_pricecalculator.py @@ -0,0 +1,227 @@ +# -*- coding: utf-8 -*- +# Copyright 2020 PrivateStorage.io, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Tests for ``_zkapauthorizer.pricecalculator``. +""" + +from functools import ( + partial, +) + +from testtools import ( + TestCase, +) +from testtools.matchers import ( + Equals, + GreaterThan, + IsInstance, + MatchesAll, +) + +from hypothesis import ( + given, +) + +from hypothesis.strategies import ( + integers, + lists, + tuples, +) + +from ..pricecalculator import ( + PriceCalculator, +) + +from .strategies import ( + sizes, + share_parameters, +) +from .matchers import ( + greater_or_equal, +) + +file_sizes = lists(sizes(), min_size=1) + +class PriceCalculatorTests(TestCase): + """ + Tests for ``PriceCalculator``. + """ + @given( + integers(min_value=1), + integers(min_value=1), + file_sizes, + ) + def test_pass_value(self, pass_value, more_value, file_sizes): + """ + The result of ``PriceCalculator.calculate`` increases or remains the same + as pass value decreases. + """ + calculator = partial(PriceCalculator, shares_needed=1, shares_total=1) + less_value = calculator(pass_value=pass_value) + more_value = calculator(pass_value=pass_value + more_value) + + less_value_price = less_value.calculate(file_sizes) + more_value_price = more_value.calculate(file_sizes) + + self.assertThat( + less_value_price, + greater_or_equal(more_value_price), + ) + + @given( + integers(min_value=1, max_value=127), + integers(min_value=1, max_value=127), + file_sizes, + ) + def test_shares_needed(self, shares_needed, more_needed, file_sizes): + """ + The result of ``PriceCalculator.calculate`` never increases as + ``shares_needed`` increases. + """ + calculator = partial(PriceCalculator, pass_value=100, shares_total=255) + fewer_needed = calculator(shares_needed=shares_needed) + more_needed = calculator(shares_needed=shares_needed + more_needed) + + fewer_needed_price = fewer_needed.calculate(file_sizes) + more_needed_price = more_needed.calculate(file_sizes) + + self.assertThat( + fewer_needed_price, + greater_or_equal(more_needed_price), + ) + + + @given( + integers(min_value=1, max_value=127), + integers(min_value=1, max_value=127), + file_sizes, + ) + def test_shares_total(self, shares_total, more_total, file_sizes): + """ + The result of ``PriceCalculator.calculate`` always increases as + ``shares_total`` increases. + """ + calculator = partial(PriceCalculator, pass_value=100, shares_needed=1) + fewer_total = calculator(shares_total=shares_total) + more_total = calculator(shares_total=shares_total + more_total) + + fewer_total_price = fewer_total.calculate(file_sizes) + more_total_price = more_total.calculate(file_sizes) + + self.assertThat( + more_total_price, + greater_or_equal(fewer_total_price), + ) + + @given( + integers(min_value=1, max_value=100).flatmap( + lambda num_files: tuples( + lists(sizes(), min_size=num_files, max_size=num_files), + lists(sizes(), min_size=num_files, max_size=num_files), + ), + ), + integers(min_value=1), + share_parameters(), + ) + def test_file_sizes(self, file_sizes, pass_value, parameters): + """ + The result of ``PriceCalculator.calculate`` never decreases as the values + of ``file_sizes`` increase. + """ + smaller_sizes, increases = file_sizes + larger_sizes = list(a + b for (a, b) in zip(smaller_sizes, increases)) + k, happy, N = parameters + + calculator = PriceCalculator( + pass_value=pass_value, + shares_needed=k, + shares_total=N, + ) + + smaller_sizes_price = calculator.calculate(smaller_sizes) + larger_sizes_price = calculator.calculate(larger_sizes) + + self.assertThat( + larger_sizes_price, + greater_or_equal(smaller_sizes_price), + ) + + @given( + integers(min_value=1), + share_parameters(), + file_sizes, + ) + def test_positive_integer_price(self, pass_value, parameters, file_sizes): + """ + The result of ``PriceCalculator.calculate`` for a non-empty size list is + always a positive integer. + """ + k, happy, N = parameters + calculator = PriceCalculator( + pass_value=pass_value, + shares_needed=k, + shares_total=N, + ) + price = calculator.calculate(file_sizes) + self.assertThat( + price, + MatchesAll( + IsInstance((int, long)), + GreaterThan(0), + ), + ) + + @given( + integers(min_value=1), + share_parameters(), + file_sizes, + ) + def test_linear_increase(self, pass_value, parameters, file_sizes): + """ + The result of ``PriceCalculator.calculate`` doubles if the file size list + is doubled. + """ + k, happy, N = parameters + calculator = PriceCalculator( + pass_value=pass_value, + shares_needed=k, + shares_total=N, + ) + smaller_price = calculator.calculate(file_sizes) + larger_price = calculator.calculate(file_sizes + file_sizes) + self.assertThat( + larger_price, + Equals(smaller_price * 2), + ) + + @given( + integers(min_value=1), + ) + def test_one_pass(self, pass_value): + """ + The result of ``PriceCalculator.calculate`` is exactly ``1`` if the amount + of data to be stored equals the value of a pass. + """ + calculator = PriceCalculator( + pass_value=pass_value, + shares_needed=1, + shares_total=1, + ) + price = calculator.calculate([pass_value]) + self.assertThat( + price, + Equals(1), + ) diff --git a/src/_zkapauthorizer/tests/test_strategies.py b/src/_zkapauthorizer/tests/test_strategies.py index 6a0307ea4b799204dba2c4f297473a0d52619215..3f60fcd8a617e344e20b7c5e2bb496143e07d571 100644 --- a/src/_zkapauthorizer/tests/test_strategies.py +++ b/src/_zkapauthorizer/tests/test_strategies.py @@ -34,6 +34,8 @@ from hypothesis import ( ) from hypothesis.strategies import ( data, + one_of, + just, ) from allmydata.client import ( @@ -42,6 +44,7 @@ from allmydata.client import ( from .strategies import ( tahoe_config_texts, + share_parameters, ) class TahoeConfigsTests(TestCase): @@ -54,7 +57,15 @@ class TahoeConfigsTests(TestCase): Configurations built by the strategy can be parsed. """ tempdir = self.useFixture(TempDir()) - config_text = data.draw(tahoe_config_texts({})) + config_text = data.draw( + tahoe_config_texts( + storage_client_plugins={}, + shares=one_of( + just((None, None, None)), + share_parameters(), + ), + ), + ) note(config_text) config_from_string( tempdir.join(b"tahoe.ini"),