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

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/cc_modules/tests/webview_tests.py 

5 

6=============================================================================== 

7 

8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry. 

9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk). 

10 

11 This file is part of CamCOPS. 

12 

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. 

17 

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. 

22 

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/>. 

25 

26=============================================================================== 

27""" 

28 

29from collections import OrderedDict 

30import datetime 

31import json 

32import logging 

33import time 

34from typing import cast 

35import unittest 

36from unittest import mock 

37 

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 

46 

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) 

118 

119log = logging.getLogger(__name__) 

120 

121 

122# ============================================================================= 

123# Unit testing 

124# ============================================================================= 

125 

126UTF8 = "utf-8" 

127 

128TEST_NHS_NUMBER_1 = generate_random_nhs_number() 

129TEST_NHS_NUMBER_2 = generate_random_nhs_number() 

130 

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) 

141 

142 

143class WebviewTests(DemoDatabaseTestCase): 

144 """ 

145 Unit tests. 

146 """ 

147 

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)) 

152 

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() 

165 

166 self.assertFalse(any_records_use_group(self.req, group)) 

167 

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") 

175 

176 

177class AddTaskScheduleViewTests(DemoDatabaseTestCase): 

178 """ 

179 Unit tests. 

180 """ 

181 

182 def test_schedule_form_displayed(self) -> None: 

183 view = AddTaskScheduleView(self.req) 

184 

185 response = view.dispatch() 

186 self.assertEqual(response.status_code, 200) 

187 self.assertEqual(response.body.decode(UTF8).count("<form"), 1) 

188 

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 ) 

205 

206 self.req.fake_request_post_from_dict(multidict) 

207 

208 view = AddTaskScheduleView(self.req) 

209 

210 with self.assertRaises(HTTPFound) as e: 

211 view.dispatch() 

212 

213 schedule = self.dbsession.query(TaskSchedule).one() 

214 

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") 

220 

221 self.assertEqual(e.exception.status_code, 302) 

222 self.assertIn( 

223 Routes.VIEW_TASK_SCHEDULES, e.exception.headers["Location"] 

224 ) 

225 

226 

227class EditTaskScheduleViewTests(DemoDatabaseTestCase): 

228 """ 

229 Unit tests. 

230 """ 

231 

232 def setUp(self) -> None: 

233 super().setUp() 

234 

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() 

240 

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 ) 

252 

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 ) 

258 

259 view = EditTaskScheduleView(self.req) 

260 

261 with self.assertRaises(HTTPFound) as e: 

262 view.dispatch() 

263 

264 schedule = self.dbsession.query(TaskSchedule).one() 

265 

266 self.assertEqual(schedule.name, "MOJO") 

267 

268 self.assertEqual(e.exception.status_code, 302) 

269 self.assertIn( 

270 Routes.VIEW_TASK_SCHEDULES, e.exception.headers["Location"] 

271 ) 

272 

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) 

277 

278 group_b = Group() 

279 group_b.name = "Group B" 

280 self.dbsession.add(group_b) 

281 self.dbsession.commit() 

282 

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() 

288 

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 

296 

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 ) 

307 

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 ) 

313 

314 view = EditTaskScheduleView(self.req) 

315 

316 with self.assertRaises(HTTPBadRequest) as cm: 

317 view.dispatch() 

318 

319 self.assertIn("not a group administrator", cm.exception.message) 

320 

321 

322class DeleteTaskScheduleViewTests(DemoDatabaseTestCase): 

323 """ 

324 Unit tests. 

325 """ 

326 

327 def setUp(self) -> None: 

328 super().setUp() 

329 

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() 

335 

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 ) 

353 

354 self.req.fake_request_post_from_dict(multidict) 

355 

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) 

361 

362 with self.assertRaises(HTTPFound) as e: 

363 view.dispatch() 

364 

365 self.assertEqual(e.exception.status_code, 302) 

366 self.assertIn( 

367 Routes.VIEW_TASK_SCHEDULES, e.exception.headers["Location"] 

368 ) 

369 

370 item = self.dbsession.query(TaskScheduleItem).one_or_none() 

371 

372 self.assertIsNone(item) 

373 

374 

375class AddTaskScheduleItemViewTests(DemoDatabaseTestCase): 

376 """ 

377 Unit tests. 

378 """ 

379 

380 def setUp(self) -> None: 

381 super().setUp() 

382 

383 self.schedule = TaskSchedule() 

384 self.schedule.group_id = self.group.id 

385 self.schedule.name = "Test" 

386 

387 self.dbsession.add(self.schedule) 

388 self.dbsession.commit() 

389 

390 def test_schedule_item_form_displayed(self) -> None: 

391 view = AddTaskScheduleItemView(self.req) 

392 

393 self.req.add_get_params({ViewParam.SCHEDULE_ID: str(self.schedule.id)}) 

394 

395 response = view.dispatch() 

396 self.assertEqual(response.status_code, 200) 

397 self.assertEqual(response.body.decode(UTF8).count("<form"), 1) 

398 

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 ) 

421 

422 self.req.fake_request_post_from_dict(multidict) 

423 

424 view = AddTaskScheduleItemView(self.req) 

425 

426 with self.assertRaises(HTTPFound) as e: 

427 view.dispatch() 

428 

429 item = self.dbsession.query(TaskScheduleItem).one() 

430 

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) 

435 

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 ) 

442 

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 ) 

464 

465 self.req.fake_request_post_from_dict(multidict) 

466 

467 view = AddTaskScheduleItemView(self.req) 

468 

469 with self.assertRaises(HTTPFound): 

470 view.dispatch() 

471 

472 item = self.dbsession.query(TaskScheduleItem).one_or_none() 

473 

474 self.assertIsNone(item) 

475 

476 def test_non_existent_schedule_handled(self) -> None: 

477 self.req.add_get_params({ViewParam.SCHEDULE_ID: "99999"}) 

478 

479 view = AddTaskScheduleItemView(self.req) 

480 

481 with self.assertRaises(HTTPBadRequest): 

482 view.dispatch() 

483 

484 

485class EditTaskScheduleItemViewTests(DemoDatabaseTestCase): 

486 """ 

487 Unit tests. 

488 """ 

489 

490 def setUp(self) -> None: 

491 from pendulum import Duration 

492 

493 super().setUp() 

494 

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() 

500 

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() 

508 

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 ) 

530 

531 self.req.fake_request_post_from_dict(multidict) 

532 

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) 

538 

539 with self.assertRaises(HTTPFound) as cm: 

540 view.dispatch() 

541 

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 ) 

549 

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 ) 

571 

572 self.req.fake_request_post_from_dict(multidict) 

573 

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) 

579 

580 with self.assertRaises(HTTPFound): 

581 view.dispatch() 

582 

583 self.assertEqual(self.item.task_table_name, "ace3") 

584 

585 def test_non_existent_item_handled(self) -> None: 

586 self.req.add_get_params({ViewParam.SCHEDULE_ITEM_ID: "99999"}) 

587 

588 view = EditTaskScheduleItemView(self.req) 

589 

590 with self.assertRaises(HTTPBadRequest): 

591 view.dispatch() 

592 

593 def test_null_item_handled(self) -> None: 

594 view = EditTaskScheduleItemView(self.req) 

595 

596 with self.assertRaises(HTTPBadRequest): 

597 view.dispatch() 

598 

599 def test_get_form_values(self) -> None: 

600 view = EditTaskScheduleItemView(self.req) 

601 view.object = self.item 

602 

603 form_values = view.get_form_values() 

604 

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) 

610 

611 due_within = self.item.due_by - self.item.due_from 

612 self.assertEqual(form_values[ViewParam.DUE_WITHIN], due_within) 

613 

614 def test_group_a_item_cannot_be_edited_by_group_b_admin(self) -> None: 

615 from pendulum import Duration 

616 

617 group_a = Group() 

618 group_a.name = "Group A" 

619 self.dbsession.add(group_a) 

620 

621 group_b = Group() 

622 group_b.name = "Group B" 

623 self.dbsession.add(group_b) 

624 self.dbsession.commit() 

625 

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() 

631 

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() 

639 

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 

647 

648 view = EditTaskScheduleItemView(self.req) 

649 view.object = group_a_item 

650 

651 with self.assertRaises(HTTPBadRequest) as cm: 

652 view.get_schedule() 

653 

654 self.assertIn("not a group administrator", cm.exception.message) 

655 

656 

657class DeleteTaskScheduleItemViewTests(DemoDatabaseTestCase): 

658 """ 

659 Unit tests. 

660 """ 

661 

662 def setUp(self) -> None: 

663 super().setUp() 

664 

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() 

670 

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() 

676 

677 def test_delete_form_displayed(self) -> None: 

678 view = DeleteTaskScheduleItemView(self.req) 

679 

680 self.req.add_get_params( 

681 {ViewParam.SCHEDULE_ITEM_ID: str(self.item.id)} 

682 ) 

683 

684 response = view.dispatch() 

685 self.assertEqual(response.status_code, 200) 

686 self.assertEqual(response.body.decode(UTF8).count("<form"), 1) 

687 

688 def test_errors_displayed_when_deletion_validation_fails(self) -> None: 

689 self.req.fake_request_post_from_dict({FormAction.DELETE: "delete"}) 

690 

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) 

696 

697 response = view.dispatch() 

698 self.assertIn( 

699 "Errors have been highlighted", response.body.decode(UTF8) 

700 ) 

701 

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 ) 

719 

720 self.req.fake_request_post_from_dict(multidict) 

721 

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) 

727 

728 with self.assertRaises(HTTPFound) as e: 

729 view.dispatch() 

730 

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 ) 

737 

738 item = self.dbsession.query(TaskScheduleItem).one_or_none() 

739 

740 self.assertIsNone(item) 

741 

742 def test_schedule_item_not_deleted_on_cancel(self) -> None: 

743 self.req.fake_request_post_from_dict({FormAction.CANCEL: "cancel"}) 

744 

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) 

750 

751 with self.assertRaises(HTTPFound): 

752 view.dispatch() 

753 

754 item = self.dbsession.query(TaskScheduleItem).one_or_none() 

755 

756 self.assertIsNotNone(item) 

757 

758 

759class EditFinalizedPatientViewTests(BasicDatabaseTestCase): 

760 """ 

761 Unit tests. 

762 """ 

763 

764 def test_raises_when_patient_does_not_exists(self) -> None: 

765 with self.assertRaises(HTTPBadRequest) as cm: 

766 edit_finalized_patient(self.req) 

767 

768 self.assertEqual( 

769 str(cm.exception), "Cannot find Patient with _pk:None" 

770 ) 

771 

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) 

775 

776 self.req.add_get_params({ViewParam.SERVER_PK: str(patient.pk)}) 

777 

778 with self.assertRaises(HTTPBadRequest) as cm: 

779 edit_finalized_patient(self.req) 

780 

781 self.assertEqual(str(cm.exception), "Bad patient: not in a group") 

782 

783 def test_raises_when_not_authorized(self) -> None: 

784 patient = self.create_patient() 

785 

786 self.req._debugging_user = User() 

787 

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)}) 

794 

795 with self.assertRaises(HTTPBadRequest) as cm: 

796 edit_finalized_patient(self.req) 

797 

798 self.assertEqual( 

799 str(cm.exception), "Not authorized to edit this patient" 

800 ) 

801 

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() 

806 

807 patient = self.create_patient(id=1, _device_id=device.id, _era=ERA_NOW) 

808 

809 self.req.add_get_params({ViewParam.SERVER_PK: str(patient.pk)}) 

810 

811 with self.assertRaises(HTTPBadRequest) as cm: 

812 edit_finalized_patient(self.req) 

813 

814 self.assertIn("Patient is not editable", str(cm.exception)) 

815 

816 def test_patient_updated(self) -> None: 

817 patient = self.create_patient() 

818 

819 self.req.add_get_params( 

820 {ViewParam.SERVER_PK: str(patient.pk)}, set_method_get=False 

821 ) 

822 

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 ) 

855 

856 self.req.fake_request_post_from_dict(multidict) 

857 

858 with self.assertRaises(HTTPFound): 

859 edit_finalized_patient(self.req) 

860 

861 self.dbsession.commit() 

862 

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") 

871 

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) 

876 

877 self.assertEqual(len(patient.special_notes), 1) 

878 note = patient.special_notes[0].note 

879 

880 self.assertIn("Patient details edited", note) 

881 self.assertIn("forename", note) 

882 self.assertIn("Jo", note) 

883 

884 self.assertIn("surname", note) 

885 self.assertIn("Patient", note) 

886 

887 self.assertIn("idnum1", note) 

888 self.assertIn(str(TEST_NHS_NUMBER_1), note) 

889 

890 messages = self.req.session.peek_flash(FlashQueue.SUCCESS) 

891 

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]) 

897 

898 self.assertIn("surname", messages[0]) 

899 self.assertIn("Patient", messages[0]) 

900 

901 self.assertIn("idnum1", messages[0]) 

902 self.assertIn(str(TEST_NHS_NUMBER_1), messages[0]) 

903 

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() 

924 

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 } 

934 

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 ) 

939 

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 ) 

990 

991 self.req.fake_request_post_from_dict(multidict) 

992 

993 with self.assertRaises(HTTPFound): 

994 edit_finalized_patient(self.req) 

995 

996 messages = self.req.session.peek_flash(FlashQueue.INFO) 

997 

998 self.assertIn("No changes required", messages[0]) 

999 

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 ) 

1016 

1017 from camcops_server.tasks import Bmi 

1018 

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) 

1028 

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() 

1039 

1040 self.req.add_get_params({ViewParam.SERVER_PK: str(patient.pk)}) 

1041 

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() 

1047 

1048 args, kwargs = mock_render.call_args 

1049 

1050 context = args[0] 

1051 

1052 self.assertIn("form", context) 

1053 self.assertIn(task1, context["tasks"]) 

1054 self.assertIn(task2, context["tasks"]) 

1055 

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 

1070 

1071 changes = OrderedDict() # type: OrderedDict 

1072 

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 } 

1080 

1081 view._save_simple_params(appstruct, changes) 

1082 

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) 

1092 

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 ) 

1106 

1107 view.object = patient 

1108 

1109 changes = OrderedDict() # type: OrderedDict 

1110 

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 } 

1123 

1124 view._save_idrefs(appstruct, changes) 

1125 

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)) 

1132 

1133 

1134class EditServerCreatedPatientViewTests(BasicDatabaseTestCase): 

1135 """ 

1136 Unit tests. 

1137 """ 

1138 

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() 

1148 

1149 view = EditServerCreatedPatientView(self.req) 

1150 view.object = patient 

1151 

1152 appstruct = {ViewParam.GROUP_ID: new_group.id} 

1153 

1154 view.save_object(appstruct) 

1155 

1156 self.assertEqual(patient.group_id, new_group.id) 

1157 

1158 messages = self.req.session.peek_flash(FlashQueue.SUCCESS) 

1159 

1160 self.assertIn("testgroup", messages[0]) 

1161 self.assertIn("newgroup", messages[0]) 

1162 self.assertIn("group:", messages[0]) 

1163 

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) 

1166 

1167 view = EditServerCreatedPatientView(self.req) 

1168 

1169 self.req.add_get_params({ViewParam.SERVER_PK: str(patient.pk)}) 

1170 

1171 with self.assertRaises(HTTPBadRequest) as cm: 

1172 view.get_object() 

1173 

1174 self.assertIn("Patient is not editable", str(cm.exception)) 

1175 

1176 def test_patient_task_schedules_updated(self) -> None: 

1177 patient = self.create_patient(sex="F", as_server_patient=True) 

1178 

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() 

1192 

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 } 

1202 

1203 self.dbsession.add(patient_task_schedule) 

1204 

1205 patient_task_schedule = PatientTaskSchedule() 

1206 patient_task_schedule.patient_pk = patient.pk 

1207 patient_task_schedule.schedule_id = schedule3.id 

1208 

1209 self.dbsession.add(patient_task_schedule) 

1210 self.dbsession.commit() 

1211 

1212 self.req.add_get_params( 

1213 {ViewParam.SERVER_PK: str(patient.pk)}, set_method_get=False 

1214 ) 

1215 

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 ) 

1275 

1276 self.req.fake_request_post_from_dict(multidict) 

1277 

1278 with self.assertRaises(HTTPFound): 

1279 edit_server_created_patient(self.req) 

1280 

1281 self.dbsession.commit() 

1282 

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) 

1289 

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) 

1300 

1301 messages = self.req.session.peek_flash(FlashQueue.SUCCESS) 

1302 

1303 self.assertIn( 

1304 f"Amended patient record with server PK {patient.pk}", messages[0] 

1305 ) 

1306 self.assertIn("Task schedules", messages[0]) 

1307 

1308 def test_unprivileged_user_cannot_edit_patient(self) -> None: 

1309 patient = self.create_patient(sex="F", as_server_patient=True) 

1310 

1311 user = self.create_user(username="testuser") 

1312 self.dbsession.flush() 

1313 

1314 self.req._debugging_user = user 

1315 

1316 view = EditServerCreatedPatientView(self.req) 

1317 view.object = patient 

1318 

1319 self.req.add_get_params({ViewParam.SERVER_PK: str(patient.pk)}) 

1320 

1321 with self.assertRaises(HTTPBadRequest) as cm: 

1322 view.dispatch() 

1323 

1324 self.assertEqual( 

1325 cm.exception.message, "Not authorized to edit this patient" 

1326 ) 

1327 

1328 def test_patient_can_be_assigned_the_same_schedule_twice(self) -> None: 

1329 patient = self.create_patient(sex="F", as_server_patient=True) 

1330 

1331 schedule1 = TaskSchedule() 

1332 schedule1.group_id = self.group.id 

1333 schedule1.name = "Test 1" 

1334 self.dbsession.add(schedule1) 

1335 self.dbsession.flush() 

1336 

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() 

1343 

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 } 

1360 

1361 view = EditServerCreatedPatientView(self.req) 

1362 view.object = patient 

1363 

1364 changes = {} 

1365 view._save_task_schedules(appstruct, changes) 

1366 self.req.dbsession.commit() 

1367 

1368 self.assertEqual(patient.task_schedules[0].task_schedule, schedule1) 

1369 self.assertEqual(patient.task_schedules[1].task_schedule, schedule1) 

1370 

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 ) 

1383 

1384 schedule1 = TaskSchedule() 

1385 schedule1.group_id = self.group.id 

1386 schedule1.name = "Test 1" 

1387 self.dbsession.add(schedule1) 

1388 self.dbsession.commit() 

1389 

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 } 

1399 

1400 self.dbsession.add(patient_task_schedule) 

1401 self.dbsession.commit() 

1402 

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 ) 

1408 

1409 self.req.add_get_params({ViewParam.SERVER_PK: str(patient.pk)}) 

1410 

1411 view = EditServerCreatedPatientView(self.req) 

1412 view.object = patient 

1413 

1414 form_values = view.get_form_values() 

1415 

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") 

1426 

1427 self.assertEqual(form_values[ViewParam.SERVER_PK], patient.pk) 

1428 self.assertEqual(form_values[ViewParam.GROUP_ID], patient.group.id) 

1429 

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) 

1435 

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 ) 

1452 

1453 

1454class AddPatientViewTests(DemoDatabaseTestCase): 

1455 """ 

1456 Unit tests. 

1457 """ 

1458 

1459 def test_patient_created(self) -> None: 

1460 view = AddPatientView(self.req) 

1461 

1462 schedule1 = TaskSchedule() 

1463 schedule1.group_id = self.group.id 

1464 schedule1.name = "Test 1" 

1465 self.dbsession.add(schedule1) 

1466 

1467 schedule2 = TaskSchedule() 

1468 schedule2.group_id = self.group.id 

1469 schedule2.name = "Test 2" 

1470 self.dbsession.add(schedule2) 

1471 self.dbsession.commit() 

1472 

1473 start_datetime1 = local(2020, 6, 12) 

1474 start_datetime2 = local(2020, 7, 1) 

1475 

1476 settings1 = json.dumps( 

1477 {"name 1": "value 1", "name 2": "value 2", "name 3": "value 3"} 

1478 ) 

1479 

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 } 

1509 

1510 view.save_object(appstruct) 

1511 

1512 patient = cast(Patient, view.object) 

1513 

1514 server_device = Device.get_server_device(self.req.dbsession) 

1515 

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) 

1520 

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") 

1529 

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) 

1534 

1535 patient_task_schedules = { 

1536 pts.task_schedule.name: pts for pts in patient.task_schedules 

1537 } 

1538 

1539 self.assertIn("Test 1", patient_task_schedules) 

1540 self.assertIn("Test 2", patient_task_schedules) 

1541 

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 ) 

1549 

1550 def test_patient_takes_next_available_id(self) -> None: 

1551 self.create_patient(id=1234, as_server_patient=True) 

1552 

1553 view = AddPatientView(self.req) 

1554 

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 } 

1572 

1573 view.save_object(appstruct) 

1574 

1575 patient = cast(Patient, view.object) 

1576 

1577 self.assertEqual(patient.id, 1235) 

1578 

1579 def test_form_rendered_with_values(self) -> None: 

1580 view = AddPatientView(self.req) 

1581 

1582 with mock.patch.object(view, "render_to_response") as mock_render: 

1583 view.dispatch() 

1584 

1585 args, kwargs = mock_render.call_args 

1586 

1587 context = args[0] 

1588 

1589 self.assertIn("form", context) 

1590 

1591 def test_unprivileged_user_cannot_add_patient(self) -> None: 

1592 user = self.create_user(username="testuser") 

1593 self.dbsession.flush() 

1594 

1595 self.req._debugging_user = user 

1596 

1597 with self.assertRaises(HTTPBadRequest) as cm: 

1598 add_patient(self.req) 

1599 

1600 self.assertEqual( 

1601 cm.exception.message, "Not authorized to manage patients" 

1602 ) 

1603 

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() 

1609 

1610 self.req._debugging_user = user 

1611 

1612 view = AddPatientView(self.req) 

1613 

1614 with mock.patch.object(view, "render_to_response") as mock_render: 

1615 view.dispatch() 

1616 

1617 args, kwargs = mock_render.call_args 

1618 

1619 context = args[0] 

1620 

1621 self.assertIn("testgroup", context["form"]) 

1622 

1623 

1624class DeleteServerCreatedPatientViewTests(BasicDatabaseTestCase): 

1625 """ 

1626 Unit tests. 

1627 """ 

1628 

1629 def setUp(self) -> None: 

1630 super().setUp() 

1631 

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 ) 

1642 

1643 patient_pk = self.patient.pk 

1644 

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 ) 

1651 

1652 PatientIdNumIndexEntry.index_idnum(idnum, self.dbsession) 

1653 

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() 

1659 

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() 

1665 

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 ) 

1682 

1683 def test_patient_schedule_and_idnums_deleted(self) -> None: 

1684 self.req.fake_request_post_from_dict(self.multidict) 

1685 

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) 

1691 

1692 with self.assertRaises(HTTPFound) as e: 

1693 view.dispatch() 

1694 

1695 self.assertEqual(e.exception.status_code, 302) 

1696 self.assertIn( 

1697 Routes.VIEW_PATIENT_TASK_SCHEDULES, e.exception.headers["Location"] 

1698 ) 

1699 

1700 deleted_patient = ( 

1701 self.dbsession.query(Patient) 

1702 .filter(Patient._pk == patient_pk) 

1703 .one_or_none() 

1704 ) 

1705 

1706 self.assertIsNone(deleted_patient) 

1707 

1708 pts = ( 

1709 self.dbsession.query(PatientTaskSchedule) 

1710 .filter(PatientTaskSchedule.patient_pk == patient_pk) 

1711 .one_or_none() 

1712 ) 

1713 

1714 self.assertIsNone(pts) 

1715 

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 ) 

1726 

1727 self.assertIsNone(idnum) 

1728 

1729 def test_registered_patient_deleted(self) -> None: 

1730 from camcops_server.cc_modules.client_api import ( 

1731 get_or_create_single_user, 

1732 ) 

1733 

1734 user1, _ = get_or_create_single_user(self.req, "test", self.patient) 

1735 self.assertEqual(user1.single_patient, self.patient) 

1736 

1737 user2, _ = get_or_create_single_user(self.req, "test", self.patient) 

1738 self.assertEqual(user2.single_patient, self.patient) 

1739 

1740 self.req.fake_request_post_from_dict(self.multidict) 

1741 

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) 

1747 

1748 with self.assertRaises(HTTPFound): 

1749 view.dispatch() 

1750 

1751 self.dbsession.commit() 

1752 

1753 deleted_patient = ( 

1754 self.dbsession.query(Patient) 

1755 .filter(Patient._pk == patient_pk) 

1756 .one_or_none() 

1757 ) 

1758 

1759 self.assertIsNone(deleted_patient) 

1760 

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. 

1765 

1766 # user = self.dbsession.query(User).filter( 

1767 # User.id == user1.id).one_or_none() 

1768 # self.assertIsNone(user.single_patient_pk) 

1769 

1770 # user = self.dbsession.query(User).filter( 

1771 # User.id == user2.id).one_or_none() 

1772 # self.assertIsNone(user.single_patient_pk) 

1773 

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 

1786 

1787 saved_patient = ( 

1788 self.dbsession.query(Patient) 

1789 .filter(Patient._pk == patient_pk) 

1790 .one_or_none() 

1791 ) 

1792 

1793 self.assertIsNotNone(saved_patient) 

1794 

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 ) 

1801 

1802 PatientIdNumIndexEntry.index_idnum(idnum, self.dbsession) 

1803 

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 ) 

1814 

1815 self.assertIsNotNone(saved_idnum) 

1816 

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() 

1822 

1823 self.req.fake_request_post_from_dict(self.multidict) 

1824 

1825 self.req.add_get_params( 

1826 {ViewParam.SERVER_PK: self.patient._pk}, set_method_get=False 

1827 ) 

1828 view = DeleteServerCreatedPatientView(self.req) 

1829 

1830 with self.assertRaises(HTTPFound): 

1831 view.dispatch() 

1832 

1833 saved_patient = ( 

1834 self.dbsession.query(Patient) 

1835 .filter(Patient._pk == patient_pk) 

1836 .one_or_none() 

1837 ) 

1838 

1839 self.assertIsNotNone(saved_patient) 

1840 

1841 saved_pts = ( 

1842 self.dbsession.query(PatientTaskSchedule) 

1843 .filter(PatientTaskSchedule.patient_pk == patient_pk) 

1844 .one_or_none() 

1845 ) 

1846 

1847 self.assertIsNotNone(saved_pts) 

1848 

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 ) 

1859 

1860 self.assertIsNotNone(saved_idnum) 

1861 

1862 def test_unprivileged_user_cannot_delete_patient(self) -> None: 

1863 self.req.fake_request_post_from_dict(self.multidict) 

1864 

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) 

1870 

1871 user = self.create_user(username="testuser") 

1872 self.dbsession.flush() 

1873 

1874 self.req._debugging_user = user 

1875 

1876 with self.assertRaises(HTTPBadRequest) as cm: 

1877 view.dispatch() 

1878 

1879 self.assertEqual( 

1880 cm.exception.message, "Not authorized to delete this patient" 

1881 ) 

1882 

1883 def test_unprivileged_user_cannot_see_delete_form(self) -> None: 

1884 self.req.fake_request_post_from_dict(self.multidict) 

1885 

1886 patient_pk = self.patient.pk 

1887 self.req.add_get_params({ViewParam.SERVER_PK: str(patient_pk)}) 

1888 view = DeleteServerCreatedPatientView(self.req) 

1889 

1890 user = self.create_user(username="testuser") 

1891 self.dbsession.flush() 

1892 

1893 self.req._debugging_user = user 

1894 

1895 with self.assertRaises(HTTPBadRequest) as cm: 

1896 view.dispatch() 

1897 

1898 self.assertEqual( 

1899 cm.exception.message, "Not authorized to delete this patient" 

1900 ) 

1901 

1902 

1903class EraseTaskTestCase(BasicDatabaseTestCase): 

1904 """ 

1905 Unit tests. 

1906 """ 

1907 

1908 def create_tasks(self) -> None: 

1909 from camcops_server.tasks.bmi import Bmi 

1910 

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 

1916 

1917 self.dbsession.add(self.task) 

1918 self.dbsession.commit() 

1919 

1920 

1921class EraseTaskLeavingPlaceholderViewTests(EraseTaskTestCase): 

1922 """ 

1923 Unit tests. 

1924 """ 

1925 

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) 

1935 

1936 with mock.patch.object(view, "render_to_response") as mock_render: 

1937 view.dispatch() 

1938 

1939 args, kwargs = mock_render.call_args 

1940 context = args[0] 

1941 

1942 self.assertIn("form", context) 

1943 

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 ) 

1963 

1964 self.req.fake_request_post_from_dict(multidict) 

1965 

1966 view = EraseTaskLeavingPlaceholderView(self.req) 

1967 with mock.patch.object( 

1968 self.task, "manually_erase" 

1969 ) as mock_manually_erase: 

1970 

1971 with self.assertRaises(HTTPFound): 

1972 view.dispatch() 

1973 

1974 mock_manually_erase.assert_called_once() 

1975 args, kwargs = mock_manually_erase.call_args 

1976 request = args[0] 

1977 

1978 self.assertEqual(request, self.req) 

1979 

1980 def test_task_not_deleted_on_cancel(self) -> None: 

1981 self.req.fake_request_post_from_dict({FormAction.CANCEL: "cancel"}) 

1982 

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) 

1991 

1992 with self.assertRaises(HTTPFound): 

1993 view.dispatch() 

1994 

1995 task = self.dbsession.query(self.task.__class__).one_or_none() 

1996 

1997 self.assertIsNotNone(task) 

1998 

1999 def test_redirect_on_cancel(self) -> None: 

2000 self.req.fake_request_post_from_dict({FormAction.CANCEL: "cancel"}) 

2001 

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) 

2010 

2011 with self.assertRaises(HTTPFound) as cm: 

2012 view.dispatch() 

2013 

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 ) 

2028 

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) 

2035 

2036 with self.assertRaises(HTTPBadRequest) as cm: 

2037 view.dispatch() 

2038 

2039 self.assertEqual(cm.exception.message, "No such task: phq9, PK=123") 

2040 

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() 

2045 

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) 

2054 

2055 with self.assertRaises(HTTPBadRequest) as cm: 

2056 view.dispatch() 

2057 

2058 self.assertIn("Task is live on tablet", cm.exception.message) 

2059 

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 ): 

2064 

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) 

2073 

2074 with self.assertRaises(HTTPBadRequest) as cm: 

2075 view.dispatch() 

2076 

2077 self.assertIn("Not authorized to erase tasks", cm.exception.message) 

2078 

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() 

2083 

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) 

2092 

2093 with self.assertRaises(HTTPBadRequest) as cm: 

2094 view.dispatch() 

2095 

2096 self.assertIn("already erased", cm.exception.message) 

2097 

2098 

2099class EraseTaskEntirelyViewTests(EraseTaskTestCase): 

2100 """ 

2101 Unit tests. 

2102 """ 

2103 

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 ) 

2123 

2124 self.req.fake_request_post_from_dict(multidict) 

2125 

2126 view = EraseTaskEntirelyView(self.req) 

2127 

2128 with mock.patch.object( 

2129 self.task, "delete_entirely" 

2130 ) as mock_delete_entirely: 

2131 

2132 with self.assertRaises(HTTPFound): 

2133 view.dispatch() 

2134 

2135 mock_delete_entirely.assert_called_once() 

2136 args, kwargs = mock_delete_entirely.call_args 

2137 request = args[0] 

2138 

2139 self.assertEqual(request, self.req) 

2140 

2141 messages = self.req.session.peek_flash(FlashQueue.SUCCESS) 

2142 self.assertTrue(len(messages) > 0) 

2143 

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]) 

2147 

2148 

2149class EditGroupViewTests(DemoDatabaseTestCase): 

2150 """ 

2151 Unit tests. 

2152 """ 

2153 

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) 

2158 

2159 other_group_2 = Group() 

2160 other_group_2.name = "other-group-2" 

2161 self.dbsession.add(other_group_2) 

2162 

2163 self.dbsession.commit() 

2164 

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) 

2183 

2184 with self.assertRaises(HTTPFound): 

2185 edit_group(self.req) 

2186 

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) 

2193 

2194 def test_ip_use_added(self) -> None: 

2195 from camcops_server.cc_modules.cc_ipuse import IpContexts 

2196 

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) 

2215 

2216 with self.assertRaises(HTTPFound): 

2217 edit_group(self.req) 

2218 

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) 

2223 

2224 def test_ip_use_updated(self) -> None: 

2225 from camcops_server.cc_modules.cc_ipuse import IpContexts 

2226 

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() 

2231 

2232 old_id = self.group.ip_use.id 

2233 

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) 

2252 

2253 with self.assertRaises(HTTPFound): 

2254 edit_group(self.req) 

2255 

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) 

2261 

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) 

2266 

2267 a_group = Group() 

2268 a_group.name = "a-group" 

2269 self.dbsession.add(a_group) 

2270 self.dbsession.commit() 

2271 

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 

2276 

2277 self.dbsession.add(self.group) 

2278 self.dbsession.commit() 

2279 

2280 view = EditGroupView(self.req) 

2281 view.object = self.group 

2282 

2283 form_values = view.get_form_values() 

2284 

2285 self.assertEqual( 

2286 form_values[ViewParam.GROUP_IDS], [a_group.id, z_group.id] 

2287 ) 

2288 

2289 def test_group_id_displayed_in_form(self) -> None: 

2290 view = EditGroupView(self.req) 

2291 view.object = self.group 

2292 

2293 form_values = view.get_form_values() 

2294 

2295 self.assertEqual(form_values[ViewParam.GROUP_ID], self.group.id) 

2296 

2297 def test_ip_use_displayed_in_form(self) -> None: 

2298 view = EditGroupView(self.req) 

2299 view.object = self.group 

2300 

2301 form_values = view.get_form_values() 

2302 

2303 self.assertEqual(form_values[ViewParam.IP_USE], self.group.ip_use) 

2304 

2305 

2306class SendEmailFromPatientTaskScheduleViewTests(BasicDatabaseTestCase): 

2307 def setUp(self) -> None: 

2308 super().setUp() 

2309 

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 ) 

2320 

2321 patient_pk = self.patient.pk 

2322 

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 ) 

2329 

2330 PatientIdNumIndexEntry.index_idnum(idnum, self.dbsession) 

2331 

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() 

2337 

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() 

2343 

2344 def test_displays_form(self) -> None: 

2345 self.req.add_get_params( 

2346 {ViewParam.PATIENT_TASK_SCHEDULE_ID: str(self.pts.id)} 

2347 ) 

2348 

2349 view = SendEmailFromPatientTaskScheduleView(self.req) 

2350 with mock.patch.object(view, "render_to_response") as mock_render: 

2351 view.dispatch() 

2352 

2353 args, kwargs = mock_render.call_args 

2354 context = args[0] 

2355 

2356 self.assertIn("form", context) 

2357 

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() 

2362 

2363 self.assertIn( 

2364 "Patient task schedule does not exist", cm.exception.message 

2365 ) 

2366 

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 

2377 

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 ) 

2387 

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) 

2394 

2395 with self.assertRaises(HTTPFound): 

2396 view.dispatch() 

2397 

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) 

2404 

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"]) 

2411 

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 

2422 

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 ) 

2433 

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) 

2440 

2441 with self.assertRaises(HTTPFound): 

2442 view.dispatch() 

2443 

2444 args, kwargs = mock_make_email.call_args 

2445 self.assertEqual(kwargs["to"], "patient@example.com") 

2446 self.assertEqual(kwargs["cc"], "cc@example.com") 

2447 

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 

2458 

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 ) 

2470 

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) 

2477 

2478 with self.assertRaises(HTTPFound): 

2479 view.dispatch() 

2480 

2481 args, kwargs = mock_make_email.call_args 

2482 self.assertEqual(kwargs["to"], "patient@example.com") 

2483 self.assertEqual(kwargs["bcc"], "bcc@example.com") 

2484 

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 ) 

2499 

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) 

2506 

2507 with self.assertRaises(HTTPFound): 

2508 view.dispatch() 

2509 

2510 messages = self.req.session.peek_flash(FlashQueue.SUCCESS) 

2511 self.assertTrue(len(messages) > 0) 

2512 

2513 self.assertIn("Email sent to patient@example.com", messages[0]) 

2514 

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 ) 

2532 

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) 

2539 

2540 with self.assertRaises(HTTPFound): 

2541 view.dispatch() 

2542 

2543 messages = self.req.session.peek_flash(FlashQueue.DANGER) 

2544 self.assertTrue(len(messages) > 0) 

2545 

2546 self.assertIn( 

2547 "Failed to send email to patient@example.com", messages[0] 

2548 ) 

2549 

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 ) 

2564 

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) 

2571 

2572 self.assertEqual(len(self.pts.emails), 0) 

2573 

2574 with self.assertRaises(HTTPFound): 

2575 view.dispatch() 

2576 

2577 self.assertEqual(len(self.pts.emails), 1) 

2578 self.assertEqual(self.pts.emails[0].email.to, "patient@example.com") 

2579 

2580 def test_unprivileged_user_cannot_email_patient(self) -> None: 

2581 user = self.create_user(username="testuser") 

2582 self.dbsession.flush() 

2583 

2584 self.req._debugging_user = user 

2585 

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 ) 

2595 

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 ) 

2601 

2602 with self.assertRaises(HTTPBadRequest) as cm: 

2603 view = SendEmailFromPatientTaskScheduleView(self.req) 

2604 view.dispatch() 

2605 

2606 self.assertEqual( 

2607 cm.exception.message, "Not authorized to email patients" 

2608 ) 

2609 

2610 

2611class LoginViewTests(TestStateMixin, BasicDatabaseTestCase): 

2612 def setUp(self) -> None: 

2613 super().setUp() 

2614 

2615 self.req.matched_route.name = "login_view" 

2616 

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) 

2622 

2623 with mock.patch.object(view, "render_to_response") as mock_render: 

2624 view.dispatch() 

2625 

2626 args, kwargs = mock_render.call_args 

2627 context = args[0] 

2628 

2629 self.assertIn("form", context) 

2630 self.assertIn("https://www.example.com", context["form"]) 

2631 

2632 def test_template_rendered(self) -> None: 

2633 view = LoginView(self.req) 

2634 response = view.dispatch() 

2635 

2636 self.assertIn("Log in", response.body.decode(UTF8)) 

2637 

2638 def test_password_autocomplete_read_from_config(self) -> None: 

2639 self.req.config.disable_password_autocomplete = False 

2640 

2641 view = LoginView(self.req) 

2642 

2643 with mock.patch.object(view, "render_to_response") as mock_render: 

2644 view.dispatch() 

2645 

2646 args, kwargs = mock_render.call_args 

2647 context = args[0] 

2648 

2649 self.assertIn('autocomplete="current-password"', context["form"]) 

2650 

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 ) 

2657 

2658 multidict = MultiDict( 

2659 [ 

2660 (ViewParam.USERNAME, user.username), 

2661 (ViewParam.PASSWORD, "secret"), 

2662 (FormAction.SUBMIT, "submit"), 

2663 ] 

2664 ) 

2665 

2666 self.req.fake_request_post_from_dict(multidict) 

2667 

2668 view = LoginView(self.req) 

2669 

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() 

2675 

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) 

2681 

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) 

2688 

2689 multidict = MultiDict( 

2690 [ 

2691 (ViewParam.USERNAME, user.username), 

2692 (ViewParam.PASSWORD, "secret"), 

2693 (FormAction.SUBMIT, "submit"), 

2694 ] 

2695 ) 

2696 

2697 self.req.fake_request_post_from_dict(multidict) 

2698 

2699 view = LoginView(self.req) 

2700 

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() 

2707 

2708 args, kwargs = mock_user_login.call_args 

2709 self.assertEqual(args[0], self.req) 

2710 

2711 args, kwargs = mock_session_login.call_args 

2712 self.assertEqual(args[0], user) 

2713 

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) 

2718 

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) 

2728 

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 ) 

2735 

2736 with mock.patch.object(view, "render_to_response") as mock_render: 

2737 view.dispatch() 

2738 

2739 args, kwargs = mock_render.call_args 

2740 context = args[0] 

2741 

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 ) 

2748 

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 ) 

2770 

2771 with mock.patch.object(view, "render_to_response") as mock_render: 

2772 view.dispatch() 

2773 

2774 args, kwargs = mock_render.call_args 

2775 context = args[0] 

2776 

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 ) 

2782 

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 ) 

2787 

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) 

2799 

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 ) 

2806 

2807 with mock.patch.object(view, "render_to_response") as mock_render: 

2808 view.dispatch() 

2809 

2810 args, kwargs = mock_render.call_args 

2811 context = args[0] 

2812 

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 ) 

2819 

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) 

2838 

2839 multidict = MultiDict( 

2840 [ 

2841 (ViewParam.USERNAME, user.username), 

2842 (ViewParam.PASSWORD, "secret"), 

2843 (FormAction.SUBMIT, "submit"), 

2844 ] 

2845 ) 

2846 

2847 self.req.fake_request_post_from_dict(multidict) 

2848 

2849 view = LoginView(self.req) 

2850 

2851 with mock.patch.object( 

2852 mock_time, "time", return_value=1234567890.1234567 

2853 ): 

2854 view.dispatch() 

2855 

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 ) 

2868 

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" 

2880 

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) 

2891 

2892 multidict = MultiDict( 

2893 [ 

2894 (ViewParam.USERNAME, user.username), 

2895 (ViewParam.PASSWORD, "secret"), 

2896 (FormAction.SUBMIT, "submit"), 

2897 ] 

2898 ) 

2899 

2900 self.req.fake_request_post_from_dict(multidict) 

2901 

2902 view = LoginView(self.req) 

2903 expected_code = pyotp.HOTP(user.mfa_secret_key).at(1) 

2904 view.dispatch() 

2905 

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") 

2915 

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"]) 

2922 

2923 def test_user_with_hotp_is_sent_sms(self) -> None: 

2924 test_config = {"username": "testuser", "password": "testpass"} 

2925 

2926 self.req.config.sms_backend = get_sms_backend( 

2927 SmsBackendNames.CONSOLE, {} 

2928 ) 

2929 self.req.config.sms_config = test_config 

2930 

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) 

2943 

2944 multidict = MultiDict( 

2945 [ 

2946 (ViewParam.USERNAME, user.username), 

2947 (ViewParam.PASSWORD, "secret"), 

2948 (FormAction.SUBMIT, "submit"), 

2949 ] 

2950 ) 

2951 

2952 self.req.fake_request_post_from_dict(multidict) 

2953 

2954 view = LoginView(self.req) 

2955 expected_code = pyotp.HOTP(user.mfa_secret_key).at(1) 

2956 

2957 with self.assertLogs(level=logging.INFO) as logging_cm: 

2958 view.dispatch() 

2959 

2960 expected_message = f"Your CamCOPS verification code is {expected_code}" 

2961 

2962 self.assertIn( 

2963 ConsoleSmsBackend.make_msg(TEST_PHONE_NUMBER, expected_message), 

2964 logging_cm.output[0], 

2965 ) 

2966 

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) 

2982 

2983 multidict = MultiDict( 

2984 [ 

2985 (ViewParam.USERNAME, user.username), 

2986 (ViewParam.PASSWORD, "secret"), 

2987 (FormAction.SUBMIT, "submit"), 

2988 ] 

2989 ) 

2990 

2991 self.req.fake_request_post_from_dict(multidict) 

2992 

2993 view = LoginView(self.req) 

2994 

2995 view.dispatch() 

2996 

2997 self.assertEqual(user.hotp_counter, 1) 

2998 

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() 

3008 

3009 self.create_membership(user, self.group, may_use_webviewer=True) 

3010 

3011 totp = pyotp.TOTP(user.mfa_secret_key) 

3012 

3013 multidict = MultiDict( 

3014 [ 

3015 (ViewParam.ONE_TIME_PASSWORD, totp.now()), 

3016 (FormAction.SUBMIT, "submit"), 

3017 ] 

3018 ) 

3019 

3020 self.req.fake_request_post_from_dict(multidict) 

3021 

3022 view = LoginView(self.req) 

3023 view.state.update(mfa_user_id=user.id, step=MfaMixin.STEP_MFA) 

3024 

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() 

3032 

3033 args, kwargs = mock_user_login.call_args 

3034 self.assertEqual(args[0], self.req) 

3035 

3036 args, kwargs = mock_session_login.call_args 

3037 self.assertEqual(args[0], user) 

3038 

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() 

3044 

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() 

3055 

3056 self.create_membership(user, self.group, may_use_webviewer=True) 

3057 

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 ) 

3065 

3066 self.req.fake_request_post_from_dict(multidict) 

3067 

3068 view = LoginView(self.req) 

3069 view.state.update(mfa_user_id=user.id, step=MfaMixin.STEP_MFA) 

3070 

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() 

3078 

3079 args, kwargs = mock_user_login.call_args 

3080 self.assertEqual(args[0], self.req) 

3081 

3082 args, kwargs = mock_session_login.call_args 

3083 self.assertEqual(args[0], user) 

3084 

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() 

3090 

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) 

3101 

3102 hotp = pyotp.HOTP(user.mfa_secret_key) 

3103 

3104 multidict = MultiDict( 

3105 [ 

3106 (ViewParam.ONE_TIME_PASSWORD, hotp.at(2)), 

3107 (FormAction.SUBMIT, "submit"), 

3108 ] 

3109 ) 

3110 

3111 self.req.fake_request_post_from_dict(multidict) 

3112 

3113 view = LoginView(self.req) 

3114 view.state.update(step=MfaMixin.STEP_MFA, mfa_user_id=user.id) 

3115 

3116 with mock.patch.object(view, "timed_out", return_value=False): 

3117 with self.assertRaises(HTTPFound): 

3118 view.dispatch() 

3119 

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]) 

3123 

3124 self.assert_state_is_clean() 

3125 

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) 

3136 

3137 totp = pyotp.TOTP(user.mfa_secret_key) 

3138 

3139 multidict = MultiDict( 

3140 [ 

3141 (ViewParam.ONE_TIME_PASSWORD, totp.now()), 

3142 (FormAction.SUBMIT, "submit"), 

3143 ] 

3144 ) 

3145 

3146 self.req.fake_request_post_from_dict(multidict) 

3147 

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 ) 

3154 

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() 

3160 

3161 mock_fail_timed_out.assert_called_once() 

3162 

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() 

3167 

3168 self.create_membership(user, self.group, may_use_webviewer=False) 

3169 

3170 multidict = MultiDict( 

3171 [ 

3172 (ViewParam.USERNAME, user.username), 

3173 (ViewParam.PASSWORD, "secret"), 

3174 (FormAction.SUBMIT, "submit"), 

3175 ] 

3176 ) 

3177 

3178 self.req.fake_request_post_from_dict(multidict) 

3179 

3180 view = LoginView(self.req) 

3181 

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() 

3190 

3191 mock_fail_not_authorized.assert_called_once() 

3192 

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 ) 

3201 

3202 self.req.fake_request_post_from_dict(multidict) 

3203 

3204 view = LoginView(self.req) 

3205 

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() 

3217 

3218 args, kwargs = mock_act.call_args 

3219 self.assertEqual(args[0], self.req) 

3220 self.assertEqual(args[1], "unknown") 

3221 

3222 mock_logout.assert_called_once() 

3223 mock_fail_not_authorized.assert_called_once() 

3224 

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 

3229 

3230 self.assertFalse(view.timed_out()) 

3231 

3232 def test_timed_out_false_when_no_authenticated_user(self) -> None: 

3233 view = LoginView(self.req) 

3234 

3235 self.assertFalse(view.timed_out()) 

3236 

3237 def test_timed_out_false_when_no_authentication_time(self) -> None: 

3238 view = LoginView(self.req) 

3239 

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 

3244 

3245 self.assertFalse(view.timed_out()) 

3246 

3247 

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 ) 

3256 

3257 with self.assertRaises(HTTPFound) as cm: 

3258 edit_user(self.req) 

3259 

3260 self.assertEqual(cm.exception.status_code, 302) 

3261 self.assertIn( 

3262 f"/{Routes.VIEW_ALL_USERS}", cm.exception.headers["Location"] 

3263 ) 

3264 

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)}) 

3267 

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) 

3273 

3274 self.assertIn("Nobody may edit the system user", cm.exception.message) 

3275 

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 

3280 

3281 self.req.add_get_params({ViewParam.USER_ID: str(superuser.id)}) 

3282 

3283 response = edit_user(self.req) 

3284 

3285 self.assertIn("Superuser (CAUTION!)", response.body.decode(UTF8)) 

3286 

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 

3295 

3296 self.req.add_get_params({ViewParam.USER_ID: str(regular_user.id)}) 

3297 

3298 response = edit_user(self.req) 

3299 content = response.body.decode(UTF8) 

3300 

3301 self.assertIn("Full name", content) 

3302 self.assertNotIn("Superuser (CAUTION!)", content) 

3303 

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() 

3308 

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 ) 

3319 

3320 with self.assertRaises(HTTPBadRequest) as cm: 

3321 edit_user(self.req) 

3322 

3323 self.assertIn("Can't rename user", cm.exception.message) 

3324 

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() 

3333 

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 ) 

3347 

3348 with self.assertRaises(HTTPFound): 

3349 edit_user(self.req) 

3350 

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") 

3355 

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() 

3360 

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 ) 

3374 

3375 with mock.patch.object(user, "set_group_ids") as mock_set_group_ids: 

3376 with self.assertRaises(HTTPFound): 

3377 edit_user(self.req) 

3378 

3379 mock_set_group_ids.assert_called_once_with([group.id]) 

3380 

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 

3392 

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 ) 

3406 

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) 

3412 

3413 mock_set_group_ids.assert_called_once_with([group_a.id, group_b.id]) 

3414 

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 

3429 

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 ) 

3443 

3444 with self.assertRaises(HTTPFound): 

3445 edit_user(self.req) 

3446 

3447 self.assertIsNone(regular_user.upload_group_id) 

3448 

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 

3465 

3466 view = EditUserGroupAdminView(self.req) 

3467 # Would normally be set when going through dispatch() 

3468 view.object = regular_user 

3469 

3470 form_values = view.get_form_values() 

3471 

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]) 

3483 

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() 

3491 

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 ) 

3503 

3504 with self.assertRaises(HTTPBadRequest) as cm: 

3505 edit_user(self.req) 

3506 

3507 self.assertIn( 

3508 "used for multi-factor authentication", cm.exception.message 

3509 ) 

3510 

3511 

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() 

3518 

3519 self.req._debugging_user = regular_user 

3520 view = EditOwnUserMfaView(self.req) 

3521 

3522 # Would normally be set when going through dispatch() 

3523 view.object = regular_user 

3524 

3525 form_values = view.get_form_values() 

3526 

3527 self.assertEqual( 

3528 form_values[ViewParam.MFA_METHOD], regular_user.mfa_method 

3529 ) 

3530 

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() 

3538 

3539 self.req._debugging_user = regular_user 

3540 view = EditOwnUserMfaView(self.req) 

3541 

3542 # Would normally be set when going through dispatch() 

3543 view.object = regular_user 

3544 view.state.update(step=EditOwnUserMfaView.STEP_HOTP_EMAIL) 

3545 

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() 

3552 

3553 mock_random_base32.assert_called_once() 

3554 

3555 self.assertEqual( 

3556 form_values[ViewParam.MFA_SECRET_KEY], mock_secret_key 

3557 ) 

3558 self.assertEqual(form_values[ViewParam.EMAIL], regular_user.email) 

3559 

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() 

3567 

3568 self.req._debugging_user = regular_user 

3569 view = EditOwnUserMfaView(self.req) 

3570 

3571 # Would normally be set when going through dispatch() 

3572 view.object = regular_user 

3573 view.state.update(step=EditOwnUserMfaView.STEP_HOTP_SMS) 

3574 

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() 

3581 

3582 mock_random_base32.assert_called_once() 

3583 

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 ) 

3590 

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() 

3596 

3597 self.req._debugging_user = regular_user 

3598 view = EditOwnUserMfaView(self.req) 

3599 

3600 # Would normally be set when going through dispatch() 

3601 view.object = regular_user 

3602 view.state.update(step=EditOwnUserMfaView.STEP_TOTP) 

3603 

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() 

3610 

3611 mock_random_base32.assert_called_once() 

3612 

3613 self.assertEqual( 

3614 form_values[ViewParam.MFA_SECRET_KEY], mock_secret_key 

3615 ) 

3616 

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() 

3624 

3625 mfa_secret_key = pyotp.random_base32() 

3626 

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] 

3636 

3637 view = EditOwnUserMfaView(self.req) 

3638 view.state.update(step=EditOwnUserMfaView.STEP_TOTP) 

3639 

3640 view.dispatch() 

3641 

3642 self.assertEqual(regular_user.mfa_secret_key, mfa_secret_key) 

3643 

3644 def test_user_can_set_method_totp(self) -> None: 

3645 regular_user = self.create_user(username="regular_user") 

3646 self.dbsession.flush() 

3647 

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] 

3657 

3658 view = EditOwnUserMfaView(self.req) 

3659 

3660 view.dispatch() 

3661 

3662 self.assertEqual(regular_user.mfa_method, MfaMethod.TOTP) 

3663 

3664 def test_user_can_set_method_hotp_email(self) -> None: 

3665 regular_user = self.create_user(username="regular_user") 

3666 self.dbsession.flush() 

3667 

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] 

3677 

3678 view = EditOwnUserMfaView(self.req) 

3679 

3680 view.dispatch() 

3681 

3682 self.assertEqual(regular_user.mfa_method, MfaMethod.HOTP_EMAIL) 

3683 self.assertEqual(regular_user.hotp_counter, 0) 

3684 

3685 def test_user_can_set_method_hotp_sms(self) -> None: 

3686 regular_user = self.create_user(username="regular_user") 

3687 self.dbsession.flush() 

3688 

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] 

3698 

3699 view = EditOwnUserMfaView(self.req) 

3700 

3701 view.dispatch() 

3702 

3703 self.assertEqual(regular_user.mfa_method, MfaMethod.HOTP_SMS) 

3704 self.assertEqual(regular_user.hotp_counter, 0) 

3705 

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() 

3711 

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 ] 

3726 

3727 view = EditOwnUserMfaView(self.req) 

3728 

3729 with self.assertRaises(HTTPFound): 

3730 view.dispatch() 

3731 

3732 self.assertEqual(regular_user.mfa_method, MfaMethod.NO_MFA) 

3733 

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() 

3738 

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] 

3748 

3749 view = EditOwnUserMfaView(self.req) 

3750 view.state.update(step=EditOwnUserMfaView.STEP_HOTP_SMS) 

3751 

3752 view.dispatch() 

3753 

3754 test_number = phonenumbers.parse(TEST_PHONE_NUMBER) 

3755 self.assertEqual(regular_user.phone_number, test_number) 

3756 

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() 

3763 

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) 

3772 

3773 view = EditOwnUserMfaView(self.req) 

3774 view.state.update(step=EditOwnUserMfaView.STEP_HOTP_EMAIL) 

3775 

3776 view.dispatch() 

3777 

3778 self.assertEqual(regular_user.email, "regular_user@example.com") 

3779 

3780 

3781class ChangeOtherPasswordViewTests(TestStateMixin, BasicDatabaseTestCase): 

3782 def setUp(self) -> None: 

3783 super().setUp() 

3784 

3785 self.req.matched_route.name = "change_other_password" 

3786 

3787 def test_raises_for_invalid_user(self) -> None: 

3788 multidict = MultiDict([(FormAction.SUBMIT, "submit")]) 

3789 self.req.fake_request_post_from_dict(multidict) 

3790 

3791 self.req.add_get_params( 

3792 {ViewParam.USER_ID: "123"}, set_method_get=False 

3793 ) 

3794 

3795 view = ChangeOtherPasswordView(self.req) 

3796 with self.assertRaises(HTTPBadRequest) as cm: 

3797 view.dispatch() 

3798 

3799 self.assertIn("Cannot find User with id:123", cm.exception.message) 

3800 

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) 

3815 

3816 self.req.add_get_params( 

3817 {ViewParam.USER_ID: str(self.user.id)}, set_method_get=False 

3818 ) 

3819 

3820 view = ChangeOtherPasswordView(self.req) 

3821 with self.assertRaises(HTTPBadRequest) as cm: 

3822 view.dispatch() 

3823 

3824 self.assertIn("Nobody may edit the system user", cm.exception.message) 

3825 

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() 

3833 

3834 self.assertFalse(regular_user.must_change_password) 

3835 

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) 

3847 

3848 self.req.add_get_params( 

3849 {ViewParam.USER_ID: str(regular_user.id)}, set_method_get=False 

3850 ) 

3851 

3852 view = ChangeOtherPasswordView(self.req) 

3853 

3854 with mock.patch.object( 

3855 regular_user, "set_password" 

3856 ) as mock_set_password: 

3857 with self.assertRaises(HTTPFound): 

3858 view.dispatch() 

3859 

3860 mock_set_password.assert_called_once_with(self.req, "monkeybusiness") 

3861 self.assertFalse(regular_user.must_change_password) 

3862 

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]) 

3866 

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() 

3874 

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) 

3887 

3888 self.req.add_get_params( 

3889 {ViewParam.USER_ID: str(regular_user.id)}, set_method_get=False 

3890 ) 

3891 

3892 view = ChangeOtherPasswordView(self.req) 

3893 

3894 with mock.patch.object( 

3895 regular_user, "force_password_change" 

3896 ) as mock_force_change: 

3897 with self.assertRaises(HTTPFound): 

3898 view.dispatch() 

3899 

3900 mock_force_change.assert_called_once() 

3901 

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 ) 

3909 

3910 view = ChangeOtherPasswordView(self.req) 

3911 with self.assertRaises(HTTPFound) as cm: 

3912 view.dispatch() 

3913 

3914 self.assertEqual(cm.exception.status_code, 302) 

3915 self.assertIn( 

3916 f"/{Routes.CHANGE_OWN_PASSWORD}", cm.exception.headers["Location"] 

3917 ) 

3918 

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 ) 

3932 

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 ) 

3939 

3940 view = ChangeOtherPasswordView(self.req) 

3941 

3942 with mock.patch.object(view, "render_to_response") as mock_render: 

3943 view.dispatch() 

3944 

3945 args, kwargs = mock_render.call_args 

3946 context = args[0] 

3947 

3948 self.assertIn("form", context) 

3949 self.assertIn("Enter the six-digit code", context["form"]) 

3950 

3951 def test_code_sent_if_mfa_setup(self) -> None: 

3952 self.req.config.sms_backend = get_sms_backend( 

3953 SmsBackendNames.CONSOLE, {} 

3954 ) 

3955 

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() 

3968 

3969 self.req._debugging_user = superuser 

3970 self.req.add_get_params( 

3971 {ViewParam.USER_ID: str(user.id)}, set_method_get=False 

3972 ) 

3973 

3974 view = ChangeOtherPasswordView(self.req) 

3975 with self.assertLogs(level=logging.INFO) as logging_cm: 

3976 view.dispatch() 

3977 

3978 expected_code = pyotp.HOTP(superuser.mfa_secret_key).at(1) 

3979 expected_message = f"Your CamCOPS verification code is {expected_code}" 

3980 

3981 self.assertIn( 

3982 ConsoleSmsBackend.make_msg(TEST_PHONE_NUMBER, expected_message), 

3983 logging_cm.output[0], 

3984 ) 

3985 

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() 

3997 

3998 self.req._debugging_user = superuser 

3999 self.req.add_get_params( 

4000 {ViewParam.USER_ID: str(user.id)}, set_method_get=False 

4001 ) 

4002 

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) 

4011 

4012 view = ChangeOtherPasswordView(self.req) 

4013 

4014 response = view.dispatch() 

4015 

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 ) 

4024 

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() 

4036 

4037 self.req._debugging_user = superuser 

4038 self.req.add_get_params( 

4039 {ViewParam.USER_ID: str(user.id)}, set_method_get=False 

4040 ) 

4041 

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) 

4050 

4051 view = ChangeOtherPasswordView(self.req) 

4052 

4053 with self.assertRaises(HTTPFound): 

4054 view.dispatch() 

4055 

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]) 

4059 

4060 self.assert_state_is_clean() 

4061 

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() 

4072 

4073 self.req._debugging_user = superuser 

4074 self.req.add_get_params( 

4075 {ViewParam.USER_ID: str(user.id)}, set_method_get=False 

4076 ) 

4077 

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) 

4086 

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 ) 

4093 

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() 

4099 

4100 mock_fail_timed_out.assert_called_once() 

4101 

4102 

4103class EditOtherUserMfaViewTests(TestStateMixin, BasicDatabaseTestCase): 

4104 def setUp(self) -> None: 

4105 super().setUp() 

4106 

4107 self.req.matched_route.name = "edit_other_user_mfa" 

4108 

4109 def test_raises_for_invalid_user(self) -> None: 

4110 multidict = MultiDict([(FormAction.SUBMIT, "submit")]) 

4111 self.req.fake_request_post_from_dict(multidict) 

4112 

4113 self.req.add_get_params( 

4114 {ViewParam.USER_ID: "123"}, set_method_get=False 

4115 ) 

4116 

4117 view = EditOtherUserMfaView(self.req) 

4118 with self.assertRaises(HTTPBadRequest) as cm: 

4119 view.dispatch() 

4120 

4121 self.assertIn("Cannot find User with id:123", cm.exception.message) 

4122 

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) 

4129 

4130 self.req.add_get_params( 

4131 {ViewParam.USER_ID: str(self.user.id)}, set_method_get=False 

4132 ) 

4133 

4134 view = EditOtherUserMfaView(self.req) 

4135 with self.assertRaises(HTTPBadRequest) as cm: 

4136 view.dispatch() 

4137 

4138 self.assertIn("Nobody may edit the system user", cm.exception.message) 

4139 

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() 

4149 

4150 self.assertFalse(regular_user.must_change_password) 

4151 

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) 

4157 

4158 self.req.add_get_params( 

4159 {ViewParam.USER_ID: str(regular_user.id)}, set_method_get=False 

4160 ) 

4161 

4162 view = EditOtherUserMfaView(self.req) 

4163 with self.assertRaises(HTTPFound): 

4164 view.dispatch() 

4165 

4166 self.assertEqual(regular_user.mfa_method, MfaMethod.NO_MFA) 

4167 

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 ) 

4174 

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)}) 

4180 

4181 view = EditOtherUserMfaView(self.req) 

4182 with self.assertRaises(HTTPFound) as cm: 

4183 view.dispatch() 

4184 

4185 self.assertEqual(cm.exception.status_code, 302) 

4186 self.assertIn( 

4187 f"/{Routes.EDIT_OWN_USER_MFA}", cm.exception.headers["Location"] 

4188 ) 

4189 

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 ) 

4203 

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 ) 

4210 

4211 view = EditOtherUserMfaView(self.req) 

4212 

4213 with mock.patch.object(view, "render_to_response") as mock_render: 

4214 view.dispatch() 

4215 

4216 args, kwargs = mock_render.call_args 

4217 context = args[0] 

4218 

4219 self.assertIn("form", context) 

4220 self.assertIn("Enter the six-digit code", context["form"]) 

4221 

4222 def test_code_sent_if_mfa_setup(self) -> None: 

4223 self.req.config.sms_backend = get_sms_backend( 

4224 SmsBackendNames.CONSOLE, {} 

4225 ) 

4226 

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() 

4239 

4240 self.req._debugging_user = superuser 

4241 self.req.add_get_params( 

4242 {ViewParam.USER_ID: str(user.id)}, set_method_get=False 

4243 ) 

4244 

4245 view = EditOtherUserMfaView(self.req) 

4246 with self.assertLogs(level=logging.INFO) as logging_cm: 

4247 view.dispatch() 

4248 

4249 expected_code = pyotp.HOTP(superuser.mfa_secret_key).at(1) 

4250 expected_message = f"Your CamCOPS verification code is {expected_code}" 

4251 

4252 self.assertIn( 

4253 ConsoleSmsBackend.make_msg(TEST_PHONE_NUMBER, expected_message), 

4254 logging_cm.output[0], 

4255 ) 

4256 

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() 

4268 

4269 self.req._debugging_user = superuser 

4270 self.req.add_get_params( 

4271 {ViewParam.USER_ID: str(user.id)}, set_method_get=False 

4272 ) 

4273 

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) 

4282 

4283 view = EditOtherUserMfaView(self.req) 

4284 

4285 response = view.dispatch() 

4286 

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 ) 

4295 

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() 

4307 

4308 self.req._debugging_user = superuser 

4309 self.req.add_get_params( 

4310 {ViewParam.USER_ID: str(user.id)}, set_method_get=False 

4311 ) 

4312 

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) 

4321 

4322 view = EditOtherUserMfaView(self.req) 

4323 

4324 with self.assertRaises(HTTPFound): 

4325 view.dispatch() 

4326 

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]) 

4330 

4331 self.assert_state_is_clean() 

4332 

4333 

4334class EditUserGroupMembershipViewTests(BasicDatabaseTestCase): 

4335 def setUp(self) -> None: 

4336 super().setUp() 

4337 

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() 

4343 

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() 

4349 

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) 

4355 

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() 

4361 

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) 

4373 

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 ) 

4389 

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 ) 

4395 

4396 with self.assertRaises(HTTPFound): 

4397 edit_user_group_membership(self.req) 

4398 

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) 

4408 

4409 def test_groupadmin_can_update_user_group_membership(self) -> None: 

4410 self.req._debugging_user = self.group_admin 

4411 

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) 

4421 

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 ) 

4436 

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 ) 

4442 

4443 with self.assertRaises(HTTPFound): 

4444 edit_user_group_membership(self.req) 

4445 

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) 

4455 

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() 

4460 

4461 multidict = MultiDict([(FormAction.SUBMIT, "submit")]) 

4462 

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 ) 

4468 

4469 with self.assertRaises(HTTPBadRequest) as cm: 

4470 edit_user_group_membership(self.req) 

4471 

4472 self.assertIn("Nobody may edit the system user", cm.exception.message) 

4473 

4474 def test_raises_if_cant_administer_group(self) -> None: 

4475 group_a = self.create_group("groupa") 

4476 group_b = self.create_group("groupb") 

4477 

4478 user1 = self.create_user(username="user1") 

4479 user2 = self.create_user(username="user2") 

4480 self.dbsession.flush() 

4481 

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), 

4486 

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() 

4491 

4492 multidict = MultiDict([(FormAction.SUBMIT, "submit")]) 

4493 

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 ) 

4499 

4500 self.req._debugging_user = user1 

4501 

4502 with self.assertRaises(HTTPBadRequest) as cm: 

4503 edit_user_group_membership(self.req) 

4504 

4505 self.assertIn( 

4506 "You may not administer this group", cm.exception.message 

4507 ) 

4508 

4509 def test_cancel_returns_to_users_list(self) -> None: 

4510 multidict = MultiDict([(FormAction.CANCEL, "cancel")]) 

4511 

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 ) 

4517 

4518 with self.assertRaises(HTTPFound) as cm: 

4519 edit_user_group_membership(self.req) 

4520 

4521 self.assertEqual(cm.exception.status_code, 302) 

4522 

4523 self.assertIn(Routes.VIEW_ALL_USERS, cm.exception.headers["Location"]) 

4524 

4525 

4526class ChangeOwnPasswordViewTests(TestStateMixin, BasicDatabaseTestCase): 

4527 def setUp(self) -> None: 

4528 super().setUp() 

4529 

4530 self.req.matched_route.name = "change_own_password" 

4531 

4532 def test_user_can_change_password(self) -> None: 

4533 new_password = "monkeybusiness" 

4534 

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 ) 

4547 

4548 self.req.fake_request_post_from_dict(multidict) 

4549 self.req._debugging_user = user 

4550 

4551 with mock.patch.object(user, "set_password") as mock_set_password: 

4552 with self.assertRaises(HTTPFound): 

4553 change_own_password(self.req) 

4554 

4555 mock_set_password.assert_called_once_with(self.req, new_password) 

4556 

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() 

4561 

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 

4569 

4570 with mock.patch.object(self.req.session, "flash") as mock_flash: 

4571 change_own_password(self.req) 

4572 

4573 args, kwargs = mock_flash.call_args 

4574 self.assertIn("Your password has expired", args[0]) 

4575 self.assertEqual(kwargs["queue"], FlashQueue.DANGER) 

4576 

4577 def test_password_must_differ(self) -> None: 

4578 view = ChangeOwnPasswordView(self.req) 

4579 

4580 form_kwargs = view.get_form_kwargs() 

4581 self.assertIn("must_differ", form_kwargs) 

4582 self.assertTrue(form_kwargs["must_differ"]) 

4583 

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 

4597 

4598 view = ChangeOwnPasswordView(self.req) 

4599 

4600 with mock.patch.object(view, "render_to_response") as mock_render: 

4601 view.dispatch() 

4602 

4603 args, kwargs = mock_render.call_args 

4604 context = args[0] 

4605 

4606 self.assertIn("form", context) 

4607 self.assertIn("Enter the six-digit code", context["form"]) 

4608 

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 ) 

4622 

4623 self.req._debugging_user = user 

4624 view = ChangeOwnPasswordView(self.req) 

4625 with self.assertLogs(level=logging.INFO) as logging_cm: 

4626 view.dispatch() 

4627 

4628 expected_code = pyotp.HOTP(user.mfa_secret_key).at(1) 

4629 expected_message = f"Your CamCOPS verification code is {expected_code}" 

4630 

4631 self.assertIn( 

4632 ConsoleSmsBackend.make_msg(TEST_PHONE_NUMBER, expected_message), 

4633 logging_cm.output[0], 

4634 ) 

4635 

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() 

4646 

4647 self.req._debugging_user = user 

4648 

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) 

4657 

4658 view = ChangeOwnPasswordView(self.req) 

4659 

4660 response = view.dispatch() 

4661 

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 ) 

4670 

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() 

4681 

4682 self.req._debugging_user = user 

4683 

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) 

4692 

4693 view = ChangeOwnPasswordView(self.req) 

4694 

4695 with self.assertRaises(HTTPFound): 

4696 view.dispatch() 

4697 

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]) 

4701 

4702 self.assert_state_is_clean() 

4703 

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() 

4713 

4714 self.req._debugging_user = user 

4715 

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) 

4724 

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 ) 

4731 

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() 

4737 

4738 mock_fail_timed_out.assert_called_once()