__main__.py 20.9 KB
Newer Older
1
2
#!/usr/bin/env python3

3
import collections
4
5
6
7
8
import slixmpp
import asyncio
import logging
import signal
import atexit
9
import lxml.etree
10
import sys
11
import io
louiz’'s avatar
louiz’ committed
12
import os
13
from functools import partial
louiz’'s avatar
louiz’ committed
14
from slixmpp.xmlstream.matcher.base import MatcherBase
15
16


louiz’'s avatar
louiz’ committed
17
class MatchAll(MatcherBase):
18
19
20
21
22
23
    """match everything"""

    def match(self, xml):
        return True


24
class StanzaError(Exception):
25
26
27
28
29
30
31
32
33
34
35
    """
    Raised when a step fails.
    """
    pass


class SkipStepError(Exception):
    """
    Raised by a step when it needs to be skiped, by running
    the next available step immediately.
    """
36
37
38
    pass


39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
class XMPPComponent(slixmpp.BaseXMPP):
    """
    XMPPComponent sending a “scenario” of stanzas, checking that the responses
    match the expected results.
    """

    def __init__(self, scenario, biboumi):
        super().__init__(jid="biboumi.localhost", default_ns="jabber:component:accept")
        self.is_component = True
        self.stream_header = '<stream:stream %s %s from="%s" id="%s">' % (
            'xmlns="jabber:component:accept"',
            'xmlns:stream="%s"' % self.stream_ns,
            self.boundjid, self.get_id())
        self.stream_footer = "</stream:stream>"

        self.register_handler(slixmpp.Callback('Match All',
                                               MatchAll(None),
                                               self.handle_incoming_stanza))

        self.add_event_handler("session_end", self.on_end_session)

        asyncio.async(self.accept_routine())

        self.scenario = scenario
        self.biboumi = biboumi
64
65
66
        # A callable, taking a stanza as argument and raising a StanzaError
        # exception if the test should fail.
        self.stanza_checker = None
67
68
69
70
71
72
73
74
75
76
77
78
        self.failed = False
        self.accepting_server = None

    def error(self, message):
        print("Failure: %s" % (message,))
        self.scenario.steps = []
        self.failed = True

    def on_end_session(self, event):
        self.loop.stop()

    def handle_incoming_stanza(self, stanza):
79
80
81
82
83
        if self.stanza_checker:
            try:
                self.stanza_checker(stanza)
            except StanzaError as e:
                self.error(e)
84
85
86
87
            except SkipStepError:
                # Run the next step and then re-handle this same stanza
                self.run_scenario()
                return self.handle_incoming_stanza(stanza)
88
            self.stanza_checker = None
89
90
91
92
93
94
95
96
97
98
99
100
101
102
        self.run_scenario()

    def run_scenario(self):
        if scenario.steps:
            step = scenario.steps.pop(0)
            step(self, self.biboumi)
        else:
            self.biboumi.stop()

    @asyncio.coroutine
    def accept_routine(self):
        self.accepting_server = yield from self.loop.create_server(lambda: self,
                                                                   "127.0.0.1", "8811", reuse_address=True)

103
104
    def check_stanza_against_all_expected_xpaths(self):
        pass
louiz’'s avatar
louiz’ committed
105

louiz’'s avatar
louiz’ committed
106

107
108
109
110
111
112
113
def check_xpath(xpaths, stanza):
    for xpath in xpaths:
        tree = lxml.etree.parse(io.StringIO(str(stanza)))
        matched = tree.xpath(xpath, namespaces={'re': 'http://exslt.org/regular-expressions',
                                                'muc_user': 'http://jabber.org/protocol/muc#user'})
        if not matched:
            raise StanzaError("Received stanza “%s” did not match expected xpath “%s”" % (stanza, xpath))
114

louiz’'s avatar
louiz’ committed
115

116
117
118
119
120
121
122
def check_xpath_optional(xpaths, stanza):
    try:
        check_xpath(xpaths, stanza)
    except StanzaError:
        raise SkipStepError()


123
124
125
126
127
128
129
130
class Scenario:
    """Defines a list of actions that are executed in sequence, until one of
    them throws an exception, or until the end.  An action can be something
    like “send a stanza”, “receive the next stanza and check that it matches
    the given XPath”, “send a signal”, “wait for the end of the process”,
    etc
    """

131
    def __init__(self, name, steps, conf="basic"):
132
133
134
135
136
        """
        Steps is a list of 2-tuple:
        [(action, answer), (action, answer)]
        """
        self.name = name
137
        self.steps = []
138
        self.conf = conf
139
140
141
142
143
144
        for elem in steps:
            if isinstance(elem, collections.Iterable):
                for step in elem:
                    self.steps.append(step)
            else:
                self.steps.append(elem)
145

louiz’'s avatar
louiz’ committed
146

147
148
class ProcessRunner:
    def __init__(self):
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
        self.process = None
        self.signal_sent = False

    @asyncio.coroutine
    def start(self):
        self.process = yield from self.create

    @asyncio.coroutine
    def wait(self):
        code = yield from self.process.wait()
        return code

    def stop(self):
        if not self.signal_sent:
            self.signal_sent = True
            if self.process:
                self.process.send_signal(signal.SIGINT)

167
168
169
    def __del__(self):
        self.stop()

170

171
172
173
174
175
176
177
178
179
180
181
182
183
184
class BiboumiRunner(ProcessRunner):
    def __init__(self, name, with_valgrind):
        super().__init__()
        self.name = name
        self.fd = open("biboumi_%s_output.txt" % (name,), "w")
        if with_valgrind:
            self.create = asyncio.create_subprocess_exec("valgrind", "--leak-check=full", "--show-leak-kinds=all",
                                                         "--errors-for-leak-kinds=all", "--error-exitcode=16",
                                                         "./biboumi", "test.conf", stdin=None, stdout=self.fd,
                                                         stderr=self.fd, loop=None, limit=None)
        else:
            self.create = asyncio.create_subprocess_exec("./biboumi", "test.conf", stdin=None, stdout=self.fd,
                                                         stderr=self.fd, loop=None, limit=None)

louiz’'s avatar
louiz’ committed
185

186
187
188
class IrcServerRunner(ProcessRunner):
    def __init__(self):
        super().__init__()
louiz’'s avatar
louiz’ committed
189
        self.create = asyncio.create_subprocess_exec("charybdis", "-foreground", "-configfile", os.getcwd() + "/../tests/end_to_end/ircd.conf",
190
                                                     stderr=asyncio.subprocess.PIPE)
191

louiz’'s avatar
louiz’ committed
192

193
def send_stanza(stanza, xmpp, biboumi):
194
    xmpp.send_raw(stanza.format_map(common_replacements))
195
196
197
    asyncio.get_event_loop().call_soon(xmpp.run_scenario)


198
199
def expect_stanza(xpaths, xmpp, biboumi, optional=False):
    check_func = check_xpath if not optional else check_xpath_optional
200
    if isinstance(xpaths, str):
201
        xmpp.stanza_checker = partial(check_func, [xpaths.format_map(common_replacements)])
202
    elif isinstance(xpaths, tuple):
203
        xmpp.stanza_checker = partial(check_func, [xpath.format_map(common_replacements) for xpath in xpaths])
204
205
    else:
        print("Warning, from argument type passed to expect_stanza: %s" % (type(xpaths)))
206

louiz’'s avatar
louiz’ committed
207

208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
class BiboumiTest:
    """
    Spawns a biboumi process and a fake XMPP Component that will run a
    Scenario.  It redirects the outputs of the subprocess into separated
    files, and detects any failure in the running of the scenario.
    """

    def __init__(self, scenario, expected_code=0):
        self.scenario = scenario
        self.expected_code = 0

    def run(self, with_valgrind=True):
        print("Running scenario: %s%s" % (self.scenario.name, " (with valgrind)" if with_valgrind else ''))
        # Redirect the slixmpp logging into a specific file
        output_filename = "slixmpp_%s_output.txt" % (self.scenario.name,)
        with open(output_filename, "w"):
            pass
        logging.basicConfig(level=logging.DEBUG,
                            format='%(levelname)-8s %(message)s',
                            filename=output_filename)

229
        with open("test.conf", "w") as fd:
230
            fd.write(confs[scenario.conf])
231

232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
        # Start the XMPP component and biboumi
        biboumi = BiboumiRunner(scenario.name, with_valgrind)
        xmpp = XMPPComponent(self.scenario, biboumi)
        asyncio.get_event_loop().run_until_complete(biboumi.start())

        asyncio.get_event_loop().call_soon(xmpp.run_scenario)

        xmpp.process()

        code = asyncio.get_event_loop().run_until_complete(biboumi.wait())
        failed = False
        if not xmpp.failed:
            if code != self.expected_code:
                xmpp.error("Wrong return code from biboumi's process: %d" % (code,))
                failed = True
            else:
                print("Success!")
        else:
            failed = True

        if xmpp.server:
            xmpp.accepting_server.close()

        return not failed

louiz’'s avatar
louiz’ committed
257

258
259
confs = {
'basic':
260
261
262
"""hostname=biboumi.localhost
password=coucou
db_name=biboumi.sqlite
263
264
265
266
267
268
269
270
271
port=8811""",

'fixed_server':
"""hostname=biboumi.localhost
password=coucou
db_name=biboumi.sqlite
port=8811
fixed_irc_server=irc.localhost
"""}
272

273
274
275
common_replacements = {
    'irc_server_one': 'irc.localhost@biboumi.localhost',
    'irc_host_one': 'irc.localhost',
276
    'biboumi_host': 'biboumi.localhost',
277
278
279
280
281
282
283
284
285
286
    'resource_one': 'resource1',
    'nick_one': 'Nick',
    'jid_one': 'first@example.com',
    'jid_two': 'second@example.com',
    'nick_two': 'Bobby',
}


def handshake_sequence():
    return (partial(expect_stanza, "//handshake"),
287
            partial(send_stanza, "<handshake xmlns='jabber:component:accept'/>"))
288
289
290
291
292
293
294
295
296
297


def connection_sequence(irc_host, jid):
    jid = jid.format_map(common_replacements)
    xpath    = "/message[@to='" + jid + "'][@from='irc.localhost@biboumi.localhost']/body[text()='%s']"
    xpath_re = "/message[@to='" + jid + "'][@from='irc.localhost@biboumi.localhost']/body[re:test(text(), '%s')]"
    return (
    partial(expect_stanza,
          xpath % ('Connecting to %s:6697 (encrypted)' % irc_host)),
    partial(expect_stanza,
298
            xpath % 'Connection failed: Connection refused'),
299
300
301
    partial(expect_stanza,
          xpath % ('Connecting to %s:6670 (encrypted)' % irc_host)),
    partial(expect_stanza,
302
            xpath % 'Connection failed: Connection refused'),
303
304
305
    partial(expect_stanza,
          xpath % ('Connecting to %s:6667 (not encrypted)' % irc_host)),
    partial(expect_stanza,
306
307
            xpath % 'Connected to IRC server.'),
    # These two messages can be receive in any order
308
    partial(expect_stanza,
309
          xpath_re % (r'^%s: \*\*\* (Checking Ident|Looking up your hostname...)$' % irc_host)),
310
    partial(expect_stanza,
311
          xpath_re % (r'^%s: \*\*\* (Checking Ident|Looking up your hostname...)$' % irc_host)),
312
313
    # These three messages can be received in any order
    partial(expect_stanza,
314
          xpath_re % (r'^%s: (\*\*\* Found your hostname: .*|ACK multi-prefix|\*\*\* No Ident response)$' % irc_host)),
315
    partial(expect_stanza,
316
          xpath_re % (r'^%s: (\*\*\* Found your hostname: .*|ACK multi-prefix|\*\*\* No Ident response)$' % irc_host)),
317
    partial(expect_stanza,
318
          xpath_re % (r'^%s: (\*\*\* Found your hostname: .*|ACK multi-prefix|\*\*\* No Ident response)$' % irc_host)),
319
320
321
    partial(expect_stanza,
          xpath_re % (r'^%s: Your host is .*$' % irc_host)),
    partial(expect_stanza,
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
          xpath_re % (r'^%s: This server was created .*$' % irc_host)),
    partial(expect_stanza,
          xpath_re % (r'^%s: There are \d+ users and \d+ invisible on \d+ servers$' % irc_host)),
    partial(expect_stanza,
          xpath_re % (r'^%s: \d+ channels formed$' % irc_host), optional=True),
    partial(expect_stanza,
          xpath_re % (r'^%s: I have \d+ clients and \d+ servers$' % irc_host)),
    partial(expect_stanza,
          xpath_re % (r'^%s: \d+ \d+ Current local users \d+, max \d+$' % irc_host)),
    partial(expect_stanza,
          xpath_re % (r'^%s: \d+ \d+ Current global users \d+, max \d+$' % irc_host)),
    partial(expect_stanza,
          xpath_re % (r'^%s: Highest connection count: \d+ \(\d+ clients\) \(\d+ connections received\)$' % irc_host)),
    partial(expect_stanza,
            xpath % "- This is charybdis MOTD you might replace it, but if not your friends will\n- laugh at you.\n"),
337
    partial(expect_stanza,
338
            xpath_re % r'^User mode for \w+ is \[\+i\]$'),
339
340
341
    )


342
343
344
345
346
347
348
349
350
if __name__ == '__main__':

    atexit.register(asyncio.get_event_loop().close)

    # Start the test component, accepting connections on the configured
    # port.
    scenarios = (
        Scenario("basic_handshake_success",
                 [
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
                     handshake_sequence()
                 ]),
        Scenario("irc_server_connection",
                 [
                     handshake_sequence(),
                     partial(send_stanza,
                             "<presence from='{jid_one}/{resource_one}' to='#foo%{irc_server_one}/{nick_one}' />"),
                     connection_sequence("irc.localhost", '{jid_one}/{resource_one}'),
                 ]),
        Scenario("simple_channel_join",
                 [
                     handshake_sequence(),
                     partial(send_stanza,
                             "<presence from='{jid_one}/{resource_one}' to='#foo%{irc_server_one}/{nick_one}' />"),
                     connection_sequence("irc.localhost", '{jid_one}/{resource_one}'),
                     partial(expect_stanza,
367
368
369
                             "/message/body[text()='Mode #foo [+nt] by {irc_host_one}']"),
                     partial(expect_stanza,
                             ("/presence[@to='{jid_one}/{resource_one}'][@from='#foo%{irc_server_one}/{nick_one}']/muc_user:x/muc_user:item[@affiliation='admin'][@role='moderator']",
370
371
372
                             "/presence/muc_user:x/muc_user:status[@code='110']")
                             ),
                     partial(expect_stanza, "/message[@from='#foo%{irc_server_one}'][@type='groupchat']/subject[not(text())]"),
373
                 ]),
374
        Scenario("channel_join_with_two_users",
375
                 [
376
377
                     handshake_sequence(),
                     # First user joins
louiz’'s avatar
louiz’ committed
378
                     partial(send_stanza,
379
380
381
                             "<presence from='{jid_one}/{resource_one}' to='#foo%{irc_server_one}/{nick_one}' />"),
                     connection_sequence("irc.localhost", '{jid_one}/{resource_one}'),
                     partial(expect_stanza,
382
383
384
                             "/message/body[text()='Mode #foo [+nt] by {irc_host_one}']"),
                     partial(expect_stanza,
                             ("/presence[@to='{jid_one}/{resource_one}'][@from='#foo%{irc_server_one}/{nick_one}']/muc_user:x/muc_user:item[@affiliation='admin'][@jid='~nick@localhost'][@role='moderator']",
385
386
387
388
389
390
391
                             "/presence/muc_user:x/muc_user:status[@code='110']")
                             ),
                     partial(expect_stanza, "/message[@from='#foo%{irc_server_one}'][@type='groupchat']/subject[not(text())]"),

                     # Second user joins
                     partial(send_stanza,
                             "<presence from='{jid_two}/{resource_one}' to='#foo%{irc_server_one}/{nick_two}' />"),
louiz’'s avatar
louiz’ committed
392
393
394
395
396
397
                     connection_sequence("irc.localhost", '{jid_two}/{resource_one}'),
                     # Our presence, sent to the other user
                     partial(expect_stanza,
                             ("/presence[@to='{jid_one}/{resource_one}'][@from='#foo%{irc_server_one}/{nick_two}']/muc_user:x/muc_user:item[@affiliation='none'][@jid='~bobby@localhost'][@role='participant']",)),
                     # The other user presence
                     partial(expect_stanza,
398
                             "/presence[@to='{jid_second}/{resource_one}'][@from='#foo%{irc_server_one}/{nick_one}']/muc_user:x/muc_user:item[@affiliation='none'][@jid='~nick@localhost'][@role='participant']"),
louiz’'s avatar
louiz’ committed
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
                     # Our own presence
                     partial(expect_stanza,
                             ("/presence[@to='{jid_two}/{resource_one}'][@from='#foo%{irc_server_one}/{nick_two}']/muc_user:x/muc_user:item[@affiliation='none'][@jid='~bobby@localhost'][@role='participant']",
                              "/presence/muc_user:x/muc_user:status[@code='110']")
                             ),
                     partial(expect_stanza, "/message[@from='#foo%{irc_server_one}'][@type='groupchat']/subject[not(text())]"),
                 ]),
        Scenario("channel_custom_topic",
                 [
                     handshake_sequence(),
                     # First user joins
                     partial(send_stanza,
                             "<presence from='{jid_one}/{resource_one}' to='#foo%{irc_server_one}/{nick_one}' />"),
                     connection_sequence("irc.localhost", '{jid_one}/{resource_one}'),
                     partial(expect_stanza,
414
415
416
417
                             "/message/body[text()='Mode #foo [+nt] by {irc_host_one}']"),
                     partial(expect_stanza,
                             ("/presence[@to='{jid_one}/{resource_one}'][@from='#foo%{irc_server_one}/{nick_one}']/muc_user:x/muc_user:item[@affiliation='admin'][@jid='~nick@localhost'][@role='moderator']",
                             "/presence/muc_user:x/muc_user:status[@code='110']")
louiz’'s avatar
louiz’ committed
418
419
420
421
422
                             ),
                     partial(expect_stanza, "/message[@from='#foo%{irc_server_one}'][@type='groupchat']/subject[not(text())]"),

                     # First user sets the topic
                     partial(send_stanza,
423
                             "<message from='{jid_one}/{resource_one}' to='#foo%{irc_server_one}' type='groupchat'><subject>TOPIC TEST</subject></message>"),
424
                     partial(expect_stanza, "/message[@from='#foo%{irc_server_one}/{nick_one}'][@type='groupchat'][@to='{jid_one}/{resource_one}']/subject[text()='TOPIC TEST']"),
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440

                     # Second user joins
                     partial(send_stanza,
                             "<presence from='{jid_two}/{resource_one}' to='#foo%{irc_server_one}/{nick_two}' />"),
                     connection_sequence("irc.localhost", '{jid_two}/{resource_one}'),
                     # Our presence, sent to the other user
                     partial(expect_stanza,
                             ("/presence[@to='{jid_one}/{resource_one}'][@from='#foo%{irc_server_one}/{nick_two}']/muc_user:x/muc_user:item[@affiliation='none'][@jid='~bobby@localhost'][@role='participant']",)),
                     # The other user presence
                     partial(expect_stanza,
                             "/presence[@to='{jid_second}/{resource_one}'][@from='#foo%{irc_server_one}/{nick_one}']/muc_user:x/muc_user:item[@affiliation='admin'][@jid='~nick@localhost'][@role='moderator']"),
                     # Our own presence
                     partial(expect_stanza,
                             ("/presence[@to='{jid_two}/{resource_one}'][@from='#foo%{irc_server_one}/{nick_two}']/muc_user:x/muc_user:item[@affiliation='none'][@jid='~bobby@localhost'][@role='participant']",
                              "/presence/muc_user:x/muc_user:status[@code='110']")
                             ),
441
                     partial(expect_stanza, "/message[@from='#foo%{irc_server_one}/{nick_one}'][@type='groupchat']/subject[text()='TOPIC TEST']"),
442
                 ]),
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
        Scenario("channel_basic_join_on_fixed_irc_server",
                 [
                     handshake_sequence(),
                     partial(send_stanza,
                     "<presence from='{jid_one}/{resource_one}' to='#zgeg@{biboumi_host}/{nick_one}' />"),
                     connection_sequence("irc.localhost", '{jid_one}/{resource_one}'),
                     partial(expect_stanza,
                     "/message/body[text()='Mode #zgeg [+nt] by {irc_host_one}']"),
                     partial(expect_stanza,
                     ("/presence[@to='{jid_one}/{resource_one}'][@from='#zgeg@{biboumi_host}/{nick_one}']/muc_user:x/muc_user:item[@affiliation='admin'][@role='moderator']",
                     "/presence/muc_user:x/muc_user:status[@code='110']")
                     ),
                     partial(expect_stanza, "/message[@from='#zgeg@{biboumi_host}'][@type='groupchat']/subject[not(text())]"),
                 ], conf='fixed_server'
                 ),
458
459
460
461
    )

    failures = 0

louiz’'s avatar
louiz’ committed
462
    irc_output = open("irc_output.txt", "w")
463
    irc = IrcServerRunner()
louiz’'s avatar
louiz’ committed
464
    print("Starting irc server…")
465
466
467
    asyncio.get_event_loop().run_until_complete(irc.start())
    while True:
        res = asyncio.get_event_loop().run_until_complete(irc.process.stderr.readline())
louiz’'s avatar
louiz’ committed
468
        irc_output.write(res.decode())
469
        if not res:
louiz’'s avatar
louiz’ committed
470
            print("IRC server failed to start, see irc_output.txt for more details. Exiting…")
471
            sys.exit(1)
louiz’'s avatar
louiz’ committed
472
        if b"now running in foreground mode" in res:
473
            break
louiz’'s avatar
louiz’ committed
474
    print("irc server started.")
475
476
477
478
479
480
481
482
483
    print("Running %s checks for biboumi." % (len(scenarios)))

    for scenario in scenarios:
        test = BiboumiTest(scenario)
        if not test.run(False):
            print("You can check the files slixmpp_%s_output.txt and biboumi_%s_output.txt to help you debug." %
                  (scenario.name, scenario.name))
            failures += 1

louiz’'s avatar
louiz’ committed
484
    print("Waiting for irc server to exit…")
485
    irc.stop()
louiz’'s avatar
louiz’ committed
486
    asyncio.get_event_loop().run_until_complete(irc.wait())
487

488
489
490
491
492
493
    if failures:
        print("%d test%s failed, please fix %s." % (failures, 's' if failures > 1 else '',
                                                    'them' if failures > 1 else 'it'))
        sys.exit(1)
    else:
        print("All tests passed successfully")