diff tests/unit/test_plugin_xep_0420.py @ 3877:00212260f659

plugin XEP-0420: Implementation of Stanza Content Encryption: Includes implementation of XEP-0082 (XMPP date and time profiles) and tests for both new plugins. Everything is type checked, linted, format checked and unit tested. Adds new dependency xmlschema. fix 377
author Syndace <me@syndace.dev>
date Tue, 23 Aug 2022 12:04:11 +0200
parents
children 8289ac1b34f4
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/unit/test_plugin_xep_0420.py	Tue Aug 23 12:04:11 2022 +0200
@@ -0,0 +1,581 @@
+#!/usr/bin/env python3
+
+# Tests for Libervia's Stanza Content Encryption plugin
+# Copyright (C) 2022-2022 Tim Henkes (me@syndace.dev)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+# Type-check with `mypy --strict --disable-error-code no-untyped-call`
+# Lint with `pylint`
+
+from datetime import datetime, timezone
+from typing import Callable, Iterator, Optional, cast
+
+import pytest
+
+from sat.plugins.plugin_xep_0334 import NS_HINTS
+from sat.plugins.plugin_xep_0420 import (
+    NS_SCE, XEP_0420, AffixVerificationFailed, ProfileRequirementsNotMet, SCEAffixPolicy,
+    SCECustomAffix, SCEProfile
+)
+from sat.tools.xml_tools import ElementParser
+from twisted.words.xish import domish
+
+
+__all__ = [  # pylint: disable=unused-variable
+    "test_unpack_matches_original",
+    "test_affixes_included",
+    "test_all_affixes_verified",
+    "test_incomplete_affixes",
+    "test_rpad_affix",
+    "test_time_affix",
+    "test_to_affix",
+    "test_from_affix",
+    "test_custom_affixes",
+    "test_namespace_conversion",
+    "test_non_encryptable_elements",
+    "test_schema_validation"
+]
+
+
+string_to_domish = cast(Callable[[str], domish.Element], ElementParser())
+
+
+class CustomAffixImpl(SCECustomAffix):
+    """
+    A simple custom affix implementation for testing purposes. Verifies the full JIDs of
+    both sender and recipient.
+
+    @warning: This is just an example, an affix element like this might not make sense due
+        to potentially allowed modifications of recipient/sender full JIDs (I don't know
+        enough about XMPP routing to know whether full JIDs are always left untouched by
+        the server).
+    """
+
+    @property
+    def element_name(self) -> str:
+        return "full-jids"
+
+    @property
+    def element_schema(self) -> str:
+        return """<xs:element name="full-jids">
+            <xs:complexType>
+                <xs:attribute name="recipient" type="xs:string"/>
+                <xs:attribute name="sender" type="xs:string"/>
+            </xs:complexType>
+        </xs:element>"""
+
+    def create(self, stanza: domish.Element) -> domish.Element:
+        recipient = cast(Optional[str], stanza.getAttribute("to", None))
+        sender = cast(Optional[str], stanza.getAttribute("from", None))
+
+        if recipient is None or sender is None:
+            raise ValueError(
+                "Stanza doesn't have ``to`` and ``from`` attributes required by the"
+                " full-jids custom affix."
+            )
+
+        element = domish.Element((NS_SCE, "full-jids"))
+        element["recipient"] = recipient
+        element["sender"] = sender
+        return element
+
+    def verify(self, stanza: domish.Element, element: domish.Element) -> None:
+        recipient_target = element["recipient"]
+        recipient_actual = stanza.getAttribute("to")
+
+        sender_target = element["sender"]
+        sender_actual = stanza.getAttribute("from")
+
+        if recipient_actual != recipient_target or sender_actual != sender_target:
+            raise AffixVerificationFailed(
+                f"Full JIDs differ. Recipient: actual={recipient_actual} vs."
+                f" target={recipient_target}; Sender: actual={sender_actual} vs."
+                f" target={sender_target}"
+            )
+
+
+def test_unpack_matches_original() -> None:  # pylint: disable=missing-function-docstring
+    profile = SCEProfile(
+        SCEAffixPolicy.REQUIRED,
+        SCEAffixPolicy.OPTIONAL,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.OPTIONAL,
+        custom_policies={ CustomAffixImpl(): SCEAffixPolicy.NOT_NEEDED }
+    )
+
+    stanza_string = (
+        '<message from="foo@example.com" to="bar@example.com"><body>Test with both a body'
+        ' and some other custom element.</body><custom xmlns="urn:xmpp:example:0"'
+        ' test="matches-original">some more content</custom></message>'
+    )
+
+    stanza = string_to_domish(stanza_string)
+
+    envelope_serialized = XEP_0420.pack_stanza(profile, stanza)
+
+    # The stanza should not have child elements any more
+    assert len(list(stanza.elements())) == 0
+
+    XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
+
+    # domish.Element doesn't override __eq__, thus we compare the .toXml() strings here in
+    # the hope that serialization for an example as small as this is unique enough to be
+    # compared that way.
+    assert stanza.toXml() == string_to_domish(stanza_string).toXml()
+
+
+def test_affixes_included() -> None:  # pylint: disable=missing-function-docstring
+    custom_affix = CustomAffixImpl()
+
+    profile = SCEProfile(
+        SCEAffixPolicy.REQUIRED,
+        SCEAffixPolicy.OPTIONAL,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.OPTIONAL,
+        custom_policies={ custom_affix: SCEAffixPolicy.OPTIONAL }
+    )
+
+    stanza = string_to_domish("""<message from="foo@example.com" to="bar@example.com">
+        <body>
+            Make sure that both the REQUIRED and the OPTIONAL affixes are included.
+        </body>
+    </message>""")
+
+    affix_values = XEP_0420.unpack_stanza(
+        profile,
+        stanza,
+        XEP_0420.pack_stanza(profile, stanza)
+    )
+
+    assert affix_values.rpad is not None
+    assert affix_values.timestamp is not None
+    assert affix_values.recipient is None
+    assert affix_values.sender is not None
+    assert custom_affix in affix_values.custom
+
+
+def test_all_affixes_verified() -> None:  # pylint: disable=missing-function-docstring
+    packing_profile = SCEProfile(
+        SCEAffixPolicy.REQUIRED,
+        SCEAffixPolicy.REQUIRED,
+        SCEAffixPolicy.REQUIRED,
+        SCEAffixPolicy.REQUIRED,
+        custom_policies={}
+    )
+
+    unpacking_profile = SCEProfile(
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        custom_policies={}
+    )
+
+    stanza = string_to_domish("""<message from="foo@example.com" to="bar@example.com">
+        <body>
+            When unpacking, all affixes are loaded, even those marked as NOT_NEEDED.
+        </body>
+    </message>""")
+
+    envelope_serialized = XEP_0420.pack_stanza(packing_profile, stanza)
+
+    affix_values = XEP_0420.unpack_stanza(unpacking_profile, stanza, envelope_serialized)
+
+    assert affix_values.rpad is not None
+    assert affix_values.timestamp is not None
+    assert affix_values.recipient is not None
+    assert affix_values.sender is not None
+
+    # When unpacking, all affixes are verified, even if they are NOT_NEEDED by the profile
+    stanza = string_to_domish(
+        """<message from="fooo@example.com" to="baz@example.com"></message>"""
+    )
+
+    with pytest.raises(AffixVerificationFailed):
+        XEP_0420.unpack_stanza(unpacking_profile, stanza, envelope_serialized)
+
+
+def test_incomplete_affixes() -> None:  # pylint: disable=missing-function-docstring
+    packing_profile = SCEProfile(
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        custom_policies={}
+    )
+
+    unpacking_profile = SCEProfile(
+        SCEAffixPolicy.REQUIRED,
+        SCEAffixPolicy.REQUIRED,
+        SCEAffixPolicy.REQUIRED,
+        SCEAffixPolicy.REQUIRED,
+        custom_policies={}
+    )
+
+    stanza = string_to_domish("""<message from="foo@example.com" to="bar@example.com">
+        <body>Check that all affixes REQUIRED by the profile are present.</body>
+    </message>""")
+
+    with pytest.raises(ProfileRequirementsNotMet):
+        XEP_0420.unpack_stanza(
+            unpacking_profile,
+            stanza,
+            XEP_0420.pack_stanza(packing_profile, stanza)
+        )
+
+    # Do the same but with a custom affix missing
+    custom_affix = CustomAffixImpl()
+
+    packing_profile = SCEProfile(
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        custom_policies={ custom_affix: SCEAffixPolicy.NOT_NEEDED }
+    )
+
+    unpacking_profile = SCEProfile(
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        custom_policies={ custom_affix: SCEAffixPolicy.REQUIRED }
+    )
+
+    stanza = string_to_domish("""<message from="foo@example.com" to="bar@example.com">
+        <body>
+            Check that all affixes REQUIRED by the profile are present, including custom
+            affixes.
+        </body>
+    </message>""")
+
+    with pytest.raises(ProfileRequirementsNotMet):
+        XEP_0420.unpack_stanza(
+            unpacking_profile,
+            stanza,
+            XEP_0420.pack_stanza(packing_profile, stanza)
+        )
+
+
+def test_rpad_affix() -> None:  # pylint: disable=missing-function-docstring
+    profile = SCEProfile(
+        SCEAffixPolicy.REQUIRED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        custom_policies={}
+    )
+
+    for _ in range(100):
+        stanza = string_to_domish("""<message from="foo@example.com" to="bar@example.com">
+            <body>OK</body>
+        </message>""")
+
+        affix_values = XEP_0420.unpack_stanza(
+            profile,
+            stanza,
+            XEP_0420.pack_stanza(profile, stanza)
+        )
+
+        # Test that the rpad exists and that the content elements are always padded to at
+        # least 53 characters
+        assert affix_values.rpad is not None
+        assert len(affix_values.rpad) >= 53 - len("<body xmlns='jabber:client'>OK</body>")
+
+
+def test_time_affix() -> None:  # pylint: disable=missing-function-docstring
+    profile = SCEProfile(
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.REQUIRED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        custom_policies={}
+    )
+
+    stanza = string_to_domish(
+        """<message from="foo@example.com" to="bar@example.com"></message>"""
+    )
+
+    envelope_serialized = f"""<envelope xmlns="{NS_SCE}">
+        <content>
+            <body xmlns="jabber:client">
+                The time affix is only parsed and not otherwise verified. Not much to test
+                here.
+            </body>
+        </content>
+        <time stamp="1969-07-21T02:56:15Z"/>
+    </envelope>""".encode("utf-8")
+
+    affix_values = XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
+    assert affix_values.timestamp == datetime(1969, 7, 21, 2, 56, 15, tzinfo=timezone.utc)
+
+
+def test_to_affix() -> None:  # pylint: disable=missing-function-docstring
+    profile = SCEProfile(
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.REQUIRED,
+        SCEAffixPolicy.NOT_NEEDED,
+        custom_policies={}
+    )
+
+    stanza = string_to_domish("""<message from="foo@example.com" to="bar@example.com">
+        <body>Check that the ``to`` affix is correctly added.</body>
+    </message>""")
+
+    envelope_serialized = XEP_0420.pack_stanza(profile, stanza)
+    affix_values = XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
+    assert affix_values.recipient is not None
+    assert affix_values.recipient.userhost() == "bar@example.com"
+
+    # Check that a mismatch in recipient bare JID causes an exception to be raised
+    stanza = string_to_domish(
+        """<message from="foo@example.com" to="baz@example.com"></message>"""
+    )
+
+    with pytest.raises(AffixVerificationFailed):
+        XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
+
+    # Check that only the bare JID matters
+    stanza = string_to_domish(
+        """<message from="foo@example.com" to="bar@example.com/device"></message>"""
+    )
+
+    affix_values = XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
+    assert affix_values.recipient is not None
+    assert affix_values.recipient.userhost() == "bar@example.com"
+
+    stanza = string_to_domish("""<message from="foo@example.com">
+        <body>
+            Check that a missing "to" attribute on the stanza fails stanza packing.
+        </body>
+    </message>""")
+
+    with pytest.raises(ValueError):
+        XEP_0420.pack_stanza(profile, stanza)
+
+
+def test_from_affix() -> None:  # pylint: disable=missing-function-docstring
+    profile = SCEProfile(
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.REQUIRED,
+        custom_policies={}
+    )
+
+    stanza = string_to_domish("""<message from="foo@example.com" to="bar@example.com">
+        <body>Check that the ``from`` affix is correctly added.</body>
+    </message>""")
+
+    envelope_serialized = XEP_0420.pack_stanza(profile, stanza)
+    affix_values = XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
+    assert affix_values.sender is not None
+    assert affix_values.sender.userhost() == "foo@example.com"
+
+    # Check that a mismatch in sender bare JID causes an exception to be raised
+    stanza = string_to_domish(
+        """<message from="fooo@example.com" to="bar@example.com"></message>"""
+    )
+
+    with pytest.raises(AffixVerificationFailed):
+        XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
+
+    # Check that only the bare JID matters
+    stanza = string_to_domish(
+        """<message from="foo@example.com/device" to="bar@example.com"></message>"""
+    )
+
+    affix_values = XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
+    assert affix_values.sender is not None
+    assert affix_values.sender.userhost() == "foo@example.com"
+
+    stanza = string_to_domish("""<message to="bar@example.com">
+        <body>
+            Check that a missing "from" attribute on the stanza fails stanza packing.
+        </body>
+    </message>""")
+
+    with pytest.raises(ValueError):
+        XEP_0420.pack_stanza(profile, stanza)
+
+
+def test_custom_affixes() -> None:  # pylint: disable=missing-function-docstring
+    custom_affix = CustomAffixImpl()
+
+    packing_profile = SCEProfile(
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        custom_policies={ custom_affix: SCEAffixPolicy.REQUIRED }
+    )
+
+    unpacking_profile = SCEProfile(
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        custom_policies={}
+    )
+
+    stanza = string_to_domish("""<message from="foo@example.com" to="bar@example.com">
+        <body>
+            If a custom affix is included in the envelope, but not excpected by the
+            recipient, the schema validation should fail.
+        </body>
+    </message>""")
+
+    with pytest.raises(ValueError):
+        XEP_0420.unpack_stanza(
+            unpacking_profile,
+            stanza,
+            XEP_0420.pack_stanza(packing_profile, stanza)
+        )
+
+    profile = packing_profile
+
+    stanza = string_to_domish("""<message from="foo@example.com/device0"
+        to="bar@example.com/Libervia.123">
+        <body>The affix element should be returned as part of the affix values.</body>
+    </message>""")
+
+    affix_values = XEP_0420.unpack_stanza(
+        profile,
+        stanza,
+        XEP_0420.pack_stanza(profile, stanza)
+    )
+
+    assert custom_affix in affix_values.custom
+    assert affix_values.custom[custom_affix].getAttribute("recipient") == \
+        "bar@example.com/Libervia.123"
+    assert affix_values.custom[custom_affix].getAttribute("sender") == \
+        "foo@example.com/device0"
+
+
+def test_namespace_conversion() -> None:  # pylint: disable=missing-function-docstring
+    profile = SCEProfile(
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        custom_policies={}
+    )
+
+    stanza = domish.Element((None, "message"))
+    stanza["from"] = "foo@example.com"
+    stanza["to"] = "bar@example.com"
+    stanza.addElement(
+        "body",
+        content=(
+            "This body element has namespace ``None``, which has to be replaced with"
+            " jabber:client."
+        )
+    )
+
+    envelope_serialized = XEP_0420.pack_stanza(profile, stanza)
+    envelope = string_to_domish(envelope_serialized.decode("utf-8"))
+    content = next(cast(Iterator[domish.Element], envelope.elements(NS_SCE, "content")))
+
+    # The body should have been assigned ``jabber:client`` as its namespace
+    assert next(
+        cast(Iterator[domish.Element], content.elements("jabber:client", "body")),
+        None
+    ) is not None
+
+    XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
+
+    # The body should still have ``jabber:client`` after unpacking
+    assert next(
+        cast(Iterator[domish.Element], stanza.elements("jabber:client", "body")),
+        None
+    ) is not None
+
+
+def test_non_encryptable_elements() -> None:  # pylint: disable=missing-function-docstring
+    profile = SCEProfile(
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        custom_policies={}
+    )
+
+    stanza = string_to_domish("""<message from="foo@example.com" to="bar@example.com">
+        <body>This stanza includes a store hint which must not be encrypted.</body>
+        <store xmlns="urn:xmpp:hints"/>
+    </message>""")
+
+    envelope_serialized = XEP_0420.pack_stanza(profile, stanza)
+    envelope = string_to_domish(envelope_serialized.decode("utf-8"))
+    content = next(cast(Iterator[domish.Element], envelope.elements(NS_SCE, "content")))
+
+    # The store hint must not have been moved to the content element
+    assert next(
+        cast(Iterator[domish.Element], stanza.elements(NS_HINTS, "store")),
+        None
+    ) is not None
+
+    assert next(
+        cast(Iterator[domish.Element], content.elements(NS_HINTS, "store")),
+        None
+    ) is None
+
+    stanza = string_to_domish(
+        """<message from="foo@example.com" to="bar@example.com"></message>"""
+    )
+
+    envelope_serialized = f"""<envelope xmlns="{NS_SCE}">
+        <content>
+            <body xmlns="jabber:client">
+                The store hint must not be moved to the stanza.
+            </body>
+            <store xmlns="urn:xmpp:hints"/>
+        </content>
+    </envelope>""".encode("utf-8")
+
+    XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
+
+    assert next(
+        cast(Iterator[domish.Element], stanza.elements(NS_HINTS, "store")),
+        None
+    ) is None
+
+
+def test_schema_validation() -> None:  # pylint: disable=missing-function-docstring
+    profile = SCEProfile(
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        custom_policies={}
+    )
+
+    stanza = string_to_domish(
+        """<message from="foo@example.com" to="bar@example.com"></message>"""
+    )
+
+    envelope_serialized = f"""<envelope xmlns="{NS_SCE}">
+        <content>
+            <body xmlns="jabber:client">
+                An unknwon affix should cause a schema validation error.
+            </body>
+            <store xmlns="urn:xmpp:hints"/>
+        </content>
+        <unknown-affix unknown-attr="unknown"/>
+    </envelope>""".encode("utf-8")
+
+    with pytest.raises(ValueError):
+        XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)