1# -*- coding: utf-8 -*- 2# 3# Copyright (C) 2008-2021 Edgewall Software 4# All rights reserved. 5# 6# This software is licensed as described in the file COPYING, which 7# you should have received as part of this distribution. The terms 8# are also available at https://trac.edgewall.org/wiki/TracLicense. 9# 10# This software consists of voluntary contributions made by many 11# individuals. For the exact contribution history, see the revision 12# history and logs, available at https://trac.edgewall.org/log/. 13 14"""Object for creating and destroying a Trac environment for testing purposes. 15Provides some Trac environment-wide utility functions, and a way to call 16:command:`trac-admin` without it being on the path.""" 17 18import contextlib 19import hashlib 20import io 21import locale 22import os 23import re 24import sys 25import time 26from subprocess import call, run, Popen, DEVNULL, PIPE, STDOUT 27 28from trac.admin.api import AdminCommandManager 29from trac.config import Configuration, ConfigurationAdmin, UnicodeConfigParser 30from trac.db.api import DatabaseManager 31from trac.env import open_environment 32from trac.perm import PermissionAdmin 33from trac.test import EnvironmentStub, get_dburi, rmtree 34from trac.tests.contentgen import random_unique_camel 35from trac.tests.functional import trac_source_tree 36from trac.tests.functional.better_twill import tc, ConnectError 37from trac.util import create_file, terminate 38from trac.util.compat import close_fds, wait_for_file_mtime_change 39 40# TODO: refactor to support testing multiple frontends, backends 41# (and maybe repositories and authentication). 42# 43# Frontends:: 44# tracd, ap2+mod_python, ap2+mod_wsgi, ap2+mod_fastcgi, ap2+cgi, 45# lighty+fastcgi, lighty+cgi, cherrypy+wsgi 46# 47# Backends:: 48# sqlite3+pysqlite2, postgres+psycopg2 python bindings, 49# mysql+pymysql with server v5 50# (those need to test search escaping, among many other things like long 51# paths in browser and unicode chars being allowed/translating...) 52 53 54class FunctionalTestEnvironment(object): 55 """Common location for convenience functions that work with the test 56 environment on Trac. Subclass this and override some methods if you are 57 using a different :term:`VCS`. 58 59 :class:`FunctionalTestEnvironment` requires a `dirname` in which 60 the test repository and Trac environment will be created, `port` 61 for the :command:`tracd` webserver to run on, and the `url` which 62 can access this (usually ``localhost``). 63 """ 64 65 def __init__(self, dirname, port, url): 66 """Create a :class:`FunctionalTestEnvironment`, see the class itself 67 for parameter information.""" 68 self.trac_src = trac_source_tree 69 self.url = url 70 self.command_cwd = os.path.normpath(os.path.join(dirname, '..')) 71 self.dirname = os.path.abspath(dirname) 72 self.tracdir = os.path.join(self.dirname, "trac") 73 self.htpasswd = os.path.join(self.dirname, "htpasswd") 74 self.port = port 75 self.server = None 76 self.logfile = None 77 self.init() 78 self.destroy() 79 time.sleep(0.1) # Avoid race condition on Windows 80 self.create() 81 locale.setlocale(locale.LC_ALL, '') 82 83 @property 84 def dburi(self): 85 dburi = get_dburi() 86 if dburi == 'sqlite::memory:': 87 # functional tests obviously can't work with the in-memory database 88 dburi = 'sqlite:db/trac.db' 89 return dburi 90 91 def destroy(self): 92 """Remove all of the test environment data.""" 93 env = EnvironmentStub(path=self.tracdir, destroying=True) 94 env.destroy_db() 95 96 self.destroy_repo() 97 if os.path.exists(self.dirname): 98 rmtree(self.dirname) 99 100 repotype = 'svn' 101 102 def init(self): 103 """ Hook for modifying settings or class attributes before 104 any methods are called. """ 105 pass 106 107 def create_repo(self): 108 """Hook for creating the repository.""" 109 # The default test environment does not include a source repo 110 111 def destroy_repo(self): 112 """Hook for removing the repository.""" 113 # The default test environment does not include a source repo 114 115 def post_create(self, env): 116 """Hook for modifying the environment after creation. For example, to 117 set configuration like: 118 :: 119 120 def post_create(self, env): 121 env.config.set('git', 'path', '/usr/bin/git') 122 env.config.save() 123 124 """ 125 pass 126 127 def get_enabled_components(self): 128 """Return a list of components that should be enabled after 129 environment creation. For anything more complicated, use the 130 :meth:`post_create` method. 131 """ 132 return ['tracopt.versioncontrol.svn.*'] 133 134 def create(self): 135 """Create a new test environment. 136 This sets up Trac, calls :meth:`create_repo` and sets up 137 authentication. 138 """ 139 os.mkdir(self.dirname) 140 # testing.log gets any unused output from subprocesses 141 self.logfile = open(os.path.join(self.dirname, 'testing.log'), 'wb', 142 buffering=0) 143 self.create_repo() 144 145 config_file = os.path.join(self.dirname, 'config.ini') 146 config = Configuration(config_file) 147 repo_path = self.repo_path_for_initenv() 148 if repo_path: 149 config.set('repositories', '.dir', repo_path) 150 config.set('repositories', '.type', self.repotype) 151 for component in self.get_enabled_components(): 152 config.set('components', component, 'enabled') 153 config.save() 154 self._tracadmin('initenv', self.tracdir, self.dburi, 155 '--config=%s' % config_file) 156 if call([sys.executable, '-m', 'contrib.htpasswd', '-c', '-b', 157 self.htpasswd, 'admin', 'admin'], 158 close_fds=close_fds, cwd=self.command_cwd): 159 raise Exception("Unable to setup admin password") 160 self.adduser('user') 161 self.adduser('joe') 162 self.grant_perm('admin', 'TRAC_ADMIN') 163 env = self.get_trac_environment() 164 self.post_create(env) 165 166 def close(self): 167 self.stop() 168 if self.logfile: 169 self.logfile.close() 170 self.logfile = None 171 172 def adduser(self, user): 173 """Add a user to the environment. The password will be set to the 174 same as username.""" 175 self._tracadmin('session', 'add', user) 176 if call([sys.executable, '-m', 'contrib.htpasswd', '-b', 177 self.htpasswd, user, user], 178 close_fds=close_fds, cwd=self.command_cwd): 179 raise Exception('Unable to setup password for user "%s"' % user) 180 181 def deluser(self, user): 182 """Delete a user from the environment.""" 183 self._tracadmin('session', 'delete', user) 184 if call([sys.executable, '-m', 'contrib.htpasswd', '-D', 185 self.htpasswd, user], 186 close_fds=close_fds, cwd=self.command_cwd): 187 raise Exception('Unable to remove password for user "%s"' % user) 188 189 def grant_perm(self, user, perm): 190 """Grant permission(s) to specified user. A single permission may 191 be specified as a string, or multiple permissions may be 192 specified as a list or tuple of strings.""" 193 env = self.get_trac_environment() 194 if isinstance(perm, (list, tuple)): 195 PermissionAdmin(env)._do_add(user, *perm) 196 else: 197 PermissionAdmin(env)._do_add(user, perm) 198 # We need to force an environment reset, as this is necessary 199 # for the permission change to take effect: grant only 200 # invalidates the `DefaultPermissionStore._all_permissions` 201 # cache, but the `DefaultPermissionPolicy.permission_cache` is 202 # unaffected. 203 env.config.touch() 204 205 def revoke_perm(self, user, perm): 206 """Revoke permission(s) from specified user. A single permission 207 may be specified as a string, or multiple permissions may be 208 specified as a list or tuple of strings.""" 209 env = self.get_trac_environment() 210 if isinstance(perm, (list, tuple)): 211 PermissionAdmin(env)._do_remove(user, *perm) 212 else: 213 PermissionAdmin(env)._do_remove(user, perm) 214 # Force an environment reset (see grant_perm above) 215 env.config.touch() 216 217 def set_config(self, *args): 218 """Calls trac-admin to set the value for the given option 219 in `trac.ini`.""" 220 self._execute_command('config', 'set', *args) 221 222 def get_config(self, *args): 223 """Calls trac-admin to get the value for the given option 224 in `trac.ini`.""" 225 out = io.StringIO() 226 with contextlib.redirect_stdout(out): 227 self._execute_command('config', 'get', *args) 228 return out.getvalue() 229 230 def remove_config(self, *args): 231 """Calls trac-admin to remove the value for the given option 232 in `trac.ini`.""" 233 self._execute_command('config', 'remove', *args) 234 235 def add_milestone(self, name=None, due=None): 236 return self._add_ticket_field_value('milestone', name, due) 237 238 def add_component(self, name=None, owner=None): 239 return self._add_ticket_field_value('component', name, owner) 240 241 def add_version(self, name=None, time=None): 242 return self._add_ticket_field_value('version', name, time) 243 244 def add_severity(self, name=None): 245 return self._add_ticket_field_value('severity', name) 246 247 def add_priority(self, name=None): 248 return self._add_ticket_field_value('priority', name) 249 250 def add_resolution(self, name=None): 251 return self._add_ticket_field_value('resolution', name) 252 253 def add_ticket_type(self, name=None): 254 return self._add_ticket_field_value('ticket_type', name) 255 256 def _add_ticket_field_value(self, field, name, *args): 257 if name is None: 258 name = random_unique_camel() 259 self._execute_command(field, 'add', name, *args) 260 return name 261 262 def _tracadmin(self, *args): 263 """Internal utility method for calling trac-admin""" 264 proc = run([sys.executable, '-m', 'trac.admin.console', 265 self.tracdir] + list(args), 266 stdin=DEVNULL, stdout=PIPE, stderr=STDOUT, 267 close_fds=close_fds, cwd=self.command_cwd) 268 if proc.stderr: 269 self.logfile.write(proc.stderr) 270 out = str(proc.stdout, 'utf-8') 271 if proc.returncode: 272 print(out) 273 raise Exception("Failed while running trac-admin with arguments " 274 "%r.\nExitcode: %s \n%s" 275 % (args, proc.returncode, proc.stderr)) 276 else: 277 return out 278 279 def _execute_command(self, *args): 280 env = self.get_trac_environment() 281 AdminCommandManager(env).execute_command(*args) 282 283 def start(self): 284 """Starts the webserver, and waits for it to come up.""" 285 args = [sys.executable, '-m', 'trac.web.standalone'] 286 options = ["--port=%s" % self.port, "-s", "--hostname=127.0.0.1", 287 "--basic-auth=trac,%s," % self.htpasswd] 288 if 'TRAC_TEST_TRACD_OPTIONS' in os.environ: 289 options += os.environ['TRAC_TEST_TRACD_OPTIONS'].split() 290 self.server = Popen(args + options + [self.tracdir], 291 stdout=self.logfile, stderr=self.logfile, 292 close_fds=close_fds, 293 cwd=self.command_cwd) 294 # Verify that the url is ok 295 timeout = 30 296 while timeout: 297 try: 298 tc.go(self.url) 299 break 300 except ConnectError: 301 time.sleep(1) 302 timeout -= 1 303 else: 304 raise Exception('Timed out waiting for server to start.') 305 tc.url(self.url, regexp=False) 306 307 def stop(self): 308 """Stops the webserver, if running 309 310 FIXME: probably needs a nicer way to exit for coverage to work 311 """ 312 if self.server: 313 terminate(self.server.pid) 314 self.server.wait() 315 self.server = None 316 317 def restart(self): 318 """Restarts the webserver""" 319 self.stop() 320 self.start() 321 322 def get_trac_environment(self): 323 """Returns a Trac environment object""" 324 return open_environment(self.tracdir, use_cache=True) 325 326 def repo_path_for_initenv(self): 327 """Default to no repository""" 328 return None 329 330 def call_in_dir(self, dir, args, environ=None): 331 proc = Popen(args, stdout=PIPE, stderr=self.logfile, 332 close_fds=close_fds, cwd=dir, env=environ) 333 (data, _) = proc.communicate() 334 if proc.wait(): 335 raise Exception('Unable to run command %s in %s' % 336 (args, dir)) 337 self.logfile.write(data) 338 return data 339 340 def enable_authz_permpolicy(self, authz_content, filename=None): 341 """Enables the Authz permissions policy. The `authz_content` will 342 be written to `filename`, and may be specified in a triple-quoted 343 string.:: 344 345 [wiki:WikiStart@*] 346 * = WIKI_VIEW 347 [wiki:PrivatePage@*] 348 john = WIKI_VIEW 349 * = !WIKI_VIEW 350 351 `authz_content` may also be a dictionary of dictionaries specifying 352 the sections and key/value pairs of each section, however this form 353 should only be used when the order of the entries in the file is not 354 important, as the order cannot be known.:: 355 356 { 357 'wiki:WikiStart@*': {'*': 'WIKI_VIEW'}, 358 'wiki:PrivatePage@*': {'john': 'WIKI_VIEW', '*': '!WIKI_VIEW'}, 359 } 360 361 The `filename` parameter is optional, and if omitted a filename will 362 be generated by computing a hash of `authz_content`, prefixed with 363 "authz-". 364 """ 365 if filename is None: 366 filename = 'authz-' + hashlib.md5(repr(authz_content). 367 encode('utf-8')).hexdigest()[:9] 368 env = self.get_trac_environment() 369 authz_file = os.path.join(env.conf_dir, filename) 370 if os.path.exists(authz_file): 371 wait_for_file_mtime_change(authz_file) 372 if isinstance(authz_content, str): 373 authz_content = [line.strip() + '\n' 374 for line in authz_content.strip().splitlines()] 375 authz_content = ['# -*- coding: utf-8 -*-\n'] + authz_content 376 create_file(authz_file, authz_content) 377 else: 378 parser = UnicodeConfigParser() 379 for section, options in authz_content.items(): 380 parser.add_section(section) 381 for key, value in options.items(): 382 parser.set(section, key, value) 383 with open(authz_file, 'w', encoding='utf-8') as f: 384 parser.write(f) 385 permission_policies = env.config.get('trac', 'permission_policies') 386 env.config.set('trac', 'permission_policies', 387 'AuthzPolicy, ' + permission_policies) 388 env.config.set('authz_policy', 'authz_file', authz_file) 389 env.config.set('components', 'tracopt.perm.authz_policy.*', 'enabled') 390 env.config.save() 391 392 def disable_authz_permpolicy(self): 393 """Disables the Authz permission policy.""" 394 env = self.get_trac_environment() 395 permission_policies = env.config.get('trac', 'permission_policies') 396 pp_list = [p.strip() for p in permission_policies.split(',')] 397 if 'AuthzPolicy' in pp_list: 398 pp_list.remove('AuthzPolicy') 399 permission_policies = ', '.join(pp_list) 400 env.config.set('trac', 'permission_policies', permission_policies) 401 env.config.remove('authz_policy', 'authz_file') 402 env.config.remove('components', 'tracopt.perm.authz_policy.*') 403 env.config.save() 404