Implemented dmarc_addresses feature.
[mailman.git] / src / mailman / rest / tests / test_listconf.py
blob16fe49d2e155563fa660e19f5da940763fe226f4
1 # Copyright (C) 2014-2023 by the Free Software Foundation, Inc.
3 # This file is part of GNU Mailman.
5 # GNU Mailman is free software: you can redistribute it and/or modify it under
6 # the terms of the GNU General Public License as published by the Free
7 # Software Foundation, either version 3 of the License, or (at your option)
8 # any later version.
10 # GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
11 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12 # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
13 # more details.
15 # You should have received a copy of the GNU General Public License along with
16 # GNU Mailman. If not, see <https://www.gnu.org/licenses/>.
18 """Test list configuration via the REST API."""
20 import unittest
22 from mailman.app.lifecycle import create_list
23 from mailman.database.transaction import transaction
24 from mailman.interfaces.digests import DigestFrequency
25 from mailman.interfaces.mailinglist import (
26 IAcceptableAliasSet,
27 SubscriptionPolicy,
29 from mailman.interfaces.template import ITemplateManager
30 from mailman.testing.helpers import call_api
31 from mailman.testing.layers import RESTLayer
32 from urllib.error import HTTPError
33 from zope.component import getUtility
36 # The representation of the listconf resource as a dictionary. This is used
37 # when PUTting to the list's configuration resource.
38 RESOURCE = dict(
39 acceptable_aliases=[
40 'ant@example.com',
41 'bee@example.com',
42 'cat@example.com',
44 accept_these_nonmembers=[
45 r'ant_*@example.com',
47 admin_immed_notify=False,
48 admin_notify_mchanges=True,
49 administrivia=False,
50 advertised=False,
51 allow_list_posts=False,
52 anonymous_list=True,
53 archive_policy='never',
54 archive_rendering_mode='text',
55 autorespond_owner='respond_and_discard',
56 autorespond_postings='respond_and_continue',
57 autorespond_requests='respond_and_discard',
58 autoresponse_grace_period='45d',
59 autoresponse_owner_text='the owner',
60 autoresponse_postings_text='the mailing list',
61 autoresponse_request_text='the robot',
62 bounce_info_stale_after='7d',
63 bounce_notify_owner_on_bounce_increment=False,
64 bounce_notify_owner_on_disable=False,
65 bounce_notify_owner_on_removal=True,
66 bounce_score_threshold=5,
67 bounce_you_are_disabled_warnings=3,
68 bounce_you_are_disabled_warnings_interval='2d',
69 collapse_alternatives=False,
70 convert_html_to_plaintext=True,
71 default_member_action='hold',
72 default_nonmember_action='discard',
73 description='This is my mailing list',
74 digest_send_periodic=True,
75 digest_size_threshold=10.5,
76 digest_volume_frequency='monthly',
77 digests_enabled=True,
78 discard_these_nonmembers=[
79 'aperson@example.com',
81 display_name='Fnords',
82 dmarc_mitigate_action='munge_from',
83 dmarc_mitigate_unconditionally=False,
84 dmarc_addresses='',
85 dmarc_moderation_notice='Some moderation notice',
86 dmarc_wrapped_message_text='some message text',
87 emergency=False,
88 filter_action='discard',
89 filter_extensions=['.exe'],
90 filter_content=True,
91 filter_types=['application/zip'],
92 first_strip_reply_to=True,
93 forward_unrecognized_bounces_to='administrators',
94 gateway_to_mail=False,
95 gateway_to_news=False,
96 goodbye_message_uri='mailman:///goodbye.txt',
97 hold_these_nonmembers=[
98 r'*@example.com',
100 include_rfc2369_headers=False,
101 info='This is the mailing list info',
102 linked_newsgroup='',
103 moderator_password='password',
104 max_message_size='150',
105 newsgroup_moderation='none',
106 nntp_prefix_subject_too=False,
107 pass_extensions=['.pdf'],
108 pass_types=['image/jpeg'],
109 personalize='none',
110 posting_pipeline='virgin',
111 preferred_language='en',
112 process_bounces=True,
113 reject_these_nonmembers=[
114 'bperson@example.com',
116 reply_goes_to_list='point_to_list',
117 reply_to_address='bee@example.com',
118 require_explicit_destination=True,
119 member_roster_visibility='public',
120 send_goodbye_message=False,
121 send_welcome_message=False,
122 subject_prefix='[ant]',
123 subscription_policy='confirm_then_moderate',
124 unsubscription_policy='confirm',
125 welcome_message_uri='mailman:///welcome.txt',
126 respond_to_post_requests=True,
127 max_num_recipients=150,
128 max_days_to_hold=20,
132 class TestConfiguration(unittest.TestCase):
133 """Test list configuration via the REST API."""
135 layer = RESTLayer
137 def setUp(self):
138 with transaction():
139 self._mlist = create_list('ant@example.com')
141 def test_get_missing_attribute(self):
142 with self.assertRaises(HTTPError) as cm:
143 call_api(
144 'http://localhost:9001/3.0/lists/ant.example.com/config/bogus')
145 self.assertEqual(cm.exception.code, 404)
146 self.assertEqual(cm.exception.reason, 'Unknown attribute: bogus')
148 def test_put_configuration(self):
149 # When using PUT, all writable attributes must be included.
150 json, response = call_api(
151 'http://localhost:9001/3.0/lists/ant.example.com/config',
152 RESOURCE,
153 'PUT')
154 self.assertEqual(response.status_code, 204)
155 self.assertEqual(self._mlist.display_name, 'Fnords')
156 # All three acceptable aliases were set.
157 self.assertEqual(set(IAcceptableAliasSet(self._mlist).aliases),
158 set(RESOURCE['acceptable_aliases']))
160 def test_put_attribute(self):
161 json, response = call_api(
162 'http://localhost:9001/3.0/lists/ant.example.com'
163 '/config/reply_to_address')
164 self.assertEqual(json['reply_to_address'], '')
165 json, response = call_api(
166 'http://localhost:9001/3.0/lists/ant.example.com'
167 '/config/reply_to_address',
168 dict(reply_to_address='bar@ant.example.com'),
169 'PUT')
170 self.assertEqual(response.status_code, 204)
171 json, response = call_api(
172 'http://localhost:9001/3.0/lists/ant.example.com'
173 '/config/reply_to_address')
174 self.assertEqual(json['reply_to_address'], 'bar@ant.example.com')
176 def test_put_extra_attribute(self):
177 bogus_resource = RESOURCE.copy()
178 bogus_resource['bogus'] = 'yes'
179 with self.assertRaises(HTTPError) as cm:
180 call_api(
181 'http://localhost:9001/3.0/lists/ant.example.com/config',
182 bogus_resource,
183 'PUT')
184 self.assertEqual(cm.exception.code, 400)
185 self.assertTrue('Unexpected parameters: bogus' in cm.exception.reason)
187 def test_put_attribute_mismatch(self):
188 json, response = call_api(
189 'http://localhost:9001/3.0/lists/ant.example.com'
190 '/config/reply_to_address')
191 self.assertEqual(json['reply_to_address'], '')
192 with self.assertRaises(HTTPError) as cm:
193 json, response = call_api(
194 'http://localhost:9001/3.0/lists/ant.example.com'
195 '/config/reply_to_address',
196 dict(display_name='bar@ant.example.com'),
197 'PUT')
198 self.assertEqual(cm.exception.code, 400)
199 self.assertTrue(
200 'Unexpected parameters: display_name' in cm.exception.reason)
202 def test_put_attribute_double(self):
203 with self.assertRaises(HTTPError) as cm:
204 resource, response = call_api(
205 'http://localhost:9001/3.0/lists/ant.example.com'
206 '/config/reply_to_address',
207 dict(display_name='bar@ant.example.com',
208 reply_to_address='foo@example.com'),
209 'PUT')
210 self.assertEqual(cm.exception.code, 400)
211 self.assertTrue(
212 'Unexpected parameters: display_name' in cm.exception.reason)
214 def test_put_read_only_attribute(self):
215 with self.assertRaises(HTTPError) as cm:
216 call_api('http://localhost:9001/3.0/lists/ant.example.com'
217 '/config/mail_host',
218 dict(mail_host='foo.example.com'),
219 'PUT')
220 self.assertEqual(cm.exception.code, 400)
221 self.assertEqual(cm.exception.reason, 'Read-only attribute: mail_host')
223 def test_put_missing_attribute(self):
224 with self.assertRaises(HTTPError) as cm:
225 call_api(
226 'http://localhost:9001/3.0/lists/ant.example.com/config/bogus',
227 dict(bogus='no matter'),
228 'PUT')
229 self.assertEqual(cm.exception.code, 404)
230 self.assertTrue('Unknown attribute: bogus' in cm.exception.reason)
232 def test_patch_subscription_policy(self):
233 # The new subscription_policy value can be patched.
235 # To start with, the subscription policy is confirm by default.
236 resource, response = call_api(
237 'http://localhost:9001/3.0/lists/ant.example.com/config')
238 self.assertEqual(resource['subscription_policy'], 'confirm')
239 # Let's patch it to do some moderation.
240 resource, response = call_api(
241 'http://localhost:9001/3.0/lists/ant.example.com/config', dict(
242 subscription_policy='confirm_then_moderate'),
243 method='PATCH')
244 self.assertEqual(response.status_code, 204)
245 # And now we verify that it has the requested setting.
246 self.assertEqual(self._mlist.subscription_policy,
247 SubscriptionPolicy.confirm_then_moderate)
249 def test_patch_attribute_double(self):
250 with self.assertRaises(HTTPError) as cm:
251 call_api(
252 'http://localhost:9001/3.0/lists/ant.example.com'
253 '/config/reply_to_address',
254 dict(display_name='bar@ant.example.com',
255 reply_to_address='foo'),
256 'PATCH')
257 self.assertEqual(cm.exception.code, 400)
258 self.assertEqual(cm.exception.reason, 'Expected 1 attribute, got 2')
260 def test_unknown_patch_attribute(self):
261 with self.assertRaises(HTTPError) as cm:
262 call_api('http://localhost:9001/3.0/lists/ant.example.com/config',
263 dict(bogus=1),
264 'PATCH')
265 self.assertEqual(cm.exception.code, 400)
266 self.assertEqual(cm.exception.reason, 'Unknown attribute: bogus')
268 def test_read_only_patch_attribute(self):
269 with self.assertRaises(HTTPError) as cm:
270 call_api('http://localhost:9001/3.0/lists/ant.example.com'
271 '/config/mail_host',
272 dict(mail_host='foo.example.com'),
273 'PATCH')
274 self.assertEqual(cm.exception.code, 400)
275 self.assertEqual(cm.exception.reason, 'Read-only attribute: mail_host')
277 def test_patch_missing_attribute(self):
278 with self.assertRaises(HTTPError) as cm:
279 call_api(
280 'http://localhost:9001/3.0/lists/ant.example.com/config/bogus',
281 dict(bogus='no matter'),
282 'PATCH')
283 self.assertEqual(cm.exception.code, 404)
284 self.assertEqual(cm.exception.reason, 'Unknown attribute: bogus')
286 def test_patch_bad_value(self):
287 with self.assertRaises(HTTPError) as cm:
288 call_api(
289 'http://localhost:9001/3.0/lists/ant.example.com/config'
290 '/archive_policy',
291 dict(archive_policy='not a valid archive policy'),
292 'PATCH')
293 self.assertEqual(cm.exception.code, 400)
294 self.assertEqual(
295 cm.exception.reason,
296 'Invalid Parameter "archive_policy": Accepted Values are:'
297 ' never, private, public.')
299 def test_patch_with_json_boolean(self):
300 # Ensure we can patch with JSON boolean value.
301 with transaction():
302 self._mlist.gateway_to_mail = False
303 response = call_api(
304 'http://localhost:9001/3.0/lists/ant.example.com/config',
305 method='PATCH', headers={'Content-Type': 'application/json'},
306 json={'gateway_to_mail': True})
307 self.assertEqual(response[1].status_code, 204)
308 self.assertTrue(self._mlist.gateway_to_mail)
310 def test_bad_pipeline_name(self):
311 with self.assertRaises(HTTPError) as cm:
312 call_api(
313 'http://localhost:9001/3.0/lists/ant.example.com/config'
314 '/posting_pipeline',
315 dict(posting_pipeline='not a valid pipeline'),
316 'PATCH')
317 self.assertEqual(cm.exception.code, 400)
318 self.assertEqual(
319 cm.exception.reason,
320 'Invalid Parameter "posting_pipeline": Unknown pipeline: not a valid pipeline.') # noqa: E501
322 def test_get_digest_send_periodic(self):
323 with transaction():
324 self._mlist.digest_send_periodic = False
325 resource, response = call_api(
326 'http://localhost:9001/3.0/lists/ant.example.com/config'
327 '/digest_send_periodic')
328 self.assertFalse(resource['digest_send_periodic'])
330 def test_patch_digest_send_periodic(self):
331 with transaction():
332 self._mlist.digest_send_periodic = False
333 resource, response = call_api(
334 'http://localhost:9001/3.0/lists/ant.example.com/config'
335 '/digest_send_periodic',
336 dict(digest_send_periodic=True),
337 'PATCH')
338 self.assertEqual(response.status_code, 204)
339 self.assertTrue(self._mlist.digest_send_periodic)
341 def test_put_digest_send_periodic(self):
342 with transaction():
343 self._mlist.digest_send_periodic = False
344 resource, response = call_api(
345 'http://localhost:9001/3.0/lists/ant.example.com/config'
346 '/digest_send_periodic',
347 dict(digest_send_periodic=True),
348 'PUT')
349 self.assertEqual(response.status_code, 204)
350 self.assertTrue(self._mlist.digest_send_periodic)
352 def test_get_digest_volume_frequency(self):
353 with transaction():
354 self._mlist.digest_volume_frequency = DigestFrequency.yearly
355 resource, response = call_api(
356 'http://localhost:9001/3.0/lists/ant.example.com/config'
357 '/digest_volume_frequency')
358 self.assertEqual(resource['digest_volume_frequency'], 'yearly')
360 def test_patch_digest_volume_frequency(self):
361 with transaction():
362 self._mlist.digest_volume_frequency = DigestFrequency.yearly
363 resource, response = call_api(
364 'http://localhost:9001/3.0/lists/ant.example.com/config'
365 '/digest_volume_frequency',
366 dict(digest_volume_frequency='monthly'),
367 'PATCH')
368 self.assertEqual(response.status_code, 204)
369 self.assertEqual(self._mlist.digest_volume_frequency,
370 DigestFrequency.monthly)
372 def test_put_digest_volume_frequency(self):
373 with transaction():
374 self._mlist.digest_volume_frequency = DigestFrequency.yearly
375 resource, response = call_api(
376 'http://localhost:9001/3.0/lists/ant.example.com/config'
377 '/digest_volume_frequency',
378 dict(digest_volume_frequency='monthly'),
379 'PUT')
380 self.assertEqual(response.status_code, 204)
381 self.assertEqual(self._mlist.digest_volume_frequency,
382 DigestFrequency.monthly)
384 def test_bad_patch_digest_volume_frequency(self):
385 with transaction():
386 self._mlist.digest_volume_frequency = DigestFrequency.yearly
387 with self.assertRaises(HTTPError) as cm:
388 call_api(
389 'http://localhost:9001/3.0/lists/ant.example.com/config'
390 '/digest_volume_frequency',
391 dict(digest_volume_frequency='once in a while'),
392 'PATCH')
393 self.assertEqual(cm.exception.code, 400)
394 self.assertEqual(
395 cm.exception.reason,
396 'Invalid Parameter "digest_volume_frequency": Accepted Values are:'
397 ' yearly, monthly, quarterly, weekly, daily.')
399 def test_bad_put_digest_volume_frequency(self):
400 with transaction():
401 self._mlist.digest_volume_frequency = DigestFrequency.yearly
402 with self.assertRaises(HTTPError) as cm:
403 call_api(
404 'http://localhost:9001/3.0/lists/ant.example.com/config'
405 '/digest_volume_frequency',
406 dict(digest_volume_frequency='once in a while'),
407 'PUT')
408 self.assertEqual(cm.exception.code, 400)
409 self.assertEqual(
410 cm.exception.reason,
411 'Invalid Parameter "digest_volume_frequency": Accepted Values are:'
412 ' yearly, monthly, quarterly, weekly, daily.')
414 def test_get_digests_enabled(self):
415 with transaction():
416 self._mlist.digests_enabled = False
417 resource, response = call_api(
418 'http://localhost:9001/3.0/lists/ant.example.com/config'
419 '/digests_enabled')
420 self.assertFalse(resource['digests_enabled'])
422 def test_patch_digests_enabled(self):
423 with transaction():
424 self._mlist.digests_enabled = False
425 resource, response = call_api(
426 'http://localhost:9001/3.0/lists/ant.example.com/config'
427 '/digests_enabled',
428 dict(digests_enabled=True),
429 'PATCH')
430 self.assertEqual(response.status_code, 204)
431 self.assertTrue(self._mlist.digests_enabled)
433 def test_put_digests_enabled(self):
434 with transaction():
435 self._mlist.digests_enabled = False
436 resource, response = call_api(
437 'http://localhost:9001/3.0/lists/ant.example.com/config'
438 '/digests_enabled',
439 dict(digests_enabled=True),
440 'PUT')
441 self.assertEqual(response.status_code, 204)
442 self.assertTrue(self._mlist.digests_enabled)
444 def test_get_goodbye_message_uri(self):
445 with transaction():
446 getUtility(ITemplateManager).set(
447 'list:user:notice:goodbye', self._mlist.list_id,
448 'mailman:///goodbye.txt')
449 resource, response = call_api(
450 'http://localhost:9001/3.0/lists/ant.example.com/config'
451 '/goodbye_message_uri')
452 self.assertEqual(
453 resource['goodbye_message_uri'], 'mailman:///goodbye.txt')
455 def test_patch_goodbye_message_uri_parent(self):
456 resource, response = call_api(
457 'http://localhost:9001/3.0/lists/ant.example.com/config',
458 dict(goodbye_message_uri='mailman:///salutation.txt'),
459 'PATCH')
460 self.assertEqual(response.status_code, 204)
461 self.assertEqual(
462 getUtility(ITemplateManager).raw(
463 'list:user:notice:goodbye', self._mlist.list_id).uri,
464 'mailman:///salutation.txt')
466 def test_patch_goodbye_message_uri(self):
467 resource, response = call_api(
468 'http://localhost:9001/3.0/lists/ant.example.com/config'
469 '/goodbye_message_uri',
470 dict(goodbye_message_uri='mailman:///salutation.txt'),
471 'PATCH')
472 self.assertEqual(response.status_code, 204)
473 self.assertEqual(
474 getUtility(ITemplateManager).raw(
475 'list:user:notice:goodbye', self._mlist.list_id).uri,
476 'mailman:///salutation.txt')
478 def test_put_goodbye_message_uri(self):
479 manager = getUtility(ITemplateManager)
480 with transaction():
481 manager.set(
482 'list:user:notice:goodbye',
483 self._mlist.list_id,
484 'mailman:///somefile.txt')
485 resource, response = call_api(
486 'http://localhost:9001/3.0/lists/ant.example.com/config'
487 '/goodbye_message_uri',
488 dict(goodbye_message_uri='mailman:///salutation.txt'),
489 'PUT')
490 self.assertEqual(response.status_code, 204)
491 self.assertEqual(
492 manager.raw('list:user:notice:goodbye', self._mlist.list_id).uri,
493 'mailman:///salutation.txt')
495 def test_advertised(self):
496 # GL issue #220 claimed advertised was read-only.
497 with transaction():
498 self._mlist.advertised = False
499 resource, response = call_api(
500 'http://localhost:9001/3.0/lists/ant.example.com/config'
501 '/advertised')
502 self.assertFalse(resource['advertised'])
503 resource, response = call_api(
504 'http://localhost:9001/3.0/lists/ant.example.com/config',
505 dict(advertised=True),
506 'PATCH')
507 self.assertTrue(self._mlist.advertised)
509 def test_patch_bad_description_value(self):
510 # Do not accept multiline descriptions. GL#273
511 with self.assertRaises(HTTPError) as cm:
512 call_api(
513 'http://localhost:9001/3.0/lists/ant.example.com/config'
514 '/description',
515 dict(description='This\ncontains\nnewlines.'),
516 'PATCH')
517 self.assertEqual(cm.exception.code, 400)
518 self.assertEqual(
519 cm.exception.reason,
520 'Invalid Parameter "description":'
521 ' This value must be a single line: This\ncontains\nnewlines..')
523 def test_patch_info(self):
524 with transaction():
525 resource, response = call_api(
526 'http://localhost:9001/3.0/lists/ant.example.com/config',
527 dict(info='multiline\ntest\nvalue'),
528 'PATCH')
529 self.assertEqual(self._mlist.info, 'multiline\ntest\nvalue')
530 # Now empty it
531 with transaction():
532 resource, response = call_api(
533 'http://localhost:9001/3.0/lists/ant.example.com/config',
534 dict(info=''),
535 'PATCH')
536 self.assertEqual(self._mlist.info, '')
538 def test_patch_send_welcome_message(self):
539 with transaction():
540 self._mlist.send_welcome_message = False
541 resource, response = call_api(
542 'http://localhost:9001/3.0/lists/ant.example.com/config'
543 '/send_welcome_message',
544 dict(send_welcome_message=True),
545 'PATCH')
546 self.assertEqual(response.status_code, 204)
547 self.assertTrue(self._mlist.send_welcome_message)
549 def test_patch_send_goodbye_message(self):
550 with transaction():
551 self._mlist.send_goodbye_message = False
552 resource, response = call_api(
553 'http://localhost:9001/3.0/lists/ant.example.com/config'
554 '/send_goodbye_message',
555 dict(send_goodbye_message=True),
556 'PATCH')
557 self.assertEqual(response.status_code, 204)
558 self.assertTrue(self._mlist.send_goodbye_message)
560 def test_delete_top_level_listconf(self):
561 with self.assertRaises(HTTPError) as cm:
562 call_api('http://localhost:9001/3.0/lists/ant.example.com/config',
563 method='DELETE')
564 self.assertEqual(cm.exception.code, 400)
565 self.assertEqual(cm.exception.reason,
566 'Cannot delete the list configuration itself')
568 def test_delete_read_only_attribute(self):
569 with self.assertRaises(HTTPError) as cm:
570 call_api(
571 'http://localhost:9001/3.0/lists/ant.example.com/'
572 'config/post_id',
573 method='DELETE')
574 self.assertEqual(cm.exception.code, 400)
575 self.assertEqual(cm.exception.reason,
576 'Read-only attribute: post_id')
578 def test_delete_undeletable_attribute(self):
579 with self.assertRaises(HTTPError) as cm:
580 call_api(
581 'http://localhost:9001/3.0/lists/ant.example.com/'
582 'config/administrivia',
583 method='DELETE')
584 self.assertEqual(cm.exception.code, 400)
585 self.assertEqual(cm.exception.reason,
586 'Attribute cannot be DELETEd: administrivia')