feat(fusion_login_audit): failure-burst alert email + cooldown

Mail template + helpers (_fc_alert_*, _fc_recent_failure_count,
_fc_send_failure_alert) wired into _check_credentials so that crossing
the consecutive-failure threshold within the window queues exactly one
mail.mail per attempted login per 60-minute cooldown. Master switch
x_fc_login_audit_alert_enabled honoured. Recipients are members of
base.group_system with a non-empty email and share=False; the
__system__ superuser is excluded by Odoo''s default user filter.

Tests (3 new, 22 total green):
  test_failure_burst_queues_one_email
  test_cooldown_suppresses_second_alert
  test_alert_disabled_master_switch

setUp ensures base.user_admin has an email (fusion-dev''s admin user
ships without one; the only user with an email is __system__, which
is filtered out of standard res.users searches).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-26 21:43:18 -04:00
parent 6f6aa6e90a
commit a2d13cf83b
4 changed files with 274 additions and 1 deletions

View File

@@ -15,6 +15,14 @@ class TestFusionLoginAuditModel(TransactionCase):
# `registry.cursor()` for a TestCursor that wraps the test cursor.
super().setUp()
self.registry_enter_test_mode()
# The alert tests below assume at least one admin has an email
# (otherwise the recipient filter empties and no mail is queued).
# In a fresh fusion-dev DB, base.user_admin's email is NULL; the
# superuser (__system__) has an email but is filtered out of normal
# res.users searches. Ensure admin has a usable email.
admin = self.env.ref('base.user_admin')
if not admin.email:
admin.sudo().write({'email': 'admin@test.example.com'})
def test_model_exists_and_creates(self):
"""Audit row can be created with all expected fields."""
@@ -284,3 +292,103 @@ class TestFusionLoginAuditModel(TransactionCase):
# Boolean field set to False yields get_param() == False (Python
# bool, the default), not the string 'False'.
self.assertFalse(ICP.get_param('fusion_login_audit.alert_enabled'))
def test_failure_burst_queues_one_email(self):
"""N consecutive failures (within window) queue exactly one mail.mail."""
from odoo.exceptions import AccessDenied
ICP = self.env['ir.config_parameter'].sudo()
ICP.set_param('fusion_login_audit.alert_threshold', '3')
ICP.set_param('fusion_login_audit.alert_window_min', '15')
ICP.set_param('fusion_login_audit.alert_enabled', 'True')
# Clear any cooldown leftover from earlier tests.
ICP.set_param('fusion_login_audit.last_alert:burst@example.com', '')
user = self.env['res.users'].sudo().create({
'name': 'Burst Tester',
'login': 'burst@example.com',
'password': 'burst-tester-pw-1',
})
Mail = self.env['mail.mail'].sudo()
before = Mail.search_count([('subject', 'ilike', 'burst@example.com')])
for _i in range(3):
raised = False
try:
user._check_credentials(
{'login': user.login, 'password': 'wrong',
'type': 'password'},
{'interactive': False},
)
except AccessDenied:
raised = True
self.assertTrue(raised)
after = Mail.search_count([('subject', 'ilike', 'burst@example.com')])
self.assertEqual(after, before + 1,
"Exactly one alert mail should be queued")
def test_cooldown_suppresses_second_alert(self):
"""Failures beyond the threshold within the cooldown queue zero more emails."""
from odoo.exceptions import AccessDenied
ICP = self.env['ir.config_parameter'].sudo()
ICP.set_param('fusion_login_audit.alert_threshold', '3')
ICP.set_param('fusion_login_audit.alert_window_min', '15')
ICP.set_param('fusion_login_audit.alert_enabled', 'True')
ICP.set_param('fusion_login_audit.last_alert:cool@example.com', '')
user = self.env['res.users'].sudo().create({
'name': 'Cooldown Tester',
'login': 'cool@example.com',
'password': 'cooldown-tester-pw-1',
})
Mail = self.env['mail.mail'].sudo()
for _i in range(3):
try:
user._check_credentials(
{'login': user.login, 'password': 'wrong',
'type': 'password'},
{'interactive': False},
)
except AccessDenied:
pass
count_after_3 = Mail.search_count([('subject', 'ilike', 'cool@example.com')])
for _i in range(2):
try:
user._check_credentials(
{'login': user.login, 'password': 'wrong',
'type': 'password'},
{'interactive': False},
)
except AccessDenied:
pass
count_after_5 = Mail.search_count([('subject', 'ilike', 'cool@example.com')])
self.assertEqual(count_after_5, count_after_3,
"Cooldown should suppress additional emails")
def test_alert_disabled_master_switch(self):
"""alert_enabled=False suppresses all alerts regardless of threshold."""
from odoo.exceptions import AccessDenied
ICP = self.env['ir.config_parameter'].sudo()
ICP.set_param('fusion_login_audit.alert_threshold', '1')
ICP.set_param('fusion_login_audit.alert_window_min', '15')
# Use the actual boolean field's storage semantics — see CLAUDE.md rule #5.
# Writing False through the settings form deletes the param; here we
# set the string 'False' explicitly to simulate "disabled".
ICP.set_param('fusion_login_audit.alert_enabled', 'False')
ICP.set_param('fusion_login_audit.last_alert:disabled@example.com', '')
user = self.env['res.users'].sudo().create({
'name': 'Disabled Tester',
'login': 'disabled@example.com',
'password': 'disabled-tester-pw-1',
})
Mail = self.env['mail.mail'].sudo()
before = Mail.search_count([('subject', 'ilike', 'disabled@example.com')])
try:
user._check_credentials(
{'login': user.login, 'password': 'wrong',
'type': 'password'},
{'interactive': False},
)
except AccessDenied:
pass
after = Mail.search_count([('subject', 'ilike', 'disabled@example.com')])
self.assertEqual(after, before, "Disabled alerts should queue nothing")