|
1 |
| -# Copyright 2019 Therp BV <https://therp.nl> |
| 1 | +# Copyright 2019-2025 Therp BV <https://therp.nl> |
2 | 2 | # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
|
3 |
| -from unittest.mock import patch |
| 3 | +from unittest import mock |
| 4 | + |
| 5 | +from lxml.html import document_fromstring |
4 | 6 |
|
5 | 7 | from odoo import http
|
| 8 | +from odoo.tests import HOST, Opener, get_db_name, tagged |
| 9 | + |
| 10 | +from .common import HttpCaseSMS |
6 | 11 |
|
7 |
| -from .common import Common |
| 12 | +_module_ns = "odoo.addons.auth_sms" |
| 13 | +_requests_class = _module_ns + ".models.sms_provider.requests" |
| 14 | +_users_class = _module_ns + ".models.res_users.ResUsers" |
8 | 15 |
|
9 | 16 |
|
10 |
| -class TestAuthSms(Common): |
| 17 | +@tagged("post_install", "-at_install") |
| 18 | +class TestAuthSms(HttpCaseSMS): |
11 | 19 | def test_auth_sms_login_no_2fa(self):
|
12 | 20 | # admin doesn't have sms verification turned on
|
13 |
| - with self._request( |
14 |
| - "/web/login", |
15 |
| - method="POST", |
16 |
| - data={ |
17 |
| - "login": self.env.user.login, |
18 |
| - "password": self.env.user.login, |
19 |
| - }, |
20 |
| - ) as (request, endpoint): |
21 |
| - response = endpoint() |
22 |
| - self.assertFalse(response.template) |
| 21 | + response = self._login_user(self.admin_user.login, self.admin_user.login) |
| 22 | + self.assertEqual(response.request.path_url, "/web") |
| 23 | + self.assertEqual(response.status_code, 200) |
| 24 | + |
| 25 | + def test_auth_sms_login_no_error(self): |
| 26 | + # first request: login |
| 27 | + response = self._mock_login_user(self.demo_user.login, self.password) |
| 28 | + self.assertEqual(response.request.path_url, "/web/login") |
| 29 | + # fill the correct code |
| 30 | + response = self._enter_code(self.code) |
| 31 | + self.assertEqual(response.request.path_url, "/web") |
23 | 32 |
|
24 | 33 | def test_auth_sms_login(self):
|
25 | 34 | # first request: login
|
26 |
| - with self._request( |
27 |
| - "/web/login", |
28 |
| - data={ |
29 |
| - "login": self.demo_user.login, |
30 |
| - "password": self.demo_user.login, |
31 |
| - }, |
32 |
| - ) as (request, endpoint), patch( |
33 |
| - "odoo.addons.auth_sms.models.sms_provider.requests.post", |
34 |
| - ) as mock_request_post: |
| 35 | + response = self._mock_login_user(self.demo_user.login, self.password) |
| 36 | + self.assertEqual(response.request.path_url, "/web/login") |
| 37 | + # then fill in a wrong code |
| 38 | + response = self._enter_code("wrong code") |
| 39 | + self.assertEqual(response.request.path_url, "/auth_sms/code") |
| 40 | + # fill the correct code |
| 41 | + response = self._enter_code(self.code) |
| 42 | + self.assertEqual(response.request.path_url, "/web") |
| 43 | + |
| 44 | + def test_auth_sms_rate_limit(self): |
| 45 | + """Request codes until we hit the rate limit.""" |
| 46 | + # Make sure there are no codes left. |
| 47 | + self.env["auth_sms.code"].search([("user_id", "=", self.demo_user.id)]).unlink() |
| 48 | + for _i in range(10): |
| 49 | + response = self._mock_login_user(self.demo_user.login, self.password) |
| 50 | + self.assertEqual(response.request.path_url, "/web/login") |
| 51 | + # 10th time should result in error (assuming default limit). |
| 52 | + # DO not call with _mock, as sms will not be send anyway. |
| 53 | + response = self._login_user(self.demo_user.login, self.password) |
| 54 | + self.assertEqual(response.request.path_url, "/web/login") |
| 55 | + self.assertEqual(response.status_code, 200) |
| 56 | + self.assertIn( |
| 57 | + "Rate limit for SMS exceeded", |
| 58 | + response.text, |
| 59 | + ) |
| 60 | + |
| 61 | + def _mock_login_user(self, login, password): |
| 62 | + """Login as a specific user (assume password is same as login).""" |
| 63 | + with mock.patch(_requests_class + ".post") as mock_request_post: |
35 | 64 | mock_request_post.return_value.json.return_value = {
|
36 | 65 | "originator": "originator",
|
37 | 66 | }
|
38 |
| - response = endpoint() |
39 |
| - self.assertEqual(response.template, "auth_sms.template_code") |
40 |
| - self.assertTrue(request.session["auth_sms.password"]) |
41 |
| - mock_request_post.assert_called_once() |
42 |
| - http.root.session_store.save(request.session) |
| 67 | + response = self._login_user(login, password) |
| 68 | + # retrieve the code to use from the mocked call |
| 69 | + self.code = mock_request_post.mock_calls[0][2]["data"]["body"] |
| 70 | + # retrieve the secret from the response, if present. |
| 71 | + document = document_fromstring(response.content) |
| 72 | + secret_inputs = document.xpath("//input[@name='secret']") |
| 73 | + self.secret = secret_inputs[0].get("value") if secret_inputs else None |
| 74 | + return response |
43 | 75 |
|
44 |
| - # then fill in a wrong code |
45 |
| - with self._request( |
46 |
| - "/auth_sms/code", |
47 |
| - data={ |
48 |
| - "secret": response.qcontext["secret"], |
49 |
| - "user_login": response.qcontext["login"], |
50 |
| - "password": "wrong code", |
51 |
| - }, |
52 |
| - ) as (request, endpoint): |
53 |
| - response = endpoint() |
54 |
| - self.assertEqual(response.template, "auth_sms.template_code") |
55 |
| - self.assertTrue(response.qcontext["error"]) |
| 76 | + def _login_user(self, login, password): |
| 77 | + """Login as a specific user.""" |
| 78 | + # Code largely taken from password_security/tests/test_login.py. |
| 79 | + # session must be part of self, because of csrf_token method. |
| 80 | + self.session = http.root.session_store.new() |
| 81 | + self.opener = Opener(self.env.cr) |
| 82 | + self.opener.cookies.set("session_id", self.session.sid, domain=HOST, path="/") |
| 83 | + with mock.patch("odoo.http.db_filter") as db_filter: |
| 84 | + db_filter.side_effect = lambda dbs, host=None: [get_db_name()] |
| 85 | + # The response returned here is not the odoo.http.Response class, |
| 86 | + # but the requests.Response. |
| 87 | + response = self.url_open( |
| 88 | + "/web/login", |
| 89 | + data={ |
| 90 | + "login": login, |
| 91 | + "password": password, |
| 92 | + "csrf_token": http.Request.csrf_token(self), |
| 93 | + }, |
| 94 | + ) |
| 95 | + response.raise_for_status() |
| 96 | + return response |
56 | 97 |
|
57 |
| - # fill the correct code |
58 |
| - with self._request( |
| 98 | + def _enter_code(self, code): |
| 99 | + """Enter code from sms (wrong or correct).""" |
| 100 | + return self.url_open( |
59 | 101 | "/auth_sms/code",
|
60 | 102 | data={
|
61 |
| - "secret": response.qcontext["secret"], |
62 |
| - "user_login": response.qcontext["login"], |
63 |
| - "password": mock_request_post.mock_calls[0][2]["data"]["body"], |
| 103 | + "secret": self.secret, |
| 104 | + "user_login": self.demo_user.login, |
| 105 | + "password": code, |
| 106 | + "csrf_token": http.Request.csrf_token(self), |
64 | 107 | },
|
65 |
| - ) as (request, endpoint): |
66 |
| - response = endpoint() |
67 |
| - self.assertFalse(response.is_qweb) |
68 |
| - self.assertTrue(response.data) |
69 |
| - |
70 |
| - def test_auth_sms_rate_limit(self): |
71 |
| - # request codes until we hit the rate limit |
72 |
| - with self._request( |
73 |
| - "/web/login", |
74 |
| - data={ |
75 |
| - "login": self.demo_user.login, |
76 |
| - "password": self.demo_user.login, |
77 |
| - }, |
78 |
| - ) as (request, endpoint), patch( |
79 |
| - "odoo.addons.auth_sms.models.sms_provider.requests.post", |
80 |
| - ) as mock_request_post: |
81 |
| - mock_request_post.return_value.json.return_value = { |
82 |
| - "originator": "originator", |
83 |
| - } |
84 |
| - for _i in range(9): |
85 |
| - response = endpoint() |
86 |
| - self.assertNotIn("error", response.qcontext) |
87 |
| - response = endpoint() |
88 |
| - self.assertTrue(response.qcontext["error"]) |
| 108 | + ) |
0 commit comments