rahim237cm Claude Opus 5 commited on
Commit
da2ad52
·
1 Parent(s): c1d6b49

Etape 11 : authentification reelle branchee (demande n°2)

Browse files

app/core/auth.py (188 lignes, JWT + bcrypt + roles) etait complet et importe
par PERSONNE. Les 9 endpoints d'administration passaient par deux stubs
renvoyant {"id": 1, "role": "admin"} : ils etaient ouverts a tous.

BUG BLOQUANT decouvert en chemin : passlib 1.7.4 (derniere version, 2020) est
incompatible avec bcrypt >= 4.1 — il lit bcrypt.__about__.__version__, supprime
depuis. Avec bcrypt 5.x installe, hash_password echouait avec "password cannot
be longer than 72 bytes" MEME pour 13 caracteres : l'authentification aurait ete
inutilisable telle que declaree.
-> bcrypt utilise directement, passlib retire des manifestes. Pre-hachage
SHA-256 + base64 avant bcrypt : entree de longueur fixe, donc support des
phrases de passe longues sans la troncature silencieuse a 72 octets de bcrypt
(deux mots de passe partageant leurs 72 premiers octets etaient equivalents).

Ajouts :
- app/api/routes/auth.py : POST /login (formulaire OAuth2, l'URL exacte attendue
par oauth2_scheme, donc le bouton Authorize de /docs fonctionne),
POST /login/json (pour le front), GET /me, POST /logout.
Meme message d'erreur pour email inconnu et mot de passe faux : les distinguer
permettrait d'enumerer les comptes. Pas d'inscription publique.
- scripts/create_admin.py, idempotent, valide le mot de passe via UserCreate pour
que le script et l'API appliquent la meme politique.

Corrections :
- core/auth.py codait ALGORITHM et ACCESS_TOKEN_EXPIRE_MINUTES en dur (7 jours)
alors que config.py declare 30 minutes : la configuration n'avait aucun effet.
- datetime.utcnow (deprecie) -> datetime.now(timezone.utc), stocke en UTC naif
car les colonnes DateTime du schema sont sans fuseau.
- admin.py : liste _mock_users en memoire remplacee par le modele User ; les
schemas locaux masquaient app/schemas/user.py et le UserCreate local n'avait
AUCUN champ mot de passe (un compte cree par l'API n'aurait jamais pu se
connecter). Garde-fous ajoutes : pas d'auto-elevation, pas d'auto-suppression,
pas de suppression du dernier superadmin actif.

Endpoints fermes (12/12 verifies a 401 en acces anonyme) : ocr/detect,
ocr/process, upload/{id} DELETE, upload/cleanup, search/reindex, search/stats,
categories POST/PUT/DELETE, batch-upload upload+status, laws/admin *, admin/users.

FAILLE CORRIGEE : /ocr/detect et /ocr/process acceptaient un chemin serveur
arbitraire et renvoyaient le texte de tout fichier lisible par Tesseract.
resolve_upload_path() contraint desormais le fichier au repertoire d'upload —
motif strict, resolution des liens symboliques, verification is_file. Refus
verifie meme AVEC un jeton administrateur : l'auth seule ne suffisait pas.

Fixtures d'authentification ajoutees au conftest. Surcharger get_current_user
suffit aux trois niveaux d'acces, qui en dependent tous.

Verifie de bout en bout : mauvais mot de passe 401, email inconnu 401 (message
identique), connexion 200, /auth/me 200, endpoint admin 200 avec jeton, jeton
invalide 401, path traversal 400, connexion formulaire 200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

app/api/routes/admin.py CHANGED
@@ -19,9 +19,17 @@ from typing import Any, Dict, List, TypedDict, cast
19
 
20
  from fastapi import APIRouter, Depends, HTTPException, status
21
  from pydantic import BaseModel, EmailStr, Field
 
22
  from sqlalchemy.ext.asyncio import AsyncSession
23
 
 
 
 
 
 
24
  from app.core.database import get_db, health_check_db
 
 
25
 
26
  logger = logging.getLogger(__name__)
27
 
@@ -29,84 +37,15 @@ router = APIRouter(tags=["Admin"])
29
 
30
 
31
  # ==================== SCHEMAS ====================
32
-
33
-
34
- class UserCreate(BaseModel):
35
- """Schema for creating a user."""
36
-
37
- email: EmailStr = Field(..., description="User email")
38
- username: str = Field(..., min_length=3, max_length=50, description="Username")
39
- role: str = Field(default="user", description="User role (user or admin)")
40
- is_active: bool = Field(default=True, description="User active status")
41
-
42
-
43
- class UserUpdate(BaseModel):
44
- """Schema for updating a user."""
45
-
46
- email: EmailStr | None = None
47
- username: str | None = Field(None, min_length=3, max_length=50)
48
- role: str | None = None
49
- is_active: bool | None = None
50
-
51
-
52
- class UserResponse(BaseModel):
53
- """Schema for user response."""
54
-
55
- id: int
56
- email: str
57
- username: str
58
- role: str
59
- is_active: bool
60
- created_at: datetime
61
-
62
-
63
- class UserDict(TypedDict):
64
- """Internal dictionary type for mock storage."""
65
- id: int
66
- email: str
67
- username: str
68
- role: str
69
- is_active: bool
70
- created_at: datetime
71
-
72
-
73
- # ==================== MOCK USER STORAGE ====================
74
- # TODO: Replace with actual database model
75
-
76
-
77
- _mock_users: List[UserDict] = [
78
- {
79
- "id": 1,
80
- "email": "admin@jurix.cm",
81
- "username": "admin",
82
- "role": "admin",
83
- "is_active": True,
84
- "created_at": datetime.now(),
85
- },
86
- {
87
- "id": 2,
88
- "email": "user@jurix.cm",
89
- "username": "user1",
90
- "role": "user",
91
- "is_active": True,
92
- "created_at": datetime.now(),
93
- },
94
- ]
95
-
96
- _next_user_id = 3
97
-
98
-
99
- # ==================== AUTHENTICATION ====================
100
-
101
-
102
- async def get_current_admin():
103
- """
104
- Dependency for admin authentication.
105
-
106
- TODO: Implement actual JWT authentication.
107
- For now, returns mock admin user.
108
- """
109
- return {"id": 1, "role": "admin", "username": "admin"}
110
 
111
 
112
  # ==================== USER MANAGEMENT ====================
@@ -116,191 +55,155 @@ async def get_current_admin():
116
  async def list_users(
117
  skip: int = 0,
118
  limit: int = 100,
119
- current_admin: dict = Depends(get_current_admin),
120
- ) -> List[UserResponse]:
121
- """
122
- List all users (admin only).
123
-
124
- **Parameters:**
125
- - skip: Number of records to skip
126
- - limit: Maximum records to return
127
-
128
- **Returns:**
129
- - List of users
130
-
131
- **Requires:** Admin authentication
132
- """
133
  logger.info(f"📋 GET /admin/users - skip={skip}, limit={limit}")
134
-
135
- users = _mock_users[skip : skip + limit]
136
- return [
137
- UserResponse(
138
- id=u["id"],
139
- email=u["email"],
140
- username=u["username"],
141
- role=u["role"],
142
- is_active=u["is_active"],
143
- created_at=u["created_at"]
144
- )
145
- for u in users
146
- ]
147
 
148
 
149
  @router.post("/users", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
150
  async def create_user(
151
- user: UserCreate,
152
- current_admin: dict = Depends(get_current_admin),
153
- ) -> UserResponse:
 
154
  """
155
- Create a new user (admin only).
156
-
157
- **Parameters:**
158
- - email: User email (unique)
159
- - username: Username (unique)
160
- - role: User role (user or admin)
161
- - is_active: Active status
162
-
163
- **Returns:**
164
- - Created user
165
-
166
- **Raises:**
167
- - 409: Email or username already exists
168
-
169
- **Requires:** Admin authentication
170
  """
171
- global _next_user_id
172
-
173
- assert user is not None, "UserCreate must not be None"
174
- assert isinstance(user.email, str) and len(user.email) > 0, "Email must be a non-empty string"
175
- assert isinstance(user.username, str) and len(user.username) >= 3, "Username must be at least 3 characters"
176
 
177
- logger.info(f"➕ POST /admin/users - Creating user: {user.email}")
178
-
179
- # Check for duplicate email or username
180
- for existing_user in _mock_users:
181
- if existing_user["email"] == user.email:
182
- raise HTTPException(
183
- status_code=status.HTTP_409_CONFLICT,
184
- detail=f"Email already exists: {user.email}",
185
- )
186
- if existing_user["username"] == user.username:
187
- raise HTTPException(
188
- status_code=status.HTTP_409_CONFLICT,
189
- detail=f"Username already exists: {user.username}",
190
- )
191
-
192
- # Create new user
193
- new_user: UserDict = {
194
- "id": _next_user_id,
195
- "email": str(user.email),
196
- "username": user.username,
197
- "role": user.role,
198
- "is_active": user.is_active,
199
- "created_at": datetime.now(),
200
- }
201
-
202
- _mock_users.append(new_user)
203
- _next_user_id += 1
204
-
205
- logger.info(f"✅ User created: ID={new_user['id']}, email={new_user['email']}")
206
- return UserResponse(
207
- id=new_user["id"],
208
- email=new_user["email"],
209
- username=new_user["username"],
210
- role=new_user["role"],
211
- is_active=new_user["is_active"],
212
- created_at=new_user["created_at"]
213
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
 
215
 
216
  @router.put("/users/{user_id}", response_model=UserResponse)
217
  async def update_user(
218
  user_id: int,
219
- user_update: UserUpdate,
220
- current_admin: dict = Depends(get_current_admin),
221
- ) -> UserResponse:
 
222
  """
223
- Update a user (admin only).
224
-
225
- **Parameters:**
226
- - user_id: User ID
227
- - Partial update fields
228
-
229
- **Returns:**
230
- - Updated user
231
-
232
- **Raises:**
233
- - 404: User not found
234
-
235
- **Requires:** Admin authentication
236
  """
237
- assert isinstance(user_id, int) and user_id > 0, "user_id must be a positive integer"
238
- assert user_update is not None, "UserUpdate must not be None"
 
 
 
 
 
239
 
240
- logger.info(f"✏️ PUT /admin/users/{user_id}")
241
-
242
- # Find user
243
- user = next((u for u in _mock_users if u["id"] == user_id), None)
244
-
245
- if not user:
246
  raise HTTPException(
247
- status_code=status.HTTP_404_NOT_FOUND,
248
- detail=f"User not found: {user_id}",
 
 
 
 
 
 
 
 
249
  )
250
-
251
- # Update fields
252
- update_data = user_update.model_dump(exclude_unset=True)
253
- user_raw = cast(Dict[str, Any], user)
254
- for field, value in update_data.items():
255
- user_raw[field] = value
256
-
257
- logger.info(f"✅ User {user_id} updated")
258
- return UserResponse(
259
- id=user["id"],
260
- email=user["email"],
261
- username=user["username"],
262
- role=user["role"],
263
- is_active=user["is_active"],
264
- created_at=user["created_at"]
265
- )
266
 
267
 
268
  @router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
269
  async def delete_user(
270
  user_id: int,
271
- current_admin: dict = Depends(get_current_admin),
272
- ):
273
- """
274
- Delete a user (admin only).
275
-
276
- **Parameters:**
277
- - user_id: User ID
278
-
279
- **Returns:**
280
- - 204 No Content
281
-
282
- **Raises:**
283
- - 404: User not found
284
-
285
- **Requires:** Admin authentication
286
  """
287
- assert isinstance(user_id, int) and user_id > 0, "user_id must be a positive integer"
288
 
289
- logger.info(f"🗑️ DELETE /admin/users/{user_id}")
290
-
291
- # Find user
292
- user = next((u for u in _mock_users if u["id"] == user_id), None)
293
-
294
- if not user:
 
295
  raise HTTPException(
296
- status_code=status.HTTP_404_NOT_FOUND,
297
- detail=f"User not found: {user_id}",
298
  )
299
-
300
- # Remove user
301
- _mock_users.remove(user)
302
-
303
- logger.info(f"✅ User {user_id} deleted")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  return None
305
 
306
 
@@ -310,7 +213,7 @@ async def delete_user(
310
  @router.get("/system")
311
  async def get_system_info(
312
  db: AsyncSession = Depends(get_db),
313
- current_admin: dict = Depends(get_current_admin),
314
  ) -> Dict:
315
  """
316
  Get system information (admin only).
 
19
 
20
  from fastapi import APIRouter, Depends, HTTPException, status
21
  from pydantic import BaseModel, EmailStr, Field
22
+ from sqlalchemy import or_, select
23
  from sqlalchemy.ext.asyncio import AsyncSession
24
 
25
+ from app.core.auth import (
26
+ get_current_admin_user,
27
+ get_current_superadmin_user,
28
+ hash_password,
29
+ )
30
  from app.core.database import get_db, health_check_db
31
+ from app.models.user import User
32
+ from app.schemas.user import UserCreate, UserResponse, UserUpdate
33
 
34
  logger = logging.getLogger(__name__)
35
 
 
37
 
38
 
39
  # ==================== SCHEMAS ====================
40
+ #
41
+ # Les schemas UserCreate / UserUpdate / UserResponse etaient redefinis ici et
42
+ # MASQUAIENT ceux de app/schemas/user.py. Le UserCreate local n'avait aucun champ
43
+ # mot de passe : un utilisateur cree par cette API n'aurait jamais pu se
44
+ # connecter. Ils sont desormais importes depuis la source unique.
45
+ #
46
+ # La liste _mock_users (en memoire, perdue a chaque redemarrage, non partagee
47
+ # entre workers) a ete supprimee au profit du modele User et de la table users,
48
+ # qui existaient tous deux depuis la migration 0b21fb6a3651 sans etre utilises.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
 
51
  # ==================== USER MANAGEMENT ====================
 
55
  async def list_users(
56
  skip: int = 0,
57
  limit: int = 100,
58
+ db: AsyncSession = Depends(get_db),
59
+ current_admin: User = Depends(get_current_admin_user),
60
+ ) -> List[User]:
61
+ """Liste les comptes (administrateurs)."""
 
 
 
 
 
 
 
 
 
 
62
  logger.info(f"📋 GET /admin/users - skip={skip}, limit={limit}")
63
+ result = await db.execute(
64
+ select(User).order_by(User.id).offset(skip).limit(min(limit, 500))
65
+ )
66
+ return list(result.scalars().all())
 
 
 
 
 
 
 
 
 
67
 
68
 
69
  @router.post("/users", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
70
  async def create_user(
71
+ payload: UserCreate,
72
+ db: AsyncSession = Depends(get_db),
73
+ current_admin: User = Depends(get_current_admin_user),
74
+ ) -> User:
75
  """
76
+ Crée un compte (administrateurs).
77
+
78
+ Un administrateur ne peut pas créer de compte plus privilégié que le sien :
79
+ sans cette règle, tout admin pourrait se hisser au rang de superadmin en
80
+ créant un second compte.
 
 
 
 
 
 
 
 
 
 
81
  """
82
+ if payload.role in ("admin", "superadmin") and not current_admin.is_superadmin():
83
+ raise HTTPException(
84
+ status_code=status.HTTP_403_FORBIDDEN,
85
+ detail="Seul un superadmin peut créer un compte privilégié",
86
+ )
87
 
88
+ existing = await db.execute(
89
+ select(User).where(
90
+ or_(User.email == payload.email, User.username == payload.username)
91
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  )
93
+ if existing.scalar_one_or_none() is not None:
94
+ raise HTTPException(
95
+ status_code=status.HTTP_409_CONFLICT,
96
+ detail="Un compte existe déjà avec cet email ou ce nom d'utilisateur",
97
+ )
98
+
99
+ user = User(
100
+ email=payload.email,
101
+ username=payload.username,
102
+ full_name=payload.full_name,
103
+ role=payload.role,
104
+ is_active=payload.is_active,
105
+ hashed_password=hash_password(payload.password),
106
+ )
107
+ db.add(user)
108
+ await db.commit()
109
+ await db.refresh(user)
110
+ logger.info(f"✅ Compte créé : {user.email} (rôle {user.role})")
111
+ return user
112
 
113
 
114
  @router.put("/users/{user_id}", response_model=UserResponse)
115
  async def update_user(
116
  user_id: int,
117
+ payload: UserUpdate,
118
+ db: AsyncSession = Depends(get_db),
119
+ current_admin: User = Depends(get_current_admin_user),
120
+ ) -> User:
121
  """
122
+ Met à jour un compte (administrateurs).
123
+
124
+ Deux garde-fous : seul un superadmin peut modifier un rôle, et personne ne
125
+ peut se retirer ses propres droits — sinon la première erreur de manipulation
126
+ ferme définitivement l'accès à l'instance.
 
 
 
 
 
 
 
 
127
  """
128
+ user = (
129
+ await db.execute(select(User).where(User.id == user_id))
130
+ ).scalar_one_or_none()
131
+ if user is None:
132
+ raise HTTPException(
133
+ status_code=status.HTTP_404_NOT_FOUND, detail=f"Compte {user_id} introuvable"
134
+ )
135
 
136
+ changes = payload.model_dump(exclude_unset=True)
137
+
138
+ if "role" in changes and not current_admin.is_superadmin():
 
 
 
139
  raise HTTPException(
140
+ status_code=status.HTTP_403_FORBIDDEN,
141
+ detail="Seul un superadmin peut modifier un rôle",
142
+ )
143
+ if user.id == current_admin.id and (
144
+ changes.get("role") not in (None, current_admin.role)
145
+ or changes.get("is_active") is False
146
+ ):
147
+ raise HTTPException(
148
+ status_code=status.HTTP_400_BAD_REQUEST,
149
+ detail="Impossible de réduire ses propres droits",
150
  )
151
+
152
+ password = changes.pop("password", None)
153
+ if password:
154
+ user.hashed_password = hash_password(password)
155
+ for field, value in changes.items():
156
+ setattr(user, field, value)
157
+
158
+ await db.commit()
159
+ await db.refresh(user)
160
+ logger.info(f"✏️ Compte mis à jour : {user.email}")
161
+ return user
 
 
 
 
 
162
 
163
 
164
  @router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
165
  async def delete_user(
166
  user_id: int,
167
+ db: AsyncSession = Depends(get_db),
168
+ current_admin: User = Depends(get_current_superadmin_user),
169
+ ) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
170
  """
171
+ Supprime un compte (superadministrateurs).
172
 
173
+ Refuse la suppression de soi-même et celle du dernier superadmin actif : les
174
+ deux mènent à une instance dont plus personne n'a les clés.
175
+ """
176
+ user = (
177
+ await db.execute(select(User).where(User.id == user_id))
178
+ ).scalar_one_or_none()
179
+ if user is None:
180
  raise HTTPException(
181
+ status_code=status.HTTP_404_NOT_FOUND, detail=f"Compte {user_id} introuvable"
 
182
  )
183
+ if user.id == current_admin.id:
184
+ raise HTTPException(
185
+ status_code=status.HTTP_400_BAD_REQUEST,
186
+ detail="Impossible de supprimer son propre compte",
187
+ )
188
+ if user.role == "superadmin":
189
+ remaining = (
190
+ await db.execute(
191
+ select(User).where(
192
+ User.role == "superadmin",
193
+ User.is_active.is_(True),
194
+ User.id != user.id,
195
+ )
196
+ )
197
+ ).scalars().first()
198
+ if remaining is None:
199
+ raise HTTPException(
200
+ status_code=status.HTTP_400_BAD_REQUEST,
201
+ detail="Impossible de supprimer le dernier superadmin actif",
202
+ )
203
+
204
+ await db.delete(user)
205
+ await db.commit()
206
+ logger.info(f"🗑️ Compte supprimé : {user.email}")
207
  return None
208
 
209
 
 
213
  @router.get("/system")
214
  async def get_system_info(
215
  db: AsyncSession = Depends(get_db),
216
+ current_admin: User = Depends(get_current_admin_user),
217
  ) -> Dict:
218
  """
219
  Get system information (admin only).
app/api/routes/auth.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Routes d'authentification.
3
+
4
+ `app/core/auth.py` implémentait déjà l'intégralité de la chaîne JWT — hachage
5
+ bcrypt, création de jeton, dépendances par rôle — mais n'était importé nulle
6
+ part, et aucune route de connexion n'existait. Les endpoints « admin » passaient
7
+ par deux stubs renvoyant un dictionnaire en dur.
8
+
9
+ Deux points d'entrée de connexion, volontairement :
10
+
11
+ - `POST /login` accepte un formulaire OAuth2. C'est exactement l'URL déclarée
12
+ par `oauth2_scheme` (`tokenUrl="/api/v1/auth/login"`), donc le bouton
13
+ *Authorize* de /docs fonctionne — précieux pour administrer l'instance tant
14
+ que l'interface d'administration n'est pas terminée.
15
+ - `POST /login/json` accepte du JSON, plus naturel pour le front SvelteKit.
16
+
17
+ Les deux partagent le même `_authenticate`.
18
+
19
+ Il n'y a **pas** de route d'inscription publique : la création de comptes passe
20
+ par `POST /api/v1/admin/users` ou par `scripts/create_admin.py`. Une inscription
21
+ ouverte donnerait à n'importe qui l'accès au RAG, donc à la facturation Gemini.
22
+
23
+ Author: JuriX Team
24
+ """
25
+
26
+ import logging
27
+ from datetime import datetime, timezone
28
+
29
+ from fastapi import APIRouter, Depends, HTTPException, status
30
+ from fastapi.security import OAuth2PasswordRequestForm
31
+ from sqlalchemy import select
32
+ from sqlalchemy.ext.asyncio import AsyncSession
33
+
34
+ from app.core.auth import (
35
+ create_access_token,
36
+ get_current_active_user,
37
+ verify_password,
38
+ )
39
+ from app.core.database import get_db
40
+ from app.models.user import User
41
+ from app.schemas.user import UserLogin, UserResponse, UserWithToken
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ router = APIRouter()
46
+
47
+ # Message unique pour « email inconnu » et « mot de passe faux » : les
48
+ # distinguer permettrait d'énumérer les comptes existants.
49
+ _INVALID = "Identifiants invalides"
50
+
51
+
52
+ async def _authenticate(db: AsyncSession, email: str, password: str) -> User:
53
+ """
54
+ Vérifie un couple email / mot de passe.
55
+
56
+ Raises:
57
+ HTTPException 401: identifiants invalides
58
+ HTTPException 403: compte désactivé
59
+ """
60
+ result = await db.execute(select(User).where(User.email == email.lower().strip()))
61
+ user = result.scalar_one_or_none()
62
+
63
+ if user is None or not verify_password(password, user.hashed_password):
64
+ logger.warning(f"🔒 Échec de connexion pour {email!r}")
65
+ raise HTTPException(
66
+ status_code=status.HTTP_401_UNAUTHORIZED,
67
+ detail=_INVALID,
68
+ headers={"WWW-Authenticate": "Bearer"},
69
+ )
70
+
71
+ if not user.is_active:
72
+ raise HTTPException(
73
+ status_code=status.HTTP_403_FORBIDDEN, detail="Compte désactivé"
74
+ )
75
+
76
+ # Les colonnes DateTime du modele sont SANS fuseau : y ecrire un datetime
77
+ # avec fuseau leve "can't subtract offset-naive and offset-aware datetimes"
78
+ # au moment du flush. On stocke donc de l'UTC naif, comme le reste du schema.
79
+ user.last_login_at = datetime.now(timezone.utc).replace(tzinfo=None)
80
+ await db.commit()
81
+ await db.refresh(user)
82
+
83
+ logger.info(f"✅ Connexion de {user.email} (rôle {user.role})")
84
+ return user
85
+
86
+
87
+ @router.post("/login", response_model=UserWithToken, status_code=status.HTTP_200_OK)
88
+ async def login(
89
+ form_data: OAuth2PasswordRequestForm = Depends(),
90
+ db: AsyncSession = Depends(get_db),
91
+ ) -> UserWithToken:
92
+ """
93
+ Connexion au format formulaire OAuth2 (utilisée par /docs).
94
+
95
+ Le champ `username` porte l'adresse email.
96
+ """
97
+ user = await _authenticate(db, form_data.username, form_data.password)
98
+ return UserWithToken(
99
+ **UserResponse.model_validate(user).model_dump(),
100
+ access_token=create_access_token({"sub": user.email}),
101
+ )
102
+
103
+
104
+ @router.post("/login/json", response_model=UserWithToken, status_code=status.HTTP_200_OK)
105
+ async def login_json(
106
+ credentials: UserLogin,
107
+ db: AsyncSession = Depends(get_db),
108
+ ) -> UserWithToken:
109
+ """Connexion au format JSON (utilisée par le front)."""
110
+ user = await _authenticate(db, credentials.email, credentials.password)
111
+ return UserWithToken(
112
+ **UserResponse.model_validate(user).model_dump(),
113
+ access_token=create_access_token({"sub": user.email}),
114
+ )
115
+
116
+
117
+ @router.get("/me", response_model=UserResponse, status_code=status.HTTP_200_OK)
118
+ async def read_me(current_user: User = Depends(get_current_active_user)) -> User:
119
+ """Renvoie l'utilisateur associé au jeton fourni."""
120
+ return current_user
121
+
122
+
123
+ @router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
124
+ async def logout(current_user: User = Depends(get_current_active_user)) -> None:
125
+ """
126
+ Déconnexion.
127
+
128
+ Les JWT sont sans état : rien n'est révoqué côté serveur, le client jette son
129
+ jeton. L'endpoint existe pour donner un point d'appel explicite au front et
130
+ pour tracer la déconnexion.
131
+ """
132
+ logger.info(f"👋 Déconnexion de {current_user.email}")
133
+ return None
app/api/routes/batch_upload.py CHANGED
@@ -21,6 +21,8 @@ from app.core.database import AsyncSessionLocal, get_db
21
  from app.models.law import Law
22
  from app.tasks.process_law import process_law_async
23
  from app.core.config import settings
 
 
24
 
25
  logger = logging.getLogger(__name__)
26
 
@@ -89,7 +91,8 @@ async def websocket_endpoint(websocket: WebSocket, session_id: str):
89
  async def batch_upload(
90
  files: List[UploadFile] = File(...),
91
  session_id: str | None = None,
92
- db: AsyncSession = Depends(get_db)
 
93
  ):
94
  """
95
  Batch upload multiple PDF files.
@@ -241,7 +244,8 @@ async def process_batch(laws: List[dict], session_id: str) -> None:
241
 
242
  law.status = "processing"
243
  law.processing_progress = 0
244
- law.processing_started_at = datetime.now(timezone.utc)
 
245
  await db.commit()
246
 
247
  await manager.send_progress(session_id, {
@@ -305,7 +309,8 @@ async def process_batch(laws: List[dict], session_id: str) -> None:
305
  @router.get("/status")
306
  async def get_batch_status(
307
  status: str | None = None,
308
- db: AsyncSession = Depends(get_db)
 
309
  ):
310
  """
311
  Get all laws with optional status filter.
 
21
  from app.models.law import Law
22
  from app.tasks.process_law import process_law_async
23
  from app.core.config import settings
24
+ from app.core.auth import get_current_admin_user
25
+ from app.models.user import User
26
 
27
  logger = logging.getLogger(__name__)
28
 
 
91
  async def batch_upload(
92
  files: List[UploadFile] = File(...),
93
  session_id: str | None = None,
94
+ db: AsyncSession = Depends(get_db),
95
+ current_admin: User = Depends(get_current_admin_user),
96
  ):
97
  """
98
  Batch upload multiple PDF files.
 
244
 
245
  law.status = "processing"
246
  law.processing_progress = 0
247
+ # UTC naif : la colonne est un DateTime sans fuseau.
248
+ law.processing_started_at = datetime.now(timezone.utc).replace(tzinfo=None)
249
  await db.commit()
250
 
251
  await manager.send_progress(session_id, {
 
309
  @router.get("/status")
310
  async def get_batch_status(
311
  status: str | None = None,
312
+ db: AsyncSession = Depends(get_db),
313
+ current_admin: User = Depends(get_current_admin_user),
314
  ):
315
  """
316
  Get all laws with optional status filter.
app/api/routes/categories.py CHANGED
@@ -31,6 +31,8 @@ from app.services.category_service import (
31
  CategoryServiceError
32
  )
33
  from app.schemas.law import CategoryCreate, CategoryUpdate, CategoryResponse, CategoryStats
 
 
34
 
35
  # Configure logger
36
  logger = logging.getLogger(__name__)
@@ -59,7 +61,8 @@ router = APIRouter(
59
  )
60
  async def create_category(
61
  category_data: CategoryCreate,
62
- db: AsyncSession = Depends(get_db)
 
63
  ) -> CategoryResponse:
64
  """
65
  Create a new legal category.
@@ -274,7 +277,8 @@ async def get_category(
274
  async def update_category(
275
  category_id: int,
276
  category_data: CategoryUpdate,
277
- db: AsyncSession = Depends(get_db)
 
278
  ) -> CategoryResponse:
279
  """
280
  Update a category.
@@ -341,7 +345,8 @@ async def update_category(
341
  async def delete_category(
342
  category_id: int,
343
  force: bool = Query(False, description="Force delete even if laws are associated"),
344
- db: AsyncSession = Depends(get_db)
 
345
  ):
346
  """
347
  Delete a category.
 
31
  CategoryServiceError
32
  )
33
  from app.schemas.law import CategoryCreate, CategoryUpdate, CategoryResponse, CategoryStats
34
+ from app.core.auth import get_current_admin_user
35
+ from app.models.user import User
36
 
37
  # Configure logger
38
  logger = logging.getLogger(__name__)
 
61
  )
62
  async def create_category(
63
  category_data: CategoryCreate,
64
+ db: AsyncSession = Depends(get_db),
65
+ current_admin: User = Depends(get_current_admin_user),
66
  ) -> CategoryResponse:
67
  """
68
  Create a new legal category.
 
277
  async def update_category(
278
  category_id: int,
279
  category_data: CategoryUpdate,
280
+ db: AsyncSession = Depends(get_db),
281
+ current_admin: User = Depends(get_current_admin_user),
282
  ) -> CategoryResponse:
283
  """
284
  Update a category.
 
345
  async def delete_category(
346
  category_id: int,
347
  force: bool = Query(False, description="Force delete even if laws are associated"),
348
+ db: AsyncSession = Depends(get_db),
349
+ current_admin: User = Depends(get_current_admin_user),
350
  ):
351
  """
352
  Delete a category.
app/api/routes/laws.py CHANGED
@@ -23,6 +23,8 @@ from sqlalchemy import select
23
  from sqlalchemy.ext.asyncio import AsyncSession
24
  from sqlalchemy.orm import selectinload
25
 
 
 
26
  from app.core.database import get_db, AsyncSessionLocal
27
  from app.models.law import Category, Law
28
  from app.schemas.law import (
@@ -54,17 +56,10 @@ router = APIRouter(tags=["Laws"])
54
  # ==================== DEPENDENCIES ====================
55
 
56
 
57
- # TODO: Implement authentication
58
- # For now, this is a placeholder
59
- async def get_current_admin_user():
60
- """
61
- Dependency for admin authentication.
62
-
63
- TODO: Implement actual authentication with JWT tokens.
64
- For now, returns a mock admin user.
65
- """
66
- # In production, verify JWT token and check admin role
67
- return {"id": 1, "role": "admin"}
68
 
69
 
70
  # ==================== PUBLIC ENDPOINTS ====================
@@ -502,7 +497,7 @@ async def get_law_pdf_page_image(
502
  async def ingest_law(
503
  request: LawIngestRequest,
504
  db: AsyncSession = Depends(get_db),
505
- current_user: dict = Depends(get_current_admin_user),
506
  ):
507
  """
508
  Ingest a new law from an uploaded file.
@@ -632,7 +627,7 @@ async def ingest_law(
632
  async def create_law(
633
  law: LawCreate,
634
  db: AsyncSession = Depends(get_db),
635
- current_user: dict = Depends(get_current_admin_user),
636
  ):
637
  """
638
  Create a new law (admin only).
@@ -709,7 +704,7 @@ async def update_law(
709
  law_id: int,
710
  law_update: LawUpdate,
711
  db: AsyncSession = Depends(get_db),
712
- current_user: dict = Depends(get_current_admin_user),
713
  ):
714
  """
715
  Update an existing law (admin only).
@@ -771,7 +766,7 @@ async def update_law(
771
  async def delete_law(
772
  law_id: int,
773
  db: AsyncSession = Depends(get_db),
774
- current_user: dict = Depends(get_current_admin_user),
775
  ):
776
  """
777
  Delete a law (admin only).
 
23
  from sqlalchemy.ext.asyncio import AsyncSession
24
  from sqlalchemy.orm import selectinload
25
 
26
+ from app.core.auth import get_current_admin_user
27
+ from app.models.user import User
28
  from app.core.database import get_db, AsyncSessionLocal
29
  from app.models.law import Category, Law
30
  from app.schemas.law import (
 
56
  # ==================== DEPENDENCIES ====================
57
 
58
 
59
+ # L'authentification reelle vit dans app/core/auth.py (JWT + bcrypt + roles).
60
+ # Un stub renvoyant {"id": 1, "role": "admin"} occupait cette place : les quatre
61
+ # endpoints d'administration ci-dessous etaient donc ouverts a tous.
62
+ # get_current_admin_user est importe en tete de fichier.
 
 
 
 
 
 
 
63
 
64
 
65
  # ==================== PUBLIC ENDPOINTS ====================
 
497
  async def ingest_law(
498
  request: LawIngestRequest,
499
  db: AsyncSession = Depends(get_db),
500
+ current_user: User = Depends(get_current_admin_user),
501
  ):
502
  """
503
  Ingest a new law from an uploaded file.
 
627
  async def create_law(
628
  law: LawCreate,
629
  db: AsyncSession = Depends(get_db),
630
+ current_user: User = Depends(get_current_admin_user),
631
  ):
632
  """
633
  Create a new law (admin only).
 
704
  law_id: int,
705
  law_update: LawUpdate,
706
  db: AsyncSession = Depends(get_db),
707
+ current_user: User = Depends(get_current_admin_user),
708
  ):
709
  """
710
  Update an existing law (admin only).
 
766
  async def delete_law(
767
  law_id: int,
768
  db: AsyncSession = Depends(get_db),
769
+ current_user: User = Depends(get_current_admin_user),
770
  ):
771
  """
772
  Delete a law (admin only).
app/api/routes/ocr.py CHANGED
@@ -13,7 +13,7 @@ Date: 2026-01-11
13
  import logging
14
  from pathlib import Path
15
 
16
- from fastapi import APIRouter, HTTPException, status
17
  from fastapi.responses import JSONResponse
18
 
19
  from app.schemas.ocr import (
@@ -23,6 +23,9 @@ from app.schemas.ocr import (
23
  PDFTypeDetectionRequest,
24
  PDFTypeDetectionResult,
25
  )
 
 
 
26
  from app.services.ocr_service import OCRError, OCRService
27
 
28
  logger = logging.getLogger(__name__)
@@ -65,7 +68,10 @@ def get_ocr_service() -> OCRService:
65
  - OCR recommendation
66
  """,
67
  )
68
- async def detect_pdf_type(request: PDFTypeDetectionRequest) -> PDFTypeDetectionResult:
 
 
 
69
  """
70
  Detect PDF type without processing.
71
 
@@ -81,19 +87,24 @@ async def detect_pdf_type(request: PDFTypeDetectionRequest) -> PDFTypeDetectionR
81
  500: Detection error
82
  """
83
  assert request is not None, "PDFTypeDetectionRequest must not be None"
84
- assert isinstance(request.pdf_path, str) and len(request.pdf_path) > 0, "pdf_path must be a non-empty string"
85
 
86
- logger.info(f"🔍 PDF type detection request: {request.pdf_path}")
87
 
88
- pdf_path = Path(request.pdf_path)
89
-
90
- # Validate path
91
- if not pdf_path.exists():
 
 
 
 
92
  raise HTTPException(
93
  status_code=status.HTTP_404_NOT_FOUND,
94
- detail=f"PDF file not found: {request.pdf_path}",
95
  )
96
 
 
 
97
  try:
98
  service = get_ocr_service()
99
  result = await service.detect_pdf_type(pdf_path)
@@ -126,7 +137,10 @@ async def detect_pdf_type(request: PDFTypeDetectionRequest) -> PDFTypeDetectionR
126
  **Languages:** French (fra), English (eng), or both (fra+eng)
127
  """,
128
  )
129
- async def process_pdf(request: OCRRequest) -> OCRResult:
 
 
 
130
  """
131
  Process PDF with OCR.
132
 
@@ -142,22 +156,27 @@ async def process_pdf(request: OCRRequest) -> OCRResult:
142
  500: Processing error
143
  """
144
  assert request is not None, "OCRRequest must not be None"
145
- assert isinstance(request.pdf_path, str) and len(request.pdf_path) > 0, "pdf_path must be a non-empty string"
146
 
147
  logger.info(
148
- f"📄 OCR processing request: {request.pdf_path} "
149
  f"(lang: {request.language}, dpi: {request.dpi})"
150
  )
151
 
152
- pdf_path = Path(request.pdf_path)
153
-
154
- # Validate path
155
- if not pdf_path.exists():
 
 
 
 
156
  raise HTTPException(
157
  status_code=status.HTTP_404_NOT_FOUND,
158
- detail=f"PDF file not found: {request.pdf_path}",
159
  )
160
 
 
 
161
  try:
162
  service = get_ocr_service()
163
  result = await service.process_pdf(
 
13
  import logging
14
  from pathlib import Path
15
 
16
+ from fastapi import APIRouter, Depends, HTTPException, status
17
  from fastapi.responses import JSONResponse
18
 
19
  from app.schemas.ocr import (
 
23
  PDFTypeDetectionRequest,
24
  PDFTypeDetectionResult,
25
  )
26
+ from app.core.auth import get_current_admin_user
27
+ from app.models.user import User
28
+ from app.utils.file_utils import resolve_upload_path
29
  from app.services.ocr_service import OCRError, OCRService
30
 
31
  logger = logging.getLogger(__name__)
 
68
  - OCR recommendation
69
  """,
70
  )
71
+ async def detect_pdf_type(
72
+ request: PDFTypeDetectionRequest,
73
+ current_admin: User = Depends(get_current_admin_user),
74
+ ) -> PDFTypeDetectionResult:
75
  """
76
  Detect PDF type without processing.
77
 
 
87
  500: Detection error
88
  """
89
  assert request is not None, "PDFTypeDetectionRequest must not be None"
 
90
 
91
+ logger.info(f"🔍 PDF type detection request: {request.file_id}")
92
 
93
+ # resolve_upload_path contraint le fichier au repertoire d'upload.
94
+ # Auparavant : Path(request.pdf_path) sur un chemin serveur arbitraire, sans
95
+ # authentification — enumeration et lecture de n'importe quel fichier lisible.
96
+ try:
97
+ pdf_path = resolve_upload_path(request.file_id)
98
+ except ValueError as e:
99
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
100
+ except FileNotFoundError:
101
  raise HTTPException(
102
  status_code=status.HTTP_404_NOT_FOUND,
103
+ detail=f"Fichier introuvable : {request.file_id}",
104
  )
105
 
106
+ # Validate path
107
+
108
  try:
109
  service = get_ocr_service()
110
  result = await service.detect_pdf_type(pdf_path)
 
137
  **Languages:** French (fra), English (eng), or both (fra+eng)
138
  """,
139
  )
140
+ async def process_pdf(
141
+ request: OCRRequest,
142
+ current_admin: User = Depends(get_current_admin_user),
143
+ ) -> OCRResult:
144
  """
145
  Process PDF with OCR.
146
 
 
156
  500: Processing error
157
  """
158
  assert request is not None, "OCRRequest must not be None"
 
159
 
160
  logger.info(
161
+ f"📄 OCR processing request: {request.file_id} "
162
  f"(lang: {request.language}, dpi: {request.dpi})"
163
  )
164
 
165
+ # resolve_upload_path contraint le fichier au repertoire d'upload.
166
+ # Auparavant : Path(request.pdf_path) sur un chemin serveur arbitraire, sans
167
+ # authentification — enumeration et lecture de n'importe quel fichier lisible.
168
+ try:
169
+ pdf_path = resolve_upload_path(request.file_id)
170
+ except ValueError as e:
171
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
172
+ except FileNotFoundError:
173
  raise HTTPException(
174
  status_code=status.HTTP_404_NOT_FOUND,
175
+ detail=f"Fichier introuvable : {request.file_id}",
176
  )
177
 
178
+ # Validate path
179
+
180
  try:
181
  service = get_ocr_service()
182
  result = await service.process_pdf(
app/api/routes/search.py CHANGED
@@ -26,6 +26,8 @@ from app.schemas.search import (
26
  SearchStats,
27
  )
28
  from app.services.search_service import SearchService, SearchServiceError
 
 
29
 
30
  logger = logging.getLogger(__name__)
31
 
@@ -345,7 +347,8 @@ async def find_article(
345
  # dependencies=[Depends(require_admin)]
346
  )
347
  async def reindex_all(
348
- background_tasks: BackgroundTasks, search_service: SearchService = Depends(get_search_service)
 
349
  ) -> ReindexResponse:
350
  """
351
  Reconstruit les tsvector de toutes les lois publiées (admin uniquement).
@@ -434,7 +437,10 @@ async def reindex_all(
434
  response_model=SearchStats,
435
  status_code=status.HTTP_200_OK,
436
  )
437
- async def search_stats(db: AsyncSession = Depends(get_db)) -> SearchStats:
 
 
 
438
  """
439
  Statistiques d'indexation (administrateurs).
440
 
 
26
  SearchStats,
27
  )
28
  from app.services.search_service import SearchService, SearchServiceError
29
+ from app.core.auth import get_current_admin_user
30
+ from app.models.user import User
31
 
32
  logger = logging.getLogger(__name__)
33
 
 
347
  # dependencies=[Depends(require_admin)]
348
  )
349
  async def reindex_all(
350
+ background_tasks: BackgroundTasks, search_service: SearchService = Depends(get_search_service),
351
+ current_admin: User = Depends(get_current_admin_user),
352
  ) -> ReindexResponse:
353
  """
354
  Reconstruit les tsvector de toutes les lois publiées (admin uniquement).
 
437
  response_model=SearchStats,
438
  status_code=status.HTTP_200_OK,
439
  )
440
+ async def search_stats(
441
+ db: AsyncSession = Depends(get_db),
442
+ current_admin: User = Depends(get_current_admin_user),
443
+ ) -> SearchStats:
444
  """
445
  Statistiques d'indexation (administrateurs).
446
 
app/api/routes/upload.py CHANGED
@@ -15,11 +15,13 @@ import logging
15
  from pathlib import Path
16
  from typing import Dict
17
 
18
- from fastapi import APIRouter, File, HTTPException, UploadFile, status
19
  from fastapi.responses import JSONResponse
20
 
21
  from app.schemas.file_upload import FileUploadResult, UploadServiceHealth
22
  from app.services.file_upload_service import FileUploadError, FileUploadService
 
 
23
 
24
  logger = logging.getLogger(__name__)
25
 
@@ -189,7 +191,10 @@ async def get_upload_status(file_id: str) -> Dict:
189
  summary="Delete uploaded file",
190
  description="Delete an uploaded file from temporary storage.",
191
  )
192
- async def delete_upload(file_id: str) -> Dict:
 
 
 
193
  """
194
  Delete an uploaded file.
195
 
@@ -233,7 +238,10 @@ async def delete_upload(file_id: str) -> Dict:
233
  summary="Cleanup old files",
234
  description="Remove files older than specified hours (default: 24h).",
235
  )
236
- async def cleanup_old_files(max_age_hours: int = 24) -> Dict:
 
 
 
237
  """
238
  Cleanup old uploaded files.
239
 
 
15
  from pathlib import Path
16
  from typing import Dict
17
 
18
+ from fastapi import Depends, APIRouter, File, HTTPException, UploadFile, status
19
  from fastapi.responses import JSONResponse
20
 
21
  from app.schemas.file_upload import FileUploadResult, UploadServiceHealth
22
  from app.services.file_upload_service import FileUploadError, FileUploadService
23
+ from app.core.auth import get_current_admin_user
24
+ from app.models.user import User
25
 
26
  logger = logging.getLogger(__name__)
27
 
 
191
  summary="Delete uploaded file",
192
  description="Delete an uploaded file from temporary storage.",
193
  )
194
+ async def delete_upload(
195
+ file_id: str,
196
+ current_admin: User = Depends(get_current_admin_user),
197
+ ) -> Dict:
198
  """
199
  Delete an uploaded file.
200
 
 
238
  summary="Cleanup old files",
239
  description="Remove files older than specified hours (default: 24h).",
240
  )
241
+ async def cleanup_old_files(
242
+ max_age_hours: int = 24,
243
+ current_admin: User = Depends(get_current_admin_user),
244
+ ) -> Dict:
245
  """
246
  Cleanup old uploaded files.
247
 
app/core/auth.py CHANGED
@@ -7,13 +7,16 @@ Author: JuriX Team
7
  Date: 2026-01-12
8
  """
9
 
10
- from datetime import datetime, timedelta
11
  from typing import Optional
12
 
13
  from fastapi import Depends, HTTPException, status
14
  from fastapi.security import OAuth2PasswordBearer
 
 
 
 
15
  from jose import JWTError, jwt
16
- from passlib.context import CryptContext
17
  from sqlalchemy import select
18
  from sqlalchemy.ext.asyncio import AsyncSession
19
 
@@ -21,20 +24,36 @@ from app.core.config import settings
21
  from app.core.database import get_db
22
  from app.models.user import User
23
 
24
- # Password hashing
25
- pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
  # OAuth2 scheme
28
  oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
29
 
30
- # JWT settings
31
- SECRET_KEY = (
32
- settings.SECRET_KEY
33
- if hasattr(settings, "SECRET_KEY")
34
- else "dev-secret-key-change-in-production"
35
- )
36
- ALGORITHM = "HS256"
37
- ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
38
 
39
 
40
  def hash_password(password: str) -> str:
@@ -47,7 +66,7 @@ def hash_password(password: str) -> str:
47
  Returns:
48
  Hashed password
49
  """
50
- return pwd_context.hash(password)
51
 
52
 
53
  def verify_password(plain_password: str, hashed_password: str) -> bool:
@@ -61,7 +80,11 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
61
  Returns:
62
  True if password matches, False otherwise
63
  """
64
- return pwd_context.verify(plain_password, hashed_password)
 
 
 
 
65
 
66
 
67
  def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
@@ -77,9 +100,9 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -
77
  """
78
  to_encode = data.copy()
79
  if expires_delta:
80
- expire = datetime.utcnow() + expires_delta
81
  else:
82
- expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
83
 
84
  to_encode.update({"exp": expire})
85
  encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
 
7
  Date: 2026-01-12
8
  """
9
 
10
+ from datetime import datetime, timedelta, timezone
11
  from typing import Optional
12
 
13
  from fastapi import Depends, HTTPException, status
14
  from fastapi.security import OAuth2PasswordBearer
15
+ import base64
16
+ import hashlib
17
+
18
+ import bcrypt
19
  from jose import JWTError, jwt
 
20
  from sqlalchemy import select
21
  from sqlalchemy.ext.asyncio import AsyncSession
22
 
 
24
  from app.core.database import get_db
25
  from app.models.user import User
26
 
27
+ # ==================== HACHAGE DES MOTS DE PASSE ====================
28
+ #
29
+ # bcrypt est utilise directement, sans passlib. passlib 1.7.4 (derniere version,
30
+ # 2020) lit `bcrypt.__about__.__version__` pour detecter le backend — attribut
31
+ # supprime depuis bcrypt 4.1. Avec bcrypt 5.x, le hachage echouait totalement
32
+ # avec "password cannot be longer than 72 bytes" MEME pour un mot de passe de
33
+ # 13 caracteres. L'authentification etait donc inutilisable telle que declaree.
34
+ #
35
+ # bcrypt ignore silencieusement tout ce qui depasse 72 octets : deux mots de
36
+ # passe partageant les 72 premiers octets seraient equivalents. On pre-hache donc
37
+ # en SHA-256 puis on encode en base64, ce qui donne une entree de longueur fixe
38
+ # (44 octets) et supporte les phrases de passe de n'importe quelle longueur.
39
+
40
+
41
+ def _prepare(password: str) -> bytes:
42
+ """Normalise un mot de passe en une entree bcrypt de longueur fixe."""
43
+ digest = hashlib.sha256(password.encode("utf-8")).digest()
44
+ return base64.b64encode(digest)
45
 
46
  # OAuth2 scheme
47
  oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
48
 
49
+ # JWT settings — lus depuis la configuration.
50
+ # Auparavant ALGORITHM et ACCESS_TOKEN_EXPIRE_MINUTES etaient codes en dur ici
51
+ # (7 jours) alors que config.py declare 30 minutes : les deux valeurs se
52
+ # contredisaient et celle du fichier de configuration n'avait aucun effet.
53
+ # Le garde-fou hasattr etait vestigial : SECRET_KEY existe toujours.
54
+ SECRET_KEY = settings.SECRET_KEY
55
+ ALGORITHM = settings.ALGORITHM
56
+ ACCESS_TOKEN_EXPIRE_MINUTES = settings.ACCESS_TOKEN_EXPIRE_MINUTES
57
 
58
 
59
  def hash_password(password: str) -> str:
 
66
  Returns:
67
  Hashed password
68
  """
69
+ return bcrypt.hashpw(_prepare(password), bcrypt.gensalt()).decode("utf-8")
70
 
71
 
72
  def verify_password(plain_password: str, hashed_password: str) -> bool:
 
80
  Returns:
81
  True if password matches, False otherwise
82
  """
83
+ try:
84
+ return bcrypt.checkpw(_prepare(plain_password), hashed_password.encode("utf-8"))
85
+ except (ValueError, TypeError):
86
+ # Empreinte illisible ou tronquee : on refuse plutot que de propager.
87
+ return False
88
 
89
 
90
  def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
 
100
  """
101
  to_encode = data.copy()
102
  if expires_delta:
103
+ expire = datetime.now(timezone.utc) + expires_delta
104
  else:
105
+ expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
106
 
107
  to_encode.update({"exp": expire})
108
  encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
app/main.py CHANGED
@@ -7,7 +7,7 @@ from fastapi import FastAPI
7
  from fastapi.middleware.cors import CORSMiddleware
8
  from app.core.config import settings
9
  from app.core.database import close_db
10
- from app.api.routes import language, classifier, articles, search, rag, categories, personas, upload, ocr, laws, analytics, admin, batch_upload
11
 
12
  logger = logging.getLogger(__name__)
13
 
@@ -75,6 +75,7 @@ async def health():
75
 
76
 
77
  # Include routers with global /api/v1 prefix
 
78
  app.include_router(language.router, prefix="/api/v1/language", tags=["language"])
79
  app.include_router(classifier.router, prefix="/api/v1/classifier", tags=["classifier"])
80
  app.include_router(search.router, prefix="/api/v1/search", tags=["search"])
 
7
  from fastapi.middleware.cors import CORSMiddleware
8
  from app.core.config import settings
9
  from app.core.database import close_db
10
+ from app.api.routes import auth, language, classifier, articles, search, rag, categories, personas, upload, ocr, laws, analytics, admin, batch_upload
11
 
12
  logger = logging.getLogger(__name__)
13
 
 
75
 
76
 
77
  # Include routers with global /api/v1 prefix
78
+ app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
79
  app.include_router(language.router, prefix="/api/v1/language", tags=["language"])
80
  app.include_router(classifier.router, prefix="/api/v1/classifier", tags=["classifier"])
81
  app.include_router(search.router, prefix="/api/v1/search", tags=["search"])
app/schemas/ocr.py CHANGED
@@ -112,7 +112,8 @@ class OCRRequest(BaseModel):
112
  Request schema for OCR processing.
113
  """
114
 
115
- pdf_path: str = Field(..., min_length=1, description="Path to PDF file")
 
116
  language: str = Field(
117
  default="fra+eng",
118
  description="Tesseract language codes (e.g., 'fra', 'eng', 'fra+eng')",
@@ -138,7 +139,7 @@ class OCRRequest(BaseModel):
138
  class Config:
139
  json_schema_extra = {
140
  "example": {
141
- "pdf_path": "/path/to/document.pdf",
142
  "language": "fra+eng",
143
  "dpi": 300,
144
  "timeout_seconds": 60,
@@ -149,10 +150,11 @@ class OCRRequest(BaseModel):
149
  class PDFTypeDetectionRequest(BaseModel):
150
  """Request schema for PDF type detection only."""
151
 
152
- pdf_path: str = Field(..., min_length=1, description="Path to PDF file")
 
153
 
154
  class Config:
155
- json_schema_extra = {"example": {"pdf_path": "/path/to/document.pdf"}}
156
 
157
 
158
  # ============================================================================
 
112
  Request schema for OCR processing.
113
  """
114
 
115
+ file_id: str = Field(
116
+ ..., min_length=1, description="Identifiant du fichier uploadé (jamais un chemin serveur)")
117
  language: str = Field(
118
  default="fra+eng",
119
  description="Tesseract language codes (e.g., 'fra', 'eng', 'fra+eng')",
 
139
  class Config:
140
  json_schema_extra = {
141
  "example": {
142
+ "file_id": "/path/to/document.pdf",
143
  "language": "fra+eng",
144
  "dpi": 300,
145
  "timeout_seconds": 60,
 
150
  class PDFTypeDetectionRequest(BaseModel):
151
  """Request schema for PDF type detection only."""
152
 
153
+ file_id: str = Field(
154
+ ..., min_length=1, description="Identifiant du fichier uploadé (jamais un chemin serveur)")
155
 
156
  class Config:
157
+ json_schema_extra = {"example": {"file_id": "/path/to/document.pdf"}}
158
 
159
 
160
  # ============================================================================
app/utils/file_utils.py CHANGED
@@ -14,6 +14,7 @@ Date: 2026-01-11
14
 
15
  import hashlib
16
  import logging
 
17
  import uuid
18
  from pathlib import Path
19
  from typing import Optional, Tuple
@@ -353,3 +354,51 @@ def get_mime_type(file_type: str) -> str:
353
  raise ValueError(f"Unsupported file type: {file_type}")
354
 
355
  return MIME_TYPES[file_type]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
  import hashlib
16
  import logging
17
+ import re
18
  import uuid
19
  from pathlib import Path
20
  from typing import Optional, Tuple
 
354
  raise ValueError(f"Unsupported file type: {file_type}")
355
 
356
  return MIME_TYPES[file_type]
357
+
358
+
359
+ # ==================== RESOLUTION SECURISEE DES FICHIERS ====================
360
+
361
+ _FILE_ID_RE = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
362
+
363
+
364
+ def resolve_upload_path(file_id: str) -> Path:
365
+ """
366
+ Résout un identifiant de fichier en chemin, à l'intérieur du répertoire d'upload.
367
+
368
+ Motivation : `/api/v1/ocr/detect` et `/api/v1/ocr/process` acceptaient un
369
+ `pdf_path` arbitraire — un chemin du système de fichiers du serveur — et se
370
+ contentaient d'un `.exists()`. N'importe qui pouvait donc énumérer les
371
+ fichiers de la machine (404 contre 500) et se faire renvoyer le texte de tout
372
+ document lisible par Tesseract. L'authentification seule ne suffit pas : un
373
+ jeton d'administration volé rouvrirait la faille.
374
+
375
+ Trois barrières indépendantes :
376
+ 1. le motif rejette `..`, `/`, `%2f`, les chemins absolus et les octets nuls
377
+ AVANT tout accès au disque ;
378
+ 2. `resolve()` + `is_relative_to` bloquent les évasions par lien symbolique ;
379
+ 3. `is_file()` écarte les répertoires et les fichiers spéciaux.
380
+
381
+ Args:
382
+ file_id: Identifiant du fichier, sans extension
383
+
384
+ Returns:
385
+ Chemin absolu du fichier
386
+
387
+ Raises:
388
+ ValueError: identifiant invalide ou chemin sortant du répertoire
389
+ FileNotFoundError: aucun fichier correspondant
390
+ """
391
+ if not _FILE_ID_RE.match(file_id or ""):
392
+ raise ValueError(f"Identifiant de fichier invalide : {file_id!r}")
393
+
394
+ from app.services.file_upload_service import get_upload_service
395
+
396
+ root = get_upload_service().storage_path.resolve()
397
+ for extension in (".pdf", ".docx"):
398
+ candidate = (root / f"{file_id}{extension}").resolve()
399
+ if not candidate.is_relative_to(root):
400
+ raise ValueError("Le chemin sort du répertoire d'upload")
401
+ if candidate.is_file():
402
+ return candidate
403
+
404
+ raise FileNotFoundError(file_id)
pyproject.toml CHANGED
@@ -25,7 +25,8 @@ pgvector = "^0.2.3"
25
 
26
  # Security
27
  python-jose = {extras = ["cryptography"], version = "^3.3.0"}
28
- passlib = {extras = ["bcrypt"], version = "^1.7.4"}
 
29
  python-multipart = "^0.0.6"
30
 
31
  # (Meilisearch replaced by PostgreSQL tsvector + pg_trgm)
 
25
 
26
  # Security
27
  python-jose = {extras = ["cryptography"], version = "^3.3.0"}
28
+ # bcrypt direct : passlib est incompatible avec bcrypt >= 4.1
29
+ bcrypt = ">=4.0,<6.0"
30
  python-multipart = "^0.0.6"
31
 
32
  # (Meilisearch replaced by PostgreSQL tsvector + pg_trgm)
requirements.txt CHANGED
@@ -19,7 +19,10 @@ pgvector>=0.2.3,<1.0.0
19
 
20
  # ── Security ──────────────────────────────────────────────────────────────────
21
  python-jose[cryptography]>=3.3.0,<4.0.0
22
- passlib[bcrypt]>=1.7.4,<2.0.0
 
 
 
23
  python-multipart>=0.0.6,<1.0.0
24
 
25
  # ── Document Processing ───────────────────────────────────────────────────────
 
19
 
20
  # ── Security ──────────────────────────────────────────────────────────────────
21
  python-jose[cryptography]>=3.3.0,<4.0.0
22
+ # bcrypt utilise directement, sans passlib : passlib 1.7.4 (derniere version,
23
+ # 2020) est incompatible avec bcrypt >= 4.1 (il lit bcrypt.__about__, supprime),
24
+ # et le hachage echouait totalement avec bcrypt 5.x.
25
+ bcrypt>=4.0,<6.0
26
  python-multipart>=0.0.6,<1.0.0
27
 
28
  # ── Document Processing ───────────────────────────────────────────────────────
scripts/create_admin.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Crée ou met à jour le compte administrateur initial.
3
+
4
+ Aucun utilisateur n'était créé nulle part dans le projet : le modèle `User` et la
5
+ table `users` existaient depuis la migration 0b21fb6a3651 sans qu'aucun code ne
6
+ les alimente. Une fois l'authentification branchée, sans ce script il devient
7
+ impossible de se connecter à sa propre instance.
8
+
9
+ Idempotent : relancé, il ne duplique rien et ne modifie que ce qui doit l'être.
10
+
11
+ Usage:
12
+ python scripts/create_admin.py
13
+ python scripts/create_admin.py --email admin@jurix.cm --username admin
14
+ python scripts/create_admin.py --reset-password
15
+ python scripts/create_admin.py --role superadmin
16
+
17
+ Les valeurs par défaut viennent de .env : ADMIN_EMAIL, ADMIN_USERNAME,
18
+ ADMIN_PASSWORD. Si aucun mot de passe n'est fourni ni par option ni par
19
+ l'environnement, il est demandé de façon interactive — jamais de valeur par
20
+ défaut en dur.
21
+ """
22
+
23
+ import argparse
24
+ import asyncio
25
+ import getpass
26
+ import os
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ sys.path.insert(0, str(Path(__file__).parent.parent))
31
+
32
+ from dotenv import load_dotenv # noqa: E402
33
+ from sqlalchemy import select # noqa: E402
34
+
35
+ load_dotenv()
36
+
37
+ from app.core.auth import hash_password # noqa: E402
38
+ from app.core.database import AsyncSessionLocal # noqa: E402
39
+ from app.models.user import User # noqa: E402
40
+ from app.schemas.user import UserCreate # noqa: E402
41
+
42
+
43
+ def parse_args() -> argparse.Namespace:
44
+ p = argparse.ArgumentParser(description="Crée ou met à jour un administrateur JuriX")
45
+ p.add_argument("--email", default=os.getenv("ADMIN_EMAIL", "admin@jurix.cm"))
46
+ p.add_argument("--username", default=os.getenv("ADMIN_USERNAME", "admin"))
47
+ p.add_argument("--password", default=os.getenv("ADMIN_PASSWORD"))
48
+ p.add_argument(
49
+ "--role",
50
+ default=os.getenv("ADMIN_ROLE", "superadmin"),
51
+ choices=["admin", "superadmin"],
52
+ )
53
+ p.add_argument(
54
+ "--reset-password",
55
+ action="store_true",
56
+ help="Réinitialise le mot de passe d'un compte existant",
57
+ )
58
+ return p.parse_args()
59
+
60
+
61
+ async def main() -> int:
62
+ args = parse_args()
63
+
64
+ password = args.password
65
+ if not password:
66
+ password = getpass.getpass(f"Mot de passe pour {args.email} : ")
67
+ if not password:
68
+ print("❌ Aucun mot de passe fourni.", file=sys.stderr)
69
+ return 1
70
+
71
+ # On valide via UserCreate pour que le script et l'API appliquent
72
+ # exactement la même politique de mot de passe.
73
+ try:
74
+ UserCreate(
75
+ email=args.email,
76
+ username=args.username,
77
+ password=password,
78
+ role=args.role,
79
+ )
80
+ except Exception as e:
81
+ print(f"❌ Données invalides : {e}", file=sys.stderr)
82
+ return 1
83
+
84
+ try:
85
+ async with AsyncSessionLocal() as db:
86
+ existing = (
87
+ await db.execute(select(User).where(User.email == args.email.lower()))
88
+ ).scalar_one_or_none()
89
+
90
+ if existing is None:
91
+ db.add(
92
+ User(
93
+ email=args.email.lower(),
94
+ username=args.username.lower(),
95
+ hashed_password=hash_password(password),
96
+ role=args.role,
97
+ is_active=True,
98
+ is_verified=True,
99
+ )
100
+ )
101
+ await db.commit()
102
+ print(f"✅ Compte créé : {args.email} (rôle {args.role})")
103
+ return 0
104
+
105
+ changed = []
106
+ if existing.role != args.role:
107
+ existing.role = args.role
108
+ changed.append("rôle")
109
+ if not existing.is_active:
110
+ existing.is_active = True
111
+ changed.append("réactivation")
112
+ if args.reset_password:
113
+ existing.hashed_password = hash_password(password)
114
+ changed.append("mot de passe")
115
+
116
+ if changed:
117
+ await db.commit()
118
+ print(f"✅ Compte mis à jour : {args.email} ({', '.join(changed)})")
119
+ else:
120
+ print(
121
+ f"ℹ️ Compte déjà conforme : {args.email} (rôle {existing.role}). "
122
+ "Utilisez --reset-password pour changer le mot de passe."
123
+ )
124
+ return 0
125
+
126
+ except Exception as e:
127
+ print(f"❌ Base de données inaccessible : {e}", file=sys.stderr)
128
+ print(" Vérifiez DATABASE_URL dans .env et que les migrations sont appliquées.",
129
+ file=sys.stderr)
130
+ return 1
131
+
132
+
133
+ if __name__ == "__main__":
134
+ sys.exit(asyncio.run(main()))
tests/conftest.py CHANGED
@@ -89,6 +89,7 @@ _DB_FIXTURES = {
89
  "test_superadmin_user",
90
  "auth_headers",
91
  "admin_headers",
 
92
  }
93
 
94
  _pg_available: bool | None = None
@@ -332,3 +333,89 @@ def override_get_db(async_db_session: AsyncSession):
332
  yield async_db_session
333
 
334
  return _override_get_db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  "test_superadmin_user",
90
  "auth_headers",
91
  "admin_headers",
92
+ "as_admin",
93
  }
94
 
95
  _pg_available: bool | None = None
 
333
  yield async_db_session
334
 
335
  return _override_get_db
336
+
337
+
338
+ # ==================== AUTHENTIFICATION ====================
339
+ #
340
+ # Surcharger `get_current_user` suffit à débloquer les trois niveaux d'accès :
341
+ # `get_current_active_user`, `get_current_admin_user` et
342
+ # `get_current_superadmin_user` en dépendent tous. Les surcharger un par un
343
+ # obligerait à traiter chaque endpoint séparément.
344
+ #
345
+ # `client` reste volontairement anonyme, pour que les tests qui vérifient les
346
+ # 401 continuent de fonctionner.
347
+
348
+
349
+ async def _make_user(db: AsyncSession, email: str, username: str, role: str):
350
+ from app.core.auth import hash_password
351
+ from app.models.user import User
352
+
353
+ user = User(
354
+ email=email,
355
+ username=username,
356
+ hashed_password=hash_password("MotDePasseTest123"),
357
+ role=role,
358
+ is_active=True,
359
+ is_verified=True,
360
+ )
361
+ db.add(user)
362
+ await db.commit()
363
+ await db.refresh(user)
364
+ return user
365
+
366
+
367
+ @pytest_asyncio.fixture
368
+ async def test_user(db_session: AsyncSession):
369
+ """Compte sans privilège."""
370
+ return await _make_user(db_session, "user@test.cm", "usertest", "user")
371
+
372
+
373
+ @pytest_asyncio.fixture
374
+ async def test_admin_user(db_session: AsyncSession):
375
+ """Compte administrateur."""
376
+ return await _make_user(db_session, "admin@test.cm", "admintest", "admin")
377
+
378
+
379
+ @pytest_asyncio.fixture
380
+ async def test_superadmin_user(db_session: AsyncSession):
381
+ """Compte superadministrateur."""
382
+ return await _make_user(db_session, "super@test.cm", "supertest", "superadmin")
383
+
384
+
385
+ @pytest.fixture
386
+ def auth_headers(test_user):
387
+ """En-tête Authorization pour un compte sans privilège (jeton réel)."""
388
+ from app.core.auth import create_access_token
389
+
390
+ return {"Authorization": f"Bearer {create_access_token({'sub': test_user.email})}"}
391
+
392
+
393
+ @pytest.fixture
394
+ def admin_headers(test_admin_user):
395
+ """En-tête Authorization pour un administrateur (jeton réel)."""
396
+ from app.core.auth import create_access_token
397
+
398
+ return {"Authorization": f"Bearer {create_access_token({'sub': test_admin_user.email})}"}
399
+
400
+
401
+ @pytest.fixture
402
+ def as_admin(test_admin_user):
403
+ """
404
+ Court-circuite l'authentification en se faisant passer pour un administrateur.
405
+
406
+ Utile aux tests qui portent sur la logique métier et non sur l'authentification :
407
+ ils n'ont pas à fabriquer un jeton ni à gérer son expiration.
408
+ """
409
+ from app.core.auth import get_current_user
410
+
411
+ app.dependency_overrides[get_current_user] = lambda: test_admin_user
412
+ try:
413
+ yield test_admin_user
414
+ finally:
415
+ app.dependency_overrides.pop(get_current_user, None)
416
+
417
+
418
+ @pytest_asyncio.fixture
419
+ async def admin_client(client: AsyncClient, as_admin) -> AsyncClient:
420
+ """Client HTTP authentifié comme administrateur."""
421
+ return client
tests/test_api/test_category_routes.py CHANGED
@@ -11,9 +11,9 @@ class TestCreateCategoryAPI:
11
  """Tests for POST /api/v1/categories endpoint."""
12
 
13
  @pytest.mark.asyncio
14
- async def test_create_category_success(self, client: AsyncClient):
15
  """Test successful category creation via API."""
16
- response = await client.post(
17
  "/api/v1/categories",
18
  json={"name": "API Test Category", "description": "Created via API"}
19
  )
@@ -26,16 +26,16 @@ class TestCreateCategoryAPI:
26
  assert "created_at" in data
27
 
28
  @pytest.mark.asyncio
29
- async def test_create_category_duplicate_name(self, client: AsyncClient):
30
  """Test creating duplicate category returns 409."""
31
  # First create
32
- await client.post(
33
  "/api/v1/categories",
34
  json={"name": "Duplicate Test", "description": "First"}
35
  )
36
 
37
  # Try to create again
38
- response = await client.post(
39
  "/api/v1/categories",
40
  json={"name": "Duplicate Test", "description": "Second"}
41
  )
@@ -44,9 +44,9 @@ class TestCreateCategoryAPI:
44
  assert "already exists" in response.json()["detail"].lower()
45
 
46
  @pytest.mark.asyncio
47
- async def test_create_category_invalid_data(self, client: AsyncClient):
48
  """Test creating category with invalid data returns 422."""
49
- response = await client.post(
50
  "/api/v1/categories",
51
  json={} # Missing required 'name' field
52
  )
@@ -99,17 +99,17 @@ class TestGetCategoryAPI:
99
  """Tests for GET /api/v1/categories/{id} endpoint."""
100
 
101
  @pytest.mark.asyncio
102
- async def test_get_category_success(self, client: AsyncClient):
103
  """Test getting single category by ID."""
104
  # First create a category
105
- create_response = await client.post(
106
  "/api/v1/categories",
107
  json={"name": "Get Test Category"}
108
  )
109
  created = create_response.json()
110
 
111
  # Now get it
112
- response = await client.get(f"/api/v1/categories/{created['id']}")
113
 
114
  assert response.status_code == 200
115
  data = response.json()
@@ -129,17 +129,17 @@ class TestUpdateCategoryAPI:
129
  """Tests for PUT /api/v1/categories/{id} endpoint."""
130
 
131
  @pytest.mark.asyncio
132
- async def test_update_category_success(self, client: AsyncClient):
133
  """Test successful category update."""
134
  # Create a category
135
- create_response = await client.post(
136
  "/api/v1/categories",
137
  json={"name": "Update Test", "description": "Original"}
138
  )
139
  created = create_response.json()
140
 
141
  # Update it
142
- response = await client.put(
143
  f"/api/v1/categories/{created['id']}",
144
  json={"name": "Updated Name", "description": "Updated Desc"}
145
  )
@@ -151,9 +151,9 @@ class TestUpdateCategoryAPI:
151
  assert data["description"] == "Updated Desc"
152
 
153
  @pytest.mark.asyncio
154
- async def test_update_category_not_found(self, client: AsyncClient):
155
  """Test updating non-existent category returns 404."""
156
- response = await client.put(
157
  "/api/v1/categories/999999",
158
  json={"name": "New Name"}
159
  )
@@ -161,21 +161,21 @@ class TestUpdateCategoryAPI:
161
  assert response.status_code == 404
162
 
163
  @pytest.mark.asyncio
164
- async def test_update_category_duplicate_name(self, client: AsyncClient):
165
  """Test updating to duplicate name returns 409."""
166
  # Create two categories
167
- cat1 = (await client.post(
168
  "/api/v1/categories",
169
  json={"name": "Category 1"}
170
  )).json()
171
 
172
- cat2 = (await client.post(
173
  "/api/v1/categories",
174
  json={"name": "Category 2"}
175
  )).json()
176
 
177
  # Try to update cat2 to have same name as cat1
178
- response = await client.put(
179
  f"/api/v1/categories/{cat2['id']}",
180
  json={"name": "Category 1"}
181
  )
@@ -187,43 +187,43 @@ class TestDeleteCategoryAPI:
187
  """Tests for DELETE /api/v1/categories/{id} endpoint."""
188
 
189
  @pytest.mark.asyncio
190
- async def test_delete_category_success(self, client: AsyncClient):
191
  """Test successful category deletion."""
192
  # Create a category
193
- create_response = await client.post(
194
  "/api/v1/categories",
195
  json={"name": "Delete Test"}
196
  )
197
  created = create_response.json()
198
 
199
  # Delete it
200
- response = await client.delete(f"/api/v1/categories/{created['id']}")
201
 
202
  assert response.status_code == 204
203
 
204
  # Verify it's gone
205
- get_response = await client.get(f"/api/v1/categories/{created['id']}")
206
  assert get_response.status_code == 404
207
 
208
  @pytest.mark.asyncio
209
- async def test_delete_category_not_found(self, client: AsyncClient):
210
  """Test deleting non-existent category returns 404."""
211
- response = await client.delete("/api/v1/categories/999999")
212
 
213
  assert response.status_code == 404
214
 
215
  @pytest.mark.asyncio
216
- async def test_delete_category_with_force(self, client: AsyncClient):
217
  """Test force delete parameter."""
218
  # Create a category
219
- create_response = await client.post(
220
  "/api/v1/categories",
221
  json={"name": "Force Delete Test"}
222
  )
223
  created = create_response.json()
224
 
225
  # Force delete it
226
- response = await client.delete(
227
  f"/api/v1/categories/{created['id']}?force=true"
228
  )
229
 
@@ -383,9 +383,9 @@ class TestErrorHandling:
383
  assert response.status_code in [200, 422]
384
 
385
  @pytest.mark.asyncio
386
- async def test_create_category_with_extra_fields(self, client: AsyncClient):
387
  """Test creating category with extra fields ignores them."""
388
- response = await client.post(
389
  "/api/v1/categories",
390
  json={
391
  "name": "Extra Fields Test",
@@ -404,10 +404,10 @@ class TestEndToEndWorkflow:
404
  """End-to-end workflow tests."""
405
 
406
  @pytest.mark.asyncio
407
- async def test_full_crud_workflow(self, client: AsyncClient):
408
  """Test complete CRUD workflow: create → read → update → delete."""
409
  # 1. Create
410
- create_response = await client.post(
411
  "/api/v1/categories",
412
  json={"name": "E2E Test Category", "description": "End to end test"}
413
  )
@@ -416,18 +416,18 @@ class TestEndToEndWorkflow:
416
  category_id = created["id"]
417
 
418
  # 2. Read (single)
419
- get_response = await client.get(f"/api/v1/categories/{category_id}")
420
  assert get_response.status_code == 200
421
  assert get_response.json()["name"] == "E2E Test Category"
422
 
423
  # 3. Read (list)
424
- list_response = await client.get("/api/v1/categories")
425
  assert list_response.status_code == 200
426
  category_ids = [cat["id"] for cat in list_response.json()]
427
  assert category_id in category_ids
428
 
429
  # 4. Update
430
- update_response = await client.put(
431
  f"/api/v1/categories/{category_id}",
432
  json={"name": "E2E Updated", "description": "Updated description"}
433
  )
@@ -435,15 +435,15 @@ class TestEndToEndWorkflow:
435
  assert update_response.json()["name"] == "E2E Updated"
436
 
437
  # 5. Verify update
438
- get_updated = await client.get(f"/api/v1/categories/{category_id}")
439
  assert get_updated.json()["name"] == "E2E Updated"
440
 
441
  # 6. Delete
442
- delete_response = await client.delete(f"/api/v1/categories/{category_id}")
443
  assert delete_response.status_code == 204
444
 
445
  # 7. Verify deletion
446
- get_deleted = await client.get(f"/api/v1/categories/{category_id}")
447
  assert get_deleted.status_code == 404
448
 
449
  @pytest.mark.asyncio
@@ -464,28 +464,28 @@ class TestEndToEndWorkflow:
464
  assert single_stats.json()["category_id"] == first_cat["category_id"]
465
 
466
  @pytest.mark.asyncio
467
- async def test_mapping_workflow(self, client: AsyncClient):
468
  """Test mapping utilities workflow."""
469
  # 1. Create a new category
470
- create_response = await client.post(
471
  "/api/v1/categories",
472
  json={"name": "Mapping Test Category"}
473
  )
474
  created = create_response.json()
475
 
476
  # 2. Get ID-to-name mapping
477
- id_to_name = await client.get("/api/v1/categories/mapping/id-to-name")
478
  assert id_to_name.status_code == 200
479
  assert "Mapping Test Category" in id_to_name.json().values()
480
 
481
  # 3. Get name-to-ID mapping
482
- name_to_id = await client.get("/api/v1/categories/mapping/name-to-id")
483
  assert name_to_id.status_code == 200
484
  assert "Mapping Test Category" in name_to_id.json().keys()
485
  assert name_to_id.json()["Mapping Test Category"] == created["id"]
486
 
487
  # Cleanup
488
- await client.delete(f"/api/v1/categories/{created['id']}")
489
 
490
 
491
  # Summary:
 
11
  """Tests for POST /api/v1/categories endpoint."""
12
 
13
  @pytest.mark.asyncio
14
+ async def test_create_category_success(self, admin_client: AsyncClient):
15
  """Test successful category creation via API."""
16
+ response = await admin_client.post(
17
  "/api/v1/categories",
18
  json={"name": "API Test Category", "description": "Created via API"}
19
  )
 
26
  assert "created_at" in data
27
 
28
  @pytest.mark.asyncio
29
+ async def test_create_category_duplicate_name(self, admin_client: AsyncClient):
30
  """Test creating duplicate category returns 409."""
31
  # First create
32
+ await admin_client.post(
33
  "/api/v1/categories",
34
  json={"name": "Duplicate Test", "description": "First"}
35
  )
36
 
37
  # Try to create again
38
+ response = await admin_client.post(
39
  "/api/v1/categories",
40
  json={"name": "Duplicate Test", "description": "Second"}
41
  )
 
44
  assert "already exists" in response.json()["detail"].lower()
45
 
46
  @pytest.mark.asyncio
47
+ async def test_create_category_invalid_data(self, admin_client: AsyncClient):
48
  """Test creating category with invalid data returns 422."""
49
+ response = await admin_client.post(
50
  "/api/v1/categories",
51
  json={} # Missing required 'name' field
52
  )
 
99
  """Tests for GET /api/v1/categories/{id} endpoint."""
100
 
101
  @pytest.mark.asyncio
102
+ async def test_get_category_success(self, admin_client: AsyncClient):
103
  """Test getting single category by ID."""
104
  # First create a category
105
+ create_response = await admin_client.post(
106
  "/api/v1/categories",
107
  json={"name": "Get Test Category"}
108
  )
109
  created = create_response.json()
110
 
111
  # Now get it
112
+ response = await admin_client.get(f"/api/v1/categories/{created['id']}")
113
 
114
  assert response.status_code == 200
115
  data = response.json()
 
129
  """Tests for PUT /api/v1/categories/{id} endpoint."""
130
 
131
  @pytest.mark.asyncio
132
+ async def test_update_category_success(self, admin_client: AsyncClient):
133
  """Test successful category update."""
134
  # Create a category
135
+ create_response = await admin_client.post(
136
  "/api/v1/categories",
137
  json={"name": "Update Test", "description": "Original"}
138
  )
139
  created = create_response.json()
140
 
141
  # Update it
142
+ response = await admin_client.put(
143
  f"/api/v1/categories/{created['id']}",
144
  json={"name": "Updated Name", "description": "Updated Desc"}
145
  )
 
151
  assert data["description"] == "Updated Desc"
152
 
153
  @pytest.mark.asyncio
154
+ async def test_update_category_not_found(self, admin_client: AsyncClient):
155
  """Test updating non-existent category returns 404."""
156
+ response = await admin_client.put(
157
  "/api/v1/categories/999999",
158
  json={"name": "New Name"}
159
  )
 
161
  assert response.status_code == 404
162
 
163
  @pytest.mark.asyncio
164
+ async def test_update_category_duplicate_name(self, admin_client: AsyncClient):
165
  """Test updating to duplicate name returns 409."""
166
  # Create two categories
167
+ cat1 = (await admin_client.post(
168
  "/api/v1/categories",
169
  json={"name": "Category 1"}
170
  )).json()
171
 
172
+ cat2 = (await admin_client.post(
173
  "/api/v1/categories",
174
  json={"name": "Category 2"}
175
  )).json()
176
 
177
  # Try to update cat2 to have same name as cat1
178
+ response = await admin_client.put(
179
  f"/api/v1/categories/{cat2['id']}",
180
  json={"name": "Category 1"}
181
  )
 
187
  """Tests for DELETE /api/v1/categories/{id} endpoint."""
188
 
189
  @pytest.mark.asyncio
190
+ async def test_delete_category_success(self, admin_client: AsyncClient):
191
  """Test successful category deletion."""
192
  # Create a category
193
+ create_response = await admin_client.post(
194
  "/api/v1/categories",
195
  json={"name": "Delete Test"}
196
  )
197
  created = create_response.json()
198
 
199
  # Delete it
200
+ response = await admin_client.delete(f"/api/v1/categories/{created['id']}")
201
 
202
  assert response.status_code == 204
203
 
204
  # Verify it's gone
205
+ get_response = await admin_client.get(f"/api/v1/categories/{created['id']}")
206
  assert get_response.status_code == 404
207
 
208
  @pytest.mark.asyncio
209
+ async def test_delete_category_not_found(self, admin_client: AsyncClient):
210
  """Test deleting non-existent category returns 404."""
211
+ response = await admin_client.delete("/api/v1/categories/999999")
212
 
213
  assert response.status_code == 404
214
 
215
  @pytest.mark.asyncio
216
+ async def test_delete_category_with_force(self, admin_client: AsyncClient):
217
  """Test force delete parameter."""
218
  # Create a category
219
+ create_response = await admin_client.post(
220
  "/api/v1/categories",
221
  json={"name": "Force Delete Test"}
222
  )
223
  created = create_response.json()
224
 
225
  # Force delete it
226
+ response = await admin_client.delete(
227
  f"/api/v1/categories/{created['id']}?force=true"
228
  )
229
 
 
383
  assert response.status_code in [200, 422]
384
 
385
  @pytest.mark.asyncio
386
+ async def test_create_category_with_extra_fields(self, admin_client: AsyncClient):
387
  """Test creating category with extra fields ignores them."""
388
+ response = await admin_client.post(
389
  "/api/v1/categories",
390
  json={
391
  "name": "Extra Fields Test",
 
404
  """End-to-end workflow tests."""
405
 
406
  @pytest.mark.asyncio
407
+ async def test_full_crud_workflow(self, admin_client: AsyncClient):
408
  """Test complete CRUD workflow: create → read → update → delete."""
409
  # 1. Create
410
+ create_response = await admin_client.post(
411
  "/api/v1/categories",
412
  json={"name": "E2E Test Category", "description": "End to end test"}
413
  )
 
416
  category_id = created["id"]
417
 
418
  # 2. Read (single)
419
+ get_response = await admin_client.get(f"/api/v1/categories/{category_id}")
420
  assert get_response.status_code == 200
421
  assert get_response.json()["name"] == "E2E Test Category"
422
 
423
  # 3. Read (list)
424
+ list_response = await admin_client.get("/api/v1/categories")
425
  assert list_response.status_code == 200
426
  category_ids = [cat["id"] for cat in list_response.json()]
427
  assert category_id in category_ids
428
 
429
  # 4. Update
430
+ update_response = await admin_client.put(
431
  f"/api/v1/categories/{category_id}",
432
  json={"name": "E2E Updated", "description": "Updated description"}
433
  )
 
435
  assert update_response.json()["name"] == "E2E Updated"
436
 
437
  # 5. Verify update
438
+ get_updated = await admin_client.get(f"/api/v1/categories/{category_id}")
439
  assert get_updated.json()["name"] == "E2E Updated"
440
 
441
  # 6. Delete
442
+ delete_response = await admin_client.delete(f"/api/v1/categories/{category_id}")
443
  assert delete_response.status_code == 204
444
 
445
  # 7. Verify deletion
446
+ get_deleted = await admin_client.get(f"/api/v1/categories/{category_id}")
447
  assert get_deleted.status_code == 404
448
 
449
  @pytest.mark.asyncio
 
464
  assert single_stats.json()["category_id"] == first_cat["category_id"]
465
 
466
  @pytest.mark.asyncio
467
+ async def test_mapping_workflow(self, admin_client: AsyncClient):
468
  """Test mapping utilities workflow."""
469
  # 1. Create a new category
470
+ create_response = await admin_client.post(
471
  "/api/v1/categories",
472
  json={"name": "Mapping Test Category"}
473
  )
474
  created = create_response.json()
475
 
476
  # 2. Get ID-to-name mapping
477
+ id_to_name = await admin_client.get("/api/v1/categories/mapping/id-to-name")
478
  assert id_to_name.status_code == 200
479
  assert "Mapping Test Category" in id_to_name.json().values()
480
 
481
  # 3. Get name-to-ID mapping
482
+ name_to_id = await admin_client.get("/api/v1/categories/mapping/name-to-id")
483
  assert name_to_id.status_code == 200
484
  assert "Mapping Test Category" in name_to_id.json().keys()
485
  assert name_to_id.json()["Mapping Test Category"] == created["id"]
486
 
487
  # Cleanup
488
+ await admin_client.delete(f"/api/v1/categories/{created['id']}")
489
 
490
 
491
  # Summary:
tests/test_api/test_laws_api.py CHANGED
@@ -133,7 +133,7 @@ class TestCreateLaw:
133
  """Tests pour POST /api/v1/admin/laws."""
134
 
135
  @pytest.mark.asyncio
136
- async def test_create_law_success(self, client: AsyncClient):
137
  """Test création loi."""
138
  new_law = {
139
  "reference": "LOI-2024-002",
@@ -144,7 +144,7 @@ class TestCreateLaw:
144
  "status": "active"
145
  }
146
 
147
- response = await client.post("/api/v1/admin/laws", json=new_law)
148
 
149
  assert response.status_code == 201
150
  law = response.json()
@@ -153,7 +153,7 @@ class TestCreateLaw:
153
  assert "id" in law
154
 
155
  @pytest.mark.asyncio
156
- async def test_create_law_duplicate_reference(self, client: AsyncClient, sample_law):
157
  """Test création avec référence existante."""
158
  duplicate_law = {
159
  "reference": sample_law.reference,
@@ -163,20 +163,20 @@ class TestCreateLaw:
163
  "language": "fr"
164
  }
165
 
166
- response = await client.post("/api/v1/admin/laws", json=duplicate_law)
167
 
168
  assert response.status_code == 409
169
  assert "already exists" in response.json()["detail"].lower()
170
 
171
  @pytest.mark.asyncio
172
- async def test_create_law_validation_error(self, client: AsyncClient):
173
  """Test création avec données invalides."""
174
  invalid_law = {
175
  "reference": "", # Invalid: empty
176
  "title": "Test"
177
  }
178
 
179
- response = await client.post("/api/v1/admin/laws", json=invalid_law)
180
 
181
  assert response.status_code == 422
182
 
@@ -188,14 +188,14 @@ class TestUpdateLaw:
188
  """Tests pour PUT /api/v1/admin/laws/{id}."""
189
 
190
  @pytest.mark.asyncio
191
- async def test_update_law_success(self, client: AsyncClient, sample_law):
192
  """Test mise à jour loi."""
193
  update_data = {
194
  "title": "Titre mis à jour",
195
  "status": "archived"
196
  }
197
 
198
- response = await client.put(
199
  f"/api/v1/admin/laws/{sample_law.id}",
200
  json=update_data
201
  )
@@ -206,9 +206,9 @@ class TestUpdateLaw:
206
  assert law["status"] == update_data["status"]
207
 
208
  @pytest.mark.asyncio
209
- async def test_update_law_not_found(self, client: AsyncClient):
210
  """Test mise à jour loi non trouvée."""
211
- response = await client.put(
212
  "/api/v1/admin/laws/99999",
213
  json={"title": "Test"}
214
  )
@@ -223,20 +223,20 @@ class TestDeleteLaw:
223
  """Tests pour DELETE /api/v1/admin/laws/{id}."""
224
 
225
  @pytest.mark.asyncio
226
- async def test_delete_law_success(self, client: AsyncClient, sample_law):
227
  """Test suppression loi."""
228
- response = await client.delete(f"/api/v1/admin/laws/{sample_law.id}")
229
 
230
  assert response.status_code == 204
231
 
232
  # Vérifier que la loi est supprimée
233
- get_response = await client.get(f"/api/v1/laws/{sample_law.id}")
234
  assert get_response.status_code == 404
235
 
236
  @pytest.mark.asyncio
237
- async def test_delete_law_not_found(self, client: AsyncClient):
238
  """Test suppression loi non trouvée."""
239
- response = await client.delete("/api/v1/admin/laws/99999")
240
 
241
  assert response.status_code == 404
242
 
@@ -248,7 +248,7 @@ class TestLawsIntegration:
248
  """Tests d'intégration."""
249
 
250
  @pytest.mark.asyncio
251
- async def test_full_crud_workflow(self, client: AsyncClient):
252
  """Test workflow CRUD complet."""
253
  # 1. Create
254
  new_law = {
@@ -259,17 +259,17 @@ class TestLawsIntegration:
259
  "language": "fr",
260
  "status": "active"
261
  }
262
- create_response = await client.post("/api/v1/admin/laws", json=new_law)
263
  assert create_response.status_code == 201
264
  law_id = create_response.json()["id"]
265
 
266
  # 2. Read
267
- read_response = await client.get(f"/api/v1/laws/{law_id}")
268
  assert read_response.status_code == 200
269
  assert read_response.json()["title"] == new_law["title"]
270
 
271
  # 3. Update
272
- update_response = await client.put(
273
  f"/api/v1/admin/laws/{law_id}",
274
  json={"title": "Titre modifié"}
275
  )
@@ -277,9 +277,9 @@ class TestLawsIntegration:
277
  assert update_response.json()["title"] == "Titre modifié"
278
 
279
  # 4. Delete
280
- delete_response = await client.delete(f"/api/v1/admin/laws/{law_id}")
281
  assert delete_response.status_code == 204
282
 
283
  # 5. Verify deletion
284
- verify_response = await client.get(f"/api/v1/laws/{law_id}")
285
  assert verify_response.status_code == 404
 
133
  """Tests pour POST /api/v1/admin/laws."""
134
 
135
  @pytest.mark.asyncio
136
+ async def test_create_law_success(self, admin_client: AsyncClient):
137
  """Test création loi."""
138
  new_law = {
139
  "reference": "LOI-2024-002",
 
144
  "status": "active"
145
  }
146
 
147
+ response = await admin_client.post("/api/v1/admin/laws", json=new_law)
148
 
149
  assert response.status_code == 201
150
  law = response.json()
 
153
  assert "id" in law
154
 
155
  @pytest.mark.asyncio
156
+ async def test_create_law_duplicate_reference(self, admin_client: AsyncClient, sample_law):
157
  """Test création avec référence existante."""
158
  duplicate_law = {
159
  "reference": sample_law.reference,
 
163
  "language": "fr"
164
  }
165
 
166
+ response = await admin_client.post("/api/v1/admin/laws", json=duplicate_law)
167
 
168
  assert response.status_code == 409
169
  assert "already exists" in response.json()["detail"].lower()
170
 
171
  @pytest.mark.asyncio
172
+ async def test_create_law_validation_error(self, admin_client: AsyncClient):
173
  """Test création avec données invalides."""
174
  invalid_law = {
175
  "reference": "", # Invalid: empty
176
  "title": "Test"
177
  }
178
 
179
+ response = await admin_client.post("/api/v1/admin/laws", json=invalid_law)
180
 
181
  assert response.status_code == 422
182
 
 
188
  """Tests pour PUT /api/v1/admin/laws/{id}."""
189
 
190
  @pytest.mark.asyncio
191
+ async def test_update_law_success(self, admin_client: AsyncClient, sample_law):
192
  """Test mise à jour loi."""
193
  update_data = {
194
  "title": "Titre mis à jour",
195
  "status": "archived"
196
  }
197
 
198
+ response = await admin_client.put(
199
  f"/api/v1/admin/laws/{sample_law.id}",
200
  json=update_data
201
  )
 
206
  assert law["status"] == update_data["status"]
207
 
208
  @pytest.mark.asyncio
209
+ async def test_update_law_not_found(self, admin_client: AsyncClient):
210
  """Test mise à jour loi non trouvée."""
211
+ response = await admin_client.put(
212
  "/api/v1/admin/laws/99999",
213
  json={"title": "Test"}
214
  )
 
223
  """Tests pour DELETE /api/v1/admin/laws/{id}."""
224
 
225
  @pytest.mark.asyncio
226
+ async def test_delete_law_success(self, admin_client: AsyncClient, sample_law):
227
  """Test suppression loi."""
228
+ response = await admin_client.delete(f"/api/v1/admin/laws/{sample_law.id}")
229
 
230
  assert response.status_code == 204
231
 
232
  # Vérifier que la loi est supprimée
233
+ get_response = await admin_client.get(f"/api/v1/laws/{sample_law.id}")
234
  assert get_response.status_code == 404
235
 
236
  @pytest.mark.asyncio
237
+ async def test_delete_law_not_found(self, admin_client: AsyncClient):
238
  """Test suppression loi non trouvée."""
239
+ response = await admin_client.delete("/api/v1/admin/laws/99999")
240
 
241
  assert response.status_code == 404
242
 
 
248
  """Tests d'intégration."""
249
 
250
  @pytest.mark.asyncio
251
+ async def test_full_crud_workflow(self, admin_client: AsyncClient):
252
  """Test workflow CRUD complet."""
253
  # 1. Create
254
  new_law = {
 
259
  "language": "fr",
260
  "status": "active"
261
  }
262
+ create_response = await admin_client.post("/api/v1/admin/laws", json=new_law)
263
  assert create_response.status_code == 201
264
  law_id = create_response.json()["id"]
265
 
266
  # 2. Read
267
+ read_response = await admin_client.get(f"/api/v1/laws/{law_id}")
268
  assert read_response.status_code == 200
269
  assert read_response.json()["title"] == new_law["title"]
270
 
271
  # 3. Update
272
+ update_response = await admin_client.put(
273
  f"/api/v1/admin/laws/{law_id}",
274
  json={"title": "Titre modifié"}
275
  )
 
277
  assert update_response.json()["title"] == "Titre modifié"
278
 
279
  # 4. Delete
280
+ delete_response = await admin_client.delete(f"/api/v1/admin/laws/{law_id}")
281
  assert delete_response.status_code == 204
282
 
283
  # 5. Verify deletion
284
+ verify_response = await admin_client.get(f"/api/v1/laws/{law_id}")
285
  assert verify_response.status_code == 404