Replace monkey-patching of wormhole with a parameter to run_cli

This commit is contained in:
Jean-Paul Calderone 2022-04-12 11:01:04 -04:00
parent dffcdf2854
commit bc6dafa999
5 changed files with 614 additions and 215 deletions

View File

@ -37,9 +37,6 @@ from allmydata.util.assertutil import precondition
from allmydata.util.encodingutil import listdir_unicode, argv_to_unicode, quote_local_unicode_path, get_io_encoding
from allmydata.util import fileutil, i2p_provider, iputil, tor_provider, jsonbytes as json
from wormhole import wormhole
dummy_tac = """
import sys
print("Nodes created by Tahoe-LAFS v1.11.0 or later cannot be run by")
@ -377,7 +374,7 @@ def _get_config_via_wormhole(config):
relay_url = config.parent['wormhole-server']
print("Connecting to '{}'".format(relay_url), file=out)
wh = wormhole.create(
wh = config.parent.wormhole.create(
appid=config.parent['wormhole-invite-appid'],
relay_url=relay_url,
reactor=reactor,

View File

@ -58,11 +58,17 @@ process_control_commands = [
class Options(usage.Options):
"""
:ivar wormhole: An object exposing the magic-wormhole API (mainly a test
hook).
"""
# unit tests can override these to point at StringIO instances
stdin = sys.stdin
stdout = sys.stdout
stderr = sys.stderr
from wormhole import wormhole
subCommands = ( create_node.subCommands
+ admin.subCommands
+ process_control_commands

View File

@ -18,8 +18,6 @@ except ImportError:
from twisted.python import usage
from twisted.internet import defer, reactor
from wormhole import wormhole
from allmydata.util.encodingutil import argv_to_abspath
from allmydata.util import jsonbytes as json
from allmydata.scripts.common import get_default_nodedir, get_introducer_furl
@ -44,13 +42,15 @@ class InviteOptions(usage.Options):
self['nick'] = args[0].strip()
wormhole = None
@defer.inlineCallbacks
def _send_config_via_wormhole(options, config):
out = options.stdout
err = options.stderr
relay_url = options.parent['wormhole-server']
print("Connecting to '{}'...".format(relay_url), file=out)
wh = wormhole.create(
wh = options.parent.wormhole.create(
appid=options.parent['wormhole-invite-appid'],
relay_url=relay_url,
reactor=reactor,

View File

@ -2,62 +2,21 @@
Tests for ``tahoe invite``.
"""
import os
import mock
import json
import os
from os.path import join
from typing import Optional, Sequence
from twisted.trial import unittest
from twisted.internet import defer
from twisted.trial import unittest
from ...client import read_config
from ...scripts import runner
from ...util.jsonbytes import dumps_bytes
from ..common_util import run_cli
from ..no_network import GridTestMixin
from .common import CLITestMixin
from ...client import (
read_config,
)
class _FakeWormhole(object):
def __init__(self, outgoing_messages):
self.messages = []
for o in outgoing_messages:
assert isinstance(o, bytes)
self._outgoing = outgoing_messages
def get_code(self):
return defer.succeed(u"6-alarmist-tuba")
def set_code(self, code):
self._code = code
def get_welcome(self):
return defer.succeed(
{
u"welcome": {},
}
)
def allocate_code(self):
return None
def send_message(self, msg):
assert isinstance(msg, bytes)
self.messages.append(msg)
def get_message(self):
return defer.succeed(self._outgoing.pop(0))
def close(self):
return defer.succeed(None)
def _create_fake_wormhole(outgoing_messages):
outgoing_messages = [
m.encode("utf-8") if isinstance(m, str) else m
for m in outgoing_messages
]
return _FakeWormhole(outgoing_messages)
from .wormholetesting import MemoryWormholeServer, memory_server
class Join(GridTestMixin, CLITestMixin, unittest.TestCase):
@ -74,24 +33,35 @@ class Join(GridTestMixin, CLITestMixin, unittest.TestCase):
successfully join after an invite
"""
node_dir = self.mktemp()
server = MemoryWormholeServer()
options = runner.Options()
options.wormhole = server
reactor = object()
with mock.patch('allmydata.scripts.create_node.wormhole') as w:
fake_wh = _create_fake_wormhole([
json.dumps({u"abilities": {u"server-v1": {}}}),
json.dumps({
wormhole = server.create(
"tahoe-lafs.org/invite",
"ws://wormhole.tahoe-lafs.org:4000/v1",
reactor,
)
code = yield wormhole.get_code()
messages = [
{u"abilities": {u"server-v1": {}}},
{
u"shares-needed": 1,
u"shares-happy": 1,
u"shares-total": 1,
u"nickname": u"somethinghopefullyunique",
u"introducer": u"pb://foo",
}),
])
w.create = mock.Mock(return_value=fake_wh)
},
]
for msg in messages:
wormhole.send_message(dumps_bytes(msg))
rc, out, err = yield run_cli(
"create-client",
"--join", "1-abysmal-ant",
"--join", code,
node_dir,
options=options,
)
self.assertEqual(0, rc)
@ -116,25 +86,36 @@ class Join(GridTestMixin, CLITestMixin, unittest.TestCase):
Server sends JSON with unknown/illegal key
"""
node_dir = self.mktemp()
server = MemoryWormholeServer()
options = runner.Options()
options.wormhole = server
reactor = object()
with mock.patch('allmydata.scripts.create_node.wormhole') as w:
fake_wh = _create_fake_wormhole([
json.dumps({u"abilities": {u"server-v1": {}}}),
json.dumps({
wormhole = server.create(
"tahoe-lafs.org/invite",
"ws://wormhole.tahoe-lafs.org:4000/v1",
reactor,
)
code = yield wormhole.get_code()
messages = [
{u"abilities": {u"server-v1": {}}},
{
u"shares-needed": 1,
u"shares-happy": 1,
u"shares-total": 1,
u"nickname": u"somethinghopefullyunique",
u"introducer": u"pb://foo",
u"something-else": u"not allowed",
}),
])
w.create = mock.Mock(return_value=fake_wh)
},
]
for msg in messages:
wormhole.send_message(dumps_bytes(msg))
rc, out, err = yield run_cli(
"create-client",
"--join", "1-abysmal-ant",
"--join", code,
node_dir,
options=options,
)
# should still succeed -- just ignores the not-whitelisted
@ -156,7 +137,7 @@ class Invite(GridTestMixin, CLITestMixin, unittest.TestCase):
intro_dir,
)
def _invite_success(self, extra_args=(), tahoe_config=None):
async def _invite_success(self, extra_args=(), tahoe_config=None):
# type: (Sequence[bytes], Optional[bytes]) -> defer.Deferred
"""
Exercise an expected-success case of ``tahoe invite``.
@ -178,25 +159,46 @@ class Invite(GridTestMixin, CLITestMixin, unittest.TestCase):
with open(join(intro_dir, "tahoe.cfg"), "wb") as fobj_cfg:
fobj_cfg.write(tahoe_config)
with mock.patch('allmydata.scripts.tahoe_invite.wormhole') as w:
fake_wh = _create_fake_wormhole([
json.dumps({u"abilities": {u"client-v1": {}}}),
])
w.create = mock.Mock(return_value=fake_wh)
wormhole_server, helper = memory_server()
options = runner.Options()
options.wormhole = wormhole_server
reactor = object()
extra_args = tuple(extra_args)
d = run_cli(
async def server():
# Run the server side of the invitation process using the CLI.
rc, out, err = await run_cli(
"-d", intro_dir,
"invite",
*(extra_args + ("foo",))
*tuple(extra_args) + ("foo",),
options=options,
)
def done(result):
rc, out, err = result
self.assertEqual(2, len(fake_wh.messages))
async def client():
# Run the client side of the invitation by manually pumping a
# message through the wormhole.
# First, wait for the server to create the wormhole at all.
wormhole = await helper.wait_for_wormhole(
"tahoe-lafs.org/invite",
"ws://wormhole.tahoe-lafs.org:4000/v1",
)
# Then read out its code and open the other side of the wormhole.
code = await wormhole.when_code()
other_end = wormhole_server.create(
"tahoe-lafs.org/invite",
"ws://wormhole.tahoe-lafs.org:4000/v1",
reactor,
)
other_end.set_code(code)
# Send a proper client abilities message.
other_end.send_message(dumps_bytes({u"abilities": {u"client-v1": {}}}))
# Check the server's messages. First, it should announce its
# abilities correctly.
server_abilities = json.loads(await other_end.when_received())
self.assertEqual(
json.loads(fake_wh.messages[0]),
server_abilities,
{
"abilities":
{
@ -204,7 +206,10 @@ class Invite(GridTestMixin, CLITestMixin, unittest.TestCase):
},
},
)
invite = json.loads(fake_wh.messages[1])
# Second, it should have an invitation with a nickname and
# introducer furl.
invite = json.loads(await other_end.when_received())
self.assertEqual(
invite["nickname"], "foo",
)
@ -212,19 +217,24 @@ class Invite(GridTestMixin, CLITestMixin, unittest.TestCase):
invite["introducer"], "pb://fooblam",
)
return invite
d.addCallback(done)
return d
invite, _ = await defer.gatherResults(map(
defer.Deferred.fromCoroutine,
[client(), server()],
))
return invite
@defer.inlineCallbacks
def test_invite_success(self):
"""
successfully send an invite
"""
invite = yield self._invite_success((
invite = yield defer.Deferred.fromCoroutine(self._invite_success((
"--shares-needed", "1",
"--shares-happy", "2",
"--shares-total", "3",
))
)))
self.assertEqual(
invite["shares-needed"], "1",
)
@ -241,12 +251,12 @@ class Invite(GridTestMixin, CLITestMixin, unittest.TestCase):
If ``--shares-{needed,happy,total}`` are not given on the command line
then the invitation is generated using the configured values.
"""
invite = yield self._invite_success(tahoe_config=b"""
invite = yield defer.Deferred.fromCoroutine(self._invite_success(tahoe_config=b"""
[client]
shares.needed = 2
shares.happy = 4
shares.total = 6
""")
"""))
self.assertEqual(
invite["shares-needed"], "2",
)
@ -265,11 +275,8 @@ shares.total = 6
"""
intro_dir = os.path.join(self.basedir, "introducer")
with mock.patch('allmydata.scripts.tahoe_invite.wormhole') as w:
fake_wh = _create_fake_wormhole([
json.dumps({u"abilities": {u"client-v1": {}}}),
])
w.create = mock.Mock(return_value=fake_wh)
options = runner.Options()
options.wormhole = None
rc, out, err = yield run_cli(
"-d", intro_dir,
@ -278,6 +285,7 @@ shares.total = 6
"--shares-happy", "1",
"--shares-total", "1",
"foo",
options=options,
)
self.assertNotEqual(rc, 0)
self.assertIn(u"Can't find introducer FURL", out + err)
@ -294,23 +302,51 @@ shares.total = 6
with open(join(priv_dir, "introducer.furl"), "w") as f:
f.write("pb://fooblam\n")
with mock.patch('allmydata.scripts.tahoe_invite.wormhole') as w:
fake_wh = _create_fake_wormhole([
json.dumps({u"abilities": {u"client-v9000": {}}}),
])
w.create = mock.Mock(return_value=fake_wh)
wormhole_server, helper = memory_server()
options = runner.Options()
options.wormhole = wormhole_server
reactor = object()
rc, out, err = yield run_cli(
async def server():
rc, out, err = await run_cli(
"-d", intro_dir,
"invite",
"--shares-needed", "1",
"--shares-happy", "1",
"--shares-total", "1",
"foo",
options=options,
)
self.assertNotEqual(rc, 0)
self.assertIn(u"No 'client-v1' in abilities", out + err)
async def client():
# Run the client side of the invitation by manually pumping a
# message through the wormhole.
# First, wait for the server to create the wormhole at all.
wormhole = await helper.wait_for_wormhole(
"tahoe-lafs.org/invite",
"ws://wormhole.tahoe-lafs.org:4000/v1",
)
# Then read out its code and open the other side of the wormhole.
code = await wormhole.when_code()
other_end = wormhole_server.create(
"tahoe-lafs.org/invite",
"ws://wormhole.tahoe-lafs.org:4000/v1",
reactor,
)
other_end.set_code(code)
# Send some surprising client abilities.
other_end.send_message(dumps_bytes({u"abilities": {u"client-v9000": {}}}))
yield defer.gatherResults(map(
defer.Deferred.fromCoroutine,
[client(), server()],
))
@defer.inlineCallbacks
def test_invite_no_client_abilities(self):
"""
@ -323,23 +359,52 @@ shares.total = 6
with open(join(priv_dir, "introducer.furl"), "w") as f:
f.write("pb://fooblam\n")
with mock.patch('allmydata.scripts.tahoe_invite.wormhole') as w:
fake_wh = _create_fake_wormhole([
json.dumps({}),
])
w.create = mock.Mock(return_value=fake_wh)
wormhole_server, helper = memory_server()
options = runner.Options()
options.wormhole = wormhole_server
reactor = object()
rc, out, err = yield run_cli(
async def server():
# Run the server side of the invitation process using the CLI.
rc, out, err = await run_cli(
"-d", intro_dir,
"invite",
"--shares-needed", "1",
"--shares-happy", "1",
"--shares-total", "1",
"foo",
options=options,
)
self.assertNotEqual(rc, 0)
self.assertIn(u"No 'abilities' from client", out + err)
async def client():
# Run the client side of the invitation by manually pumping a
# message through the wormhole.
# First, wait for the server to create the wormhole at all.
wormhole = await helper.wait_for_wormhole(
"tahoe-lafs.org/invite",
"ws://wormhole.tahoe-lafs.org:4000/v1",
)
# Then read out its code and open the other side of the wormhole.
code = await wormhole.when_code()
other_end = wormhole_server.create(
"tahoe-lafs.org/invite",
"ws://wormhole.tahoe-lafs.org:4000/v1",
reactor,
)
other_end.set_code(code)
# Send a no-abilities message through to the server.
other_end.send_message(dumps_bytes({}))
yield defer.gatherResults(map(
defer.Deferred.fromCoroutine,
[client(), server()],
))
@defer.inlineCallbacks
def test_invite_wrong_server_abilities(self):
"""
@ -352,23 +417,35 @@ shares.total = 6
with open(join(priv_dir, "introducer.furl"), "w") as f:
f.write("pb://fooblam\n")
with mock.patch('allmydata.scripts.create_node.wormhole') as w:
fake_wh = _create_fake_wormhole([
json.dumps({u"abilities": {u"server-v9000": {}}}),
json.dumps({
wormhole_server = MemoryWormholeServer()
options = runner.Options()
options.wormhole = wormhole_server
reactor = object()
wormhole = wormhole_server.create(
"tahoe-lafs.org/invite",
"ws://wormhole.tahoe-lafs.org:4000/v1",
reactor,
)
code = yield wormhole.get_code()
messages = [
{u"abilities": {u"server-v9000": {}}},
{
"shares-needed": "1",
"shares-total": "1",
"shares-happy": "1",
"nickname": "foo",
"introducer": "pb://fooblam",
}),
])
w.create = mock.Mock(return_value=fake_wh)
},
]
for msg in messages:
wormhole.send_message(dumps_bytes(msg))
rc, out, err = yield run_cli(
"create-client",
"--join", "1-alarmist-tuba",
"--join", code,
"foo",
options=options,
)
self.assertNotEqual(rc, 0)
self.assertIn("Expected 'server-v1' in server abilities", out + err)
@ -385,23 +462,35 @@ shares.total = 6
with open(join(priv_dir, "introducer.furl"), "w") as f:
f.write("pb://fooblam\n")
with mock.patch('allmydata.scripts.create_node.wormhole') as w:
fake_wh = _create_fake_wormhole([
json.dumps({}),
json.dumps({
server = MemoryWormholeServer()
options = runner.Options()
options.wormhole = server
reactor = object()
wormhole = server.create(
"tahoe-lafs.org/invite",
"ws://wormhole.tahoe-lafs.org:4000/v1",
reactor,
)
code = yield wormhole.get_code()
messages = [
{},
{
"shares-needed": "1",
"shares-total": "1",
"shares-happy": "1",
"nickname": "bar",
"introducer": "pb://fooblam",
}),
])
w.create = mock.Mock(return_value=fake_wh)
},
]
for msg in messages:
wormhole.send_message(dumps_bytes(msg))
rc, out, err = yield run_cli(
"create-client",
"--join", "1-alarmist-tuba",
"--join", code,
"bar",
options=options,
)
self.assertNotEqual(rc, 0)
self.assertIn("Expected 'abilities' in server introduction", out + err)
@ -413,13 +502,16 @@ shares.total = 6
"""
intro_dir = os.path.join(self.basedir, "introducer")
with mock.patch('allmydata.scripts.tahoe_invite.wormhole'):
options = runner.Options()
options.wormhole = None
rc, out, err = yield run_cli(
"-d", intro_dir,
"invite",
"--shares-needed", "1",
"--shares-happy", "1",
"--shares-total", "1",
options=options,
)
self.assertTrue(rc)
self.assertIn(u"Provide a single argument", out + err)

View File

@ -0,0 +1,304 @@
"""
An in-memory implementation of some of the magic-wormhole interfaces for
use by automated tests.
For example::
async def peerA(mw):
wormhole = mw.create("myapp", "wss://myserver", reactor)
code = await wormhole.get_code()
print(f"I have a code: {code}")
message = await wormhole.when_received()
print(f"I have a message: {message}")
async def local_peerB(helper, mw):
peerA_wormhole = await helper.wait_for_wormhole("myapp", "wss://myserver")
code = await peerA_wormhole.when_code()
peerB_wormhole = mw.create("myapp", "wss://myserver")
peerB_wormhole.set_code(code)
peerB_wormhole.send_message("Hello, peer A")
# Run peerA against local_peerB with pure in-memory message passing.
server, helper = memory_server()
run(gather(peerA(server), local_peerB(helper, server)))
# Run peerA against a peerB somewhere out in the world, using a real
# wormhole relay server somewhere.
import wormhole
run(peerA(wormhole))
"""
from __future__ import annotations
from typing import Iterator
from collections.abc import Awaitable
from inspect import getargspec
from itertools import count
from sys import stderr
from attrs import frozen, define, field, Factory
from twisted.internet.defer import Deferred, DeferredQueue, succeed
from wormhole._interfaces import IWormhole
from wormhole.wormhole import create
from zope.interface import implementer
@define
class MemoryWormholeServer(object):
"""
A factory for in-memory wormholes.
:ivar _apps: Wormhole state arranged by the application id and relay URL
it belongs to.
:ivar _waiters: Observers waiting for a wormhole to be created for a
specific application id and relay URL combination.
"""
_apps: dict[tuple[str, str], _WormholeApp] = field(default=Factory(dict))
_waiters: dict[tuple[str, str], Deferred] = field(default=Factory(dict))
def create(
self,
appid,
relay_url,
reactor,
versions={},
delegate=None,
journal=None,
tor=None,
timing=None,
stderr=stderr,
_eventual_queue=None,
_enable_dilate=False,
):
"""
Create a wormhole. It will be able to connect to other wormholes created
by this instance (and constrained by the normal appid/relay_url
rules).
"""
if tor is not None:
raise ValueError("Cannot deal with Tor right now.")
if _enable_dilate:
raise ValueError("Cannot deal with dilation right now.")
key = (relay_url, appid)
wormhole = _MemoryWormhole(self._view(key))
if key in self._waiters:
self._waiters.pop(key).callback(wormhole)
return wormhole
def _view(self, key: tuple[str, str]) -> _WormholeServerView:
"""
Created a view onto this server's state that is limited by a certain
appid/relay_url pair.
"""
return _WormholeServerView(self, key)
@frozen
class TestingHelper(object):
"""
Provide extra functionality for interacting with an in-memory wormhole
implementation.
This is intentionally a separate API so that it is not confused with
proper public interface of the real wormhole implementation.
"""
_server: MemoryWormholeServer
async def wait_for_wormhole(self, appid: str, relay_url: str) -> IWormhole:
"""
Wait for a wormhole to appear at a specific location.
:param appid: The appid that the resulting wormhole will have.
:param relay_url: The URL of the relay at which the resulting wormhole
will presume to be created.
:return: The first wormhole to be created which matches the given
parameters.
"""
key = relay_url, appid
if key in self._server._waiters:
raise ValueError(f"There is already a waiter for {key}")
d = Deferred()
self._server._waiters[key] = d
wormhole = await d
return wormhole
def _verify():
"""
Roughly confirm that the in-memory wormhole creation function matches the
interface of the real implementation.
"""
# Poor man's interface verification.
a = getargspec(create)
b = getargspec(MemoryWormholeServer.create)
# I know it has a `self` argument at the beginning. That's okay.
b = b._replace(args=b.args[1:])
assert a == b, "{} != {}".format(a, b)
_verify()
@define
class _WormholeApp(object):
"""
Represent a collection of wormholes that belong to the same
appid/relay_url scope.
"""
wormholes: dict = field(default=Factory(dict))
_waiting: dict = field(default=Factory(dict))
_counter: Iterator[int] = field(default=Factory(count))
def allocate_code(self, wormhole, code):
"""
Allocate a new code for the given wormhole.
This also associates the given wormhole with the code for future
lookup.
Code generation logic is trivial and certainly not good enough for any
real use. It is sufficient for automated testing, though.
"""
if code is None:
code = "{}-persnickety-tardigrade".format(next(self._counter))
self.wormholes.setdefault(code, []).append(wormhole)
try:
waiters = self._waiting.pop(code)
except KeyError:
pass
else:
for w in waiters:
w.callback(wormhole)
return code
def wait_for_wormhole(self, code: str) -> Awaitable[_MemoryWormhole]:
"""
Return a ``Deferred`` which fires with the next wormhole to be associated
with the given code. This is used to let the first end of a wormhole
rendezvous with the second end.
"""
d = Deferred()
self._waiting.setdefault(code, []).append(d)
return d
@frozen
class _WormholeServerView(object):
"""
Present an interface onto the server to be consumed by individual
wormholes.
"""
_server: MemoryWormholeServer
_key: tuple[str, str]
def allocate_code(self, wormhole: _MemoryWormhole, code: str) -> str:
"""
Allocate a new code for the given wormhole in the scope associated with
this view.
"""
app = self._server._apps.setdefault(self._key, _WormholeApp())
return app.allocate_code(wormhole, code)
def wormhole_by_code(self, code, exclude):
"""
Retrieve all wormholes previously associated with a code.
"""
app = self._server._apps[self._key]
wormholes = app.wormholes[code]
try:
[wormhole] = list(wormhole for wormhole in wormholes if wormhole != exclude)
except ValueError:
return app.wait_for_wormhole(code)
return succeed(wormhole)
@implementer(IWormhole)
@define
class _MemoryWormhole(object):
"""
Represent one side of a wormhole as conceived by ``MemoryWormholeServer``.
"""
_view: _WormholeServerView
_code: str = None
_payload: DeferredQueue = field(default=Factory(DeferredQueue))
_waiting_for_code: list[Deferred] = field(default=Factory(list))
_allocated: bool = False
def allocate_code(self):
if self._code is not None:
raise ValueError(
"allocate_code used with a wormhole which already has a code"
)
self._allocated = True
self._code = self._view.allocate_code(self, None)
waiters = self._waiting_for_code
self._waiting_for_code = None
for d in waiters:
d.callback(self._code)
def set_code(self, code):
if self._code is None:
self._code = code
self._view.allocate_code(self, code)
else:
raise ValueError("set_code used with a wormhole which already has a code")
def when_code(self):
if self._code is None:
d = Deferred()
self._waiting_for_code.append(d)
return d
return succeed(self._code)
get_code = when_code
def get_welcome(self):
return succeed("welcome")
def send_message(self, payload):
self._payload.put(payload)
def when_received(self):
if self._code is None:
raise ValueError(
"This implementation requires set_code or allocate_code "
"before when_received."
)
d = self._view.wormhole_by_code(self._code, exclude=self)
def got_wormhole(wormhole):
msg = wormhole._payload.get()
return msg
d.addCallback(got_wormhole)
return d
get_message = when_received
def close(self):
pass
# 0.9.2 compatibility
def get_code(self):
if self._code is None:
self.allocate_code()
return self.when_code()
get = when_received
def memory_server() -> tuple[MemoryWormholeServer, TestingHelper]:
"""
Create a paired in-memory wormhole server and testing helper.
"""
server = MemoryWormholeServer()
return server, TestingHelper(server)