======================
SchoolTool CAS package
======================

This package allows SchoolTool to operate in a single sign-on environment
acting as a client to a CAS server.

Test Framework
--------------

First we need to set up our tests.

    >>> from zope.component import provideUtility, provideAdapter
    >>> from zope.app.testing import setup
    >>> root = setup.placefulSetUp(True)
    >>> from schooltool.relationship.tests import setUpRelationships
    >>> setUpRelationships()

    Principal registration:

    >>> from zope.principalregistry.principalregistry import principalRegistry
    >>> from zope.authentication.interfaces import IAuthentication
    >>> provideUtility(principalRegistry, IAuthentication)

    Session setup:

    >>> from zope.session.session import ClientId, Session
    >>> from zope.session.session import PersistentSessionDataContainer
    >>> from zope.publisher.interfaces import IRequest
    >>> from zope.session.http import CookieClientIdManager
    >>> from zope.session.interfaces import ISessionDataContainer
    >>> from zope.session.interfaces import IClientId
    >>> from zope.session.interfaces import IClientIdManager, ISession
    >>> provideAdapter(ClientId, (IRequest,), IClientId)
    >>> provideAdapter(Session, (IRequest,), ISession)
    >>> provideUtility(CookieClientIdManager(), IClientIdManager)
    >>> sdc = PersistentSessionDataContainer()
    >>> provideUtility(sdc, ISessionDataContainer, 'schooltool.auth')

    Application and Authentication setup:

    >>> from schooltool.app.app import SchoolToolApplication
    >>> from schooltool.app.app import getSchoolToolApplication
    >>> from schooltool.app.interfaces import ISchoolToolApplication
    >>> app = SchoolToolApplication()
    >>> root['frogpond'] = app
    >>> from schooltool.app.security import SchoolToolAuthenticationUtility
    >>> auth = SchoolToolAuthenticationUtility()
    >>> from schooltool.app.security import setUpLocalAuth
    >>> setUpLocalAuth(app, auth)
    >>> from zope.component.hooks import setSite
    >>> setSite(app)
    >>> provideAdapter(getSchoolToolApplication, (None,), ISchoolToolApplication)

CAS Authentication Utility
--------------------------

Now we will set up the CASAuthenticationPlugin and CASAuthorityUtility.  Our
CASAuthorityUtility will be created with a fake CAS server address.

    >>> from schooltool.cas import CASAuthenticationPlugin, CASAuthorityUtility
    >>> from schooltool.cas.interfaces import (ICASAuthenticationPlugin, 
    ...                                        ICASAuthority)
    >>> plugin = CASAuthenticationPlugin()
    >>> provideUtility(plugin, ICASAuthenticationPlugin)
    >>> cas_authority = CASAuthorityUtility('https://fakecas.org/')
    >>> provideUtility(cas_authority, ICASAuthority)

The plugin can find it's CAS authority.

    >>> plugin.authority is cas_authority
    True
    
We'll create a person and use the plugin's base class method, getPrincipal,
to get the person's corresponding principal to be used in later tests:

    >>> from schooltool.person.person import Person, PersonContainer
    >>> from zope.security.interfaces import IPrincipal
    >>> app['persons'] = PersonContainer()
    >>> person = Person(username=u"frog", title="Frog")
    >>> app['persons']['frog'] = person
    >>> principal = auth.getPrincipal('sb.person.frog')
    >>> from zope.interface.verify import verifyObject
    >>> verifyObject(IPrincipal, principal)
    True
    >>> principal.id
    'sb.person.frog'
    >>> principal.title
    'Frog'

At first, there are no credentials stored in the session.

    >>> from zope.publisher.browser import TestRequest
    >>> request = TestRequest()
    >>> plugin.getCredentials(request) is None
    True
    >>> plugin.checkCredentials(request)
    False

If we store our principal in the session, we will get it back when we ask for 
it.

    >>> plugin.storeCredentials(request, principal)
    >>> principal = plugin.getCredentials(request)
    >>> principal.id
    'sb.person.frog'
    >>> plugin.checkCredentials(request)
    True

If we clear the credentials, we'll be back where we started.

    >>> plugin.clearCredentials(request)
    >>> plugin.getCredentials(request) is None
    True
    >>> plugin.checkCredentials(request)
    False

Calling the authenticate method
-------------------------------

The first time we call the authenticate method, we'll call it with the 'CAS'
flag but no ticket.  This case comes up when the user signs into the app,
and we want to present them with the calendar and not force them to
authenticate.

    >>> request.form = {'CAS': '1'}
    >>> principal = plugin.authenticate(request)
    >>> principal is None
    True
    >>> plugin.checkCredentials(request)
    True

We'll clear the credentials from the session and call it with an empty form.
It will return us 'None' for the principal and the request is redirected to the
fake CAS server's login page with instructions on how to redirect back to us in
the 'service' portion of the query string.

    >>> plugin.clearCredentials(request)
    >>> request.form = {}
    >>> plugin.authenticate(request) is None
    True
    >>> request.response.getHeader('Location')
    'https://fakecas.org/login?gateway=true&service=http%3A%2F%2F127.0.0.1%3FCAS%3D1'

Now let's assume that the user filled in the correct credentials for person,
'frog', and now the CAS server has redirected back to us with a ticket.  Our
authenticate method will urlopen the CAS server, passing that ticket to get
back the principal id.

Since we can't call a fake server, we'll have to overlay the '_urlopen' method
of the plugin with a test method that simulates the response the real CAS server
would give for the ticket we pass it.  Our fake method will print the requested
url for us to test against. 

    >>> def _urlopen(requrl):
    ...     print requrl
    ...     for field in requrl.split('?')[1].split('&'):
    ...         left, right = field.split('=')
    ...         if left == 'ticket':
    ...             ticket = right
    ...     if ticket == 'frog_ticket':
    ...         return 'yes\nfrog'
    ...     else:
    ...         return 'no\n'
    >>> plugin._urlopen = _urlopen
    
The first ticket we'll pass is a bad ticket.

    >>> request.form['ticket'] = 'bad_ticket'
    >>> principal = plugin.authenticate(request)
    https://fakecas.org/validate?ticket=bad_ticket&service=http%3A%2F%2F127.0.0
    >>> principal is None
    True

Next we'll pass a good ticket.

    >>> request.form['ticket'] = 'frog_ticket'
    >>> principal = plugin.authenticate(request)
    https://fakecas.org/validate?ticket=frog_ticket&service=http%3A%2F%2F127.0.0
    >>> principal.id
    'sb.person.frog'

Now that we've successfully authenticated, we don't even need a ticket to get
the principal as it has been successfully stored in the session.

    >>> request.form = {}
    >>> principal = plugin.authenticate(request)
    >>> principal.id
    'sb.person.frog'

Calling the unauthorized method
-------------------------------

Whenever a user tries to access a resourse that they are not permitted to,
schooltool calls the authentication plugin's unauthorized method.  It will
redirect us to the CAS server with the renew flag set to force the user to
authenticate.

    >>> request.form = {}
    >>> plugin.unauthorized('sb.person.frog', request)
    >>> request.response.getHeader('Location')
    'https://fakecas.org/login?renew=true&service=http%3A%2F%2F127.0.0.1'

If we have from values set, which would be the case if the user was filling
out a form at the time the tried to access an unauthorized link, unauthorized
will store the form data in the session and add the form_id to the service
part of the CAS redirect.

    >>> request.form = {'REQUEST_METHOD': 'POST', 'my_field': 'value'}
    >>> plugin.unauthorized('sb.person.frog', request)
    >>> request.response.getHeader('Location')
    'https://fakecas.org/login?renew=true&service=http%3A%2F%2F127.0.0.1%3F%26post_form%3Dform0'
    >>> sdc[sdc.keys()[0]]['schooltool.auth']['form0']
    {'REQUEST_METHOD': 'POST', 'my_field': 'value'}

Login/Logout
------------

The last thing we have to test are the view classes for logging in and out of
the CAS server.  These view classes are registered in the live configuration
to work off of our authentication plugin, just as the standard schooltool
login and logout views are registered against the standard schooltool plugin.

    >>> from schooltool.cas import LoginView, LogoutView

For the login view we will always be redirected to the CAS server's login page
with the 'renew' flag set that tells the server to force the user to enter
credentials.  There are three different cases that effect the value of the
service passed to the server.

The first case is when our user is already authenticated.  This is the case as
a result of our last test of the plugin's authenticate method.  The service
will be the user's logindisplatch view.

    >>> request.form = {}
    >>> LoginView(object(), request)()
    >>> request.response.getHeader('Location')
    'https://fakecas.org/login?renew=true&service=http%3A%2F%2F127.0.0.1%2Ffrogpond%2Fpersons%2Ffrog%2F%40%40logindispatch'
    
The second case is when out form has a 'nexturl' in it.  In that case the
service will be set to that.

    >>> request.form = {'nexturl': 'some_nexturl'}
    >>> LoginView(object(), request)()
    >>> request.response.getHeader('Location')
    'https://fakecas.org/login?renew=true&service=some_nexturl'

The third case is when there is no nexturl and no credentials saved.  In that
case the service will just be the application object.

    >>> plugin.clearCredentials(request)
    >>> request.form = {}
    >>> LoginView(object(), request)()
    >>> request.response.getHeader('Location')
    'https://fakecas.org/login?renew=true&service=http%3A%2F%2F127.0.0.1%2Ffrogpond'

The logout view has only one path.  It always redirects to the CAS server's
login page with the service set to the application object.

    >>> LoginView(object(), request)()
    >>> request.response.getHeader('Location')
    'https://fakecas.org/login?renew=true&service=http%3A%2F%2F127.0.0.1%2Ffrogpond'

Test Framework Teardown
-----------------------

    >>> setup.placefulTearDown()


