Coverage for src/lexigram/auth/storage/db_stores.py: 26%

95 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 12:26 +0800

1"""Database-backed user store implementations.""" 

2 

3from __future__ import annotations 

4 

5from typing import TYPE_CHECKING, Any 

6 

7from lexigram.auth.models.user import User, UserCredentials 

8from lexigram.auth.storage._mongo_store import MongoDBUserStore 

9from lexigram.auth.storage._sql_store import SQLUserStore 

10from lexigram.di.decorators import inject 

11from lexigram.logging import get_logger 

12from lexigram.serialization import dumps, loads 

13 

14if TYPE_CHECKING: 

15 from lexigram.contracts import DatabaseProviderProtocol 

16 

17logger = get_logger(__name__) 

18 

19 

20@inject 

21class RedisUserStore: 

22 """Redis-backed user cache (read-through cache, not primary storage). 

23 

24 Implements :class:`~lexigram.auth.storage.token_store.CachedUserStore` — 

25 it is designed for **point-lookup** operations only. Full-scan operations 

26 (``list_users``, ``count_users``, ``get_user_by_email``) are intentionally 

27 not supported; a relational or document-oriented 

28 :class:`~lexigram.auth.storage.token_store.UserStoreProtocol` must be used as the 

29 primary source of truth. 

30 

31 Stores user records as Redis hashes under ``{prefix}{user_id}``. 

32 Credential fields (``hashed_password``, ``previous_passwords``) are kept 

33 in the same Redis hash but are only accessible via :meth:`get_credentials` 

34 and mutated via :meth:`update_credentials`. 

35 

36 Args: 

37 db_provider: Database provider whose ``redis`` attribute exposes 

38 a Redis-compatible async client. 

39 prefix: Key prefix applied to every user key. Defaults to 

40 ``"user:"``. 

41 ttl: Optional time-to-live in seconds applied to every cached 

42 user entry. ``None`` means entries never expire (default). 

43 

44 .. warning:: 

45 Do **not** use :class:`RedisUserStore` for operations that require 

46 full enumeration (``list_users``, ``count_users``, 

47 ``get_user_by_email``). These methods raise :exc:`NotImplementedError` 

48 by design. Use a SQL or MongoDB store for those operations. 

49 """ 

50 

51 def __init__( 

52 self, 

53 db_provider: DatabaseProviderProtocol, 

54 prefix: str = "user:", 

55 ttl: int | None = None, 

56 ) -> None: 

57 self.db_provider = db_provider 

58 self.prefix = prefix 

59 self.ttl = ttl 

60 

61 async def _user_key(self, user_id: str) -> str: 

62 """Generate Redis key for user""" 

63 return f"{self.prefix}{user_id}" 

64 

65 async def _user_from_data(self, data: dict[str, Any]) -> User: 

66 """Convert Redis hash data to a User object (credentials excluded).""" 

67 from datetime import datetime 

68 

69 def parse_dt(val: Any) -> datetime | None: 

70 if not val: 

71 return None 

72 if isinstance(val, datetime): 

73 return val 

74 try: 

75 if isinstance(val, (int, float)): 

76 return datetime.fromtimestamp(val) 

77 return datetime.fromisoformat(str(val)) 

78 except (ValueError, TypeError): 

79 return None 

80 

81 return User( 

82 user_id=data["id"], 

83 name=data["name"] if "name" in data else data.get("username"), 

84 email=data["email"], 

85 is_active=data.get("is_active", True), 

86 is_verified=data.get("is_verified", False), 

87 roles=loads(data.get("roles", "[]")), 

88 permissions=loads(data.get("permissions", "[]")), 

89 profile=loads(data.get("profile", "{}")), 

90 created_at=parse_dt(data.get("created_at")), 

91 updated_at=parse_dt(data.get("updated_at")), 

92 last_login_at=parse_dt(data.get("last_login_at")), 

93 login_count=int(data.get("login_count", 0)), 

94 ) 

95 

96 async def _data_from_user(self, user: User) -> dict[str, str]: 

97 """Convert User object to Redis hash fields (non-credential fields only).""" 

98 data: dict[str, str] = { 

99 "id": user.user_id, 

100 "name": user.name or "", 

101 "email": user.email, 

102 "is_active": str(user.is_active), 

103 "is_verified": str(user.is_verified), 

104 "roles": str(dumps(user.roles)), 

105 "permissions": str(dumps(user.permissions)), 

106 "profile": str(dumps(user.profile)), 

107 "login_count": str(user.login_count), 

108 } 

109 

110 if user.created_at: 

111 data["created_at"] = user.created_at.isoformat() 

112 if user.updated_at: 

113 data["updated_at"] = user.updated_at.isoformat() 

114 if user.last_login_at: 

115 data["last_login_at"] = user.last_login_at.isoformat() 

116 

117 return data 

118 

119 async def create_user( 

120 self, 

121 name: str, 

122 email: str, 

123 hashed_password: str | None, 

124 roles: list[str] | None = None, 

125 permissions: list[str] | None = None, 

126 profile: dict[str, Any] | None = None, 

127 **kwargs: Any, 

128 ) -> User: 

129 """Cache a new user entry. 

130 

131 .. note:: 

132 Redis is typically used as a read-through cache. For persistent 

133 primary storage use :class:`SQLUserStore` or :class:`MongoDBUserStore`. 

134 """ 

135 import uuid 

136 

137 user_id = str(uuid.uuid4()) 

138 

139 user = User( 

140 user_id=user_id, 

141 name=name, 

142 email=email, 

143 roles=list(roles or []), 

144 permissions=list(permissions or []), 

145 profile=profile or {}, 

146 ) 

147 

148 key = await self._user_key(user_id) 

149 data = await self._data_from_user(user) 

150 # Store credential fields alongside other fields in the same hash 

151 if hashed_password: 

152 data["hashed_password"] = hashed_password 

153 data["previous_passwords"] = str(dumps([])) 

154 

155 await self.db_provider.redis.hset(key, mapping=data) # type: ignore[attr-defined] 

156 if self.ttl is not None: 

157 await self.db_provider.redis.expire(key, self.ttl) # type: ignore[attr-defined] 

158 

159 logger.info("Cached user: %s", name) 

160 return user 

161 

162 async def get_user_by_id(self, user_id: str) -> User | None: 

163 """Get user by ID""" 

164 key = await self._user_key(user_id) 

165 data = await self.db_provider.redis.hgetall(key) # type: ignore[attr-defined] 

166 

167 if not data: 

168 return None 

169 

170 str_data = { 

171 k.decode() if isinstance(k, bytes) else k: ( 

172 v.decode() if isinstance(v, bytes) else v 

173 ) 

174 for k, v in data.items() 

175 } 

176 

177 return await self._user_from_data(str_data) 

178 

179 async def get_user_by_email(self, email: str) -> User | None: 

180 """Not supported — Redis does not index by email efficiently. 

181 

182 .. warning:: 

183 This always raises :exc:`NotImplementedError`. Use the primary 

184 relational/document store for email-based lookups. 

185 """ 

186 raise NotImplementedError( 

187 "RedisUserStore (CachedUserStore) does not support email lookups. " 

188 "Use the primary UserStoreProtocol for get_user_by_email.", 

189 ) 

190 

191 async def update_user(self, user: User) -> None: 

192 """Update non-credential fields for the cached user entry.""" 

193 key = await self._user_key(user.user_id) 

194 data = await self._data_from_user(user) 

195 

196 await self.db_provider.redis.hset(key, mapping=data) # type: ignore[attr-defined] 

197 if self.ttl is not None: 

198 await self.db_provider.redis.expire(key, self.ttl) # type: ignore[attr-defined] 

199 

200 logger.info("Updated cached user: %s", user.name) 

201 

202 async def delete_user(self, user_id: str) -> None: 

203 """Delete a user""" 

204 key = await self._user_key(user_id) 

205 await self.db_provider.redis.delete(key) # type: ignore[attr-defined] 

206 

207 logger.info("Deleted cached user: %s", user_id) 

208 

209 async def list_users(self, skip: int = 0, limit: int = 100) -> list[User]: 

210 """Not supported — Redis is not designed for full user enumeration. 

211 

212 .. warning:: 

213 Always raises :exc:`NotImplementedError`. Use the primary 

214 relational/document store for listing users. 

215 """ 

216 raise NotImplementedError( 

217 "RedisUserStore (CachedUserStore) does not support list_users. " 

218 "Use the primary UserStoreProtocol for enumeration operations.", 

219 ) 

220 

221 async def count_users(self) -> int: 

222 """Not supported — Redis is not designed for counting all users. 

223 

224 .. warning:: 

225 Always raises :exc:`NotImplementedError`. Use the primary 

226 relational/document store for count operations. 

227 """ 

228 raise NotImplementedError( 

229 "RedisUserStore (CachedUserStore) does not support count_users. " 

230 "Use the primary UserStoreProtocol for enumeration operations.", 

231 ) 

232 

233 async def get_credentials(self, user_id: str) -> UserCredentials | None: 

234 """Return cached credential data for *user_id*. 

235 

236 Returns ``None`` if the user entry is not in the cache. 

237 """ 

238 key = await self._user_key(user_id) 

239 data = await self.db_provider.redis.hgetall(key) # type: ignore[attr-defined] 

240 if not data: 

241 return None 

242 str_data = { 

243 k.decode() if isinstance(k, bytes) else k: ( 

244 v.decode() if isinstance(v, bytes) else v 

245 ) 

246 for k, v in data.items() 

247 } 

248 return UserCredentials( 

249 user_id=str_data.get("id", user_id), 

250 hashed_password=str_data.get("hashed_password"), 

251 previous_hashes=loads(str_data.get("previous_passwords", "[]")), 

252 ) 

253 

254 async def update_credentials(self, creds: UserCredentials) -> None: 

255 """Update credential fields in the cached user hash.""" 

256 key = await self._user_key(creds.user_id) 

257 data: dict[str, str] = { 

258 "previous_passwords": str(dumps(creds.previous_hashes)), 

259 } 

260 if creds.hashed_password is not None: 

261 data["hashed_password"] = creds.hashed_password 

262 

263 await self.db_provider.redis.hset(key, mapping=data) # type: ignore[attr-defined] 

264 if self.ttl is not None: 

265 await self.db_provider.redis.expire(key, self.ttl) # type: ignore[attr-defined] 

266 

267 logger.info("Updated cached credentials for user: %s", creds.user_id) 

268 

269 

270__all__ = ["MongoDBUserStore", "RedisUserStore", "SQLUserStore"]