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..e48f900634ab6bb7d78bf6c48794aab4d4b020fc --- /dev/null +++ b/docs/source/interface.rst @@ -0,0 +1,50 @@ +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:: + + {"value": <string>} + +The ``value`` property merely indicates the payment reference number which was requested. +Further properties will be added to this response in the near future. + +``GET /storage-plugins/privatestorageio-satauthz-v1/payment-reference-number`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This endpoint allows an external agent to retrieve the status of all payment reference numbers. +This endpoint accepts no request body. + +The response is **OK** with ``application/json`` content-type response body like:: + + {"payment-reference-numbers": [<payment reference status object>, ...]} + +The elements of the list are objects like the one returned by issuing a **GET** to a child of this collection resource. diff --git a/requirements-tests.in b/requirements-tests.in index eb7961896371c2ea899ea51bd04f514eba1fb4d9..eb1dc70dd9adc9cb00f8adabc2c5084c42d22440 100644 --- a/requirements-tests.in +++ b/requirements-tests.in @@ -4,3 +4,4 @@ attrs hypothesis testtools fixtures +treq diff --git a/requirements-tests.txt b/requirements-tests.txt index 6f1599c6585731861f897f41cdfe76a0f3b3aa29..b3280eee0b954b167f9cfc9ecf1949a5e3bec39d 100644 --- a/requirements-tests.txt +++ b/requirements-tests.txt @@ -8,6 +8,10 @@ argparse==1.4.0 \ --hash=sha256:62b089a55be1d8949cd2bc7e0df0bddb9e028faefc8c32038cc84862aefdd6e4 \ --hash=sha256:c31647edb69fd3d465a847ea3157d37bed1f95f19760b11a47aa91c04b666314 \ # via unittest2 +asn1crypto==0.24.0 \ + --hash=sha256:2f1adbb7546ed199e3c90ef23ec95c5cf3585bac7d11fb7eb562a3fe89c64e87 \ + --hash=sha256:9d5c20441baf0cb60a4ac34cc447c6c189024b6b4c6cd7877034f4965c464e49 \ + # via cryptography attrs==19.1.0 \ --hash=sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79 \ --hash=sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399 @@ -19,6 +23,36 @@ certifi==2019.6.16 \ --hash=sha256:046832c04d4e752f37383b628bc601a7ea7211496b4638f6514d0e5b9acc4939 \ --hash=sha256:945e3ba63a0b9f577b1395204e13c3a231f9bc0223888be653286534e5873695 \ # via requests +cffi==1.12.3 \ + --hash=sha256:041c81822e9f84b1d9c401182e174996f0bae9991f33725d059b771744290774 \ + --hash=sha256:046ef9a22f5d3eed06334d01b1e836977eeef500d9b78e9ef693f9380ad0b83d \ + --hash=sha256:066bc4c7895c91812eff46f4b1c285220947d4aa46fa0a2651ff85f2afae9c90 \ + --hash=sha256:066c7ff148ae33040c01058662d6752fd73fbc8e64787229ea8498c7d7f4041b \ + --hash=sha256:2444d0c61f03dcd26dbf7600cf64354376ee579acad77aef459e34efcb438c63 \ + --hash=sha256:300832850b8f7967e278870c5d51e3819b9aad8f0a2c8dbe39ab11f119237f45 \ + --hash=sha256:34c77afe85b6b9e967bd8154e3855e847b70ca42043db6ad17f26899a3df1b25 \ + --hash=sha256:46de5fa00f7ac09f020729148ff632819649b3e05a007d286242c4882f7b1dc3 \ + --hash=sha256:4aa8ee7ba27c472d429b980c51e714a24f47ca296d53f4d7868075b175866f4b \ + --hash=sha256:4d0004eb4351e35ed950c14c11e734182591465a33e960a4ab5e8d4f04d72647 \ + --hash=sha256:4e3d3f31a1e202b0f5a35ba3bc4eb41e2fc2b11c1eff38b362de710bcffb5016 \ + --hash=sha256:50bec6d35e6b1aaeb17f7c4e2b9374ebf95a8975d57863546fa83e8d31bdb8c4 \ + --hash=sha256:55cad9a6df1e2a1d62063f79d0881a414a906a6962bc160ac968cc03ed3efcfb \ + --hash=sha256:5662ad4e4e84f1eaa8efce5da695c5d2e229c563f9d5ce5b0113f71321bcf753 \ + --hash=sha256:59b4dc008f98fc6ee2bb4fd7fc786a8d70000d058c2bbe2698275bc53a8d3fa7 \ + --hash=sha256:73e1ffefe05e4ccd7bcea61af76f36077b914f92b76f95ccf00b0c1b9186f3f9 \ + --hash=sha256:a1f0fd46eba2d71ce1589f7e50a9e2ffaeb739fb2c11e8192aa2b45d5f6cc41f \ + --hash=sha256:a2e85dc204556657661051ff4bab75a84e968669765c8a2cd425918699c3d0e8 \ + --hash=sha256:a5457d47dfff24882a21492e5815f891c0ca35fefae8aa742c6c263dac16ef1f \ + --hash=sha256:a8dccd61d52a8dae4a825cdbb7735da530179fea472903eb871a5513b5abbfdc \ + --hash=sha256:ae61af521ed676cf16ae94f30fe202781a38d7178b6b4ab622e4eec8cefaff42 \ + --hash=sha256:b012a5edb48288f77a63dba0840c92d0504aa215612da4541b7b42d849bc83a3 \ + --hash=sha256:d2c5cfa536227f57f97c92ac30c8109688ace8fa4ac086d19d0af47d134e2909 \ + --hash=sha256:d42b5796e20aacc9d15e66befb7a345454eef794fdb0737d1af593447c6c8f45 \ + --hash=sha256:dee54f5d30d775f525894d67b1495625dd9322945e7fee00731952e0368ff42d \ + --hash=sha256:e070535507bd6aa07124258171be2ee8dfc19119c28ca94c9dfb7efd23564512 \ + --hash=sha256:e1ff2748c84d97b065cc95429814cdba39bcbd77c9c85c89344b317dc0d9cbff \ + --hash=sha256:ed851c75d1e0e043cbf5ca9a8e1b13c4c90f3fbd863dacb01c0808e2b5204201 \ + # via cryptography chardet==3.0.4 \ --hash=sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae \ --hash=sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691 \ @@ -63,12 +97,30 @@ coverage==4.5.3 \ --hash=sha256:f8019c5279eb32360ca03e9fac40a12667715546eed5c5eb59eb381f2f501260 \ --hash=sha256:fc5f4d209733750afd2714e9109816a29500718b32dd9a5db01c0cb3a019b96a \ # via codecov +cryptography==2.7 \ + --hash=sha256:24b61e5fcb506424d3ec4e18bca995833839bf13c59fc43e530e488f28d46b8c \ + --hash=sha256:25dd1581a183e9e7a806fe0543f485103232f940fcfc301db65e630512cce643 \ + --hash=sha256:3452bba7c21c69f2df772762be0066c7ed5dc65df494a1d53a58b683a83e1216 \ + --hash=sha256:41a0be220dd1ed9e998f5891948306eb8c812b512dc398e5a01846d855050799 \ + --hash=sha256:5751d8a11b956fbfa314f6553d186b94aa70fdb03d8a4d4f1c82dcacf0cbe28a \ + --hash=sha256:5f61c7d749048fa6e3322258b4263463bfccefecb0dd731b6561cb617a1d9bb9 \ + --hash=sha256:72e24c521fa2106f19623a3851e9f89ddfdeb9ac63871c7643790f872a305dfc \ + --hash=sha256:7b97ae6ef5cba2e3bb14256625423413d5ce8d1abb91d4f29b6d1a081da765f8 \ + --hash=sha256:961e886d8a3590fd2c723cf07be14e2a91cf53c25f02435c04d39e90780e3b53 \ + --hash=sha256:96d8473848e984184b6728e2c9d391482008646276c3ff084a1bd89e15ff53a1 \ + --hash=sha256:ae536da50c7ad1e002c3eee101871d93abdc90d9c5f651818450a0d3af718609 \ + --hash=sha256:b0db0cecf396033abb4a93c95d1602f268b3a68bb0a9cc06a7cff587bb9a7292 \ + --hash=sha256:cfee9164954c186b191b91d4193989ca994703b2fff406f71cf454a2d3c7327e \ + --hash=sha256:e6347742ac8f35ded4a46ff835c60e68c22a536a8ae5c4422966d06946b6d4c6 \ + --hash=sha256:f27d93f0139a3c056172ebb5d4f9056e770fdf0206c2f422ff2ebbad142e09ed \ + --hash=sha256:f57b76e46a58b63d1c6375017f4564a28f19a5ca912691fd2e4261b3414b618d \ + # via pyopenssl, service-identity enum34==1.1.6 \ --hash=sha256:2d81cbbe0e73112bdfe6ef8576f2238f2ba27dd0d55752a776c41d38b7da2850 \ --hash=sha256:644837f692e5f550741432dd3f223bbb9852018674981b1664e5dc339387588a \ --hash=sha256:6bd0f6ad48ec2aa117d3d141940d484deccda84d4fcd884f5c3d93c23ecd8c79 \ --hash=sha256:8ad8c4783bf61ded74527bffb48ed9b54166685e4230386a9ed9b1279e2df5b1 \ - # via hypothesis + # via cryptography, hypothesis extras==1.0.0 \ --hash=sha256:132e36de10b9c91d5d4cc620160a476e0468a88f16c9431817a6729611a81b4e \ --hash=sha256:f689f08df47e2decf76aa6208c081306e7bd472630eb1ec8a875c67de2366e87 \ @@ -86,11 +138,15 @@ hypothesis==4.27.0 \ idna==2.8 \ --hash=sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407 \ --hash=sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c \ - # via hyperlink, requests + # via hyperlink, requests, twisted incremental==17.5.0 \ --hash=sha256:717e12246dddf231a349175f48d74d93e2897244939173b01974ab6661406b9f \ --hash=sha256:7b751696aaf36eebfab537e458929e194460051ccad279c72b755a167eebd4b3 \ - # via twisted + # via treq, twisted +ipaddress==1.0.22 \ + --hash=sha256:64b28eec5e78e7510698f6d4da08800a5c575caa4a286c93d651c5d3ff7b6794 \ + --hash=sha256:b146c751ea45cad6188dd6cf2d9b757f6f4f8d6ffb96a023e6f2e26eea02a72c \ + # via cryptography, service-identity linecache2==1.0.0 \ --hash=sha256:4b26ff4e7110db76eeb6f5a7b64a82623839d595c2038eeda662f2a2db78e97c \ --hash=sha256:e78be9c0a0dfcbac712fe04fbf92b96cddae80b1b842f24248214c8496f006ef \ @@ -99,10 +155,25 @@ pbr==5.4.0 \ --hash=sha256:36ebd78196e8c9588c972f5571230a059ff83783fabbbbedecc07be263ccd7e6 \ --hash=sha256:5a03f59455ad54f01a94c15829b8b70065462b7bd8d5d7e983306b59127fc841 \ # via fixtures, testtools +pyasn1-modules==0.2.6 \ + --hash=sha256:43c17a83c155229839cc5c6b868e8d0c6041dba149789b6d6e28801c64821722 \ + --hash=sha256:e30199a9d221f1b26c885ff3d87fd08694dbbe18ed0e8e405a2a7126d30ce4c0 \ + # via service-identity +pyasn1==0.4.6 \ + --hash=sha256:3bb81821d47b17146049e7574ab4bf1e315eb7aead30efe5d6a9ca422c9710be \ + --hash=sha256:b773d5c9196ffbc3a1e13bdf909d446cad80a039aa3340bcad72f395b76ebc86 \ + # via pyasn1-modules, service-identity +pycparser==2.19 \ + --hash=sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3 \ + # via cffi pyhamcrest==1.9.0 \ --hash=sha256:6b672c02fdf7470df9674ab82263841ce8333fb143f32f021f6cb26f0e512420 \ --hash=sha256:8ffaa0a53da57e89de14ced7185ac746227a8894dbd5a3c718bf05ddbd1d56cd \ # via twisted +pyopenssl==19.0.0 \ + --hash=sha256:aeca66338f6de19d1aa46ed634c3b9ae519a64b458f8468aec688e7e3c20f200 \ + --hash=sha256:c727930ad54b10fc157015014b666f2d8b41f70c0d03e83ab67624fd3dd5d1e6 \ + # via twisted python-mimeparse==1.6.0 \ --hash=sha256:76e4b03d700a641fd7761d3cd4fdbbdcd787eade1ebfac43f877016328334f78 \ --hash=sha256:a295f03ff20341491bfe4717a39cd0a8cc9afad619ba44b77e86b0ab8a2b8282 \ @@ -110,11 +181,15 @@ python-mimeparse==1.6.0 \ requests==2.22.0 \ --hash=sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4 \ --hash=sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31 \ - # via codecov + # via codecov, treq +service-identity==18.1.0 \ + --hash=sha256:001c0707759cb3de7e49c078a7c0c9cd12594161d3bf06b9c254fdcb1a60dc36 \ + --hash=sha256:0858a54aabc5b459d1aafa8a518ed2081a285087f349fe3e55197989232e2e2d \ + # via twisted six==1.12.0 \ --hash=sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c \ --hash=sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73 \ - # via automat, fixtures, pyhamcrest, testtools, unittest2 + # via automat, cryptography, fixtures, pyhamcrest, pyopenssl, testtools, treq, unittest2 testtools==2.3.0 \ --hash=sha256:5827ec6cf8233e0f29f51025addd713ca010061204fdea77484a2934690a0559 \ --hash=sha256:a2be448869171b6e0f26d9544088b8b98439ec180ce272040236d570a40bcbed @@ -122,7 +197,10 @@ traceback2==1.4.0 \ --hash=sha256:05acc67a09980c2ecfedd3423f7ae0104839eccb55fc645773e1caa0951c3030 \ --hash=sha256:8253cebec4b19094d67cc5ed5af99bf1dba1285292226e98a31929f87a5d6b23 \ # via testtools, unittest2 -twisted==19.2.1 \ +treq==18.6.0 \ + --hash=sha256:91e09ff6b524cc90aa5e934b909c8d0d1a9d36ebd618b6c38e37b17013e69f48 \ + --hash=sha256:b936621beee3af98eca6dd74e0a8f939d8163c50714466a4994d22983a713ec2 +twisted[tls]==19.2.1 \ --hash=sha256:fa2c04c2d68a9be7fc3975ba4947f653a57a656776f24be58ff0fe4b9aaf3e52 unittest2==1.1.0 \ --hash=sha256:13f77d0875db6d9b435e1d4f41e74ad4cc2eb6e1d5c824996092b3430f088bb8 \ diff --git a/requirements.in b/requirements.in index a65a2be8d945c9391c604f71ab21c8e36ec0fe07..b35f3c038dc93795c97b4fff635f32a687f6ad6d 100644 --- a/requirements.in +++ b/requirements.in @@ -1,4 +1,4 @@ attrs zope.interface twisted -https://github.com/tahoe-lafs/tahoe-lafs/archive/48bd16a8d9109910122cc2e2c85eb4f378390135.zip#egg=tahoe-lafs +https://github.com/tahoe-lafs/tahoe-lafs/archive/6c1a37c95188c1d9a877286ef726280a68d38a4b.zip#egg=tahoe-lafs diff --git a/requirements.txt b/requirements.txt index de68811252e40ec68f47411fa725f3114e410428..3542f6171328df3b3708c3b5b28628573cca3df3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -148,13 +148,12 @@ magic-wormhole==0.11.2 \ nevow==0.14.4 \ --hash=sha256:0c6f3d897403216619a6eb8402f1248fbbade7b29c91a70a89092a06bbcb09be \ --hash=sha256:2299a0d2a0c1312040705599d5d571acfea74df82b968c0b9264f6f45266cf6e -pyasn1-modules==0.2.5 \ - --hash=sha256:ef721f68f7951fab9b0404d42590f479e30d9005daccb1699b0a51bb4177db96 \ - --hash=sha256:f309b6c94724aeaf7ca583feb1cc70430e10d7551de5e36edfc1ae6909bcfb3c \ - # via service-identity -pyasn1==0.4.5 \ - --hash=sha256:da2420fe13a9452d8ae97a0e478adde1dee153b11ba832a95b223a2ba01c10f7 \ - --hash=sha256:da6b43a8c9ae93bc80e2739efb38cc776ba74a886e3e9318d65fe81a8b8a2c6e \ +pyasn1-modules==0.2.6 \ + --hash=sha256:43c17a83c155229839cc5c6b868e8d0c6041dba149789b6d6e28801c64821722 \ + --hash=sha256:e30199a9d221f1b26c885ff3d87fd08694dbbe18ed0e8e405a2a7126d30ce4c0 +pyasn1==0.4.6 \ + --hash=sha256:3bb81821d47b17146049e7574ab4bf1e315eb7aead30efe5d6a9ca422c9710be \ + --hash=sha256:b773d5c9196ffbc3a1e13bdf909d446cad80a039aa3340bcad72f395b76ebc86 \ # via pyasn1-modules, service-identity, twisted pycparser==2.19 \ --hash=sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3 \ @@ -220,8 +219,8 @@ spake2==0.8 \ --hash=sha256:c17a614b29ee4126206e22181f70a406c618d3c6c62ca6d6779bce95e9c926f4 \ --hash=sha256:ce80705f8516c54364931f3b2c9a917ba001500d7f2fc76a0e8cf3bcaf0e30f7 \ # via magic-wormhole -https://github.com/tahoe-lafs/tahoe-lafs/archive/48bd16a8d9109910122cc2e2c85eb4f378390135.zip#egg=tahoe-lafs \ - --hash=sha256:982e67ee3e515f400ab88b7ce02dba8a7c0dd897eb07f3111a80a06778e2b6e4 +https://github.com/tahoe-lafs/tahoe-lafs/archive/6c1a37c95188c1d9a877286ef726280a68d38a4b.zip#egg=tahoe-lafs \ + --hash=sha256:fda66824f274f003d4cb98cf59104549a4e572f943c25383f2db56615aa6d105 tqdm==4.32.2 \ --hash=sha256:14a285392c32b6f8222ecfbcd217838f88e11630affe9006cd0e94c7eff3cb61 \ --hash=sha256:25d4c0ea02a305a688e7e9c2cdc8f862f989ef2a4701ab28ee963295f5b109ab \ 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..ac1dd02b3c0af55fdc63e7d383ec663b02db45b3 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, node_config): + return resource_from_configuration(node_config) diff --git a/src/_secureaccesstokenauthorizer/controller.py b/src/_secureaccesstokenauthorizer/controller.py new file mode 100644 index 0000000000000000000000000000000000000000..5b3bea2b4492ac9c0578e0c474cdc6b93c6279c0 --- /dev/null +++ b/src/_secureaccesstokenauthorizer/controller.py @@ -0,0 +1,27 @@ +# 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): + self.store.add(prn) diff --git a/src/_secureaccesstokenauthorizer/model.py b/src/_secureaccesstokenauthorizer/model.py new file mode 100644 index 0000000000000000000000000000000000000000..fb259f1e4e4377a43a70c740ff87909ebb3d977d --- /dev/null +++ b/src/_secureaccesstokenauthorizer/model.py @@ -0,0 +1,232 @@ +# 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 functools import ( + wraps, +) +from json import ( + loads, + dumps, +) + +from sqlite3 import ( + OperationalError, + connect as _connect, +) + +import attr + +from twisted.python.filepath import ( + FilePath, +) + + +class StoreOpenError(Exception): + """ + There was a problem opening the underlying data store. + """ + def __init__(self, reason): + self.reason = reason + + +class SchemaError(TypeError): + pass + + +CONFIG_DB_NAME = u"privatestorageio-satauthz-v1.sqlite3" + +def open_and_initialize(path, required_schema_version, connect=None): + """ + Open a SQLite3 database for use as a payment reference store. + + Create the database and populate it with a schema, if it does not already + exist. + + :param FilePath path: The location of the SQLite3 database file. + + :param int required_schema_version: The schema version which must be + present in the database in order for a SQLite3 connection to be + returned. + + :raise SchemaError: If the schema in the database does not match the + required schema version. + + :return: A SQLite3 connection object for the database at the given path. + """ + if connect is None: + connect = _connect + try: + path.parent().makedirs(ignoreExistingDirectory=True) + except OSError as e: + raise StoreOpenError(e) + + dbfile = path.asBytesMode().path + try: + conn = connect( + dbfile, + isolation_level="IMMEDIATE", + ) + except OperationalError as e: + raise StoreOpenError(e) + + with conn: + cursor = conn.cursor() + cursor.execute( + # This code knows how to create schema version 1. This is + # regardless of what the caller *wants* to find in the database. + """ + CREATE TABLE IF NOT EXISTS [version] AS SELECT 1 AS [version] + """ + ) + cursor.execute( + """ + SELECT [version] FROM [version] + """ + ) + [(actual_version,)] = cursor.fetchall() + if actual_version != required_schema_version: + raise SchemaError( + "Unexpected database schema version. Required {}. Got {}.".format( + required_schema_version, + actual_version, + ), + ) + + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS [payment-references] ( + [number] text, + + PRIMARY KEY([number]) + ) + """, + ) + return conn + + +def with_cursor(f): + @wraps(f) + def with_cursor(self, *a, **kw): + with self._connection: + return f(self, self._connection.cursor(), *a, **kw) + return with_cursor + + +def memory_connect(path, *a, **kw): + """ + Always connect to an in-memory SQLite3 database. + """ + return _connect(":memory:", *a, **kw) + + +@attr.s(frozen=True) +class PaymentReferenceStore(object): + """ + This class implements persistence for payment references. + + :ivar allmydata.node._Config node_config: The Tahoe-LAFS node configuration object for + the node that owns the persisted payment preferences. + """ + database_path = attr.ib(type=FilePath) + _connection = attr.ib() + + @classmethod + def from_node_config(cls, node_config, connect=None): + db_path = FilePath(node_config.get_private_path(CONFIG_DB_NAME)) + conn = open_and_initialize( + db_path, + required_schema_version=1, + connect=connect, + ) + return cls( + db_path, + conn, + ) + + @with_cursor + def get(self, cursor, prn): + cursor.execute( + """ + SELECT + ([number]) + FROM + [payment-references] + WHERE + [number] = ? + """, + (prn,), + ) + refs = cursor.fetchall() + if len(refs) == 0: + raise KeyError(prn) + return PaymentReference(refs[0][0]) + + @with_cursor + def add(self, cursor, prn): + cursor.execute( + """ + INSERT OR IGNORE INTO [payment-references] VALUES (?) + """, + (prn,) + ) + + @with_cursor + def list(self, cursor): + cursor.execute( + """ + SELECT ([number]) FROM [payment-references] + """, + ) + refs = cursor.fetchall() + + return list( + PaymentReference(number) + for (number,) + in refs + ) + + +@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.marshal()) + + + def marshal(self): + return 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..d8b3626e862d35dbb034345344af368ebbb947cf --- /dev/null +++ b/src/_secureaccesstokenauthorizer/resource.py @@ -0,0 +1,182 @@ +# 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, dumps, +) + +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, store=None): + """ + 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. + + :param PaymentReferenceStore store: The store to use. If ``None`` a + sensible one is constructed. + + :return IResource: The root of the resource hierarchy presented by the + client side of the plugin. + """ + if store is None: + store = PaymentReferenceStore.from_node_config(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): + """ + Record a PRN and begin attempting to redeem it. + """ + 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 is_syntactic_prn(prn): + return bad_request().render(request) + + self._controller.redeem(prn) + return b"" + + + def render_GET(self, request): + request.responseHeaders.setRawHeaders(u"content-type", [u"application/json"]) + return dumps({ + u"payment-reference-numbers": list( + prn.marshal() + for prn + in self._store.list() + ), + }) + + + def getChild(self, segment, request): + prn = segment.decode("utf-8") + if not is_syntactic_prn(prn): + return bad_request() + try: + payment_reference = self._store.get(prn) + except KeyError: + return NoResource() + return PaymentReferenceView(payment_reference) + + +def is_syntactic_prn(prn): + """ + :param prn: A candidate object to inspect. + + :return bool: ``True`` if and only if ``prn`` is a unicode string + containing a syntactically valid payment reference number. This says + **nothing** about the validity of the represented PRN itself. A + ``True`` result only means the unicode string can be **interpreted** + as a PRN. + """ + if not isinstance(prn, unicode): + return False + if len(prn) != 44: + # TODO. 44 is the length of 32 bytes base64 encoded. This model + # information presumably belongs somewhere else. + return False + try: + urlsafe_b64decode(prn.encode("ascii")) + except Exception: + return False + return True + + +class PaymentReferenceView(Resource): + """ + This class implements a view for a ``PaymentReference`` instance. + """ + def __init__(self, reference): + """ + :param PaymentReference reference: The model object for which to provide a + view. + """ + 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 IResource: A resource which can be rendered to produce a **BAD + REQUEST** response. + """ + 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..c54c99067b80438f119edbc9521b9d1e84d87499 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, @@ -27,7 +28,7 @@ from testtools.matchers import ( @attr.s class Provides(object): """ - Match objects that provide one or more Zope Interface interfaces. + Match objects that provide all of a list of Zope Interface interfaces. """ interfaces = attr.ib() @@ -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..4af061defb0f2d34a3912078e55dfd76e580d205 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,152 @@ 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, + ).map( + lambda prn: prn.decode("ascii"), + ) + + +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 +377,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..3bd4812c4fc639e510eb0227b809f002f9647645 --- /dev/null +++ b/src/_secureaccesstokenauthorizer/tests/test_client_resource.py @@ -0,0 +1,516 @@ +# 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 urllib import ( + quote, +) + +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, + note, +) +from hypothesis.strategies import ( + one_of, + just, + fixed_dictionaries, + lists, + 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 ..model import ( + PaymentReferenceStore, + memory_connect, +) +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): + """ + :param bytes bytestring: A candidate byte string to inspect. + + :return bool: ``False`` if and only if ``bytestring`` is JSON encoded. + """ + try: + loads(bytestring) + except: + return True + return False + +def not_payment_reference_numbers(): + """ + Builds unicode strings which are not legal payment reference numbers. + """ + return one_of( + text().filter( + lambda t: ( + not is_urlsafe_base64(t) + ), + ), + payment_reference_numbers().map( + # Turn a valid PRN into a PRN that is invalid only by containing a + # character from the base64 alphabet in place of one from the + # urlsafe-base64 alphabet. + lambda prn: u"/" + prn[1:], + ), + ) + +def is_urlsafe_base64(text): + """ + :param unicode text: A candidate unicode string to inspect. + + :return bool: ``True`` if and only if ``text`` is urlsafe-base64 encoded + """ + 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), + # Not even JSON + binary().filter(is_not_json), + ) + + +def root_from_config(config): + """ + Create a client root resource from a Tahoe-LAFS configuration. + + :param _Config config: The Tahoe-LAFS configuration. + + :return IResource: The root client resource. + """ + return from_configuration( + config, + PaymentReferenceStore.from_node_config( + config, + memory_connect, + ), + ) + + +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.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``. + """ + 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]), + ) + + + @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. + """ + tempdir = self.useFixture(TempDir()) + config = get_config(tempdir.join(b"tahoe"), b"tub.port") + root = root_from_config(config) + 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*. + """ + tempdir = self.useFixture(TempDir()) + config = get_config(tempdir.join(b"tahoe"), b"tub.port") + root = root_from_config(config) + 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, not_payment_reference_numbers()) + def test_get_invalid_prn(self, get_config, not_prn): + """ + When a syntactically invalid PRN is requested with a ``GET`` to a child of + ``PaymentReferenceNumberCollection`` the response is **BAD REQUEST**. + """ + tempdir = self.useFixture(TempDir()) + config = get_config(tempdir.join(b"tahoe"), b"tub.port") + root = root_from_config(config) + agent = RequestTraversalAgent(root) + url = u"http://127.0.0.1/payment-reference-number/{}".format( + quote( + not_prn.encode("utf-8"), + safe=b"", + ).decode("utf-8"), + ).encode("ascii") + requesting = agent.request( + b"GET", + url, + ) + 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``. + """ + tempdir = self.useFixture(TempDir()) + config = get_config(tempdir.join(b"tahoe"), b"tub.port") + root = root_from_config(config) + 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. + """ + tempdir = self.useFixture(TempDir()) + config = get_config(tempdir.join(b"tahoe"), b"tub.port") + root = root_from_config(config) + 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( + quote( + prn.encode("utf-8"), + safe=b"", + ).decode("utf-8"), + ).encode("ascii"), + ) + + self.assertThat( + getting, + succeeded( + MatchesAll( + ok_response(headers=application_json()), + AfterPreprocessing( + json_content, + succeeded( + Equals({ + u"version": 1, + u"number": prn, + }), + ), + ), + ), + ), + ) + + @given(tahoe_configs_with_client_config, lists(payment_reference_numbers(), unique=True)) + def test_list_prns(self, get_config, prns): + """ + A ``GET`` to the ``PaymentReferenceNumberCollection`` itself returns a + list of existing payment reference numbers. + """ + # Hypothesis causes our test case instances to be re-used many times + # between setUp and tearDown. Avoid re-using the same temporary + # directory for every Hypothesis iteration because this test leaves + # state behind that invalidates future iterations. + tempdir = self.useFixture(TempDir()) + config = get_config(tempdir.join(b"tahoe"), b"tub.port") + root = root_from_config(config) + agent = RequestTraversalAgent(root) + + note("{} PRNs".format(len(prns))) + + for prn in prns: + 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", + b"http://127.0.0.1/payment-reference-number", + ) + + self.assertThat( + getting, + succeeded( + MatchesAll( + ok_response(headers=application_json()), + AfterPreprocessing( + json_content, + succeeded( + Equals({ + u"payment-reference-numbers": list( + {u"version": 1, u"number": prn} + for prn + in prns + ), + }), + ), + ), + ), + ), + ) + + +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_matchers.py b/src/_secureaccesstokenauthorizer/tests/test_matchers.py new file mode 100644 index 0000000000000000000000000000000000000000..ab95ee31b6a7f402406f40ca31dccd443a9c9d56 --- /dev/null +++ b/src/_secureaccesstokenauthorizer/tests/test_matchers.py @@ -0,0 +1,106 @@ +# 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.tests.matchers``. +""" + +from zope.interface import ( + Interface, + implementer, +) + +from testtools import ( + TestCase, +) +from testtools.matchers import ( + Not, + Is, +) + +from .matchers import ( + Provides, + returns, +) + + +class IX(Interface): + pass + + +class IY(Interface): + pass + + +@implementer(IX, IY) +class X(object): + pass + + +@implementer(IY) +class Y(object): + pass + + +class ProvidesTests(TestCase): + """ + Tests for ``Provides``. + """ + def test_match(self): + """ + ``Provides.match`` returns ``None`` when the given object provides all of + the configured interfaces. + """ + self.assertThat( + Provides([IX, IY]).match(X()), + Is(None), + ) + + def test_mismatch(self): + """ + ``Provides.match`` does not return ``None`` when the given object provides + none of the configured interfaces. + """ + self.assertThat( + Provides([IX, IY]).match(Y()), + Not(Is(None)), + ) + + +class ReturnsTests(TestCase): + """ + Tests for ``returns``. + """ + def test_match(self): + """ + ``returns(m)`` returns a matcher that matches when the given object + returns a value matched by ``m``. + """ + result = object() + self.assertThat( + returns(Is(result)).match(lambda: result), + Is(None), + ) + + def test_mismatch(self): + """ + ``returns(m)`` returns a matcher that does not match when the given object + returns a value not matched by ``m``. + """ + result = object() + other = object() + self.assertThat( + returns(Is(result)).match(lambda: other), + Not(Is(None)), + ) diff --git a/src/_secureaccesstokenauthorizer/tests/test_model.py b/src/_secureaccesstokenauthorizer/tests/test_model.py new file mode 100644 index 0000000000000000000000000000000000000000..3ef54d46d16e960fc2728f2abf46309bf1c49fc9 --- /dev/null +++ b/src/_secureaccesstokenauthorizer/tests/test_model.py @@ -0,0 +1,257 @@ +# coding: utf-8 +# 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 ( + 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, +) +from hypothesis.strategies import ( + lists, +) + +from twisted.python.filepath import ( + FilePath, +) + +from ..model import ( + SchemaError, + StoreOpenError, + PaymentReferenceStore, + PaymentReference, + open_and_initialize, + memory_connect, +) + +from .strategies import ( + tahoe_configs, + payment_reference_numbers, +) + + +class PaymentReferenceStoreTests(TestCase): + """ + Tests for ``PaymentReferenceStore``. + """ + def test_create_mismatched_schema(self): + """ + ``open_and_initialize`` raises ``SchemaError`` if asked for a database + with a schema version other than it can create. + """ + tempdir = self.useFixture(TempDir()) + dbpath = tempdir.join(b"db.sqlite3") + self.assertThat( + lambda: open_and_initialize( + FilePath(dbpath), + required_schema_version=100, + ), + raises(SchemaError), + ) + + + @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.from_node_config( + config, + memory_connect, + ) + 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.from_node_config( + config, + memory_connect, + ) + 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.from_node_config( + config, + memory_connect, + ) + store.add(prn) + store.add(prn) + payment_reference = store.get(prn) + self.assertThat( + payment_reference, + MatchesStructure( + number=Equals(prn), + ), + ) + + + @given(tahoe_configs(), lists(payment_reference_numbers())) + def test_list(self, get_config, prns): + """ + ``PaymentReferenceStore.list`` returns a ``list`` containing a + ``PaymentReference`` object for each payment reference number + previously added. + """ + tempdir = self.useFixture(TempDir()) + nodedir = tempdir.join(b"node") + config = get_config(nodedir, b"tub.port") + store = PaymentReferenceStore.from_node_config( + config, + memory_connect, + ) + + for prn in prns: + store.add(prn) + + self.assertThat( + store.list(), + AfterPreprocessing( + lambda refs: set(ref.number for ref in refs), + Equals(set(prns)), + ), + ) + + + @given(tahoe_configs()) + def test_uncreateable_store_directory(self, get_config): + """ + If the underlying directory in the node configuration cannot be created + then ``PaymentReferenceStore.from_node_config`` raises + ``StoreOpenError``. + """ + tempdir = self.useFixture(TempDir()) + nodedir = tempdir.join(b"node") + + # Create the node directory without permission to create the + # underlying directory. + mkdir(nodedir, 0o500) + + config = get_config(nodedir, b"tub.port") + + self.assertThat( + lambda: PaymentReferenceStore.from_node_config( + config, + memory_connect, + ), + Raises( + AfterPreprocessing( + lambda (type, exc, tb): exc, + MatchesAll( + IsInstance(StoreOpenError), + MatchesStructure( + reason=MatchesAll( + IsInstance(OSError), + MatchesStructure( + errno=Equals(EACCES), + ), + ), + ), + ), + ), + ), + ) + + + @given(tahoe_configs()) + def test_unopenable_store(self, get_config): + """ + If the underlying database file cannot be opened then + ``PaymentReferenceStore.from_node_config`` raises ``StoreOpenError``. + """ + tempdir = self.useFixture(TempDir()) + nodedir = tempdir.join(b"node") + + config = get_config(nodedir, b"tub.port") + + # Create the underlying database file. + store = PaymentReferenceStore.from_node_config(config) + + # Prevent further access to it. + store.database_path.chmod(0o000) + + self.assertThat( + lambda: PaymentReferenceStore.from_node_config( + config, + ), + raises(StoreOpenError), + ) + + +class PaymentReferenceTests(TestCase): + """ + Tests for ``PaymentReference``. + """ + @given(payment_reference_numbers()) + def test_json_roundtrip(self, prn): + """ + ``PaymentReference.to_json . PaymentReference.from_json → id`` + """ + ref = PaymentReference(prn) + self.assertThat( + PaymentReference.from_json(ref.to_json()), + Equals(ref), + ) diff --git a/src/_secureaccesstokenauthorizer/tests/test_plugin.py b/src/_secureaccesstokenauthorizer/tests/test_plugin.py index 14d544bfb361b8e0adcb5ad93d65cf528f62e64c..a7b9dceca6ac9f768d8d9b0f7722f4b25dc7a643 100644 --- a/src/_secureaccesstokenauthorizer/tests/test_plugin.py +++ b/src/_secureaccesstokenauthorizer/tests/test_plugin.py @@ -20,6 +20,10 @@ from zope.interface import ( implementer, ) +from fixtures import ( + TempDir, +) + from testtools import ( TestCase, ) @@ -57,11 +61,15 @@ from twisted.plugin import ( from twisted.test.proto_helpers import ( StringTransport, ) +from twisted.web.resource import ( + IResource, +) from twisted.plugins.secureaccesstokenauthorizer import ( storage_server, ) from .strategies import ( + tahoe_configs, configurations, announcements, ) @@ -222,3 +230,22 @@ class ClientPluginTests(TestCase): storage_client_deferred, succeeded(Provides([IStorageServer])), ) + + +class ClientResourceTests(TestCase): + """ + Tests for the plugin's implementation of + ``IFoolscapStoragePlugin.get_client_resource``. + """ + @given(tahoe_configs()) + def test_interface(self, get_config): + """ + ``get_client_resource`` returns an object that provides ``IResource``. + """ + tempdir = self.useFixture(TempDir()) + nodedir = tempdir.join(b"node") + config = get_config(nodedir, b"tub.port") + self.assertThat( + storage_server.get_client_resource(config), + 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"), + )