diff --git a/docs/source/index.rst b/docs/source/index.rst index fd3924b8df3198d362136841eb9bf8aad5702fef..58bf2107b2c7e5a2b482b018d3eb9aa2165339c6 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -12,6 +12,7 @@ Welcome to Secure Access Token Authorizer's documentation! code_of_conduct CONTRIBUTING + interface Indices and tables ================== diff --git a/docs/source/interface.rst b/docs/source/interface.rst new file mode 100644 index 0000000000000000000000000000000000000000..0dcccabc1590e6974aeb6a7147cc5e7389123907 --- /dev/null +++ b/docs/source/interface.rst @@ -0,0 +1,44 @@ +Interface +========= + +Client +------ + +When enabled in a Tahoe-LAFS client node, +SecureAccessTokenAuthorizer publishes an HTTP-based interface inside the main Tahoe-LAFS web interface. + +``PUT /storage-plugins/privatestorageio-satauthz-v1/payment-reference-number`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This endpoint allows an external agent which has submitted a payment to cause the plugin to redeem the payment reference for tokens. +The request body for this endpoint must have the ``application/json`` content-type. +The request body contains a simple json object containing the payment reference number:: + + {"payment-reference-number": "<payment reference number>"} + +The endpoint responds to such a request with an **OK** HTTP response code if the payment reference number is accepted for processing. +If the payment reference number cannot be accepted at the time of the request then the response code will be anything other than **OK**. + +If the response is **OK** then a repeated request with the same body will have no effect. +If the response is not **OK** then a repeated request with the same body will try to accept the number again. + +``GET /storage-plugins/privatestorageio-satauthz-v1/payment-reference-number/<payment reference number>`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This endpoint allows an external agent to monitor the status of the redemption of a payment reference number. +This endpoint accepts no request body. + +If the payment reference number is not known then the response is **NOT FOUND**. +For any payment reference number which has previously been submitted, +the response is **OK** with an ``application/json`` content-type response body like:: + + {"stage": <integer>, + "of": <integer>, + "stage-name": <string>, + "stage-entered-time": <iso8601 timestamp> + } + +The ``stage`` property indicates how far into redemption the plugin has proceeded. +The ``of`` property indicates how many steps the process involves in total. +The ``stage-name`` property gives a human-meaningful description of the current stage. +The ``stage-entered-time`` property gives the timestamp for the start of the current staged. diff --git a/secure-access-token-authorizer.nix b/secure-access-token-authorizer.nix index b76b884c87c66730b85cd8b1aa9eda2aa4e6339f..e0b7508acfb0ff8b60cde29ba17d075fa108229d 100644 --- a/secure-access-token-authorizer.nix +++ b/secure-access-token-authorizer.nix @@ -1,11 +1,12 @@ { buildPythonPackage, sphinx, circleci-cli , attrs, zope_interface, twisted, tahoe-lafs -, fixtures, testtools, hypothesis, pyflakes +, fixtures, testtools, hypothesis, pyflakes, treq, coverage }: buildPythonPackage rec { version = "0.0"; name = "secure-access-token-authorizer-${version}"; src = ./.; + depsBuildBuild = [ sphinx circleci-cli @@ -19,13 +20,16 @@ buildPythonPackage rec { ]; checkInputs = [ + coverage fixtures testtools hypothesis + twisted + treq ]; checkPhase = '' ${pyflakes}/bin/pyflakes src/_secureaccesstokenauthorizer - ${twisted}/bin/trial _secureaccesstokenauthorizer + python -m coverage run --source _secureaccesstokenauthorizer,twisted.plugins.secureaccesstokenauthorizer --module twisted.trial _secureaccesstokenauthorizer ''; } diff --git a/src/_secureaccesstokenauthorizer/_base64.py b/src/_secureaccesstokenauthorizer/_base64.py new file mode 100644 index 0000000000000000000000000000000000000000..ed838f8ee1eafe9ee3eb434426635fb910c83883 --- /dev/null +++ b/src/_secureaccesstokenauthorizer/_base64.py @@ -0,0 +1,43 @@ +# Copyright 2019 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. + +""" +This module implements base64 encoding-related functionality. +""" + +from __future__ import ( + absolute_import, +) + +from re import ( + compile as _compile, +) + +from binascii import ( + Error, +) + +from base64 import ( + b64decode as _b64decode, +) + +_b64decode_validator = _compile(b'^[A-Za-z0-9-_]*={0,2}$') + +def urlsafe_b64decode(s): + """ + Like ``base64.b64decode`` but with validation. + """ + if not _b64decode_validator.match(s): + raise Error('Non-base64 digit found') + return _b64decode(s, altchars=b"-_") diff --git a/src/_secureaccesstokenauthorizer/_plugin.py b/src/_secureaccesstokenauthorizer/_plugin.py index ba09200c97c053c641466f71da01fd93f39290ac..cbb80959eaac22bbcea95208599a5e636b5403ba 100644 --- a/src/_secureaccesstokenauthorizer/_plugin.py +++ b/src/_secureaccesstokenauthorizer/_plugin.py @@ -41,6 +41,10 @@ from ._storage_server import ( TOKEN_LENGTH, ) +from .resource import ( + from_configuration as resource_from_configuration, +) + @implementer(IAnnounceableStorageServer) @attr.s class AnnounceableStorageServer(object): @@ -78,3 +82,7 @@ class SecureAccessTokenAuthorizer(object): lambda: [b"x" * TOKEN_LENGTH], ) ) + + + def get_client_resource(self, configuration): + return resource_from_configuration(configuration) diff --git a/src/_secureaccesstokenauthorizer/controller.py b/src/_secureaccesstokenauthorizer/controller.py new file mode 100644 index 0000000000000000000000000000000000000000..568ed9cf867bba4e7a092dd446199d2573a06a8c --- /dev/null +++ b/src/_secureaccesstokenauthorizer/controller.py @@ -0,0 +1,30 @@ +# Copyright 2019 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. + +""" +This module implements controllers (in the MVC sense) for the web interface +for the client side of the storage plugin. +""" + +import attr + +@attr.s +class PaymentController(object): + store = attr.ib() + + def redeem(self, prn): + try: + self.store.get(prn) + except KeyError: + self.store.add(prn) diff --git a/src/_secureaccesstokenauthorizer/model.py b/src/_secureaccesstokenauthorizer/model.py new file mode 100644 index 0000000000000000000000000000000000000000..915481bb646619f754a582a371165f4e8385261d --- /dev/null +++ b/src/_secureaccesstokenauthorizer/model.py @@ -0,0 +1,120 @@ +# Copyright 2019 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. + +""" +This module implements models (in the MVC sense) for the client side of +the storage plugin. +""" + +from os import ( + makedirs, +) +from errno import ( + EEXIST, +) +from json import ( + loads, + dumps, +) +import attr + +# XXX +from allmydata.node import ( + _Config, + MissingConfigEntry, +) + +class StoreAddError(Exception): + def __init__(self, reason): + self.reason = reason + + +class StoreDirectoryError(Exception): + def __init__(self, reason): + self.reason = reason + + +@attr.s(frozen=True) +class PaymentReferenceStore(object): + """ + This class implements persistence for payment references. + + :ivar _Config node_config: The Tahoe-LAFS node configuration object for + the node that owns the persisted payment preferences. + """ + _CONFIG_DIR = u"privatestorageio-satauthz-v1" + node_config = attr.ib(type=_Config) + + def _config_key(self, prn): + return u"{}/{}.prn+json".format(self._CONFIG_DIR, prn) + + def _read_pr_json(self, prn): + private_config_item = self._config_key(prn) + try: + return self.node_config.get_private_config(private_config_item) + except MissingConfigEntry: + raise KeyError(prn) + + def _write_pr_json(self, prn, pr_json): + private_config_item = self._config_key(prn) + # XXX Need an API to be able to avoid touching the filesystem directly + # here. + container = self.node_config.get_private_path(self._CONFIG_DIR) + try: + makedirs(container) + except EnvironmentError as e: + if EEXIST != e.errno: + raise StoreDirectoryError(e) + try: + self.node_config.write_private_config(private_config_item, pr_json) + except Exception as e: + raise StoreAddError(e) + + def get(self, prn): + payment_reference_json = self._read_pr_json(prn) + return PaymentReference.from_json(payment_reference_json) + + def add(self, prn): + # XXX Not *exactly* atomic is it? Probably want a + # write_private_config_if_not_exists or something. + try: + self._read_pr_json(prn) + except KeyError: + self._write_pr_json(prn, PaymentReference(prn).to_json()) + + +@attr.s +class PaymentReference(object): + number = attr.ib() + + @classmethod + def from_json(cls, json): + values = loads(json) + version = values.pop(u"version") + return getattr(cls, "from_json_v{}".format(version))(values) + + + @classmethod + def from_json_v1(cls, values): + return cls(**values) + + + def to_json(self): + return dumps(self.to_json_v1()) + + + def to_json_v1(self): + result = attr.asdict(self) + result[u"version"] = 1 + return result diff --git a/src/_secureaccesstokenauthorizer/resource.py b/src/_secureaccesstokenauthorizer/resource.py new file mode 100644 index 0000000000000000000000000000000000000000..f76f0a6fabf072f45af6cba8120f16cd396edd6b --- /dev/null +++ b/src/_secureaccesstokenauthorizer/resource.py @@ -0,0 +1,137 @@ +# Copyright 2019 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. + +""" +This module implements views (in the MVC sense) for the web interface for +the client side of the storage plugin. This interface allows users to redeem +payment codes for fresh tokens. + +In the future it should also allow users to read statistics about token usage. +""" + +from json import ( + loads, +) + +from twisted.web.http import ( + BAD_REQUEST, +) +from twisted.web.resource import ( + ErrorPage, + NoResource, + Resource, +) + +from ._base64 import ( + urlsafe_b64decode, +) + +from .model import ( + PaymentReferenceStore, +) +from .controller import ( + PaymentController, +) + +def from_configuration(node_config): + """ + Instantiate the plugin root resource using data from its configuration + section in the Tahoe-LAFS configuration file:: + + [storageclient.plugins.privatestorageio-satauthz-v1] + # nothing yet + + :param _Config node_config: An object representing the overall node + configuration. The plugin configuration can be extracted from this. + This is also used to read and write files in the private storage area + of the node's persistent state location. + + :return IResource: The root of the resource hierarchy presented by the + client side of the plugin. + """ + store = PaymentReferenceStore(node_config) + controller = PaymentController(store) + root = Resource() + root.putChild( + b"payment-reference-number", + _PaymentReferenceNumberCollection( + store, + controller, + ), + ) + return root + + +class _PaymentReferenceNumberCollection(Resource): + """ + This class implements redemption of payment reference numbers (PRNs). + Users **PUT** such numbers to this resource which delegates redemption + responsibilities to the redemption controller. Child resources of this + resource can also be retrieved to monitor the status of previously + submitted PRNs. + """ + def __init__(self, store, controller): + self._store = store + self._controller = controller + Resource.__init__(self) + + + def render_PUT(self, request): + try: + payload = loads(request.content.read()) + except Exception: + return bad_request().render(request) + if payload.keys() != [u"payment-reference-number"]: + return bad_request().render(request) + prn = payload[u"payment-reference-number"] + if not isinstance(prn, unicode): + return bad_request().render(request) + try: + urlsafe_b64decode(prn.encode("ascii")) + except Exception: + return bad_request().render(request) + + self._controller.redeem(prn) + return b"" + + + def getChild(self, segment, request): + prn = segment + try: + urlsafe_b64decode(prn) + except Exception: + return bad_request() + try: + payment_reference = self._store.get(prn) + except KeyError: + return NoResource() + return PaymentReferenceView(payment_reference) + + + +class PaymentReferenceView(Resource): + def __init__(self, reference): + self._reference = reference + Resource.__init__(self) + + + def render_GET(self, request): + request.responseHeaders.setRawHeaders(u"content-type", [u"application/json"]) + return self._reference.to_json() + + +def bad_request(): + return ErrorPage( + BAD_REQUEST, b"Bad Request", b"Bad Request", + ) diff --git a/src/_secureaccesstokenauthorizer/tests/matchers.py b/src/_secureaccesstokenauthorizer/tests/matchers.py index 16d967a512f1ee976ac845750fdf476fc95958a6..c36c50796f5ebabf6da188bb2a939f4906df55c5 100644 --- a/src/_secureaccesstokenauthorizer/tests/matchers.py +++ b/src/_secureaccesstokenauthorizer/tests/matchers.py @@ -19,6 +19,7 @@ Testtools matchers useful for the test suite. import attr from testtools.matchers import ( + Matcher, Mismatch, ContainsDict, Always, @@ -54,3 +55,26 @@ def matches_version_dictionary(): b'application-version': Always(), b'http://allmydata.org/tahoe/protocols/storage/v1': Always(), }) + + + +def returns(matcher): + """ + Matches a no-argument callable that returns a value matched by the given + matcher. + """ + return _Returns(matcher) + + + +class _Returns(Matcher): + def __init__(self, result_matcher): + self.result_matcher = result_matcher + + + def match(self, matchee): + return self.result_matcher.match(matchee()) + + + def __str__(self): + return "Returns({})".format(self.result_matcher) diff --git a/src/_secureaccesstokenauthorizer/tests/strategies.py b/src/_secureaccesstokenauthorizer/tests/strategies.py index c41f7cca88ebf7cf3d80984e7c26c9df96a9c0f6..7da47c2b72185eacd304f0ca27bf69597f15dcc9 100644 --- a/src/_secureaccesstokenauthorizer/tests/strategies.py +++ b/src/_secureaccesstokenauthorizer/tests/strategies.py @@ -16,20 +16,31 @@ Hypothesis strategies for property testing. """ +from base64 import ( + urlsafe_b64encode, +) + import attr from hypothesis.strategies import ( one_of, just, binary, + characters, + text, integers, sets, lists, tuples, dictionaries, + fixed_dictionaries, builds, ) +from twisted.web.test.requesthelper import ( + DummyRequest, +) + from allmydata.interfaces import ( StorageIndex, LeaseRenewSecret, @@ -37,13 +48,150 @@ from allmydata.interfaces import ( WriteEnablerSecret, ) +from allmydata.client import ( + config_from_string, +) + + +def _merge_dictionaries(dictionaries): + result = {} + for d in dictionaries: + result.update(d) + return result + + +def _tahoe_config_quote(text): + return text.replace(u"%", u"%%") + + +def _config_string_from_sections(divided_sections): + sections = _merge_dictionaries(divided_sections) + return u"".join(list( + u"[{name}]\n{items}\n".format( + name=name, + items=u"\n".join( + u"{key} = {value}".format(key=key, value=_tahoe_config_quote(value)) + for (key, value) + in contents.items() + ) + ) + for (name, contents) in sections.items() + )) + + +def tahoe_config_texts(storage_client_plugins): + """ + Build the text of complete Tahoe-LAFS configurations for a node. + """ + return builds( + lambda *sections: _config_string_from_sections( + sections, + ), + fixed_dictionaries( + { + "storageclient.plugins.{}".format(name): configs + for (name, configs) + in storage_client_plugins.items() + }, + ), + fixed_dictionaries( + { + "node": fixed_dictionaries( + { + "nickname": node_nicknames(), + }, + ), + "client": fixed_dictionaries( + { + "storage.plugins": just( + u",".join(storage_client_plugins.keys()), + ), + }, + ), + }, + ), + ) + + +def tahoe_configs(storage_client_plugins=None): + """ + Build complete Tahoe-LAFS configurations for a node. + """ + if storage_client_plugins is None: + storage_client_plugins = {} + return tahoe_config_texts( + storage_client_plugins, + ).map( + lambda config_text: lambda basedir, portnumfile: config_from_string( + basedir, + portnumfile, + config_text.encode("utf-8"), + ), + ) + +def node_nicknames(): + """ + Builds Tahoe-LAFS node nicknames. + """ + return text( + min_size=0, + max_size=16, + alphabet=characters( + blacklist_categories={ + # Surrogates + u"Cs", + # Unnamed and control characters + u"Cc", + }, + ), + ) + + def configurations(): """ - Build configuration values for the plugin. + Build configuration values for the server-side plugin. """ return just({}) +def client_configurations(): + """ + Build configuration values for the client-side plugin. + """ + return just({}) + + +def payment_reference_numbers(): + """ + Build unicode strings in the format of payment reference numbers. + """ + return binary( + min_size=32, + max_size=32, + ).map( + urlsafe_b64encode, + ) + + +def request_paths(): + """ + Build lists of unicode strings that represent the path component of an + HTTP request. + + :see: ``requests`` + """ + + +def requests(paths=request_paths()): + """ + Build objects providing ``twisted.web.iweb.IRequest``. + """ + return builds( + DummyRequest, + paths, + ) + + def storage_indexes(): """ Build Tahoe-LAFS storage indexes. @@ -227,7 +375,7 @@ def test_and_write_vectors_for_shares(): # before assignment. min_size=1, # Just for practical purposes... - max_size=8, + max_size=4, ) diff --git a/src/_secureaccesstokenauthorizer/tests/test_base64.py b/src/_secureaccesstokenauthorizer/tests/test_base64.py new file mode 100644 index 0000000000000000000000000000000000000000..569cd6994b6c7ce030070927cac978498917ef3d --- /dev/null +++ b/src/_secureaccesstokenauthorizer/tests/test_base64.py @@ -0,0 +1,55 @@ +# Copyright 2019 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 ``_secureaccesstokenauthorizer._base64``. +""" + +from base64 import ( + urlsafe_b64encode, +) + +from testtools import ( + TestCase, +) +from testtools.matchers import ( + Equals, +) + +from hypothesis import ( + given, +) +from hypothesis.strategies import ( + binary, +) + +from .._base64 import ( + urlsafe_b64decode, +) + + +class Base64Tests(TestCase): + """ + Tests for ``urlsafe_b64decode``. + """ + @given(binary()) + def test_roundtrip(self, bytestring): + """ + Byte strings round-trip through ``base64.urlsafe_b64encode`` and + ``urlsafe_b64decode``. + """ + self.assertThat( + urlsafe_b64decode(urlsafe_b64encode(bytestring)), + Equals(bytestring), + ) diff --git a/src/_secureaccesstokenauthorizer/tests/test_client_resource.py b/src/_secureaccesstokenauthorizer/tests/test_client_resource.py new file mode 100644 index 0000000000000000000000000000000000000000..30b58c0004da7053b6a84b4d587a66cc7c3cb759 --- /dev/null +++ b/src/_secureaccesstokenauthorizer/tests/test_client_resource.py @@ -0,0 +1,407 @@ +# Copyright 2019 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 the web resource provided by the client part of the Tahoe-LAFS +plugin. +""" + +import attr + +from .._base64 import ( + urlsafe_b64decode, +) + +from json import ( + dumps, + loads, +) +from io import ( + BytesIO, +) + +from testtools import ( + TestCase, +) +from testtools.matchers import ( + MatchesStructure, + MatchesAll, + AfterPreprocessing, + Equals, + Always, +) +from testtools.twistedsupport import ( + CaptureTwistedLogs, + succeeded, +) +from testtools.content import ( + text_content, +) + +from fixtures import ( + TempDir, +) + +from hypothesis import ( + given, +) +from hypothesis.strategies import ( + one_of, + just, + fixed_dictionaries, + integers, + binary, + text, +) + +from twisted.internet.task import ( + Cooperator, +) +from twisted.web.http import ( + OK, + NOT_FOUND, + BAD_REQUEST, +) +from twisted.web.resource import ( + IResource, + getChildForRequest, +) +from twisted.web.client import ( + FileBodyProducer, + readBody, +) + +from treq.testing import ( + RequestTraversalAgent, +) + +from ..resource import ( + from_configuration, +) + +from .strategies import ( + tahoe_configs, + client_configurations, + payment_reference_numbers, + requests, +) +from .matchers import ( + Provides, +) + +# Helper to work-around https://github.com/twisted/treq/issues/161 +def uncooperator(started=True): + return Cooperator( + # Don't stop consuming the iterator until it's done. + terminationPredicateFactory=lambda: lambda: False, + scheduler=lambda what: (what(), object())[1], + started=started, + ) + + + +tahoe_configs_with_client_config = tahoe_configs(storage_client_plugins={ + u"privatestorageio-satauthz-v1": client_configurations(), +}) + +def is_not_json(bytestring): + try: + loads(bytestring) + except: + return True + return False + +def not_payment_reference_numbers(): + return text().filter( + lambda t: ( + # exclude / because it changes url dispatch and makes tests fail + # differently. + u"/" not in t and not is_urlsafe_base64(t) + ), + ) + +def is_urlsafe_base64(text): + try: + urlsafe_b64decode(text) + except: + return False + return True + +def invalid_bodies(): + """ + Build byte strings that ``PUT /payment-reference-number`` considers + invalid. + """ + return one_of( + # The wrong key but the right kind of value. + fixed_dictionaries({ + u"some-key": payment_reference_numbers(), + }).map(dumps), + # The right key but the wrong kind of value. + fixed_dictionaries({ + u"payment-reference-number": one_of( + integers(), + not_payment_reference_numbers(), + ), + }).map(dumps), + fixed_dictionaries({ + u"payment-reference-number": integers(), + }).map(dumps), + # Not even JSON + binary().filter(is_not_json), + ) + +class PaymentReferenceNumberTests(TestCase): + """ + Tests relating to ``/payment-reference-number`` as implemented by the + ``_secureaccesstokenauthorizer.resource`` module and its handling of + payment reference numbers (PRNs). + """ + def setUp(self): + super(PaymentReferenceNumberTests, self).setUp() + self.tempdir = self.useFixture(TempDir()) + self.useFixture(CaptureTwistedLogs()) + + + @given(tahoe_configs_with_client_config, requests(just([u"payment-reference-number"]))) + def test_reachable(self, get_config, request): + """ + A resource is reachable at the ``payment-reference-number`` child of a the + resource returned by ``from_configuration``. + """ + root = from_configuration( + get_config(self.tempdir.join(b"tahoe.ini"), b"tub.port"), + ) + + self.assertThat( + getChildForRequest(root, request), + Provides([IResource]), + ) + + + @given(tahoe_configs_with_client_config, payment_reference_numbers()) + def test_put_prn(self, get_config, prn): + """ + When a PRN is sent in a ``PUT`` to ``PaymentReferenceNumberCollection`` it + is passed in to the PRN redemption model object for handling and an + ``OK`` response is returned. + """ + root = from_configuration( + get_config(self.tempdir.join(b"tahoe.ini"), b"tub.port"), + ) + agent = RequestTraversalAgent(root) + producer = FileBodyProducer( + BytesIO(dumps({u"payment-reference-number": prn})), + cooperator=uncooperator(), + ) + requesting = agent.request( + b"PUT", + b"http://127.0.0.1/payment-reference-number", + bodyProducer=producer, + ) + self.addDetail( + u"requesting result", + text_content(u"{}".format(vars(requesting.result))), + ) + self.assertThat( + requesting, + succeeded( + ok_response(), + ), + ) + + @given(tahoe_configs_with_client_config, invalid_bodies()) + def test_put_invalid_body(self, get_config, body): + """ + If the body of a ``PUT`` to ``PaymentReferenceNumberCollection`` does not + consist of an object with a single *payment-reference-number* property + then the response is *BAD REQUEST*. + """ + root = from_configuration( + get_config(self.tempdir.join(b"tahoe.ini"), b"tub.port"), + ) + agent = RequestTraversalAgent(root) + producer = FileBodyProducer( + BytesIO(body), + cooperator=uncooperator(), + ) + requesting = agent.request( + b"PUT", + b"http://127.0.0.1/payment-reference-number", + bodyProducer=producer, + ) + self.addDetail( + u"requesting result", + text_content(u"{}".format(vars(requesting.result))), + ) + self.assertThat( + requesting, + succeeded( + bad_request_response(), + ), + ) + + @given(tahoe_configs_with_client_config, payment_reference_numbers()) + def test_get_invalid_prn(self, get_config, prn): + """ + When a syntactically invalid PRN is requested with a ``GET`` to a child of + ``PaymentReferenceNumberCollection`` the response is **BAD REQUEST**. + """ + not_prn = prn[1:] + root = from_configuration( + get_config(self.tempdir.join(b"tahoe.ini"), b"tub.port"), + ) + + agent = RequestTraversalAgent(root) + requesting = agent.request( + b"GET", + u"http://127.0.0.1/payment-reference-number/{}".format(not_prn).encode("utf-8"), + ) + self.assertThat( + requesting, + succeeded( + bad_request_response(), + ), + ) + + + @given(tahoe_configs_with_client_config, payment_reference_numbers()) + def test_get_unknown_prn(self, get_config, prn): + """ + When a PRN is requested with a ``GET`` to a child of + ``PaymentReferenceNumberCollection`` the response is **NOT FOUND** if + the PRN hasn't previously been submitted with a ``PUT``. + """ + root = from_configuration( + get_config(self.tempdir.join(b"tahoe.ini"), b"tub.port"), + ) + + agent = RequestTraversalAgent(root) + requesting = agent.request( + b"GET", + u"http://127.0.0.1/payment-reference-number/{}".format(prn).encode("ascii"), + ) + self.assertThat( + requesting, + succeeded( + not_found_response(), + ), + ) + + + @given(tahoe_configs_with_client_config, payment_reference_numbers()) + def test_get_known_prn(self, get_config, prn): + """ + When a PRN is first ``PUT`` and then later a ``GET`` is issued for the + same PRN then the response code is **OK** and details about the PRN + are included in a json-encoded response body. + """ + root = from_configuration( + get_config(self.tempdir.join(b"tahoe.ini"), b"tub.port"), + ) + + agent = RequestTraversalAgent(root) + + producer = FileBodyProducer( + BytesIO(dumps({u"payment-reference-number": prn})), + cooperator=uncooperator(), + ) + putting = agent.request( + b"PUT", + b"http://127.0.0.1/payment-reference-number", + bodyProducer=producer, + ) + self.assertThat( + putting, + succeeded( + ok_response(), + ), + ) + + getting = agent.request( + b"GET", + u"http://127.0.0.1/payment-reference-number/{}".format(prn).encode("ascii"), + ) + + self.assertThat( + getting, + succeeded( + MatchesAll( + ok_response(headers=application_json()), + AfterPreprocessing( + json_content, + succeeded( + Equals({ + u"version": 1, + u"number": prn, + }), + ), + ), + ), + ), + ) + + +def application_json(): + return AfterPreprocessing( + lambda h: h.getRawHeaders(u"content-type"), + Equals([u"application/json"]), + ) + + +def json_content(response): + reading = readBody(response) + reading.addCallback(loads) + return reading + + +def ok_response(headers=None): + return match_response(OK, headers) + + +def not_found_response(headers=None): + return match_response(NOT_FOUND, headers) + + +def bad_request_response(headers=None): + return match_response(BAD_REQUEST, headers) + + +def match_response(code, headers): + if headers is None: + headers = Always() + return _MatchResponse( + code=Equals(code), + headers=headers, + ) + + +@attr.s +class _MatchResponse(object): + code = attr.ib() + headers = attr.ib() + _details = attr.ib(default=attr.Factory(dict)) + + def match(self, response): + self._details.update({ + u"code": response.code, + u"headers": response.headers.getAllRawHeaders(), + }) + return MatchesStructure( + code=self.code, + headers=self.headers, + ).match(response) + + def get_details(self): + return self._details diff --git a/src/_secureaccesstokenauthorizer/tests/test_model.py b/src/_secureaccesstokenauthorizer/tests/test_model.py new file mode 100644 index 0000000000000000000000000000000000000000..2b875b94856ba167f961e2a8e9750755dd4b1501 --- /dev/null +++ b/src/_secureaccesstokenauthorizer/tests/test_model.py @@ -0,0 +1,187 @@ +# Copyright 2019 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 ``_secureaccesstokenauthorizer.model``. +""" + +from os import ( + chmod, + mkdir, +) +from errno import ( + EACCES, +) + +from testtools import ( + TestCase, +) +from testtools.matchers import ( + AfterPreprocessing, + MatchesStructure, + MatchesAll, + Equals, + Raises, + IsInstance, + raises, +) + +from fixtures import ( + TempDir, +) + +from hypothesis import ( + given, + assume, +) + +from ..model import ( + StoreDirectoryError, + StoreAddError, + PaymentReferenceStore, +) + +from .strategies import ( + tahoe_configs, + payment_reference_numbers, +) + + +class PaymentReferenceStoreTests(TestCase): + """ + Tests for ``PaymentReferenceStore``. + """ + @given(tahoe_configs(), payment_reference_numbers()) + def test_get_missing(self, get_config, prn): + """ + ``PaymentReferenceStore.get`` raises ``KeyError`` when called with a + payment reference number not previously added to the store. + """ + tempdir = self.useFixture(TempDir()) + config = get_config(tempdir.join(b"node"), b"tub.port") + store = PaymentReferenceStore(config) + self.assertThat( + lambda: store.get(prn), + raises(KeyError), + ) + + @given(tahoe_configs(), payment_reference_numbers()) + def test_add(self, get_config, prn): + """ + ``PaymentReferenceStore.get`` returns a ``PaymentReference`` representing + a payment reference previously added to the store with + ``PaymentReferenceStore.add``. + """ + tempdir = self.useFixture(TempDir()) + config = get_config(tempdir.join(b"node"), b"tub.port") + store = PaymentReferenceStore(config) + store.add(prn) + payment_reference = store.get(prn) + self.assertThat( + payment_reference, + MatchesStructure( + number=Equals(prn), + ), + ) + + @given(tahoe_configs(), payment_reference_numbers()) + def test_add_idempotent(self, get_config, prn): + """ + More than one call to ``PaymentReferenceStore.add`` with the same argument + results in the same state as a single call. + """ + tempdir = self.useFixture(TempDir()) + config = get_config(tempdir.join(b"node"), b"tub.port") + store = PaymentReferenceStore(config) + store.add(prn) + store.add(prn) + payment_reference = store.get(prn) + self.assertThat( + payment_reference, + MatchesStructure( + number=Equals(prn), + ), + ) + + + @given(tahoe_configs(), payment_reference_numbers(), payment_reference_numbers()) + def test_unwriteable_store_directory(self, get_config, prn_a, prn_b): + """ + If the underlying directory in the node configuration is not writeable + then ``PaymentReferenceStore.add`` raises ``StoreAddError``. + """ + assume(prn_a != prn_b) + tempdir = self.useFixture(TempDir()) + nodedir = tempdir.join(b"node") + config = get_config(nodedir, b"tub.port") + store = PaymentReferenceStore(config) + # Initialize the underlying directory. + store.add(prn_a) + # Mess it up + chmod(config.get_private_path(store._CONFIG_DIR), 0o500) + + self.assertThat( + lambda: store.add(prn_b), + Raises( + AfterPreprocessing( + lambda (type, exc, tb): exc, + MatchesAll( + IsInstance(StoreAddError), + MatchesStructure( + reason=MatchesAll( + IsInstance(IOError), + MatchesStructure( + errno=Equals(EACCES), + ), + ), + ), + ), + ), + ), + ) + + @given(tahoe_configs(), payment_reference_numbers()) + def test_uncreateable_store_directory(self, get_config, prn): + """ + If the underlying directory in the node configuration cannot be created + then ``PaymentReferenceStore.add`` raises ``StoreDirectoryError``. + """ + tempdir = self.useFixture(TempDir()) + nodedir = tempdir.join(b"node") + config = get_config(nodedir, b"tub.port") + store = PaymentReferenceStore(config) + + # Create the node directory without permission to create the + # underlying directory. + mkdir(nodedir, 0o500) + + self.assertThat( + lambda: store.add(prn), + Raises( + AfterPreprocessing( + lambda (type, exc, tb): exc, + MatchesAll( + IsInstance(StoreDirectoryError), + MatchesStructure( + reason=MatchesAll( + IsInstance(OSError), + MatchesStructure( + errno=Equals(EACCES), + ), + ), + ), + ), + ), + ), + ) diff --git a/src/_secureaccesstokenauthorizer/tests/test_plugin.py b/src/_secureaccesstokenauthorizer/tests/test_plugin.py index 14d544bfb361b8e0adcb5ad93d65cf528f62e64c..7d3de2b5b92e18037a86c68427b0a57f1736468a 100644 --- a/src/_secureaccesstokenauthorizer/tests/test_plugin.py +++ b/src/_secureaccesstokenauthorizer/tests/test_plugin.py @@ -57,6 +57,9 @@ from twisted.plugin import ( from twisted.test.proto_helpers import ( StringTransport, ) +from twisted.web.resource import ( + IResource, +) from twisted.plugins.secureaccesstokenauthorizer import ( storage_server, ) @@ -222,3 +225,19 @@ class ClientPluginTests(TestCase): storage_client_deferred, succeeded(Provides([IStorageServer])), ) + + +class ClientResourceTests(TestCase): + """ + Tests for the plugin's implementation of + ``IFoolscapStoragePlugin.get_client_resource``. + """ + @given(configurations()) + def test_interface(self, configuration): + """ + ``get_client_resource`` returns an object that provides ``IResource``. + """ + self.assertThat( + storage_server.get_client_resource(configuration), + Provides([IResource]), + ) diff --git a/src/_secureaccesstokenauthorizer/tests/test_strategies.py b/src/_secureaccesstokenauthorizer/tests/test_strategies.py new file mode 100644 index 0000000000000000000000000000000000000000..22f5aacfa67f0a06f886122f4d30856b718b8eac --- /dev/null +++ b/src/_secureaccesstokenauthorizer/tests/test_strategies.py @@ -0,0 +1,59 @@ +# Copyright 2019 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 our custom Hypothesis strategies. +""" + +from testtools import ( + TestCase, +) + +from fixtures import ( + TempDir, +) + +from hypothesis import ( + given, + note, +) +from hypothesis.strategies import ( + data, +) + +from allmydata.client import ( + config_from_string, +) + +from .strategies import ( + tahoe_config_texts, +) + +class TahoeConfigsTests(TestCase): + """ + Tests for ``tahoe_configs``. + """ + @given(data()) + def test_parses(self, data): + """ + Configurations built by the strategy can be parsed. + """ + tempdir = self.useFixture(TempDir()) + config_text = data.draw(tahoe_config_texts({})) + note(config_text) + config_from_string( + tempdir.join(b"tahoe.ini"), + b"tub.port", + config_text.encode("utf-8"), + )