From 0000cb492763547ee08cfb50a123133afb181d28 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 11 Aug 2026 14:13:32 +0200 Subject: [PATCH] PARAF-503: Added acroform signature tags validation --- .github/workflows/main.yml | 4 +- CHANGES.rst | 2 + setup.py | 1 + src/imio/esign/acroform.py | 192 +++++++++++++++++ src/imio/esign/browser/configure.zcml | 11 + .../browser/templates/acroform_errors.pt | 17 ++ src/imio/esign/browser/views.py | 59 ++++++ src/imio/esign/events.py | 33 +++ src/imio/esign/events.zcml | 4 + src/imio/esign/tests/base.py | 41 ++++ src/imio/esign/tests/signer_tags.pdf | Bin 0 -> 35087 bytes src/imio/esign/tests/test_acroform.py | 197 ++++++++++++++++++ src/imio/esign/tests/test_browser_views.py | 73 ++++++- src/imio/esign/tests/test_events.py | 68 ++++++ test-4.3.cfg | 1 + 15 files changed, 700 insertions(+), 3 deletions(-) create mode 100644 src/imio/esign/acroform.py create mode 100644 src/imio/esign/browser/templates/acroform_errors.pt create mode 100644 src/imio/esign/tests/signer_tags.pdf create mode 100644 src/imio/esign/tests/test_acroform.py create mode 100644 src/imio/esign/tests/test_events.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e2b864c..65dc88a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -26,7 +26,7 @@ jobs: - name: Install dependencies run: | sudo apt-get update - sudo apt-get install -y libjpeg8-dev libbz2-dev liblzma-dev + sudo apt-get install -y libjpeg8-dev libbz2-dev liblzma-dev poppler-utils - name: Set up pyenv and Python uses: "gabrielfalcao/pyenv-action@v18" with: @@ -64,7 +64,7 @@ jobs: - name: Setup Env run: | sudo apt-get update -qqy - sudo apt-get install -y libjpeg8-dev libbz2-dev liblzma-dev libreadline-dev + sudo apt-get install -y libjpeg8-dev libbz2-dev liblzma-dev libreadline-dev poppler-utils - name: Set up pyenv and Python uses: "gabrielfalcao/pyenv-action@v18" with: diff --git a/CHANGES.rst b/CHANGES.rst index 66e282e..bfceb69 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -11,6 +11,8 @@ Changelog [chris-adam] - When a session status is received as completed, mark each signer as signed. [sgeulette] +- Added acroform signature and seal tags validation (PARAF-503). + [chris-adam] 1.0b10 (2026-06-18) ------------------- diff --git a/setup.py b/setup.py index 9444c35..dfe102f 100644 --- a/setup.py +++ b/setup.py @@ -78,6 +78,7 @@ "mock", "imio.annex", "mock", + "reportlab", ], }, entry_points=""" diff --git a/src/imio/esign/acroform.py b/src/imio/esign/acroform.py new file mode 100644 index 0000000..1a92957 --- /dev/null +++ b/src/imio/esign/acroform.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- +"""Detection and validation of the acroform tags of a signable file. + +A signable file may carry one tag per signer, telling the external signing service where to +paste the signature image, like {{#"ID":"Signer1","Size":{"Height":"70","Width":"200"}#}} +and one seal tag, for the seal image, like +{{#"ID":"SCEAU","Size":{"Height":"200","Width":"200"}#}} +""" + +from collections import OrderedDict +from imio.esign import _ +from imio.esign import logger +from imio.esign.interfaces import IContextUidProvider +from imio.esign.utils import get_session_info +from imio.helpers.content import uuidToObject +from io import BytesIO +from Products.CMFPlone.utils import safe_unicode +from xml.sax.saxutils import unescape +from zope.component import getAdapter +from zope.i18n import translate + +import re +import subprocess +import zipfile + + +ACROFORM_TAG_RE = re.compile(r"\{\{#(.*?)#\}\}") +SIGNER_ID_RE = re.compile(r'"ID":"Signer(0|[1-9]\d*)"') +SEAL_ID_RE = re.compile(r'"ID":"SCEAU"') +XML_TAG_RE = re.compile(r"<[^>]*>") +WHITESPACE_RE = re.compile(r"\s+") +PDFTOTEXT = "pdftotext" + +# content type -> path, inside the zipped document, of the xml part holding the text +ZIPPED_XML_PARTS = { + "application/vnd.oasis.opendocument.text": "content.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "word/document.xml", +} + + +def _pdf_text(data): + """Return the text of a pdf, read by the poppler pdftotext binary.""" + process = subprocess.Popen( + [PDFTOTEXT, "-q", "-enc", "UTF-8", "-", "-"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + out, err = process.communicate(data) + if process.returncode != 0: + raise RuntimeError("pdftotext failed: {}".format(err)) + return safe_unicode(out) + + +def _zipped_xml_text(data, part_name): + """Return the text of the given xml part of a zipped document (odt, docx). + + The entities have to be resolved: a document generated by appy/POD stores the double + quotes of a tag as ", so the tag would never be recognised (PARAF-503). + """ + zip_file = zipfile.ZipFile(BytesIO(data)) + try: + xml = safe_unicode(zip_file.read(part_name)) + finally: + zip_file.close() + return unescape(XML_TAG_RE.sub(u" ", xml), {u""": u'"', u"'": u"'"}) + + +def extract_text(nbf): + """Return the text of a file field, with every whitespace character removed.""" + if nbf is None or not getattr(nbf, "data", None): + return u"" + try: + if nbf.contentType == "application/pdf": + text = _pdf_text(nbf.data) + elif nbf.contentType in ZIPPED_XML_PARTS: + text = _zipped_xml_text(nbf.data, ZIPPED_XML_PARTS[nbf.contentType]) + else: + return u"" + except OSError: + logger.error("Cannot run '%s': the signature tags of pdf files are not checked!", PDFTOTEXT) + return u"" + except Exception: + logger.debug("Could not extract the text of file '%s'", nbf.filename, exc_info=True) + return u"" + return WHITESPACE_RE.sub(u"", text) + + +def get_tag_ids(nbf): + """Return the acroform tag ids of a file field, reading its text only once. + + :return: the signer number of every signature tag, in document order, and the number + of seal tags + """ + numbers = [] + seals = 0 + for payload in ACROFORM_TAG_RE.findall(extract_text(nbf)): + match = SIGNER_ID_RE.search(payload) + if match: + numbers.append(int(match.group(1))) + elif SEAL_ID_RE.search(payload): + seals += 1 + return numbers, seals + + +def validate_signer_numbers(numbers, nb_signers): + """Return the acroform tag error messages for one file. + + A file is valid when it holds no tag at all, or exactly one tag per signer from Signer1 + to Signer + """ + if not numbers: + return [] + counts = {} + for nb in numbers: + counts[nb] = counts.get(nb, 0) + 1 + errors = [] + for nb in sorted(counts): + if counts[nb] > 1: + errors.append(_("The signature tag of Signer${nb} is present ${count} times.", + mapping={"nb": nb, "count": counts[nb]})) + if nb < 1 or nb > nb_signers: + errors.append(_("There is a signature tag for Signer${nb} but ${count} signer(s) are defined.", + mapping={"nb": nb, "count": nb_signers})) + for nb in range(1, nb_signers + 1): + if nb not in counts: + errors.append(_("The signature tag of Signer${nb} is missing.", mapping={"nb": nb})) + return errors + + +def validate_seal_count(count, seal): + """Return the acroform seal tag error messages for one file. + + A file is valid when it holds no seal tag, or exactly one while a seal is defined. + """ + if not count: + return [] + if not seal: + return [_("There is a seal tag but no seal is defined.")] + if count > 1: + return [_("The seal tag is present ${count} times.", mapping={"count": count})] + return [] + + +def check_file(obj, nb_signers, seal=False): + """Return the acroform tag error messages of a content object holding a file. + + :param nb_signers: number of signers, or None when the signature tags are unused + :param seal: whether a seal is defined on the container + """ + numbers, seal_count = get_tag_ids(getattr(obj, "file", None)) + errors = [] if nb_signers is None else validate_signer_numbers(numbers, nb_signers) + return errors + validate_seal_count(seal_count, seal) + + +def get_session_acroform_errors(session_id): + """Return the acroform tag errors of the files of a session, checked against its signers. + + :param session_id: internal session id + :return: OrderedDict {file uid: (file object, context uid, [error messages])}, holding + only the files in error + """ + res = OrderedDict() + session = get_session_info(session_id) + if not session: + return res + nb_signers = len(session.get("signers", [])) + for file_info in session["files"]: + obj = uuidToObject(uuid=file_info["uid"], unrestricted=True) + if obj is None: + continue + errors = check_file(obj, nb_signers, session.get("seal", False)) + if errors: + context_uid = getAdapter(obj, IContextUidProvider).get_context_uid() + res[file_info["uid"]] = (obj, context_uid, errors) + return res + + +def format_errors(pairs, request): + """Return a one line readable summary of acroform tag errors. + + :param pairs: iterable of (file object, [error messages]) + :param request: used to translate the messages + """ + return u" / ".join( + [ + u"{}: {}".format( + safe_unicode(obj.Title()), u" ".join([translate(msg, context=request) for msg in messages]) + ) + for obj, messages in pairs + ] + ) diff --git a/src/imio/esign/browser/configure.zcml b/src/imio/esign/browser/configure.zcml index 87d4558..ada7bbd 100644 --- a/src/imio/esign/browser/configure.zcml +++ b/src/imio/esign/browser/configure.zcml @@ -4,6 +4,7 @@ xmlns:i18n="http://namespaces.zope.org/i18n" xmlns:plone="http://namespaces.plone.org/plone" xmlns:browser="http://namespaces.zope.org/browser" + xmlns:zcml="http://namespaces.zope.org/zcml" i18n_domain="imio.esign"> @@ -87,6 +88,16 @@ permission="imio.esign.ManageSessions" /--> + + +
+ Wrong signature or seal tags + +
+ diff --git a/src/imio/esign/browser/views.py b/src/imio/esign/browser/views.py index 4c3024a..2330875 100644 --- a/src/imio/esign/browser/views.py +++ b/src/imio/esign/browser/views.py @@ -1,11 +1,14 @@ # -*- coding: utf-8 -*- from AccessControl import Unauthorized +from collections import OrderedDict from copy import deepcopy from datetime import datetime from datetime import timedelta from imio.esign import _ from imio.esign import manage_session_perm +from imio.esign.acroform import format_errors +from imio.esign.acroform import get_session_acroform_errors from imio.esign.audit import audit from imio.esign.browser.table import external_session_link from imio.esign.browser.table import SessionsTable @@ -166,6 +169,23 @@ def __call__(self, session_id=None): if session_id is None: api.portal.show_message(_("No session ID provided!"), request=self.request, type="error") return self.context.absolute_url() + "/@@parapheo" + acroform_errors = get_session_acroform_errors(int(session_id)) + if acroform_errors: + audit("send_to_external_service", "session={} error=acroform".format(session_id)) + api.portal.show_message( + _( + "Session ${id} not sent because signature or seal tags are wrong: ${details}", + mapping={ + "id": session_id, + "details": format_errors( + [(obj, messages) for obj, _cuid, messages in acroform_errors.values()], self.request + ), + }, + ), + request=self.request, + type="error", + ) + return self.context.absolute_url() + "/@@parapheo" resp = create_external_session(int(session_id)) if resp == "_session_not_found_": audit("send_to_external_service", "session={} error=session_not_found".format(session_id)) @@ -296,6 +316,45 @@ def sessions(self): return get_sessions_for(self.context.UID()) +class AcroformErrorsViewlet(ViewletBase): + """Show the signature tag errors of the files of a draft session. + + Works both on a file and on its container: + - a file reports its own errors + - a container reports the errors of the files it owns. + """ + + index = ViewPageTemplateFile("templates/acroform_errors.pt") + draft_states = ("draft", "draft_full") + + @CachedProperty + def errors(self): + """Return OrderedDict {file uid: (file object, [translated error messages])}.""" + uid = self.context.UID() + annot = get_session_annotation() + if uid in annot["uids"]: # the context is a file itself + session_ids = [annot["uids"][uid]] + else: # the context is a container + session_ids = list(get_sessions_for(uid).keys()) + res = OrderedDict() + for session_id in session_ids: + if get_session_info(session_id).get("state") not in self.draft_states: + continue + for f_uid, (obj, context_uid, messages) in get_session_acroform_errors(session_id).items(): + if uid not in (f_uid, context_uid): + continue + res[f_uid] = (obj, [translate(msg, context=self.request) for msg in messages]) + return res + + def available(self): + return bool(self.errors) + + def render(self): + if not self.available(): + return "" + return self.index() + + @implementer(IPublishTraverse) class DownloadFileView(BrowserView): """View to download a file based on an identifier passed in the URL path. diff --git a/src/imio/esign/events.py b/src/imio/esign/events.py index 2afa7f2..bc7ca0b 100644 --- a/src/imio/esign/events.py +++ b/src/imio/esign/events.py @@ -1,11 +1,16 @@ # -*- coding: utf-8 -*- +from imio.esign import _ +from imio.esign.acroform import get_tag_ids from imio.esign.utils import get_file_info from imio.esign.utils import get_session_annotation from imio.esign.utils import get_sessions_for from imio.esign.utils import remove_files_from_session from imio.helpers.transmogrifier import get_correct_id from os import path +from plone import api +from Products.CMFPlone.utils import safe_unicode +from zope.annotation.interfaces import IAnnotations def on_categorized_annex_updated(annex, event): @@ -65,6 +70,34 @@ def on_categorized_annex_updated(annex, event): break +def on_annex_added(annex, event): + """Tell the user which acroform tags the file of a new annex holds. + + Nothing is shown when the file holds no tag, or when the file was generated from a template. + """ + + def is_generated_from_template(obj): + if IAnnotations(obj).get("documentgenerator"): + return True + published = getattr(obj, "REQUEST", None) and obj.REQUEST.get("PUBLISHED", None) + return "document-generation" in getattr(published, "__name__", "") + + if is_generated_from_template(annex): + return + numbers, seal_count = get_tag_ids(getattr(annex, "file", None)) + tags = [u"Signer{}".format(nb) for nb in numbers if nb > 0] + seal_count * [u"SCEAU"] + if not tags: + return + api.portal.show_message( + _( + "Acroform tags detected in '${title}': ${tags}", + mapping={"title": safe_unicode(annex.Title()), "tags": u", ".join(tags)}, + ), + request=annex.REQUEST, + type="info", + ) + + def on_annex_will_be_removed(annex, event): """Called when an annex will be removed, before the removed event.""" if event.object.portal_type == "Plone Site": diff --git a/src/imio/esign/events.zcml b/src/imio/esign/events.zcml index fed54fd..6f76c8b 100644 --- a/src/imio/esign/events.zcml +++ b/src/imio/esign/events.zcml @@ -5,4 +5,8 @@ collective.iconifiedcategory.interfaces.ICategorizedElementUpdatedEvent" handler=".events.on_categorized_annex_updated" /> + + diff --git a/src/imio/esign/tests/base.py b/src/imio/esign/tests/base.py index f634313..52e58b4 100644 --- a/src/imio/esign/tests/base.py +++ b/src/imio/esign/tests/base.py @@ -1,16 +1,57 @@ # -*- coding: utf-8 -*- """Shared base test class for imio.esign tests.""" from imio.esign.testing import IMIO_ESIGN_INTEGRATION_TESTING +from io import BytesIO from plone.app.testing import login from plone.app.testing import setRoles from plone.app.testing import TEST_USER_ID from plone.app.testing import TEST_USER_NAME +from plone.namedfile.file import NamedBlobFile +from reportlab.pdfgen import canvas import os import unittest +import zipfile TESTS_DIR = os.path.dirname(__file__) +ODT_CONTENT_TYPE = "application/vnd.oasis.opendocument.text" + + +def tag(nb): + """Return the acroform signature tag of the given signer number.""" + return u'{{#"ID":"Signer%d","Size":{"Height":"70","Width":"200"}#}}' % nb + + +def seal_tag(): + """Return the acroform seal tag.""" + return u'{{#"ID":"SCEAU","Size":{"Height":"200","Width":"200"}#}}' + + +def pdf_file(*paragraphs): + """Return a NamedBlobFile holding a one page pdf with the given lines of text.""" + buf = BytesIO() + pdf = canvas.Canvas(buf) + y = 800 + for paragraph in paragraphs: + pdf.drawString(30, y, paragraph) + y -= 30 + pdf.save() + return NamedBlobFile(data=buf.getvalue(), filename=u"file.pdf", contentType="application/pdf") + + +def odt_file(*paragraphs): + """Return a NamedBlobFile holding an odt whose content.xml has the given paragraphs.""" + body = u"".join([u"" + paragraph + u"" for paragraph in paragraphs]) + xml = ( + u"" + u"" + body + u"" + ) + buf = BytesIO() + zip_file = zipfile.ZipFile(buf, "w") + zip_file.writestr("content.xml", xml.encode("utf-8")) + zip_file.close() + return NamedBlobFile(data=buf.getvalue(), filename=u"file.odt", contentType=ODT_CONTENT_TYPE) class BaseEsignTest(unittest.TestCase): diff --git a/src/imio/esign/tests/signer_tags.pdf b/src/imio/esign/tests/signer_tags.pdf new file mode 100644 index 0000000000000000000000000000000000000000..ca8acf696522ca33ede33665c9b9d28b1f50244f GIT binary patch literal 35087 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 literal 0 HcmV?d00001 diff --git a/src/imio/esign/tests/test_acroform.py b/src/imio/esign/tests/test_acroform.py new file mode 100644 index 0000000..1f731e6 --- /dev/null +++ b/src/imio/esign/tests/test_acroform.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8 -*- +"""acroform tests for this package.""" +from collections import OrderedDict +from imio.esign.acroform import check_file +from imio.esign.acroform import extract_text +from imio.esign.acroform import format_errors +from imio.esign.acroform import get_session_acroform_errors +from imio.esign.acroform import get_tag_ids +from imio.esign.acroform import validate_seal_count +from imio.esign.acroform import validate_signer_numbers +from imio.esign.tests.base import BaseEsignTest +from imio.esign.tests.base import ODT_CONTENT_TYPE +from imio.esign.tests.base import odt_file +from imio.esign.tests.base import pdf_file +from imio.esign.tests.base import seal_tag +from imio.esign.tests.base import tag +from imio.esign.tests.base import TESTS_DIR +from imio.esign.utils import add_files_to_session +from plone.namedfile.file import NamedBlobFile + +import os + + +class TestAcroform(BaseEsignTest): + """Tests for the acroform module.""" + + def setUp(self): + super(TestAcroform, self).setUp() + self.folder = self.portal["folder0"] + self.annex = self.folder["annex0"] + self.signers = [ + ("user1", "user1@sign.com", "User 1", "Position 1"), + ("user2", "user2@sign.com", "User 2", "Position 2"), + ] + + def test_extract_text(self): + """pdf and odt are read, whitespace is dropped, unreadable files give u"".""" + # --- pdf --- + self.assertEqual(extract_text(pdf_file(u"hello world")), u"helloworld") + self.assertEqual(extract_text(pdf_file(tag(1), u"text")), u'{{#"ID":"Signer1","Size":{"Height":"70","Width":"200"}#}}text') + + # --- odt --- + self.assertEqual(extract_text(odt_file(u"hello world")), u"helloworld") + self.assertEqual(extract_text(odt_file(tag(2))), u'{{#"ID":"Signer2","Size":{"Height":"70","Width":"200"}#}}') + + # --- the quotes stored as xml entities, as appy/POD writes them --- + escaped = odt_file(tag(1).replace(u'"', u"""), u"l'essai") + self.assertEqual(extract_text(escaped), u'{{#"ID":"Signer1","Size":{"Height":"70","Width":"200"}#}}l\'essai') + self.assertEqual(get_tag_ids(escaped), ([1], 0)) + + # --- a real pdf produced by LibreOffice: its embedded fonts defeat PyPDF2 --- + with open(os.path.join(TESTS_DIR, "signer_tags.pdf"), "rb") as fh: + real_pdf = NamedBlobFile(data=fh.read(), filename=u"signer_tags.pdf", contentType="application/pdf") + self.assertIn(tag(1) + tag(2), extract_text(real_pdf)) + self.assertEqual(get_tag_ids(real_pdf), ([1, 2], 0)) + + # --- unreadable: no file, no data, unsupported type, corrupt data --- + self.assertEqual(extract_text(None), u"") + self.assertEqual(extract_text(NamedBlobFile(data="", filename=u"f.pdf", contentType="application/pdf")), u"") + self.assertEqual( + extract_text(NamedBlobFile(data="plain text", filename=u"f.txt", contentType="text/plain")), u"" + ) + self.assertEqual( + extract_text(NamedBlobFile(data="not a pdf", filename=u"f.pdf", contentType="application/pdf")), u"" + ) + self.assertEqual(extract_text(NamedBlobFile(data="not a zip", filename=u"f.odt", + contentType=ODT_CONTENT_TYPE)), u"") + + def test_get_tag_ids(self): + """Signer numbers come in document order, seal tags are counted, unknown ids are ignored.""" + self.assertEqual(get_tag_ids(pdf_file(u"no tag here")), ([], 0)) + self.assertEqual(get_tag_ids(pdf_file(tag(2), tag(1))), ([2, 1], 0)) + self.assertEqual(get_tag_ids(odt_file(tag(1), tag(1))), ([1, 1], 0)) + + # --- the seal tag, alone, beside a signer and twice --- + self.assertEqual(get_tag_ids(odt_file(seal_tag())), ([], 1)) + self.assertEqual(get_tag_ids(pdf_file(tag(1), seal_tag())), ([1], 1)) + self.assertEqual(get_tag_ids(odt_file(seal_tag(), seal_tag())), ([], 2)) + + # --- an id that is neither a signer nor the seal --- + self.assertEqual(get_tag_ids(odt_file(u'{{#"ID":"WRONG"#}}')), ([], 0)) + + # --- a padded number is not the signer number: the service would not fill it --- + self.assertEqual(get_tag_ids(odt_file(u'{{#"ID":"Signer01"#}}')), ([], 0)) + self.assertEqual(get_tag_ids(odt_file(u'{{#"ID":"Signer10"#}}')), ([10], 0)) + + def test_validate_signer_numbers(self): + """No tag is valid; a complete set is valid; duplicate, unknown and missing are errors.""" + # --- no tag at all is always valid --- + self.assertEqual(validate_signer_numbers([], 2), []) + self.assertEqual(validate_signer_numbers([], 0), []) + + # --- one tag per signer, any order --- + self.assertEqual(validate_signer_numbers([1, 2], 2), []) + self.assertEqual(validate_signer_numbers([2, 1], 2), []) + + # --- the same signer twice --- + errors = validate_signer_numbers([1, 1, 2], 2) + self.assertEqual(len(errors), 1) + self.assertIn(u"is present ${count} times", errors[0]) + self.assertEqual(errors[0].mapping, {"nb": 1, "count": 2}) + + # --- a signer that does not exist --- + errors = validate_signer_numbers([1, 2, 3], 2) + self.assertEqual(len(errors), 1) + self.assertIn(u"but ${count} signer(s) are defined", errors[0]) + self.assertEqual(errors[0].mapping, {"nb": 3, "count": 2}) + + # --- an incomplete set --- + errors = validate_signer_numbers([1], 2) + self.assertEqual(len(errors), 1) + self.assertIn(u"is missing", errors[0]) + self.assertEqual(errors[0].mapping, {"nb": 2}) + + # --- a tag while no signer is defined --- + errors = validate_signer_numbers([1], 0) + self.assertEqual(len(errors), 1) + self.assertEqual(errors[0].mapping, {"nb": 1, "count": 0}) + + def test_validate_seal_count(self): + """No seal tag is valid; one is valid with a seal; more than one and no seal are errors.""" + # --- no seal tag at all is always valid --- + self.assertEqual(validate_seal_count(0, True), []) + self.assertEqual(validate_seal_count(0, False), []) + + # --- exactly one tag while a seal is defined --- + self.assertEqual(validate_seal_count(1, True), []) + + # --- the tag while no seal is defined --- + errors = validate_seal_count(1, False) + self.assertEqual(len(errors), 1) + self.assertIn(u"no seal is defined", errors[0]) + + # --- the tag several times --- + errors = validate_seal_count(2, True) + self.assertEqual(len(errors), 1) + self.assertIn(u"is present ${count} times", errors[0]) + self.assertEqual(errors[0].mapping, {"count": 2}) + + # --- several tags and no seal: only the missing seal is reported --- + errors = validate_seal_count(2, False) + self.assertEqual(len(errors), 1) + self.assertIn(u"no seal is defined", errors[0]) + + def test_check_file(self): + """The errors of the file field of a content object.""" + self.annex.file = pdf_file(tag(1), tag(2)) + self.assertEqual(check_file(self.annex, 2), []) + self.assertEqual(len(check_file(self.annex, 1)), 1) + self.assertEqual(check_file(self.folder, 2), []) # no file field at all + + # --- the signature tags are ignored when nb_signers is None --- + self.assertEqual(check_file(self.annex, None), []) + + # --- the seal tag, with and without a seal on the container --- + self.annex.file = pdf_file(tag(1), seal_tag()) + self.assertEqual(check_file(self.annex, 1, True), []) + self.assertEqual(len(check_file(self.annex, 1, False)), 1) + + # --- a wrong signature tag and a wrong seal tag are both reported --- + self.assertEqual(len(check_file(self.annex, 2, False)), 2) + + def test_get_session_acroform_errors(self): + """Session files are checked against the number of signers of their session.""" + self.annex.file = pdf_file(tag(1)) + session_id, _session = add_files_to_session(self.signers, (self.annex.UID(),))[-1] + + # --- unknown session --- + self.assertEqual(get_session_acroform_errors(9999), OrderedDict()) + + # --- one tag but two signers: the missing one is reported --- + errors = get_session_acroform_errors(session_id) + self.assertEqual(list(errors.keys()), [self.annex.UID()]) + obj, context_uid, messages = errors[self.annex.UID()] + self.assertEqual(obj, self.annex) + self.assertEqual(context_uid, self.folder.UID()) + self.assertEqual(len(messages), 1) + + # --- a complete set of tags: nothing reported --- + self.annex.file = pdf_file(tag(1), tag(2)) + self.assertEqual(get_session_acroform_errors(session_id), OrderedDict()) + + # --- the seal tag is checked against the seal of the session --- + self.annex.file = pdf_file(tag(1), tag(2), seal_tag()) + self.assertEqual(len(get_session_acroform_errors(session_id)), 1) # this session has no seal + sealed_id, _sealed = add_files_to_session(self.signers, (self.annex.UID(),), seal=True)[-1] + self.assertEqual(get_session_acroform_errors(sealed_id), OrderedDict()) + self.annex.file = pdf_file(tag(1), tag(2), seal_tag(), seal_tag()) + self.assertEqual(len(get_session_acroform_errors(sealed_id)), 1) + + def test_format_errors(self): + """The summary names each file and its messages.""" + messages = validate_signer_numbers([1], 2) + summary = format_errors([(self.annex, messages)], self.request) + self.assertTrue(summary.startswith(u"Annex 0: ")) + self.assertIn(u"Signer2", summary) + self.assertEqual(format_errors([], self.request), u"") diff --git a/src/imio/esign/tests/test_browser_views.py b/src/imio/esign/tests/test_browser_views.py index bb22aa3..9f868fd 100644 --- a/src/imio/esign/tests/test_browser_views.py +++ b/src/imio/esign/tests/test_browser_views.py @@ -4,6 +4,7 @@ from collections import OrderedDict from datetime import datetime from datetime import timedelta +from imio.esign.browser.views import AcroformErrorsViewlet from imio.esign.browser.views import DownloadFileView from imio.esign.browser.views import ExternalSessionCreateView from imio.esign.browser.views import FacetedSessionInfoViewlet @@ -12,6 +13,8 @@ from imio.esign.browser.views import SigningUsersCsv from imio.esign.config import set_esign_registry_signing_users_email_content from imio.esign.tests.base import BaseEsignTest +from imio.esign.tests.base import pdf_file +from imio.esign.tests.base import tag from imio.esign.utils import add_files_to_session from imio.esign.utils import get_session_annotation from imio.pyutils.utils import shortuid_encode_id @@ -106,7 +109,12 @@ def test_may_create_external_sessions(self): self.view() def test_call(self): - """Missing id → error; sentinel strings → specific errors; 200 → success; non-200 → error. + """ + Missing id → error + wrong signature tags → nothing sent + sentinel strings → specific + errors; 200 → success + non-200 → error create_external_session mocked: HTTP tested in test_utils.py. """ @@ -120,6 +128,20 @@ def test_call(self): self.request.form["session_id"] = str(self.session_id) + # --- wrong signature tags: the session is not sent (PARAF-503) --- + annex = self.portal["folder0"]["annex0"] + original_file = annex.file + annex.file = pdf_file(tag(2)) # a tag for a signer the session does not have + with patch("imio.esign.browser.views.create_external_session") as mock_create: + result = ExternalSessionCreateView(self.folder, self.request)() + self.assertFalse(mock_create.called) + messages = IStatusMessage(self.request).show() + self.assertIn("not sent because signature or seal tags are wrong", messages[0].message) + self.assertEqual(messages[0].type, "error") + self.assertEqual("http://nohost/plone/folder0/@@parapheo", result) + annex.file = original_file + _clear_status_messages(self.request) + def _run(return_value): _clear_status_messages(self.request) with patch("imio.esign.browser.views.create_external_session", return_value=return_value): @@ -625,6 +647,55 @@ def test_sessions(self): self.assertEqual(list(sessions.keys()), [0, 1]) +class TestAcroformErrorsViewlet(BaseEsignTest): + """Tests for AcroformErrorsViewlet.""" + + def setUp(self): + super(TestAcroformErrorsViewlet, self).setUp() + self.folder = self.portal["folder0"] + self.annex = self.folder["annex0"] + self.other_annex = self.portal["folder1"]["annex1"] + self.signers = [ + ("user1", "user1@sign.com", "User 1", "Position 1"), + ("user2", "user2@sign.com", "User 2", "Position 2"), + ] + + def test_errors(self): + """Reported on the file and on its container, for draft sessions only.""" + # --- no session at all --- + self.assertEqual(AcroformErrorsViewlet(self.annex, self.request, None, None).errors, OrderedDict()) + self.assertEqual(AcroformErrorsViewlet(self.folder, self.request, None, None).errors, OrderedDict()) + + # --- a draft session, one tag but two signers --- + self.annex.file = pdf_file(tag(1)) + session_id, session = add_files_to_session(self.signers, (self.annex.UID(), self.other_annex.UID()))[-1] + errors = AcroformErrorsViewlet(self.annex, self.request, None, None).errors + self.assertEqual(list(errors.keys()), [self.annex.UID()]) + obj, messages = errors[self.annex.UID()] + self.assertEqual(obj, self.annex) + self.assertEqual(messages, [u"The signature tag of Signer2 is missing."]) + + # --- the container of the file reports it too --- + errors = AcroformErrorsViewlet(self.folder, self.request, None, None).errors + self.assertEqual(list(errors.keys()), [self.annex.UID()]) + + # --- the container of another file of the same session reports nothing --- + self.assertEqual( + AcroformErrorsViewlet(self.portal["folder1"], self.request, None, None).errors, OrderedDict() + ) + + # --- once the session left the draft states, nothing is reported --- + session["state"] = "sent" + self.assertEqual(AcroformErrorsViewlet(self.annex, self.request, None, None).errors, OrderedDict()) + session["state"] = "draft_full" + self.assertEqual(len(AcroformErrorsViewlet(self.annex, self.request, None, None).errors), 1) + + # --- a complete set of tags --- + self.annex.file = pdf_file(tag(1), tag(2)) + self.assertEqual(AcroformErrorsViewlet(self.annex, self.request, None, None).errors, OrderedDict()) + self.assertEqual(get_session_annotation()["uids"][self.annex.UID()], session_id) + + class TestSessionsListingView(BaseEsignTest): """Test SessionsListingView browser view.""" diff --git a/src/imio/esign/tests/test_events.py b/src/imio/esign/tests/test_events.py new file mode 100644 index 0000000..509b7aa --- /dev/null +++ b/src/imio/esign/tests/test_events.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +"""events tests for this package.""" +from collective.iconifiedcategory.utils import calculate_category_id +from imio.esign.events import on_annex_added +from imio.esign.tests.base import BaseEsignTest +from imio.esign.tests.base import pdf_file +from imio.esign.tests.base import seal_tag +from imio.esign.tests.base import tag +from plone import api +from Products.statusmessages.interfaces import IStatusMessage +from zope.annotation.interfaces import IAnnotations + + +class TestEvents(BaseEsignTest): + """Tests for the events module.""" + + def _add_annex(self, annex_id, file_object): + """Create an annex holding the given file in folder0.""" + return api.content.create( + container=self.portal["folder0"], + type="annex", + id=annex_id, + title=u"Tagged annex", + content_category=calculate_category_id(self.portal["annexes_types"]["annexes"]["to_sign"]), + scan_id="0123456000009{}".format(len(self.portal["folder0"])), + file=file_object, + ) + + def test_on_annex_added(self): + """The acroform tags of a new annex are listed, and nothing is shown without a tag.""" + # --- a file holding no tag says nothing --- + self._add_annex("annex-no-tag", pdf_file(u"no tag here")) + self.assertEqual(IStatusMessage(self.request).show(), []) + + # --- signature and seal tags, in document order, duplicates listed every time --- + self._add_annex("annex-tags", pdf_file(tag(2), tag(1), seal_tag(), seal_tag())) + messages = IStatusMessage(self.request).show() + self.assertEqual(len(messages), 1) + self.assertEqual(messages[0].type, "info") + self.assertIn(u"Tagged annex", messages[0].message) + self.assertIn(u"Signer2, Signer1, SCEAU, SCEAU", messages[0].message) + + # --- Signer0 is never announced: no signer can fill it --- + self._add_annex("annex-signer0", pdf_file(tag(0))) + self.assertEqual(IStatusMessage(self.request).show(), []) + self._add_annex("annex-signer0-and-1", pdf_file(tag(0), tag(1))) + messages = IStatusMessage(self.request).show() + self.assertEqual(len(messages), 1) + self.assertNotIn(u"Signer0", messages[0].message) + self.assertIn(u"Signer1", messages[0].message) + + def test_on_annex_added_generated_file(self): + """A file produced from a template says nothing: its tags come from the template.""" + # --- while the generation view is publishing, before it writes its annotation --- + class _FakeView(object): + __name__ = "persistent-document-generation" + + self.request["PUBLISHED"] = _FakeView() + self._add_annex("annex-generated", pdf_file(tag(1), seal_tag())) + self.assertEqual(IStatusMessage(self.request).show(), []) + del self.request.other["PUBLISHED"] + + # --- later, on a file that already carries the generation annotation --- + annex = self._add_annex("annex-annotated", pdf_file(tag(1))) + IStatusMessage(self.request).show() # consume the message of the creation above + IAnnotations(annex)["documentgenerator"] = {"template_uid": "some-uid"} + on_annex_added(annex, None) + self.assertEqual(IStatusMessage(self.request).show(), []) diff --git a/test-4.3.cfg b/test-4.3.cfg index 5213fc9..435409b 100644 --- a/test-4.3.cfg +++ b/test-4.3.cfg @@ -45,6 +45,7 @@ mr.developer = 2.0.4 plone.formwidget.namedfile = 2.0.5 plone.formwidget.querystring = 1.1.9 plone.restapi = 4.1.4 +reportlab = 3.4.0 #requests = 2.27.1 six = 1.16.0 traitlets = 4.3.3