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)
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
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."""
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 (
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.
44 accept_these_nonmembers
=[
47 admin_immed_notify
=False,
48 admin_notify_mchanges
=True,
51 allow_list_posts
=False,
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',
78 discard_these_nonmembers
=[
79 'aperson@example.com',
81 display_name
='Fnords',
82 dmarc_mitigate_action
='munge_from',
83 dmarc_mitigate_unconditionally
=False,
85 dmarc_moderation_notice
='Some moderation notice',
86 dmarc_wrapped_message_text
='some message text',
88 filter_action
='discard',
89 filter_extensions
=['.exe'],
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
=[
100 include_rfc2369_headers
=False,
101 info
='This is the mailing list info',
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'],
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,
132 class TestConfiguration(unittest
.TestCase
):
133 """Test list configuration via the REST API."""
139 self
._mlist
= create_list('ant@example.com')
141 def test_get_missing_attribute(self
):
142 with self
.assertRaises(HTTPError
) as cm
:
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',
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'),
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
:
181 'http://localhost:9001/3.0/lists/ant.example.com/config',
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'),
198 self
.assertEqual(cm
.exception
.code
, 400)
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'),
210 self
.assertEqual(cm
.exception
.code
, 400)
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'
218 dict(mail_host
='foo.example.com'),
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
:
226 'http://localhost:9001/3.0/lists/ant.example.com/config/bogus',
227 dict(bogus
='no matter'),
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'),
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
:
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'),
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',
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'
272 dict(mail_host
='foo.example.com'),
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
:
280 'http://localhost:9001/3.0/lists/ant.example.com/config/bogus',
281 dict(bogus
='no matter'),
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
:
289 'http://localhost:9001/3.0/lists/ant.example.com/config'
291 dict(archive_policy
='not a valid archive policy'),
293 self
.assertEqual(cm
.exception
.code
, 400)
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.
302 self
._mlist
.gateway_to_mail
= False
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
:
313 'http://localhost:9001/3.0/lists/ant.example.com/config'
315 dict(posting_pipeline
='not a valid pipeline'),
317 self
.assertEqual(cm
.exception
.code
, 400)
320 'Invalid Parameter "posting_pipeline": Unknown pipeline: not a valid pipeline.') # noqa: E501
322 def test_get_digest_send_periodic(self
):
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
):
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),
338 self
.assertEqual(response
.status_code
, 204)
339 self
.assertTrue(self
._mlist
.digest_send_periodic
)
341 def test_put_digest_send_periodic(self
):
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),
349 self
.assertEqual(response
.status_code
, 204)
350 self
.assertTrue(self
._mlist
.digest_send_periodic
)
352 def test_get_digest_volume_frequency(self
):
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
):
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'),
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
):
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'),
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
):
386 self
._mlist
.digest_volume_frequency
= DigestFrequency
.yearly
387 with self
.assertRaises(HTTPError
) as cm
:
389 'http://localhost:9001/3.0/lists/ant.example.com/config'
390 '/digest_volume_frequency',
391 dict(digest_volume_frequency
='once in a while'),
393 self
.assertEqual(cm
.exception
.code
, 400)
396 'Invalid Parameter "digest_volume_frequency": Accepted Values are:'
397 ' yearly, monthly, quarterly, weekly, daily.')
399 def test_bad_put_digest_volume_frequency(self
):
401 self
._mlist
.digest_volume_frequency
= DigestFrequency
.yearly
402 with self
.assertRaises(HTTPError
) as cm
:
404 'http://localhost:9001/3.0/lists/ant.example.com/config'
405 '/digest_volume_frequency',
406 dict(digest_volume_frequency
='once in a while'),
408 self
.assertEqual(cm
.exception
.code
, 400)
411 'Invalid Parameter "digest_volume_frequency": Accepted Values are:'
412 ' yearly, monthly, quarterly, weekly, daily.')
414 def test_get_digests_enabled(self
):
416 self
._mlist
.digests_enabled
= False
417 resource
, response
= call_api(
418 'http://localhost:9001/3.0/lists/ant.example.com/config'
420 self
.assertFalse(resource
['digests_enabled'])
422 def test_patch_digests_enabled(self
):
424 self
._mlist
.digests_enabled
= False
425 resource
, response
= call_api(
426 'http://localhost:9001/3.0/lists/ant.example.com/config'
428 dict(digests_enabled
=True),
430 self
.assertEqual(response
.status_code
, 204)
431 self
.assertTrue(self
._mlist
.digests_enabled
)
433 def test_put_digests_enabled(self
):
435 self
._mlist
.digests_enabled
= False
436 resource
, response
= call_api(
437 'http://localhost:9001/3.0/lists/ant.example.com/config'
439 dict(digests_enabled
=True),
441 self
.assertEqual(response
.status_code
, 204)
442 self
.assertTrue(self
._mlist
.digests_enabled
)
444 def test_get_goodbye_message_uri(self
):
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')
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'),
460 self
.assertEqual(response
.status_code
, 204)
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'),
472 self
.assertEqual(response
.status_code
, 204)
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
)
482 'list:user:notice:goodbye',
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'),
490 self
.assertEqual(response
.status_code
, 204)
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.
498 self
._mlist
.advertised
= False
499 resource
, response
= call_api(
500 'http://localhost:9001/3.0/lists/ant.example.com/config'
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),
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
:
513 'http://localhost:9001/3.0/lists/ant.example.com/config'
515 dict(description
='This\ncontains\nnewlines.'),
517 self
.assertEqual(cm
.exception
.code
, 400)
520 'Invalid Parameter "description":'
521 ' This value must be a single line: This\ncontains\nnewlines..')
523 def test_patch_info(self
):
525 resource
, response
= call_api(
526 'http://localhost:9001/3.0/lists/ant.example.com/config',
527 dict(info
='multiline\ntest\nvalue'),
529 self
.assertEqual(self
._mlist
.info
, 'multiline\ntest\nvalue')
532 resource
, response
= call_api(
533 'http://localhost:9001/3.0/lists/ant.example.com/config',
536 self
.assertEqual(self
._mlist
.info
, '')
538 def test_patch_send_welcome_message(self
):
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),
546 self
.assertEqual(response
.status_code
, 204)
547 self
.assertTrue(self
._mlist
.send_welcome_message
)
549 def test_patch_send_goodbye_message(self
):
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),
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',
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
:
571 'http://localhost:9001/3.0/lists/ant.example.com/'
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
:
581 'http://localhost:9001/3.0/lists/ant.example.com/'
582 'config/administrivia',
584 self
.assertEqual(cm
.exception
.code
, 400)
585 self
.assertEqual(cm
.exception
.reason
,
586 'Attribute cannot be DELETEd: administrivia')