Skip to content

Commit e23a979

Browse files
authored
Merge pull request #142 from cssgunc/test-refactor
Backend Test Refactor
2 parents 70b2d98 + 57e94c3 commit e23a979

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+5416
-9964
lines changed

.devcontainer/devcontainer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@
9090
"python.analysis.autoImportCompletions": true,
9191
"ruff.enable": true,
9292
"ruff.organizeImports": true,
93-
"ruff.fixAll": true
93+
"ruff.fixAll": true,
94+
"ruff.lineLength": 100
9495
}
9596
}
9697
}

.devcontainer/post_create.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
#!/bin/bash
22

33
cd ../frontend
4-
npm ci --verbose
4+
npm i --verbose
55

66
cd ../backend
77
python -m script.create_db
8-
python -m script.reset_dev
8+
python -m script.reset_dev

backend/pytest.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@ python_classes = Test*
66
python_functions = test_*
77
asyncio_mode = auto
88
asyncio_default_fixture_loop_scope = function
9-
addopts = --color=yes
9+
addopts = --color=yes

backend/script/reset_dev.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,6 @@ async def reset_dev():
9090
data = json.load(f)
9191

9292
police = PoliceEntity(
93-
id=1,
9493
email=data["police"]["email"],
9594
hashed_password=data["police"]["hashed_password"],
9695
)

backend/src/core/authentication.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,20 @@
11
from typing import Literal
22

3-
from fastapi import Depends, HTTPException, Request, status
3+
from fastapi import Depends, Request
44
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
55
from src.core.exceptions import CredentialsException, ForbiddenException
66
from src.modules.account.account_model import Account, AccountRole
77
from src.modules.police.police_model import PoliceAccount
88

9+
StringRole = Literal["student", "admin", "staff", "police"]
10+
911

1012
class HTTPBearer401(HTTPBearer):
1113
async def __call__(self, request: Request):
1214
try:
1315
return await super().__call__(request)
1416
except Exception:
15-
raise HTTPException(
16-
status_code=status.HTTP_401_UNAUTHORIZED,
17-
detail="Not authenticated",
18-
headers={"WWW-Authenticate": "Bearer"},
19-
)
17+
raise CredentialsException()
2018

2119

2220
bearer_scheme = HTTPBearer401()
@@ -69,7 +67,7 @@ async def authenticate_user(
6967
return user
7068

7169

72-
def authenticate_by_role(*roles: Literal["police", "student", "admin", "staff"]):
70+
def authenticate_by_role(*roles: StringRole):
7371
"""
7472
Middleware factory to ensure the authenticated user has one of the specified roles.
7573
"""
@@ -79,8 +77,12 @@ async def _authenticate(
7977
) -> Account | PoliceAccount:
8078
token = authorization.credentials.lower()
8179

82-
if "police" in roles and token == "police":
83-
return PoliceAccount(email="[email protected]")
80+
# Check if police token and police is allowed
81+
if token == "police":
82+
if "police" in roles:
83+
return PoliceAccount(email="[email protected]")
84+
else:
85+
raise ForbiddenException(detail="Insufficient privileges")
8486

8587
role_map = {
8688
"student": AccountRole.STUDENT,
@@ -116,9 +118,7 @@ async def authenticate_staff_or_admin(
116118

117119

118120
async def authenticate_student_or_admin(
119-
account: Account | PoliceAccount = Depends(
120-
authenticate_by_role("student", "admin")
121-
),
121+
account: Account | PoliceAccount = Depends(authenticate_by_role("student", "admin")),
122122
) -> Account:
123123
if not isinstance(account, Account):
124124
raise ForbiddenException(detail="Insufficient privileges")

backend/src/core/bcrypt_utils.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import bcrypt
2+
3+
4+
def hash_password(password: str) -> str:
5+
"""Hash a password using bcrypt."""
6+
salt = bcrypt.gensalt()
7+
hashed = bcrypt.hashpw(password.encode("utf-8"), salt)
8+
return hashed.decode("utf-8")
9+
10+
11+
def verify_password(password: str, hashed_password: str) -> bool:
12+
"""Verify a password against its hash."""
13+
return bcrypt.checkpw(password.encode("utf-8"), hashed_password.encode("utf-8"))

backend/src/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ def handle_http_exception(req: Request, exc: HTTPException):
2424
return JSONResponse(
2525
status_code=exc.status_code,
2626
content={"message": exc.detail},
27+
headers=exc.headers,
2728
)
2829

2930

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,15 @@
1-
import enum
1+
from typing import Self
22

33
from sqlalchemy import CheckConstraint, Enum, Integer, String
4-
from sqlalchemy.orm import Mapped, mapped_column
4+
from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column
55
from src.core.database import EntityBase
6+
from src.modules.account.account_model import Account, AccountData, AccountRole
67

78

8-
class AccountRole(enum.Enum):
9-
STUDENT = "student"
10-
STAFF = "staff"
11-
ADMIN = "admin"
12-
13-
14-
class AccountEntity(EntityBase):
9+
class AccountEntity(MappedAsDataclass, EntityBase):
1510
__tablename__ = "accounts"
1611

17-
id: Mapped[int] = mapped_column(Integer, primary_key=True)
12+
id: Mapped[int] = mapped_column(Integer, primary_key=True, init=False)
1813
email: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False)
1914
first_name: Mapped[str] = mapped_column(String, nullable=False)
2015
last_name: Mapped[str] = mapped_column(String, nullable=False)
@@ -27,3 +22,23 @@ class AccountEntity(EntityBase):
2722
nullable=False,
2823
)
2924
role: Mapped[AccountRole] = mapped_column(Enum(AccountRole), nullable=False)
25+
26+
@classmethod
27+
def from_model(cls, data: "AccountData") -> Self:
28+
return cls(
29+
email=data.email,
30+
first_name=data.first_name,
31+
last_name=data.last_name,
32+
pid=data.pid,
33+
role=AccountRole(data.role),
34+
)
35+
36+
def to_model(self) -> "Account":
37+
return Account(
38+
id=self.id,
39+
email=self.email,
40+
first_name=self.first_name,
41+
last_name=self.last_name,
42+
pid=self.pid,
43+
role=self.role,
44+
)

backend/src/modules/account/account_model.py

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1-
from typing import Self
1+
from enum import Enum
22

33
from pydantic import BaseModel, EmailStr, Field
4-
from src.modules.account.account_entity import AccountEntity, AccountRole
4+
5+
6+
class AccountRole(Enum):
7+
STUDENT = "student"
8+
STAFF = "staff"
9+
ADMIN = "admin"
510

611

712
class AccountData(BaseModel):
@@ -23,14 +28,3 @@ class Account(BaseModel):
2328
last_name: str
2429
pid: str
2530
role: AccountRole
26-
27-
@classmethod
28-
def from_entity(cls, account_entity: AccountEntity) -> Self:
29-
return cls(
30-
id=account_entity.id,
31-
email=account_entity.email,
32-
first_name=account_entity.first_name,
33-
last_name=account_entity.last_name,
34-
pid=account_entity.pid,
35-
role=AccountRole(account_entity.role.value),
36-
)

backend/src/modules/account/account_service.py

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,27 +48,25 @@ async def _get_account_entity_by_email(self, email: str) -> AccountEntity:
4848
async def get_accounts(self) -> list[Account]:
4949
result = await self.session.execute(select(AccountEntity))
5050
accounts = result.scalars().all()
51-
return [Account.from_entity(account) for account in accounts]
51+
return [account.to_model() for account in accounts]
5252

53-
async def get_accounts_by_roles(
54-
self, roles: list[AccountRole] | None = None
55-
) -> list[Account]:
53+
async def get_accounts_by_roles(self, roles: list[AccountRole] | None = None) -> list[Account]:
5654
if not roles:
5755
return await self.get_accounts()
5856

5957
result = await self.session.execute(
6058
select(AccountEntity).where(AccountEntity.role.in_(roles))
6159
)
6260
accounts = result.scalars().all()
63-
return [Account.from_entity(account) for account in accounts]
61+
return [account.to_model() for account in accounts]
6462

6563
async def get_account_by_id(self, account_id: int) -> Account:
6664
account_entity = await self._get_account_entity_by_id(account_id)
67-
return Account.from_entity(account_entity)
65+
return account_entity.to_model()
6866

6967
async def get_account_by_email(self, email: str) -> Account:
7068
account_entity = await self._get_account_entity_by_email(email)
71-
return Account.from_entity(account_entity)
69+
return account_entity.to_model()
7270

7371
async def create_account(self, data: AccountData) -> Account:
7472
try:
@@ -93,7 +91,7 @@ async def create_account(self, data: AccountData) -> Account:
9391
# handle race condition where another session inserted the same email
9492
raise AccountConflictException(data.email)
9593
await self.session.refresh(new_account)
96-
return Account.from_entity(new_account)
94+
return new_account.to_model()
9795

9896
async def update_account(self, account_id: int, data: AccountData) -> Account:
9997
account_entity = await self._get_account_entity_by_id(account_id)
@@ -120,11 +118,11 @@ async def update_account(self, account_id: int, data: AccountData) -> Account:
120118
except IntegrityError:
121119
raise AccountConflictException(data.email)
122120
await self.session.refresh(account_entity)
123-
return Account.from_entity(account_entity)
121+
return account_entity.to_model()
124122

125123
async def delete_account(self, account_id: int) -> Account:
126124
account_entity = await self._get_account_entity_by_id(account_id)
127-
account = Account.from_entity(account_entity)
125+
account = account_entity.to_model()
128126
await self.session.delete(account_entity)
129127
await self.session.commit()
130-
return account
128+
return account

0 commit comments

Comments
 (0)