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

62 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-24 23:31 +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 

138class PasswordResetTokenInvalidError(AdminAuthError): 

139 """Raised when a password reset token is unknown or already consumed.""" 

140 

141 _code: str = "LEX_ERR_ADMIN_020" 

142 

143 

144class PasswordResetTokenExpiredError(AdminAuthError): 

145 """Raised when a password reset token has expired.""" 

146 

147 _code: str = "LEX_ERR_ADMIN_021" 

148 

149 

150class MfaNotEnabledError(AdminAuthError): 

151 """Raised when 2FA is required but not configured/enabled.""" 

152 

153 _code: str = "LEX_ERR_ADMIN_022" 

154 

155 

156class MfaVerificationFailedError(AdminAuthError): 

157 """Raised when a TOTP code is missing, invalid, or expired.""" 

158 

159 _code: str = "LEX_ERR_ADMIN_023" 

160 

161 

162class EmailVerificationRequiredError(AdminAuthError): 

163 """Raised when login is blocked because the email is unverified.""" 

164 

165 _code: str = "LEX_ERR_ADMIN_024" 

166 

167 

168class EmailVerificationTokenInvalidError(AdminAuthError): 

169 """Raised when a verification token is missing, invalid, used, or expired.""" 

170 

171 _code: str = "LEX_ERR_ADMIN_025" 

172 

173 

174class EmailOtpDeliveryError(AdminAuthError): 

175 """Raised when an email OTP cannot be delivered.""" 

176 

177 _code: str = "LEX_ERR_ADMIN_026" 

178 

179 

180class EmailOtpCooldownError(AdminAuthError): 

181 """Raised when an email OTP resend is attempted too soon.""" 

182 

183 _code: str = "LEX_ERR_ADMIN_027" 

184 

185 

186__all__ = [ 

187 "AccountLockedError", 

188 "AdminAuthError", 

189 "CsrfValidationError", 

190 "EmailOtpCooldownError", 

191 "EmailOtpDeliveryError", 

192 "EmailVerificationRequiredError", 

193 "EmailVerificationTokenInvalidError", 

194 "InvalidCredentialsError", 

195 "MfaNotEnabledError", 

196 "MfaVerificationFailedError", 

197 "PasswordPolicyError", 

198 "PasswordResetTokenExpiredError", 

199 "PasswordResetTokenInvalidError", 

200 "RateLimitExceededError", 

201 "SessionExpiredError", 

202 "SessionNotFoundError", 

203 "SetupAlreadyCompletedError", 

204 "SetupTokenInvalidError", 

205]