Skip to content

Commit c2d63c6

Browse files
Moved password hash to a separate database model, resolved some mypy type linter errors
1 parent 2587dcf commit c2d63c6

File tree

11 files changed

+178
-45
lines changed

11 files changed

+178
-45
lines changed

docs/customization.qmd

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ To run the tests, use these commands:
4141
The project uses type annotations and mypy for static type checking. To run mypy, use this command from the root directory:
4242

4343
```bash
44-
mypy
44+
mypy .
4545
```
4646

4747
We find that mypy is an enormous time-saver, catching many errors early and greatly reducing time spent debugging unit tests. However, note that mypy requires you type annotate every variable, function, and method in your code base, so taking advantage of it requires a lifestyle change!

main.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -255,14 +255,14 @@ async def read_organization(
255255
params: dict = Depends(common_authenticated_parameters)
256256
):
257257
# Get the organization only if the user is a member of it
258-
organization: Organization = params["user"].organizations.get(org_id)
259-
if not organization:
258+
org: Organization = params["user"].organizations.get(org_id)
259+
if not org:
260260
raise organization.OrganizationNotFoundError()
261261

262262
# Eagerly load roles and users
263-
organization.roles
264-
organization.users
265-
params["organization"] = organization
263+
org.roles
264+
org.users
265+
params["organization"] = org
266266

267267
return templates.TemplateResponse(params["request"], "users/organization.html", params)
268268

routers/authentication.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from fastapi.responses import RedirectResponse
77
from pydantic import BaseModel, EmailStr, ConfigDict
88
from sqlmodel import Session, select
9-
from utils.models import User
9+
from utils.models import User, UserPassword
1010
from utils.auth import (
1111
get_session,
1212
get_user_from_reset_token,
@@ -125,15 +125,19 @@ async def register(
125125
user: UserRegister = Depends(UserRegister.as_form),
126126
session: Session = Depends(get_session),
127127
) -> RedirectResponse:
128+
# Check if the email is already registered
128129
db_user = session.exec(select(User).where(
129130
User.email == user.email)).first()
130131

131132
if db_user:
132133
raise HTTPException(status_code=400, detail="Email already registered")
133134

135+
# Hash the password
134136
hashed_password = get_password_hash(user.password)
137+
138+
# Create the user
135139
db_user = User(name=user.name, email=user.email,
136-
hashed_password=hashed_password)
140+
password=UserPassword(hashed_password=hashed_password))
137141
session.add(db_user)
138142
session.commit()
139143
session.refresh(db_user)
@@ -155,9 +159,11 @@ async def login(
155159
user: UserLogin = Depends(UserLogin.as_form),
156160
session: Session = Depends(get_session),
157161
) -> RedirectResponse:
162+
# Check if the email is registered
158163
db_user = session.exec(select(User).where(
159164
User.email == user.email)).first()
160-
if not db_user or not verify_password(user.password, db_user.hashed_password):
165+
166+
if not db_user or not db_user.password or not verify_password(user.password, db_user.password.hashed_password):
161167
raise HTTPException(status_code=400, detail="Invalid credentials")
162168

163169
# Create access token
@@ -259,7 +265,17 @@ async def reset_password(
259265
raise HTTPException(status_code=400, detail="Invalid or expired token")
260266

261267
# Update password and mark token as used
262-
authorized_user.hashed_password = get_password_hash(user.new_password)
268+
if authorized_user.password:
269+
authorized_user.password.hashed_password = get_password_hash(
270+
user.new_password
271+
)
272+
else:
273+
logger.warning(
274+
"User password not found during password reset; creating new password for user")
275+
authorized_user.password = UserPassword(
276+
hashed_password=get_password_hash(user.new_password)
277+
)
278+
263279
reset_token.used = True
264280
session.commit()
265281
session.refresh(authorized_user)

routers/organization.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
from logging import getLogger
22
from fastapi import APIRouter, Depends, HTTPException, Form
3-
from fastapi.responses import RedirectResponse, HTMLResponse
3+
from fastapi.responses import RedirectResponse
44
from pydantic import BaseModel, ConfigDict, field_validator
55
from sqlmodel import Session, select
66
from utils.db import get_session
77
from utils.auth import get_authenticated_user, get_user_with_relations
8-
from utils.models import Organization, User, Role, utc_time, default_roles
8+
from utils.models import Organization, User, Role, utc_time, default_roles, ValidPermissions
99
from datetime import datetime
1010

1111
logger = getLogger("uvicorn.error")
@@ -139,9 +139,11 @@ def update_organization(
139139
session: Session = Depends(get_session)
140140
) -> RedirectResponse:
141141
# This will raise appropriate exceptions if org doesn't exist or user lacks access
142-
organization: Organization = user.organizations.get(org.id)
142+
organization: Organization | None = next(
143+
(org for org in user.organizations if org.id == org.id), None)
143144

144-
if not organization or not any(role.permissions.EDIT_ORGANIZATION for role in organization.roles):
145+
# Check if user has permission to edit organization
146+
if not organization or not user.has_permission(ValidPermissions.EDIT_ORGANIZATION, organization):
145147
raise InsufficientPermissionsError()
146148

147149
# Check if new name already exists for another organization
@@ -169,7 +171,8 @@ def delete_organization(
169171
session: Session = Depends(get_session)
170172
) -> RedirectResponse:
171173
# Check if user has permission to delete organization
172-
organization: Organization = user.organizations.get(org_id)
174+
organization: Organization | None = next(
175+
(org for org in user.organizations if org.id == org_id), None)
173176
if not organization or not any(role.permissions.DELETE_ORGANIZATION for role in organization.roles):
174177
raise InsufficientPermissionsError()
175178

routers/user.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,15 @@ async def delete_account(
6363
current_user: User = Depends(get_authenticated_user),
6464
session: Session = Depends(get_session)
6565
):
66+
if not current_user.password:
67+
raise HTTPException(
68+
status_code=500,
69+
detail="User password not found in database; please contact a system administrator"
70+
)
71+
6672
if not verify_password(
6773
user_delete_account.confirm_delete_password,
68-
current_user.hashed_password
74+
current_user.password.hashed_password
6975
):
7076
raise HTTPException(
7177
status_code=400,

tests/conftest.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
import pytest
22
from dotenv import load_dotenv
33
from sqlmodel import create_engine, Session, select
4+
from sqlalchemy import Engine
45
from fastapi.testclient import TestClient
56
from utils.db import get_connection_url, set_up_db, tear_down_db, get_session
6-
from utils.models import User, PasswordResetToken, Organization, Role
7+
from utils.models import User, PasswordResetToken, Organization, Role, UserPassword
78
from utils.auth import get_password_hash, create_access_token, create_refresh_token
89
from main import app
910

1011
load_dotenv()
1112

1213

14+
# Define a custom exception for test setup errors
15+
class SetupError(Exception):
16+
"""Exception raised for errors in the test setup process."""
17+
pass
18+
19+
1320
@pytest.fixture(scope="session")
14-
def engine():
21+
def engine() -> Engine:
1522
"""
1623
Create a new SQLModel engine for the test database.
1724
Use an in-memory SQLite database for testing.
@@ -63,7 +70,7 @@ def test_user(session: Session):
6370
user = User(
6471
name="Test User",
6572
66-
hashed_password=get_password_hash("Test123!@#")
73+
password=UserPassword(hashed_password=get_password_hash("Test123!@#"))
6774
)
6875
session.add(user)
6976
session.commit()

tests/test_authentication.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
validate_token,
1818
generate_password_reset_url
1919
)
20-
20+
from .conftest import SetupError
2121

2222
# --- Fixture setup ---
2323

@@ -28,7 +28,7 @@ def mock_email_response():
2828
"""
2929
Returns a mock Email response object
3030
"""
31-
return resend.Email(id="6229f547-f3f6-4eb8-b0dc-82c1b09121b6")
31+
return resend.Email(id="mock_resend_id")
3232

3333

3434
@pytest.fixture
@@ -104,7 +104,12 @@ def test_register_endpoint(unauth_client: TestClient, session: Session):
104104
User.email == "[email protected]")).first()
105105
assert user is not None
106106
assert user.name == "New User"
107-
assert verify_password("NewPass123!@#", user.hashed_password)
107+
108+
# Verify password was hashed and matches
109+
if not user.password:
110+
raise SetupError(
111+
"Test setup failed; user.password is None")
112+
assert verify_password("NewPass123!@#", user.password.hashed_password)
108113

109114

110115
def test_login_endpoint(unauth_client: TestClient, test_user: User):
@@ -200,10 +205,14 @@ def test_password_reset_flow(unauth_client: TestClient, session: Session, test_u
200205
)
201206
assert response.status_code == 303
202207

208+
if not test_user.password:
209+
raise SetupError(
210+
"Test setup failed; test_user.password is None")
211+
203212
# Verify password was updated and token was marked as used
204213
session.refresh(test_user)
205214
session.refresh(reset_token)
206-
assert verify_password("NewPass123!@#", test_user.hashed_password)
215+
assert verify_password("NewPass123!@#", test_user.password.hashed_password)
207216
assert reset_token.used
208217

209218

tests/test_db.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import warnings
22
from sqlmodel import Session, select, inspect
3+
from sqlalchemy import Engine
34
from utils.db import (
45
get_connection_url,
56
assign_permissions_to_role,
@@ -9,7 +10,7 @@
910
set_up_db,
1011
)
1112
from utils.models import Role, Permission, Organization, RolePermissionLink, ValidPermissions
12-
from sqlalchemy import Engine
13+
from .conftest import SetupError
1314

1415

1516
def test_get_connection_url():
@@ -43,8 +44,12 @@ def test_create_default_roles(session: Session, test_organization: Organization)
4344
session.commit()
4445

4546
# Create roles for test organization
46-
roles = create_default_roles(session, test_organization.id)
47-
session.commit()
47+
if test_organization.id is not None:
48+
roles = create_default_roles(session, test_organization.id)
49+
session.commit()
50+
else:
51+
raise SetupError(
52+
"Test setup failed; test_organization.id is None")
4853

4954
# Verify roles were created
5055
assert len(roles) == 3 # Owner, Administrator, Member

tests/test_models.py

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import pytest
1+
from datetime import timedelta, datetime, UTC
2+
from typing import Optional
23
from sqlmodel import select, Session
34
from utils.models import (
45
Permission,
@@ -8,9 +9,9 @@
89
ValidPermissions,
910
User,
1011
UserRoleLink,
11-
PasswordResetToken,
12+
PasswordResetToken
1213
)
13-
from datetime import timedelta, datetime, UTC
14+
from .conftest import SetupError
1415

1516

1617
def test_permissions_persist_after_role_deletion(session: Session):
@@ -102,10 +103,9 @@ def test_organization_users_property(session: Session, test_user: User, test_org
102103
session.refresh(test_organization)
103104

104105
# Test the users property
105-
users_list = test_organization.users
106+
users_list: list[User] = test_organization.users
106107
assert len(users_list) == 1
107-
# users_list is a list of lists due to the property implementation
108-
assert test_user in users_list[0]
108+
assert test_user in users_list
109109

110110

111111
def test_cascade_delete_organization(session: Session, test_user: User, test_organization: Organization):
@@ -185,3 +185,46 @@ def test_password_reset_token_is_expired(session: Session, test_user: User):
185185
# Verify expiration states
186186
assert expired_token.is_expired()
187187
assert not valid_token.is_expired()
188+
189+
190+
def test_user_has_permission(session: Session, test_user: User, test_organization: Organization):
191+
"""
192+
Test that User.has_permission method correctly checks if a user has a specific
193+
permission for a given organization.
194+
"""
195+
# Create a role with specific permissions in the test organization
196+
role = Role(name="Test Role", organization_id=test_organization.id)
197+
session.add(role)
198+
session.commit()
199+
session.refresh(role)
200+
201+
# Assign permissions to the role
202+
delete_org_permission: Optional[Permission] = session.exec(
203+
select(Permission).where(Permission.name ==
204+
ValidPermissions.DELETE_ORGANIZATION)
205+
).first()
206+
edit_org_permission: Optional[Permission] = session.exec(
207+
select(Permission).where(Permission.name ==
208+
ValidPermissions.EDIT_ORGANIZATION)
209+
).first()
210+
211+
if delete_org_permission is not None and edit_org_permission is not None:
212+
role.permissions.append(delete_org_permission)
213+
role.permissions.append(edit_org_permission)
214+
else:
215+
raise SetupError(
216+
"Test setup failed; permission not found in database")
217+
session.commit()
218+
219+
# Link the user to the role
220+
test_user.roles.append(role)
221+
session.commit()
222+
session.refresh(test_user)
223+
224+
# Test the has_permission method
225+
assert test_user.has_permission(
226+
ValidPermissions.DELETE_ORGANIZATION, test_organization) is True
227+
assert test_user.has_permission(
228+
ValidPermissions.EDIT_ORGANIZATION, test_organization) is True
229+
assert test_user.has_permission(
230+
ValidPermissions.INVITE_USER, test_organization) is False

utils/db.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import os
22
import logging
3-
from typing import Generator
3+
from typing import Generator, Union, Sequence
44
from dotenv import load_dotenv
55
from sqlalchemy.engine import URL
66
from sqlmodel import create_engine, Session, SQLModel, select
@@ -57,7 +57,7 @@ def get_session() -> Generator[Session, None, None]:
5757
yield session
5858

5959

60-
def assign_permissions_to_role(session: Session, role: Role, permissions: list[Permission], check_first: bool = False) -> None:
60+
def assign_permissions_to_role(session: Session, role: Role, permissions: Union[list[Permission], Sequence[Permission]], check_first: bool = False) -> None:
6161
"""
6262
Assigns permissions to a role in the database.
6363

0 commit comments

Comments
 (0)