diff --git a/irctest/cases.py b/irctest/cases.py index 3315004..84c2238 100644 --- a/irctest/cases.py +++ b/irctest/cases.py @@ -305,15 +305,9 @@ class BaseServerTestCase(_IrcTestCase): def assertDisconnected(self, client): try: - self.getLines(client) - self.sendLine(client, 'PING foo') - while True: - l = self.getLine(client) - self.assertNotEqual(line, '') - m = message_parser.parse_message(l) - self.assertNotEqual(m.command, 'PONG', - 'Client not disconnected.') - except socket.error: + self.getMessages(client) + self.getMessages(client) + except (socket.error, ConnectionClosed): del self.clients[client] return else: @@ -324,13 +318,16 @@ class BaseServerTestCase(_IrcTestCase): """Skip to the point where we are registered """ + result = [] while True: m = self.getMessage(client, synchronize=False) + result.append(m) if m.command == '001': - return m + return result + def connectClient(self, nick, name=None, capabilities=None, - skip_if_cap_nak=False): - client = self.addClient(name) + skip_if_cap_nak=False, show_io=None): + client = self.addClient(name, show_io=show_io) if capabilities is not None and 0 < len(capabilities): self.sendLine(client, 'CAP REQ :{}'.format(' '.join(capabilities))) m = self.getRegistrationMessage(client) @@ -349,7 +346,7 @@ class BaseServerTestCase(_IrcTestCase): self.sendLine(client, 'NICK {}'.format(nick)) self.sendLine(client, 'USER username * * :Realname') - self.skipToWelcome(client) + welcome = self.skipToWelcome(client) self.sendLine(client, 'PING foo') # Skip all that happy welcoming stuff @@ -364,6 +361,9 @@ class BaseServerTestCase(_IrcTestCase): else: (key, value) = (param, None) self.server_support[key] = value + welcome.append(m) + + return welcome def joinClient(self, client, channel): self.sendLine(client, 'JOIN {}'.format(channel)) diff --git a/irctest/controllers/oragono.py b/irctest/controllers/oragono.py index c8ac934..4b26f03 100644 --- a/irctest/controllers/oragono.py +++ b/irctest/controllers/oragono.py @@ -17,8 +17,12 @@ server: check-ident: false + password: {hashed_password} + max-sendq: 16k + allow-plaintext-resume: true + connection-limits: cidr-len-ipv4: 24 cidr-len-ipv6: 120 @@ -77,6 +81,15 @@ history: client-length: 128 """ +def hash_password(password): + if isinstance(password, str): + password = password.encode('utf-8') + # simulate entry of password and confirmation: + input_ = password + b'\n' + password + b'\n' + p = subprocess.Popen(['oragono', 'genpasswd'], stdin=subprocess.PIPE, stdout=subprocess.PIPE) + out, _ = p.communicate(input_) + return out.decode('utf-8') + class OragonoController(BaseServerController, DirectoryBasedController): software_name = 'Oragono' supported_sasl_mechanisms = { @@ -84,8 +97,6 @@ class OragonoController(BaseServerController, DirectoryBasedController): } def create_config(self): super().create_config() - with self.open_file('ircd.yaml'): - pass def kill_proc(self): self.proc.kill() @@ -96,9 +107,6 @@ class OragonoController(BaseServerController, DirectoryBasedController): if valid_metadata_keys or invalid_metadata_keys: raise NotImplementedByController( 'Defining valid and invalid METADATA keys.') - if password is not None: - #TODO(dan): fix dis - raise NotImplementedByController('PASS command') self.create_config() tls_config = "" if ssl: @@ -111,12 +119,16 @@ class OragonoController(BaseServerController, DirectoryBasedController): ) assert self.proc is None self.port = port + hashed_password = '' # oragono will understand this as 'no password required' + if password is not None: + hashed_password = hash_password(password) with self.open_file('server.yml') as fd: fd.write(TEMPLATE_CONFIG.format( directory=self.directory, hostname=hostname, port=port, tls=tls_config, + hashed_password=hashed_password, )) subprocess.call(['oragono', 'initdb', '--conf', os.path.join(self.directory, 'server.yml'), '--quiet']) diff --git a/irctest/server_tests/test_labeled_responses.py b/irctest/server_tests/test_labeled_responses.py index 8aa37fb..84b5e73 100644 --- a/irctest/server_tests/test_labeled_responses.py +++ b/irctest/server_tests/test_labeled_responses.py @@ -2,8 +2,9 @@ """ +import re + from irctest import cases -from irctest.basecontrollers import NotImplementedByController class LabeledResponsesTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): @cases.SpecificationSelector.requiredBySpecification('IRCv3.2') @@ -232,3 +233,47 @@ class LabeledResponsesTestCase(cases.BaseServerTestCase, cases.OptionalityHelper self.assertEqual(m.tags['draft/label'], '12345', m, fail_msg="Echo'd label doesn't match the label we sent (should be '12345'): {msg}") self.assertEqual(number_of_labels, 1, m1, fail_msg="When sending a TAGMSG to self with echo-message, we only expect one message to contain the label. Instead, {} messages had the label".format(number_of_labels)) + + @cases.SpecificationSelector.requiredBySpecification('IRCv3.2') + def testBatchedJoinMessages(self): + self.connectClient('bar', capabilities=['batch', 'draft/labeled-response', 'draft/message-tags-0.2', 'server-time'], skip_if_cap_nak=True) + self.getMessages(1) + + self.sendLine(1, '@draft/label=12345 JOIN #xyz') + m = self.getMessages(1) + + # we expect at least join and names lines, which must be batched + self.assertGreaterEqual(len(m), 3) + + # valid BATCH start line: + batch_start = m[0] + self.assertMessageEqual(batch_start, command='BATCH') + self.assertEqual(len(batch_start.params), 2) + self.assertTrue(batch_start.params[0].startswith('+'), 'batch start param must begin with +, got %s' % (batch_start.params[0],)) + batch_id = batch_start.params[0][1:] + # batch id MUST be alphanumerics and hyphens + self.assertTrue(re.match(r'^[A-Za-z0-9\-]+$', batch_id) is not None, 'batch id must be alphanumerics and hyphens, got %r' % (batch_id,)) + self.assertEqual(batch_start.params[1], 'draft/labeled-response') + self.assertEqual(batch_start.tags.get('draft/label'), '12345') + + # valid BATCH end line + batch_end = m[-1] + self.assertMessageEqual(batch_end, command='BATCH', params=['-' + batch_id]) + + # messages must have the BATCH tag + for message in m[1:-1]: + self.assertEqual(message.tags.get('batch'), batch_id) + + @cases.SpecificationSelector.requiredBySpecification('Oragono') + def testNoBatchForSingleMessage(self): + self.connectClient('bar', capabilities=['batch', 'draft/labeled-response', 'draft/message-tags-0.2', 'server-time']) + self.getMessages(1) + + self.sendLine(1, '@draft/label=98765 PING adhoctestline') + # no BATCH should be initiated for a one-line response, it should just be labeled + ms = self.getMessages(1) + self.assertEqual(len(ms), 1) + m = ms[0] + self.assertMessageEqual(m, command='PONG', params=['adhoctestline']) + # check the label + self.assertEqual(m.tags.get('draft/label'), '98765') diff --git a/irctest/server_tests/test_regressions.py b/irctest/server_tests/test_regressions.py new file mode 100644 index 0000000..f0ca2e9 --- /dev/null +++ b/irctest/server_tests/test_regressions.py @@ -0,0 +1,27 @@ +""" +Regression tests for bugs in oragono. +""" + +from irctest import cases + +class RegressionsTestCase(cases.BaseServerTestCase): + + @cases.SpecificationSelector.requiredBySpecification('RFC1459') + def testFailedNickChange(self): + # see oragono commit d0ded906d4ac8f + self.connectClient('alice') + self.connectClient('bob') + + # bob tries to change to an in-use nickname; this MUST fail + self.sendLine(2, 'NICK alice') + ms = self.getMessages(2) + self.assertEqual(len(ms), 1) + self.assertMessageEqual(ms[0], command='433') # ERR_NICKNAMEINUSE + + # bob MUST still own the bob nick, and be able to receive PRIVMSG as bob + self.sendLine(1, 'PRIVMSG bob hi') + ms = self.getMessages(1) + self.assertEqual(len(ms), 0) + ms = self.getMessages(2) + self.assertEqual(len(ms), 1) + self.assertMessageEqual(ms[0], command='PRIVMSG', params=['bob', 'hi']) diff --git a/irctest/server_tests/test_resume.py b/irctest/server_tests/test_resume.py new file mode 100644 index 0000000..bb2cd40 --- /dev/null +++ b/irctest/server_tests/test_resume.py @@ -0,0 +1,94 @@ +""" + +""" + +from irctest import cases + + +class ResumeTestCase(cases.BaseServerTestCase): + + @cases.SpecificationSelector.requiredBySpecification('Oragono') + def testNoResumeByDefault(self): + self.connectClient('bar', capabilities=['batch', 'echo-message', 'draft/labeled-response']) + ms = self.getMessages(1) + resume_messages = [m for m in ms if m.command == 'RESUME'] + self.assertEqual(resume_messages, [], 'should not see RESUME messages unless explicitly negotiated') + + @cases.SpecificationSelector.requiredBySpecification('Oragono') + def testResume(self): + self.connectClient('bar', capabilities=['batch', 'draft/labeled-response', 'server-time']) + ms = self.getMessages(1) + + welcome = self.connectClient('baz', capabilities=['batch', 'draft/labeled-response', 'server-time', 'draft/resume-0.2']) + resume_messages = [m for m in welcome if m.command == 'RESUME'] + self.assertEqual(len(resume_messages), 1) + self.assertEqual(resume_messages[0].params[0], 'TOKEN') + token = resume_messages[0].params[1] + + self.joinChannel(1, '#xyz') + self.joinChannel(2, '#xyz') + self.sendLine(1, 'PRIVMSG #xyz :hello friends') + self.sendLine(1, 'PRIVMSG baz :hello friend singular') + self.getMessages(1) + # should receive these messages + privmsgs = [m for m in self.getMessages(2) if m.command == 'PRIVMSG'] + self.assertEqual(len(privmsgs), 2) + privmsgs.sort(key=lambda m: m.params[0]) + self.assertMessageEqual(privmsgs[0], command='PRIVMSG', params=['#xyz', 'hello friends']) + self.assertMessageEqual(privmsgs[1], command='PRIVMSG', params=['baz', 'hello friend singular']) + channelMsgTime = privmsgs[0].tags.get('time') + + # tokens MUST be cryptographically secure; therefore, this token should be invalid + # with probability at least 1 - 1/(2**128) + bad_token = 'a' * len(token) + self.addClient() + self.sendLine(3, 'CAP LS') + self.sendLine(3, 'CAP REQ :batch draft/labeled-response server-time draft/resume-0.2') + self.sendLine(3, 'NICK tempnick') + self.sendLine(3, 'USER tempuser 0 * tempuser') + self.sendLine(3, 'RESUME baz ' + bad_token + ' 2006-01-02T15:04:05.999Z') + + # resume with a bad token MUST fail + ms = self.getMessages(3) + resume_err_messages = [m for m in ms if m.command == 'RESUME' and m.params[0] == 'ERR'] + self.assertEqual(len(resume_err_messages), 1) + # however, registration should proceed with the alternative nick + self.sendLine(3, 'CAP END') + welcome_msgs = [m for m in self.getMessages(3) if m.command == '001'] # RPL_WELCOME + self.assertEqual(welcome_msgs[0].params[0], 'tempnick') + + self.addClient() + self.sendLine(4, 'CAP LS') + self.sendLine(4, 'CAP REQ :batch draft/labeled-response server-time draft/resume-0.2') + self.sendLine(4, 'NICK tempnick_') + self.sendLine(4, 'USER tempuser 0 * tempuser') + # resume with a timestamp in the distant past + self.sendLine(4, 'RESUME baz ' + token + ' 2006-01-02T15:04:05.999Z') + # successful resume does not require CAP END: + # https://github.com/ircv3/ircv3-specifications/pull/306/files#r255318883 + ms = self.getMessages(4) + + resume_messages = [m for m in ms if m.command == 'RESUME'] + self.assertEqual(len(resume_messages), 2) + self.assertEqual(resume_messages[0].params[0], 'TOKEN') + new_token = resume_messages[0].params[1] + self.assertNotEqual(token, new_token, 'should receive a new, strong resume token; instead got ' + new_token) + self.assertMessageEqual(resume_messages[1], command='RESUME', params=['SUCCESS', 'baz']) + + # test replay of messages + privmsgs = [m for m in ms if m.command == 'PRIVMSG' and m.prefix.startswith('bar')] + self.assertEqual(len(privmsgs), 2) + privmsgs.sort(key=lambda m: m.params[0]) + self.assertMessageEqual(privmsgs[0], command='PRIVMSG', params=['#xyz', 'hello friends']) + self.assertMessageEqual(privmsgs[1], command='PRIVMSG', params=['baz', 'hello friend singular']) + # should replay with the original server-time + # TODO this probably isn't testing anything because the timestamp only has second resolution, + # hence will typically match by accident + self.assertEqual(privmsgs[0].tags.get('time'), channelMsgTime) + + # original client should have been disconnected + self.assertDisconnected(2) + # new client should be receiving PRIVMSG sent to baz + self.sendLine(1, 'PRIVMSG baz :hello again') + self.getMessages(1) + self.assertMessageEqual(self.getMessage(4), command='PRIVMSG', params=['baz', 'hello again'])