Coverage for src / lexigram / admin / auth / errors.py: 63%

46 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-13 22:14 +0800

1"""Admin authentication exceptions. 

2 

3Leaf exception hierarchy for admin auth. All exceptions are intentionally 

4minimal — descriptive docstrings and a standard message only, no extra logic. 

5 

6``AdminAuthError`` extends ``DomainError`` because admin auth failures are 

7expected, recoverable domain failures (invalid credentials, locked accounts, 

8expired sessions) rather than infrastructure or programming errors. 

9""" 

10 

11from __future__ import annotations 

12 

13from datetime import datetime 

14from typing import Any 

15 

16from lexigram.contracts.exceptions.domain import DomainError 

17 

18 

19class AdminAuthError(DomainError): 

20 """Base exception for all admin authentication errors.""" 

21 

22 _code: str = "LEX_ERR_ADMIN_010" 

23 

24 

25class InvalidCredentialsError(AdminAuthError): 

26 """Raised when email/password combination is incorrect.""" 

27 

28 _code: str = "LEX_ERR_ADMIN_011" 

29 

30 

31class AccountLockedError(AdminAuthError): 

32 """Raised when an account is temporarily or permanently locked. 

33 

34 Args: 

35 message: Human-readable description. 

36 unlock_at: When the lock expires (None for permanent lockout). 

37 retry_after: Seconds until retry is permitted. 

38 reason: Categorised reason string (lockout, rate_limit, etc.). 

39 """ 

40 

41 _code: str = "LEX_ERR_ADMIN_012" 

42 

43 def __init__( 

44 self, 

45 message: str, 

46 unlock_at: datetime | None = None, 

47 retry_after: int | None = None, 

48 reason: str = "lockout", 

49 ) -> None: 

50 super().__init__(message) 

51 self.unlock_at = unlock_at 

52 self.retry_after = retry_after 

53 self.reason = reason 

54 

55 def to_payload(self) -> dict[str, Any]: 

56 """Return a structured error payload for API responses. 

57 

58 Returns: 

59 Dict with reason, unlock_at (ISO8601), and retry_after keys. 

60 """ 

61 payload: dict[str, Any] = {"reason": self.reason} 

62 if self.unlock_at is not None: 

63 payload["unlock_at"] = self.unlock_at.isoformat() 

64 if self.retry_after is not None: 

65 payload["retry_after"] = self.retry_after 

66 return payload 

67 

68 

69class RateLimitExceededError(AdminAuthError): 

70 """Raised when the IP-based rate limit is exceeded. 

71 

72 Args: 

73 message: Human-readable description. 

74 retry_after: Seconds until retry is permitted. 

75 reason: Categorised reason string. 

76 """ 

77 

78 _code: str = "LEX_ERR_ADMIN_013" 

79 

80 def __init__( 

81 self, 

82 message: str, 

83 retry_after: int | None = None, 

84 reason: str = "rate_limit", 

85 ) -> None: 

86 super().__init__(message) 

87 self.retry_after = retry_after 

88 self.reason = reason 

89 

90 def to_payload(self) -> dict[str, Any]: 

91 """Return a structured error payload for API responses. 

92 

93 Returns: 

94 Dict with reason and retry_after keys. 

95 """ 

96 payload: dict[str, Any] = {"reason": self.reason} 

97 if self.retry_after is not None: 

98 payload["retry_after"] = self.retry_after 

99 return payload 

100 

101 

102class SessionNotFoundError(AdminAuthError): 

103 """Raised when the requested session does not exist.""" 

104 

105 _code: str = "LEX_ERR_ADMIN_014" 

106 

107 

108class SessionExpiredError(AdminAuthError): 

109 """Raised when the session has exceeded its idle or absolute timeout.""" 

110 

111 _code: str = "LEX_ERR_ADMIN_015" 

112 

113 

114class CsrfValidationError(AdminAuthError): 

115 """Raised when CSRF token is missing, invalid, or expired.""" 

116 

117 _code: str = "LEX_ERR_ADMIN_016" 

118 

119 

120class PasswordPolicyError(AdminAuthError): 

121 """Raised when a password does not meet policy requirements.""" 

122 

123 _code: str = "LEX_ERR_ADMIN_017" 

124 

125 

126class SetupAlreadyCompletedError(AdminAuthError): 

127 """Raised when setup is attempted after an admin account already exists.""" 

128 

129 _code: str = "LEX_ERR_ADMIN_018" 

130 

131 

132class SetupTokenInvalidError(AdminAuthError): 

133 """Raised when the ADMIN_SETUP_TOKEN env var is set and the provided token doesn't match.""" 

134 

135 _code: str = "LEX_ERR_ADMIN_019" 

136 

137 

138__all__ = [ 

139 "AccountLockedError", 

140 "AdminAuthError", 

141 "CsrfValidationError", 

142 "InvalidCredentialsError", 

143 "PasswordPolicyError", 

144 "RateLimitExceededError", 

145 "SessionExpiredError", 

146 "SessionNotFoundError", 

147 "SetupAlreadyCompletedError", 

148 "SetupTokenInvalidError", 

149]