Coverage for src/lexigram/auth/admin/handlers/failed_logins.py: 100%
19 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 00:58 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 00:58 +0800
1"""Failed logins widget handler."""
3from __future__ import annotations
5from lexigram.auth.services.activity_tracker import AuthActivityTracker
6from lexigram.contracts.admin import Stat, StatContent, Tone, WidgetParams
7from lexigram.contracts.admin.errors import AdminError
8from lexigram.result import Ok, Result
11class FailedLoginsWidgetHandler:
12 """Handler for the failed logins widget.
14 Args:
15 tracker: injected AuthActivityTracker.
16 """
18 def __init__(self, tracker: AuthActivityTracker) -> None:
19 """Initialize handler with the activity tracker.
21 Args:
22 tracker: AuthActivityTracker for login failure tracking.
23 """
24 self._tracker = tracker
26 async def get_data(self, params: WidgetParams) -> Result[StatContent, AdminError]:
27 """Fetch failed login statistics.
29 Mirrors the widget template: a single ``is_elevated`` boundary gates
30 the danger tone (the template's ``{% if is_elevated %}`` badge), with
31 neutral styling otherwise. No multi-tier threshold ladder exists.
33 Infrastructure failures propagate as exceptions.
35 Args:
36 params: Widget parameters (includes time_window_minutes).
38 Returns:
39 Result containing StatContent or AdminError.
40 """
41 window = getattr(params, "time_window_minutes", 30)
42 count, unique_ips = self._tracker.failed_login_summary(
43 window_minutes=int(window or 30)
44 )
45 return Ok(
46 self._build_content(
47 count=count, unique_ips=unique_ips, is_elevated=count > 0
48 )
49 )
51 def _build_content(
52 self, count: int, unique_ips: int, is_elevated: bool
53 ) -> StatContent:
54 """Build the StatContent mirroring the widget template.
56 Args:
57 count: Number of failed login attempts over the window.
58 unique_ips: Number of unique source IPs.
59 is_elevated: Whether the count is above the elevated threshold.
61 Returns:
62 StatContent with a danger tone when elevated and neutral styling
63 otherwise, plus a conditional unique-IP stat.
64 """
65 tone = Tone.DANGER if is_elevated else Tone.DEFAULT
66 stats: list[Stat] = [
67 Stat(label="Failed Logins (1 hour)", value=str(count), tone=tone),
68 ]
69 if unique_ips > 0:
70 stats.append(Stat(label="Unique IPs", value=str(unique_ips)))
71 return StatContent(stats=tuple(stats))
74__all__ = ["FailedLoginsWidgetHandler"]