Merge pull request #728 from meejah/ticket3317-verified-fakes
Ticket 3317: start of verified fakes
This commit is contained in:
commit
ffd24b9c7f
|
@ -0,0 +1 @@
|
||||||
|
allmydata.testing.web, a new module, now offers a supported Python API for testing Tahoe-LAFS web API clients.
|
|
@ -0,0 +1,170 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Tahoe-LAFS -- secure, distributed storage grid
|
||||||
|
#
|
||||||
|
# Copyright © 2020 The Tahoe-LAFS Software Foundation
|
||||||
|
#
|
||||||
|
# This file is part of Tahoe-LAFS.
|
||||||
|
#
|
||||||
|
# See the docs/about.rst file for licensing information.
|
||||||
|
|
||||||
|
"""
|
||||||
|
Tests for the allmydata.testing helpers
|
||||||
|
"""
|
||||||
|
|
||||||
|
from twisted.internet.defer import (
|
||||||
|
inlineCallbacks,
|
||||||
|
)
|
||||||
|
|
||||||
|
from allmydata.uri import (
|
||||||
|
from_string,
|
||||||
|
CHKFileURI,
|
||||||
|
)
|
||||||
|
from allmydata.testing.web import (
|
||||||
|
create_tahoe_treq_client,
|
||||||
|
capability_generator,
|
||||||
|
)
|
||||||
|
|
||||||
|
from hyperlink import (
|
||||||
|
DecodedURL,
|
||||||
|
)
|
||||||
|
|
||||||
|
from hypothesis import (
|
||||||
|
given,
|
||||||
|
)
|
||||||
|
from hypothesis.strategies import (
|
||||||
|
binary,
|
||||||
|
)
|
||||||
|
|
||||||
|
from testtools import (
|
||||||
|
TestCase,
|
||||||
|
)
|
||||||
|
from testtools.matchers import (
|
||||||
|
Always,
|
||||||
|
Equals,
|
||||||
|
IsInstance,
|
||||||
|
MatchesStructure,
|
||||||
|
AfterPreprocessing,
|
||||||
|
)
|
||||||
|
from testtools.twistedsupport import (
|
||||||
|
succeeded,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWebTest(TestCase):
|
||||||
|
"""
|
||||||
|
Test the WebUI verified-fakes infrastucture
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Note: do NOT use setUp() because Hypothesis doesn't work
|
||||||
|
# properly with it. You must instead do all fixture-type work
|
||||||
|
# yourself in each test.
|
||||||
|
|
||||||
|
@given(
|
||||||
|
content=binary(),
|
||||||
|
)
|
||||||
|
def test_create_and_download(self, content):
|
||||||
|
"""
|
||||||
|
Upload some content (via 'PUT /uri') and then download it (via
|
||||||
|
'GET /uri?uri=...')
|
||||||
|
"""
|
||||||
|
http_client = create_tahoe_treq_client()
|
||||||
|
|
||||||
|
@inlineCallbacks
|
||||||
|
def do_test():
|
||||||
|
resp = yield http_client.put("http://example.com/uri", content)
|
||||||
|
self.assertThat(resp.code, Equals(201))
|
||||||
|
|
||||||
|
cap_raw = yield resp.content()
|
||||||
|
cap = from_string(cap_raw)
|
||||||
|
self.assertThat(cap, IsInstance(CHKFileURI))
|
||||||
|
|
||||||
|
resp = yield http_client.get(
|
||||||
|
"http://example.com/uri?uri={}".format(cap.to_string())
|
||||||
|
)
|
||||||
|
self.assertThat(resp.code, Equals(200))
|
||||||
|
|
||||||
|
round_trip_content = yield resp.content()
|
||||||
|
|
||||||
|
# using the form "/uri/<cap>" is also valid
|
||||||
|
|
||||||
|
resp = yield http_client.get(
|
||||||
|
"http://example.com/uri/{}".format(cap.to_string())
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.code, 200)
|
||||||
|
|
||||||
|
round_trip_content = yield resp.content()
|
||||||
|
self.assertEqual(content, round_trip_content)
|
||||||
|
self.assertThat(
|
||||||
|
do_test(),
|
||||||
|
succeeded(Always()),
|
||||||
|
)
|
||||||
|
|
||||||
|
@given(
|
||||||
|
content=binary(),
|
||||||
|
)
|
||||||
|
def test_duplicate_upload(self, content):
|
||||||
|
"""
|
||||||
|
Upload the same content (via 'PUT /uri') twice
|
||||||
|
"""
|
||||||
|
|
||||||
|
http_client = create_tahoe_treq_client()
|
||||||
|
|
||||||
|
@inlineCallbacks
|
||||||
|
def do_test():
|
||||||
|
resp = yield http_client.put("http://example.com/uri", content)
|
||||||
|
self.assertEqual(resp.code, 201)
|
||||||
|
|
||||||
|
cap_raw = yield resp.content()
|
||||||
|
self.assertThat(
|
||||||
|
cap_raw,
|
||||||
|
AfterPreprocessing(
|
||||||
|
from_string,
|
||||||
|
IsInstance(CHKFileURI)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = yield http_client.put("http://example.com/uri", content)
|
||||||
|
self.assertThat(resp.code, Equals(200))
|
||||||
|
self.assertThat(
|
||||||
|
do_test(),
|
||||||
|
succeeded(Always()),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_download_missing(self):
|
||||||
|
"""
|
||||||
|
Error if we download a capability that doesn't exist
|
||||||
|
"""
|
||||||
|
|
||||||
|
http_client = create_tahoe_treq_client()
|
||||||
|
cap_gen = capability_generator("URI:CHK:")
|
||||||
|
|
||||||
|
uri = DecodedURL.from_text(u"http://example.com/uri?uri={}".format(next(cap_gen)))
|
||||||
|
resp = http_client.get(uri.to_uri().to_text())
|
||||||
|
|
||||||
|
self.assertThat(
|
||||||
|
resp,
|
||||||
|
succeeded(
|
||||||
|
MatchesStructure(
|
||||||
|
code=Equals(500)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_download_no_arg(self):
|
||||||
|
"""
|
||||||
|
Error if we GET from "/uri" with no ?uri= query-arg
|
||||||
|
"""
|
||||||
|
|
||||||
|
http_client = create_tahoe_treq_client()
|
||||||
|
|
||||||
|
uri = DecodedURL.from_text(u"http://example.com/uri/")
|
||||||
|
resp = http_client.get(uri.to_uri().to_text())
|
||||||
|
|
||||||
|
self.assertThat(
|
||||||
|
resp,
|
||||||
|
succeeded(
|
||||||
|
MatchesStructure(
|
||||||
|
code=Equals(400)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
|
@ -0,0 +1,289 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Tahoe-LAFS -- secure, distributed storage grid
|
||||||
|
#
|
||||||
|
# Copyright © 2020 The Tahoe-LAFS Software Foundation
|
||||||
|
#
|
||||||
|
# This file is part of Tahoe-LAFS.
|
||||||
|
#
|
||||||
|
# See the docs/about.rst file for licensing information.
|
||||||
|
|
||||||
|
"""
|
||||||
|
Test-helpers for clients that use the WebUI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
import attr
|
||||||
|
|
||||||
|
from hyperlink import DecodedURL
|
||||||
|
|
||||||
|
from twisted.web.resource import (
|
||||||
|
Resource,
|
||||||
|
)
|
||||||
|
from twisted.web.iweb import (
|
||||||
|
IBodyProducer,
|
||||||
|
)
|
||||||
|
from twisted.web import (
|
||||||
|
http,
|
||||||
|
)
|
||||||
|
|
||||||
|
from twisted.internet.defer import (
|
||||||
|
succeed,
|
||||||
|
)
|
||||||
|
|
||||||
|
from treq.client import (
|
||||||
|
HTTPClient,
|
||||||
|
FileBodyProducer,
|
||||||
|
)
|
||||||
|
from treq.testing import (
|
||||||
|
RequestTraversalAgent,
|
||||||
|
)
|
||||||
|
from zope.interface import implementer
|
||||||
|
|
||||||
|
import allmydata.uri
|
||||||
|
from allmydata.util import (
|
||||||
|
base32,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
"create_fake_tahoe_root",
|
||||||
|
"create_tahoe_treq_client",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeTahoeRoot(Resource, object):
|
||||||
|
"""
|
||||||
|
An in-memory 'fake' of a Tahoe WebUI root. Currently it only
|
||||||
|
implements (some of) the `/uri` resource.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, uri=None):
|
||||||
|
"""
|
||||||
|
:param uri: a Resource to handle the `/uri` tree.
|
||||||
|
"""
|
||||||
|
Resource.__init__(self) # this is an old-style class :(
|
||||||
|
self._uri = uri
|
||||||
|
self.putChild(b"uri", self._uri)
|
||||||
|
|
||||||
|
def add_data(self, kind, data):
|
||||||
|
fresh, cap = self._uri.add_data(kind, data)
|
||||||
|
return cap
|
||||||
|
|
||||||
|
|
||||||
|
KNOWN_CAPABILITIES = [
|
||||||
|
getattr(allmydata.uri, t).BASE_STRING
|
||||||
|
for t in dir(allmydata.uri)
|
||||||
|
if hasattr(getattr(allmydata.uri, t), 'BASE_STRING')
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def capability_generator(kind):
|
||||||
|
"""
|
||||||
|
Deterministically generates a stream of valid capabilities of the
|
||||||
|
given kind. The N, K and size values aren't related to anything
|
||||||
|
real.
|
||||||
|
|
||||||
|
:param str kind: the kind of capability, like `URI:CHK`
|
||||||
|
|
||||||
|
:returns: a generator that yields new capablities of a particular
|
||||||
|
kind.
|
||||||
|
"""
|
||||||
|
if kind not in KNOWN_CAPABILITIES:
|
||||||
|
raise ValueError(
|
||||||
|
"Unknown capability kind '{} (valid are {})'".format(
|
||||||
|
kind,
|
||||||
|
", ".join(KNOWN_CAPABILITIES),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# what we do here is to start with empty hashers for the key and
|
||||||
|
# ueb_hash and repeatedly feed() them a zero byte on each
|
||||||
|
# iteration .. so the same sequence of capabilities will always be
|
||||||
|
# produced. We could add a seed= argument if we wanted to produce
|
||||||
|
# different sequences.
|
||||||
|
number = 0
|
||||||
|
key_hasher = hashlib.new("sha256")
|
||||||
|
ueb_hasher = hashlib.new("sha256") # ueb means "URI Extension Block"
|
||||||
|
|
||||||
|
# capabilities are "prefix:<128-bits-base32>:<256-bits-base32>:N:K:size"
|
||||||
|
while True:
|
||||||
|
number += 1
|
||||||
|
key_hasher.update("\x00")
|
||||||
|
ueb_hasher.update("\x00")
|
||||||
|
|
||||||
|
key = base32.b2a(key_hasher.digest()[:16]) # key is 16 bytes
|
||||||
|
ueb_hash = base32.b2a(ueb_hasher.digest()) # ueb hash is 32 bytes
|
||||||
|
|
||||||
|
cap = u"{kind}{key}:{ueb_hash}:{n}:{k}:{size}".format(
|
||||||
|
kind=kind,
|
||||||
|
key=key,
|
||||||
|
ueb_hash=ueb_hash,
|
||||||
|
n=1,
|
||||||
|
k=1,
|
||||||
|
size=number * 1000,
|
||||||
|
)
|
||||||
|
yield cap.encode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s
|
||||||
|
class _FakeTahoeUriHandler(Resource, object):
|
||||||
|
"""
|
||||||
|
An in-memory fake of (some of) the `/uri` endpoint of a Tahoe
|
||||||
|
WebUI
|
||||||
|
"""
|
||||||
|
|
||||||
|
isLeaf = True
|
||||||
|
|
||||||
|
data = attr.ib(default=attr.Factory(dict))
|
||||||
|
capability_generators = attr.ib(default=attr.Factory(dict))
|
||||||
|
|
||||||
|
def _generate_capability(self, kind):
|
||||||
|
"""
|
||||||
|
:param str kind: any valid capability-string type
|
||||||
|
|
||||||
|
:returns: the next capability-string for the given kind
|
||||||
|
"""
|
||||||
|
if kind not in self.capability_generators:
|
||||||
|
self.capability_generators[kind] = capability_generator(kind)
|
||||||
|
capability = next(self.capability_generators[kind])
|
||||||
|
return capability
|
||||||
|
|
||||||
|
def add_data(self, kind, data):
|
||||||
|
"""
|
||||||
|
adds some data to our grid
|
||||||
|
|
||||||
|
:returns: a two-tuple: a bool (True if the data is freshly added) and a capability-string
|
||||||
|
"""
|
||||||
|
if not isinstance(data, bytes):
|
||||||
|
raise TypeError("'data' must be bytes")
|
||||||
|
|
||||||
|
for k in self.data:
|
||||||
|
if self.data[k] == data:
|
||||||
|
return (False, k)
|
||||||
|
|
||||||
|
cap = self._generate_capability(kind)
|
||||||
|
# it should be impossible for this to already be in our data,
|
||||||
|
# but check anyway to be sure
|
||||||
|
if cap in self.data:
|
||||||
|
raise Exception("Internal error; key already exists somehow")
|
||||||
|
self.data[cap] = data
|
||||||
|
return (True, cap)
|
||||||
|
|
||||||
|
def render_PUT(self, request):
|
||||||
|
data = request.content.read()
|
||||||
|
fresh, cap = self.add_data("URI:CHK:", data)
|
||||||
|
if fresh:
|
||||||
|
request.setResponseCode(http.CREATED) # real code does this for brand-new files
|
||||||
|
else:
|
||||||
|
request.setResponseCode(http.OK) # replaced/modified files
|
||||||
|
return cap
|
||||||
|
|
||||||
|
def render_POST(self, request):
|
||||||
|
t = request.args[u"t"][0]
|
||||||
|
data = request.content.read()
|
||||||
|
|
||||||
|
type_to_kind = {
|
||||||
|
"mkdir-immutable": "URI:DIR2-CHK:"
|
||||||
|
}
|
||||||
|
kind = type_to_kind[t]
|
||||||
|
fresh, cap = self.add_data(kind, data)
|
||||||
|
return cap
|
||||||
|
|
||||||
|
def render_GET(self, request):
|
||||||
|
uri = DecodedURL.from_text(request.uri.decode('utf8'))
|
||||||
|
capability = None
|
||||||
|
for arg, value in uri.query:
|
||||||
|
if arg == u"uri":
|
||||||
|
capability = value
|
||||||
|
# it's legal to use the form "/uri/<capability>"
|
||||||
|
if capability is None and request.postpath and request.postpath[0]:
|
||||||
|
capability = request.postpath[0]
|
||||||
|
|
||||||
|
# if we don't yet have a capability, that's an error
|
||||||
|
if capability is None:
|
||||||
|
request.setResponseCode(http.BAD_REQUEST)
|
||||||
|
return b"GET /uri requires uri="
|
||||||
|
|
||||||
|
# the user gave us a capability; if our Grid doesn't have any
|
||||||
|
# data for it, that's an error.
|
||||||
|
if capability not in self.data:
|
||||||
|
request.setResponseCode(http.BAD_REQUEST)
|
||||||
|
return u"No data for '{}'".format(capability).decode("ascii")
|
||||||
|
|
||||||
|
return self.data[capability]
|
||||||
|
|
||||||
|
|
||||||
|
def create_fake_tahoe_root():
|
||||||
|
"""
|
||||||
|
If you wish to pre-populate data into the fake Tahoe grid, retain
|
||||||
|
a reference to this root by creating it yourself and passing it to
|
||||||
|
`create_tahoe_treq_client`. For example::
|
||||||
|
|
||||||
|
root = create_fake_tahoe_root()
|
||||||
|
cap_string = root.add_data(...)
|
||||||
|
client = create_tahoe_treq_client(root)
|
||||||
|
|
||||||
|
:returns: an IResource instance that will handle certain Tahoe URI
|
||||||
|
endpoints similar to a real Tahoe server.
|
||||||
|
"""
|
||||||
|
root = _FakeTahoeRoot(
|
||||||
|
uri=_FakeTahoeUriHandler(),
|
||||||
|
)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
@implementer(IBodyProducer)
|
||||||
|
class _SynchronousProducer(object):
|
||||||
|
"""
|
||||||
|
A partial implementation of an :obj:`IBodyProducer` which produces its
|
||||||
|
entire payload immediately. There is no way to access to an instance of
|
||||||
|
this object from :obj:`RequestTraversalAgent` or :obj:`StubTreq`, or even a
|
||||||
|
:obj:`Resource: passed to :obj:`StubTreq`.
|
||||||
|
|
||||||
|
This does not implement the :func:`IBodyProducer.stopProducing` method,
|
||||||
|
because that is very difficult to trigger. (The request from
|
||||||
|
`RequestTraversalAgent` would have to be canceled while it is still in the
|
||||||
|
transmitting state), and the intent is to use `RequestTraversalAgent` to
|
||||||
|
make synchronous requests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, body):
|
||||||
|
"""
|
||||||
|
Create a synchronous producer with some bytes.
|
||||||
|
"""
|
||||||
|
if isinstance(body, FileBodyProducer):
|
||||||
|
body = body._inputFile.read()
|
||||||
|
|
||||||
|
if not isinstance(body, bytes):
|
||||||
|
raise ValueError(
|
||||||
|
"'body' must be bytes not '{}'".format(type(body))
|
||||||
|
)
|
||||||
|
self.body = body
|
||||||
|
self.length = len(body)
|
||||||
|
|
||||||
|
def startProducing(self, consumer):
|
||||||
|
"""
|
||||||
|
Immediately produce all data.
|
||||||
|
"""
|
||||||
|
consumer.write(self.body)
|
||||||
|
return succeed(None)
|
||||||
|
|
||||||
|
|
||||||
|
def create_tahoe_treq_client(root=None):
|
||||||
|
"""
|
||||||
|
:param root: an instance created via `create_fake_tahoe_root`. The
|
||||||
|
caller might want a copy of this to call `.add_data` for example.
|
||||||
|
|
||||||
|
:returns: an instance of treq.client.HTTPClient wired up to
|
||||||
|
in-memory fakes of the Tahoe WebUI. Only a subset of the real
|
||||||
|
WebUI is available.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if root is None:
|
||||||
|
root = create_fake_tahoe_root()
|
||||||
|
|
||||||
|
client = HTTPClient(
|
||||||
|
agent=RequestTraversalAgent(root),
|
||||||
|
data_to_body_producer=_SynchronousProducer,
|
||||||
|
)
|
||||||
|
return client
|
Loading…
Reference in New Issue