# HG changeset patch # Parent c22caa54600c4f85db2a400c7fbea5497f943aa1 Add authenticator for accepting XMPP client connections. The new authenticator XMPPClientListenAuthenticator is to be used together with an `XmlStream` created for an incoming XMPP stream. It uses the new initializers for SASL (PLAIN only), resource binding and session establishement. This authenticator needs at least one Twisted Cred portal to hold the domain served. After authenticating, an avatar and a logout callback are returned. Upon binding a resource, the avatar's `bindResource` method is called with the desired resource name. Upon stream disconnect, the logout callback is called. diff --git a/wokkel/client.py b/wokkel/client.py --- a/wokkel/client.py +++ b/wokkel/client.py @@ -10,14 +10,27 @@ that should probably eventually move there. """ +import base64 + from twisted.application import service -from twisted.internet import reactor +from twisted.cred import credentials, error as ecred +from twisted.internet import defer, reactor +from twisted.python import log from twisted.names.srvconnect import SRVConnector -from twisted.words.protocols.jabber import client, sasl, xmlstream +from twisted.words.protocols.jabber import client, error, sasl, xmlstream +from twisted.words.xish import domish from wokkel import generic +from wokkel.iwokkel import IUserSession from wokkel.subprotocols import StreamManager +NS_CLIENT = 'jabber:client' + +XPATH_AUTH = "/auth[@xmlns='%s']" % sasl.NS_XMPP_SASL +XPATH_BIND = "/iq[@type='set']/bind[@xmlns='%s']" % client.NS_XMPP_BIND +XPATH_SESSION = "/iq[@type='set']/session[@xmlns='%s']" % \ + client.NS_XMPP_SESSION + class CheckAuthInitializer(object): """ Check what authentication methods are available. @@ -51,7 +64,7 @@ autentication. """ - namespace = 'jabber:client' + namespace = NS_CLIENT def __init__(self, jid, password): xmlstream.ConnectAuthenticator.__init__(self, jid.host) @@ -186,3 +199,284 @@ c = XMPPClientConnector(reactor, domain, factory) c.connect() return factory.deferred + + + +class InvalidMechanism(Exception): + """ + The requested SASL mechanism is invalid. + """ + + + +class AuthorizationIdentifierNotSupported(Exception): + """ + Authorization Identifiers are not supported. + """ + + + +class SASLReceivingInitializer(generic.BaseReceivingInitializer): + """ + Stream initializer for SASL authentication, receiving side. + + This authenticator uses L{Twisted Cred}, the pluggable + authentication system. As such it takes a + L{Portal} to select authentication mechanisms, + creates a credential object for the selected authentication mechanism and + passes it to the portal to login and acquire an avatar. + + The avatar will be set on the C{avatar} attribute of the + L{xmlstream.XmlStream}. + + Currently, only the C{PLAIN} SASL mechanism is supported. + """ + + required = True + _mechanisms = None + __credentialsMap = { + credentials.IAnonymous: 'ANONYMOUS', + credentials.IUsernamePassword: 'PLAIN', + } + + def __init__(self, name, xs, portal): + generic.BaseReceivingInitializer.__init__(self, name, xs) + self.portal = portal + self.failureGrace = 3 + + + def getFeatures(self): + feature = domish.Element((sasl.NS_XMPP_SASL, 'mechanisms')) + + # Advertise supported SASL mechanisms that have corresponding + # checkers in the Portal. + self._mechanisms = set() + for interface in self.portal.listCredentialsInterfaces(): + try: + mechanism = self.__credentialsMap[interface] + except KeyError: + pass + else: + self._mechanisms.add(mechanism) + feature.addElement('mechanism', content=mechanism) + + return [feature] + + + def initialize(self): + self.xmlstream.avatar = None + self.xmlstream.addObserver(XPATH_AUTH, self._onAuth) + return self.deferred + + + def _onAuth(self, auth): + """ + Called when the start of the SASL negotiation is received. + + @type auth: L{domish.Element}. + """ + auth.handled = True + + def cb(_): + response = domish.Element((sasl.NS_XMPP_SASL, 'success')) + self.xmlstream.send(response) + self.xmlstream.reset() + self.deferred.callback(xmlstream.Reset) + + def eb(failure): + if failure.check(ecred.UnauthorizedLogin): + condition = 'not-authorized' + elif failure.check(InvalidMechanism): + condition = 'invalid-mechanism' + elif failure.check(AuthorizationIdentifierNotSupported): + condition = 'invalid-authz' + else: + log.err(failure) + condition = 'temporary-auth-failure' + + response = domish.Element((sasl.NS_XMPP_SASL, 'failure')) + response.addElement(condition) + self.xmlstream.send(response) + + # Close stream on too many failing authentication attempts + self.failureGrace -= 1 + if self.failureGrace == 0: + self.deferred.errback(error.StreamError('policy-violation')) + else: + return + + d = defer.maybeDeferred(self._doAuth, auth) + d.addCallbacks(cb, eb) + + + def _credentialsFrom_PLAIN(self, auth): + """ + Create credentials from the initial response for PLAIN. + """ + initialResponse = base64.b64decode(unicode(auth)) + authzid, authcid, passwd = initialResponse.split('\x00') + + if authzid: + raise AuthorizationIdentifierNotSupported() + + creds = credentials.UsernamePassword(username=authcid, + password=passwd) + return creds + + + def _credentialsFrom_ANONYMOUS(self, auth): + """ + Create credentials from the initial response for ANONYMOUS. + """ + return credentials.Anonymous() + + + def _doAuth(self, auth): + """ + Start authentication. + """ + mechanism = auth.getAttribute('mechanism') + + if mechanism not in self._mechanisms: + raise InvalidMechanism() + + creds = getattr(self, '_credentialsFrom_' + mechanism)(auth) + + def cb((iface, avatar, logout)): + self.xmlstream.avatar = avatar + self.xmlstream.addObserver(xmlstream.STREAM_END_EVENT, + lambda _: logout()) + + d = self.portal.login(creds, self.xmlstream, IUserSession) + d.addCallback(cb) + return d + + + +class BindReceivingInitializer(generic.BaseReceivingInitializer): + """ + Stream initializer for resource binding, receiving side. + + Upon a request for resource binding, this will call C{bindResource} on + the stream's avatar. + """ + + required = True + + def getFeatures(self): + feature = domish.Element((client.NS_XMPP_BIND, 'bind')) + return [feature] + + + def initialize(self): + self.xmlstream.addOnetimeObserver(XPATH_BIND, self.onBind) + return self.deferred + + + def onBind(self, iq): + def cb(boundJID): + self.xmlstream.otherEntity = boundJID + + response = xmlstream.toResponse(iq, 'result') + response.addElement((client.NS_XMPP_BIND, 'bind')) + response.bind.addElement((client.NS_XMPP_BIND, 'jid'), + content=boundJID.full()) + + return response + + iq.handled = True + resource = unicode(iq.bind) or None + d = self.xmlstream.avatar.bindResource(resource) + d.addCallback(cb) + d.addCallback(self.xmlstream.send) + d.chainDeferred(self.deferred) + + + +class SessionReceivingInitializer(generic.BaseReceivingInitializer): + """ + Stream initializer for session establishment, receiving side. + + This is mostly a no-op and just returns a result stanza. If resource + binding hasn't yet completed, this will return a stanza error with the + condition C{'forbidden'}. + + Note that RFC 6120 deprecated the session establishment protocol. This + is provided for backwards compatibility. + """ + + required = False + + def getFeatures(self): + feature = domish.Element((client.NS_XMPP_SESSION, 'session')) + return [feature] + + + def initialize(self): + self.xmlstream.addOnetimeObserver(XPATH_SESSION, self.onSession, 1) + return self.deferred + + + def onSession(self, iq): + iq.handled = True + + reply = domish.Element((None, 'iq')) + + if self.xmlstream.otherEntity: + reply = xmlstream.toResponse(iq, 'result') + else: + reply = error.StanzaError('forbidden').toResponse(iq) + self.xmlstream.send(reply) + self.deferred.callback(None) + + + +class XMPPClientListenAuthenticator(generic.FeatureListenAuthenticator): + """ + XML Stream authenticator for XMPP clients, server side. + + @ivar portals: Mapping of server JIDs to Cred Portals. + @type portals: C{dict} of L{twisted.words.protocols.jabber.jid.JID} to + L{twisted.cred.portal.Portal}. + """ + + namespace = NS_CLIENT + + def __init__(self, portals): + generic.FeatureListenAuthenticator.__init__(self) + self.portals = portals + self.portal = None + + + def getInitializers(self): + """ + Return initializers based on previously completed initializers. + + This has three stages: 1. SASL, 2. Resource binding and session + establishment. 3. Completed. Note that session establishment + is optional. + """ + if not self.completedInitializers: + return [SASLReceivingInitializer('sasl', self.xmlstream, self.portal)] + elif self.completedInitializers[-1] == 'sasl': + return [BindReceivingInitializer('bind', self.xmlstream), + SessionReceivingInitializer('session', self.xmlstream)] + + + def checkStream(self): + """ + Check that the stream header has proper addressing. + + The C{'to'} attribute must be present and there should have a matching + portal in L{portals}. + """ + generic.FeatureListenAuthenticator.checkStream(self) + + if not self.xmlstream.thisEntity: + raise error.StreamError('improper-addressing') + + # Check if we serve the domain and use the associated portal. + try: + self.portal = self.portals[self.xmlstream.thisEntity] + except KeyError: + raise error.StreamError('host-unknown') diff --git a/wokkel/generic.py b/wokkel/generic.py --- a/wokkel/generic.py +++ b/wokkel/generic.py @@ -467,6 +467,7 @@ def __init__(self): self.completedInitializers = [] + self._initialized = False def _onElementFallback(self, element): @@ -558,11 +559,12 @@ self.xmlstream.send(features) - if not required: + if not required and not self._initialized: # There are no required initializers anymore. This stream is # now ready for the exchange of stanzas. self.xmlstream.removeObserver(XPATH_ALL, self._onElementFallback) self.xmlstream.dispatch(self.xmlstream, xmlstream.STREAM_AUTHD_EVENT) + self._initialized = True if ds: d = defer.DeferredList(ds, fireOnOneCallback=True, diff --git a/wokkel/iwokkel.py b/wokkel/iwokkel.py --- a/wokkel/iwokkel.py +++ b/wokkel/iwokkel.py @@ -985,6 +985,55 @@ +class IUserSession(Interface): + """ + Interface for a XMPP user client session avatar. + """ + + entity = Attribute( + """ + The JID for this session. + """) + + + def loggedIn(realm, mind): + """ + Called by the realm when login occurs. + + @param realm: The realm though which login is occurring. + @param mind: The mind object. + """ + + + def bindResource(resource): + """ + Bind a resource to this session. + + @type resource: C{unicode}. + """ + + + def logout(): + """ + End this session. + + This is called when the stream is disconnected. + """ + + + def send(element): + """ + Called when the client sends a stanza. + """ + + + def receive(element): + """ + Have the client receive a stanza. + """ + + + class IReceivingInitializer(Interface): """ Interface for XMPP stream initializers for receiving entities. diff --git a/wokkel/test/test_client.py b/wokkel/test/test_client.py --- a/wokkel/test/test_client.py +++ b/wokkel/test/test_client.py @@ -5,16 +5,29 @@ Tests for L{wokkel.client}. """ +from base64 import b64encode + +from zope.interface import implements + +from twisted.cred.portal import IRealm, Portal +from twisted.cred.checkers import InMemoryUsernamePasswordDatabaseDontUse from twisted.internet import defer +from twisted.test import proto_helpers from twisted.trial import unittest -from twisted.words.protocols.jabber import xmlstream +from twisted.words.protocols.jabber import error, xmlstream +from twisted.words.protocols.jabber.client import NS_XMPP_BIND +from twisted.words.protocols.jabber.client import NS_XMPP_SESSION from twisted.words.protocols.jabber.client import XMPPAuthenticator from twisted.words.protocols.jabber.jid import JID +from twisted.words.protocols.jabber.sasl import NS_XMPP_SASL +from twisted.words.protocols.jabber.xmlstream import INIT_FAILED_EVENT +from twisted.words.protocols.jabber.xmlstream import NS_STREAMS from twisted.words.protocols.jabber.xmlstream import STREAM_AUTHD_EVENT -from twisted.words.protocols.jabber.xmlstream import INIT_FAILED_EVENT from twisted.words.protocols.jabber.xmlstream import XMPPHandler +from twisted.words.xish import xpath -from wokkel import client +from wokkel import client, iwokkel +from wokkel.generic import TestableXmlStream, FeatureListenAuthenticator class XMPPClientTest(unittest.TestCase): """ @@ -164,3 +177,505 @@ self.assertEqual(factory.deferred, d2) return d1 + + + +class TestSession(object): + implements(iwokkel.IUserSession) + + def __init__(self, domain, user): + self.domain = domain + self.user = user + + + def bindResource(self, resource): + return defer.succeed(JID(tuple=(self.user, self.domain, resource))) + + + +class TestRealm(object): + + implements(IRealm) + + logoutCalled = False + + def __init__(self, domain): + self.domain = domain + + + def requestAvatar(self, avatarId, mind, *interfaces): + return (iwokkel.IUserSession, + TestSession(self.domain, avatarId.decode('utf-8')), + self.logout) + + + def logout(self): + self.logoutCalled = True + + + +class TestableFeatureListenAuthenticator(FeatureListenAuthenticator): + namespace = 'jabber:client' + + initialized = None + + def __init__(self, getInitializers): + """ + Set up authenticator. + + @param getInitializers: Function to override the getInitializers + method. It will receive C{self} as the only argument. + """ + FeatureListenAuthenticator.__init__(self) + + import types + self.getInitializers = types.MethodType(getInitializers, self) + + xs = TestableXmlStream(self) + xs.makeConnection(proto_helpers.StringTransport()) + + + def streamStarted(self, rootElement): + """ + Set up observers for authentication events. + """ + def authenticated(_): + self.initialized = True + + self.xmlstream.addObserver(STREAM_AUTHD_EVENT, authenticated) + FeatureListenAuthenticator.streamStarted(self, rootElement) + + + +class SASLReceivingInitializerTest(unittest.TestCase): + """ + Tests for L{client.SASLReceivingInitializer}. + """ + + def setUp(self): + realm = TestRealm(u'example.org') + checker = InMemoryUsernamePasswordDatabaseDontUse(test='secret') + self.portal = portal = Portal(realm, (checker,)) + + def getInitializers(self): + self.initializer = client.SASLReceivingInitializer('sasl', + self.xmlstream, + portal) + return [self.initializer] + + self.authenticator = TestableFeatureListenAuthenticator(getInitializers) + self.xmlstream = self.authenticator.xmlstream + + + def test_getFeatures(self): + """ + The stream features list SASL with the PLAIN mechanism. + """ + xs = self.xmlstream + xs.dataReceived("") + + self.assertTrue(xs.headerSent) + + # Check SASL mechanisms + features = xs.output[-1] + self.assertTrue(xpath.matches("/features[@xmlns='%s']" + "/mechanisms[@xmlns='%s']" + "/mechanism[@xmlns='%s' and " + "text()='PLAIN']" % + (NS_STREAMS, NS_XMPP_SASL, NS_XMPP_SASL), + features)) + + + def test_auth(self): + """ + Authenticating causes an avatar to be set on the authenticator. + """ + xs = self.xmlstream + xs.dataReceived("") + xs.output = [] + response = b64encode('\x00'.join(['', 'test', 'secret'])) + xs.dataReceived("%s" % response) + self.assertTrue(iwokkel.IUserSession.providedBy(self.xmlstream.avatar)) + self.assertFalse(xs.headerSent) + self.assertEqual(1, len(xs.output)) + self.assertFalse(self.authenticator.initialized) + + + def test_authInvalidMechanism(self): + """ + Authenticating with an invalid SASL mechanism causes a streamError. + """ + xs = self.xmlstream + xs.dataReceived("") + xs.output = [] + xs.dataReceived("") + self.assertTrue(xpath.matches(("/failure[@xmlns='%s']" + "/invalid-mechanism[@xmlns='%s']" % + (NS_XMPP_SASL, NS_XMPP_SASL)), + xs.output[-1])) + + + def test_authFail(self): + """ + Authenticating causes an avatar to be set on the authenticator. + """ + xs = self.xmlstream + xs.dataReceived("") + xs.output = [] + response = b64encode('\x00'.join(['', 'test', 'bad'])) + xs.dataReceived("%s" % response) + self.assertIdentical(None, self.xmlstream.avatar) + self.assertTrue(xs.headerSent) + + self.assertTrue(xpath.matches(("/failure[@xmlns='%s']" + "/not-authorized[@xmlns='%s']" % + (NS_XMPP_SASL, NS_XMPP_SASL)), + xs.output[-1])) + + self.assertFalse(self.authenticator.initialized) + + + def test_authFailMultiple(self): + """ + Authenticating causes an avatar to be set on the authenticator. + """ + xs = self.xmlstream + xs.dataReceived("") + + xs.output = [] + response = b64encode('\x00'.join(['', 'test', 'bad'])) + + attempts = self.authenticator.initializer.failureGrace + for attempt in xrange(attempts): + xs.dataReceived("%s" % response) + self.assertTrue(xpath.matches(("/failure[@xmlns='%s']" + "/not-authorized[@xmlns='%s']" % + (NS_XMPP_SASL, NS_XMPP_SASL)), + xs.output[-1])) + self.xmlstream.assertStreamError(self, condition='policy-violation') + self.assertFalse(self.authenticator.initialized) + + + def test_authException(self): + """ + Other authentication exceptions yield temporary-auth-failure. + """ + class Error(Exception): + pass + + def login(credentials, mind, *interfaces): + raise Error() + + self.portal.login = login + + xs = self.xmlstream + xs.dataReceived("") + xs.output = [] + response = b64encode('\x00'.join(['', 'test', 'bad'])) + xs.dataReceived("%s" % response) + self.assertIdentical(None, self.xmlstream.avatar) + self.assertTrue(xs.headerSent) + + self.assertTrue(xpath.matches(("/failure[@xmlns='%s']" + "/temporary-auth-failure[@xmlns='%s']" % + (NS_XMPP_SASL, NS_XMPP_SASL)), + xs.output[-1])) + self.assertFalse(self.authenticator.initialized) + self.assertEqual(1, len(self.flushLoggedErrors(Error))) + + + def test_authNonAsciiUsername(self): + """ + Authenticating causes an avatar to be set on the authenticator. + """ + xs = self.xmlstream + xs.dataReceived("") + xs.output = [] + response = b64encode('\x00'.join(['', 'test\xa1', 'secret'])) + xs.dataReceived("%s" % response) + self.assertIdentical(None, self.xmlstream.avatar) + self.assertTrue(xs.headerSent) + + self.assertEqual(1, len(xs.output)) + failure = xs.output[-1] + condition = failure.elements().next() + self.assertEqual('not-authorized', condition.name) + + + def test_authAuthorizationIdentifier(self): + """ + Authorization Identifiers are not supported. + """ + xs = self.xmlstream + xs.dataReceived("") + xs.output = [] + response = b64encode('\x00'.join(['other', 'test', 'secret'])) + xs.dataReceived("%s" % response) + self.assertIdentical(None, self.xmlstream.avatar) + self.assertTrue(xs.headerSent) + + self.assertEqual(1, len(xs.output)) + failure = xs.output[-1] + condition = failure.elements().next() + self.assertEqual('invalid-authz', condition.name) + + + +class BindReceivingInitializerTest(unittest.TestCase): + """ + Tests for L{client.BindReceivingInitializer}. + """ + + def setUp(self): + def getInitializers(self): + self.initializer = client.BindReceivingInitializer('bind', + self.xmlstream) + return [self.initializer] + + self.authenticator = TestableFeatureListenAuthenticator(getInitializers) + self.xmlstream = self.authenticator.xmlstream + self.xmlstream.avatar = TestSession('example.org', 'test') + + + def test_getFeatures(self): + """ + The stream features include resource binding. + """ + xs = self.xmlstream + xs.dataReceived("") + + features = xs.output[-1] + self.assertTrue(xpath.matches("/features[@xmlns='%s']" + "/bind[@xmlns='%s']" % + (NS_STREAMS, NS_XMPP_BIND), + features)) + + + def test_bind(self): + """ + To bind a resource, the avatar is requested one and a JID is returned. + """ + xs = self.xmlstream + xs.dataReceived("") + + # This initializer is required. + self.assertFalse(self.authenticator.initialized) + + xs.output = [] + xs.dataReceived(""" + Home + """ % NS_XMPP_BIND) + + self.assertTrue(xs.headerSent, "Unexpected stream restart") + + # In response to the bind request, a result iq and the new stream + # features are sent + response = xs.output[-2] + self.assertTrue(xpath.matches("/iq[@type='result']" + "/bind[@xmlns='%s']" + "/jid[@xmlns='%s' and " + "text()='%s']" % + (NS_XMPP_BIND, + NS_XMPP_BIND, + 'test@example.org/Home'), + response)) + + self.assertTrue(self.authenticator.initialized) + + + +class SessionReceivingInitializerTest(unittest.TestCase): + """ + Tests for L{client.SessionReceivingInitializer}. + """ + + def setUp(self): + def getInitializers(self): + self.initializer = client.SessionReceivingInitializer('session', + self.xmlstream) + return [self.initializer] + + self.authenticator = TestableFeatureListenAuthenticator(getInitializers) + self.xmlstream = self.authenticator.xmlstream + + + def test_getFeatures(self): + """ + The stream features include session establishment. + """ + xs = self.xmlstream + xs.dataReceived("") + + features = xs.output[-1] + self.assertTrue(xpath.matches("/features[@xmlns='%s']" + "/session[@xmlns='%s']" % + (NS_STREAMS, NS_XMPP_SESSION), + features)) + + def test_session(self): + """ + Session establishment is a no-op iq exchange. + """ + xs = self.xmlstream + xs.dataReceived("") + + # This initializer is not required. + self.assertTrue(self.authenticator.initialized) + + # If resource binding has completed, xs.otherEntity has been set. + xs.otherEntity = JID('test@example.org/Home') + + xs.output = [] + xs.dataReceived(""" + + """ % NS_XMPP_SESSION) + + self.assertTrue(xs.headerSent, "Unexpected stream restart") + + # In response to the session request, a result iq and the new stream + # features are sent + response = xs.output[-2] + self.assertTrue(xpath.matches("/iq[@type='result']", response)) + + + + def test_sessionNoBind(self): + """ + Session establishment requires resource binding being completed. + """ + xs = self.xmlstream + xs.dataReceived("") + + # This initializer is not required. + self.assertTrue(self.authenticator.initialized) + + xs.output = [] + xs.dataReceived(""" + + """ % NS_XMPP_SESSION) + + self.assertTrue(xs.headerSent, "Unexpected stream restart") + + # In response to the session request, a result iq and the new stream + # features are sent + response = xs.output[-2] + stanzaError = error.exceptionFromStanza(response) + self.assertEqual('forbidden', stanzaError.condition) + + + +class XMPPClientListenAuthenticatorTest(unittest.TestCase): + """ + Tests for L{client.XMPPClientListenAuthenticator}. + """ + + def setUp(self): + portals = {JID('example.org'): None} + self.authenticator = client.XMPPClientListenAuthenticator(portals) + self.xmlstream = TestableXmlStream(self.authenticator) + self.xmlstream.makeConnection(self) + + + def test_getInitializersStart(self): + """ + Upon the start of negotation, only the SASL initializer is available. + """ + inits = self.authenticator.getInitializers() + (init,) = inits + self.assertEqual('sasl', init.name) + self.assertIsInstance(init, client.SASLReceivingInitializer) + + + def test_getInitializersPostSASL(self): + """ + After SASL, the resource binding and session establishment initializers + are available. + """ + self.authenticator.completedInitializers = ['sasl'] + inits = self.authenticator.getInitializers() + (bind, session) = inits + self.assertEqual('bind', bind.name) + self.assertIsInstance(bind, client.BindReceivingInitializer) + self.assertEqual('session', session.name) + self.assertIsInstance(session, client.SessionReceivingInitializer) + + + def test_streamStartedWrongNamespace(self): + """ + An incorrect stream namespace causes a stream error. + """ + xs = self.xmlstream + xs.dataReceived("") + self.xmlstream.assertStreamError(self, condition='invalid-namespace') + + + def test_streamStartedNoTo(self): + """ + A missing 'to' attribute on the stream header causes a stream error. + """ + xs = self.xmlstream + xs.dataReceived("") + self.xmlstream.assertStreamError(self, condition='improper-addressing') + + + def test_streamStartedUnknownHost(self): + """ + An unknown 'to' on the stream header causes a stream error. + """ + xs = self.xmlstream + xs.dataReceived("") + self.xmlstream.assertStreamError(self, condition='host-unknown') diff --git a/wokkel/test/test_generic.py b/wokkel/test/test_generic.py --- a/wokkel/test/test_generic.py +++ b/wokkel/test/test_generic.py @@ -331,15 +331,12 @@ """ def setUp(self): - self.gotAuthenticated = False - self.initFailure = None + self.gotAuthenticated = 0 self.authenticator = generic.FeatureListenAuthenticator() self.authenticator.namespace = 'jabber:server' self.xmlstream = generic.TestableXmlStream(self.authenticator) self.xmlstream.addObserver('//event/stream/authd', self.onAuthenticated) - self.xmlstream.addObserver('//event/xmpp/initfailed', - self.onInitFailed) self.init = TestableReceivingInitializer('init', self.xmlstream, 'testns', 'test') @@ -351,11 +348,7 @@ def onAuthenticated(self, obj): - self.gotAuthenticated = True - - - def onInitFailed(self, failure): - self.initFailure = failure + self.gotAuthenticated += 1 def test_getInitializers(self): @@ -537,6 +530,38 @@ " " "") + def test_streamStartedInitializerNotRequired(self): + """ + If no initializers are required, initialization is done. + """ + self.init.required = False + xs = self.xmlstream + xs.makeConnection(proto_helpers.StringTransport()) + xs.dataReceived("") + + self.assertEqual(1, self.gotAuthenticated) + + + def test_streamStartedInitializerNotRequiredDoneOnce(self): + """ + If no initializers are required, the authd event is not sent again. + """ + self.init.required = False + xs = self.xmlstream + xs.makeConnection(proto_helpers.StringTransport()) + xs.dataReceived("") + + self.assertEqual(1, self.gotAuthenticated) + xs.output = [] + self.init.deferred.callback(None) + self.assertEqual(1, self.gotAuthenticated) + def test_streamStartedXmlStanzasHandledIgnored(self): """