Skip to content

Commit 4c73ed8

Browse files
committed
[TST] auth_sms[_auth_signup]: make tests great again
1 parent 14b91b3 commit 4c73ed8

File tree

12 files changed

+217
-172
lines changed

12 files changed

+217
-172
lines changed

auth_sms/README.rst

+8
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,14 @@ OCA, or the Odoo Community Association, is a nonprofit organization whose
119119
mission is to support the collaborative development of Odoo features and
120120
promote its widespread use.
121121

122+
.. |maintainer-NL66278| image:: https://github.com/NL66278.png?size=40px
123+
:target: https://github.com/NL66278
124+
:alt: NL66278
125+
126+
Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:
127+
128+
|maintainer-NL66278|
129+
122130
This module is part of the `OCA/server-auth <https://github.com/OCA/server-auth/tree/16.0/auth_sms>`_ project on GitHub.
123131

124132
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

auth_sms/__manifest__.py

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"name": "Two factor authentication via SMS",
55
"version": "16.0.1.0.0",
66
"author": "Therp BV,Odoo Community Association (OCA)",
7+
"maintainers": ["NL66278"],
78
"license": "AGPL-3",
89
"category": "Tools",
910
"website": "https://github.com/OCA/server-auth",

auth_sms/models/res_users.py

+23-23
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
# Copyright 2019 Therp BV <https://therp.nl>
1+
# Copyright 2019-2025 Therp BV <https://therp.nl>
22
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
33
import logging
44
import random
55
import string
6-
from datetime import datetime, timedelta
6+
from datetime import timedelta
77

88
from odoo import _, api, fields, models
99
from odoo.exceptions import UserError
@@ -114,42 +114,42 @@ def _auth_sms_send(self, user_id):
114114
raise UserError(_("Sending SMS failed"))
115115

116116
def _auth_sms_check_rate_limit(self):
117-
"""return false if the user has requested an SMS code too often"""
117+
"""Return false if the user has requested an SMS code too often"""
118118
self.ensure_one()
119-
rate_limit_hours = float(
119+
rate_limit_hours = self._get_rate_limit_hours()
120+
rate_limit_limit = self._get_rate_limit_limit()
121+
if not (rate_limit_hours and rate_limit_limit):
122+
return False
123+
cutoff_time = fields.Datetime.now() - timedelta(hours=rate_limit_hours)
124+
already_sent = self.env["auth_sms.code"].search_count(
125+
[("create_date", ">=", cutoff_time), ("user_id", "=", self.id)]
126+
)
127+
within_limit = already_sent <= rate_limit_limit
128+
if not within_limit:
129+
_logger.info("To many sms's send to user %(login)s", {"login": self.login})
130+
return within_limit
131+
132+
def _get_rate_limit_hours(self):
133+
"""Return timeframe in which to check count of sms's send to user."""
134+
return float(
120135
self.env["ir.config_parameter"]
121136
.sudo()
122137
.get_param(
123138
"auth_sms.rate_limit_hours",
124139
24,
125140
)
126141
)
127-
rate_limit_limit = float(
142+
143+
def _get_rate_limit_limit(self):
144+
"""Return limit of times sms send to user within a specific timeframe."""
145+
return float(
128146
self.env["ir.config_parameter"]
129147
.sudo()
130148
.get_param(
131149
"auth_sms.rate_limit_limit",
132150
10,
133151
)
134152
)
135-
return (
136-
rate_limit_hours
137-
and rate_limit_limit
138-
and self.env["auth_sms.code"].search(
139-
[
140-
(
141-
"create_date",
142-
">=",
143-
fields.Datetime.to_string(
144-
datetime.now() - timedelta(hours=rate_limit_hours),
145-
),
146-
),
147-
("user_id", "=", self.id),
148-
],
149-
count=True,
150-
)
151-
<= rate_limit_limit
152-
)
153153

154154
def _mfa_type(self):
155155
"""If auth_sms enabled, disable other totp methods."""

auth_sms/models/sms_provider.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,7 @@ class SmsProvider(models.Model):
2020
# could be the preparation for a module base_sms that doesn't rely on
2121
# Odoo's in app purchases as the v12 sms module does
2222
_name = "sms.provider"
23-
_description = "Holds whatever data necessary to send an SMS via some "
24-
"provider"
23+
_description = "Holds whatever data necessary to send an SMS via some provider"
2524
_rec_name = "provider"
2625
_order = "sequence desc"
2726

auth_sms/static/description/index.html

+2
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,8 @@ <h2><a class="toc-backref" href="#toc-entry-9">Maintainers</a></h2>
464464
<p>OCA, or the Odoo Community Association, is a nonprofit organization whose
465465
mission is to support the collaborative development of Odoo features and
466466
promote its widespread use.</p>
467+
<p>Current <a class="reference external" href="https://odoo-community.org/page/maintainer-role">maintainer</a>:</p>
468+
<p><a class="reference external image-reference" href="https://github.com/NL66278"><img alt="NL66278" src="https://github.com/NL66278.png?size=40px" /></a></p>
467469
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/server-auth/tree/16.0/auth_sms">OCA/server-auth</a> project on GitHub.</p>
468470
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
469471
</div>

auth_sms/tests/__init__.py

-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
11
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
2-
from . import common
32
from . import test_auth_sms

auth_sms/tests/common.py

+25-43
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,29 @@
1-
# Copyright 2019 Therp BV <https://therp.nl>
1+
# Copyright 2019-2025 Therp BV <https://therp.nl>
22
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
3-
from contextlib import contextmanager
4-
from functools import partial
3+
from odoo.tests import HttpCase, new_test_user
54

6-
from werkzeug.test import EnvironBuilder
7-
from werkzeug.wrappers import Request as WerkzeugRequest
85

9-
from odoo import http
10-
from odoo.tests.common import TransactionCase
6+
class HttpCaseSMS(HttpCase):
7+
@classmethod
8+
def setUpClass(cls):
9+
super().setUpClass()
10+
cls.admin_user = cls.env.ref("base.user_admin")
11+
cls.username = "dportier"
12+
cls.password = "!asdQWE12345_3" # strong password
13+
cls.demo_user = cls._create_user()
14+
cls.code = None
15+
cls.secret = None
1116

12-
13-
class Common(TransactionCase):
14-
def setUp(self):
15-
super(Common, self).setUp()
16-
self.session = http.root.session_store.new()
17-
self.env["res.users"]._register_hook()
18-
self.demo_user = self.env.ref("auth_sms.demo_user")
19-
self.env["auth_sms.code"].search([]).unlink()
20-
21-
@contextmanager
22-
def _request(self, path, method="POST", data=None):
23-
"""yield request, endpoint for given http request data"""
24-
werkzeug_env = EnvironBuilder(
25-
method=method,
26-
path=path,
27-
data=data,
28-
headers=[("cookie", "session_id=%s" % self.session.sid)],
29-
environ_base={
30-
"HTTP_HOST": "localhost",
31-
"REMOTE_ADDR": "127.0.0.1",
32-
},
33-
).get_environ()
34-
werkzeug_request = WerkzeugRequest(werkzeug_env)
35-
http.root.setup_session(werkzeug_request)
36-
werkzeug_request.session.db = self.env.cr.dbname
37-
http.root.setup_db(werkzeug_request)
38-
http.root.setup_lang(werkzeug_request)
39-
40-
request = http.HttpRequest(werkzeug_request)
41-
request._env = self.env
42-
with request:
43-
routing_map = self.env["ir.http"].routing_map()
44-
endpoint, dummy = routing_map.bind_to_environ(werkzeug_env).match(
45-
return_rule=False,
46-
)
47-
yield request, partial(endpoint, **request.params)
17+
@classmethod
18+
def _create_user(cls):
19+
"""Create auth_sms_enabled user."""
20+
return new_test_user(
21+
cls.env,
22+
login=cls.username,
23+
context={"no_reset_password": True},
24+
password=cls.password,
25+
name="Auth SMS test user",
26+
mobile="0123456789",
27+
email="auth_sms_test_user@yourcompany.com",
28+
auth_sms_enabled=True,
29+
)

auth_sms/tests/test_auth_sms.py

+89-69
Original file line numberDiff line numberDiff line change
@@ -1,88 +1,108 @@
1-
# Copyright 2019 Therp BV <https://therp.nl>
1+
# Copyright 2019-2025 Therp BV <https://therp.nl>
22
# 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
46

57
from odoo import http
8+
from odoo.tests import HOST, Opener, get_db_name, tagged
9+
10+
from .common import HttpCaseSMS
611

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

916

10-
class TestAuthSms(Common):
17+
@tagged("post_install", "-at_install")
18+
class TestAuthSms(HttpCaseSMS):
1119
def test_auth_sms_login_no_2fa(self):
1220
# 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")
2332

2433
def test_auth_sms_login(self):
2534
# 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:
3564
mock_request_post.return_value.json.return_value = {
3665
"originator": "originator",
3766
}
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
4375

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
5697

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(
59101
"/auth_sms/code",
60102
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),
64107
},
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+
)

auth_sms_auth_signup/README.rst

+8
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ OCA, or the Odoo Community Association, is a nonprofit organization whose
7777
mission is to support the collaborative development of Odoo features and
7878
promote its widespread use.
7979

80+
.. |maintainer-NL66278| image:: https://github.com/NL66278.png?size=40px
81+
:target: https://github.com/NL66278
82+
:alt: NL66278
83+
84+
Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:
85+
86+
|maintainer-NL66278|
87+
8088
This module is part of the `OCA/server-auth <https://github.com/OCA/server-auth/tree/16.0/auth_sms_auth_signup>`_ project on GitHub.
8189

8290
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

auth_sms_auth_signup/__manifest__.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
# Copyright 2019 Therp BV <https://therp.nl>
1+
# Copyright 2019-2025 Therp BV <https://therp.nl>
22
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
33
{
44
"name": "Two factor authentication via SMS - password reset",
55
"version": "16.0.1.0.0",
66
"author": "Therp BV,Odoo Community Association (OCA)",
7+
"maintainers": ["NL66278"],
78
"license": "AGPL-3",
89
"category": "Tools",
910
"website": "https://github.com/OCA/server-auth",

0 commit comments

Comments
 (0)