1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2014-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
14import os
15import tempfile
16import textwrap
17import unittest
18
19from trac.config import ConfigurationError
20from trac.perm import PermissionSystem
21from trac.test import EnvironmentStub, MockRequest
22from trac.ticket.api import TicketSystem
23from trac.ticket.batch import BatchModifyModule
24from trac.ticket.default_workflow import ConfigurableTicketWorkflow
25from trac.ticket.model import Component, Ticket
26from trac.ticket.test import insert_ticket
27from trac.ticket.web_ui import TicketModule
28from trac.util import create_file
29from trac.util.datefmt import to_utimestamp
30from trac.web.api import RequestDone
31from tracopt.perm.authz_policy import AuthzPolicy
32
33
34class ConfigurableTicketWorkflowTestCase(unittest.TestCase):
35
36    def setUp(self):
37        self.env = EnvironmentStub()
38        config = self.env.config
39        config.set('ticket-workflow', 'change_owner', 'new -> new')
40        config.set('ticket-workflow', 'change_owner.operations', 'set_owner')
41        self.ctlr = TicketSystem(self.env).action_controllers[0]
42        self.ticket_module = TicketModule(self.env)
43
44    def tearDown(self):
45        self.env.reset_db()
46
47    def _add_component(self, name='test', owner='owner1'):
48        component = Component(self.env)
49        component.name = name
50        component.owner = owner
51        component.insert()
52
53    def _reload_workflow(self):
54        self.ctlr.actions = self.ctlr.get_all_actions()
55
56    def test_get_all_actions_custom_attribute(self):
57        """Custom attribute in ticket-workflow."""
58        config = self.env.config['ticket-workflow']
59        config.set('resolve.set_milestone', 'reject')
60        all_actions = self.ctlr.get_all_actions()
61
62        resolve_action = None
63        for name, attrs in all_actions.items():
64            if name == 'resolve':
65                resolve_action = attrs
66
67        self.assertIsNotNone(resolve_action)
68        self.assertIn('set_milestone', list(resolve_action))
69        self.assertEqual('reject', resolve_action['set_milestone'])
70
71    def test_owner_from_component(self):
72        """Verify that the owner of a new ticket is set to the owner
73        of the component.
74        """
75        self._add_component('component3', 'cowner3')
76
77        req = MockRequest(self.env, method='POST', args={
78            'field_reporter': 'reporter1',
79            'field_summary': 'the summary',
80            'field_component': 'component3',
81        })
82        self.assertRaises(RequestDone, self.ticket_module.process_request, req)
83        ticket = Ticket(self.env, 1)
84
85        self.assertEqual('component3', ticket['component'])
86        self.assertEqual('cowner3', ticket['owner'])
87
88    def test_component_change(self):
89        """New ticket owner is updated when the component is changed.
90        """
91        self._add_component('component3', 'cowner3')
92        self._add_component('component4', 'cowner4')
93
94        ticket = insert_ticket(self.env, reporter='reporter1',
95                               summary='the summary', component='component3',
96                               owner='cowner3', status='new')
97
98        req = MockRequest(self.env, method='POST', args={
99            'id': ticket.id,
100            'field_component': 'component4',
101            'submit': True,
102            'action': 'leave',
103            'view_time': str(to_utimestamp(ticket['changetime'])),
104        })
105        self.assertRaises(RequestDone, self.ticket_module.process_request, req)
106        ticket = Ticket(self.env, ticket.id)
107
108        self.assertEqual('component4', ticket['component'])
109        self.assertEqual('cowner4', ticket['owner'])
110
111    def test_component_change_and_owner_change(self):
112        """New ticket owner is not updated if owner is explicitly
113        changed.
114        """
115        self._add_component('component3', 'cowner3')
116        self._add_component('component4', 'cowner4')
117
118        ticket = insert_ticket(self.env, reporter='reporter1',
119                               summary='the summary', component='component3',
120                               status='new')
121
122        req = MockRequest(self.env, method='POST', args={
123            'id': ticket.id,
124            'field_component': 'component4',
125            'submit': True,
126            'action': 'change_owner',
127            'action_change_owner_reassign_owner': 'owner1',
128            'view_time': str(to_utimestamp(ticket['changetime'])),
129        })
130        self.assertRaises(RequestDone, self.ticket_module.process_request, req)
131        ticket = Ticket(self.env, ticket.id)
132
133        self.assertEqual('component4', ticket['component'])
134        self.assertEqual('owner1', ticket['owner'])
135
136    def test_old_owner_not_old_component_owner(self):
137        """New ticket owner is not updated if old owner is not the owner
138        of the old component.
139        """
140        self._add_component('component3', 'cowner3')
141        self._add_component('component4', 'cowner4')
142
143        ticket = insert_ticket(self.env, reporter='reporter1',
144                               summary='the summary', component='component3',
145                               owner='owner1', status='new')
146
147        req = MockRequest(self.env, method='POST', args={
148            'id': ticket.id,
149            'field_component': 'component4',
150            'submit': True,
151            'action': 'leave',
152            'view_time': str(to_utimestamp(ticket['changetime'])),
153        })
154        self.assertRaises(RequestDone, self.ticket_module.process_request, req)
155        ticket = Ticket(self.env, ticket.id)
156
157        self.assertEqual('component4', ticket['component'])
158        self.assertEqual('owner1', ticket['owner'])
159
160    def test_new_component_has_no_owner(self):
161        """Ticket is not disowned when the component is changed to a
162        component with no owner.
163        """
164        self._add_component('component3', 'cowner3')
165        self._add_component('component4', '')
166
167        ticket = insert_ticket(self.env, reporter='reporter1',
168                               summary='the summary', component='component3',
169                               owner='cowner3', status='new')
170
171        req = MockRequest(self.env, method='POST', args={
172            'id': ticket.id,
173            'field_component': 'component4',
174            'submit': True,
175            'action': 'leave',
176            'view_time': str(to_utimestamp(ticket['changetime'])),
177        })
178        self.assertRaises(RequestDone, self.ticket_module.process_request, req)
179        ticket = Ticket(self.env, ticket.id)
180
181        self.assertEqual('component4', ticket['component'])
182        self.assertEqual('cowner3', ticket['owner'])
183
184    def _test_get_allowed_owners(self):
185        ticket = insert_ticket(self.env, summary='Ticket 1')
186        self.env.insert_users([('user1', None, None, 1),
187                               ('user2', None, None, 1),
188                               ('user3', None, None, 1)])
189        ps = PermissionSystem(self.env)
190        for user in ('user1', 'user3'):
191            ps.grant_permission(user, 'TICKET_MODIFY')
192        self.env.config.set('ticket', 'restrict_owner', True)
193        return ticket
194
195    def test_get_allowed_owners_returns_set_owner_list(self):
196        """Users specified in `set_owner` for the action are returned."""
197        req = None
198        action = {'set_owner': ['user4', 'user5']}
199        ticket = self._test_get_allowed_owners()
200        self.assertEqual(['user4', 'user5'],
201                         self.ctlr.get_allowed_owners(req, ticket, action))
202
203    def test_get_allowed_owners_returns_user_with_ticket_modify(self):
204        """Users with TICKET_MODIFY are are returned if `set_owner` is
205        not specified for the action.
206        """
207        req = None
208        action = {}
209        ticket = self._test_get_allowed_owners()
210        self.assertEqual(['user1', 'user3'],
211                         self.ctlr.get_allowed_owners(req, ticket, action))
212
213    def test_status_change_with_operation(self):
214        """Status change with operation."""
215        ticket = Ticket(self.env)
216        ticket['new'] = 'status1'
217        ticket['owner'] = 'user1'
218        ticket.insert()
219        req = MockRequest(self.env, path_info='/ticket', authname='user2',
220                          method='POST')
221
222        label, control, hints = \
223            self.ctlr.render_ticket_action_control(req, ticket, 'accept')
224
225        self.assertEqual('accept', label)
226        self.assertEqual('', str(control))
227        self.assertEqual("The owner will be <span class=\"trac-author-user\">"
228                         "user2</span>. The status will be 'accepted'.",
229                         str(hints))
230
231    def test_status_change_with_no_operation(self):
232        """Existing ticket status change with no operation."""
233        config = self.env.config
234        config.set('ticket-workflow', 'change_status', 'status1 -> status2')
235        self._reload_workflow()
236        ticket = Ticket(self.env)
237        ticket['status'] = 'status1'
238        ticket.insert()
239        req = MockRequest(self.env, path_info='/ticket', method='POST')
240
241        label, control, hints = \
242            self.ctlr.render_ticket_action_control(req, ticket,
243                                                   'change_status')
244
245        self.assertEqual('change status', label)
246        self.assertEqual('', str(control))
247        self.assertEqual("Next status will be 'status2'.", str(hints))
248
249    def test_new_ticket_status_change_with_no_operation(self):
250        """New ticket status change with no operation."""
251        config = self.env.config
252        config.set('ticket-workflow', 'change_status', '<none> -> status1')
253        self._reload_workflow()
254        ticket = Ticket(self.env)
255        req = MockRequest(self.env, path_info='/newticket', method='POST')
256
257        label, control, hints = \
258            self.ctlr.render_ticket_action_control(req, ticket,
259                                                   'change_status')
260
261        self.assertEqual('change status', label)
262        self.assertEqual('', str(control))
263        self.assertEqual("The status will be 'status1'.", str(hints))
264
265    def test_operation_with_no_status_change(self):
266        """Operation with no status change."""
267        config = self.env.config
268        config.set('ticket-workflow', 'change_owner', 'closed -> closed')
269        config.set('ticket-workflow', 'change_owner.operations', 'set_owner')
270
271        self._reload_workflow()
272        ticket = Ticket(self.env)
273        ticket['status'] = 'closed'
274        ticket['owner'] = 'user2'
275        ticket.insert()
276        req = MockRequest(self.env, path_info='/ticket', method='POST',
277                          authname='user1')
278
279        label, control, hints = \
280            self.ctlr.render_ticket_action_control(req, ticket,
281                                                   'change_owner')
282
283        self.assertEqual('change owner', label)
284        self.assertEqual(
285            'to <input id="action_change_owner_reassign_owner" '
286            'name="action_change_owner_reassign_owner" type="text" '
287            'value="user1" />', str(control))
288        self.assertEqual(
289            'The owner will be changed from <span class="trac-author">'
290            'user2</span> to the specified user.', str(hints))
291
292    def test_transition_to_star(self):
293        """Action not rendered by CTW for transition to *
294
295        AdvancedTicketWorkflow uses the behavior for the triage operation
296        (see #12823)
297        """
298        config = self.env.config
299        config.set('ticket-workflow', 'create_and_triage', '<none> -> *')
300        config.set('ticket-workflow', 'create_and_triage.operations', 'triage')
301        self._reload_workflow()
302        ticket = Ticket(self.env)
303        req = MockRequest(self.env, path_info='/newticket', method='POST')
304
305        actions = self.ctlr.get_ticket_actions(req, ticket)
306
307        # create_and_triage not in actions
308        self.assertEqual({(1, 'create'), (0, 'create_and_assign')},
309                         set(actions))
310
311    def test_transition_to_star_with_leave_operation(self):
312        """Action is rendered by CTW for transition to * with leave_status
313        """
314        config = self.env.config
315        config.set('ticket-workflow', 'change_owner', 'assigned,closed -> *')
316        config.set('ticket-workflow', 'change_owner.operations',
317                   'leave_status,set_owner')
318        self._reload_workflow()
319        status = ['assigned', 'closed']
320        for s in status:
321            ticket = Ticket(self.env)
322            ticket['status'] = s
323            ticket['owner'] = 'user2'
324            ticket.insert()
325            req = MockRequest(self.env, path_info='/ticket', method='POST',
326                              authname='user1')
327
328            label, control, hints = \
329                self.ctlr.render_ticket_action_control(req, ticket,
330                                                       'change_owner')
331            self.assertEqual('change owner', label)
332            self.assertEqual(
333                'to <input id="action_change_owner_reassign_owner" '
334                'name="action_change_owner_reassign_owner" type="text" '
335                'value="user1" />', str(control))
336            self.assertEqual(
337                'The owner will be changed from <span class="trac-author">'
338                'user2</span> to the specified user.', str(hints))
339
340    def test_leave_operation(self):
341        ticket = Ticket(self.env)
342        ticket['status'] = 'assigned'
343        ticket['owner'] = 'user2'
344        ticket.insert()
345        req = MockRequest(self.env, path_info='/ticket', method='POST',
346                          authname='user1')
347
348        label, control, hints = \
349            self.ctlr.render_ticket_action_control(req, ticket, 'leave')
350
351        self.assertEqual('leave', label)
352        self.assertEqual('as assigned', str(control))
353        self.assertEqual('The owner will remain <span class="trac-author">'
354                         'user2</span>.', str(hints))
355
356    def test_get_actions_by_operation_for_req(self):
357        """Request with no permission checking."""
358        req = MockRequest(self.env, path_info='/ticket/1')
359        ticket = insert_ticket(self.env, status='new')
360        actions = self.ctlr.get_actions_by_operation_for_req(req, ticket,
361                                                             'set_owner')
362        self.assertEqual([(0, 'change_owner'), (0, 'reassign')],
363                         sorted(actions))
364
365    def test_get_actions_by_operation_for_req_with_ticket_modify(self):
366        """User without TICKET_MODIFY won't have reassign action."""
367        req = MockRequest(self.env, authname='user1', path_info='/ticket/1')
368        ticket = insert_ticket(self.env, status='new')
369        actions = self.ctlr.get_actions_by_operation_for_req(req, ticket,
370                                                             'set_owner')
371        self.assertEqual([(0, 'change_owner')], sorted(actions))
372
373    def test_get_actions_by_operation_for_req_without_ticket_modify(self):
374        """User with TICKET_MODIFY will have reassign action."""
375        PermissionSystem(self.env).grant_permission('user1', 'TICKET_MODIFY')
376        req = MockRequest(self.env, authname='user1', path_info='/ticket/1')
377        ticket = insert_ticket(self.env, status='new')
378        actions = self.ctlr.get_actions_by_operation_for_req(req, ticket,
379                                                             'set_owner')
380        self.assertEqual([(0, 'change_owner'), (0, 'reassign')],
381                         sorted(actions))
382
383
384class ResetActionTestCase(unittest.TestCase):
385
386    def setUp(self):
387        self.env = EnvironmentStub(default_data=True)
388        self.perm_sys = PermissionSystem(self.env)
389        self.ctlr = TicketSystem(self.env).action_controllers[0]
390        self.req1 = MockRequest(self.env, authname='user1')
391        self.req2 = MockRequest(self.env, authname='user2')
392        self.ticket = insert_ticket(self.env, status='invalid')
393
394    def tearDown(self):
395        self.env.reset_db()
396
397    def _reload_workflow(self):
398        self.ctlr.actions = self.ctlr.get_all_actions()
399
400    def test_default_reset_action(self):
401        """Default reset action."""
402        self.perm_sys.grant_permission('user2', 'TICKET_ADMIN')
403        self._reload_workflow()
404
405        actions1 = self.ctlr.get_ticket_actions(self.req1, self.ticket)
406        actions2 = self.ctlr.get_ticket_actions(self.req2, self.ticket)
407        chgs2 = self.ctlr.get_ticket_changes(self.req2, self.ticket, '_reset')
408
409        self.assertEqual(1, len(actions1))
410        self.assertNotIn((0, '_reset'), actions1)
411        self.assertEqual(2, len(actions2))
412        self.assertIn((0, '_reset'), actions2)
413        self.assertEqual('new', chgs2['status'])
414
415    def test_default_reset_action_without_new_state(self):
416        """Default reset action not available when no new state."""
417        self.perm_sys.grant_permission('user2', 'TICKET_ADMIN')
418        config = self.env.config
419        # Replace 'new' state with 'untriaged'
420        config.set('ticket-workflow', 'create',
421                   '<none> -> untriaged')
422        config.set('ticket-workflow', 'accept',
423                   'untriaged,assigned,accepted,reopened -> accepted')
424        config.set('ticket-workflow', 'resolve',
425                   'untriaged,assigned,accepted,reopened -> closed')
426        config.set('ticket-workflow', 'reassign',
427                   'untriaged,assigned,accepted,reopened -> assigned')
428        self._reload_workflow()
429
430        actions = self.ctlr.get_ticket_actions(self.req2, self.ticket)
431
432        self.assertEqual(1, len(actions))
433        self.assertNotIn((0, '_reset'), actions)
434
435    def test_custom_reset_action(self):
436        """Custom reset action in [ticket-workflow] section."""
437        config = self.env.config['ticket-workflow']
438        config.set('_reset', '-> review')
439        config.set('_reset.operations', 'reset_workflow')
440        config.set('_reset.permissions', 'TICKET_BATCH_MODIFY')
441        config.set('_reset.default', 2)
442        self.perm_sys.grant_permission('user2', 'TICKET_BATCH_MODIFY')
443        self._reload_workflow()
444
445        actions1 = self.ctlr.get_ticket_actions(self.req1, self.ticket)
446        actions2 = self.ctlr.get_ticket_actions(self.req2, self.ticket)
447        chgs2 = self.ctlr.get_ticket_changes(self.req2, self.ticket, '_reset')
448
449        self.assertEqual(1, len(actions1))
450        self.assertNotIn((2, '_reset'), actions1)
451        self.assertEqual(2, len(actions2))
452        self.assertIn((2, '_reset'), actions2)
453        self.assertEqual('review', chgs2['status'])
454
455
456class SetOwnerAttributeTestCase(unittest.TestCase):
457
458    def setUp(self):
459        self.env = EnvironmentStub(default_data=True)
460        self.perm_sys = PermissionSystem(self.env)
461        self.ctlr = TicketSystem(self.env).action_controllers[0]
462        self.ticket = insert_ticket(self.env, status='new')
463        self.env.insert_users([
464            (user, None, None) for user in ('user1', 'user2', 'user3', 'user4')
465        ])
466        permissions = [
467            ('user1', 'TICKET_EDIT_CC'),
468            ('user2', 'TICKET_EDIT_CC'),
469            ('user2', 'TICKET_BATCH_MODIFY'),
470            ('user3', 'TICKET_ADMIN'),
471            ('user4', 'TICKET_VIEW'),
472            ('user1', 'group1'),
473            ('user2', 'group1'),
474            ('user2', 'group2'),
475            ('user3', 'group2'),
476            ('user4', 'group3')
477        ]
478        for perm in permissions:
479            self.perm_sys.grant_permission(*perm)
480        self.req = MockRequest(self.env, authname='user1')
481        self.expected = """\
482to <select id="action_reassign_reassign_owner" \
483name="action_reassign_reassign_owner"><option selected="selected" \
484value="user1">user1</option><option value="user2">user2</option>\
485<option value="user3">user3</option></select>"""
486
487    def _reload_workflow(self):
488        self.ctlr.actions = self.ctlr.get_all_actions()
489
490    def tearDown(self):
491        self.env.reset_db()
492
493    def test_users(self):
494        self.env.config.set('ticket-workflow', 'reassign.set_owner',
495                            'user1, user2, user3')
496        self._reload_workflow()
497
498        args = self.req, self.ticket, 'reassign'
499        label, control, hints = self.ctlr.render_ticket_action_control(*args)
500
501        self.assertEqual(self.expected, str(control))
502
503    def test_groups(self):
504        self.env.config.set('ticket-workflow', 'reassign.set_owner',
505                            'group1, group2')
506        self._reload_workflow()
507
508        args = self.req, self.ticket, 'reassign'
509        label, control, hints = self.ctlr.render_ticket_action_control(*args)
510
511        self.assertEqual(self.expected, str(control))
512
513    def test_permission(self):
514        self.env.config.set('ticket-workflow', 'reassign.set_owner',
515                            'TICKET_EDIT_CC, TICKET_BATCH_MODIFY')
516        self._reload_workflow()
517
518        args = self.req, self.ticket, 'reassign'
519        label, control, hints = self.ctlr.render_ticket_action_control(*args)
520
521        self.assertEqual(self.expected, str(control))
522
523
524class SetOwnerToSelfAttributeTestCase(unittest.TestCase):
525
526    def setUp(self):
527        self.env = EnvironmentStub(default_data=True)
528        self.ctlr = TicketSystem(self.env).action_controllers[0]
529        self.req = MockRequest(self.env, authname='user1')
530        ps = PermissionSystem(self.env)
531        for user in ('user1', 'user2'):
532            ps.grant_permission(user, 'TICKET_MODIFY')
533        self.env.insert_users([('user1', 'User 1', None),
534                               ('user2', 'User 2', None)])
535
536    def _get_ticket_actions(self, req, ticket):
537        return [action[1] for action
538                          in self.ctlr.get_ticket_actions(req, ticket)]
539
540    def _reload_workflow(self):
541        self.ctlr.actions = self.ctlr.get_all_actions()
542
543    def _insert_ticket(self, status, owner, resolution=None):
544        ticket = Ticket(self.env)
545        ticket['status'] = status
546        ticket['owner'] = owner
547        if resolution:
548            ticket['resolution'] = resolution
549        ticket.insert()
550        return ticket
551
552    def test_owner_is_other(self):
553        """Ticket owner is not auth'ed user.
554
555        The workflow action is shown when the state will be changed by
556        the action.
557        """
558        ticket = self._insert_ticket('accepted', 'user2')
559        args = self.req, ticket, 'accept'
560
561        label, control, hints = self.ctlr.render_ticket_action_control(*args)
562        ticket_actions = self._get_ticket_actions(*args[0:2])
563
564        self.assertIn('accept', ticket_actions)
565        self.assertEqual(label, 'accept')
566        self.assertEqual('', str(control))
567        self.assertEqual('The owner will be changed from '
568                         '<span class="trac-author">User 2</span> to '
569                         '<span class="trac-author-user">User 1</span>.',
570                         str(hints))
571
572    def test_owner_is_self_and_state_change(self):
573        """Ticket owner is auth'ed user with state change.
574
575        The workflow action is shown when the state will be changed by the
576        action, even when the ticket owner is the authenticated user.
577        """
578        ticket = self._insert_ticket('new', 'user1')
579        args = self.req, ticket, 'accept'
580
581        label, control, hints = self.ctlr.render_ticket_action_control(*args)
582        ticket_actions = self._get_ticket_actions(*args[0:2])
583
584        self.assertIn('accept', ticket_actions)
585        self.assertEqual(label, 'accept')
586        self.assertEqual('', str(control))
587        self.assertEqual('The owner will remain <span class="trac-author-user">'
588                         'User 1</span>. Next status will be \'accepted\'.',
589                         str(hints))
590
591    def test_owner_is_self_and_no_state_change(self):
592        """Ticket owner is the auth'ed user and no state change.
593
594        The ticket action is not in the list of available actions
595        when the state will not be changed by the action and the ticket
596        owner is the authenticated user.
597        """
598        ticket = self._insert_ticket('accepted', 'user1')
599        args = self.req, ticket, 'accept'
600
601        ticket_actions = self._get_ticket_actions(*args[0:2])
602
603        self.assertNotIn('accept', ticket_actions)
604
605    def test_owner_is_self_state_change_and_multiple_operations(self):
606        """Ticket owner is auth'ed user, state change and multiple ops.
607
608        The set_owner_to_self workflow hint is shown when the ticket status
609        is changed by the action, even when the ticket owner is the
610        authenticated user.
611        """
612        ticket = self._insert_ticket('new', 'user1')
613        workflow = self.env.config['ticket-workflow']
614        workflow.set('resolve_as_owner', '* -> closed')
615        workflow.set('resolve_as_owner.operations',
616                     'set_owner_to_self, set_resolution')
617        workflow.set('resolve_as_owner.set_resolution', 'fixed')
618        self._reload_workflow()
619        args = self.req, ticket, 'resolve_as_owner'
620
621        label, control, hints = self.ctlr.render_ticket_action_control(*args)
622        ticket_actions = self._get_ticket_actions(*args[0:2])
623
624        self.assertIn('resolve_as_owner', ticket_actions)
625        self.assertEqual(label, 'resolve as owner')
626        self.assertEqual(
627            'as fixed<input id="action_resolve_as_owner_resolve_resolution" '
628            'name="action_resolve_as_owner_resolve_resolution" type="hidden" '
629            'value="fixed" />', str(control))
630        self.assertEqual(
631            "The owner will remain <span class=\"trac-author-user\">User 1"
632            "</span>. The resolution will be set to fixed. Next status will "
633            "be 'closed'.", str(hints))
634
635    def test_owner_is_self_no_state_change_and_multiple_operations(self):
636        """Ticket owner is auth'ed user, no state change and multiple ops.
637
638        The set_owner_to_self workflow hint is not shown when the ticket
639        state is not changed by the action and the ticket owner is the
640        authenticated user.
641        """
642        ticket = self._insert_ticket('closed', 'user1', 'fixed')
643        workflow = self.env.config['ticket-workflow']
644        workflow.set('fix_resolution', 'closed -> closed')
645        workflow.set('fix_resolution.operations',
646                     'set_owner_to_self, set_resolution')
647        workflow.set('fix_resolution.set_resolution', 'invalid')
648        self._reload_workflow()
649        args = self.req, ticket, 'fix_resolution'
650
651        label, control, hints = self.ctlr.render_ticket_action_control(*args)
652        ticket_actions = self._get_ticket_actions(*args[0:2])
653
654        self.assertIn('fix_resolution', ticket_actions)
655        self.assertEqual(label, 'fix resolution')
656        self.assertEqual(
657            'as invalid<input id="action_fix_resolution_resolve_resolution" '
658            'name="action_fix_resolution_resolve_resolution" type="hidden" '
659            'value="invalid" />', str(control))
660        self.assertEqual("The resolution will be set to invalid.",
661                         str(hints))
662
663
664class RestrictOwnerTestCase(unittest.TestCase):
665
666    def setUp(self):
667        tmpdir = os.path.realpath(tempfile.gettempdir())
668        self.env = EnvironmentStub(enable=['trac.*', AuthzPolicy], path=tmpdir)
669        self.env.config.set('trac', 'permission_policies',
670                            'AuthzPolicy, DefaultPermissionPolicy')
671        self.env.config.set('ticket', 'restrict_owner', True)
672
673        self.perm_sys = PermissionSystem(self.env)
674        self.env.insert_users([('user1', 'User C', 'user1@example.org'),
675                               ('user2', 'User A', 'user2@example.org'),
676                               ('user3', 'User D', 'user3@example.org'),
677                               ('user4', 'User B', 'user4@example.org')])
678        self.perm_sys.grant_permission('user1', 'TICKET_MODIFY')
679        self.perm_sys.grant_permission('user2', 'TICKET_VIEW')
680        self.perm_sys.grant_permission('user3', 'TICKET_MODIFY')
681        self.perm_sys.grant_permission('user4', 'TICKET_MODIFY')
682        self.authz_file = os.path.join(tmpdir, 'trac-authz-policy')
683        create_file(self.authz_file)
684        self.env.config.set('authz_policy', 'authz_file', self.authz_file)
685        self.ctlr = TicketSystem(self.env).action_controllers[0]
686        self.req1 = MockRequest(self.env, authname='user1')
687        self.ticket = insert_ticket(self.env, status='new')
688
689    def tearDown(self):
690        self.env.reset_db()
691        os.remove(self.authz_file)
692
693    def _reload_workflow(self):
694        self.ctlr.actions = self.ctlr.get_all_actions()
695
696    def test_set_owner(self):
697        """Restricted owners list contains users with TICKET_MODIFY.
698        """
699        self.env.config.set('trac', 'show_full_names', False)
700
701        ctrl = self.ctlr.render_ticket_action_control(self.req1, self.ticket,
702                                                      'reassign')
703
704        self.assertEqual('reassign', ctrl[0])
705        self.assertIn('value="user1">user1</option>', str(ctrl[1]))
706        self.assertNotIn('value="user2">user2</option>', str(ctrl[1]))
707        self.assertIn('value="user3">user3</option>', str(ctrl[1]))
708        self.assertIn('value="user4">user4</option>', str(ctrl[1]))
709
710    def test_set_owner_fine_grained_permissions(self):
711        """Fine-grained permission checks when populating the restricted
712        owners list (#10833).
713        """
714        self.env.config.set('trac', 'show_full_names', False)
715        create_file(self.authz_file, textwrap.dedent("""\
716            [ticket:1]
717            user4 = !TICKET_MODIFY
718            """))
719
720        ctrl = self.ctlr.render_ticket_action_control(self.req1, self.ticket,
721                                                      'reassign')
722
723        self.assertEqual('reassign', ctrl[0])
724        self.assertIn('value="user1">user1</option>', str(ctrl[1]))
725        self.assertNotIn('value="user2">user2</option>', str(ctrl[1]))
726        self.assertIn('value="user3">user3</option>', str(ctrl[1]))
727        self.assertNotIn('value="user4">user4</option>', str(ctrl[1]))
728
729    def test_set_owner_show_fullnames(self):
730        """Full names are sorted when [trac] show_full_names = True."""
731        ctrl = self.ctlr.render_ticket_action_control(self.req1, self.ticket,
732                                                      'reassign')
733
734        self.assertEqual('reassign', ctrl[0])
735        self.assertEqual("""\
736to <select id="action_reassign_reassign_owner"\
737 name="action_reassign_reassign_owner">\
738<option value="user4">User B</option>\
739<option selected="selected" value="user1">User C</option>\
740<option value="user3">User D</option></select>\
741""", str(ctrl[1]))
742
743
744class SetResolutionAttributeTestCase(unittest.TestCase):
745
746    def setUp(self):
747        self.env = EnvironmentStub(default_data=True)
748        for ctlr in TicketSystem(self.env).action_controllers:
749            if isinstance(ctlr, ConfigurableTicketWorkflow):
750                self.ctlr = ctlr
751
752    def _reload_workflow(self):
753        self.ctlr.actions = self.ctlr.get_all_actions()
754
755    def test_empty_set_resolution(self):
756        config = self.env.config['ticket-workflow']
757        config.set('resolve.set_resolution', '')
758        self._reload_workflow()
759        ticket = Ticket(self.env)
760        ticket.populate({'summary': '#12882', 'status': 'new'})
761        ticket.insert()
762        req = MockRequest(self.env, path_info='/ticket/%d' % ticket.id)
763        try:
764            self.ctlr.render_ticket_action_control(req, ticket, 'resolve')
765            self.fail('ConfigurationError not raised')
766        except ConfigurationError as e:
767            self.assertIn('but none is defined', str(e))
768
769    def test_undefined_resolutions(self):
770        config = self.env.config['ticket-workflow']
771        ticket = Ticket(self.env)
772        ticket.populate({'summary': '#12882', 'status': 'new'})
773        ticket.insert()
774        req = MockRequest(self.env, path_info='/ticket/%d' % ticket.id)
775
776        config.set('resolve.set_resolution',
777                   'fixed,invalid,wontfix,,duplicate,worksforme,,,,,')
778        self._reload_workflow()
779        self.ctlr.render_ticket_action_control(req, ticket, 'resolve')
780
781        config.set('resolve.set_resolution', 'undefined,fixed')
782        self._reload_workflow()
783        try:
784            self.ctlr.render_ticket_action_control(req, ticket, 'resolve')
785            self.fail('ConfigurationError not raised')
786        except ConfigurationError as e:
787            self.assertIn('but uses undefined resolutions', str(e))
788
789
790def test_suite():
791    suite = unittest.TestSuite()
792    suite.addTest(unittest.makeSuite(ConfigurableTicketWorkflowTestCase))
793    suite.addTest(unittest.makeSuite(ResetActionTestCase))
794    suite.addTest(unittest.makeSuite(SetOwnerAttributeTestCase))
795    suite.addTest(unittest.makeSuite(SetOwnerToSelfAttributeTestCase))
796    suite.addTest(unittest.makeSuite(RestrictOwnerTestCase))
797    suite.addTest(unittest.makeSuite(SetResolutionAttributeTestCase))
798    return suite
799
800
801if __name__ == '__main__':
802    unittest.main(defaultTest='test_suite')
803