Skip to content

Commit

Permalink
[TST] auth_sms[_auth_signup]: make tests great again
Browse files Browse the repository at this point in the history
  • Loading branch information
NL66278 committed Jan 17, 2025
1 parent 6179af3 commit 20ce86c
Show file tree
Hide file tree
Showing 12 changed files with 216 additions and 172 deletions.
8 changes: 8 additions & 0 deletions auth_sms/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,14 @@ OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.

.. |maintainer-NL66278| image:: https://github.com/NL66278.png?size=40px
:target: https://github.com/NL66278
:alt: NL66278

Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:

|maintainer-NL66278|

This module is part of the `OCA/server-auth <https://github.com/OCA/server-auth/tree/16.0/auth_sms>`_ project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
1 change: 1 addition & 0 deletions auth_sms/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"name": "Two factor authentication via SMS",
"version": "16.0.1.0.0",
"author": "Therp BV,Odoo Community Association (OCA)",
"maintainers": ["NL66278"],
"license": "AGPL-3",
"category": "Tools",
"website": "https://github.com/OCA/server-auth",
Expand Down
46 changes: 23 additions & 23 deletions auth_sms/models/res_users.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# Copyright 2019 Therp BV <https://therp.nl>
# Copyright 2019-2025 Therp BV <https://therp.nl>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import logging
import random
import string
from datetime import datetime, timedelta
from datetime import timedelta

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

def _auth_sms_check_rate_limit(self):
"""return false if the user has requested an SMS code too often"""
"""Return false if the user has requested an SMS code too often"""
self.ensure_one()
rate_limit_hours = float(
rate_limit_hours = self._get_rate_limit_hours()
rate_limit_limit = self._get_rate_limit_limit()
if not (rate_limit_hours and rate_limit_limit):
return False
cutoff_time = fields.Datetime.now() - timedelta(hours=rate_limit_hours)
already_sent = self.env["auth_sms.code"].search_count(
[("create_date", ">=", cutoff_time), ("user_id", "=", self.id)]
)
within_limit = already_sent <= rate_limit_limit
if not within_limit:
_logger.info("To many sms's send to user %(login)s", {"login": self.login})
return within_limit

def _get_rate_limit_hours(self):
"""Return timeframe in which to check count of sms's send to user."""
return float(
self.env["ir.config_parameter"]
.sudo()
.get_param(
"auth_sms.rate_limit_hours",
24,
)
)
rate_limit_limit = float(

def _get_rate_limit_limit(self):
"""Return limit of times sms send to user within a specific timeframe."""
return float(
self.env["ir.config_parameter"]
.sudo()
.get_param(
"auth_sms.rate_limit_limit",
10,
)
)
return (
rate_limit_hours
and rate_limit_limit
and self.env["auth_sms.code"].search(
[
(
"create_date",
">=",
fields.Datetime.to_string(
datetime.now() - timedelta(hours=rate_limit_hours),
),
),
("user_id", "=", self.id),
],
count=True,
)
<= rate_limit_limit
)

def _mfa_type(self):
"""If auth_sms enabled, disable other totp methods."""
Expand Down
3 changes: 1 addition & 2 deletions auth_sms/models/sms_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@ class SmsProvider(models.Model):
# could be the preparation for a module base_sms that doesn't rely on
# Odoo's in app purchases as the v12 sms module does
_name = "sms.provider"
_description = "Holds whatever data necessary to send an SMS via some "
"provider"
_description = "Holds whatever data necessary to send an SMS via some provider"
_rec_name = "provider"
_order = "sequence desc"

Expand Down
2 changes: 2 additions & 0 deletions auth_sms/static/description/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,8 @@ <h2><a class="toc-backref" href="#toc-entry-9">Maintainers</a></h2>
<p>OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.</p>
<p>Current <a class="reference external" href="https://odoo-community.org/page/maintainer-role">maintainer</a>:</p>
<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>
<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>
<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>
</div>
Expand Down
1 change: 0 additions & 1 deletion auth_sms/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from . import common
from . import test_auth_sms
68 changes: 25 additions & 43 deletions auth_sms/tests/common.py
Original file line number Diff line number Diff line change
@@ -1,47 +1,29 @@
# Copyright 2019 Therp BV <https://therp.nl>
# Copyright 2019-2025 Therp BV <https://therp.nl>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from contextlib import contextmanager
from functools import partial
from odoo.tests import HttpCase, new_test_user

from werkzeug.test import EnvironBuilder
from werkzeug.wrappers import Request as WerkzeugRequest

from odoo import http
from odoo.tests.common import TransactionCase
class HttpCaseSMS(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.admin_user = cls.env.ref("base.user_admin")
cls.username = "dportier"
cls.password = "!asdQWE12345_3" # strong password
cls.demo_user = cls._create_user()
cls.code = None
cls.secret = None


class Common(TransactionCase):
def setUp(self):
super(Common, self).setUp()
self.session = http.root.session_store.new()
self.env["res.users"]._register_hook()
self.demo_user = self.env.ref("auth_sms.demo_user")
self.env["auth_sms.code"].search([]).unlink()

@contextmanager
def _request(self, path, method="POST", data=None):
"""yield request, endpoint for given http request data"""
werkzeug_env = EnvironBuilder(
method=method,
path=path,
data=data,
headers=[("cookie", "session_id=%s" % self.session.sid)],
environ_base={
"HTTP_HOST": "localhost",
"REMOTE_ADDR": "127.0.0.1",
},
).get_environ()
werkzeug_request = WerkzeugRequest(werkzeug_env)
http.root.setup_session(werkzeug_request)
werkzeug_request.session.db = self.env.cr.dbname
http.root.setup_db(werkzeug_request)
http.root.setup_lang(werkzeug_request)

request = http.HttpRequest(werkzeug_request)
request._env = self.env
with request:
routing_map = self.env["ir.http"].routing_map()
endpoint, dummy = routing_map.bind_to_environ(werkzeug_env).match(
return_rule=False,
)
yield request, partial(endpoint, **request.params)
@classmethod
def _create_user(cls):
"""Create auth_sms_enabled user."""
return new_test_user(
cls.env,
login=cls.username,
context={"no_reset_password": True},
password=cls.password,
name="Auth SMS test user",
mobile="0123456789",
email="auth_sms_test_user@yourcompany.com",
auth_sms_enabled=True,
)
157 changes: 88 additions & 69 deletions auth_sms/tests/test_auth_sms.py
Original file line number Diff line number Diff line change
@@ -1,88 +1,107 @@
# Copyright 2019 Therp BV <https://therp.nl>
# Copyright 2019-2025 Therp BV <https://therp.nl>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from unittest.mock import patch
from unittest import mock

from lxml.html import document_fromstring

from odoo import http
from odoo.tests import HOST, Opener, get_db_name, tagged

from .common import HttpCaseSMS

from .common import Common
_module_ns = "odoo.addons.auth_sms"
_requests_class = _module_ns + ".models.sms_provider.requests"
_users_class = _module_ns + ".models.res_users.ResUsers"


class TestAuthSms(Common):
@tagged("post_install", "-at_install")
class TestAuthSms(HttpCaseSMS):
def test_auth_sms_login_no_2fa(self):
# admin doesn't have sms verification turned on
with self._request(
"/web/login",
method="POST",
data={
"login": self.env.user.login,
"password": self.env.user.login,
},
) as (request, endpoint):
response = endpoint()
self.assertFalse(response.template)
response = self._login_user(self.admin_user.login, self.admin_user.login)
self.assertEqual(response.request.path_url, "/web")
self.assertEqual(response.status_code, 200)

def test_auth_sms_login_no_error(self):
# first request: login
response = self._mock_login_user(self.demo_user.login, self.password)
self.assertEqual(response.request.path_url, "/web/login")
# fill the correct code
response = self._enter_code(self.code)
self.assertEqual(response.request.path_url, "/web")

def test_auth_sms_login(self):
# first request: login
with self._request(
"/web/login",
data={
"login": self.demo_user.login,
"password": self.demo_user.login,
},
) as (request, endpoint), patch(
"odoo.addons.auth_sms.models.sms_provider.requests.post",
) as mock_request_post:
response = self._mock_login_user(self.demo_user.login, self.password)
self.assertEqual(response.request.path_url, "/web/login")
# then fill in a wrong code
response = self._enter_code("wrong code")
self.assertEqual(response.request.path_url, "/auth_sms/code")
# fill the correct code
response = self._enter_code(self.code)
self.assertEqual(response.request.path_url, "/web")

def test_auth_sms_rate_limit(self):
"""Request codes until we hit the rate limit."""
# Make sure there are no codes left.
self.env["auth_sms.code"].search([("user_id", "=", self.demo_user.id)]).unlink()
for _i in range(10):
response = self._mock_login_user(self.demo_user.login, self.password)
self.assertEqual(response.request.path_url, "/web/login")
# 10th time should result in error (assuming default limit):
response = self._mock_login_user(self.demo_user.login, self.password)
self.assertEqual(response.request.path_url, "/web/login")
self.assertEqual(response.status_code, 200)
self.assertIn(
"Rate limit for SMS exceeded",
response.text,
)

def _mock_login_user(self, login, password):
"""Login as a specific user (assume password is same as login)."""
with mock.patch(_requests_class + ".post") as mock_request_post:
mock_request_post.return_value.json.return_value = {
"originator": "originator",
}
response = endpoint()
self.assertEqual(response.template, "auth_sms.template_code")
self.assertTrue(request.session["auth_sms.password"])
mock_request_post.assert_called_once()
http.root.session_store.save(request.session)
response = self._login_user(login, password)
# retrieve the code to use from the mocked call
self.code = mock_request_post.mock_calls[0][2]["data"]["body"]
# retrieve the secret from the response, if present.
document = document_fromstring(response.content)
secret_inputs = document.xpath("//input[@name='secret']")
self.secret = secret_inputs[0].get("value") if secret_inputs else None
return response

# then fill in a wrong code
with self._request(
"/auth_sms/code",
data={
"secret": response.qcontext["secret"],
"user_login": response.qcontext["login"],
"password": "wrong code",
},
) as (request, endpoint):
response = endpoint()
self.assertEqual(response.template, "auth_sms.template_code")
self.assertTrue(response.qcontext["error"])
def _login_user(self, login, password):
"""Login as a specific user."""
# Code largely taken from password_security/tests/test_login.py.
# session must be part of self, because of csrf_token method.
self.session = http.root.session_store.new()
self.opener = Opener(self.env.cr)
self.opener.cookies.set("session_id", self.session.sid, domain=HOST, path="/")
with mock.patch("odoo.http.db_filter") as db_filter:
db_filter.side_effect = lambda dbs, host=None: [get_db_name()]
# The response returned here is not the odoo.http.Response class,
# but the requests.Response.
response = self.url_open(
"/web/login",
data={
"login": login,
"password": password,
"csrf_token": http.Request.csrf_token(self),
},
)
response.raise_for_status()
return response

# fill the correct code
with self._request(
def _enter_code(self, code):
"""Enter code from sms (wrong or correct)."""
return self.url_open(
"/auth_sms/code",
data={
"secret": response.qcontext["secret"],
"user_login": response.qcontext["login"],
"password": mock_request_post.mock_calls[0][2]["data"]["body"],
"secret": self.secret,
"user_login": self.demo_user.login,
"password": code,
"csrf_token": http.Request.csrf_token(self),
},
) as (request, endpoint):
response = endpoint()
self.assertFalse(response.is_qweb)
self.assertTrue(response.data)

def test_auth_sms_rate_limit(self):
# request codes until we hit the rate limit
with self._request(
"/web/login",
data={
"login": self.demo_user.login,
"password": self.demo_user.login,
},
) as (request, endpoint), patch(
"odoo.addons.auth_sms.models.sms_provider.requests.post",
) as mock_request_post:
mock_request_post.return_value.json.return_value = {
"originator": "originator",
}
for _i in range(9):
response = endpoint()
self.assertNotIn("error", response.qcontext)
response = endpoint()
self.assertTrue(response.qcontext["error"])
)
8 changes: 8 additions & 0 deletions auth_sms_auth_signup/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.

.. |maintainer-NL66278| image:: https://github.com/NL66278.png?size=40px
:target: https://github.com/NL66278
:alt: NL66278

Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:

|maintainer-NL66278|

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.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
3 changes: 2 additions & 1 deletion auth_sms_auth_signup/__manifest__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# Copyright 2019 Therp BV <https://therp.nl>
# Copyright 2019-2025 Therp BV <https://therp.nl>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
{
"name": "Two factor authentication via SMS - password reset",
"version": "16.0.1.0.0",
"author": "Therp BV,Odoo Community Association (OCA)",
"maintainers": ["NL66278"],
"license": "AGPL-3",
"category": "Tools",
"website": "https://github.com/OCA/server-auth",
Expand Down
Loading

0 comments on commit 20ce86c

Please sign in to comment.