comparison 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
comparison
equal deleted inserted replaced
3876:e3c1f4736ab2 3877:00212260f659
1 #!/usr/bin/env python3
2
3 # Tests for Libervia's Stanza Content Encryption plugin
4 # Copyright (C) 2022-2022 Tim Henkes (me@syndace.dev)
5
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU Affero General Public License for more details.
15
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
19 # Type-check with `mypy --strict --disable-error-code no-untyped-call`
20 # Lint with `pylint`
21
22 from datetime import datetime, timezone
23 from typing import Callable, Iterator, Optional, cast
24
25 import pytest
26
27 from sat.plugins.plugin_xep_0334 import NS_HINTS
28 from sat.plugins.plugin_xep_0420 import (
29 NS_SCE, XEP_0420, AffixVerificationFailed, ProfileRequirementsNotMet, SCEAffixPolicy,
30 SCECustomAffix, SCEProfile
31 )
32 from sat.tools.xml_tools import ElementParser
33 from twisted.words.xish import domish
34
35
36 __all__ = [ # pylint: disable=unused-variable
37 "test_unpack_matches_original",
38 "test_affixes_included",
39 "test_all_affixes_verified",
40 "test_incomplete_affixes",
41 "test_rpad_affix",
42 "test_time_affix",
43 "test_to_affix",
44 "test_from_affix",
45 "test_custom_affixes",
46 "test_namespace_conversion",
47 "test_non_encryptable_elements",
48 "test_schema_validation"
49 ]
50
51
52 string_to_domish = cast(Callable[[str], domish.Element], ElementParser())
53
54
55 class CustomAffixImpl(SCECustomAffix):
56 """
57 A simple custom affix implementation for testing purposes. Verifies the full JIDs of
58 both sender and recipient.
59
60 @warning: This is just an example, an affix element like this might not make sense due
61 to potentially allowed modifications of recipient/sender full JIDs (I don't know
62 enough about XMPP routing to know whether full JIDs are always left untouched by
63 the server).
64 """
65
66 @property
67 def element_name(self) -> str:
68 return "full-jids"
69
70 @property
71 def element_schema(self) -> str:
72 return """<xs:element name="full-jids">
73 <xs:complexType>
74 <xs:attribute name="recipient" type="xs:string"/>
75 <xs:attribute name="sender" type="xs:string"/>
76 </xs:complexType>
77 </xs:element>"""
78
79 def create(self, stanza: domish.Element) -> domish.Element:
80 recipient = cast(Optional[str], stanza.getAttribute("to", None))
81 sender = cast(Optional[str], stanza.getAttribute("from", None))
82
83 if recipient is None or sender is None:
84 raise ValueError(
85 "Stanza doesn't have ``to`` and ``from`` attributes required by the"
86 " full-jids custom affix."
87 )
88
89 element = domish.Element((NS_SCE, "full-jids"))
90 element["recipient"] = recipient
91 element["sender"] = sender
92 return element
93
94 def verify(self, stanza: domish.Element, element: domish.Element) -> None:
95 recipient_target = element["recipient"]
96 recipient_actual = stanza.getAttribute("to")
97
98 sender_target = element["sender"]
99 sender_actual = stanza.getAttribute("from")
100
101 if recipient_actual != recipient_target or sender_actual != sender_target:
102 raise AffixVerificationFailed(
103 f"Full JIDs differ. Recipient: actual={recipient_actual} vs."
104 f" target={recipient_target}; Sender: actual={sender_actual} vs."
105 f" target={sender_target}"
106 )
107
108
109 def test_unpack_matches_original() -> None: # pylint: disable=missing-function-docstring
110 profile = SCEProfile(
111 SCEAffixPolicy.REQUIRED,
112 SCEAffixPolicy.OPTIONAL,
113 SCEAffixPolicy.NOT_NEEDED,
114 SCEAffixPolicy.OPTIONAL,
115 custom_policies={ CustomAffixImpl(): SCEAffixPolicy.NOT_NEEDED }
116 )
117
118 stanza_string = (
119 '<message from="foo@example.com" to="bar@example.com"><body>Test with both a body'
120 ' and some other custom element.</body><custom xmlns="urn:xmpp:example:0"'
121 ' test="matches-original">some more content</custom></message>'
122 )
123
124 stanza = string_to_domish(stanza_string)
125
126 envelope_serialized = XEP_0420.pack_stanza(profile, stanza)
127
128 # The stanza should not have child elements any more
129 assert len(list(stanza.elements())) == 0
130
131 XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
132
133 # domish.Element doesn't override __eq__, thus we compare the .toXml() strings here in
134 # the hope that serialization for an example as small as this is unique enough to be
135 # compared that way.
136 assert stanza.toXml() == string_to_domish(stanza_string).toXml()
137
138
139 def test_affixes_included() -> None: # pylint: disable=missing-function-docstring
140 custom_affix = CustomAffixImpl()
141
142 profile = SCEProfile(
143 SCEAffixPolicy.REQUIRED,
144 SCEAffixPolicy.OPTIONAL,
145 SCEAffixPolicy.NOT_NEEDED,
146 SCEAffixPolicy.OPTIONAL,
147 custom_policies={ custom_affix: SCEAffixPolicy.OPTIONAL }
148 )
149
150 stanza = string_to_domish("""<message from="foo@example.com" to="bar@example.com">
151 <body>
152 Make sure that both the REQUIRED and the OPTIONAL affixes are included.
153 </body>
154 </message>""")
155
156 affix_values = XEP_0420.unpack_stanza(
157 profile,
158 stanza,
159 XEP_0420.pack_stanza(profile, stanza)
160 )
161
162 assert affix_values.rpad is not None
163 assert affix_values.timestamp is not None
164 assert affix_values.recipient is None
165 assert affix_values.sender is not None
166 assert custom_affix in affix_values.custom
167
168
169 def test_all_affixes_verified() -> None: # pylint: disable=missing-function-docstring
170 packing_profile = SCEProfile(
171 SCEAffixPolicy.REQUIRED,
172 SCEAffixPolicy.REQUIRED,
173 SCEAffixPolicy.REQUIRED,
174 SCEAffixPolicy.REQUIRED,
175 custom_policies={}
176 )
177
178 unpacking_profile = SCEProfile(
179 SCEAffixPolicy.NOT_NEEDED,
180 SCEAffixPolicy.NOT_NEEDED,
181 SCEAffixPolicy.NOT_NEEDED,
182 SCEAffixPolicy.NOT_NEEDED,
183 custom_policies={}
184 )
185
186 stanza = string_to_domish("""<message from="foo@example.com" to="bar@example.com">
187 <body>
188 When unpacking, all affixes are loaded, even those marked as NOT_NEEDED.
189 </body>
190 </message>""")
191
192 envelope_serialized = XEP_0420.pack_stanza(packing_profile, stanza)
193
194 affix_values = XEP_0420.unpack_stanza(unpacking_profile, stanza, envelope_serialized)
195
196 assert affix_values.rpad is not None
197 assert affix_values.timestamp is not None
198 assert affix_values.recipient is not None
199 assert affix_values.sender is not None
200
201 # When unpacking, all affixes are verified, even if they are NOT_NEEDED by the profile
202 stanza = string_to_domish(
203 """<message from="fooo@example.com" to="baz@example.com"></message>"""
204 )
205
206 with pytest.raises(AffixVerificationFailed):
207 XEP_0420.unpack_stanza(unpacking_profile, stanza, envelope_serialized)
208
209
210 def test_incomplete_affixes() -> None: # pylint: disable=missing-function-docstring
211 packing_profile = SCEProfile(
212 SCEAffixPolicy.NOT_NEEDED,
213 SCEAffixPolicy.NOT_NEEDED,
214 SCEAffixPolicy.NOT_NEEDED,
215 SCEAffixPolicy.NOT_NEEDED,
216 custom_policies={}
217 )
218
219 unpacking_profile = SCEProfile(
220 SCEAffixPolicy.REQUIRED,
221 SCEAffixPolicy.REQUIRED,
222 SCEAffixPolicy.REQUIRED,
223 SCEAffixPolicy.REQUIRED,
224 custom_policies={}
225 )
226
227 stanza = string_to_domish("""<message from="foo@example.com" to="bar@example.com">
228 <body>Check that all affixes REQUIRED by the profile are present.</body>
229 </message>""")
230
231 with pytest.raises(ProfileRequirementsNotMet):
232 XEP_0420.unpack_stanza(
233 unpacking_profile,
234 stanza,
235 XEP_0420.pack_stanza(packing_profile, stanza)
236 )
237
238 # Do the same but with a custom affix missing
239 custom_affix = CustomAffixImpl()
240
241 packing_profile = SCEProfile(
242 SCEAffixPolicy.NOT_NEEDED,
243 SCEAffixPolicy.NOT_NEEDED,
244 SCEAffixPolicy.NOT_NEEDED,
245 SCEAffixPolicy.NOT_NEEDED,
246 custom_policies={ custom_affix: SCEAffixPolicy.NOT_NEEDED }
247 )
248
249 unpacking_profile = SCEProfile(
250 SCEAffixPolicy.NOT_NEEDED,
251 SCEAffixPolicy.NOT_NEEDED,
252 SCEAffixPolicy.NOT_NEEDED,
253 SCEAffixPolicy.NOT_NEEDED,
254 custom_policies={ custom_affix: SCEAffixPolicy.REQUIRED }
255 )
256
257 stanza = string_to_domish("""<message from="foo@example.com" to="bar@example.com">
258 <body>
259 Check that all affixes REQUIRED by the profile are present, including custom
260 affixes.
261 </body>
262 </message>""")
263
264 with pytest.raises(ProfileRequirementsNotMet):
265 XEP_0420.unpack_stanza(
266 unpacking_profile,
267 stanza,
268 XEP_0420.pack_stanza(packing_profile, stanza)
269 )
270
271
272 def test_rpad_affix() -> None: # pylint: disable=missing-function-docstring
273 profile = SCEProfile(
274 SCEAffixPolicy.REQUIRED,
275 SCEAffixPolicy.NOT_NEEDED,
276 SCEAffixPolicy.NOT_NEEDED,
277 SCEAffixPolicy.NOT_NEEDED,
278 custom_policies={}
279 )
280
281 for _ in range(100):
282 stanza = string_to_domish("""<message from="foo@example.com" to="bar@example.com">
283 <body>OK</body>
284 </message>""")
285
286 affix_values = XEP_0420.unpack_stanza(
287 profile,
288 stanza,
289 XEP_0420.pack_stanza(profile, stanza)
290 )
291
292 # Test that the rpad exists and that the content elements are always padded to at
293 # least 53 characters
294 assert affix_values.rpad is not None
295 assert len(affix_values.rpad) >= 53 - len("<body xmlns='jabber:client'>OK</body>")
296
297
298 def test_time_affix() -> None: # pylint: disable=missing-function-docstring
299 profile = SCEProfile(
300 SCEAffixPolicy.NOT_NEEDED,
301 SCEAffixPolicy.REQUIRED,
302 SCEAffixPolicy.NOT_NEEDED,
303 SCEAffixPolicy.NOT_NEEDED,
304 custom_policies={}
305 )
306
307 stanza = string_to_domish(
308 """<message from="foo@example.com" to="bar@example.com"></message>"""
309 )
310
311 envelope_serialized = f"""<envelope xmlns="{NS_SCE}">
312 <content>
313 <body xmlns="jabber:client">
314 The time affix is only parsed and not otherwise verified. Not much to test
315 here.
316 </body>
317 </content>
318 <time stamp="1969-07-21T02:56:15Z"/>
319 </envelope>""".encode("utf-8")
320
321 affix_values = XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
322 assert affix_values.timestamp == datetime(1969, 7, 21, 2, 56, 15, tzinfo=timezone.utc)
323
324
325 def test_to_affix() -> None: # pylint: disable=missing-function-docstring
326 profile = SCEProfile(
327 SCEAffixPolicy.NOT_NEEDED,
328 SCEAffixPolicy.NOT_NEEDED,
329 SCEAffixPolicy.REQUIRED,
330 SCEAffixPolicy.NOT_NEEDED,
331 custom_policies={}
332 )
333
334 stanza = string_to_domish("""<message from="foo@example.com" to="bar@example.com">
335 <body>Check that the ``to`` affix is correctly added.</body>
336 </message>""")
337
338 envelope_serialized = XEP_0420.pack_stanza(profile, stanza)
339 affix_values = XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
340 assert affix_values.recipient is not None
341 assert affix_values.recipient.userhost() == "bar@example.com"
342
343 # Check that a mismatch in recipient bare JID causes an exception to be raised
344 stanza = string_to_domish(
345 """<message from="foo@example.com" to="baz@example.com"></message>"""
346 )
347
348 with pytest.raises(AffixVerificationFailed):
349 XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
350
351 # Check that only the bare JID matters
352 stanza = string_to_domish(
353 """<message from="foo@example.com" to="bar@example.com/device"></message>"""
354 )
355
356 affix_values = XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
357 assert affix_values.recipient is not None
358 assert affix_values.recipient.userhost() == "bar@example.com"
359
360 stanza = string_to_domish("""<message from="foo@example.com">
361 <body>
362 Check that a missing "to" attribute on the stanza fails stanza packing.
363 </body>
364 </message>""")
365
366 with pytest.raises(ValueError):
367 XEP_0420.pack_stanza(profile, stanza)
368
369
370 def test_from_affix() -> None: # pylint: disable=missing-function-docstring
371 profile = SCEProfile(
372 SCEAffixPolicy.NOT_NEEDED,
373 SCEAffixPolicy.NOT_NEEDED,
374 SCEAffixPolicy.NOT_NEEDED,
375 SCEAffixPolicy.REQUIRED,
376 custom_policies={}
377 )
378
379 stanza = string_to_domish("""<message from="foo@example.com" to="bar@example.com">
380 <body>Check that the ``from`` affix is correctly added.</body>
381 </message>""")
382
383 envelope_serialized = XEP_0420.pack_stanza(profile, stanza)
384 affix_values = XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
385 assert affix_values.sender is not None
386 assert affix_values.sender.userhost() == "foo@example.com"
387
388 # Check that a mismatch in sender bare JID causes an exception to be raised
389 stanza = string_to_domish(
390 """<message from="fooo@example.com" to="bar@example.com"></message>"""
391 )
392
393 with pytest.raises(AffixVerificationFailed):
394 XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
395
396 # Check that only the bare JID matters
397 stanza = string_to_domish(
398 """<message from="foo@example.com/device" to="bar@example.com"></message>"""
399 )
400
401 affix_values = XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
402 assert affix_values.sender is not None
403 assert affix_values.sender.userhost() == "foo@example.com"
404
405 stanza = string_to_domish("""<message to="bar@example.com">
406 <body>
407 Check that a missing "from" attribute on the stanza fails stanza packing.
408 </body>
409 </message>""")
410
411 with pytest.raises(ValueError):
412 XEP_0420.pack_stanza(profile, stanza)
413
414
415 def test_custom_affixes() -> None: # pylint: disable=missing-function-docstring
416 custom_affix = CustomAffixImpl()
417
418 packing_profile = SCEProfile(
419 SCEAffixPolicy.NOT_NEEDED,
420 SCEAffixPolicy.NOT_NEEDED,
421 SCEAffixPolicy.NOT_NEEDED,
422 SCEAffixPolicy.NOT_NEEDED,
423 custom_policies={ custom_affix: SCEAffixPolicy.REQUIRED }
424 )
425
426 unpacking_profile = SCEProfile(
427 SCEAffixPolicy.NOT_NEEDED,
428 SCEAffixPolicy.NOT_NEEDED,
429 SCEAffixPolicy.NOT_NEEDED,
430 SCEAffixPolicy.NOT_NEEDED,
431 custom_policies={}
432 )
433
434 stanza = string_to_domish("""<message from="foo@example.com" to="bar@example.com">
435 <body>
436 If a custom affix is included in the envelope, but not excpected by the
437 recipient, the schema validation should fail.
438 </body>
439 </message>""")
440
441 with pytest.raises(ValueError):
442 XEP_0420.unpack_stanza(
443 unpacking_profile,
444 stanza,
445 XEP_0420.pack_stanza(packing_profile, stanza)
446 )
447
448 profile = packing_profile
449
450 stanza = string_to_domish("""<message from="foo@example.com/device0"
451 to="bar@example.com/Libervia.123">
452 <body>The affix element should be returned as part of the affix values.</body>
453 </message>""")
454
455 affix_values = XEP_0420.unpack_stanza(
456 profile,
457 stanza,
458 XEP_0420.pack_stanza(profile, stanza)
459 )
460
461 assert custom_affix in affix_values.custom
462 assert affix_values.custom[custom_affix].getAttribute("recipient") == \
463 "bar@example.com/Libervia.123"
464 assert affix_values.custom[custom_affix].getAttribute("sender") == \
465 "foo@example.com/device0"
466
467
468 def test_namespace_conversion() -> None: # pylint: disable=missing-function-docstring
469 profile = SCEProfile(
470 SCEAffixPolicy.NOT_NEEDED,
471 SCEAffixPolicy.NOT_NEEDED,
472 SCEAffixPolicy.NOT_NEEDED,
473 SCEAffixPolicy.NOT_NEEDED,
474 custom_policies={}
475 )
476
477 stanza = domish.Element((None, "message"))
478 stanza["from"] = "foo@example.com"
479 stanza["to"] = "bar@example.com"
480 stanza.addElement(
481 "body",
482 content=(
483 "This body element has namespace ``None``, which has to be replaced with"
484 " jabber:client."
485 )
486 )
487
488 envelope_serialized = XEP_0420.pack_stanza(profile, stanza)
489 envelope = string_to_domish(envelope_serialized.decode("utf-8"))
490 content = next(cast(Iterator[domish.Element], envelope.elements(NS_SCE, "content")))
491
492 # The body should have been assigned ``jabber:client`` as its namespace
493 assert next(
494 cast(Iterator[domish.Element], content.elements("jabber:client", "body")),
495 None
496 ) is not None
497
498 XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
499
500 # The body should still have ``jabber:client`` after unpacking
501 assert next(
502 cast(Iterator[domish.Element], stanza.elements("jabber:client", "body")),
503 None
504 ) is not None
505
506
507 def test_non_encryptable_elements() -> None: # pylint: disable=missing-function-docstring
508 profile = SCEProfile(
509 SCEAffixPolicy.NOT_NEEDED,
510 SCEAffixPolicy.NOT_NEEDED,
511 SCEAffixPolicy.NOT_NEEDED,
512 SCEAffixPolicy.NOT_NEEDED,
513 custom_policies={}
514 )
515
516 stanza = string_to_domish("""<message from="foo@example.com" to="bar@example.com">
517 <body>This stanza includes a store hint which must not be encrypted.</body>
518 <store xmlns="urn:xmpp:hints"/>
519 </message>""")
520
521 envelope_serialized = XEP_0420.pack_stanza(profile, stanza)
522 envelope = string_to_domish(envelope_serialized.decode("utf-8"))
523 content = next(cast(Iterator[domish.Element], envelope.elements(NS_SCE, "content")))
524
525 # The store hint must not have been moved to the content element
526 assert next(
527 cast(Iterator[domish.Element], stanza.elements(NS_HINTS, "store")),
528 None
529 ) is not None
530
531 assert next(
532 cast(Iterator[domish.Element], content.elements(NS_HINTS, "store")),
533 None
534 ) is None
535
536 stanza = string_to_domish(
537 """<message from="foo@example.com" to="bar@example.com"></message>"""
538 )
539
540 envelope_serialized = f"""<envelope xmlns="{NS_SCE}">
541 <content>
542 <body xmlns="jabber:client">
543 The store hint must not be moved to the stanza.
544 </body>
545 <store xmlns="urn:xmpp:hints"/>
546 </content>
547 </envelope>""".encode("utf-8")
548
549 XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
550
551 assert next(
552 cast(Iterator[domish.Element], stanza.elements(NS_HINTS, "store")),
553 None
554 ) is None
555
556
557 def test_schema_validation() -> None: # pylint: disable=missing-function-docstring
558 profile = SCEProfile(
559 SCEAffixPolicy.NOT_NEEDED,
560 SCEAffixPolicy.NOT_NEEDED,
561 SCEAffixPolicy.NOT_NEEDED,
562 SCEAffixPolicy.NOT_NEEDED,
563 custom_policies={}
564 )
565
566 stanza = string_to_domish(
567 """<message from="foo@example.com" to="bar@example.com"></message>"""
568 )
569
570 envelope_serialized = f"""<envelope xmlns="{NS_SCE}">
571 <content>
572 <body xmlns="jabber:client">
573 An unknwon affix should cause a schema validation error.
574 </body>
575 <store xmlns="urn:xmpp:hints"/>
576 </content>
577 <unknown-affix unknown-attr="unknown"/>
578 </envelope>""".encode("utf-8")
579
580 with pytest.raises(ValueError):
581 XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)