Coverage for cc_modules/tests/webview_tests.py: 12%
2087 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
1#!/usr/bin/env python
3"""
4camcops_server/cc_modules/tests/webview_tests.py
6===============================================================================
8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
11 This file is part of CamCOPS.
13 CamCOPS is free software: you can redistribute it and/or modify
14 it under the terms of the GNU General Public License as published by
15 the Free Software Foundation, either version 3 of the License, or
16 (at your option) any later version.
18 CamCOPS is distributed in the hope that it will be useful,
19 but WITHOUT ANY WARRANTY; without even the implied warranty of
20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21 GNU General Public License for more details.
23 You should have received a copy of the GNU General Public License
24 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
26===============================================================================
27"""
29from collections import OrderedDict
30import datetime
31import json
32import logging
33import time
34from typing import cast
35import unittest
36from unittest import mock
38from cardinal_pythonlib.classes import class_attribute_names
39from cardinal_pythonlib.httpconst import MimeType
40from cardinal_pythonlib.nhs import generate_random_nhs_number
41from pendulum import local
42import phonenumbers
43import pyotp
44from pyramid.httpexceptions import HTTPBadRequest, HTTPFound
45from webob.multidict import MultiDict
47from camcops_server.cc_modules.cc_constants import (
48 ERA_NOW,
49 MfaMethod,
50 SmsBackendNames,
51)
52from camcops_server.cc_modules.cc_device import Device
53from camcops_server.cc_modules.cc_group import Group
54from camcops_server.cc_modules.cc_membership import UserGroupMembership
55from camcops_server.cc_modules.cc_patient import Patient
56from camcops_server.cc_modules.cc_patientidnum import PatientIdNum
57from camcops_server.cc_modules.cc_pyramid import (
58 FlashQueue,
59 FormAction,
60 Routes,
61 ViewArg,
62 ViewParam,
63)
64from camcops_server.cc_modules.cc_sms import ConsoleSmsBackend, get_sms_backend
65from camcops_server.cc_modules.cc_taskindex import PatientIdNumIndexEntry
66from camcops_server.cc_modules.cc_taskschedule import (
67 PatientTaskSchedule,
68 TaskSchedule,
69 TaskScheduleItem,
70)
71from camcops_server.cc_modules.cc_unittest import (
72 BasicDatabaseTestCase,
73 DemoDatabaseTestCase,
74)
75from camcops_server.cc_modules.cc_user import (
76 SecurityAccountLockout,
77 SecurityLoginFailure,
78 User,
79)
80from camcops_server.cc_modules.cc_validators import (
81 validate_alphanum_underscore,
82)
83from camcops_server.cc_modules.cc_view_classes import FormWizardMixin
84from camcops_server.cc_modules.tests.cc_view_classes_tests import (
85 TestStateMixin,
86)
87from camcops_server.cc_modules.webview import (
88 add_patient,
89 AddPatientView,
90 AddTaskScheduleItemView,
91 AddTaskScheduleView,
92 any_records_use_group,
93 change_own_password,
94 ChangeOtherPasswordView,
95 ChangeOwnPasswordView,
96 DeleteServerCreatedPatientView,
97 DeleteTaskScheduleItemView,
98 DeleteTaskScheduleView,
99 edit_finalized_patient,
100 edit_group,
101 edit_server_created_patient,
102 edit_user,
103 edit_user_group_membership,
104 EditFinalizedPatientView,
105 EditGroupView,
106 EditOtherUserMfaView,
107 EditOwnUserMfaView,
108 EditServerCreatedPatientView,
109 EditTaskScheduleItemView,
110 EditTaskScheduleView,
111 EditUserGroupAdminView,
112 EraseTaskEntirelyView,
113 EraseTaskLeavingPlaceholderView,
114 LoginView,
115 MfaMixin,
116 SendEmailFromPatientTaskScheduleView,
117)
119log = logging.getLogger(__name__)
122# =============================================================================
123# Unit testing
124# =============================================================================
126UTF8 = "utf-8"
128TEST_NHS_NUMBER_1 = generate_random_nhs_number()
129TEST_NHS_NUMBER_2 = generate_random_nhs_number()
131# https://www.ofcom.org.uk/phones-telecoms-and-internet/information-for-industry/numbering/numbers-for-drama # noqa: E501
132# 07700 900000 to 900999 reserved for TV and Radio drama purposes
133# but unfortunately phonenumbers considers these invalid. However, it offers
134# some examples:
135TEST_PHONE_NUMBER = "+{ctry}{tel}".format(
136 ctry=phonenumbers.PhoneMetadata.metadata_for_region("GB").country_code,
137 tel=phonenumbers.PhoneMetadata.metadata_for_region(
138 "GB"
139 ).personal_number.example_number,
140)
143class WebviewTests(DemoDatabaseTestCase):
144 """
145 Unit tests.
146 """
148 def test_any_records_use_group_true(self) -> None:
149 # All tasks created in DemoDatabaseTestCase will be in this group
150 self.announce("test_any_records_use_group_true")
151 self.assertTrue(any_records_use_group(self.req, self.group))
153 def test_any_records_use_group_false(self) -> None:
154 """
155 If this fails with:
156 sqlalchemy.exc.InvalidRequestError: SQL expression, column, or mapped
157 entity expected - got <name of task base class>
158 then the base class probably needs to be declared __abstract__. See
159 DiagnosisItemBase as an example.
160 """
161 self.announce("test_any_records_use_group_false")
162 group = Group()
163 self.dbsession.add(self.group)
164 self.dbsession.commit()
166 self.assertFalse(any_records_use_group(self.req, group))
168 def test_webview_constant_validators(self) -> None:
169 self.announce("test_webview_constant_validators")
170 for x in class_attribute_names(ViewArg):
171 try:
172 validate_alphanum_underscore(x, self.req)
173 except ValueError:
174 self.fail(f"Operations.{x} fails validate_alphanum_underscore")
177class AddTaskScheduleViewTests(DemoDatabaseTestCase):
178 """
179 Unit tests.
180 """
182 def test_schedule_form_displayed(self) -> None:
183 view = AddTaskScheduleView(self.req)
185 response = view.dispatch()
186 self.assertEqual(response.status_code, 200)
187 self.assertEqual(response.body.decode(UTF8).count("<form"), 1)
189 def test_schedule_is_created(self) -> None:
190 multidict = MultiDict(
191 [
192 ("_charset_", UTF8),
193 ("__formid__", "deform"),
194 (ViewParam.CSRF_TOKEN, self.req.session.get_csrf_token()),
195 (ViewParam.NAME, "MOJO"),
196 (ViewParam.GROUP_ID, self.group.id),
197 (ViewParam.EMAIL_FROM, "server@example.com"),
198 (ViewParam.EMAIL_CC, "cc@example.com"),
199 (ViewParam.EMAIL_BCC, "bcc@example.com"),
200 (ViewParam.EMAIL_SUBJECT, "Subject"),
201 (ViewParam.EMAIL_TEMPLATE, "Email template"),
202 (FormAction.SUBMIT, "submit"),
203 ]
204 )
206 self.req.fake_request_post_from_dict(multidict)
208 view = AddTaskScheduleView(self.req)
210 with self.assertRaises(HTTPFound) as e:
211 view.dispatch()
213 schedule = self.dbsession.query(TaskSchedule).one()
215 self.assertEqual(schedule.name, "MOJO")
216 self.assertEqual(schedule.email_from, "server@example.com")
217 self.assertEqual(schedule.email_bcc, "bcc@example.com")
218 self.assertEqual(schedule.email_subject, "Subject")
219 self.assertEqual(schedule.email_template, "Email template")
221 self.assertEqual(e.exception.status_code, 302)
222 self.assertIn(
223 Routes.VIEW_TASK_SCHEDULES, e.exception.headers["Location"]
224 )
227class EditTaskScheduleViewTests(DemoDatabaseTestCase):
228 """
229 Unit tests.
230 """
232 def setUp(self) -> None:
233 super().setUp()
235 self.schedule = TaskSchedule()
236 self.schedule.group_id = self.group.id
237 self.schedule.name = "Test"
238 self.dbsession.add(self.schedule)
239 self.dbsession.commit()
241 def test_schedule_name_can_be_updated(self) -> None:
242 multidict = MultiDict(
243 [
244 ("_charset_", UTF8),
245 ("__formid__", "deform"),
246 (ViewParam.CSRF_TOKEN, self.req.session.get_csrf_token()),
247 (ViewParam.NAME, "MOJO"),
248 (ViewParam.GROUP_ID, self.group.id),
249 (FormAction.SUBMIT, "submit"),
250 ]
251 )
253 self.req.fake_request_post_from_dict(multidict)
254 self.req.add_get_params(
255 {ViewParam.SCHEDULE_ID: str(self.schedule.id)},
256 set_method_get=False,
257 )
259 view = EditTaskScheduleView(self.req)
261 with self.assertRaises(HTTPFound) as e:
262 view.dispatch()
264 schedule = self.dbsession.query(TaskSchedule).one()
266 self.assertEqual(schedule.name, "MOJO")
268 self.assertEqual(e.exception.status_code, 302)
269 self.assertIn(
270 Routes.VIEW_TASK_SCHEDULES, e.exception.headers["Location"]
271 )
273 def test_group_a_schedule_cannot_be_edited_by_group_b_admin(self) -> None:
274 group_a = Group()
275 group_a.name = "Group A"
276 self.dbsession.add(group_a)
278 group_b = Group()
279 group_b.name = "Group B"
280 self.dbsession.add(group_b)
281 self.dbsession.commit()
283 group_a_schedule = TaskSchedule()
284 group_a_schedule.group_id = group_a.id
285 group_a_schedule.name = "Group A schedule"
286 self.dbsession.add(group_a_schedule)
287 self.dbsession.commit()
289 self.user = User()
290 self.user.upload_group_id = group_b.id
291 self.user.username = "group b admin"
292 self.user.set_password(self.req, "secret123")
293 self.dbsession.add(self.user)
294 self.dbsession.commit()
295 self.req._debugging_user = self.user
297 multidict = MultiDict(
298 [
299 ("_charset_", UTF8),
300 ("__formid__", "deform"),
301 (ViewParam.CSRF_TOKEN, self.req.session.get_csrf_token()),
302 (ViewParam.NAME, "Something else"),
303 (ViewParam.GROUP_ID, self.group.id),
304 (FormAction.SUBMIT, "submit"),
305 ]
306 )
308 self.req.fake_request_post_from_dict(multidict)
309 self.req.add_get_params(
310 {ViewParam.SCHEDULE_ID: str(self.schedule.id)},
311 set_method_get=False,
312 )
314 view = EditTaskScheduleView(self.req)
316 with self.assertRaises(HTTPBadRequest) as cm:
317 view.dispatch()
319 self.assertIn("not a group administrator", cm.exception.message)
322class DeleteTaskScheduleViewTests(DemoDatabaseTestCase):
323 """
324 Unit tests.
325 """
327 def setUp(self) -> None:
328 super().setUp()
330 self.schedule = TaskSchedule()
331 self.schedule.group_id = self.group.id
332 self.schedule.name = "Test"
333 self.dbsession.add(self.schedule)
334 self.dbsession.commit()
336 def test_schedule_item_is_deleted(self) -> None:
337 multidict = MultiDict(
338 [
339 ("_charset_", UTF8),
340 ("__formid__", "deform"),
341 (ViewParam.CSRF_TOKEN, self.req.session.get_csrf_token()),
342 ("confirm_1_t", "true"),
343 ("confirm_2_t", "true"),
344 ("confirm_4_t", "true"),
345 ("__start__", "danger:mapping"),
346 ("target", "7176"),
347 ("user_entry", "7176"),
348 ("__end__", "danger:mapping"),
349 ("delete", "delete"),
350 (FormAction.DELETE, "delete"),
351 ]
352 )
354 self.req.fake_request_post_from_dict(multidict)
356 self.req.add_get_params(
357 {ViewParam.SCHEDULE_ID: str(self.schedule.id)},
358 set_method_get=False,
359 )
360 view = DeleteTaskScheduleView(self.req)
362 with self.assertRaises(HTTPFound) as e:
363 view.dispatch()
365 self.assertEqual(e.exception.status_code, 302)
366 self.assertIn(
367 Routes.VIEW_TASK_SCHEDULES, e.exception.headers["Location"]
368 )
370 item = self.dbsession.query(TaskScheduleItem).one_or_none()
372 self.assertIsNone(item)
375class AddTaskScheduleItemViewTests(DemoDatabaseTestCase):
376 """
377 Unit tests.
378 """
380 def setUp(self) -> None:
381 super().setUp()
383 self.schedule = TaskSchedule()
384 self.schedule.group_id = self.group.id
385 self.schedule.name = "Test"
387 self.dbsession.add(self.schedule)
388 self.dbsession.commit()
390 def test_schedule_item_form_displayed(self) -> None:
391 view = AddTaskScheduleItemView(self.req)
393 self.req.add_get_params({ViewParam.SCHEDULE_ID: str(self.schedule.id)})
395 response = view.dispatch()
396 self.assertEqual(response.status_code, 200)
397 self.assertEqual(response.body.decode(UTF8).count("<form"), 1)
399 def test_schedule_item_is_created(self) -> None:
400 multidict = MultiDict(
401 [
402 ("_charset_", UTF8),
403 ("__formid__", "deform"),
404 (ViewParam.CSRF_TOKEN, self.req.session.get_csrf_token()),
405 (ViewParam.SCHEDULE_ID, self.schedule.id),
406 (ViewParam.TABLE_NAME, "ace3"),
407 (ViewParam.CLINICIAN_CONFIRMATION, "true"),
408 ("__start__", "due_from:mapping"),
409 ("months", "1"),
410 ("weeks", "2"),
411 ("days", "3"),
412 ("__end__", "due_from:mapping"),
413 ("__start__", "due_within:mapping"),
414 ("months", "2"), # 60 days
415 ("weeks", "3"), # 21 days
416 ("days", "15"), # 15 days
417 ("__end__", "due_within:mapping"),
418 (FormAction.SUBMIT, "submit"),
419 ]
420 )
422 self.req.fake_request_post_from_dict(multidict)
424 view = AddTaskScheduleItemView(self.req)
426 with self.assertRaises(HTTPFound) as e:
427 view.dispatch()
429 item = self.dbsession.query(TaskScheduleItem).one()
431 self.assertEqual(item.schedule_id, self.schedule.id)
432 self.assertEqual(item.task_table_name, "ace3")
433 self.assertEqual(item.due_from.in_days(), 47)
434 self.assertEqual(item.due_by.in_days(), 143)
436 self.assertEqual(e.exception.status_code, 302)
437 self.assertIn(
438 f"{Routes.VIEW_TASK_SCHEDULE_ITEMS}"
439 f"?{ViewParam.SCHEDULE_ID}={self.schedule.id}",
440 e.exception.headers["Location"],
441 )
443 def test_schedule_item_is_not_created_on_cancel(self) -> None:
444 multidict = MultiDict(
445 [
446 ("_charset_", UTF8),
447 ("__formid__", "deform"),
448 (ViewParam.CSRF_TOKEN, self.req.session.get_csrf_token()),
449 (ViewParam.SCHEDULE_ID, self.schedule.id),
450 (ViewParam.TABLE_NAME, "ace3"),
451 ("__start__", "due_from:mapping"),
452 ("months", "1"),
453 ("weeks", "2"),
454 ("days", "3"),
455 ("__end__", "due_from:mapping"),
456 ("__start__", "due_within:mapping"),
457 ("months", "4"),
458 ("weeks", "3"),
459 ("days", "2"),
460 ("__end__", "due_within:mapping"),
461 (FormAction.CANCEL, "cancel"),
462 ]
463 )
465 self.req.fake_request_post_from_dict(multidict)
467 view = AddTaskScheduleItemView(self.req)
469 with self.assertRaises(HTTPFound):
470 view.dispatch()
472 item = self.dbsession.query(TaskScheduleItem).one_or_none()
474 self.assertIsNone(item)
476 def test_non_existent_schedule_handled(self) -> None:
477 self.req.add_get_params({ViewParam.SCHEDULE_ID: "99999"})
479 view = AddTaskScheduleItemView(self.req)
481 with self.assertRaises(HTTPBadRequest):
482 view.dispatch()
485class EditTaskScheduleItemViewTests(DemoDatabaseTestCase):
486 """
487 Unit tests.
488 """
490 def setUp(self) -> None:
491 from pendulum import Duration
493 super().setUp()
495 self.schedule = TaskSchedule()
496 self.schedule.group_id = self.group.id
497 self.schedule.name = "Test"
498 self.dbsession.add(self.schedule)
499 self.dbsession.commit()
501 self.item = TaskScheduleItem()
502 self.item.schedule_id = self.schedule.id
503 self.item.task_table_name = "ace3"
504 self.item.due_from = Duration(days=30)
505 self.item.due_by = Duration(days=60)
506 self.dbsession.add(self.item)
507 self.dbsession.commit()
509 def test_schedule_item_is_updated(self) -> None:
510 multidict = MultiDict(
511 [
512 ("_charset_", UTF8),
513 ("__formid__", "deform"),
514 (ViewParam.CSRF_TOKEN, self.req.session.get_csrf_token()),
515 (ViewParam.SCHEDULE_ID, self.schedule.id),
516 (ViewParam.TABLE_NAME, "bmi"),
517 ("__start__", "due_from:mapping"),
518 ("months", "0"),
519 ("weeks", "0"),
520 ("days", "30"),
521 ("__end__", "due_from:mapping"),
522 ("__start__", "due_within:mapping"),
523 ("months", "0"),
524 ("weeks", "0"),
525 ("days", "60"),
526 ("__end__", "due_within:mapping"),
527 (FormAction.SUBMIT, "submit"),
528 ]
529 )
531 self.req.fake_request_post_from_dict(multidict)
533 self.req.add_get_params(
534 {ViewParam.SCHEDULE_ITEM_ID: str(self.item.id)},
535 set_method_get=False,
536 )
537 view = EditTaskScheduleItemView(self.req)
539 with self.assertRaises(HTTPFound) as cm:
540 view.dispatch()
542 self.assertEqual(self.item.task_table_name, "bmi")
543 self.assertEqual(cm.exception.status_code, 302)
544 self.assertIn(
545 f"{Routes.VIEW_TASK_SCHEDULE_ITEMS}"
546 f"?{ViewParam.SCHEDULE_ID}={self.item.schedule_id}",
547 cm.exception.headers["Location"],
548 )
550 def test_schedule_item_is_not_updated_on_cancel(self) -> None:
551 multidict = MultiDict(
552 [
553 ("_charset_", UTF8),
554 ("__formid__", "deform"),
555 (ViewParam.CSRF_TOKEN, self.req.session.get_csrf_token()),
556 (ViewParam.SCHEDULE_ID, self.schedule.id),
557 (ViewParam.TABLE_NAME, "bmi"),
558 ("__start__", "due_from:mapping"),
559 ("months", "0"),
560 ("weeks", "0"),
561 ("days", "30"),
562 ("__end__", "due_from:mapping"),
563 ("__start__", "due_within:mapping"),
564 ("months", "0"),
565 ("weeks", "0"),
566 ("days", "60"),
567 ("__end__", "due_within:mapping"),
568 (FormAction.CANCEL, "cancel"),
569 ]
570 )
572 self.req.fake_request_post_from_dict(multidict)
574 self.req.add_get_params(
575 {ViewParam.SCHEDULE_ITEM_ID: str(self.item.id)},
576 set_method_get=False,
577 )
578 view = EditTaskScheduleItemView(self.req)
580 with self.assertRaises(HTTPFound):
581 view.dispatch()
583 self.assertEqual(self.item.task_table_name, "ace3")
585 def test_non_existent_item_handled(self) -> None:
586 self.req.add_get_params({ViewParam.SCHEDULE_ITEM_ID: "99999"})
588 view = EditTaskScheduleItemView(self.req)
590 with self.assertRaises(HTTPBadRequest):
591 view.dispatch()
593 def test_null_item_handled(self) -> None:
594 view = EditTaskScheduleItemView(self.req)
596 with self.assertRaises(HTTPBadRequest):
597 view.dispatch()
599 def test_get_form_values(self) -> None:
600 view = EditTaskScheduleItemView(self.req)
601 view.object = self.item
603 form_values = view.get_form_values()
605 self.assertEqual(form_values[ViewParam.SCHEDULE_ID], self.schedule.id)
606 self.assertEqual(
607 form_values[ViewParam.TABLE_NAME], self.item.task_table_name
608 )
609 self.assertEqual(form_values[ViewParam.DUE_FROM], self.item.due_from)
611 due_within = self.item.due_by - self.item.due_from
612 self.assertEqual(form_values[ViewParam.DUE_WITHIN], due_within)
614 def test_group_a_item_cannot_be_edited_by_group_b_admin(self) -> None:
615 from pendulum import Duration
617 group_a = Group()
618 group_a.name = "Group A"
619 self.dbsession.add(group_a)
621 group_b = Group()
622 group_b.name = "Group B"
623 self.dbsession.add(group_b)
624 self.dbsession.commit()
626 group_a_schedule = TaskSchedule()
627 group_a_schedule.group_id = group_a.id
628 group_a_schedule.name = "Group A schedule"
629 self.dbsession.add(group_a_schedule)
630 self.dbsession.commit()
632 group_a_item = TaskScheduleItem()
633 group_a_item.schedule_id = group_a_schedule.id
634 group_a_item.task_table_name = "ace3"
635 group_a_item.due_from = Duration(days=30)
636 group_a_item.due_by = Duration(days=60)
637 self.dbsession.add(group_a_item)
638 self.dbsession.commit()
640 self.user = User()
641 self.user.upload_group_id = group_b.id
642 self.user.username = "group b admin"
643 self.user.set_password(self.req, "secret123")
644 self.dbsession.add(self.user)
645 self.dbsession.commit()
646 self.req._debugging_user = self.user
648 view = EditTaskScheduleItemView(self.req)
649 view.object = group_a_item
651 with self.assertRaises(HTTPBadRequest) as cm:
652 view.get_schedule()
654 self.assertIn("not a group administrator", cm.exception.message)
657class DeleteTaskScheduleItemViewTests(DemoDatabaseTestCase):
658 """
659 Unit tests.
660 """
662 def setUp(self) -> None:
663 super().setUp()
665 self.schedule = TaskSchedule()
666 self.schedule.group_id = self.group.id
667 self.schedule.name = "Test"
668 self.dbsession.add(self.schedule)
669 self.dbsession.commit()
671 self.item = TaskScheduleItem()
672 self.item.schedule_id = self.schedule.id
673 self.item.task_table_name = "ace3"
674 self.dbsession.add(self.item)
675 self.dbsession.commit()
677 def test_delete_form_displayed(self) -> None:
678 view = DeleteTaskScheduleItemView(self.req)
680 self.req.add_get_params(
681 {ViewParam.SCHEDULE_ITEM_ID: str(self.item.id)}
682 )
684 response = view.dispatch()
685 self.assertEqual(response.status_code, 200)
686 self.assertEqual(response.body.decode(UTF8).count("<form"), 1)
688 def test_errors_displayed_when_deletion_validation_fails(self) -> None:
689 self.req.fake_request_post_from_dict({FormAction.DELETE: "delete"})
691 self.req.add_get_params(
692 {ViewParam.SCHEDULE_ITEM_ID: str(self.item.id)},
693 set_method_get=False,
694 )
695 view = DeleteTaskScheduleItemView(self.req)
697 response = view.dispatch()
698 self.assertIn(
699 "Errors have been highlighted", response.body.decode(UTF8)
700 )
702 def test_schedule_item_is_deleted(self) -> None:
703 multidict = MultiDict(
704 [
705 ("_charset_", UTF8),
706 ("__formid__", "deform"),
707 (ViewParam.CSRF_TOKEN, self.req.session.get_csrf_token()),
708 ("confirm_1_t", "true"),
709 ("confirm_2_t", "true"),
710 ("confirm_4_t", "true"),
711 ("__start__", "danger:mapping"),
712 ("target", "7176"),
713 ("user_entry", "7176"),
714 ("__end__", "danger:mapping"),
715 ("delete", "delete"),
716 (FormAction.DELETE, "delete"),
717 ]
718 )
720 self.req.fake_request_post_from_dict(multidict)
722 self.req.add_get_params(
723 {ViewParam.SCHEDULE_ITEM_ID: str(self.item.id)},
724 set_method_get=False,
725 )
726 view = DeleteTaskScheduleItemView(self.req)
728 with self.assertRaises(HTTPFound) as e:
729 view.dispatch()
731 self.assertEqual(e.exception.status_code, 302)
732 self.assertIn(
733 f"{Routes.VIEW_TASK_SCHEDULE_ITEMS}"
734 f"?{ViewParam.SCHEDULE_ID}={self.item.schedule_id}",
735 e.exception.headers["Location"],
736 )
738 item = self.dbsession.query(TaskScheduleItem).one_or_none()
740 self.assertIsNone(item)
742 def test_schedule_item_not_deleted_on_cancel(self) -> None:
743 self.req.fake_request_post_from_dict({FormAction.CANCEL: "cancel"})
745 self.req.add_get_params(
746 {ViewParam.SCHEDULE_ITEM_ID: str(self.item.id)},
747 set_method_get=False,
748 )
749 view = DeleteTaskScheduleItemView(self.req)
751 with self.assertRaises(HTTPFound):
752 view.dispatch()
754 item = self.dbsession.query(TaskScheduleItem).one_or_none()
756 self.assertIsNotNone(item)
759class EditFinalizedPatientViewTests(BasicDatabaseTestCase):
760 """
761 Unit tests.
762 """
764 def test_raises_when_patient_does_not_exists(self) -> None:
765 with self.assertRaises(HTTPBadRequest) as cm:
766 edit_finalized_patient(self.req)
768 self.assertEqual(
769 str(cm.exception), "Cannot find Patient with _pk:None"
770 )
772 @unittest.skip("Can't save patient in database without group")
773 def test_raises_when_patient_not_in_a_group(self) -> None:
774 patient = self.create_patient(_group_id=None)
776 self.req.add_get_params({ViewParam.SERVER_PK: str(patient.pk)})
778 with self.assertRaises(HTTPBadRequest) as cm:
779 edit_finalized_patient(self.req)
781 self.assertEqual(str(cm.exception), "Bad patient: not in a group")
783 def test_raises_when_not_authorized(self) -> None:
784 patient = self.create_patient()
786 self.req._debugging_user = User()
788 with mock.patch.object(
789 self.req._debugging_user,
790 "may_administer_group",
791 return_value=False,
792 ):
793 self.req.add_get_params({ViewParam.SERVER_PK: str(patient.pk)})
795 with self.assertRaises(HTTPBadRequest) as cm:
796 edit_finalized_patient(self.req)
798 self.assertEqual(
799 str(cm.exception), "Not authorized to edit this patient"
800 )
802 def test_raises_when_patient_not_finalized(self) -> None:
803 device = Device(name="Not the server device")
804 self.req.dbsession.add(device)
805 self.req.dbsession.commit()
807 patient = self.create_patient(id=1, _device_id=device.id, _era=ERA_NOW)
809 self.req.add_get_params({ViewParam.SERVER_PK: str(patient.pk)})
811 with self.assertRaises(HTTPBadRequest) as cm:
812 edit_finalized_patient(self.req)
814 self.assertIn("Patient is not editable", str(cm.exception))
816 def test_patient_updated(self) -> None:
817 patient = self.create_patient()
819 self.req.add_get_params(
820 {ViewParam.SERVER_PK: str(patient.pk)}, set_method_get=False
821 )
823 multidict = MultiDict(
824 [
825 ("_charset_", UTF8),
826 ("__formid__", "deform"),
827 (ViewParam.CSRF_TOKEN, self.req.session.get_csrf_token()),
828 (ViewParam.SERVER_PK, str(patient.pk)),
829 (ViewParam.GROUP_ID, str(patient.group.id)),
830 (ViewParam.FORENAME, "Jo"),
831 (ViewParam.SURNAME, "Patient"),
832 ("__start__", "dob:mapping"),
833 ("date", "1958-04-19"),
834 ("__end__", "dob:mapping"),
835 ("__start__", "sex:rename"),
836 ("deformField7", "X"),
837 ("__end__", "sex:rename"),
838 (ViewParam.ADDRESS, "New address"),
839 (ViewParam.EMAIL, "newjopatient@example.com"),
840 (ViewParam.GP, "New GP"),
841 (ViewParam.OTHER, "New other"),
842 ("__start__", "id_references:sequence"),
843 ("__start__", "idnum_sequence:mapping"),
844 (ViewParam.WHICH_IDNUM, self.nhs_iddef.which_idnum),
845 (ViewParam.IDNUM_VALUE, str(TEST_NHS_NUMBER_1)),
846 ("__end__", "idnum_sequence:mapping"),
847 ("__end__", "id_references:sequence"),
848 ("__start__", "danger:mapping"),
849 ("target", "7836"),
850 ("user_entry", "7836"),
851 ("__end__", "danger:mapping"),
852 (FormAction.SUBMIT, "submit"),
853 ]
854 )
856 self.req.fake_request_post_from_dict(multidict)
858 with self.assertRaises(HTTPFound):
859 edit_finalized_patient(self.req)
861 self.dbsession.commit()
863 self.assertEqual(patient.forename, "Jo")
864 self.assertEqual(patient.surname, "Patient")
865 self.assertEqual(patient.dob.isoformat(), "1958-04-19")
866 self.assertEqual(patient.sex, "X")
867 self.assertEqual(patient.address, "New address")
868 self.assertEqual(patient.email, "newjopatient@example.com")
869 self.assertEqual(patient.gp, "New GP")
870 self.assertEqual(patient.other, "New other")
872 idnum = patient.get_idnum_objects()[0]
873 self.assertEqual(idnum.patient_id, patient.id)
874 self.assertEqual(idnum.which_idnum, self.nhs_iddef.which_idnum)
875 self.assertEqual(idnum.idnum_value, TEST_NHS_NUMBER_1)
877 self.assertEqual(len(patient.special_notes), 1)
878 note = patient.special_notes[0].note
880 self.assertIn("Patient details edited", note)
881 self.assertIn("forename", note)
882 self.assertIn("Jo", note)
884 self.assertIn("surname", note)
885 self.assertIn("Patient", note)
887 self.assertIn("idnum1", note)
888 self.assertIn(str(TEST_NHS_NUMBER_1), note)
890 messages = self.req.session.peek_flash(FlashQueue.SUCCESS)
892 self.assertIn(
893 f"Amended patient record with server PK {patient.pk}", messages[0]
894 )
895 self.assertIn("forename", messages[0])
896 self.assertIn("Jo", messages[0])
898 self.assertIn("surname", messages[0])
899 self.assertIn("Patient", messages[0])
901 self.assertIn("idnum1", messages[0])
902 self.assertIn(str(TEST_NHS_NUMBER_1), messages[0])
904 def test_message_when_no_changes(self) -> None:
905 patient = self.create_patient(
906 forename="Jo",
907 surname="Patient",
908 dob=datetime.date(1958, 4, 19),
909 sex="F",
910 address="Address",
911 gp="GP",
912 other="Other",
913 )
914 patient_idnum = self.create_patient_idnum(
915 patient_id=patient.id,
916 which_idnum=self.nhs_iddef.which_idnum,
917 idnum_value=TEST_NHS_NUMBER_1,
918 )
919 schedule1 = TaskSchedule()
920 schedule1.group_id = self.group.id
921 schedule1.name = "Test 1"
922 self.dbsession.add(schedule1)
923 self.dbsession.commit()
925 patient_task_schedule = PatientTaskSchedule()
926 patient_task_schedule.patient_pk = patient.pk
927 patient_task_schedule.schedule_id = schedule1.id
928 patient_task_schedule.start_datetime = local(2020, 6, 12, 9)
929 patient_task_schedule.settings = {
930 "name 1": "value 1",
931 "name 2": "value 2",
932 "name 3": "value 3",
933 }
935 self.dbsession.add(patient_task_schedule)
936 self.req.add_get_params(
937 {ViewParam.SERVER_PK: str(patient.pk)}, set_method_get=False
938 )
940 multidict = MultiDict(
941 [
942 ("_charset_", UTF8),
943 ("__formid__", "deform"),
944 (ViewParam.CSRF_TOKEN, self.req.session.get_csrf_token()),
945 (ViewParam.SERVER_PK, str(patient.pk)),
946 (ViewParam.GROUP_ID, patient.group.id),
947 (ViewParam.FORENAME, patient.forename),
948 (ViewParam.SURNAME, patient.surname),
949 ("__start__", "dob:mapping"),
950 ("date", patient.dob.isoformat()),
951 ("__end__", "dob:mapping"),
952 ("__start__", "sex:rename"),
953 ("deformField7", patient.sex),
954 ("__end__", "sex:rename"),
955 (ViewParam.ADDRESS, patient.address),
956 (ViewParam.GP, patient.gp),
957 (ViewParam.OTHER, patient.other),
958 ("__start__", "id_references:sequence"),
959 ("__start__", "idnum_sequence:mapping"),
960 (ViewParam.WHICH_IDNUM, patient_idnum.which_idnum),
961 (ViewParam.IDNUM_VALUE, patient_idnum.idnum_value),
962 ("__end__", "idnum_sequence:mapping"),
963 ("__end__", "id_references:sequence"),
964 ("__start__", "danger:mapping"),
965 ("target", "7836"),
966 ("user_entry", "7836"),
967 ("__end__", "danger:mapping"),
968 ("__start__", "task_schedules:sequence"),
969 ("__start__", "task_schedule_sequence:mapping"),
970 ("schedule_id", schedule1.id),
971 ("__start__", "start_datetime:mapping"),
972 ("date", "2020-06-12"),
973 ("time", "09:00:00"),
974 ("__end__", "start_datetime:mapping"),
975 (
976 "settings",
977 json.dumps(
978 {
979 "name 1": "value 1",
980 "name 2": "value 2",
981 "name 3": "value 3",
982 }
983 ),
984 ),
985 ("__end__", "task_schedule_sequence:mapping"),
986 ("__end__", "task_schedules:sequence"),
987 (FormAction.SUBMIT, "submit"),
988 ]
989 )
991 self.req.fake_request_post_from_dict(multidict)
993 with self.assertRaises(HTTPFound):
994 edit_finalized_patient(self.req)
996 messages = self.req.session.peek_flash(FlashQueue.INFO)
998 self.assertIn("No changes required", messages[0])
1000 def test_template_rendered_with_values(self) -> None:
1001 patient = self.create_patient(
1002 id=1,
1003 forename="Jo",
1004 surname="Patient",
1005 dob=datetime.date(1958, 4, 19),
1006 sex="F",
1007 address="Address",
1008 gp="GP",
1009 other="Other",
1010 )
1011 self.create_patient_idnum(
1012 patient_id=patient.id,
1013 which_idnum=self.nhs_iddef.which_idnum,
1014 idnum_value=TEST_NHS_NUMBER_1,
1015 )
1017 from camcops_server.tasks import Bmi
1019 task1 = Bmi()
1020 task1.id = 1
1021 task1._device_id = patient.device_id
1022 task1._group_id = patient.group_id
1023 task1._era = patient.era
1024 task1.patient_id = patient.id
1025 task1.when_created = self.era_time
1026 task1._current = False
1027 self.dbsession.add(task1)
1029 task2 = Bmi()
1030 task2.id = 2
1031 task2._device_id = patient.device_id
1032 task2._group_id = patient.group_id
1033 task2._era = patient.era
1034 task2.patient_id = patient.id
1035 task2.when_created = self.era_time
1036 task2._current = False
1037 self.dbsession.add(task2)
1038 self.dbsession.commit()
1040 self.req.add_get_params({ViewParam.SERVER_PK: str(patient.pk)})
1042 view = EditFinalizedPatientView(
1043 self.req, task_tablename=task1.tablename, task_server_pk=task1.pk
1044 )
1045 with mock.patch.object(view, "render_to_response") as mock_render:
1046 view.dispatch()
1048 args, kwargs = mock_render.call_args
1050 context = args[0]
1052 self.assertIn("form", context)
1053 self.assertIn(task1, context["tasks"])
1054 self.assertIn(task2, context["tasks"])
1056 def test_changes_to_simple_params(self) -> None:
1057 view = EditFinalizedPatientView(self.req)
1058 patient = self.create_patient(
1059 id=1,
1060 forename="Jo",
1061 surname="Patient",
1062 dob=datetime.date(1958, 4, 19),
1063 sex="F",
1064 address="Address",
1065 email="jopatient@example.com",
1066 gp="GP",
1067 other=None,
1068 )
1069 view.object = patient
1071 changes = OrderedDict() # type: OrderedDict
1073 appstruct = {
1074 ViewParam.FORENAME: "Joanna",
1075 ViewParam.SURNAME: "Patient-Patient",
1076 ViewParam.DOB: datetime.date(1958, 4, 19),
1077 ViewParam.ADDRESS: "New address",
1078 ViewParam.OTHER: "",
1079 }
1081 view._save_simple_params(appstruct, changes)
1083 self.assertEqual(changes[ViewParam.FORENAME], ("Jo", "Joanna"))
1084 self.assertEqual(
1085 changes[ViewParam.SURNAME], ("Patient", "Patient-Patient")
1086 )
1087 self.assertNotIn(ViewParam.DOB, changes)
1088 self.assertEqual(
1089 changes[ViewParam.ADDRESS], ("Address", "New address")
1090 )
1091 self.assertNotIn(ViewParam.OTHER, changes)
1093 def test_changes_to_idrefs(self) -> None:
1094 view = EditFinalizedPatientView(self.req)
1095 patient = self.create_patient(id=1)
1096 self.create_patient_idnum(
1097 patient_id=patient.id,
1098 which_idnum=self.nhs_iddef.which_idnum,
1099 idnum_value=TEST_NHS_NUMBER_1,
1100 )
1101 self.create_patient_idnum(
1102 patient_id=patient.id,
1103 which_idnum=self.study_iddef.which_idnum,
1104 idnum_value=123,
1105 )
1107 view.object = patient
1109 changes = OrderedDict() # type: OrderedDict
1111 appstruct = {
1112 ViewParam.ID_REFERENCES: [
1113 {
1114 ViewParam.WHICH_IDNUM: self.nhs_iddef.which_idnum,
1115 ViewParam.IDNUM_VALUE: TEST_NHS_NUMBER_2,
1116 },
1117 {
1118 ViewParam.WHICH_IDNUM: self.rio_iddef.which_idnum,
1119 ViewParam.IDNUM_VALUE: 456,
1120 },
1121 ]
1122 }
1124 view._save_idrefs(appstruct, changes)
1126 self.assertEqual(
1127 changes["idnum1 (NHS number)"],
1128 (TEST_NHS_NUMBER_1, TEST_NHS_NUMBER_2),
1129 )
1130 self.assertEqual(changes["idnum3 (Study number)"], (123, None))
1131 self.assertEqual(changes["idnum2 (RiO number)"], (None, 456))
1134class EditServerCreatedPatientViewTests(BasicDatabaseTestCase):
1135 """
1136 Unit tests.
1137 """
1139 def test_group_updated(self) -> None:
1140 patient = self.create_patient(sex="F", as_server_patient=True)
1141 new_group = Group()
1142 new_group.name = "newgroup"
1143 new_group.description = "New group"
1144 new_group.upload_policy = "sex AND anyidnum"
1145 new_group.finalize_policy = "sex AND idnum1"
1146 self.dbsession.add(new_group)
1147 self.dbsession.commit()
1149 view = EditServerCreatedPatientView(self.req)
1150 view.object = patient
1152 appstruct = {ViewParam.GROUP_ID: new_group.id}
1154 view.save_object(appstruct)
1156 self.assertEqual(patient.group_id, new_group.id)
1158 messages = self.req.session.peek_flash(FlashQueue.SUCCESS)
1160 self.assertIn("testgroup", messages[0])
1161 self.assertIn("newgroup", messages[0])
1162 self.assertIn("group:", messages[0])
1164 def test_raises_when_not_created_on_the_server(self) -> None:
1165 patient = self.create_patient(id=1, _device_id=self.other_device.id)
1167 view = EditServerCreatedPatientView(self.req)
1169 self.req.add_get_params({ViewParam.SERVER_PK: str(patient.pk)})
1171 with self.assertRaises(HTTPBadRequest) as cm:
1172 view.get_object()
1174 self.assertIn("Patient is not editable", str(cm.exception))
1176 def test_patient_task_schedules_updated(self) -> None:
1177 patient = self.create_patient(sex="F", as_server_patient=True)
1179 schedule1 = TaskSchedule()
1180 schedule1.group_id = self.group.id
1181 schedule1.name = "Test 1"
1182 self.dbsession.add(schedule1)
1183 schedule2 = TaskSchedule()
1184 schedule2.group_id = self.group.id
1185 schedule2.name = "Test 2"
1186 self.dbsession.add(schedule2)
1187 schedule3 = TaskSchedule()
1188 schedule3.group_id = self.group.id
1189 schedule3.name = "Test 3"
1190 self.dbsession.add(schedule3)
1191 self.dbsession.commit()
1193 patient_task_schedule = PatientTaskSchedule()
1194 patient_task_schedule.patient_pk = patient.pk
1195 patient_task_schedule.schedule_id = schedule1.id
1196 patient_task_schedule.start_datetime = local(2020, 6, 12, 9)
1197 patient_task_schedule.settings = {
1198 "name 1": "value 1",
1199 "name 2": "value 2",
1200 "name 3": "value 3",
1201 }
1203 self.dbsession.add(patient_task_schedule)
1205 patient_task_schedule = PatientTaskSchedule()
1206 patient_task_schedule.patient_pk = patient.pk
1207 patient_task_schedule.schedule_id = schedule3.id
1209 self.dbsession.add(patient_task_schedule)
1210 self.dbsession.commit()
1212 self.req.add_get_params(
1213 {ViewParam.SERVER_PK: str(patient.pk)}, set_method_get=False
1214 )
1216 changed_schedule_1_settings = {
1217 "name 1": "new value 1",
1218 "name 2": "new value 2",
1219 "name 3": "new value 3",
1220 }
1221 new_schedule_2_settings = {
1222 "name 4": "value 4",
1223 "name 5": "value 5",
1224 "name 6": "value 6",
1225 }
1226 multidict = MultiDict(
1227 [
1228 ("_charset_", UTF8),
1229 ("__formid__", "deform"),
1230 (ViewParam.CSRF_TOKEN, self.req.session.get_csrf_token()),
1231 (ViewParam.SERVER_PK, patient.pk),
1232 (ViewParam.GROUP_ID, patient.group.id),
1233 (ViewParam.FORENAME, patient.forename),
1234 (ViewParam.SURNAME, patient.surname),
1235 ("__start__", "dob:mapping"),
1236 ("date", ""),
1237 ("__end__", "dob:mapping"),
1238 ("__start__", "sex:rename"),
1239 ("deformField7", patient.sex),
1240 ("__end__", "sex:rename"),
1241 (ViewParam.ADDRESS, patient.address),
1242 (ViewParam.GP, patient.gp),
1243 (ViewParam.OTHER, patient.other),
1244 ("__start__", "id_references:sequence"),
1245 ("__start__", "idnum_sequence:mapping"),
1246 (ViewParam.WHICH_IDNUM, self.nhs_iddef.which_idnum),
1247 (ViewParam.IDNUM_VALUE, str(TEST_NHS_NUMBER_1)),
1248 ("__end__", "idnum_sequence:mapping"),
1249 ("__end__", "id_references:sequence"),
1250 ("__start__", "danger:mapping"),
1251 ("target", "7836"),
1252 ("user_entry", "7836"),
1253 ("__end__", "danger:mapping"),
1254 ("__start__", "task_schedules:sequence"),
1255 ("__start__", "task_schedule_sequence:mapping"),
1256 ("schedule_id", schedule1.id),
1257 ("__start__", "start_datetime:mapping"),
1258 ("date", "2020-06-19"),
1259 ("time", "08:00:00"),
1260 ("__end__", "start_datetime:mapping"),
1261 ("settings", json.dumps(changed_schedule_1_settings)),
1262 ("__end__", "task_schedule_sequence:mapping"),
1263 ("__start__", "task_schedule_sequence:mapping"),
1264 ("schedule_id", schedule2.id),
1265 ("__start__", "start_datetime:mapping"),
1266 ("date", "2020-07-01"),
1267 ("time", "13:45:00"),
1268 ("__end__", "start_datetime:mapping"),
1269 ("settings", json.dumps(new_schedule_2_settings)),
1270 ("__end__", "task_schedule_sequence:mapping"),
1271 ("__end__", "task_schedules:sequence"),
1272 (FormAction.SUBMIT, "submit"),
1273 ]
1274 )
1276 self.req.fake_request_post_from_dict(multidict)
1278 with self.assertRaises(HTTPFound):
1279 edit_server_created_patient(self.req)
1281 self.dbsession.commit()
1283 schedules = {
1284 pts.task_schedule.name: pts for pts in patient.task_schedules
1285 }
1286 self.assertIn("Test 1", schedules)
1287 self.assertIn("Test 2", schedules)
1288 self.assertNotIn("Test 3", schedules)
1290 self.assertEqual(
1291 schedules["Test 1"].start_datetime, local(2020, 6, 19, 8)
1292 )
1293 self.assertEqual(
1294 schedules["Test 1"].settings, changed_schedule_1_settings
1295 )
1296 self.assertEqual(
1297 schedules["Test 2"].start_datetime, local(2020, 7, 1, 13, 45)
1298 )
1299 self.assertEqual(schedules["Test 2"].settings, new_schedule_2_settings)
1301 messages = self.req.session.peek_flash(FlashQueue.SUCCESS)
1303 self.assertIn(
1304 f"Amended patient record with server PK {patient.pk}", messages[0]
1305 )
1306 self.assertIn("Task schedules", messages[0])
1308 def test_unprivileged_user_cannot_edit_patient(self) -> None:
1309 patient = self.create_patient(sex="F", as_server_patient=True)
1311 user = self.create_user(username="testuser")
1312 self.dbsession.flush()
1314 self.req._debugging_user = user
1316 view = EditServerCreatedPatientView(self.req)
1317 view.object = patient
1319 self.req.add_get_params({ViewParam.SERVER_PK: str(patient.pk)})
1321 with self.assertRaises(HTTPBadRequest) as cm:
1322 view.dispatch()
1324 self.assertEqual(
1325 cm.exception.message, "Not authorized to edit this patient"
1326 )
1328 def test_patient_can_be_assigned_the_same_schedule_twice(self) -> None:
1329 patient = self.create_patient(sex="F", as_server_patient=True)
1331 schedule1 = TaskSchedule()
1332 schedule1.group_id = self.group.id
1333 schedule1.name = "Test 1"
1334 self.dbsession.add(schedule1)
1335 self.dbsession.flush()
1337 pts = PatientTaskSchedule()
1338 pts.patient_pk = patient.pk
1339 pts.schedule_id = schedule1.id
1340 pts.start_datetime = local(2020, 6, 12, 12, 34)
1341 self.dbsession.add(pts)
1342 self.dbsession.commit()
1344 appstruct = {
1345 ViewParam.TASK_SCHEDULES: [
1346 {
1347 ViewParam.PATIENT_TASK_SCHEDULE_ID: pts.id,
1348 ViewParam.SCHEDULE_ID: schedule1.id,
1349 ViewParam.START_DATETIME: local(2020, 6, 12, 12, 34),
1350 ViewParam.SETTINGS: {},
1351 },
1352 {
1353 ViewParam.PATIENT_TASK_SCHEDULE_ID: None,
1354 ViewParam.SCHEDULE_ID: schedule1.id,
1355 ViewParam.START_DATETIME: None,
1356 ViewParam.SETTINGS: {},
1357 },
1358 ]
1359 }
1361 view = EditServerCreatedPatientView(self.req)
1362 view.object = patient
1364 changes = {}
1365 view._save_task_schedules(appstruct, changes)
1366 self.req.dbsession.commit()
1368 self.assertEqual(patient.task_schedules[0].task_schedule, schedule1)
1369 self.assertEqual(patient.task_schedules[1].task_schedule, schedule1)
1371 def test_form_values_for_existing_patient(self) -> None:
1372 patient = self.create_patient(
1373 id=1,
1374 forename="Jo",
1375 surname="Patient",
1376 dob=datetime.date(1958, 4, 19),
1377 sex="F",
1378 address="Address",
1379 email="jopatient@example.com",
1380 gp="GP",
1381 other="Other",
1382 )
1384 schedule1 = TaskSchedule()
1385 schedule1.group_id = self.group.id
1386 schedule1.name = "Test 1"
1387 self.dbsession.add(schedule1)
1388 self.dbsession.commit()
1390 patient_task_schedule = PatientTaskSchedule()
1391 patient_task_schedule.patient_pk = patient.pk
1392 patient_task_schedule.schedule_id = schedule1.id
1393 patient_task_schedule.start_datetime = local(2020, 6, 12)
1394 patient_task_schedule.settings = {
1395 "name 1": "value 1",
1396 "name 2": "value 2",
1397 "name 3": "value 3",
1398 }
1400 self.dbsession.add(patient_task_schedule)
1401 self.dbsession.commit()
1403 self.create_patient_idnum(
1404 patient_id=patient.id,
1405 which_idnum=self.nhs_iddef.which_idnum,
1406 idnum_value=TEST_NHS_NUMBER_1,
1407 )
1409 self.req.add_get_params({ViewParam.SERVER_PK: str(patient.pk)})
1411 view = EditServerCreatedPatientView(self.req)
1412 view.object = patient
1414 form_values = view.get_form_values()
1416 self.assertEqual(form_values[ViewParam.FORENAME], "Jo")
1417 self.assertEqual(form_values[ViewParam.SURNAME], "Patient")
1418 self.assertEqual(
1419 form_values[ViewParam.DOB], datetime.date(1958, 4, 19)
1420 )
1421 self.assertEqual(form_values[ViewParam.SEX], "F")
1422 self.assertEqual(form_values[ViewParam.ADDRESS], "Address")
1423 self.assertEqual(form_values[ViewParam.EMAIL], "jopatient@example.com")
1424 self.assertEqual(form_values[ViewParam.GP], "GP")
1425 self.assertEqual(form_values[ViewParam.OTHER], "Other")
1427 self.assertEqual(form_values[ViewParam.SERVER_PK], patient.pk)
1428 self.assertEqual(form_values[ViewParam.GROUP_ID], patient.group.id)
1430 idnum = form_values[ViewParam.ID_REFERENCES][0]
1431 self.assertEqual(
1432 idnum[ViewParam.WHICH_IDNUM], self.nhs_iddef.which_idnum
1433 )
1434 self.assertEqual(idnum[ViewParam.IDNUM_VALUE], TEST_NHS_NUMBER_1)
1436 task_schedule = form_values[ViewParam.TASK_SCHEDULES][0]
1437 self.assertEqual(
1438 task_schedule[ViewParam.PATIENT_TASK_SCHEDULE_ID],
1439 patient_task_schedule.id,
1440 )
1441 self.assertEqual(
1442 task_schedule[ViewParam.SCHEDULE_ID],
1443 patient_task_schedule.schedule_id,
1444 )
1445 self.assertEqual(
1446 task_schedule[ViewParam.START_DATETIME],
1447 patient_task_schedule.start_datetime,
1448 )
1449 self.assertEqual(
1450 task_schedule[ViewParam.SETTINGS], patient_task_schedule.settings
1451 )
1454class AddPatientViewTests(DemoDatabaseTestCase):
1455 """
1456 Unit tests.
1457 """
1459 def test_patient_created(self) -> None:
1460 view = AddPatientView(self.req)
1462 schedule1 = TaskSchedule()
1463 schedule1.group_id = self.group.id
1464 schedule1.name = "Test 1"
1465 self.dbsession.add(schedule1)
1467 schedule2 = TaskSchedule()
1468 schedule2.group_id = self.group.id
1469 schedule2.name = "Test 2"
1470 self.dbsession.add(schedule2)
1471 self.dbsession.commit()
1473 start_datetime1 = local(2020, 6, 12)
1474 start_datetime2 = local(2020, 7, 1)
1476 settings1 = json.dumps(
1477 {"name 1": "value 1", "name 2": "value 2", "name 3": "value 3"}
1478 )
1480 appstruct = {
1481 ViewParam.GROUP_ID: self.group.id,
1482 ViewParam.FORENAME: "Jo",
1483 ViewParam.SURNAME: "Patient",
1484 ViewParam.DOB: datetime.date(1958, 4, 19),
1485 ViewParam.SEX: "F",
1486 ViewParam.ADDRESS: "Address",
1487 ViewParam.EMAIL: "jopatient@example.com",
1488 ViewParam.GP: "GP",
1489 ViewParam.OTHER: "Other",
1490 ViewParam.ID_REFERENCES: [
1491 {
1492 ViewParam.WHICH_IDNUM: self.nhs_iddef.which_idnum,
1493 ViewParam.IDNUM_VALUE: 1192220552,
1494 }
1495 ],
1496 ViewParam.TASK_SCHEDULES: [
1497 {
1498 ViewParam.SCHEDULE_ID: schedule1.id,
1499 ViewParam.START_DATETIME: start_datetime1,
1500 ViewParam.SETTINGS: settings1,
1501 },
1502 {
1503 ViewParam.SCHEDULE_ID: schedule2.id,
1504 ViewParam.START_DATETIME: start_datetime2,
1505 ViewParam.SETTINGS: {},
1506 },
1507 ],
1508 }
1510 view.save_object(appstruct)
1512 patient = cast(Patient, view.object)
1514 server_device = Device.get_server_device(self.req.dbsession)
1516 self.assertEqual(patient.id, 1)
1517 self.assertEqual(patient.device_id, server_device.id)
1518 self.assertEqual(patient.era, ERA_NOW)
1519 self.assertEqual(patient.group.id, self.group.id)
1521 self.assertEqual(patient.forename, "Jo")
1522 self.assertEqual(patient.surname, "Patient")
1523 self.assertEqual(patient.dob.isoformat(), "1958-04-19")
1524 self.assertEqual(patient.sex, "F")
1525 self.assertEqual(patient.address, "Address")
1526 self.assertEqual(patient.email, "jopatient@example.com")
1527 self.assertEqual(patient.gp, "GP")
1528 self.assertEqual(patient.other, "Other")
1530 idnum = patient.get_idnum_objects()[0]
1531 self.assertEqual(idnum.patient_id, 1)
1532 self.assertEqual(idnum.which_idnum, self.nhs_iddef.which_idnum)
1533 self.assertEqual(idnum.idnum_value, 1192220552)
1535 patient_task_schedules = {
1536 pts.task_schedule.name: pts for pts in patient.task_schedules
1537 }
1539 self.assertIn("Test 1", patient_task_schedules)
1540 self.assertIn("Test 2", patient_task_schedules)
1542 self.assertEqual(
1543 patient_task_schedules["Test 1"].start_datetime, start_datetime1
1544 )
1545 self.assertEqual(patient_task_schedules["Test 1"].settings, settings1)
1546 self.assertEqual(
1547 patient_task_schedules["Test 2"].start_datetime, start_datetime2
1548 )
1550 def test_patient_takes_next_available_id(self) -> None:
1551 self.create_patient(id=1234, as_server_patient=True)
1553 view = AddPatientView(self.req)
1555 appstruct = {
1556 ViewParam.GROUP_ID: self.group.id,
1557 ViewParam.FORENAME: "Jo",
1558 ViewParam.SURNAME: "Patient",
1559 ViewParam.DOB: datetime.date(1958, 4, 19),
1560 ViewParam.SEX: "F",
1561 ViewParam.ADDRESS: "Address",
1562 ViewParam.GP: "GP",
1563 ViewParam.OTHER: "Other",
1564 ViewParam.ID_REFERENCES: [
1565 {
1566 ViewParam.WHICH_IDNUM: self.nhs_iddef.which_idnum,
1567 ViewParam.IDNUM_VALUE: 1192220552,
1568 }
1569 ],
1570 ViewParam.TASK_SCHEDULES: [],
1571 }
1573 view.save_object(appstruct)
1575 patient = cast(Patient, view.object)
1577 self.assertEqual(patient.id, 1235)
1579 def test_form_rendered_with_values(self) -> None:
1580 view = AddPatientView(self.req)
1582 with mock.patch.object(view, "render_to_response") as mock_render:
1583 view.dispatch()
1585 args, kwargs = mock_render.call_args
1587 context = args[0]
1589 self.assertIn("form", context)
1591 def test_unprivileged_user_cannot_add_patient(self) -> None:
1592 user = self.create_user(username="testuser")
1593 self.dbsession.flush()
1595 self.req._debugging_user = user
1597 with self.assertRaises(HTTPBadRequest) as cm:
1598 add_patient(self.req)
1600 self.assertEqual(
1601 cm.exception.message, "Not authorized to manage patients"
1602 )
1604 def test_group_listed_for_privileged_group_member(self) -> None:
1605 user = self.create_user(username="testuser")
1606 self.dbsession.flush()
1607 self.create_membership(user, self.group, may_manage_patients=True)
1608 self.dbsession.commit()
1610 self.req._debugging_user = user
1612 view = AddPatientView(self.req)
1614 with mock.patch.object(view, "render_to_response") as mock_render:
1615 view.dispatch()
1617 args, kwargs = mock_render.call_args
1619 context = args[0]
1621 self.assertIn("testgroup", context["form"])
1624class DeleteServerCreatedPatientViewTests(BasicDatabaseTestCase):
1625 """
1626 Unit tests.
1627 """
1629 def setUp(self) -> None:
1630 super().setUp()
1632 self.patient = self.create_patient(
1633 as_server_patient=True,
1634 forename="Jo",
1635 surname="Patient",
1636 dob=datetime.date(1958, 4, 19),
1637 sex="F",
1638 address="Address",
1639 gp="GP",
1640 other="Other",
1641 )
1643 patient_pk = self.patient.pk
1645 idnum = self.create_patient_idnum(
1646 as_server_patient=True,
1647 patient_id=self.patient.id,
1648 which_idnum=self.nhs_iddef.which_idnum,
1649 idnum_value=TEST_NHS_NUMBER_1,
1650 )
1652 PatientIdNumIndexEntry.index_idnum(idnum, self.dbsession)
1654 self.schedule = TaskSchedule()
1655 self.schedule.group_id = self.group.id
1656 self.schedule.name = "Test 1"
1657 self.dbsession.add(self.schedule)
1658 self.dbsession.commit()
1660 pts = PatientTaskSchedule()
1661 pts.patient_pk = patient_pk
1662 pts.schedule_id = self.schedule.id
1663 self.dbsession.add(pts)
1664 self.dbsession.commit()
1666 self.multidict = MultiDict(
1667 [
1668 ("_charset_", UTF8),
1669 ("__formid__", "deform"),
1670 (ViewParam.CSRF_TOKEN, self.req.session.get_csrf_token()),
1671 ("confirm_1_t", "true"),
1672 ("confirm_2_t", "true"),
1673 ("confirm_4_t", "true"),
1674 ("__start__", "danger:mapping"),
1675 ("target", "7176"),
1676 ("user_entry", "7176"),
1677 ("__end__", "danger:mapping"),
1678 ("delete", "delete"),
1679 (FormAction.DELETE, "delete"),
1680 ]
1681 )
1683 def test_patient_schedule_and_idnums_deleted(self) -> None:
1684 self.req.fake_request_post_from_dict(self.multidict)
1686 patient_pk = self.patient.pk
1687 self.req.add_get_params(
1688 {ViewParam.SERVER_PK: str(patient_pk)}, set_method_get=False
1689 )
1690 view = DeleteServerCreatedPatientView(self.req)
1692 with self.assertRaises(HTTPFound) as e:
1693 view.dispatch()
1695 self.assertEqual(e.exception.status_code, 302)
1696 self.assertIn(
1697 Routes.VIEW_PATIENT_TASK_SCHEDULES, e.exception.headers["Location"]
1698 )
1700 deleted_patient = (
1701 self.dbsession.query(Patient)
1702 .filter(Patient._pk == patient_pk)
1703 .one_or_none()
1704 )
1706 self.assertIsNone(deleted_patient)
1708 pts = (
1709 self.dbsession.query(PatientTaskSchedule)
1710 .filter(PatientTaskSchedule.patient_pk == patient_pk)
1711 .one_or_none()
1712 )
1714 self.assertIsNone(pts)
1716 idnum = (
1717 self.dbsession.query(PatientIdNum)
1718 .filter(
1719 PatientIdNum.patient_id == self.patient.id,
1720 PatientIdNum._device_id == self.patient.device_id,
1721 PatientIdNum._era == self.patient.era,
1722 PatientIdNum._current == True, # noqa: E712
1723 )
1724 .one_or_none()
1725 )
1727 self.assertIsNone(idnum)
1729 def test_registered_patient_deleted(self) -> None:
1730 from camcops_server.cc_modules.client_api import (
1731 get_or_create_single_user,
1732 )
1734 user1, _ = get_or_create_single_user(self.req, "test", self.patient)
1735 self.assertEqual(user1.single_patient, self.patient)
1737 user2, _ = get_or_create_single_user(self.req, "test", self.patient)
1738 self.assertEqual(user2.single_patient, self.patient)
1740 self.req.fake_request_post_from_dict(self.multidict)
1742 patient_pk = self.patient.pk
1743 self.req.add_get_params(
1744 {ViewParam.SERVER_PK: str(patient_pk)}, set_method_get=False
1745 )
1746 view = DeleteServerCreatedPatientView(self.req)
1748 with self.assertRaises(HTTPFound):
1749 view.dispatch()
1751 self.dbsession.commit()
1753 deleted_patient = (
1754 self.dbsession.query(Patient)
1755 .filter(Patient._pk == patient_pk)
1756 .one_or_none()
1757 )
1759 self.assertIsNone(deleted_patient)
1761 # TODO: We get weird behaviour when all the tests are run together
1762 # (fine for --test_class=DeleteServerCreatedPatientViewTests)
1763 # the assertion below fails with sqlite in spite of the commit()
1764 # above.
1766 # user = self.dbsession.query(User).filter(
1767 # User.id == user1.id).one_or_none()
1768 # self.assertIsNone(user.single_patient_pk)
1770 # user = self.dbsession.query(User).filter(
1771 # User.id == user2.id).one_or_none()
1772 # self.assertIsNone(user.single_patient_pk)
1774 def test_unrelated_patient_unaffected(self) -> None:
1775 other_patient = self.create_patient(
1776 as_server_patient=True,
1777 forename="Mo",
1778 surname="Patient",
1779 dob=datetime.date(1968, 11, 30),
1780 sex="M",
1781 address="Address",
1782 gp="GP",
1783 other="Other",
1784 )
1785 patient_pk = other_patient._pk
1787 saved_patient = (
1788 self.dbsession.query(Patient)
1789 .filter(Patient._pk == patient_pk)
1790 .one_or_none()
1791 )
1793 self.assertIsNotNone(saved_patient)
1795 idnum = self.create_patient_idnum(
1796 as_server_patient=True,
1797 patient_id=other_patient.id,
1798 which_idnum=self.nhs_iddef.which_idnum,
1799 idnum_value=TEST_NHS_NUMBER_2,
1800 )
1802 PatientIdNumIndexEntry.index_idnum(idnum, self.dbsession)
1804 saved_idnum = (
1805 self.dbsession.query(PatientIdNum)
1806 .filter(
1807 PatientIdNum.patient_id == other_patient.id,
1808 PatientIdNum._device_id == other_patient.device_id,
1809 PatientIdNum._era == other_patient.era,
1810 PatientIdNum._current == True, # noqa: E712
1811 )
1812 .one_or_none()
1813 )
1815 self.assertIsNotNone(saved_idnum)
1817 pts = PatientTaskSchedule()
1818 pts.patient_pk = patient_pk
1819 pts.schedule_id = self.schedule.id
1820 self.dbsession.add(pts)
1821 self.dbsession.commit()
1823 self.req.fake_request_post_from_dict(self.multidict)
1825 self.req.add_get_params(
1826 {ViewParam.SERVER_PK: self.patient._pk}, set_method_get=False
1827 )
1828 view = DeleteServerCreatedPatientView(self.req)
1830 with self.assertRaises(HTTPFound):
1831 view.dispatch()
1833 saved_patient = (
1834 self.dbsession.query(Patient)
1835 .filter(Patient._pk == patient_pk)
1836 .one_or_none()
1837 )
1839 self.assertIsNotNone(saved_patient)
1841 saved_pts = (
1842 self.dbsession.query(PatientTaskSchedule)
1843 .filter(PatientTaskSchedule.patient_pk == patient_pk)
1844 .one_or_none()
1845 )
1847 self.assertIsNotNone(saved_pts)
1849 saved_idnum = (
1850 self.dbsession.query(PatientIdNum)
1851 .filter(
1852 PatientIdNum.patient_id == other_patient.id,
1853 PatientIdNum._device_id == other_patient.device_id,
1854 PatientIdNum._era == other_patient.era,
1855 PatientIdNum._current == True, # noqa: E712
1856 )
1857 .one_or_none()
1858 )
1860 self.assertIsNotNone(saved_idnum)
1862 def test_unprivileged_user_cannot_delete_patient(self) -> None:
1863 self.req.fake_request_post_from_dict(self.multidict)
1865 patient_pk = self.patient.pk
1866 self.req.add_get_params(
1867 {ViewParam.SERVER_PK: str(patient_pk)}, set_method_get=False
1868 )
1869 view = DeleteServerCreatedPatientView(self.req)
1871 user = self.create_user(username="testuser")
1872 self.dbsession.flush()
1874 self.req._debugging_user = user
1876 with self.assertRaises(HTTPBadRequest) as cm:
1877 view.dispatch()
1879 self.assertEqual(
1880 cm.exception.message, "Not authorized to delete this patient"
1881 )
1883 def test_unprivileged_user_cannot_see_delete_form(self) -> None:
1884 self.req.fake_request_post_from_dict(self.multidict)
1886 patient_pk = self.patient.pk
1887 self.req.add_get_params({ViewParam.SERVER_PK: str(patient_pk)})
1888 view = DeleteServerCreatedPatientView(self.req)
1890 user = self.create_user(username="testuser")
1891 self.dbsession.flush()
1893 self.req._debugging_user = user
1895 with self.assertRaises(HTTPBadRequest) as cm:
1896 view.dispatch()
1898 self.assertEqual(
1899 cm.exception.message, "Not authorized to delete this patient"
1900 )
1903class EraseTaskTestCase(BasicDatabaseTestCase):
1904 """
1905 Unit tests.
1906 """
1908 def create_tasks(self) -> None:
1909 from camcops_server.tasks.bmi import Bmi
1911 self.task = Bmi()
1912 self.task.id = 1
1913 self.apply_standard_task_fields(self.task)
1914 patient = self.create_patient_with_one_idnum()
1915 self.task.patient_id = patient.id
1917 self.dbsession.add(self.task)
1918 self.dbsession.commit()
1921class EraseTaskLeavingPlaceholderViewTests(EraseTaskTestCase):
1922 """
1923 Unit tests.
1924 """
1926 def test_displays_form(self) -> None:
1927 self.req.add_get_params(
1928 {
1929 ViewParam.SERVER_PK: str(self.task.pk),
1930 ViewParam.TABLE_NAME: self.task.tablename,
1931 },
1932 set_method_get=False,
1933 )
1934 view = EraseTaskLeavingPlaceholderView(self.req)
1936 with mock.patch.object(view, "render_to_response") as mock_render:
1937 view.dispatch()
1939 args, kwargs = mock_render.call_args
1940 context = args[0]
1942 self.assertIn("form", context)
1944 def test_deletes_task_leaving_placeholder(self) -> None:
1945 multidict = MultiDict(
1946 [
1947 ("_charset_", UTF8),
1948 ("__formid__", "deform"),
1949 (ViewParam.CSRF_TOKEN, self.req.session.get_csrf_token()),
1950 (ViewParam.SERVER_PK, self.task.pk),
1951 (ViewParam.TABLE_NAME, self.task.tablename),
1952 ("confirm_1_t", "true"),
1953 ("confirm_2_t", "true"),
1954 ("confirm_4_t", "true"),
1955 ("__start__", "danger:mapping"),
1956 ("target", "7176"),
1957 ("user_entry", "7176"),
1958 ("__end__", "danger:mapping"),
1959 ("delete", "delete"),
1960 (FormAction.DELETE, "delete"),
1961 ]
1962 )
1964 self.req.fake_request_post_from_dict(multidict)
1966 view = EraseTaskLeavingPlaceholderView(self.req)
1967 with mock.patch.object(
1968 self.task, "manually_erase"
1969 ) as mock_manually_erase:
1971 with self.assertRaises(HTTPFound):
1972 view.dispatch()
1974 mock_manually_erase.assert_called_once()
1975 args, kwargs = mock_manually_erase.call_args
1976 request = args[0]
1978 self.assertEqual(request, self.req)
1980 def test_task_not_deleted_on_cancel(self) -> None:
1981 self.req.fake_request_post_from_dict({FormAction.CANCEL: "cancel"})
1983 self.req.add_get_params(
1984 {
1985 ViewParam.SERVER_PK: str(self.task.pk),
1986 ViewParam.TABLE_NAME: self.task.tablename,
1987 },
1988 set_method_get=False,
1989 )
1990 view = EraseTaskLeavingPlaceholderView(self.req)
1992 with self.assertRaises(HTTPFound):
1993 view.dispatch()
1995 task = self.dbsession.query(self.task.__class__).one_or_none()
1997 self.assertIsNotNone(task)
1999 def test_redirect_on_cancel(self) -> None:
2000 self.req.fake_request_post_from_dict({FormAction.CANCEL: "cancel"})
2002 self.req.add_get_params(
2003 {
2004 ViewParam.SERVER_PK: str(self.task.pk),
2005 ViewParam.TABLE_NAME: self.task.tablename,
2006 },
2007 set_method_get=False,
2008 )
2009 view = EraseTaskLeavingPlaceholderView(self.req)
2011 with self.assertRaises(HTTPFound) as cm:
2012 view.dispatch()
2014 self.assertEqual(cm.exception.status_code, 302)
2015 self.assertIn(f"/{Routes.TASK}", cm.exception.headers["Location"])
2016 self.assertIn(
2017 f"{ViewParam.TABLE_NAME}={self.task.tablename}",
2018 cm.exception.headers["Location"],
2019 )
2020 self.assertIn(
2021 f"{ViewParam.SERVER_PK}={self.task.pk}",
2022 cm.exception.headers["Location"],
2023 )
2024 self.assertIn(
2025 f"{ViewParam.VIEWTYPE}={ViewArg.HTML}",
2026 cm.exception.headers["Location"],
2027 )
2029 def test_raises_when_task_does_not_exist(self) -> None:
2030 self.req.add_get_params(
2031 {ViewParam.SERVER_PK: "123", ViewParam.TABLE_NAME: "phq9"},
2032 set_method_get=False,
2033 )
2034 view = EraseTaskLeavingPlaceholderView(self.req)
2036 with self.assertRaises(HTTPBadRequest) as cm:
2037 view.dispatch()
2039 self.assertEqual(cm.exception.message, "No such task: phq9, PK=123")
2041 def test_raises_when_task_is_live_on_tablet(self) -> None:
2042 self.task._era = ERA_NOW
2043 self.dbsession.add(self.task)
2044 self.dbsession.commit()
2046 self.req.add_get_params(
2047 {
2048 ViewParam.SERVER_PK: str(self.task.pk),
2049 ViewParam.TABLE_NAME: self.task.tablename,
2050 },
2051 set_method_get=False,
2052 )
2053 view = EraseTaskLeavingPlaceholderView(self.req)
2055 with self.assertRaises(HTTPBadRequest) as cm:
2056 view.dispatch()
2058 self.assertIn("Task is live on tablet", cm.exception.message)
2060 def test_raises_when_user_not_authorized_to_erase(self) -> None:
2061 with mock.patch.object(
2062 self.user, "authorized_to_erase_tasks", return_value=False
2063 ):
2065 self.req.add_get_params(
2066 {
2067 ViewParam.SERVER_PK: str(self.task.pk),
2068 ViewParam.TABLE_NAME: self.task.tablename,
2069 },
2070 set_method_get=False,
2071 )
2072 view = EraseTaskLeavingPlaceholderView(self.req)
2074 with self.assertRaises(HTTPBadRequest) as cm:
2075 view.dispatch()
2077 self.assertIn("Not authorized to erase tasks", cm.exception.message)
2079 def test_raises_when_task_already_erased(self) -> None:
2080 self.task._manually_erased = True
2081 self.dbsession.add(self.task)
2082 self.dbsession.commit()
2084 self.req.add_get_params(
2085 {
2086 ViewParam.SERVER_PK: str(self.task.pk),
2087 ViewParam.TABLE_NAME: self.task.tablename,
2088 },
2089 set_method_get=False,
2090 )
2091 view = EraseTaskLeavingPlaceholderView(self.req)
2093 with self.assertRaises(HTTPBadRequest) as cm:
2094 view.dispatch()
2096 self.assertIn("already erased", cm.exception.message)
2099class EraseTaskEntirelyViewTests(EraseTaskTestCase):
2100 """
2101 Unit tests.
2102 """
2104 def test_deletes_task_entirely(self) -> None:
2105 multidict = MultiDict(
2106 [
2107 ("_charset_", UTF8),
2108 ("__formid__", "deform"),
2109 (ViewParam.CSRF_TOKEN, self.req.session.get_csrf_token()),
2110 (ViewParam.SERVER_PK, self.task.pk),
2111 (ViewParam.TABLE_NAME, self.task.tablename),
2112 ("confirm_1_t", "true"),
2113 ("confirm_2_t", "true"),
2114 ("confirm_4_t", "true"),
2115 ("__start__", "danger:mapping"),
2116 ("target", "7176"),
2117 ("user_entry", "7176"),
2118 ("__end__", "danger:mapping"),
2119 ("delete", "delete"),
2120 (FormAction.DELETE, "delete"),
2121 ]
2122 )
2124 self.req.fake_request_post_from_dict(multidict)
2126 view = EraseTaskEntirelyView(self.req)
2128 with mock.patch.object(
2129 self.task, "delete_entirely"
2130 ) as mock_delete_entirely:
2132 with self.assertRaises(HTTPFound):
2133 view.dispatch()
2135 mock_delete_entirely.assert_called_once()
2136 args, kwargs = mock_delete_entirely.call_args
2137 request = args[0]
2139 self.assertEqual(request, self.req)
2141 messages = self.req.session.peek_flash(FlashQueue.SUCCESS)
2142 self.assertTrue(len(messages) > 0)
2144 self.assertIn("Task erased", messages[0])
2145 self.assertIn(self.task.tablename, messages[0])
2146 self.assertIn("server PK {}".format(self.task.pk), messages[0])
2149class EditGroupViewTests(DemoDatabaseTestCase):
2150 """
2151 Unit tests.
2152 """
2154 def test_group_updated(self) -> None:
2155 other_group_1 = Group()
2156 other_group_1.name = "other-group-1"
2157 self.dbsession.add(other_group_1)
2159 other_group_2 = Group()
2160 other_group_2.name = "other-group-2"
2161 self.dbsession.add(other_group_2)
2163 self.dbsession.commit()
2165 multidict = MultiDict(
2166 [
2167 ("_charset_", UTF8),
2168 ("__formid__", "deform"),
2169 (ViewParam.CSRF_TOKEN, self.req.session.get_csrf_token()),
2170 (ViewParam.GROUP_ID, self.group.id),
2171 (ViewParam.NAME, "new-name"),
2172 (ViewParam.DESCRIPTION, "new description"),
2173 (ViewParam.UPLOAD_POLICY, "anyidnum AND sex"), # reversed
2174 (ViewParam.FINALIZE_POLICY, "idnum1 AND sex"), # reversed
2175 ("__start__", "group_ids:sequence"),
2176 ("group_id_sequence", str(other_group_1.id)),
2177 ("group_id_sequence", str(other_group_2.id)),
2178 ("__end__", "group_ids:sequence"),
2179 (FormAction.SUBMIT, "submit"),
2180 ]
2181 )
2182 self.req.fake_request_post_from_dict(multidict)
2184 with self.assertRaises(HTTPFound):
2185 edit_group(self.req)
2187 self.assertEqual(self.group.name, "new-name")
2188 self.assertEqual(self.group.description, "new description")
2189 self.assertEqual(self.group.upload_policy, "anyidnum AND sex")
2190 self.assertEqual(self.group.finalize_policy, "idnum1 AND sex")
2191 self.assertIn(other_group_1, self.group.can_see_other_groups)
2192 self.assertIn(other_group_2, self.group.can_see_other_groups)
2194 def test_ip_use_added(self) -> None:
2195 from camcops_server.cc_modules.cc_ipuse import IpContexts
2197 multidict = MultiDict(
2198 [
2199 ("_charset_", UTF8),
2200 ("__formid__", "deform"),
2201 (ViewParam.CSRF_TOKEN, self.req.session.get_csrf_token()),
2202 (ViewParam.GROUP_ID, self.group.id),
2203 (ViewParam.NAME, "new-name"),
2204 (ViewParam.DESCRIPTION, "new description"),
2205 (ViewParam.UPLOAD_POLICY, "anyidnum AND sex"),
2206 (ViewParam.FINALIZE_POLICY, "idnum1 AND sex"),
2207 ("__start__", "ip_use:mapping"),
2208 (IpContexts.CLINICAL, "true"),
2209 (IpContexts.COMMERCIAL, "true"),
2210 ("__end__", "ip_use:mapping"),
2211 (FormAction.SUBMIT, "submit"),
2212 ]
2213 )
2214 self.req.fake_request_post_from_dict(multidict)
2216 with self.assertRaises(HTTPFound):
2217 edit_group(self.req)
2219 self.assertTrue(self.group.ip_use.clinical)
2220 self.assertTrue(self.group.ip_use.commercial)
2221 self.assertFalse(self.group.ip_use.educational)
2222 self.assertFalse(self.group.ip_use.research)
2224 def test_ip_use_updated(self) -> None:
2225 from camcops_server.cc_modules.cc_ipuse import IpContexts
2227 self.group.ip_use.educational = True
2228 self.group.ip_use.research = True
2229 self.dbsession.add(self.group.ip_use)
2230 self.dbsession.commit()
2232 old_id = self.group.ip_use.id
2234 multidict = MultiDict(
2235 [
2236 ("_charset_", UTF8),
2237 ("__formid__", "deform"),
2238 (ViewParam.CSRF_TOKEN, self.req.session.get_csrf_token()),
2239 (ViewParam.GROUP_ID, self.group.id),
2240 (ViewParam.NAME, "new-name"),
2241 (ViewParam.DESCRIPTION, "new description"),
2242 (ViewParam.UPLOAD_POLICY, "anyidnum AND sex"),
2243 (ViewParam.FINALIZE_POLICY, "idnum1 AND sex"),
2244 ("__start__", "ip_use:mapping"),
2245 (IpContexts.CLINICAL, "true"),
2246 (IpContexts.COMMERCIAL, "true"),
2247 ("__end__", "ip_use:mapping"),
2248 (FormAction.SUBMIT, "submit"),
2249 ]
2250 )
2251 self.req.fake_request_post_from_dict(multidict)
2253 with self.assertRaises(HTTPFound):
2254 edit_group(self.req)
2256 self.assertTrue(self.group.ip_use.clinical)
2257 self.assertTrue(self.group.ip_use.commercial)
2258 self.assertFalse(self.group.ip_use.educational)
2259 self.assertFalse(self.group.ip_use.research)
2260 self.assertEqual(self.group.ip_use.id, old_id)
2262 def test_other_groups_displayed_in_form(self) -> None:
2263 z_group = Group()
2264 z_group.name = "z-group"
2265 self.dbsession.add(z_group)
2267 a_group = Group()
2268 a_group.name = "a-group"
2269 self.dbsession.add(a_group)
2270 self.dbsession.commit()
2272 other_groups = Group.get_groups_from_id_list(
2273 self.dbsession, [z_group.id, a_group.id]
2274 )
2275 self.group.can_see_other_groups = other_groups
2277 self.dbsession.add(self.group)
2278 self.dbsession.commit()
2280 view = EditGroupView(self.req)
2281 view.object = self.group
2283 form_values = view.get_form_values()
2285 self.assertEqual(
2286 form_values[ViewParam.GROUP_IDS], [a_group.id, z_group.id]
2287 )
2289 def test_group_id_displayed_in_form(self) -> None:
2290 view = EditGroupView(self.req)
2291 view.object = self.group
2293 form_values = view.get_form_values()
2295 self.assertEqual(form_values[ViewParam.GROUP_ID], self.group.id)
2297 def test_ip_use_displayed_in_form(self) -> None:
2298 view = EditGroupView(self.req)
2299 view.object = self.group
2301 form_values = view.get_form_values()
2303 self.assertEqual(form_values[ViewParam.IP_USE], self.group.ip_use)
2306class SendEmailFromPatientTaskScheduleViewTests(BasicDatabaseTestCase):
2307 def setUp(self) -> None:
2308 super().setUp()
2310 self.patient = self.create_patient(
2311 as_server_patient=True,
2312 forename="Jo",
2313 surname="Patient",
2314 dob=datetime.date(1958, 4, 19),
2315 sex="F",
2316 address="Address",
2317 gp="GP",
2318 other="Other",
2319 )
2321 patient_pk = self.patient.pk
2323 idnum = self.create_patient_idnum(
2324 as_server_patient=True,
2325 patient_id=self.patient.id,
2326 which_idnum=self.nhs_iddef.which_idnum,
2327 idnum_value=TEST_NHS_NUMBER_1,
2328 )
2330 PatientIdNumIndexEntry.index_idnum(idnum, self.dbsession)
2332 self.schedule = TaskSchedule()
2333 self.schedule.group_id = self.group.id
2334 self.schedule.name = "Test 1"
2335 self.dbsession.add(self.schedule)
2336 self.dbsession.commit()
2338 self.pts = PatientTaskSchedule()
2339 self.pts.patient_pk = patient_pk
2340 self.pts.schedule_id = self.schedule.id
2341 self.dbsession.add(self.pts)
2342 self.dbsession.commit()
2344 def test_displays_form(self) -> None:
2345 self.req.add_get_params(
2346 {ViewParam.PATIENT_TASK_SCHEDULE_ID: str(self.pts.id)}
2347 )
2349 view = SendEmailFromPatientTaskScheduleView(self.req)
2350 with mock.patch.object(view, "render_to_response") as mock_render:
2351 view.dispatch()
2353 args, kwargs = mock_render.call_args
2354 context = args[0]
2356 self.assertIn("form", context)
2358 def test_raises_for_missing_pts_id(self) -> None:
2359 view = SendEmailFromPatientTaskScheduleView(self.req)
2360 with self.assertRaises(HTTPBadRequest) as cm:
2361 view.dispatch()
2363 self.assertIn(
2364 "Patient task schedule does not exist", cm.exception.message
2365 )
2367 @mock.patch("camcops_server.cc_modules.cc_email.send_msg")
2368 @mock.patch("camcops_server.cc_modules.cc_email.make_email")
2369 def test_sends_email(
2370 self, mock_make_email: mock.Mock, mock_send_msg: mock.Mock
2371 ) -> None:
2372 self.req.config.email_host = "smtp.example.com"
2373 self.req.config.email_port = 587
2374 self.req.config.email_host_username = "mailuser"
2375 self.req.config.email_host_password = "mailpassword"
2376 self.req.config.email_use_tls = True
2378 multidict = MultiDict(
2379 [
2380 (ViewParam.EMAIL, "patient@example.com"),
2381 (ViewParam.EMAIL_FROM, "server@example.com"),
2382 (ViewParam.EMAIL_SUBJECT, "Subject"),
2383 (ViewParam.EMAIL_BODY, "Email body"),
2384 (FormAction.SUBMIT, "submit"),
2385 ]
2386 )
2388 self.req.fake_request_post_from_dict(multidict)
2389 self.req.add_get_params(
2390 {ViewParam.PATIENT_TASK_SCHEDULE_ID: str(self.pts.id)},
2391 set_method_get=False,
2392 )
2393 view = SendEmailFromPatientTaskScheduleView(self.req)
2395 with self.assertRaises(HTTPFound):
2396 view.dispatch()
2398 args, kwargs = mock_make_email.call_args_list[0]
2399 self.assertEqual(kwargs["from_addr"], "server@example.com")
2400 self.assertEqual(kwargs["to"], "patient@example.com")
2401 self.assertEqual(kwargs["subject"], "Subject")
2402 self.assertEqual(kwargs["body"], "Email body")
2403 self.assertEqual(kwargs["content_type"], MimeType.HTML)
2405 args, kwargs = mock_send_msg.call_args
2406 self.assertEqual(kwargs["host"], "smtp.example.com")
2407 self.assertEqual(kwargs["user"], "mailuser")
2408 self.assertEqual(kwargs["password"], "mailpassword")
2409 self.assertEqual(kwargs["port"], 587)
2410 self.assertTrue(kwargs["use_tls"])
2412 @mock.patch("camcops_server.cc_modules.cc_email.send_msg")
2413 @mock.patch("camcops_server.cc_modules.cc_email.make_email")
2414 def test_sends_cc_of_email(
2415 self, mock_make_email: mock.Mock, mock_send_msg: mock.Mock
2416 ) -> None:
2417 self.req.config.email_host = "smtp.example.com"
2418 self.req.config.email_port = 587
2419 self.req.config.email_host_username = "mailuser"
2420 self.req.config.email_host_password = "mailpassword"
2421 self.req.config.email_use_tls = True
2423 multidict = MultiDict(
2424 [
2425 (ViewParam.EMAIL, "patient@example.com"),
2426 (ViewParam.EMAIL_CC, "cc@example.com"),
2427 (ViewParam.EMAIL_FROM, "server@example.com"),
2428 (ViewParam.EMAIL_SUBJECT, "Subject"),
2429 (ViewParam.EMAIL_BODY, "Email body"),
2430 (FormAction.SUBMIT, "submit"),
2431 ]
2432 )
2434 self.req.fake_request_post_from_dict(multidict)
2435 self.req.add_get_params(
2436 {ViewParam.PATIENT_TASK_SCHEDULE_ID: str(self.pts.id)},
2437 set_method_get=False,
2438 )
2439 view = SendEmailFromPatientTaskScheduleView(self.req)
2441 with self.assertRaises(HTTPFound):
2442 view.dispatch()
2444 args, kwargs = mock_make_email.call_args
2445 self.assertEqual(kwargs["to"], "patient@example.com")
2446 self.assertEqual(kwargs["cc"], "cc@example.com")
2448 @mock.patch("camcops_server.cc_modules.cc_email.send_msg")
2449 @mock.patch("camcops_server.cc_modules.cc_email.make_email")
2450 def test_sends_bcc_of_email(
2451 self, mock_make_email: mock.Mock, mock_send_msg: mock.Mock
2452 ) -> None:
2453 self.req.config.email_host = "smtp.example.com"
2454 self.req.config.email_port = 587
2455 self.req.config.email_host_username = "mailuser"
2456 self.req.config.email_host_password = "mailpassword"
2457 self.req.config.email_use_tls = True
2459 multidict = MultiDict(
2460 [
2461 (ViewParam.EMAIL, "patient@example.com"),
2462 (ViewParam.EMAIL_CC, "cc@example.com"),
2463 (ViewParam.EMAIL_BCC, "bcc@example.com"),
2464 (ViewParam.EMAIL_FROM, "server@example.com"),
2465 (ViewParam.EMAIL_SUBJECT, "Subject"),
2466 (ViewParam.EMAIL_BODY, "Email body"),
2467 (FormAction.SUBMIT, "submit"),
2468 ]
2469 )
2471 self.req.fake_request_post_from_dict(multidict)
2472 self.req.add_get_params(
2473 {ViewParam.PATIENT_TASK_SCHEDULE_ID: str(self.pts.id)},
2474 set_method_get=False,
2475 )
2476 view = SendEmailFromPatientTaskScheduleView(self.req)
2478 with self.assertRaises(HTTPFound):
2479 view.dispatch()
2481 args, kwargs = mock_make_email.call_args
2482 self.assertEqual(kwargs["to"], "patient@example.com")
2483 self.assertEqual(kwargs["bcc"], "bcc@example.com")
2485 @mock.patch("camcops_server.cc_modules.cc_email.send_msg")
2486 @mock.patch("camcops_server.cc_modules.cc_email.make_email")
2487 def test_message_on_success(
2488 self, mock_make_email: mock.Mock, mock_send_msg: mock.Mock
2489 ) -> None:
2490 multidict = MultiDict(
2491 [
2492 (ViewParam.EMAIL, "patient@example.com"),
2493 (ViewParam.EMAIL_FROM, "server@example.com"),
2494 (ViewParam.EMAIL_SUBJECT, "Subject"),
2495 (ViewParam.EMAIL_BODY, "Email body"),
2496 (FormAction.SUBMIT, "submit"),
2497 ]
2498 )
2500 self.req.fake_request_post_from_dict(multidict)
2501 self.req.add_get_params(
2502 {ViewParam.PATIENT_TASK_SCHEDULE_ID: str(self.pts.id)},
2503 set_method_get=False,
2504 )
2505 view = SendEmailFromPatientTaskScheduleView(self.req)
2507 with self.assertRaises(HTTPFound):
2508 view.dispatch()
2510 messages = self.req.session.peek_flash(FlashQueue.SUCCESS)
2511 self.assertTrue(len(messages) > 0)
2513 self.assertIn("Email sent to patient@example.com", messages[0])
2515 @mock.patch(
2516 "camcops_server.cc_modules.cc_email.send_msg",
2517 side_effect=RuntimeError("Something bad happened"),
2518 )
2519 @mock.patch("camcops_server.cc_modules.cc_email.make_email")
2520 def test_message_on_failure(
2521 self, mock_make_email: mock.Mock, mock_send_msg: mock.Mock
2522 ) -> None:
2523 multidict = MultiDict(
2524 [
2525 (ViewParam.EMAIL, "patient@example.com"),
2526 (ViewParam.EMAIL_FROM, "server@example.com"),
2527 (ViewParam.EMAIL_SUBJECT, "Subject"),
2528 (ViewParam.EMAIL_BODY, "Email body"),
2529 (FormAction.SUBMIT, "submit"),
2530 ]
2531 )
2533 self.req.fake_request_post_from_dict(multidict)
2534 self.req.add_get_params(
2535 {ViewParam.PATIENT_TASK_SCHEDULE_ID: str(self.pts.id)},
2536 set_method_get=False,
2537 )
2538 view = SendEmailFromPatientTaskScheduleView(self.req)
2540 with self.assertRaises(HTTPFound):
2541 view.dispatch()
2543 messages = self.req.session.peek_flash(FlashQueue.DANGER)
2544 self.assertTrue(len(messages) > 0)
2546 self.assertIn(
2547 "Failed to send email to patient@example.com", messages[0]
2548 )
2550 @mock.patch("camcops_server.cc_modules.cc_email.send_msg")
2551 @mock.patch("camcops_server.cc_modules.cc_email.make_email")
2552 def test_email_record_created(
2553 self, mock_make_email: mock.Mock, mock_send_msg: mock.Mock
2554 ) -> None:
2555 multidict = MultiDict(
2556 [
2557 (ViewParam.EMAIL, "patient@example.com"),
2558 (ViewParam.EMAIL_FROM, "server@example.com"),
2559 (ViewParam.EMAIL_SUBJECT, "Subject"),
2560 (ViewParam.EMAIL_BODY, "Email body"),
2561 (FormAction.SUBMIT, "submit"),
2562 ]
2563 )
2565 self.req.fake_request_post_from_dict(multidict)
2566 self.req.add_get_params(
2567 {ViewParam.PATIENT_TASK_SCHEDULE_ID: str(self.pts.id)},
2568 set_method_get=False,
2569 )
2570 view = SendEmailFromPatientTaskScheduleView(self.req)
2572 self.assertEqual(len(self.pts.emails), 0)
2574 with self.assertRaises(HTTPFound):
2575 view.dispatch()
2577 self.assertEqual(len(self.pts.emails), 1)
2578 self.assertEqual(self.pts.emails[0].email.to, "patient@example.com")
2580 def test_unprivileged_user_cannot_email_patient(self) -> None:
2581 user = self.create_user(username="testuser")
2582 self.dbsession.flush()
2584 self.req._debugging_user = user
2586 multidict = MultiDict(
2587 [
2588 (ViewParam.EMAIL, "patient@example.com"),
2589 (ViewParam.EMAIL_FROM, "server@example.com"),
2590 (ViewParam.EMAIL_SUBJECT, "Subject"),
2591 (ViewParam.EMAIL_BODY, "Email body"),
2592 (FormAction.SUBMIT, "submit"),
2593 ]
2594 )
2596 self.req.fake_request_post_from_dict(multidict)
2597 self.req.add_get_params(
2598 {ViewParam.PATIENT_TASK_SCHEDULE_ID: str(self.pts.id)},
2599 set_method_get=False,
2600 )
2602 with self.assertRaises(HTTPBadRequest) as cm:
2603 view = SendEmailFromPatientTaskScheduleView(self.req)
2604 view.dispatch()
2606 self.assertEqual(
2607 cm.exception.message, "Not authorized to email patients"
2608 )
2611class LoginViewTests(TestStateMixin, BasicDatabaseTestCase):
2612 def setUp(self) -> None:
2613 super().setUp()
2615 self.req.matched_route.name = "login_view"
2617 def test_form_rendered_with_values(self) -> None:
2618 self.req.add_get_params(
2619 {ViewParam.REDIRECT_URL: "https://www.example.com"}
2620 )
2621 view = LoginView(self.req)
2623 with mock.patch.object(view, "render_to_response") as mock_render:
2624 view.dispatch()
2626 args, kwargs = mock_render.call_args
2627 context = args[0]
2629 self.assertIn("form", context)
2630 self.assertIn("https://www.example.com", context["form"])
2632 def test_template_rendered(self) -> None:
2633 view = LoginView(self.req)
2634 response = view.dispatch()
2636 self.assertIn("Log in", response.body.decode(UTF8))
2638 def test_password_autocomplete_read_from_config(self) -> None:
2639 self.req.config.disable_password_autocomplete = False
2641 view = LoginView(self.req)
2643 with mock.patch.object(view, "render_to_response") as mock_render:
2644 view.dispatch()
2646 args, kwargs = mock_render.call_args
2647 context = args[0]
2649 self.assertIn('autocomplete="current-password"', context["form"])
2651 def test_fails_when_user_locked_out(self) -> None:
2652 user = self.create_user(username="test")
2653 user.set_password(self.req, "secret")
2654 SecurityAccountLockout.lock_user_out(
2655 self.req, user.username, lockout_minutes=1
2656 )
2658 multidict = MultiDict(
2659 [
2660 (ViewParam.USERNAME, user.username),
2661 (ViewParam.PASSWORD, "secret"),
2662 (FormAction.SUBMIT, "submit"),
2663 ]
2664 )
2666 self.req.fake_request_post_from_dict(multidict)
2668 view = LoginView(self.req)
2670 with mock.patch.object(
2671 view, "fail_locked_out", side_effect=HTTPFound
2672 ) as mock_fail_locked_out:
2673 with self.assertRaises(HTTPFound):
2674 view.dispatch()
2676 args, kwargs = mock_fail_locked_out.call_args
2677 locked_out_until = SecurityAccountLockout.user_locked_out_until(
2678 self.req, user.username
2679 )
2680 self.assertEqual(args[0], locked_out_until)
2682 @mock.patch("camcops_server.cc_modules.webview.audit")
2683 def test_user_can_log_in(self, mock_audit: mock.Mock) -> None:
2684 user = self.create_user(username="test")
2685 user.set_password(self.req, "secret")
2686 self.dbsession.flush()
2687 self.create_membership(user, self.group, may_use_webviewer=True)
2689 multidict = MultiDict(
2690 [
2691 (ViewParam.USERNAME, user.username),
2692 (ViewParam.PASSWORD, "secret"),
2693 (FormAction.SUBMIT, "submit"),
2694 ]
2695 )
2697 self.req.fake_request_post_from_dict(multidict)
2699 view = LoginView(self.req)
2701 with mock.patch.object(user, "login") as mock_user_login:
2702 with mock.patch.object(
2703 self.req.camcops_session, "login"
2704 ) as mock_session_login:
2705 with self.assertRaises(HTTPFound):
2706 view.dispatch()
2708 args, kwargs = mock_user_login.call_args
2709 self.assertEqual(args[0], self.req)
2711 args, kwargs = mock_session_login.call_args
2712 self.assertEqual(args[0], user)
2714 args, kwargs = mock_audit.call_args
2715 self.assertEqual(args[0], self.req)
2716 self.assertEqual(args[1], "Login")
2717 self.assertEqual(kwargs["user_id"], user.id)
2719 def test_user_with_totp_sees_token_form(self) -> None:
2720 user = self.create_user(
2721 username="test",
2722 mfa_secret_key=pyotp.random_base32(),
2723 mfa_method=MfaMethod.TOTP,
2724 )
2725 user.set_password(self.req, "secret")
2726 self.dbsession.flush()
2727 self.create_membership(user, self.group, may_use_webviewer=True)
2729 view = LoginView(self.req)
2730 view.state.update(
2731 mfa_user_id=user.id,
2732 step=MfaMixin.STEP_MFA,
2733 mfa_time=int(time.time()),
2734 )
2736 with mock.patch.object(view, "render_to_response") as mock_render:
2737 view.dispatch()
2739 args, kwargs = mock_render.call_args
2740 context = args[0]
2742 self.assertIn("form", context)
2743 self.assertIn("Enter the six-digit code", context["form"])
2744 self.assertIn(
2745 "Enter the code for CamCOPS displayed",
2746 context[MfaMixin.KEY_INSTRUCTIONS],
2747 )
2749 @mock.patch("camcops_server.cc_modules.cc_email.send_msg")
2750 @mock.patch("camcops_server.cc_modules.cc_email.make_email")
2751 def test_user_with_hotp_email_sees_token_form(
2752 self, mock_make_email: mock.Mock, mock_send_msg: mock.Mock
2753 ) -> None:
2754 user = self.create_user(
2755 username="test",
2756 mfa_secret_key=pyotp.random_base32(),
2757 mfa_method=MfaMethod.HOTP_EMAIL,
2758 email="user@example.com",
2759 hotp_counter=0,
2760 )
2761 user.set_password(self.req, "secret")
2762 self.dbsession.flush()
2763 self.create_membership(user, self.group, may_use_webviewer=True)
2764 view = LoginView(self.req)
2765 view.state.update(
2766 mfa_user_id=user.id,
2767 step=MfaMixin.STEP_MFA,
2768 mfa_time=int(time.time()),
2769 )
2771 with mock.patch.object(view, "render_to_response") as mock_render:
2772 view.dispatch()
2774 args, kwargs = mock_render.call_args
2775 context = args[0]
2777 self.assertIn("form", context)
2778 self.assertIn("Enter the six-digit code", context["form"])
2779 self.assertIn(
2780 "We've sent a code by email", context[MfaMixin.KEY_INSTRUCTIONS]
2781 )
2783 def test_user_with_hotp_sms_sees_token_form(self) -> None:
2784 self.req.config.sms_backend = get_sms_backend(
2785 SmsBackendNames.CONSOLE, {}
2786 )
2788 phone_number = phonenumbers.parse(TEST_PHONE_NUMBER)
2789 user = self.create_user(
2790 username="test",
2791 mfa_secret_key=pyotp.random_base32(),
2792 mfa_method=MfaMethod.HOTP_SMS,
2793 phone_number=phone_number,
2794 hotp_counter=0,
2795 )
2796 user.set_password(self.req, "secret")
2797 self.dbsession.flush()
2798 self.create_membership(user, self.group, may_use_webviewer=True)
2800 view = LoginView(self.req)
2801 view.state.update(
2802 mfa_user_id=user.id,
2803 step=MfaMixin.STEP_MFA,
2804 mfa_time=int(time.time()),
2805 )
2807 with mock.patch.object(view, "render_to_response") as mock_render:
2808 view.dispatch()
2810 args, kwargs = mock_render.call_args
2811 context = args[0]
2813 self.assertIn("form", context)
2814 self.assertIn("Enter the six-digit code", context["form"])
2815 self.assertIn(
2816 "We've sent a code by text message",
2817 context[MfaMixin.KEY_INSTRUCTIONS],
2818 )
2820 @mock.patch("camcops_server.cc_modules.cc_email.send_msg")
2821 @mock.patch("camcops_server.cc_modules.cc_email.make_email")
2822 @mock.patch("camcops_server.cc_modules.webview.time")
2823 def test_session_state_set_for_user_with_mfa(
2824 self,
2825 mock_time: mock.Mock,
2826 mock_make_email: mock.Mock,
2827 mock_send_msg: mock.Mock,
2828 ) -> None:
2829 user = self.create_user(
2830 username="test",
2831 mfa_secret_key=pyotp.random_base32(),
2832 mfa_method=MfaMethod.HOTP_EMAIL,
2833 email="user@example.com",
2834 )
2835 user.set_password(self.req, "secret")
2836 self.dbsession.flush()
2837 self.create_membership(user, self.group, may_use_webviewer=True)
2839 multidict = MultiDict(
2840 [
2841 (ViewParam.USERNAME, user.username),
2842 (ViewParam.PASSWORD, "secret"),
2843 (FormAction.SUBMIT, "submit"),
2844 ]
2845 )
2847 self.req.fake_request_post_from_dict(multidict)
2849 view = LoginView(self.req)
2851 with mock.patch.object(
2852 mock_time, "time", return_value=1234567890.1234567
2853 ):
2854 view.dispatch()
2856 self.assertEqual(
2857 self.req.camcops_session.form_state[LoginView.KEY_MFA_USER_ID],
2858 user.id,
2859 )
2860 self.assertEqual(
2861 self.req.camcops_session.form_state[MfaMixin.KEY_MFA_TIME],
2862 1234567890,
2863 )
2864 self.assertEqual(
2865 self.req.camcops_session.form_state[FormWizardMixin.PARAM_STEP],
2866 MfaMixin.STEP_MFA,
2867 )
2869 @mock.patch("camcops_server.cc_modules.cc_email.send_msg")
2870 @mock.patch("camcops_server.cc_modules.cc_email.make_email")
2871 def test_user_with_hotp_is_sent_email(
2872 self, mock_make_email: mock.Mock, mock_send_msg: mock.Mock
2873 ) -> None:
2874 self.req.config.email_host = "smtp.example.com"
2875 self.req.config.email_port = 587
2876 self.req.config.email_host_username = "mailuser"
2877 self.req.config.email_host_password = "mailpassword"
2878 self.req.config.email_use_tls = True
2879 self.req.config.email_from = "server@example.com"
2881 user = self.create_user(
2882 username="test",
2883 email="user@example.com",
2884 mfa_secret_key=pyotp.random_base32(),
2885 mfa_method=MfaMethod.HOTP_EMAIL,
2886 hotp_counter=0,
2887 )
2888 user.set_password(self.req, "secret")
2889 self.dbsession.flush()
2890 self.create_membership(user, self.group, may_use_webviewer=True)
2892 multidict = MultiDict(
2893 [
2894 (ViewParam.USERNAME, user.username),
2895 (ViewParam.PASSWORD, "secret"),
2896 (FormAction.SUBMIT, "submit"),
2897 ]
2898 )
2900 self.req.fake_request_post_from_dict(multidict)
2902 view = LoginView(self.req)
2903 expected_code = pyotp.HOTP(user.mfa_secret_key).at(1)
2904 view.dispatch()
2906 args, kwargs = mock_make_email.call_args_list[0]
2907 self.assertEqual(kwargs["from_addr"], "server@example.com")
2908 self.assertEqual(kwargs["to"], "user@example.com")
2909 self.assertEqual(kwargs["subject"], "CamCOPS authentication")
2910 self.assertIn(
2911 f"Your CamCOPS verification code is {expected_code}",
2912 kwargs["body"],
2913 )
2914 self.assertEqual(kwargs["content_type"], "text/plain")
2916 args, kwargs = mock_send_msg.call_args
2917 self.assertEqual(kwargs["host"], "smtp.example.com")
2918 self.assertEqual(kwargs["user"], "mailuser")
2919 self.assertEqual(kwargs["password"], "mailpassword")
2920 self.assertEqual(kwargs["port"], 587)
2921 self.assertTrue(kwargs["use_tls"])
2923 def test_user_with_hotp_is_sent_sms(self) -> None:
2924 test_config = {"username": "testuser", "password": "testpass"}
2926 self.req.config.sms_backend = get_sms_backend(
2927 SmsBackendNames.CONSOLE, {}
2928 )
2929 self.req.config.sms_config = test_config
2931 phone_number = phonenumbers.parse(TEST_PHONE_NUMBER)
2932 user = self.create_user(
2933 username="test",
2934 email="user@example.com",
2935 phone_number=phone_number,
2936 mfa_secret_key=pyotp.random_base32(),
2937 mfa_method=MfaMethod.HOTP_SMS,
2938 hotp_counter=0,
2939 )
2940 user.set_password(self.req, "secret")
2941 self.dbsession.flush()
2942 self.create_membership(user, self.group, may_use_webviewer=True)
2944 multidict = MultiDict(
2945 [
2946 (ViewParam.USERNAME, user.username),
2947 (ViewParam.PASSWORD, "secret"),
2948 (FormAction.SUBMIT, "submit"),
2949 ]
2950 )
2952 self.req.fake_request_post_from_dict(multidict)
2954 view = LoginView(self.req)
2955 expected_code = pyotp.HOTP(user.mfa_secret_key).at(1)
2957 with self.assertLogs(level=logging.INFO) as logging_cm:
2958 view.dispatch()
2960 expected_message = f"Your CamCOPS verification code is {expected_code}"
2962 self.assertIn(
2963 ConsoleSmsBackend.make_msg(TEST_PHONE_NUMBER, expected_message),
2964 logging_cm.output[0],
2965 )
2967 @mock.patch("camcops_server.cc_modules.cc_email.send_msg")
2968 @mock.patch("camcops_server.cc_modules.cc_email.make_email")
2969 def test_login_with_hotp_increments_counter(
2970 self, mock_make_email: mock.Mock, mock_send_msg: mock.Mock
2971 ) -> None:
2972 user = self.create_user(
2973 username="test",
2974 email="user@example.com",
2975 mfa_secret_key=pyotp.random_base32(),
2976 mfa_method=MfaMethod.HOTP_EMAIL,
2977 hotp_counter=0,
2978 )
2979 user.set_password(self.req, "secret")
2980 self.dbsession.flush()
2981 self.create_membership(user, self.group, may_use_webviewer=True)
2983 multidict = MultiDict(
2984 [
2985 (ViewParam.USERNAME, user.username),
2986 (ViewParam.PASSWORD, "secret"),
2987 (FormAction.SUBMIT, "submit"),
2988 ]
2989 )
2991 self.req.fake_request_post_from_dict(multidict)
2993 view = LoginView(self.req)
2995 view.dispatch()
2997 self.assertEqual(user.hotp_counter, 1)
2999 @mock.patch("camcops_server.cc_modules.webview.audit")
3000 def test_user_with_totp_can_log_in(self, mock_audit: mock.Mock) -> None:
3001 user = self.create_user(
3002 username="test",
3003 mfa_method=MfaMethod.TOTP,
3004 mfa_secret_key=pyotp.random_base32(),
3005 )
3006 user.set_password(self.req, "secret")
3007 self.dbsession.flush()
3009 self.create_membership(user, self.group, may_use_webviewer=True)
3011 totp = pyotp.TOTP(user.mfa_secret_key)
3013 multidict = MultiDict(
3014 [
3015 (ViewParam.ONE_TIME_PASSWORD, totp.now()),
3016 (FormAction.SUBMIT, "submit"),
3017 ]
3018 )
3020 self.req.fake_request_post_from_dict(multidict)
3022 view = LoginView(self.req)
3023 view.state.update(mfa_user_id=user.id, step=MfaMixin.STEP_MFA)
3025 with mock.patch.object(user, "login") as mock_user_login:
3026 with mock.patch.object(
3027 self.req.camcops_session, "login"
3028 ) as mock_session_login:
3029 with mock.patch.object(view, "timed_out", return_value=False):
3030 with self.assertRaises(HTTPFound):
3031 view.dispatch()
3033 args, kwargs = mock_user_login.call_args
3034 self.assertEqual(args[0], self.req)
3036 args, kwargs = mock_session_login.call_args
3037 self.assertEqual(args[0], user)
3039 args, kwargs = mock_audit.call_args
3040 self.assertEqual(args[0], self.req)
3041 self.assertEqual(args[1], "Login")
3042 self.assertEqual(kwargs["user_id"], user.id)
3043 self.assert_state_is_finished()
3045 @mock.patch("camcops_server.cc_modules.webview.audit")
3046 def test_user_with_hotp_can_log_in(self, mock_audit: mock.Mock) -> None:
3047 user = self.create_user(
3048 username="test",
3049 mfa_method=MfaMethod.HOTP_EMAIL,
3050 mfa_secret_key=pyotp.random_base32(),
3051 hotp_counter=1,
3052 )
3053 user.set_password(self.req, "secret")
3054 self.dbsession.flush()
3056 self.create_membership(user, self.group, may_use_webviewer=True)
3058 hotp = pyotp.HOTP(user.mfa_secret_key)
3059 multidict = MultiDict(
3060 [
3061 (ViewParam.ONE_TIME_PASSWORD, hotp.at(1)),
3062 (FormAction.SUBMIT, "submit"),
3063 ]
3064 )
3066 self.req.fake_request_post_from_dict(multidict)
3068 view = LoginView(self.req)
3069 view.state.update(mfa_user_id=user.id, step=MfaMixin.STEP_MFA)
3071 with mock.patch.object(user, "login") as mock_user_login:
3072 with mock.patch.object(
3073 self.req.camcops_session, "login"
3074 ) as mock_session_login:
3075 with mock.patch.object(view, "timed_out", return_value=False):
3076 with self.assertRaises(HTTPFound):
3077 view.dispatch()
3079 args, kwargs = mock_user_login.call_args
3080 self.assertEqual(args[0], self.req)
3082 args, kwargs = mock_session_login.call_args
3083 self.assertEqual(args[0], user)
3085 args, kwargs = mock_audit.call_args
3086 self.assertEqual(args[0], self.req)
3087 self.assertEqual(args[1], "Login")
3088 self.assertEqual(kwargs["user_id"], user.id)
3089 self.assert_state_is_finished()
3091 def test_form_state_cleared_on_failed_login(self) -> None:
3092 user = self.create_user(
3093 username="test",
3094 mfa_method=MfaMethod.HOTP_EMAIL,
3095 mfa_secret_key=pyotp.random_base32(),
3096 hotp_counter=1,
3097 )
3098 user.set_password(self.req, "secret")
3099 self.dbsession.flush()
3100 self.create_membership(user, self.group, may_use_webviewer=True)
3102 hotp = pyotp.HOTP(user.mfa_secret_key)
3104 multidict = MultiDict(
3105 [
3106 (ViewParam.ONE_TIME_PASSWORD, hotp.at(2)),
3107 (FormAction.SUBMIT, "submit"),
3108 ]
3109 )
3111 self.req.fake_request_post_from_dict(multidict)
3113 view = LoginView(self.req)
3114 view.state.update(step=MfaMixin.STEP_MFA, mfa_user_id=user.id)
3116 with mock.patch.object(view, "timed_out", return_value=False):
3117 with self.assertRaises(HTTPFound):
3118 view.dispatch()
3120 messages = self.req.session.peek_flash(FlashQueue.DANGER)
3121 self.assertTrue(len(messages) > 0)
3122 self.assertIn("You entered an invalid code", messages[0])
3124 self.assert_state_is_clean()
3126 def test_user_cannot_log_in_if_timed_out(self) -> None:
3127 self.req.config.mfa_timeout_s = 600
3128 user = self.create_user(
3129 username="test",
3130 mfa_method=MfaMethod.TOTP,
3131 mfa_secret_key=pyotp.random_base32(),
3132 )
3133 user.set_password(self.req, "secret")
3134 self.dbsession.flush()
3135 self.create_membership(user, self.group, may_use_webviewer=True)
3137 totp = pyotp.TOTP(user.mfa_secret_key)
3139 multidict = MultiDict(
3140 [
3141 (ViewParam.ONE_TIME_PASSWORD, totp.now()),
3142 (FormAction.SUBMIT, "submit"),
3143 ]
3144 )
3146 self.req.fake_request_post_from_dict(multidict)
3148 view = LoginView(self.req)
3149 view.state.update(
3150 mfa_user=user.id,
3151 mfa_time=int(time.time() - 601),
3152 step=MfaMixin.STEP_MFA,
3153 )
3155 with mock.patch.object(
3156 view, "fail_timed_out", side_effect=HTTPFound
3157 ) as mock_fail_timed_out:
3158 with self.assertRaises(HTTPFound):
3159 view.dispatch()
3161 mock_fail_timed_out.assert_called_once()
3163 def test_unprivileged_user_cannot_log_in(self) -> None:
3164 user = self.create_user(username="test")
3165 user.set_password(self.req, "secret")
3166 self.dbsession.flush()
3168 self.create_membership(user, self.group, may_use_webviewer=False)
3170 multidict = MultiDict(
3171 [
3172 (ViewParam.USERNAME, user.username),
3173 (ViewParam.PASSWORD, "secret"),
3174 (FormAction.SUBMIT, "submit"),
3175 ]
3176 )
3178 self.req.fake_request_post_from_dict(multidict)
3180 view = LoginView(self.req)
3182 with mock.patch.object(
3183 view, "fail_not_authorized", side_effect=HTTPFound
3184 ) as mock_fail_not_authorized:
3185 # The fail_not_authorized() function raises an exception
3186 # (of type HTTPFound) so the mock must do too. Otherwise
3187 # it will fall through inappropriately (and crash).
3188 with self.assertRaises(HTTPFound):
3189 view.dispatch()
3191 mock_fail_not_authorized.assert_called_once()
3193 def test_unknown_user_cannot_log_in(self) -> None:
3194 multidict = MultiDict(
3195 [
3196 (ViewParam.USERNAME, "unknown"),
3197 (ViewParam.PASSWORD, "secret"),
3198 (FormAction.SUBMIT, "submit"),
3199 ]
3200 )
3202 self.req.fake_request_post_from_dict(multidict)
3204 view = LoginView(self.req)
3206 with mock.patch.object(
3207 SecurityLoginFailure, "act_on_login_failure"
3208 ) as mock_act:
3209 with mock.patch.object(
3210 self.req.camcops_session, "logout"
3211 ) as mock_logout:
3212 with mock.patch.object(
3213 view, "fail_not_authorized", side_effect=HTTPFound
3214 ) as mock_fail_not_authorized:
3215 with self.assertRaises(HTTPFound):
3216 view.dispatch()
3218 args, kwargs = mock_act.call_args
3219 self.assertEqual(args[0], self.req)
3220 self.assertEqual(args[1], "unknown")
3222 mock_logout.assert_called_once()
3223 mock_fail_not_authorized.assert_called_once()
3225 def test_timed_out_false_when_timeout_zero(self) -> None:
3226 self.req.config.mfa_timeout_s = 0
3227 view = LoginView(self.req)
3228 view.state["mfa_time"] = 0
3230 self.assertFalse(view.timed_out())
3232 def test_timed_out_false_when_no_authenticated_user(self) -> None:
3233 view = LoginView(self.req)
3235 self.assertFalse(view.timed_out())
3237 def test_timed_out_false_when_no_authentication_time(self) -> None:
3238 view = LoginView(self.req)
3240 user = self.create_user(username="test")
3241 # Should never be the case that we have a user ID but no
3242 # authentication time
3243 view.state["mfa_user_id"] = user.id
3245 self.assertFalse(view.timed_out())
3248class EditUserViewTests(BasicDatabaseTestCase):
3249 def test_redirect_on_cancel(self) -> None:
3250 regular_user = self.create_user(username="regular_user")
3251 self.dbsession.flush()
3252 self.req.fake_request_post_from_dict({FormAction.CANCEL: "cancel"})
3253 self.req.add_get_params(
3254 {ViewParam.USER_ID: str(regular_user.id)}, set_method_get=False
3255 )
3257 with self.assertRaises(HTTPFound) as cm:
3258 edit_user(self.req)
3260 self.assertEqual(cm.exception.status_code, 302)
3261 self.assertIn(
3262 f"/{Routes.VIEW_ALL_USERS}", cm.exception.headers["Location"]
3263 )
3265 def test_raises_if_user_may_not_edit_another(self) -> None:
3266 self.req.add_get_params({ViewParam.USER_ID: str(self.user.id)})
3268 regular_user = self.create_user(username="regular_user")
3269 self.dbsession.flush()
3270 self.req._debugging_user = regular_user
3271 with self.assertRaises(HTTPBadRequest) as cm:
3272 edit_user(self.req)
3274 self.assertIn("Nobody may edit the system user", cm.exception.message)
3276 def test_superuser_sees_full_form(self) -> None:
3277 superuser = self.create_user(username="admin", superuser=True)
3278 self.dbsession.flush()
3279 self.req._debugging_user = superuser
3281 self.req.add_get_params({ViewParam.USER_ID: str(superuser.id)})
3283 response = edit_user(self.req)
3285 self.assertIn("Superuser (CAUTION!)", response.body.decode(UTF8))
3287 def test_groupadmin_sees_groupadmin_form(self) -> None:
3288 groupadmin = self.create_user(username="groupadmin")
3289 regular_user = self.create_user(username="regular_user")
3290 self.dbsession.flush()
3291 self.create_membership(groupadmin, self.group, groupadmin=True)
3292 self.create_membership(regular_user, self.group)
3293 self.dbsession.flush()
3294 self.req._debugging_user = groupadmin
3296 self.req.add_get_params({ViewParam.USER_ID: str(regular_user.id)})
3298 response = edit_user(self.req)
3299 content = response.body.decode(UTF8)
3301 self.assertIn("Full name", content)
3302 self.assertNotIn("Superuser (CAUTION!)", content)
3304 def test_raises_for_conflicting_user_name(self) -> None:
3305 self.create_user(username="existing_user")
3306 other_user = self.create_user(username="other_user")
3307 self.dbsession.flush()
3309 multidict = MultiDict(
3310 [
3311 (ViewParam.USERNAME, "existing_user"),
3312 (FormAction.SUBMIT, "submit"),
3313 ]
3314 )
3315 self.req.fake_request_post_from_dict(multidict)
3316 self.req.add_get_params(
3317 {ViewParam.USER_ID: str(other_user.id)}, set_method_get=False
3318 )
3320 with self.assertRaises(HTTPBadRequest) as cm:
3321 edit_user(self.req)
3323 self.assertIn("Can't rename user", cm.exception.message)
3325 def test_user_is_updated(self) -> None:
3326 user = self.create_user(
3327 username="old_username",
3328 fullname="Old Name",
3329 email="old@example.com",
3330 language="da_DK",
3331 )
3332 self.dbsession.flush()
3334 multidict = MultiDict(
3335 [
3336 (ViewParam.USERNAME, "new_username"),
3337 (ViewParam.FULLNAME, "New Name"),
3338 (ViewParam.EMAIL, "new@example.com"),
3339 (ViewParam.LANGUAGE, "en_GB"),
3340 (FormAction.SUBMIT, "submit"),
3341 ]
3342 )
3343 self.req.fake_request_post_from_dict(multidict)
3344 self.req.add_get_params(
3345 {ViewParam.USER_ID: str(user.id)}, set_method_get=False
3346 )
3348 with self.assertRaises(HTTPFound):
3349 edit_user(self.req)
3351 self.assertEqual(user.username, "new_username")
3352 self.assertEqual(user.fullname, "New Name")
3353 self.assertEqual(user.email, "new@example.com")
3354 self.assertEqual(user.language, "en_GB")
3356 def test_user_is_added_to_group(self) -> None:
3357 user = self.create_user(username="regular_user")
3358 group = self.create_group("group")
3359 self.dbsession.flush()
3361 multidict = MultiDict(
3362 [
3363 (ViewParam.USERNAME, user.username),
3364 ("__start__", "group_ids:sequence"),
3365 ("group_id_sequence", str(group.id)),
3366 ("__end__", "group_ids:sequence"),
3367 (FormAction.SUBMIT, "submit"),
3368 ]
3369 )
3370 self.req.fake_request_post_from_dict(multidict)
3371 self.req.add_get_params(
3372 {ViewParam.USER_ID: str(user.id)}, set_method_get=False
3373 )
3375 with mock.patch.object(user, "set_group_ids") as mock_set_group_ids:
3376 with self.assertRaises(HTTPFound):
3377 edit_user(self.req)
3379 mock_set_group_ids.assert_called_once_with([group.id])
3381 def test_user_stays_in_group_the_groupadmin_cannot_edit(self) -> None:
3382 regular_user = self.create_user(username="regular_user")
3383 group_b_admin = self.create_user(username="group_b_admin")
3384 group_a = self.create_group("group_a")
3385 group_b = self.create_group("group_b")
3386 self.dbsession.flush()
3387 self.create_membership(regular_user, group_a)
3388 self.create_membership(regular_user, group_b)
3389 self.create_membership(group_b_admin, group_b, groupadmin=True)
3390 self.dbsession.flush()
3391 self.req._debugging_user = group_b_admin
3393 multidict = MultiDict(
3394 [
3395 (ViewParam.USERNAME, regular_user.username),
3396 ("__start__", "group_ids:sequence"),
3397 ("group_id_sequence", str(group_b.id)),
3398 ("__end__", "group_ids:sequence"),
3399 (FormAction.SUBMIT, "submit"),
3400 ]
3401 )
3402 self.req.fake_request_post_from_dict(multidict)
3403 self.req.add_get_params(
3404 {ViewParam.USER_ID: str(regular_user.id)}, set_method_get=False
3405 )
3407 with mock.patch.object(
3408 regular_user, "set_group_ids"
3409 ) as mock_set_group_ids:
3410 with self.assertRaises(HTTPFound):
3411 edit_user(self.req)
3413 mock_set_group_ids.assert_called_once_with([group_a.id, group_b.id])
3415 def test_upload_group_id_unset_when_membership_removed(self) -> None:
3416 group_a = self.create_group("group_a")
3417 group_b = self.create_group("group_b")
3418 regular_user = self.create_user(
3419 username="regular_user", upload_group=group_a
3420 )
3421 groupadmin = self.create_user(username="groupadmin")
3422 self.dbsession.flush()
3423 self.create_membership(regular_user, group_a)
3424 self.create_membership(regular_user, group_b)
3425 self.create_membership(groupadmin, group_a, groupadmin=True)
3426 self.create_membership(groupadmin, group_b, groupadmin=True)
3427 self.dbsession.flush()
3428 self.req._debugging_user = groupadmin
3430 multidict = MultiDict(
3431 [
3432 (ViewParam.USERNAME, regular_user.username),
3433 ("__start__", "group_ids:sequence"),
3434 ("group_id_sequence", str(group_b.id)),
3435 ("__end__", "group_ids:sequence"),
3436 (FormAction.SUBMIT, "submit"),
3437 ]
3438 )
3439 self.req.fake_request_post_from_dict(multidict)
3440 self.req.add_get_params(
3441 {ViewParam.USER_ID: str(regular_user.id)}, set_method_get=False
3442 )
3444 with self.assertRaises(HTTPFound):
3445 edit_user(self.req)
3447 self.assertIsNone(regular_user.upload_group_id)
3449 def test_get_form_values(self) -> None:
3450 regular_user = self.create_user(
3451 username="regular_user",
3452 fullname="Full Name",
3453 email="user@example.com",
3454 language="da_DK",
3455 )
3456 group_b_admin = self.create_user(username="group_b_admin")
3457 group_a = self.create_group("group_a")
3458 group_b = self.create_group("group_b")
3459 self.dbsession.flush()
3460 self.create_membership(regular_user, group_a)
3461 self.create_membership(regular_user, group_b)
3462 self.create_membership(group_b_admin, group_b, groupadmin=True)
3463 self.dbsession.flush()
3464 self.req._debugging_user = group_b_admin
3466 view = EditUserGroupAdminView(self.req)
3467 # Would normally be set when going through dispatch()
3468 view.object = regular_user
3470 form_values = view.get_form_values()
3472 self.assertEqual(
3473 form_values[ViewParam.USERNAME], regular_user.username
3474 )
3475 self.assertEqual(
3476 form_values[ViewParam.FULLNAME], regular_user.fullname
3477 )
3478 self.assertEqual(form_values[ViewParam.EMAIL], regular_user.email)
3479 self.assertEqual(
3480 form_values[ViewParam.LANGUAGE], regular_user.language
3481 )
3482 self.assertEqual(form_values[ViewParam.GROUP_IDS], [group_b.id])
3484 def test_raises_if_email_address_used_for_mfa(self) -> None:
3485 regular_user = self.create_user(
3486 username="regular_user",
3487 mfa_method=MfaMethod.HOTP_EMAIL,
3488 email="user@example.com",
3489 )
3490 self.dbsession.flush()
3492 multidict = MultiDict(
3493 [
3494 (ViewParam.USERNAME, regular_user.username),
3495 (ViewParam.EMAIL, ""),
3496 (FormAction.SUBMIT, "submit"),
3497 ]
3498 )
3499 self.req.fake_request_post_from_dict(multidict)
3500 self.req.add_get_params(
3501 {ViewParam.USER_ID: str(regular_user.id)}, set_method_get=False
3502 )
3504 with self.assertRaises(HTTPBadRequest) as cm:
3505 edit_user(self.req)
3507 self.assertIn(
3508 "used for multi-factor authentication", cm.exception.message
3509 )
3512class EditOwnUserMfaViewTests(BasicDatabaseTestCase):
3513 def test_get_form_values_mfa_method(self) -> None:
3514 regular_user = self.create_user(
3515 username="regular_user", mfa_method=MfaMethod.HOTP_SMS
3516 )
3517 self.dbsession.flush()
3519 self.req._debugging_user = regular_user
3520 view = EditOwnUserMfaView(self.req)
3522 # Would normally be set when going through dispatch()
3523 view.object = regular_user
3525 form_values = view.get_form_values()
3527 self.assertEqual(
3528 form_values[ViewParam.MFA_METHOD], regular_user.mfa_method
3529 )
3531 def test_get_form_values_hotp_email(self) -> None:
3532 regular_user = self.create_user(
3533 username="regular_user",
3534 mfa_method=MfaMethod.HOTP_EMAIL,
3535 email="regular_user@example.com",
3536 )
3537 self.dbsession.flush()
3539 self.req._debugging_user = regular_user
3540 view = EditOwnUserMfaView(self.req)
3542 # Would normally be set when going through dispatch()
3543 view.object = regular_user
3544 view.state.update(step=EditOwnUserMfaView.STEP_HOTP_EMAIL)
3546 mock_secret_key = pyotp.random_base32()
3547 with mock.patch(
3548 "camcops_server.cc_modules.webview.pyotp.random_base32",
3549 return_value=mock_secret_key,
3550 ) as mock_random_base32:
3551 form_values = view.get_form_values()
3553 mock_random_base32.assert_called_once()
3555 self.assertEqual(
3556 form_values[ViewParam.MFA_SECRET_KEY], mock_secret_key
3557 )
3558 self.assertEqual(form_values[ViewParam.EMAIL], regular_user.email)
3560 def test_get_form_values_hotp_sms(self) -> None:
3561 regular_user = self.create_user(
3562 username="regular_user",
3563 mfa_method=MfaMethod.HOTP_SMS,
3564 phone_number=phonenumbers.parse(TEST_PHONE_NUMBER),
3565 )
3566 self.dbsession.flush()
3568 self.req._debugging_user = regular_user
3569 view = EditOwnUserMfaView(self.req)
3571 # Would normally be set when going through dispatch()
3572 view.object = regular_user
3573 view.state.update(step=EditOwnUserMfaView.STEP_HOTP_SMS)
3575 mock_secret_key = pyotp.random_base32()
3576 with mock.patch(
3577 "camcops_server.cc_modules.webview.pyotp.random_base32",
3578 return_value=mock_secret_key,
3579 ) as mock_random_base32:
3580 form_values = view.get_form_values()
3582 mock_random_base32.assert_called_once()
3584 self.assertEqual(
3585 form_values[ViewParam.MFA_SECRET_KEY], mock_secret_key
3586 )
3587 self.assertEqual(
3588 form_values[ViewParam.PHONE_NUMBER], regular_user.phone_number
3589 )
3591 def test_get_form_values_totp(self) -> None:
3592 regular_user = self.create_user(
3593 username="regular_user", mfa_method=MfaMethod.TOTP
3594 )
3595 self.dbsession.flush()
3597 self.req._debugging_user = regular_user
3598 view = EditOwnUserMfaView(self.req)
3600 # Would normally be set when going through dispatch()
3601 view.object = regular_user
3602 view.state.update(step=EditOwnUserMfaView.STEP_TOTP)
3604 mock_secret_key = pyotp.random_base32()
3605 with mock.patch(
3606 "camcops_server.cc_modules.webview.pyotp.random_base32",
3607 return_value=mock_secret_key,
3608 ) as mock_random_base32:
3609 form_values = view.get_form_values()
3611 mock_random_base32.assert_called_once()
3613 self.assertEqual(
3614 form_values[ViewParam.MFA_SECRET_KEY], mock_secret_key
3615 )
3617 def test_user_can_set_secret_key(self) -> None:
3618 regular_user = self.create_user(username="regular_user")
3619 regular_user.mfa_method = MfaMethod.TOTP
3620 regular_user.ensure_mfa_info()
3621 # ... otherwise, the absence of e.g. the HOTP counter will cause a
3622 # secret key reset.
3623 self.dbsession.flush()
3625 mfa_secret_key = pyotp.random_base32()
3627 multidict = MultiDict(
3628 [
3629 (ViewParam.MFA_SECRET_KEY, mfa_secret_key),
3630 (FormAction.SUBMIT, "submit"),
3631 ]
3632 )
3633 self.req._debugging_user = regular_user
3634 self.req.fake_request_post_from_dict(multidict)
3635 self.req.config.mfa_methods = [MfaMethod.TOTP]
3637 view = EditOwnUserMfaView(self.req)
3638 view.state.update(step=EditOwnUserMfaView.STEP_TOTP)
3640 view.dispatch()
3642 self.assertEqual(regular_user.mfa_secret_key, mfa_secret_key)
3644 def test_user_can_set_method_totp(self) -> None:
3645 regular_user = self.create_user(username="regular_user")
3646 self.dbsession.flush()
3648 multidict = MultiDict(
3649 [
3650 (ViewParam.MFA_METHOD, MfaMethod.TOTP),
3651 (FormAction.SUBMIT, "submit"),
3652 ]
3653 )
3654 self.req._debugging_user = regular_user
3655 self.req.fake_request_post_from_dict(multidict)
3656 self.req.config.mfa_methods = [MfaMethod.TOTP]
3658 view = EditOwnUserMfaView(self.req)
3660 view.dispatch()
3662 self.assertEqual(regular_user.mfa_method, MfaMethod.TOTP)
3664 def test_user_can_set_method_hotp_email(self) -> None:
3665 regular_user = self.create_user(username="regular_user")
3666 self.dbsession.flush()
3668 multidict = MultiDict(
3669 [
3670 (ViewParam.MFA_METHOD, MfaMethod.HOTP_EMAIL),
3671 (FormAction.SUBMIT, "submit"),
3672 ]
3673 )
3674 self.req._debugging_user = regular_user
3675 self.req.fake_request_post_from_dict(multidict)
3676 self.req.config.mfa_methods = [MfaMethod.HOTP_EMAIL]
3678 view = EditOwnUserMfaView(self.req)
3680 view.dispatch()
3682 self.assertEqual(regular_user.mfa_method, MfaMethod.HOTP_EMAIL)
3683 self.assertEqual(regular_user.hotp_counter, 0)
3685 def test_user_can_set_method_hotp_sms(self) -> None:
3686 regular_user = self.create_user(username="regular_user")
3687 self.dbsession.flush()
3689 multidict = MultiDict(
3690 [
3691 (ViewParam.MFA_METHOD, MfaMethod.HOTP_SMS),
3692 (FormAction.SUBMIT, "submit"),
3693 ]
3694 )
3695 self.req._debugging_user = regular_user
3696 self.req.fake_request_post_from_dict(multidict)
3697 self.req.config.mfa_methods = [MfaMethod.HOTP_SMS]
3699 view = EditOwnUserMfaView(self.req)
3701 view.dispatch()
3703 self.assertEqual(regular_user.mfa_method, MfaMethod.HOTP_SMS)
3704 self.assertEqual(regular_user.hotp_counter, 0)
3706 def test_user_can_disable_mfa(self) -> None:
3707 regular_user = self.create_user(
3708 username="regular_user", mfa_method=MfaMethod.TOTP
3709 )
3710 self.dbsession.flush()
3712 multidict = MultiDict(
3713 [
3714 (ViewParam.MFA_METHOD, MfaMethod.NO_MFA),
3715 (FormAction.SUBMIT, "submit"),
3716 ]
3717 )
3718 self.req._debugging_user = regular_user
3719 self.req.fake_request_post_from_dict(multidict)
3720 self.req.config.mfa_methods = [
3721 MfaMethod.TOTP,
3722 MfaMethod.HOTP_SMS,
3723 MfaMethod.HOTP_EMAIL,
3724 MfaMethod.NO_MFA,
3725 ]
3727 view = EditOwnUserMfaView(self.req)
3729 with self.assertRaises(HTTPFound):
3730 view.dispatch()
3732 self.assertEqual(regular_user.mfa_method, MfaMethod.NO_MFA)
3734 def test_user_can_set_phone_number(self) -> None:
3735 regular_user = self.create_user(username="regular_user")
3736 regular_user.mfa_method = MfaMethod.HOTP_SMS
3737 self.dbsession.flush()
3739 multidict = MultiDict(
3740 [
3741 (ViewParam.PHONE_NUMBER, TEST_PHONE_NUMBER),
3742 (FormAction.SUBMIT, "submit"),
3743 ]
3744 )
3745 self.req._debugging_user = regular_user
3746 self.req.fake_request_post_from_dict(multidict)
3747 self.req.config.mfa_methods = [MfaMethod.HOTP_SMS]
3749 view = EditOwnUserMfaView(self.req)
3750 view.state.update(step=EditOwnUserMfaView.STEP_HOTP_SMS)
3752 view.dispatch()
3754 test_number = phonenumbers.parse(TEST_PHONE_NUMBER)
3755 self.assertEqual(regular_user.phone_number, test_number)
3757 def test_user_can_set_email_address(self) -> None:
3758 regular_user = self.create_user(username="regular_user")
3759 # We're going to force this user to the e-mail verification step, so
3760 # we need to ensure it's set to use e-mail MFA:
3761 regular_user.mfa_method = MfaMethod.HOTP_EMAIL
3762 self.dbsession.flush()
3764 multidict = MultiDict(
3765 [
3766 (ViewParam.EMAIL, "regular_user@example.com"),
3767 (FormAction.SUBMIT, "submit"),
3768 ]
3769 )
3770 self.req._debugging_user = regular_user
3771 self.req.fake_request_post_from_dict(multidict)
3773 view = EditOwnUserMfaView(self.req)
3774 view.state.update(step=EditOwnUserMfaView.STEP_HOTP_EMAIL)
3776 view.dispatch()
3778 self.assertEqual(regular_user.email, "regular_user@example.com")
3781class ChangeOtherPasswordViewTests(TestStateMixin, BasicDatabaseTestCase):
3782 def setUp(self) -> None:
3783 super().setUp()
3785 self.req.matched_route.name = "change_other_password"
3787 def test_raises_for_invalid_user(self) -> None:
3788 multidict = MultiDict([(FormAction.SUBMIT, "submit")])
3789 self.req.fake_request_post_from_dict(multidict)
3791 self.req.add_get_params(
3792 {ViewParam.USER_ID: "123"}, set_method_get=False
3793 )
3795 view = ChangeOtherPasswordView(self.req)
3796 with self.assertRaises(HTTPBadRequest) as cm:
3797 view.dispatch()
3799 self.assertIn("Cannot find User with id:123", cm.exception.message)
3801 def test_raises_when_user_may_not_edit_other_user(self) -> None:
3802 regular_user = self.create_user(username="regular_user")
3803 self.dbsession.flush()
3804 multidict = MultiDict(
3805 [
3806 ("__start__", "new_password:mapping"),
3807 (ViewParam.NEW_PASSWORD, "monkeybusiness"),
3808 ("new_password-confirm", "monkeybusiness"),
3809 ("__end__", "new_password:mapping"),
3810 (FormAction.SUBMIT, "submit"),
3811 ]
3812 )
3813 self.req._debugging_user = regular_user
3814 self.req.fake_request_post_from_dict(multidict)
3816 self.req.add_get_params(
3817 {ViewParam.USER_ID: str(self.user.id)}, set_method_get=False
3818 )
3820 view = ChangeOtherPasswordView(self.req)
3821 with self.assertRaises(HTTPBadRequest) as cm:
3822 view.dispatch()
3824 self.assertIn("Nobody may edit the system user", cm.exception.message)
3826 def test_password_set(self) -> None:
3827 groupadmin = self.create_user(username="groupadmin")
3828 regular_user = self.create_user(username="regular_user")
3829 self.dbsession.flush()
3830 self.create_membership(groupadmin, self.group, groupadmin=True)
3831 self.create_membership(regular_user, self.group)
3832 self.dbsession.flush()
3834 self.assertFalse(regular_user.must_change_password)
3836 multidict = MultiDict(
3837 [
3838 ("__start__", "new_password:mapping"),
3839 (ViewParam.NEW_PASSWORD, "monkeybusiness"),
3840 ("new_password-confirm", "monkeybusiness"),
3841 ("__end__", "new_password:mapping"),
3842 (FormAction.SUBMIT, "submit"),
3843 ]
3844 )
3845 self.req._debugging_user = groupadmin
3846 self.req.fake_request_post_from_dict(multidict)
3848 self.req.add_get_params(
3849 {ViewParam.USER_ID: str(regular_user.id)}, set_method_get=False
3850 )
3852 view = ChangeOtherPasswordView(self.req)
3854 with mock.patch.object(
3855 regular_user, "set_password"
3856 ) as mock_set_password:
3857 with self.assertRaises(HTTPFound):
3858 view.dispatch()
3860 mock_set_password.assert_called_once_with(self.req, "monkeybusiness")
3861 self.assertFalse(regular_user.must_change_password)
3863 messages = self.req.session.peek_flash(FlashQueue.SUCCESS)
3864 self.assertTrue(len(messages) > 0)
3865 self.assertIn("Password changed for user 'regular_user'", messages[0])
3867 def test_user_forced_to_change_password(self) -> None:
3868 groupadmin = self.create_user(username="groupadmin")
3869 regular_user = self.create_user(username="regular_user")
3870 self.dbsession.flush()
3871 self.create_membership(groupadmin, self.group, groupadmin=True)
3872 self.create_membership(regular_user, self.group)
3873 self.dbsession.flush()
3875 multidict = MultiDict(
3876 [
3877 (ViewParam.MUST_CHANGE_PASSWORD, "true"),
3878 ("__start__", "new_password:mapping"),
3879 (ViewParam.NEW_PASSWORD, "monkeybusiness"),
3880 ("new_password-confirm", "monkeybusiness"),
3881 ("__end__", "new_password:mapping"),
3882 (FormAction.SUBMIT, "submit"),
3883 ]
3884 )
3885 self.req._debugging_user = groupadmin
3886 self.req.fake_request_post_from_dict(multidict)
3888 self.req.add_get_params(
3889 {ViewParam.USER_ID: str(regular_user.id)}, set_method_get=False
3890 )
3892 view = ChangeOtherPasswordView(self.req)
3894 with mock.patch.object(
3895 regular_user, "force_password_change"
3896 ) as mock_force_change:
3897 with self.assertRaises(HTTPFound):
3898 view.dispatch()
3900 mock_force_change.assert_called_once()
3902 def test_redirects_if_editing_own_account(self) -> None:
3903 superuser = self.create_user(username="admin", superuser=True)
3904 self.dbsession.flush()
3905 self.req._debugging_user = superuser
3906 self.req.add_get_params(
3907 {ViewParam.USER_ID: str(superuser.id)}, set_method_get=False
3908 )
3910 view = ChangeOtherPasswordView(self.req)
3911 with self.assertRaises(HTTPFound) as cm:
3912 view.dispatch()
3914 self.assertEqual(cm.exception.status_code, 302)
3915 self.assertIn(
3916 f"/{Routes.CHANGE_OWN_PASSWORD}", cm.exception.headers["Location"]
3917 )
3919 @mock.patch("camcops_server.cc_modules.cc_email.send_msg")
3920 @mock.patch("camcops_server.cc_modules.cc_email.make_email")
3921 def test_user_sees_otp_form_if_mfa_setup(
3922 self, mock_make_email: mock.Mock, mock_send_msg: mock.Mock
3923 ) -> None:
3924 superuser = self.create_user(
3925 username="admin",
3926 superuser=True,
3927 email="admin@example.com",
3928 mfa_method=MfaMethod.HOTP_EMAIL,
3929 mfa_secret_key=pyotp.random_base32(),
3930 hotp_counter=0,
3931 )
3933 user = self.create_user(username="user")
3934 self.dbsession.flush()
3935 self.req._debugging_user = superuser
3936 self.req.add_get_params(
3937 {ViewParam.USER_ID: str(user.id)}, set_method_get=False
3938 )
3940 view = ChangeOtherPasswordView(self.req)
3942 with mock.patch.object(view, "render_to_response") as mock_render:
3943 view.dispatch()
3945 args, kwargs = mock_render.call_args
3946 context = args[0]
3948 self.assertIn("form", context)
3949 self.assertIn("Enter the six-digit code", context["form"])
3951 def test_code_sent_if_mfa_setup(self) -> None:
3952 self.req.config.sms_backend = get_sms_backend(
3953 SmsBackendNames.CONSOLE, {}
3954 )
3956 phone_number = phonenumbers.parse(TEST_PHONE_NUMBER)
3957 superuser = self.create_user(
3958 username="admin",
3959 superuser=True,
3960 email="admin@example.com",
3961 phone_number=phone_number,
3962 mfa_secret_key=pyotp.random_base32(),
3963 mfa_method=MfaMethod.HOTP_SMS,
3964 hotp_counter=0,
3965 )
3966 user = self.create_user(username="user", email="user@example.com")
3967 self.dbsession.flush()
3969 self.req._debugging_user = superuser
3970 self.req.add_get_params(
3971 {ViewParam.USER_ID: str(user.id)}, set_method_get=False
3972 )
3974 view = ChangeOtherPasswordView(self.req)
3975 with self.assertLogs(level=logging.INFO) as logging_cm:
3976 view.dispatch()
3978 expected_code = pyotp.HOTP(superuser.mfa_secret_key).at(1)
3979 expected_message = f"Your CamCOPS verification code is {expected_code}"
3981 self.assertIn(
3982 ConsoleSmsBackend.make_msg(TEST_PHONE_NUMBER, expected_message),
3983 logging_cm.output[0],
3984 )
3986 def test_user_can_enter_token(self) -> None:
3987 superuser = self.create_user(
3988 username="admin",
3989 superuser=True,
3990 mfa_method=MfaMethod.HOTP_EMAIL,
3991 mfa_secret_key=pyotp.random_base32(),
3992 email="user@example.com",
3993 hotp_counter=1,
3994 )
3995 user = self.create_user(username="user", email="user@example.com")
3996 self.dbsession.flush()
3998 self.req._debugging_user = superuser
3999 self.req.add_get_params(
4000 {ViewParam.USER_ID: str(user.id)}, set_method_get=False
4001 )
4003 hotp = pyotp.HOTP(superuser.mfa_secret_key)
4004 multidict = MultiDict(
4005 [
4006 (ViewParam.ONE_TIME_PASSWORD, hotp.at(1)),
4007 (FormAction.SUBMIT, "submit"),
4008 ]
4009 )
4010 self.req.fake_request_post_from_dict(multidict)
4012 view = ChangeOtherPasswordView(self.req)
4014 response = view.dispatch()
4016 self.assertEqual(
4017 self.req.camcops_session.form_state[FormWizardMixin.PARAM_STEP],
4018 ChangeOtherPasswordView.STEP_CHANGE_PASSWORD,
4019 )
4020 self.assertIn("Change password for user:", response.body.decode(UTF8))
4021 self.assertIn(
4022 "Type the new password and confirm it", response.body.decode(UTF8)
4023 )
4025 def test_form_state_cleared_on_invalid_token(self) -> None:
4026 superuser = self.create_user(
4027 username="superuser",
4028 superuser=True,
4029 mfa_method=MfaMethod.HOTP_EMAIL,
4030 mfa_secret_key=pyotp.random_base32(),
4031 email="user@example.com",
4032 hotp_counter=1,
4033 )
4034 user = self.create_user(username="user", email="user@example.com")
4035 self.dbsession.flush()
4037 self.req._debugging_user = superuser
4038 self.req.add_get_params(
4039 {ViewParam.USER_ID: str(user.id)}, set_method_get=False
4040 )
4042 hotp = pyotp.HOTP(superuser.mfa_secret_key)
4043 multidict = MultiDict(
4044 [
4045 (ViewParam.ONE_TIME_PASSWORD, hotp.at(2)),
4046 (FormAction.SUBMIT, "submit"),
4047 ]
4048 )
4049 self.req.fake_request_post_from_dict(multidict)
4051 view = ChangeOtherPasswordView(self.req)
4053 with self.assertRaises(HTTPFound):
4054 view.dispatch()
4056 messages = self.req.session.peek_flash(FlashQueue.DANGER)
4057 self.assertTrue(len(messages) > 0)
4058 self.assertIn("You entered an invalid code", messages[0])
4060 self.assert_state_is_clean()
4062 def test_cannot_change_password_if_timed_out(self) -> None:
4063 self.req.config.mfa_timeout_s = 600
4064 superuser = self.create_user(
4065 username="admin",
4066 superuser=True,
4067 mfa_method=MfaMethod.TOTP,
4068 mfa_secret_key=pyotp.random_base32(),
4069 )
4070 user = self.create_user(username="user")
4071 self.dbsession.flush()
4073 self.req._debugging_user = superuser
4074 self.req.add_get_params(
4075 {ViewParam.USER_ID: str(user.id)}, set_method_get=False
4076 )
4078 totp = pyotp.TOTP(superuser.mfa_secret_key)
4079 multidict = MultiDict(
4080 [
4081 (ViewParam.ONE_TIME_PASSWORD, totp.now()),
4082 (FormAction.SUBMIT, "submit"),
4083 ]
4084 )
4085 self.req.fake_request_post_from_dict(multidict)
4087 view = ChangeOtherPasswordView(self.req)
4088 view.state.update(
4089 mfa_user=superuser.id,
4090 mfa_time=int(time.time() - 601),
4091 step=MfaMixin.STEP_MFA,
4092 )
4094 with mock.patch.object(
4095 view, "fail_timed_out", side_effect=HTTPFound
4096 ) as mock_fail_timed_out:
4097 with self.assertRaises(HTTPFound):
4098 view.dispatch()
4100 mock_fail_timed_out.assert_called_once()
4103class EditOtherUserMfaViewTests(TestStateMixin, BasicDatabaseTestCase):
4104 def setUp(self) -> None:
4105 super().setUp()
4107 self.req.matched_route.name = "edit_other_user_mfa"
4109 def test_raises_for_invalid_user(self) -> None:
4110 multidict = MultiDict([(FormAction.SUBMIT, "submit")])
4111 self.req.fake_request_post_from_dict(multidict)
4113 self.req.add_get_params(
4114 {ViewParam.USER_ID: "123"}, set_method_get=False
4115 )
4117 view = EditOtherUserMfaView(self.req)
4118 with self.assertRaises(HTTPBadRequest) as cm:
4119 view.dispatch()
4121 self.assertIn("Cannot find User with id:123", cm.exception.message)
4123 def test_raises_when_user_may_not_edit_other_user(self) -> None:
4124 regular_user = self.create_user(username="regular_user")
4125 self.dbsession.flush()
4126 multidict = MultiDict([(FormAction.SUBMIT, "submit")])
4127 self.req._debugging_user = regular_user
4128 self.req.fake_request_post_from_dict(multidict)
4130 self.req.add_get_params(
4131 {ViewParam.USER_ID: str(self.user.id)}, set_method_get=False
4132 )
4134 view = EditOtherUserMfaView(self.req)
4135 with self.assertRaises(HTTPBadRequest) as cm:
4136 view.dispatch()
4138 self.assertIn("Nobody may edit the system user", cm.exception.message)
4140 def test_disable_mfa(self) -> None:
4141 groupadmin = self.create_user(username="groupadmin")
4142 regular_user = self.create_user(
4143 username="regular_user", mfa_method=MfaMethod.TOTP
4144 )
4145 self.dbsession.flush()
4146 self.create_membership(groupadmin, self.group, groupadmin=True)
4147 self.create_membership(regular_user, self.group)
4148 self.dbsession.flush()
4150 self.assertFalse(regular_user.must_change_password)
4152 multidict = MultiDict(
4153 [(ViewParam.DISABLE_MFA, "true"), (FormAction.SUBMIT, "submit")]
4154 )
4155 self.req._debugging_user = groupadmin
4156 self.req.fake_request_post_from_dict(multidict)
4158 self.req.add_get_params(
4159 {ViewParam.USER_ID: str(regular_user.id)}, set_method_get=False
4160 )
4162 view = EditOtherUserMfaView(self.req)
4163 with self.assertRaises(HTTPFound):
4164 view.dispatch()
4166 self.assertEqual(regular_user.mfa_method, MfaMethod.NO_MFA)
4168 messages = self.req.session.peek_flash(FlashQueue.SUCCESS)
4169 self.assertTrue(len(messages) > 0)
4170 self.assertIn(
4171 "Multi-factor authentication disabled for user 'regular_user'",
4172 messages[0],
4173 )
4175 def test_redirects_if_editing_own_account(self) -> None:
4176 superuser = self.create_user(username="admin", superuser=True)
4177 self.dbsession.flush()
4178 self.req._debugging_user = superuser
4179 self.req.add_get_params({ViewParam.USER_ID: str(superuser.id)})
4181 view = EditOtherUserMfaView(self.req)
4182 with self.assertRaises(HTTPFound) as cm:
4183 view.dispatch()
4185 self.assertEqual(cm.exception.status_code, 302)
4186 self.assertIn(
4187 f"/{Routes.EDIT_OWN_USER_MFA}", cm.exception.headers["Location"]
4188 )
4190 @mock.patch("camcops_server.cc_modules.cc_email.send_msg")
4191 @mock.patch("camcops_server.cc_modules.cc_email.make_email")
4192 def test_user_sees_otp_form_if_mfa_setup(
4193 self, mock_make_email: mock.Mock, mock_send_msg: mock.Mock
4194 ) -> None:
4195 superuser = self.create_user(
4196 username="admin",
4197 superuser=True,
4198 email="admin@example.com",
4199 mfa_method=MfaMethod.HOTP_EMAIL,
4200 mfa_secret_key=pyotp.random_base32(),
4201 hotp_counter=0,
4202 )
4204 user = self.create_user(username="user")
4205 self.dbsession.flush()
4206 self.req._debugging_user = superuser
4207 self.req.add_get_params(
4208 {ViewParam.USER_ID: str(user.id)}, set_method_get=False
4209 )
4211 view = EditOtherUserMfaView(self.req)
4213 with mock.patch.object(view, "render_to_response") as mock_render:
4214 view.dispatch()
4216 args, kwargs = mock_render.call_args
4217 context = args[0]
4219 self.assertIn("form", context)
4220 self.assertIn("Enter the six-digit code", context["form"])
4222 def test_code_sent_if_mfa_setup(self) -> None:
4223 self.req.config.sms_backend = get_sms_backend(
4224 SmsBackendNames.CONSOLE, {}
4225 )
4227 phone_number = phonenumbers.parse(TEST_PHONE_NUMBER)
4228 superuser = self.create_user(
4229 username="admin",
4230 superuser=True,
4231 email="admin@example.com",
4232 phone_number=phone_number,
4233 mfa_secret_key=pyotp.random_base32(),
4234 mfa_method=MfaMethod.HOTP_SMS,
4235 hotp_counter=0,
4236 )
4237 user = self.create_user(username="user", email="user@example.com")
4238 self.dbsession.flush()
4240 self.req._debugging_user = superuser
4241 self.req.add_get_params(
4242 {ViewParam.USER_ID: str(user.id)}, set_method_get=False
4243 )
4245 view = EditOtherUserMfaView(self.req)
4246 with self.assertLogs(level=logging.INFO) as logging_cm:
4247 view.dispatch()
4249 expected_code = pyotp.HOTP(superuser.mfa_secret_key).at(1)
4250 expected_message = f"Your CamCOPS verification code is {expected_code}"
4252 self.assertIn(
4253 ConsoleSmsBackend.make_msg(TEST_PHONE_NUMBER, expected_message),
4254 logging_cm.output[0],
4255 )
4257 def test_user_can_enter_token(self) -> None:
4258 superuser = self.create_user(
4259 username="admin",
4260 superuser=True,
4261 mfa_method=MfaMethod.HOTP_EMAIL,
4262 mfa_secret_key=pyotp.random_base32(),
4263 email="user@example.com",
4264 hotp_counter=1,
4265 )
4266 user = self.create_user(username="user", email="user@example.com")
4267 self.dbsession.flush()
4269 self.req._debugging_user = superuser
4270 self.req.add_get_params(
4271 {ViewParam.USER_ID: str(user.id)}, set_method_get=False
4272 )
4274 hotp = pyotp.HOTP(superuser.mfa_secret_key)
4275 multidict = MultiDict(
4276 [
4277 (ViewParam.ONE_TIME_PASSWORD, hotp.at(1)),
4278 (FormAction.SUBMIT, "submit"),
4279 ]
4280 )
4281 self.req.fake_request_post_from_dict(multidict)
4283 view = EditOtherUserMfaView(self.req)
4285 response = view.dispatch()
4287 self.assertEqual(
4288 self.req.camcops_session.form_state[FormWizardMixin.PARAM_STEP],
4289 "other_user_mfa",
4290 )
4291 self.assertIn(
4292 "Edit multi-factor authentication for user:",
4293 response.body.decode(UTF8),
4294 )
4296 def test_form_state_cleared_on_invalid_token(self) -> None:
4297 superuser = self.create_user(
4298 username="superuser",
4299 superuser=True,
4300 mfa_method=MfaMethod.HOTP_EMAIL,
4301 mfa_secret_key=pyotp.random_base32(),
4302 email="user@example.com",
4303 hotp_counter=1,
4304 )
4305 user = self.create_user(username="user", email="user@example.com")
4306 self.dbsession.flush()
4308 self.req._debugging_user = superuser
4309 self.req.add_get_params(
4310 {ViewParam.USER_ID: str(user.id)}, set_method_get=False
4311 )
4313 hotp = pyotp.HOTP(superuser.mfa_secret_key)
4314 multidict = MultiDict(
4315 [
4316 (ViewParam.ONE_TIME_PASSWORD, hotp.at(2)),
4317 (FormAction.SUBMIT, "submit"),
4318 ]
4319 )
4320 self.req.fake_request_post_from_dict(multidict)
4322 view = EditOtherUserMfaView(self.req)
4324 with self.assertRaises(HTTPFound):
4325 view.dispatch()
4327 messages = self.req.session.peek_flash(FlashQueue.DANGER)
4328 self.assertTrue(len(messages) > 0)
4329 self.assertIn("You entered an invalid code", messages[0])
4331 self.assert_state_is_clean()
4334class EditUserGroupMembershipViewTests(BasicDatabaseTestCase):
4335 def setUp(self) -> None:
4336 super().setUp()
4338 self.regular_user = User()
4339 self.regular_user.username = "ruser"
4340 self.regular_user.hashedpw = ""
4341 self.dbsession.add(self.regular_user)
4342 self.dbsession.flush()
4344 self.group_admin = User()
4345 self.group_admin.username = "gadmin"
4346 self.group_admin.hashedpw = ""
4347 self.dbsession.add(self.group_admin)
4348 self.dbsession.flush()
4350 admin_ugm = UserGroupMembership(
4351 user_id=self.group_admin.id, group_id=self.group.id
4352 )
4353 admin_ugm.groupadmin = True
4354 self.dbsession.add(admin_ugm)
4356 self.ugm = UserGroupMembership(
4357 user_id=self.regular_user.id, group_id=self.group.id
4358 )
4359 self.dbsession.add(self.ugm)
4360 self.dbsession.commit()
4362 def test_superuser_can_update_user_group_membership(self) -> None:
4363 self.assertFalse(self.ugm.may_upload)
4364 self.assertFalse(self.ugm.may_register_devices)
4365 self.assertFalse(self.ugm.may_use_webviewer)
4366 self.assertFalse(self.ugm.view_all_patients_when_unfiltered)
4367 self.assertFalse(self.ugm.may_dump_data)
4368 self.assertFalse(self.ugm.may_run_reports)
4369 self.assertFalse(self.ugm.may_add_notes)
4370 self.assertFalse(self.ugm.may_manage_patients)
4371 self.assertFalse(self.ugm.may_email_patients)
4372 self.assertFalse(self.ugm.groupadmin)
4374 multidict = MultiDict(
4375 [
4376 (ViewParam.MAY_UPLOAD, "true"),
4377 (ViewParam.MAY_REGISTER_DEVICES, "true"),
4378 (ViewParam.MAY_USE_WEBVIEWER, "true"),
4379 (ViewParam.VIEW_ALL_PATIENTS_WHEN_UNFILTERED, "true"),
4380 (ViewParam.MAY_DUMP_DATA, "true"),
4381 (ViewParam.MAY_RUN_REPORTS, "true"),
4382 (ViewParam.MAY_ADD_NOTES, "true"),
4383 (ViewParam.MAY_MANAGE_PATIENTS, "true"),
4384 (ViewParam.MAY_EMAIL_PATIENTS, "true"),
4385 (ViewParam.GROUPADMIN, "true"),
4386 (FormAction.SUBMIT, "submit"),
4387 ]
4388 )
4390 self.req.fake_request_post_from_dict(multidict)
4391 self.req.add_get_params(
4392 {ViewParam.USER_GROUP_MEMBERSHIP_ID: str(self.ugm.id)},
4393 set_method_get=False,
4394 )
4396 with self.assertRaises(HTTPFound):
4397 edit_user_group_membership(self.req)
4399 self.assertTrue(self.ugm.may_upload)
4400 self.assertTrue(self.ugm.may_register_devices)
4401 self.assertTrue(self.ugm.may_use_webviewer)
4402 self.assertTrue(self.ugm.view_all_patients_when_unfiltered)
4403 self.assertTrue(self.ugm.may_dump_data)
4404 self.assertTrue(self.ugm.may_run_reports)
4405 self.assertTrue(self.ugm.may_add_notes)
4406 self.assertTrue(self.ugm.may_manage_patients)
4407 self.assertTrue(self.ugm.may_email_patients)
4409 def test_groupadmin_can_update_user_group_membership(self) -> None:
4410 self.req._debugging_user = self.group_admin
4412 self.assertFalse(self.ugm.may_upload)
4413 self.assertFalse(self.ugm.may_register_devices)
4414 self.assertFalse(self.ugm.may_use_webviewer)
4415 self.assertFalse(self.ugm.view_all_patients_when_unfiltered)
4416 self.assertFalse(self.ugm.may_dump_data)
4417 self.assertFalse(self.ugm.may_run_reports)
4418 self.assertFalse(self.ugm.may_add_notes)
4419 self.assertFalse(self.ugm.may_manage_patients)
4420 self.assertFalse(self.ugm.may_email_patients)
4422 multidict = MultiDict(
4423 [
4424 (ViewParam.MAY_UPLOAD, "true"),
4425 (ViewParam.MAY_REGISTER_DEVICES, "true"),
4426 (ViewParam.MAY_USE_WEBVIEWER, "true"),
4427 (ViewParam.VIEW_ALL_PATIENTS_WHEN_UNFILTERED, "true"),
4428 (ViewParam.MAY_DUMP_DATA, "true"),
4429 (ViewParam.MAY_RUN_REPORTS, "true"),
4430 (ViewParam.MAY_ADD_NOTES, "true"),
4431 (ViewParam.MAY_MANAGE_PATIENTS, "true"),
4432 (ViewParam.MAY_EMAIL_PATIENTS, "true"),
4433 (FormAction.SUBMIT, "submit"),
4434 ]
4435 )
4437 self.req.fake_request_post_from_dict(multidict)
4438 self.req.add_get_params(
4439 {ViewParam.USER_GROUP_MEMBERSHIP_ID: str(self.ugm.id)},
4440 set_method_get=False,
4441 )
4443 with self.assertRaises(HTTPFound):
4444 edit_user_group_membership(self.req)
4446 self.assertTrue(self.ugm.may_upload)
4447 self.assertTrue(self.ugm.may_register_devices)
4448 self.assertTrue(self.ugm.may_use_webviewer)
4449 self.assertTrue(self.ugm.view_all_patients_when_unfiltered)
4450 self.assertTrue(self.ugm.may_dump_data)
4451 self.assertTrue(self.ugm.may_run_reports)
4452 self.assertTrue(self.ugm.may_add_notes)
4453 self.assertTrue(self.ugm.may_manage_patients)
4454 self.assertTrue(self.ugm.may_email_patients)
4456 def test_raises_if_cant_edit_user(self) -> None:
4457 self.ugm.user_id = self.user.id
4458 self.dbsession.add(self.ugm)
4459 self.dbsession.commit()
4461 multidict = MultiDict([(FormAction.SUBMIT, "submit")])
4463 self.req.fake_request_post_from_dict(multidict)
4464 self.req.add_get_params(
4465 {ViewParam.USER_GROUP_MEMBERSHIP_ID: str(self.ugm.id)},
4466 set_method_get=False,
4467 )
4469 with self.assertRaises(HTTPBadRequest) as cm:
4470 edit_user_group_membership(self.req)
4472 self.assertIn("Nobody may edit the system user", cm.exception.message)
4474 def test_raises_if_cant_administer_group(self) -> None:
4475 group_a = self.create_group("groupa")
4476 group_b = self.create_group("groupb")
4478 user1 = self.create_user(username="user1")
4479 user2 = self.create_user(username="user2")
4480 self.dbsession.flush()
4482 # User 1 is a group administrator for group A,
4483 # User 2 is a member if group A
4484 self.create_membership(user1, group_a, groupadmin=True)
4485 self.create_membership(user2, group_a),
4487 # User 1 is not an administrator of group B
4488 # User 2 is a member of group B
4489 ugm = self.create_membership(user2, group_b)
4490 self.dbsession.commit()
4492 multidict = MultiDict([(FormAction.SUBMIT, "submit")])
4494 self.req.fake_request_post_from_dict(multidict)
4495 self.req.add_get_params(
4496 {ViewParam.USER_GROUP_MEMBERSHIP_ID: str(ugm.id)},
4497 set_method_get=False,
4498 )
4500 self.req._debugging_user = user1
4502 with self.assertRaises(HTTPBadRequest) as cm:
4503 edit_user_group_membership(self.req)
4505 self.assertIn(
4506 "You may not administer this group", cm.exception.message
4507 )
4509 def test_cancel_returns_to_users_list(self) -> None:
4510 multidict = MultiDict([(FormAction.CANCEL, "cancel")])
4512 self.req.fake_request_post_from_dict(multidict)
4513 self.req.add_get_params(
4514 {ViewParam.USER_GROUP_MEMBERSHIP_ID: str(self.ugm.id)},
4515 set_method_get=False,
4516 )
4518 with self.assertRaises(HTTPFound) as cm:
4519 edit_user_group_membership(self.req)
4521 self.assertEqual(cm.exception.status_code, 302)
4523 self.assertIn(Routes.VIEW_ALL_USERS, cm.exception.headers["Location"])
4526class ChangeOwnPasswordViewTests(TestStateMixin, BasicDatabaseTestCase):
4527 def setUp(self) -> None:
4528 super().setUp()
4530 self.req.matched_route.name = "change_own_password"
4532 def test_user_can_change_password(self) -> None:
4533 new_password = "monkeybusiness"
4535 user = self.create_user(username="user", mfa_method=MfaMethod.NO_MFA)
4536 user.set_password(self.req, "secret")
4537 multidict = MultiDict(
4538 [
4539 (ViewParam.OLD_PASSWORD, "secret"),
4540 ("__start__", "new_password:mapping"),
4541 (ViewParam.NEW_PASSWORD, new_password),
4542 ("new_password-confirm", new_password),
4543 ("__end__", "new_password-mapping"),
4544 (FormAction.SUBMIT, "submit"),
4545 ]
4546 )
4548 self.req.fake_request_post_from_dict(multidict)
4549 self.req._debugging_user = user
4551 with mock.patch.object(user, "set_password") as mock_set_password:
4552 with self.assertRaises(HTTPFound):
4553 change_own_password(self.req)
4555 mock_set_password.assert_called_once_with(self.req, new_password)
4557 messages = self.req.session.peek_flash(FlashQueue.SUCCESS)
4558 self.assertTrue(len(messages) > 0)
4559 self.assertIn("You have changed your password", messages[0])
4560 self.assert_state_is_finished()
4562 def test_user_sees_expiry_message(self) -> None:
4563 user = self.create_user(
4564 username="user",
4565 mfa_method=MfaMethod.NO_MFA,
4566 must_change_password=True,
4567 )
4568 self.req._debugging_user = user
4570 with mock.patch.object(self.req.session, "flash") as mock_flash:
4571 change_own_password(self.req)
4573 args, kwargs = mock_flash.call_args
4574 self.assertIn("Your password has expired", args[0])
4575 self.assertEqual(kwargs["queue"], FlashQueue.DANGER)
4577 def test_password_must_differ(self) -> None:
4578 view = ChangeOwnPasswordView(self.req)
4580 form_kwargs = view.get_form_kwargs()
4581 self.assertIn("must_differ", form_kwargs)
4582 self.assertTrue(form_kwargs["must_differ"])
4584 @mock.patch("camcops_server.cc_modules.cc_email.send_msg")
4585 @mock.patch("camcops_server.cc_modules.cc_email.make_email")
4586 def test_user_sees_otp_form_if_mfa_setup(
4587 self, mock_make_email: mock.Mock, mock_send_msg: mock.Mock
4588 ) -> None:
4589 user = self.create_user(
4590 username="user",
4591 email="user@example.com",
4592 mfa_method=MfaMethod.HOTP_EMAIL,
4593 mfa_secret_key=pyotp.random_base32(),
4594 hotp_counter=0,
4595 )
4596 self.req._debugging_user = user
4598 view = ChangeOwnPasswordView(self.req)
4600 with mock.patch.object(view, "render_to_response") as mock_render:
4601 view.dispatch()
4603 args, kwargs = mock_render.call_args
4604 context = args[0]
4606 self.assertIn("form", context)
4607 self.assertIn("Enter the six-digit code", context["form"])
4609 def test_code_sent_if_mfa_setup(self) -> None:
4610 self.req.config.sms_backend = get_sms_backend(
4611 SmsBackendNames.CONSOLE, {}
4612 )
4613 phone_number = phonenumbers.parse(TEST_PHONE_NUMBER)
4614 user = self.create_user(
4615 username="user",
4616 email="user@example.com",
4617 phone_number=phone_number,
4618 mfa_secret_key=pyotp.random_base32(),
4619 mfa_method=MfaMethod.HOTP_SMS,
4620 hotp_counter=0,
4621 )
4623 self.req._debugging_user = user
4624 view = ChangeOwnPasswordView(self.req)
4625 with self.assertLogs(level=logging.INFO) as logging_cm:
4626 view.dispatch()
4628 expected_code = pyotp.HOTP(user.mfa_secret_key).at(1)
4629 expected_message = f"Your CamCOPS verification code is {expected_code}"
4631 self.assertIn(
4632 ConsoleSmsBackend.make_msg(TEST_PHONE_NUMBER, expected_message),
4633 logging_cm.output[0],
4634 )
4636 def test_user_can_enter_token(self) -> None:
4637 user = self.create_user(
4638 username="user",
4639 mfa_method=MfaMethod.HOTP_EMAIL,
4640 mfa_secret_key=pyotp.random_base32(),
4641 email="user@example.com",
4642 hotp_counter=1,
4643 )
4644 user.set_password(self.req, "secret")
4645 self.dbsession.flush()
4647 self.req._debugging_user = user
4649 hotp = pyotp.HOTP(user.mfa_secret_key)
4650 multidict = MultiDict(
4651 [
4652 (ViewParam.ONE_TIME_PASSWORD, hotp.at(1)), # the token
4653 (FormAction.SUBMIT, "submit"),
4654 ]
4655 )
4656 self.req.fake_request_post_from_dict(multidict)
4658 view = ChangeOwnPasswordView(self.req)
4660 response = view.dispatch()
4662 self.assertEqual(
4663 self.req.camcops_session.form_state[FormWizardMixin.PARAM_STEP],
4664 ChangeOwnPasswordView.STEP_CHANGE_PASSWORD,
4665 )
4666 self.assertIn("Change your password", response.body.decode(UTF8))
4667 self.assertIn(
4668 "Type the new password and confirm it", response.body.decode(UTF8)
4669 )
4671 def test_form_state_cleared_on_invalid_token(self) -> None:
4672 user = self.create_user(
4673 username="user",
4674 mfa_method=MfaMethod.HOTP_EMAIL,
4675 mfa_secret_key=pyotp.random_base32(),
4676 email="user@example.com",
4677 hotp_counter=1,
4678 )
4679 user.set_password(self.req, "secret")
4680 self.dbsession.flush()
4682 self.req._debugging_user = user
4684 hotp = pyotp.HOTP(user.mfa_secret_key)
4685 multidict = MultiDict(
4686 [
4687 (ViewParam.ONE_TIME_PASSWORD, hotp.at(2)),
4688 (FormAction.SUBMIT, "submit"),
4689 ]
4690 )
4691 self.req.fake_request_post_from_dict(multidict)
4693 view = ChangeOwnPasswordView(self.req)
4695 with self.assertRaises(HTTPFound):
4696 view.dispatch()
4698 messages = self.req.session.peek_flash(FlashQueue.DANGER)
4699 self.assertTrue(len(messages) > 0)
4700 self.assertIn("You entered an invalid code", messages[0])
4702 self.assert_state_is_clean()
4704 def test_cannot_change_password_if_timed_out(self) -> None:
4705 self.req.config.mfa_timeout_s = 600
4706 user = self.create_user(
4707 username="user",
4708 mfa_method=MfaMethod.TOTP,
4709 mfa_secret_key=pyotp.random_base32(),
4710 )
4711 user.set_password(self.req, "secret")
4712 self.dbsession.flush()
4714 self.req._debugging_user = user
4716 totp = pyotp.TOTP(user.mfa_secret_key)
4717 multidict = MultiDict(
4718 [
4719 (ViewParam.ONE_TIME_PASSWORD, totp.now()),
4720 (FormAction.SUBMIT, "submit"),
4721 ]
4722 )
4723 self.req.fake_request_post_from_dict(multidict)
4725 view = ChangeOwnPasswordView(self.req)
4726 view.state.update(
4727 mfa_user=user.id,
4728 mfa_time=int(time.time() - 601),
4729 step=MfaMixin.STEP_MFA,
4730 )
4732 with mock.patch.object(
4733 view, "fail_timed_out", side_effect=HTTPFound
4734 ) as mock_fail_timed_out:
4735 with self.assertRaises(HTTPFound):
4736 view.dispatch()
4738 mock_fail_timed_out.assert_called_once()