source: trunk/src/allmydata/test/cli/test_invite.py

Last change on this file was 8d99ddc, checked in by Itamar Turner-Trauring <itamar@…>, at 2023-06-15T21:14:08Z

Pacify mypy

  • Property mode set to 100644
File size: 15.5 KB
Line 
1"""
2Tests for ``tahoe invite``.
3"""
4
5from __future__ import annotations
6
7import json
8import os
9from functools import partial
10from os.path import join
11from typing import Callable, Optional, Sequence, TypeVar, Union, Coroutine, Any, Tuple, cast, Generator
12
13from twisted.internet import defer
14from twisted.trial import unittest
15
16from ...client import read_config
17from ...scripts import runner
18from ...util.jsonbytes import dumps_bytes
19from ..common_util import run_cli
20from ..no_network import GridTestMixin
21from .common import CLITestMixin
22from .wormholetesting import MemoryWormholeServer, TestingHelper, memory_server, IWormhole
23
24
25# Logically:
26#   JSONable = dict[str, Union[JSONable, None, int, float, str, list[JSONable]]]
27#
28# But practically:
29JSONable = Union[dict, None, int, float, str, list]
30
31
32async def open_wormhole() -> tuple[Callable, IWormhole, str]:
33    """
34    Create a new in-memory wormhole server, open one end of a wormhole, and
35    return it and related info.
36
37    :return: A three-tuple allowing use of the wormhole.  The first element is
38        a callable like ``run_cli`` but which will run commands so that they
39        use the in-memory wormhole server instead of a real one.  The second
40        element is the open wormhole.  The third element is the wormhole's
41        code.
42    """
43    server = MemoryWormholeServer()
44    options = runner.Options()
45    options.wormhole = server
46    reactor = object()
47
48    wormhole = server.create(
49        "tahoe-lafs.org/invite",
50        "ws://wormhole.tahoe-lafs.org:4000/v1",
51        reactor,
52    )
53    code = await wormhole.get_code()
54
55    return (partial(run_cli, options=options), wormhole, code)
56
57
58def make_simple_peer(
59        reactor,
60        server: MemoryWormholeServer,
61        helper: TestingHelper,
62        messages: Sequence[JSONable],
63) -> Callable[[], Coroutine[defer.Deferred[IWormhole], Any, IWormhole]]:
64    """
65    Make a wormhole peer that just sends the given messages.
66
67    The returned function returns an awaitable that fires with the peer's end
68    of the wormhole.
69    """
70    async def peer() -> IWormhole:
71        # Run the client side of the invitation by manually pumping a
72        # message through the wormhole.
73
74        # First, wait for the server to create the wormhole at all.
75        wormhole = await helper.wait_for_wormhole(
76            "tahoe-lafs.org/invite",
77            "ws://wormhole.tahoe-lafs.org:4000/v1",
78        )
79        # Then read out its code and open the other side of the wormhole.
80        code = await wormhole.when_code()
81        other_end = server.create(
82            "tahoe-lafs.org/invite",
83            "ws://wormhole.tahoe-lafs.org:4000/v1",
84            reactor,
85        )
86        other_end.set_code(code)
87        send_messages(other_end, messages)
88        return other_end
89
90    return peer
91
92
93def send_messages(wormhole: IWormhole, messages: Sequence[JSONable]) -> None:
94    """
95    Send a list of message through a wormhole.
96    """
97    for msg in messages:
98        wormhole.send_message(dumps_bytes(msg))
99
100
101A = TypeVar("A")
102B = TypeVar("B")
103
104def concurrently(
105    client: Callable[[], Union[
106        Coroutine[defer.Deferred[A], Any, A],
107        Generator[defer.Deferred[A], Any, A],
108    ]],
109    server: Callable[[], Union[
110        Coroutine[defer.Deferred[B], Any, B],
111        Generator[defer.Deferred[B], Any, B],
112    ]],
113) -> defer.Deferred[Tuple[A, B]]:
114    """
115    Run two asynchronous functions concurrently and asynchronously return a
116    tuple of both their results.
117    """
118    result = defer.gatherResults([
119        defer.Deferred.fromCoroutine(client()),
120        defer.Deferred.fromCoroutine(server()),
121    ]).addCallback(tuple)  # type: ignore
122    return cast(defer.Deferred[Tuple[A, B]], result)
123
124class Join(GridTestMixin, CLITestMixin, unittest.TestCase):
125
126    @defer.inlineCallbacks
127    def setUp(self):
128        self.basedir = self.mktemp()
129        yield super(Join, self).setUp()
130        yield self.set_up_grid(oneshare=True)
131
132    @defer.inlineCallbacks
133    def test_create_node_join(self):
134        """
135        successfully join after an invite
136        """
137        node_dir = self.mktemp()
138        run_cli, wormhole, code = yield defer.Deferred.fromCoroutine(open_wormhole())
139        send_messages(wormhole, [
140            {u"abilities": {u"server-v1": {}}},
141            {
142                u"shares-needed": 1,
143                u"shares-happy": 1,
144                u"shares-total": 1,
145                u"nickname": u"somethinghopefullyunique",
146                u"introducer": u"pb://foo",
147            },
148        ])
149
150        rc, out, err = yield run_cli(
151            "create-client",
152            "--join", code,
153            node_dir,
154        )
155
156        self.assertEqual(0, rc)
157
158        config = read_config(node_dir, u"")
159        self.assertIn(
160            "pb://foo",
161            set(
162                furl
163                for (furl, cache)
164                in config.get_introducer_configuration().values()
165            ),
166        )
167
168        with open(join(node_dir, 'tahoe.cfg'), 'r') as f:
169            config = f.read()
170        self.assertIn(u"somethinghopefullyunique", config)
171
172    @defer.inlineCallbacks
173    def test_create_node_illegal_option(self):
174        """
175        Server sends JSON with unknown/illegal key
176        """
177        node_dir = self.mktemp()
178        run_cli, wormhole, code = yield defer.Deferred.fromCoroutine(open_wormhole())
179        send_messages(wormhole, [
180            {u"abilities": {u"server-v1": {}}},
181            {
182                u"shares-needed": 1,
183                u"shares-happy": 1,
184                u"shares-total": 1,
185                u"nickname": u"somethinghopefullyunique",
186                u"introducer": u"pb://foo",
187                u"something-else": u"not allowed",
188            },
189        ])
190
191        rc, out, err = yield run_cli(
192            "create-client",
193            "--join", code,
194            node_dir,
195        )
196
197        # should still succeed -- just ignores the not-whitelisted
198        # "something-else" option
199        self.assertEqual(0, rc)
200
201
202class Invite(GridTestMixin, CLITestMixin, unittest.TestCase):
203
204    @defer.inlineCallbacks
205    def setUp(self):
206        self.basedir = self.mktemp()
207        yield super(Invite, self).setUp()
208        yield self.set_up_grid(oneshare=True)
209        intro_dir = os.path.join(self.basedir, "introducer")
210        yield run_cli(
211            "create-introducer",
212            "--listen", "none",
213            intro_dir,
214        )
215
216    async def _invite_success(self, extra_args: Sequence[bytes] = (), tahoe_config: Optional[bytes] = None) -> str:
217        """
218        Exercise an expected-success case of ``tahoe invite``.
219
220        :param extra_args: Positional arguments to pass to ``tahoe invite``
221            before the nickname.
222
223        :param tahoe_config: If given, bytes to write to the node's
224            ``tahoe.cfg`` before running ``tahoe invite.
225        """
226        intro_dir = os.path.join(self.basedir, "introducer")
227        # we've never run the introducer, so it hasn't created
228        # introducer.furl yet
229        priv_dir = join(intro_dir, "private")
230        with open(join(priv_dir, "introducer.furl"), "w") as fobj_intro:
231            fobj_intro.write("pb://fooblam\n")
232        if tahoe_config is not None:
233            assert isinstance(tahoe_config, bytes)
234            with open(join(intro_dir, "tahoe.cfg"), "wb") as fobj_cfg:
235                fobj_cfg.write(tahoe_config)
236
237        wormhole_server, helper = memory_server()
238        options = runner.Options()
239        options.wormhole = wormhole_server
240        reactor = object()
241
242        async def server():
243            # Run the server side of the invitation process using the CLI.
244            rc, out, err = await run_cli(
245                "-d", intro_dir,
246                "invite",
247                *tuple(extra_args) + ("foo",),
248                options=options,
249            )
250
251        # Send a proper client abilities message.
252        client = make_simple_peer(reactor, wormhole_server, helper, [{u"abilities": {u"client-v1": {}}}])
253        other_end, _ = await concurrently(client, server)
254
255        # Check the server's messages.  First, it should announce its
256        # abilities correctly.
257        server_abilities = json.loads(await other_end.when_received())
258        self.assertEqual(
259            server_abilities,
260            {
261                "abilities":
262                {
263                    "server-v1": {}
264                },
265            },
266        )
267
268        # Second, it should have an invitation with a nickname and introducer
269        # furl.
270        invite = json.loads(await other_end.when_received())
271        self.assertEqual(
272            invite["nickname"], "foo",
273        )
274        self.assertEqual(
275            invite["introducer"], "pb://fooblam",
276        )
277        return invite
278
279    @defer.inlineCallbacks
280    def test_invite_success(self):
281        """
282        successfully send an invite
283        """
284        invite = yield defer.Deferred.fromCoroutine(self._invite_success((
285            "--shares-needed", "1",
286            "--shares-happy", "2",
287            "--shares-total", "3",
288        )))
289        self.assertEqual(
290            invite["shares-needed"], "1",
291        )
292        self.assertEqual(
293            invite["shares-happy"], "2",
294        )
295        self.assertEqual(
296            invite["shares-total"], "3",
297        )
298
299    @defer.inlineCallbacks
300    def test_invite_success_read_share_config(self):
301        """
302        If ``--shares-{needed,happy,total}`` are not given on the command line
303        then the invitation is generated using the configured values.
304        """
305        invite = yield defer.Deferred.fromCoroutine(self._invite_success(tahoe_config=b"""
306[client]
307shares.needed = 2
308shares.happy = 4
309shares.total = 6
310"""))
311        self.assertEqual(
312            invite["shares-needed"], "2",
313        )
314        self.assertEqual(
315            invite["shares-happy"], "4",
316        )
317        self.assertEqual(
318            invite["shares-total"], "6",
319        )
320
321
322    @defer.inlineCallbacks
323    def test_invite_no_furl(self):
324        """
325        Invites must include the Introducer FURL
326        """
327        intro_dir = os.path.join(self.basedir, "introducer")
328
329        options = runner.Options()
330        options.wormhole = None
331
332        rc, out, err = yield run_cli(
333            "-d", intro_dir,
334            "invite",
335            "--shares-needed", "1",
336            "--shares-happy", "1",
337            "--shares-total", "1",
338            "foo",
339            options=options,
340        )
341        self.assertNotEqual(rc, 0)
342        self.assertIn(u"Can't find introducer FURL", out + err)
343
344    @defer.inlineCallbacks
345    def test_invite_wrong_client_abilities(self):
346        """
347        Send unknown client version
348        """
349        intro_dir = os.path.join(self.basedir, "introducer")
350        # we've never run the introducer, so it hasn't created
351        # introducer.furl yet
352        priv_dir = join(intro_dir, "private")
353        with open(join(priv_dir, "introducer.furl"), "w") as f:
354            f.write("pb://fooblam\n")
355
356        wormhole_server, helper = memory_server()
357        options = runner.Options()
358        options.wormhole = wormhole_server
359        reactor = object()
360
361        async def server():
362            rc, out, err = await run_cli(
363                "-d", intro_dir,
364                "invite",
365                "--shares-needed", "1",
366                "--shares-happy", "1",
367                "--shares-total", "1",
368                "foo",
369                options=options,
370            )
371            self.assertNotEqual(rc, 0)
372            self.assertIn(u"No 'client-v1' in abilities", out + err)
373
374        # Send some surprising client abilities.
375        client = make_simple_peer(reactor, wormhole_server, helper, [{u"abilities": {u"client-v9000": {}}}])
376        yield concurrently(client, server)
377
378    @defer.inlineCallbacks
379    def test_invite_no_client_abilities(self):
380        """
381        Client doesn't send any client abilities at all
382        """
383        intro_dir = os.path.join(self.basedir, "introducer")
384        # we've never run the introducer, so it hasn't created
385        # introducer.furl yet
386        priv_dir = join(intro_dir, "private")
387        with open(join(priv_dir, "introducer.furl"), "w") as f:
388            f.write("pb://fooblam\n")
389
390        wormhole_server, helper = memory_server()
391        options = runner.Options()
392        options.wormhole = wormhole_server
393        reactor = object()
394
395        async def server():
396            # Run the server side of the invitation process using the CLI.
397            rc, out, err = await run_cli(
398                "-d", intro_dir,
399                "invite",
400                "--shares-needed", "1",
401                "--shares-happy", "1",
402                "--shares-total", "1",
403                "foo",
404                options=options,
405            )
406            self.assertNotEqual(rc, 0)
407            self.assertIn(u"No 'abilities' from client", out + err)
408
409        # Send a no-abilities message through to the server.
410        client = make_simple_peer(reactor, wormhole_server, helper, [{}])
411        yield concurrently(client, server)
412
413
414    @defer.inlineCallbacks
415    def test_invite_wrong_server_abilities(self):
416        """
417        Server sends unknown version
418        """
419        intro_dir = os.path.join(self.basedir, "introducer")
420        # we've never run the introducer, so it hasn't created
421        # introducer.furl yet
422        priv_dir = join(intro_dir, "private")
423        with open(join(priv_dir, "introducer.furl"), "w") as f:
424            f.write("pb://fooblam\n")
425
426        run_cli, wormhole, code = yield defer.Deferred.fromCoroutine(open_wormhole())
427        send_messages(wormhole, [
428            {u"abilities": {u"server-v9000": {}}},
429            {
430                "shares-needed": "1",
431                "shares-total": "1",
432                "shares-happy": "1",
433                "nickname": "foo",
434                "introducer": "pb://fooblam",
435            },
436        ])
437
438        rc, out, err = yield run_cli(
439            "create-client",
440            "--join", code,
441            "foo",
442        )
443        self.assertNotEqual(rc, 0)
444        self.assertIn("Expected 'server-v1' in server abilities", out + err)
445
446    @defer.inlineCallbacks
447    def test_invite_no_server_abilities(self):
448        """
449        Server sends unknown version
450        """
451        intro_dir = os.path.join(self.basedir, "introducer")
452        # we've never run the introducer, so it hasn't created
453        # introducer.furl yet
454        priv_dir = join(intro_dir, "private")
455        with open(join(priv_dir, "introducer.furl"), "w") as f:
456            f.write("pb://fooblam\n")
457
458        run_cli, wormhole, code = yield defer.Deferred.fromCoroutine(open_wormhole())
459        send_messages(wormhole, [
460            {},
461            {
462                "shares-needed": "1",
463                "shares-total": "1",
464                "shares-happy": "1",
465                "nickname": "bar",
466                "introducer": "pb://fooblam",
467            },
468        ])
469
470        rc, out, err = yield run_cli(
471            "create-client",
472            "--join", code,
473            "bar",
474        )
475        self.assertNotEqual(rc, 0)
476        self.assertIn("Expected 'abilities' in server introduction", out + err)
477
478    @defer.inlineCallbacks
479    def test_invite_no_nick(self):
480        """
481        Should still work if server sends no nickname
482        """
483        intro_dir = os.path.join(self.basedir, "introducer")
484
485        options = runner.Options()
486        options.wormhole = None
487
488        rc, out, err = yield run_cli(
489            "-d", intro_dir,
490            "invite",
491            "--shares-needed", "1",
492            "--shares-happy", "1",
493            "--shares-total", "1",
494            options=options,
495        )
496        self.assertTrue(rc)
497        self.assertIn(u"Provide a single argument", out + err)
Note: See TracBrowser for help on using the repository browser.