workflows: prevent action replay (#86577) #1095

Merged
fpeters merged 1 commits from wip/86577-prevent-action-replay into main 2024-02-09 07:28:07 +01:00
5 changed files with 55 additions and 2 deletions

View File

@ -1689,6 +1689,33 @@ def test_backoffice_handling(pub):
assert 'HELLO WORLD' in resp.text
def test_backoffice_parallel_handling(pub, freezer):
create_user(pub)
create_environment(pub)
form_class = FormDef.get_by_urlname('form-title').data_class()
number31 = [x for x in form_class.select() if x.data['1'] == 'FOO BAR 30'][0]
app = login(get_app(pub))
resp = app.get('/backoffice/management/form-title/')
assert re.findall(r'<tbody.*\/tbody>', resp.text, re.DOTALL)[0].count('<tr') == 17
# open formdata twice
resp2 = resp.click(href='%s/' % number31.id)
resp3 = resp.click(href='%s/' % number31.id)
freezer.move_to(datetime.timedelta(seconds=10))
Review

On se base sur le last_update_time pour détecter que le formdata a bougé et il est juste précis à la seconde, on avance donc dans le temps pour être sûr qu'une différente soit enregistrée.

On se base sur le last_update_time pour détecter que le formdata a bougé et il est juste précis à la seconde, on avance donc dans le temps pour être sûr qu'une différente soit enregistrée.
resp2.forms[0]['comment'] = 'HELLO WORLD'
resp2 = resp2.forms[0].submit('button_accept')
resp2 = resp2.follow()
resp3.forms[0]['comment'] = 'HELLO WORLD'
resp3 = resp3.forms[0].submit('button_accept')
assert resp3.pyquery('.global-errors summary').text() == 'Error: parallel execution.'
assert (
resp3.pyquery('.global-errors summary + p').text()
== 'Another action has been performed on this form in the meantime and data may have been changed.'
)
def test_backoffice_handling_global_action(pub):
create_user(pub)

View File

@ -461,6 +461,7 @@ def test_frontoffice_workflow_form_with_impossible_condition(pub):
formdata = formdef.data_class()()
formdata.user_id = user.id
formdata.just_created()
formdata.status = 'wf-new'
formdata.store()

View File

@ -420,6 +420,9 @@ class CardBackOfficeStatusPage(FormBackOfficeStatusPage):
sidebar_recorded_by_agent_message = _(
'The card has been recorded on %(date)s with the identifier %(identifier)s by %(agent)s.'
)
replay_detailed_message = _(
'Another action has been performed on this card in the meantime and data may have been changed.'
)
def should_fold_summary(self, mine, request_user):
return False

View File

@ -36,7 +36,7 @@ from wcs.qommon.admin.texts import TextsDirectory
from wcs.qommon.upload_storage import get_storage_object
from wcs.utils import record_timings
from wcs.wf.editable import EditableWorkflowStatusItem
from wcs.workflows import RedisplayFormException
from wcs.workflows import RedisplayFormException, ReplayException
from ..qommon import _, audit, errors, misc, template
@ -166,6 +166,10 @@ class FormStatusPage(Directory, FormTemplateMixin):
history_templates = ['wcs/formdata_history.html']
status_templates = ['wcs/formdata_status.html']
replay_detailed_message = _(
'Another action has been performed on this form in the meantime and data may have been changed.'
)
def __init__(self, formdef, filled, register_workflow_subdirs=True, custom_view=None, parent_view=None):
get_publisher().substitutions.feed(filled)
self.formdef = formdef
@ -726,7 +730,18 @@ class FormStatusPage(Directory, FormTemplateMixin):
def submit(self, form):
user = get_request().user
next_url = self.filled.handle_workflow_form(user, form)
next_url = None
try:
next_url = self.filled.handle_workflow_form(user, form)
except ReplayException:
raise RedisplayFormException(
form=form,
error={
'summary': _('Error: parallel execution.'),
'details': self.replay_detailed_message,
},
)
if next_url:
return next_url
if form.has_errors():

View File

@ -201,6 +201,10 @@ class RedisplayFormException(Exception):
form.add_global_errors([error])
class ReplayException(Exception):
pass
def get_role_dependencies(roles):
for role_id in roles or []:
if not role_id:
@ -2349,6 +2353,7 @@ class SerieOfActionsMixin:
def get_action_form(self, filled, user, displayed_fields=None):
form = Form(enctype='multipart/form-data', use_tokens=False)
form.attrs['id'] = 'wf-actions'
form.add_hidden('_ts', str(time.mktime(filled.last_update_time)))
Review

On pose le timestamp dans le formulaire.

On pose le timestamp dans le formulaire.
for item in self.items:
if not item.check_auth(filled, user):
continue
@ -2394,6 +2399,8 @@ class SerieOfActionsMixin:
return messages
def handle_form(self, form, filled, user, evo):
if form.get('_ts') != str(time.mktime(filled.last_update_time)):
raise ReplayException()
Review

Et quand le formulaire est envoyé, on vérifie que le timestamp correspond toujours.

Et quand le formulaire est envoyé, on vérifie que le timestamp correspond toujours.
evo.time = time.localtime()
evo.set_user(formdata=filled, user=user, check_submitter=get_request().is_in_frontoffice())
if not filled.evolution: