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