Coverage for src/lexigram/auth/storage/_mongo_store.py: 88%
76 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"""MongoDB-backed user store implementation.
3Migrated to use ``DocumentStoreProtocol`` from ``lexigram-nosql`` for
4connection lifecycle and collection access.
5"""
7from __future__ import annotations
9from typing import TYPE_CHECKING, Any
11from lexigram.auth.models.user import User, UserCredentials
12from lexigram.di.decorators import inject
13from lexigram.logging import get_logger
15if TYPE_CHECKING:
16 from lexigram.contracts.data import CollectionProtocol, DocumentStoreProtocol
18logger = get_logger(__name__)
21@inject
22class MongoDBUserStore:
23 """MongoDB-based user store using ``DocumentStoreProtocol``."""
25 def __init__(
26 self,
27 document_store: DocumentStoreProtocol,
28 collection_name: str = "users",
29 ):
30 self._store = document_store
31 self.collection_name = collection_name
32 self._collection: CollectionProtocol | None = None
33 self._initialized = False
35 @property
36 def _col(self) -> CollectionProtocol:
37 """Lazily initialize the collection handle."""
38 if self._collection is None:
39 self._collection = self._store.collection(self.collection_name)
40 return self._collection
42 async def _ensure_collection(self) -> None:
43 """Ensure user collection exists."""
44 if self._initialized:
45 return
46 # MongoDB collections are created automatically on first insert
47 self._initialized = True
49 async def _user_from_doc(self, doc: dict[str, Any]) -> User:
50 """Convert MongoDB document to User object."""
51 return User(
52 user_id=doc["_id"],
53 name=doc["name"] if "name" in doc else doc.get("username"),
54 email=doc["email"],
55 is_active=doc.get("is_active", True),
56 is_verified=doc.get("is_verified", False),
57 roles=doc.get("roles", []),
58 permissions=doc.get("permissions", []),
59 profile=doc.get("profile", {}),
60 created_at=doc.get("created_at"),
61 updated_at=doc.get("updated_at"),
62 last_login_at=doc.get("last_login_at"),
63 login_count=doc.get("login_count", 0),
64 )
66 async def _doc_from_user(self, user: User) -> dict[str, Any]:
67 """Convert User object to MongoDB document (non-credential fields)."""
68 return {
69 "_id": user.user_id,
70 "name": user.name,
71 "email": user.email,
72 "is_active": user.is_active,
73 "is_verified": user.is_verified,
74 "roles": user.roles,
75 "permissions": user.permissions,
76 "profile": user.profile,
77 "created_at": user.created_at,
78 "updated_at": user.updated_at,
79 "last_login_at": user.last_login_at,
80 "login_count": user.login_count,
81 }
83 async def create_user(
84 self,
85 name: str,
86 email: str,
87 hashed_password: str | None,
88 roles: list[str] | None = None,
89 permissions: list[str] | None = None,
90 profile: dict[str, Any] | None = None,
91 **kwargs: Any,
92 ) -> User:
93 """Create a new user."""
94 await self._ensure_collection()
96 import uuid
98 user_id = str(uuid.uuid4())
100 user = User(
101 user_id=user_id,
102 name=name,
103 email=email,
104 roles=list(roles or []),
105 permissions=list(permissions or []),
106 profile=profile or {},
107 )
109 doc = await self._doc_from_user(user)
110 doc["hashed_password"] = hashed_password
111 doc["previous_passwords"] = []
113 await self._col.insert_one(doc)
115 logger.info("Created user: %s", name)
116 return user
118 async def get_user_by_id(self, user_id: str) -> User | None:
119 """Get user by ID."""
120 await self._ensure_collection()
122 doc = await self._col.find_one({"_id": user_id})
123 return await self._user_from_doc(doc) if doc else None
125 async def get_user_by_email(self, email: str) -> User | None:
126 """Get user by email."""
127 await self._ensure_collection()
129 doc = await self._col.find_one({"email": email})
130 return await self._user_from_doc(doc) if doc else None
132 async def update_user(self, user: User) -> None:
133 """Update non-credential user information."""
134 await self._ensure_collection()
136 doc = await self._doc_from_user(user)
137 await self._col.replace_one({"_id": user.user_id}, doc, upsert=True)
139 logger.info("Updated user: %s", user.name)
141 async def delete_user(self, user_id: str) -> None:
142 """Delete a user."""
143 await self._ensure_collection()
145 await self._col.delete_one({"_id": user_id})
146 logger.info("Deleted user: %s", user_id)
148 async def list_users(self, skip: int = 0, limit: int = 100) -> list[User]:
149 """List users with pagination."""
150 await self._ensure_collection()
152 users = []
153 cursor = self._col.find({}, skip=skip, limit=limit)
154 async for doc in cursor: # type: ignore[attr-defined]
155 user = await self._user_from_doc(doc)
156 users.append(user)
158 return users
160 async def count_users(self) -> int:
161 """Count total users."""
162 await self._ensure_collection()
164 return await self._col.count_documents({})
166 async def get_credentials(self, user_id: str) -> UserCredentials | None:
167 """Return credential data for *user_id*."""
168 await self._ensure_collection()
169 doc = await self._col.find_one(
170 {"_id": user_id},
171 projection={"hashed_password": 1, "previous_passwords": 1},
172 )
173 if not doc:
174 return None
175 return UserCredentials(
176 user_id=user_id,
177 hashed_password=doc.get("hashed_password"),
178 previous_hashes=doc.get("previous_passwords", []),
179 )
181 async def update_credentials(self, creds: UserCredentials) -> None:
182 """Persist updated credentials for the user."""
183 await self._ensure_collection()
184 await self._col.update_one(
185 {"_id": creds.user_id},
186 {
187 "$set": {
188 "hashed_password": creds.hashed_password,
189 "previous_passwords": creds.previous_hashes,
190 }
191 },
192 )
193 logger.info("Updated credentials for user: %s", creds.user_id)
196__all__ = ["MongoDBUserStore"]