Coverage for src/lexigram/auth/storage/token_store.py: 88%
77 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"""User and token storage interfaces and implementations"""
3from __future__ import annotations
5import secrets
6from typing import Any, Protocol, runtime_checkable
8from lexigram.auth.models.user import User, UserCredentials
11@runtime_checkable
12class CachedUserStore(Protocol):
13 """Protocol for read-through cache user stores (point-lookup only).
15 Implementations such as :class:`RedisUserStore` are highly efficient for
16 ``get_user_by_id`` but are **not** designed for full enumeration
17 operations like ``list_users`` or ``count_users``. Use a relational or
18 document-oriented :class:`UserStoreProtocol` as the primary source of truth and
19 pair it with a :class:`CachedUserStore` for hot-path reads.
20 """
22 async def get_user_by_id(self, user_id: str) -> User | None:
23 """Return the user with *user_id*, or ``None`` if not found."""
24 ...
26 async def create_user(
27 self,
28 name: str,
29 email: str,
30 hashed_password: str | None,
31 roles: list[str] | None = None,
32 permissions: list[str] | None = None,
33 profile: dict[str, Any] | None = None,
34 **kwargs: Any,
35 ) -> User:
36 """Create and cache a user entry."""
37 ...
39 async def update_user(self, user: User) -> None:
40 """Update a cached user entry."""
41 ...
43 async def delete_user(self, user_id: str) -> None:
44 """Evict and delete a cached user entry."""
45 ...
48@runtime_checkable
49class UserStoreProtocol(Protocol):
50 """Protocol for user storage implementations."""
52 async def create_user(
53 self,
54 name: str,
55 email: str,
56 hashed_password: str | None,
57 roles: list[str] | None = None,
58 permissions: list[str] | None = None,
59 profile: dict[str, Any] | None = None,
60 **kwargs: Any,
61 ) -> User:
62 """Create a new user.
64 Accepts arbitrary keyword arguments for backwards compatibility
65 (e.g. ``is_verified``) which may be ignored by some implementations.
66 Stores credential data (``hashed_password``) internally and makes it
67 accessible via :meth:`get_credentials`.
68 """
69 ...
71 async def get_user_by_id(self, user_id: str) -> User | None:
72 """Get user by ID"""
73 ...
75 async def get_user_by_email(self, email: str) -> User | None:
76 """Get user by email"""
77 ...
79 async def update_user(self, user: User) -> None:
80 """Update non-credential user information.
82 Does **not** update password hashes. Use :meth:`update_credentials`
83 for password changes.
84 """
85 ...
87 async def delete_user(self, user_id: str) -> None:
88 """Delete a user"""
89 ...
91 async def list_users(self, skip: int = 0, limit: int = 100) -> list[User]:
92 """List users with pagination"""
93 ...
95 async def count_users(self) -> int:
96 """Count total users"""
97 ...
99 async def get_credentials(self, user_id: str) -> UserCredentials | None:
100 """Return the :class:`UserCredentials` for *user_id*, or ``None``.
102 Use this for all authentication and password-related operations
103 instead of reading fields directly from :class:`User`.
104 """
105 ...
107 async def update_credentials(self, creds: UserCredentials) -> None:
108 """Persist updated credential data for the user identified by
109 ``creds.user_id``.
111 This is the only sanctioned way to change a stored password hash.
112 """
113 ...
116class InMemoryUserStore(UserStoreProtocol):
117 """Simple in-memory user store for development/testing"""
119 def __init__(self) -> None:
120 self.users: dict[str, User] = {}
121 self.name_index: dict[str, str] = {}
122 self.email_index: dict[str, str] = {}
123 # Credentials stored separately from public User data
124 self._credentials: dict[str, UserCredentials] = {}
126 async def create_user(
127 self,
128 name: str,
129 email: str,
130 hashed_password: str | None,
131 roles: list[str] | None = None,
132 permissions: list[str] | None = None,
133 profile: dict[str, Any] | None = None,
134 **kwargs: Any,
135 ) -> User:
136 """Create a new user"""
137 if name in self.name_index:
138 raise ValueError(f"Name '{name}' already exists")
139 if email in self.email_index:
140 raise ValueError(f"Email '{email}' already exists")
142 user_id = secrets.token_urlsafe(16)
144 is_verified = bool(kwargs.get("is_verified", False))
146 user = User(
147 user_id=user_id,
148 name=name,
149 email=email,
150 roles=list(roles or []),
151 permissions=list(permissions or []),
152 profile=profile or {},
153 is_verified=is_verified,
154 )
156 self.users[user_id] = user
157 self.name_index[name] = user_id
158 self.email_index[email] = user_id
159 # Store credentials separately
160 self._credentials[user_id] = UserCredentials(
161 user_id=user_id,
162 hashed_password=hashed_password,
163 )
165 return user
167 async def get_user_by_id(self, user_id: str) -> User | None:
168 """Get user by ID"""
169 return self.users.get(user_id)
171 async def get_user_by_email(self, email: str) -> User | None:
172 """Get user by email"""
173 user_id = self.email_index.get(email)
174 return self.users.get(user_id) if user_id else None
176 async def get_user_by_username(self, username: str) -> User | None:
177 """Get user by username (name)."""
178 user_id = self.name_index.get(username)
179 return self.users.get(user_id) if user_id else None
181 async def update_user(self, user: User) -> None:
182 """Update user information"""
183 if user.user_id not in self.users:
184 raise ValueError(f"User '{user.user_id}' not found")
186 self.users[user.user_id] = user
187 # Update indexes if name or email changed
188 if user.name is not None:
189 self.name_index[user.name] = user.user_id
190 if user.email is not None:
191 self.email_index[user.email] = user.user_id
193 async def delete_user(self, user_id: str) -> None:
194 """Delete a user"""
195 user = self.users.get(user_id)
196 if user:
197 del self.users[user_id]
198 if user.name is not None:
199 del self.name_index[user.name]
200 if user.email is not None:
201 del self.email_index[user.email]
202 self._credentials.pop(user_id, None)
204 async def list_users(self, skip: int = 0, limit: int = 100) -> list[User]:
205 """List users with pagination"""
206 all_users = list(self.users.values())
207 return all_users[skip : skip + limit]
209 async def count_users(self) -> int:
210 """Count total users"""
211 return len(self.users)
213 async def get_credentials(self, user_id: str) -> UserCredentials | None:
214 """Return stored credentials for *user_id*."""
215 return self._credentials.get(user_id)
217 async def update_credentials(self, creds: UserCredentials) -> None:
218 """Persist updated credentials for the user."""
219 if creds.user_id not in self.users:
220 raise ValueError(f"User '{creds.user_id}' not found")
221 self._credentials[creds.user_id] = creds
224__all__ = ["CachedUserStore", "InMemoryUserStore", "UserStoreProtocol"]