blob: fe37f8567f2d1e904a9a4ff2cda1f5f99f7639f7 [file] [log] [blame]
# -*- coding: utf-8 -*-
#
# Copyright (C) 2008-2013 Edgewall Software
# All rights reserved.
#
# This software is licensed as described in the file COPYING, which
# you should have received as part of this distribution. The terms
# are also available at http://trac.edgewall.org/wiki/TracLicense.
#
# This software consists of voluntary contributions made by many
# individuals. For the exact contribution history, see the revision
# history and logs, available at http://trac.edgewall.org/log/.
"""Object for creating and destroying a Trac environment for testing purposes.
Provides some Trac environment-wide utility functions, and a way to call
:command:`trac-admin` without it being on the path."""
import locale
import os
import re
import sys
import time
from subprocess import call, Popen, PIPE, STDOUT
from trac.env import open_environment
from trac.test import EnvironmentStub, get_dburi
from trac.tests.compat import rmtree
from trac.tests.functional import logfile, trac_source_tree
from trac.tests.functional.better_twill import tc, ConnectError
from trac.util import terminate
from trac.util.compat import close_fds, wait_for_file_mtime_change
from trac.util.text import to_utf8
try:
from configobj import ConfigObj
except ImportError:
ConfigObj = None
# TODO: refactor to support testing multiple frontends, backends
# (and maybe repositories and authentication).
#
# Frontends::
# tracd, ap2+mod_python, ap2+mod_wsgi, ap2+mod_fastcgi, ap2+cgi,
# lighty+fastcgi, lighty+cgi, cherrypy+wsgi
#
# Backends::
# sqlite3+pysqlite2, postgres+psycopg2 python bindings,
# mysql+mysqldb with server v4, mysql+mysqldb with server v5
# (those need to test search escaping, among many other things like long
# paths in browser and unicode chars being allowed/translating...)
class FunctionalTestEnvironment(object):
"""Common location for convenience functions that work with the test
environment on Trac. Subclass this and override some methods if you are
using a different :term:`VCS`.
:class:`FunctionalTestEnvironment` requires a `dirname` in which
the test repository and Trac environment will be created, `port`
for the :command:`tracd` webserver to run on, and the `url` which
can access this (usually ``localhost``).
"""
def __init__(self, dirname, port, url):
"""Create a :class:`FunctionalTestEnvironment`, see the class itself
for parameter information."""
self.trac_src = trac_source_tree
self.url = url
self.command_cwd = os.path.normpath(os.path.join(dirname, '..'))
self.dirname = os.path.abspath(dirname)
self.tracdir = os.path.join(self.dirname, "trac")
self.htpasswd = os.path.join(self.dirname, "htpasswd")
self.port = port
self.pid = None
self.init()
self.destroy()
time.sleep(0.1) # Avoid race condition on Windows
self.create()
locale.setlocale(locale.LC_ALL, '')
@property
def dburi(self):
dburi = get_dburi()
if dburi == 'sqlite::memory:':
# functional tests obviously can't work with the in-memory database
dburi = 'sqlite:db/trac.db'
return dburi
def destroy(self):
"""Remove all of the test environment data."""
env = EnvironmentStub(path=self.tracdir, destroying=True)
env.destroy_db()
env.shutdown()
self.destroy_repo()
if os.path.exists(self.dirname):
rmtree(self.dirname)
repotype = 'svn'
def init(self):
""" Hook for modifying settings or class attributes before
any methods are called. """
pass
def create_repo(self):
"""Hook for creating the repository."""
# The default test environment does not include a source repo
def destroy_repo(self):
"""Hook for removing the repository."""
# The default test environment does not include a source repo
def post_create(self, env):
"""Hook for modifying the environment after creation. For example, to
set configuration like:
::
def post_create(self, env):
env.config.set('git', 'path', '/usr/bin/git')
env.config.save()
"""
pass
def get_enabled_components(self):
"""Return a list of components that should be enabled after
environment creation. For anything more complicated, use the
:meth:`post_create` method.
"""
return ['tracopt.versioncontrol.svn.*']
def create(self):
"""Create a new test environment.
This sets up Trac, calls :meth:`create_repo` and sets up
authentication.
"""
os.mkdir(self.dirname)
self.create_repo()
self._tracadmin('initenv', self.tracdir, self.dburi, self.repotype,
self.repo_path_for_initenv())
if call([sys.executable,
os.path.join(self.trac_src, 'contrib', 'htpasswd.py'), "-c",
"-b", self.htpasswd, "admin", "admin"], close_fds=close_fds,
cwd=self.command_cwd):
raise Exception('Unable to setup admin password')
self.adduser('user')
self.adduser('joe')
self.grant_perm('admin', 'TRAC_ADMIN')
# Setup Trac logging
env = self.get_trac_environment()
env.config.set('logging', 'log_type', 'file')
for component in self.get_enabled_components():
env.config.set('components', component, 'enabled')
env.config.save()
self.post_create(env)
def adduser(self, user):
"""Add a user to the environment. The password will be set to the
same as username."""
user = to_utf8(user)
if call([sys.executable, os.path.join(self.trac_src, 'contrib',
'htpasswd.py'), '-b', self.htpasswd,
user, user], close_fds=close_fds, cwd=self.command_cwd):
raise Exception('Unable to setup password for user "%s"' % user)
def deluser(self, user):
"""Delete a user from the environment."""
user = to_utf8(user)
self._tracadmin('session', 'delete', user)
if call([sys.executable, os.path.join(self.trac_src, 'contrib',
'htpasswd.py'), '-D', self.htpasswd, user],
close_fds=close_fds, cwd=self.command_cwd):
raise Exception('Unable to remove password for user "%s"' % user)
def grant_perm(self, user, perm):
"""Grant permission(s) to specified user. A single permission may
be specified as a string, or multiple permissions may be
specified as a list or tuple of strings."""
if isinstance(perm, (list, tuple)):
self._tracadmin('permission', 'add', user, *perm)
else:
self._tracadmin('permission', 'add', user, perm)
# We need to force an environment reset, as this is necessary
# for the permission change to take effect: grant only
# invalidates the `DefaultPermissionStore._all_permissions`
# cache, but the `DefaultPermissionPolicy.permission_cache` is
# unaffected.
self.get_trac_environment().config.touch()
def revoke_perm(self, user, perm):
"""Revoke permission(s) from specified user. A single permission
may be specified as a string, or multiple permissions may be
specified as a list or tuple of strings."""
if isinstance(perm, (list, tuple)):
self._tracadmin('permission', 'remove', user, *perm)
else:
self._tracadmin('permission', 'remove', user, perm)
# Force an environment reset (see grant_perm above)
self.get_trac_environment().config.touch()
def set_config(self, *args):
"""Calls trac-admin to get the value for the given option
in `trac.ini`."""
self._tracadmin('config', 'set', *args)
def get_config(self, *args):
"""Calls trac-admin to set the value for the given option
in `trac.ini`."""
return self._tracadmin('config', 'get', *args)
def remove_config(self, *args):
"""Calls trac-admin to remove the value for the given option
in `trac.ini`."""
return self._tracadmin('config', 'remove', *args)
def _tracadmin(self, *args):
"""Internal utility method for calling trac-admin"""
proc = Popen([sys.executable, os.path.join(self.trac_src, 'trac',
'admin', 'console.py'), self.tracdir],
stdin=PIPE, stdout=PIPE, stderr=STDOUT,
close_fds=close_fds, cwd=self.command_cwd)
if args:
if any('\n' in arg for arg in args):
raise Exception(
"trac-admin in interactive mode doesn't support "
"arguments with newline characters: %r" % (args,))
# Don't quote first token which is sub-command name
input = ' '.join(('"%s"' % to_utf8(arg) if idx else arg)
for idx, arg in enumerate(args))
else:
input = None
out = proc.communicate(input=input)[0]
if proc.returncode:
print(out)
logfile.write(out)
raise Exception("Failed while running trac-admin with arguments %r.\n"
"Exitcode: %s \n%s"
% (args, proc.returncode, out))
else:
# trac-admin is started in interactive mode, so we strip away
# everything up to the to the interactive prompt
return re.split(r'\r?\nTrac \[[^]]+\]> ', out, 2)[1]
def start(self):
"""Starts the webserver, and waits for it to come up."""
if 'FIGLEAF' in os.environ:
exe = os.environ['FIGLEAF']
if ' ' in exe: # e.g. 'coverage run'
args = exe.split()
else:
args = [exe]
else:
args = [sys.executable]
options = ["--port=%s" % self.port, "-s", "--hostname=127.0.0.1",
"--basic-auth=trac,%s," % self.htpasswd]
if 'TRAC_TEST_TRACD_OPTIONS' in os.environ:
options += os.environ['TRAC_TEST_TRACD_OPTIONS'].split()
args.append(os.path.join(self.trac_src, 'trac', 'web',
'standalone.py'))
server = Popen(args + options + [self.tracdir],
stdout=logfile, stderr=logfile,
close_fds=close_fds,
cwd=self.command_cwd)
self.pid = server.pid
# Verify that the url is ok
timeout = 30
while timeout:
try:
tc.go(self.url)
break
except ConnectError:
time.sleep(1)
timeout -= 1
else:
raise Exception('Timed out waiting for server to start.')
tc.url(self.url)
def stop(self):
"""Stops the webserver, if running
FIXME: probably needs a nicer way to exit for coverage to work
"""
if self.pid:
terminate(self)
def restart(self):
"""Restarts the webserver"""
self.stop()
self.start()
def get_trac_environment(self):
"""Returns a Trac environment object"""
return open_environment(self.tracdir, use_cache=True)
def repo_path_for_initenv(self):
"""Default to no repository"""
return "''" # needed for Python 2.3 and 2.4 on win32
def call_in_dir(self, dir, args, environ=None):
proc = Popen(args, stdout=PIPE, stderr=logfile,
close_fds=close_fds, cwd=dir, env=environ)
(data, _) = proc.communicate()
if proc.wait():
raise Exception('Unable to run command %s in %s' %
(args, dir))
logfile.write(data)
return data
def enable_authz_permpolicy(self, authz_content, filename=None):
"""Enables the Authz permissions policy. The `authz_content` will
be written to `filename`, and may be specified in a triple-quoted
string.::
[wiki:WikiStart@*]
* = WIKI_VIEW
[wiki:PrivatePage@*]
john = WIKI_VIEW
* = !WIKI_VIEW
`authz_content` may also be a dictionary of dictionaries specifying
the sections and key/value pairs of each section, however this form
should only be used when the order of the entries in the file is not
important, as the order cannot be known.::
{
'wiki:WikiStart@*': {'*': 'WIKI_VIEW'},
'wiki:PrivatePage@*': {'john': 'WIKI_VIEW', '*': '!WIKI_VIEW'},
}
The `filename` parameter is optional, and if omitted a filename will
be generated by computing a hash of `authz_content`, prefixed with
"authz-".
"""
if not ConfigObj:
raise ImportError("Can't enable authz permissions policy. " +
"ConfigObj not installed.")
if filename is None:
from hashlib import md5
filename = 'authz-' + md5(str(authz_content)).hexdigest()[0:9]
env = self.get_trac_environment()
permission_policies = env.config.get('trac', 'permission_policies')
env.config.set('trac', 'permission_policies',
'AuthzPolicy, ' + permission_policies)
authz_file = self.tracdir + '/conf/' + filename
if isinstance(authz_content, basestring):
authz_content = [line.strip() for line in
authz_content.strip().splitlines()]
authz_config = ConfigObj(authz_content, encoding='utf8',
write_empty_values=True, indent_type='')
authz_config.filename = authz_file
wait_for_file_mtime_change(authz_file)
authz_config.write()
env.config.set('authz_policy', 'authz_file', authz_file)
env.config.set('components', 'tracopt.perm.authz_policy.*', 'enabled')
env.config.save()
def disable_authz_permpolicy(self):
"""Disables the Authz permission policy."""
env = self.get_trac_environment()
permission_policies = env.config.get('trac', 'permission_policies')
pp_list = [p.strip() for p in permission_policies.split(',')]
if 'AuthzPolicy' in pp_list:
pp_list.remove('AuthzPolicy')
permission_policies = ', '.join(pp_list)
env.config.set('trac', 'permission_policies', permission_policies)
env.config.remove('authz_policy', 'authz_file')
env.config.remove('components', 'tracopt.perm.authz_policy.*')
env.config.save()