17 Commits

Author SHA1 Message Date
8fa72daa6b makefile and http errors 2026-07-30 20:58:53 +03:00
3705ac4f0c conftest fix 2026-07-30 15:54:43 +03:00
439d57554c integration tests 2026-07-30 15:49:08 +03:00
f87f54de55 tests logout 2026-07-29 18:32:22 +03:00
00403191f5 integrated tests 2026-07-29 00:44:14 +03:00
d24b99b8b0 integrated tests + omit unnecessary 2026-07-28 15:16:52 +03:00
ef1e39d506 fix gitignore 2026-07-28 13:35:38 +03:00
2781317797 rm coverage 2026-07-28 13:34:14 +03:00
f81ba19da4 hash tests 2026-07-28 13:31:58 +03:00
7a4df2933d ruff fix 2026-07-28 01:43:35 +03:00
5522447b07 auth unit tests & ruff visual fix 2026-07-28 01:42:29 +03:00
1d25b0edc3 refactoring some code by removing unnecessary checks and moving tests block to root / 2026-07-24 17:13:37 +03:00
26b857ed84 added httpie for tests 2026-07-23 21:50:17 +03:00
be8eb0c485 refresh tokens 0.1.3 2026-07-23 21:44:35 +03:00
eab78b6679 refresh tokens 0.1.2 2026-07-23 20:51:26 +03:00
7199387e6f access tokens 0.1.1 2026-07-23 13:06:44 +03:00
4d61d873b5 Access tokens 0.1.0 2026-07-23 11:13:52 +03:00
36 changed files with 2287 additions and 255 deletions

2
.gitignore vendored
View File

@@ -25,3 +25,5 @@ Thumbs.db
#Примеры документов
input/
output/
allure-results/
.coverage

15
main.py
View File

@@ -1,13 +1,28 @@
from fastapi import FastAPI
from src.web.protected_routes.routes import router as protected_router
from pathlib import Path
import uvicorn
app=FastAPI(root_path="/")
app.include_router(router=protected_router)
@app.get("")
def root()->dict:
return {"root":"hello, this is root"}
def create_dirs():
dirs_to_create=("./DB",
"./upload",
"./upload_bad",
"./upload_finished")
for x in dirs_to_create:
Path(x).mkdir(parents=True, exist_ok=True)
def main():
create_dirs()
uvicorn.run("main:app", reload=True)
if __name__=="__main__":

View File

@@ -1,11 +1,67 @@
VENV=source .venv/bin/activate;
VENV:=source .venv/bin/activate;
ALLURE:=.venv/allure-2.44.0/bin/allure #linux&macos
#ALLURE=.venv\allure-2.44.0\bin\allure #Windows
.PHONY:
run, m_gen, m_up
NUM_DOWN ?= 1
run:
.DEFAULT_GOAL := help
.PHONY: help
help:
@grep -E '(^[a-zA-Z0-9_-]+:.*?##.*$$)|(^##)' Makefile | awk 'BEGIN {FS = ":.*?## "}{printf "[32m%-30s\033[0m %s\n", $$1, $$2}' | sed -e 's/\[32m## /[33m/' | sed -e 's/\[32m/ /' | sed -e 's/\[33m//'
##
## Init section
##
.PHONY: run
run: ## Run application
${VENV} python3 main.py
m_gen:
##
## Migration section
##
.PHONY: m_gen
m_gen: ## Generate alembic new revision
${VENV} alembic revision --autogenerate
m_up:
.PHONY: m_up
m_up: ## Set new alembic revision
${VENV} alembic upgrade head
.PHONY: m_down
m_down: ## Downgrade alembic revision
${VENV} alembic downgrade -${NUM_DOWN}
.PHONY: m_history
m_history: ## List history of migrations
${VENV} alembic history
.PHONY: m_current
m_current: ## Current migration
${VENV} alembic current
.PHONY: m_stamp
m_stamp: ## Stamp head
${VENV} alembic stamp head
##
## Test section
##
.PHONY: test
test: ## Run tests
${VENV} pytest
.PHONY: allure
allure: ## Generate allure report
${VENV} ${ALLURE} generate tests/allure-results/reports --single-file -o tests/allure-results/html --clean
.PHONY: coverage
coverage: ## Run pytest coverage
${VENV} pytest --cov=src tests/
.PHONY: clear
clear: ## Delete old test results
rm -rf ./tests/allure-results/reports

1218
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,15 +14,36 @@ dependencies = [
"uvicorn (>=0.51.0,<0.52.0)",
"gunicorn (>=26.0.0,<27.0.0)",
"fastapi (>=0.139.1,<0.140.0)",
"pydantic (>=2.13.4,<3.0.0)",
"pydantic[email] (>=2.13.4,<3.0.0)",
"pydantic-settings (>=2.14.2,<3.0.0)",
"sqlalchemy (>=2.0.51,<3.0.0)",
"pandas (>=3.0.3,<4.0.0)",
"python-jwt (>=4.1.0,<5.0.0)",
"bcrypt (>=5.0.0,<6.0.0)"
"bcrypt (>=5.0.0,<6.0.0)",
"python-jose (>=3.5.0,<4.0.0)",
"python-multipart (>=0.0.32,<0.0.33)",
"ipython (>=9.15.0,<10.0.0)",
"httpie (>=3.2.4,<4.0.0)",
"pytest-cov (>=7.1.0,<8.0.0)",
"allure-pytest (>=2.16.0,<3.0.0)",
"pytest-mock (>=3.15.1,<4.0.0)"
]
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.coverage.run]
omit = [
"*/models/*",
"*/migrations/*",
"*/database/*",
"*/errors/*",
"__init__.py",
"*/docker/*"
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
]

12
pytest.ini Normal file
View File

@@ -0,0 +1,12 @@
[pytest]
addopts =
-l
-v
--alluredir=tests/allure-results/reports/
testpaths =
tests
markers=
unit: unit tests
integra: integrations test
e2e: e2e tests
smoke: smoke tests

View File

@@ -0,0 +1,87 @@
from uuid import UUID
from sqlalchemy import and_, not_, select
from sqlalchemy.exc import NoResultFound
from sqlalchemy.orm import sessionmaker
from src.errors.http_errors.errors import Errors
from src.models.database_models.model import RefreshTokens, engine
from src.models.pydantic_models.model import RefreshTokensOut
class JwtCrudActions:
def __init__(self) -> None:
self.Session=sessionmaker(bind=engine)
self.error=Errors()
def get_token_by_user_id(self, user_id:UUID)->RefreshTokensOut|None:
with self.Session() as session: # noqa: SIM117
with session.begin():
query=select(RefreshTokens).where(and_(RefreshTokens.user_id==user_id, not_(RefreshTokens.is_revoked)))
response=session.scalars(query).one_or_none()
if response is None:
return None
return RefreshTokensOut.model_validate(response)
def get_token_by_id(self, id:UUID)->RefreshTokensOut|None:
with self.Session() as session: # noqa: SIM117
with session.begin():
query=select(RefreshTokens).where(RefreshTokens.id==id)
response=session.scalars(query).one_or_none()
if response is None:
return None
return RefreshTokensOut.model_validate(response)
def create_token(self, data:dict)->None:
with self.Session() as session: # noqa: SIM117
with session.begin():
new_token=RefreshTokens(**data)
response=session.add(new_token)
return response
def update_token(self, old_jti:UUID, new_jti:UUID)->bool:
with self.Session() as session: # noqa: SIM117
with session.begin():
query=select(RefreshTokens).where(RefreshTokens.id==old_jti)
response=session.scalars(query).one()
response.is_revoked=True
response.replaced_by=new_jti
return True
def create_and_update_token(self,data:dict, old_jti:UUID, new_jti:UUID)->bool:
with self.Session() as session: #noqa:SIM117
with session.begin():
new_token=RefreshTokens(**data)
session.add(new_token)
query=select(RefreshTokens).where(RefreshTokens.id==old_jti)
try:
response = session.scalars(query).one()
except NoResultFound as e:
raise self.error.not_found_error(detail="Token not found") from e
response.is_revoked=True
response.replaced_by=new_jti
return True
def revoke_all(self, user_id:UUID)->bool:
with self.Session() as session: # noqa: SIM117
with session.begin():
query=select(RefreshTokens).where(RefreshTokens.user_id==user_id)
response=session.scalars(query).all()
for record in response:
record.is_revoked=True
return True
def logout(self,id:UUID)->bool:
with self.Session() as session: # noqa: SIM117
with session.begin():
query=select(RefreshTokens).where(RefreshTokens.id == id)
response=session.scalars(query).one_or_none()
if response is None:
return False
else:
response.is_revoked=True
return True

View File

@@ -0,0 +1,30 @@
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from src.models.database_models.model import User, engine
from src.models.pydantic_models.model import UserOutDB
class UsersCrudActions:
def __init__(self) -> None:
self.Session=sessionmaker(bind=engine)
def get_user_by_email(self, email:str)->UserOutDB|None:
with self.Session() as session: # noqa: SIM117
with session.begin():
query=select(User).where(User.email==email)
response=session.scalars(query).one_or_none()
if response is None:
return None
return UserOutDB.model_validate(response)
def get_user_by_id(self, id:UUID)->UserOutDB|None:
with self.Session() as session: # noqa: SIM117
with session.begin():
query=select(User).where(User.id==id)
response=session.scalars(query).one_or_none()
if response is None:
return None
return UserOutDB.model_validate(response)

View File

@@ -11,4 +11,30 @@ class Errors:
raise HTTPException(status_code=403, detail=detail, headers={"Cache-Control": "no-store, max-age=0"})
def not_found_error(self, detail:str)->HTTPException:
raise HTTPException(status_code=404, detail=detail, headers={"Cache-Control": "no-store, max-age=0"})
raise HTTPException(status_code=404, detail=detail, headers={"Cache-Control": "no-store, max-age=0"})
def conflict_error(self, detail:str)->HTTPException:
raise HTTPException(status_code=409, detail=detail)
def bad_request_error(self, detail:str) -> HTTPException:
raise HTTPException(status_code=400, detail=detail)
def validation_error(self, detail:str) -> HTTPException:
raise HTTPException(status_code=422, detail=detail)
def rate_limit_error(self, detail:str, retry_after: int = 60) -> HTTPException:
raise HTTPException(
status_code=429,
detail=detail,
headers={"Retry-After":str(retry_after)}
)
def internal_server_error(self, detail:str = "Internal server error") -> HTTPException:
raise HTTPException(status_code=500, detail=detail)
def service_unavailable_error(self, detail:str, retry_after: int = 30) -> HTTPException:
raise HTTPException(
status_code=503,
detail=detail,
headers={"Retry-After":str(retry_after)}
)

View File

@@ -0,0 +1,32 @@
"""empty message
Revision ID: 23dd6d3efe4b
Revises: 2f92088cdce4
Create Date: 2026-07-23 13:05:12.553687
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '23dd6d3efe4b'
down_revision: Union[str, Sequence[str], None] = '2f92088cdce4'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""empty message
Revision ID: 2f92088cdce4
Revises: 75074097a2a3
Create Date: 2026-07-23 11:05:02.409697
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '2f92088cdce4'
down_revision: Union[str, Sequence[str], None] = '75074097a2a3'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@@ -0,0 +1,50 @@
"""empty message
Revision ID: 385d4efec15f
Revises: 23dd6d3efe4b
Create Date: 2026-07-23 15:03:33.582896
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '385d4efec15f'
down_revision: Union[str, Sequence[str], None] = '23dd6d3efe4b'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('refresh_tokens', schema=None) as batch_op:
batch_op.alter_column('id',
existing_type=sa.INTEGER(),
type_=sa.Uuid(),
existing_nullable=False)
batch_op.alter_column('replaced_by',
existing_type=sa.INTEGER(),
type_=sa.Uuid(),
existing_nullable=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('refresh_tokens', schema=None) as batch_op:
batch_op.alter_column('replaced_by',
existing_type=sa.Uuid(),
type_=sa.INTEGER(),
existing_nullable=True)
batch_op.alter_column('id',
existing_type=sa.Uuid(),
type_=sa.INTEGER(),
existing_nullable=False)
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""empty message
Revision ID: 74814eb1b7f8
Revises: 8c136ff14180
Create Date: 2026-07-23 21:06:37.254211
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '74814eb1b7f8'
down_revision: Union[str, Sequence[str], None] = '8c136ff14180'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""empty message
Revision ID: 8c136ff14180
Revises: 385d4efec15f
Create Date: 2026-07-23 17:56:26.345954
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '8c136ff14180'
down_revision: Union[str, Sequence[str], None] = '385d4efec15f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@@ -1,5 +1,6 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Base(BaseSettings):
pass

View File

@@ -1,7 +1,10 @@
from src.models.database_models.model import Model
from uuid import UUID, uuid1
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from uuid import UUID, uuid1
from src.models.database_models.model import Model
class AccountantSettings(Model):
__tablename__="accountant_settings"

View File

@@ -1,9 +1,11 @@
from datetime import datetime
from sqlalchemy import TIMESTAMP, String, func, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.database_models.model import Model
from uuid import UUID, uuid1
from sqlalchemy import TIMESTAMP, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.database_models.model import Model
class Stored(Model):
__tablename__="reports"
@@ -11,9 +13,9 @@ class Stored(Model):
id:Mapped[int]=mapped_column(primary_key=True, index=True)
doc_id:Mapped[UUID]=mapped_column(default=uuid1, unique=True)
filename:Mapped[str]=mapped_column(String(255), index=True)
created_at:Mapped[datetime]=mapped_column(TIMESTAMP, server_default=func.now())
uploaded_at:Mapped[datetime]=mapped_column(TIMESTAMP,onupdate=func.now(), nullable=True)
doc_date:Mapped[datetime]=mapped_column(TIMESTAMP)
created_at:Mapped[datetime]=mapped_column(TIMESTAMP(timezone=True), server_default=func.now())
uploaded_at:Mapped[datetime]=mapped_column(TIMESTAMP(timezone=True),onupdate=func.now(), nullable=True)
doc_date:Mapped[datetime]=mapped_column(TIMESTAMP(timezone=True))
status:Mapped[str]=mapped_column(String(64))
user_id:Mapped[UUID]=mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
market_id:Mapped[int]=mapped_column(ForeignKey("markets.id", ondelete="CASCADE"),index=True)

View File

@@ -1,8 +1,11 @@
from decimal import Decimal
from src.models.database_models.model import Model
from sqlalchemy import Boolean, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.database_models.model import Model
class Nomenclature(Model):
__tablename__="goods"

View File

@@ -1,7 +1,20 @@
from sqlalchemy import TIMESTAMP, Table, create_engine, String, Boolean, MetaData, Column, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column, DeclarativeBase, relationship
from uuid import UUID, uuid4
from datetime import datetime
from uuid import UUID, uuid4
from sqlalchemy import (
TIMESTAMP,
Boolean,
Column,
ForeignKey,
MetaData,
String,
Table,
Uuid,
create_engine,
func,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
engine = create_engine("sqlite:///DB/database.db", echo=True)
'''remember as a boilerplate, or just cp/pst'''
@@ -17,7 +30,7 @@ class Model(DeclarativeBase):
class User(Model):
__tablename__ = "users"
id:Mapped[UUID] = mapped_column(default=uuid4,primary_key=True)
id:Mapped[UUID] = mapped_column(Uuid(as_uuid=True),default=uuid4,primary_key=True)
first_name:Mapped[str] = mapped_column(String(64), index=True)
last_name:Mapped[str]=mapped_column(String(64), index=True)
middle_name:Mapped[str]=mapped_column(String(64), index=True)
@@ -27,10 +40,11 @@ class User(Model):
group:Mapped[list['PermissionsGroups']]=relationship(secondary="user_group", back_populates="user", lazy="selectin")
direct_permissions:Mapped[list['Permissions']]=relationship(secondary="user_direct_permissions", back_populates="users_direct")
direct_permissions:Mapped[list['Permissions']]=relationship(secondary="user_direct_permissions", back_populates="users_direct", lazy="selectin")
refresh_token:Mapped[list['RefreshTokens']]=relationship(back_populates="user")
report:Mapped[list["Stored"]]=relationship(back_populates="user")
def __repr__(self) -> str:
return f"ID: {self.id}, Name: {self.first_name}, Status: {self.status}"
@@ -84,16 +98,16 @@ class RefreshTokens(Model):
__tablename__= "refresh_tokens"
id:Mapped[int]=mapped_column(primary_key=True, index=True)
id:Mapped[UUID]=mapped_column(Uuid(as_uuid=True),primary_key=True, index=True)
user_id:Mapped[UUID]=mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
token_hash:Mapped[str]=mapped_column(String(255), unique=True)
device_info:Mapped[str]=mapped_column(String(255))
ip_address:Mapped[str]=mapped_column(String(45))
is_revoked:Mapped[bool]=mapped_column(Boolean, default=False)
expires_at:Mapped[datetime]=mapped_column(TIMESTAMP)
created_at:Mapped[datetime]=mapped_column(TIMESTAMP, server_default=func.now())
replaced_by:Mapped[int|None]=mapped_column(ForeignKey("refresh_tokens.id"), nullable=True, default=None)
expires_at:Mapped[datetime]=mapped_column(TIMESTAMP(timezone=True))
created_at:Mapped[datetime]=mapped_column(TIMESTAMP(timezone=True), server_default=func.now())
replaced_by:Mapped[UUID|None]=mapped_column(ForeignKey("refresh_tokens.id"), nullable=True, default=None)
user:Mapped["User"]=relationship(back_populates="refresh_token")

View File

@@ -1,7 +1,11 @@
from typing import Annotated
from src.models.pydantic_models.model import Base
from pydantic import Field
from uuid import UUID
from pydantic import Field
from src.models.pydantic_models.model import Base
class AccountantCreate(Base):
name:Annotated[str, Field(...,max_length=64,description="name of the accountant setting")]

View File

@@ -1,9 +1,12 @@
from datetime import datetime
from typing import Annotated
from uuid import UUID
from src.models.pydantic_models.model import Base
from pydantic import Field
from src.models.pydantic_models.model import Base
class ReportCreate(Base):
filename:Annotated[str,Field(..., min_length=2, max_length=255, description="name of the report")]
doc_date:Annotated[datetime, Field(..., description="ts of the report")]

View File

@@ -1,8 +1,11 @@
from decimal import Decimal
from src.models.pydantic_models.model import Base
from pydantic import Field
from typing import Annotated
from pydantic import Field
from src.models.pydantic_models.model import Base
class NomenclatureCreate(Base):
article:Annotated[str, Field(...,min_length=5,max_length=16, description="name of the article")]

View File

@@ -1,50 +1,21 @@
from datetime import datetime
from typing import Annotated
from pydantic import BaseModel, EmailStr, Field
from uuid import UUID
from pydantic import BaseModel, EmailStr, Field
class Base(BaseModel):
model_config = {"from_attributes": True}
class UserCreate(Base):
first_name:Annotated[str, Field(..., max_length=64,description="first name of the user")]
last_name:Annotated[str, Field(...,max_length=64, description="last name of the user")]
middle_name:Annotated[str, Field(...,max_length=64, description="middle name of the user")]
email:Annotated[EmailStr, Field(...,min_length=5, max_length=255, description="email of the user")]
plain_password:Annotated[str, Field(...,min_length=8,max_length=72, description="plain password of the user")]
status:Annotated[bool, Field(..., description="status of the user")]
permissions:Annotated[list[str], Field(..., description="permissions of the user")]
permission_groups:Annotated[list[str], Field(..., description="permissions groups of the user")]
class UserOut(Base):
id:Annotated[UUID, Field(..., description="Id of the user")]
first_name:Annotated[str, Field(..., max_length=64,description="first name of the user")]
last_name:Annotated[str, Field(..., max_length=64,description="last name of the user")]
middle_name:Annotated[str, Field(..., max_length=64, description="middle name of the user")]
email:Annotated[EmailStr, Field(...,min_length=5, max_length=255, description="email of the user")]
status:Annotated[bool, Field(..., description="status of the user")]
permissions:Annotated[list[str], Field(..., description="permissions of the user")]
permission_groups:Annotated[list[str], Field(..., description="permissions groups of the user")]
class UserUpdate(Base):
first_name:Annotated[str|None, Field(None, max_length=64, description="first name of the user")]
last_name:Annotated[str|None, Field(None, max_length=64,description="last name of the user")]
middle_name:Annotated[str|None, Field(None, max_length=64,description="middle name of the user")]
email:Annotated[EmailStr|None, Field(None, min_length=5, max_length=255, description="email of the user")]
status:Annotated[bool|None, Field(None, description="status of the user")]
permissions:Annotated[list[str]|None, Field(None, description="permissions of the user")]
permission_groups:Annotated[list[str]|None, Field(None, description="permissions groups of the user")]
class PermissionsCreate(Base):
permission:Annotated[str, Field(..., max_length=30, description="permission name")]
class PermissionsOut(Base):
id:Annotated[int, Field(..., description="id of the permission")]
permission:Annotated[str, Field(..., max_length=30, description="permission name")]
@@ -55,18 +26,61 @@ class PermissionsGroupsCreate(Base):
class PermissionsGroupsOut(Base):
id:Annotated[int, Field(..., description="id of the permission group")]
group:Annotated[str, Field(..., max_length=255, description="group name for the permissions")]
class UserCreate(Base):
first_name:Annotated[str, Field(..., max_length=64,description="first name of the user")]
last_name:Annotated[str, Field(...,max_length=64, description="last name of the user")]
middle_name:Annotated[str, Field(...,max_length=64, description="middle name of the user")]
email:Annotated[EmailStr, Field(...,min_length=5, max_length=255, description="email of the user")]
plain_password:Annotated[str, Field(...,min_length=8,max_length=72, description="plain password of the user")]
status:Annotated[bool, Field(..., description="status of the user")]
direct_permissions:Annotated[list[str], Field(..., description="permissions of the user")]
group:Annotated[list[str], Field(..., description="permissions groups of the user")]
class UserOut(Base):
first_name:Annotated[str, Field(..., max_length=64,description="first name of the user")]
last_name:Annotated[str, Field(..., max_length=64,description="last name of the user")]
middle_name:Annotated[str, Field(..., max_length=64, description="middle name of the user")]
email:Annotated[EmailStr, Field(...,min_length=5, max_length=255, description="email of the user")]
direct_permissions:Annotated[list[PermissionsOut], Field(..., description="permissions of the user")]
group:Annotated[list[PermissionsGroupsOut], Field(..., description="permissions groups of the user")]
class UserOutDB(UserOut):
id:Annotated[UUID, Field(..., description="Id of the user")]
status:Annotated[bool, Field(..., description="status of the user")]
hashed_password:Annotated[str, Field(..., description="hashed password of the user")]
class UserUpdate(Base):
first_name:Annotated[str|None, Field(None, max_length=64, description="first name of the user")]
last_name:Annotated[str|None, Field(None, max_length=64,description="last name of the user")]
middle_name:Annotated[str|None, Field(None, max_length=64,description="middle name of the user")]
email:Annotated[EmailStr|None, Field(None, min_length=5, max_length=255, description="email of the user")]
status:Annotated[bool|None, Field(None, description="status of the user")]
direct_permissions:Annotated[list[str]|None, Field(None, description="permissions of the user")]
group:Annotated[list[str]|None, Field(None, description="permissions groups of the user")]
class RefreshTokensCreate(Base):
id:Annotated[UUID, Field(..., description="jti")]
user_id:Annotated[UUID, Field(..., description="foreign key for the user")]
token_hash:Annotated[str, Field(...,max_length=255, description="token hash")]
device_info:Annotated[str, Field(...,max_length=255, description="User device info")]
ip_address:Annotated[str, Field(...,max_length=45, description="ip v4/v6 of the user")]
is_revoked:Annotated[bool|None, Field(None, description="revoke token if logout was made")]
expires_at:Annotated[datetime, Field(..., description="when token is going to be expired")]
class RefreshTokensUpdate(Base):
is_revoked:Annotated[bool, Field(..., description="revoke token if logout was made")]
replaced_by:Annotated[UUID, Field(...,description="old_jti")]
class RefreshTokensOut(Base):
user_id:Annotated[UUID, Field(..., description="foreign key for the user")]
@@ -75,3 +89,7 @@ class RefreshTokensOut(Base):
ip_address:Annotated[str, Field(...,max_length=45, description="ip v4/v6 of the user")]
is_revoked:Annotated[bool|None, Field(None, description="revoke token if logout was made")]
expires_at:Annotated[datetime, Field(..., description="when token is going to be expired")]
replaced_by:Annotated[UUID|None, Field(..., description="Old refresh token")]
class RefreshRequest(Base):
refresh_token:str

189
src/service/auth/auth.py Normal file
View File

@@ -0,0 +1,189 @@
from datetime import UTC, datetime, timedelta
from uuid import UUID
from fastapi import Request
from src.database.auth.refresh_tokens import JwtCrudActions
from src.database.users.crud import UsersCrudActions
from src.errors.http_errors.errors import Errors
from src.models.configs_read.env import env_settings
from src.models.pydantic_models.model import RefreshTokensCreate, UserOut
from .jwt import Hashes, Jwt
class CurrentUser:
def __init__(self) -> None:
self.jwt_service=Jwt()
self.hash=Hashes()
self.crud_db_actions=UsersCrudActions()
self.jwt_db_actions=JwtCrudActions()
self.error=Errors()
def _check(self, form_data_email:str, form_data_password:str,):
'''check user by email'''
user=self.crud_db_actions.get_user_by_email(form_data_email)
if user is None:
raise self.error.credentials_error(detail="Wrong credentials")
if not self.hash.verify_password(plain_password=form_data_password, hashed_password=user.hashed_password):
raise self.error.credentials_error(detail="Wrong credentials")
if user.status is False:
raise self.error.credentials_error(detail="This user is deactivated")
return user
def get_current_user(self, token:str)->UserOut:
payload=self.jwt_service.jwt_decode(token)
sub=payload.get("sub")
try:
sub=UUID(sub)
except (ValueError, TypeError) as e:
raise self.error.credentials_error(detail="Jwt token is incorrect") from e
user=self.crud_db_actions.get_user_by_id(sub)
if user is None:
raise self.error.not_found_error(detail="User with this email address not found")
if user.status is False:
raise self.error.credentials_error(detail="This user is deactivated")
return UserOut.model_validate(user)
def create_access_token(self, user_id:UUID)->str:
'''create new access token if all the checks are successful'''
return self.jwt_service.create_access_token({"sub":str(user_id)})
def create_refresh_token(self,user_id:UUID, request:Request)->str:
token, jti=self.jwt_service.create_refresh_token({"sub":str(user_id)})
try:
jti=UUID(jti)
except (ValueError, TypeError) as e:
raise self.error.credentials_error(detail="Jwt token is incorrect") from e
'''create new refresh token if all the checks are successful'''
token_record=RefreshTokensCreate(
id=jti,
user_id=user_id,
token_hash=self.hash.token_to_hash(token),
device_info=request.headers.get("user-agent", "unknown"),
ip_address=request.headers.get("x-forwarded-for", "").split(",")[0].strip() or (request.client.host if request.client else "unknown"),
expires_at=datetime.now(UTC)+timedelta(days=env_settings.REFRESH_TOKEN_EXPIRE_DAYS)
)
self.jwt_db_actions.create_token(RefreshTokensCreate.model_dump(token_record))
return token
def refresh_token(self, refresh_token:str, request:Request)->tuple[str, str]:
'''decode old refresh token'''
old_refresh_token=self.jwt_service.jwt_decode(refresh_token)
sub=old_refresh_token.get("sub")
if (old_jti:=old_refresh_token.get("jti")) is None:
raise self.error.credentials_error(detail="Jwt token is incorrect")
try:
old_jti=UUID(old_jti)
sub=UUID(sub)
except (ValueError, TypeError) as e:
raise self.error.credentials_error(detail="Jwt token is incorrect") from e
'''old refresh token check'''
old_record=self.jwt_db_actions.get_token_by_id(old_jti)
if old_record is None:
raise self.error.not_found_error(detail="Token not found")
if old_record.is_revoked:
self.jwt_db_actions.revoke_all(old_record.user_id)
raise self.error.credentials_error(detail="Reuse token detected")
'''sqlite constraints about timezone'''
expires_at=old_record.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=UTC)
if expires_at<datetime.now(UTC):
raise self.error.credentials_error(detail="Token expired")
'''user check'''
user = self.crud_db_actions.get_user_by_id(sub)
if user is None:
raise self.error.not_found_error(detail="User not found")
if user.status is False:
raise self.error.credentials_error(detail="This user is deactivated")
'''create new refresh token if all the checks are successful'''
new_refresh_token, new_jti=self.jwt_service.create_refresh_token({"sub":str(sub)})
new_access_token=self.create_access_token(user_id=sub)
try:
new_jti=UUID(new_jti)
except (ValueError, TypeError):
raise self.error.credentials_error(detail="Jwt token is incorrect")
'''create database record with the new token'''
new_token_record=RefreshTokensCreate(
id=new_jti,
user_id=sub,
token_hash=self.hash.token_to_hash(new_refresh_token),
device_info=request.headers.get("user-agent", "unknown"),
ip_address=request.headers.get("x-forwarded-for", "").split(",")[0].strip() or (request.client.host if request.client else "unknown"),
expires_at=datetime.now(UTC)+timedelta(days=env_settings.REFRESH_TOKEN_EXPIRE_DAYS),
)
self.jwt_db_actions.create_and_update_token(RefreshTokensCreate.model_dump(new_token_record), old_jti, new_jti)
return (new_access_token,new_refresh_token)
def logout(self, refresh_token:str)->bool:
'''decode current refresh token'''
payload=self.jwt_service.jwt_decode(refresh_token)
if (jti:=payload.get("jti")) is None:
raise self.error.credentials_error(detail="Jwt token is incorrect")
try:
jti=UUID(jti)
except (ValueError, TypeError) as e:
raise self.error.credentials_error(detail="Jwt token is incorrect") from e
current_token = self.jwt_db_actions.get_token_by_id(jti)
if current_token is None:
raise self.error.not_found_error(detail="Refresh Token Not Found")
'''logout by assigning revoked flag'''
return self.jwt_db_actions.logout(jti)
def login(self, form_data_email:str, form_data_password:str, request:Request)->tuple[str, str]:
'''revoke all the old refresh tokens'''
user = self._check(form_data_email, form_data_password)
self.jwt_db_actions.revoke_all(user_id=user.id)
'''create access and refresh tokens'''
access_token=self.create_access_token(user_id=user.id)
refresh_token=self.create_refresh_token(user_id=user.id,request=request)
return (access_token, refresh_token)
auth=CurrentUser()

71
src/service/auth/jwt.py Normal file
View File

@@ -0,0 +1,71 @@
import hashlib
from datetime import UTC, datetime, timedelta
from uuid import uuid4
import bcrypt
from jose import JWTError, jwt
from src.errors.http_errors.errors import Errors
from src.models.configs_read.env import env_settings
'''Hash/Check hash'''
class Hashes:
def __init__(self) -> None:
pass
def plain_to_hash(self, plain_password:str)->str:
return bcrypt.hashpw(plain_password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(self, plain_password:str, hashed_password:str)->bool:
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
def token_to_hash(self, token:str)->str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
'''jwt'''
class Jwt:
def __init__(self) -> None:
self.error=Errors()
def create_access_token(self, data:dict)->str:
user_info=data.copy()
if not (user_info.get("sub")) or user_info.get("sub") == "":
raise self.error.credentials_error(detail="Jwt token is incorrect")
user_info.update({"exp": datetime.now(UTC)+timedelta(minutes=env_settings.ACCESS_TOKEN_EXPIRE_MINUTES),
"token_type":"access"})
return jwt.encode(user_info, env_settings.SECRET_KEY, env_settings.ALGORITHM)
def create_refresh_token(self, data:dict)->tuple[str, str]:
user_info=data.copy()
jti=str(uuid4())
if not (user_info.get("sub")) or user_info.get("sub") == "":
raise self.error.credentials_error(detail="Jwt token is incorrect")
user_info.update({"exp":datetime.now(UTC)+timedelta(days=env_settings.REFRESH_TOKEN_EXPIRE_DAYS),
"token_type":"refresh",
"jti":jti
})
return jwt.encode(user_info, env_settings.SECRET_KEY, env_settings.ALGORITHM), jti
def jwt_decode(self, token:str)->dict:
try:
payload=jwt.decode(token, env_settings.SECRET_KEY, algorithms=[env_settings.ALGORITHM], options={"require_exp": True} )
if not (payload.get("sub")) or not (payload.get("token_type")):
raise self.error.credentials_error(detail="Jwt token is incorrect")
except JWTError as e:
raise self.error.credentials_error(detail="JWTerror") from e
return payload

View File

@@ -1 +0,0 @@
'''unit, integr, e2e tests'''

View File

@@ -1,9 +1,55 @@
from fastapi import APIRouter
from fastapi.security import OAuth2PasswordRequestForm, OAuth2PasswordBearer
from fastapi import APIRouter, Cookie, Depends, Request, Response
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from src.models.configs_read.env import env_settings
from src.models.pydantic_models.model import UserOut
from src.service.auth.auth import auth
router=APIRouter(prefix="/protected")
oauth2_scheme=OAuth2PasswordBearer(tokenUrl="/protected/token")
oauth2_schema=OAuth2PasswordBearer(tokenUrl="/protected/token", refreshUrl="/protected/refresh")
@router.post("/token")
async def get_access_token(request: Request,response:Response, form_data:OAuth2PasswordRequestForm=Depends())->dict: # noqa: B008
access_token, refresh_token=auth.login(form_data_email=form_data.username, form_data_password=form_data.password, request=request)
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
secure=True,
samesite="strict",
max_age=env_settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
)
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/refresh")
async def get_refresh_token(request:Request,response:Response, refresh_token: str = Cookie())->dict:
access_token, refresh_token= auth.refresh_token(refresh_token=refresh_token,request=request)
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
secure=True,
samesite="strict",
max_age=env_settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
)
return {"access_token":access_token, "token_type": "bearer"}
async def get_current_user(token:str = Depends(oauth2_schema)) -> UserOut:
return UserOut.model_validate(auth.get_current_user(token))
@router.get("/logout")
async def logout(response:Response,refresh_token: str = Cookie(),current_user:UserOut=Depends(get_current_user))->bool: # noqa: B008
response.delete_cookie("refresh_token")
return auth.logout(refresh_token)
@router.get("")
def protected()->dict:
return {"protected router": "Hello, this is a protected router"}
async def protected(current_user:UserOut=Depends(get_current_user))->dict: # noqa: B008
return {"protected router": "Hello, this is a protected router"}

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
'''unit, integr, e2e tests'''

14
tests/conftest.py Normal file
View File

@@ -0,0 +1,14 @@
import pytest
from src.service.auth.jwt import Hashes, Jwt
@pytest.fixture
def jwt_service()->Jwt:
jwt_service=Jwt()
return jwt_service
@pytest.fixture
def hash_service()->Hashes:
hash_service=Hashes()
return hash_service

0
tests/e2e/conftest.py Normal file
View File

View File

View File

@@ -0,0 +1,16 @@
import pytest
from fastapi import Request
from src.service.auth.auth import CurrentUser
@pytest.fixture
def current_user_service()->CurrentUser:
current_user_service=CurrentUser()
return current_user_service
@pytest.fixture
def requests(mocker):
fake_request = mocker.MagicMock(spec=Request)
fake_request.headers = {"user-agent": "pytest-agent", "x-forwarded-for":"127.0.0.1"}
return fake_request

View File

@@ -0,0 +1,216 @@
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from uuid import uuid4
import allure
import pytest
from fastapi import HTTPException, Request
from jose import jwt
from pydantic import ValidationError
from src.models.configs_read.env import env_settings
from src.service.auth.auth import CurrentUser
from src.service.auth.jwt import Hashes, Jwt
@pytest.mark.integra
class TestAuth:
@pytest.mark.parametrize("user_data",[
pytest.param(SimpleNamespace(first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=True), id="correct_data")
])
def test_get_current_user_positive(self,current_user_service:CurrentUser, jwt_service:Jwt, monkeypatch, user_data:SimpleNamespace)->None:
with allure.step("create token"):
token=jwt_service.create_access_token({"sub":str(uuid4())})
with allure.step("patching db call functions"):
monkeypatch.setattr(current_user_service.crud_db_actions, "get_user_by_id", lambda user:user_data)
with allure.step("test get_current_user_with_fake_token"):
test_result= current_user_service.get_current_user(token)
assert test_result.first_name==user_data.first_name
assert test_result.last_name==user_data.last_name
assert test_result.middle_name==user_data.middle_name
assert test_result.email==user_data.email
assert test_result.direct_permissions==user_data.direct_permissions
assert test_result.group==user_data.group
@pytest.mark.parametrize("user_data, uuid, expected_exception",[
pytest.param(SimpleNamespace(first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=False), uuid4(), HTTPException, id="false_status"),
pytest.param(SimpleNamespace(first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=True),1234, HTTPException, id="wrong_id"),
pytest.param(SimpleNamespace(status=True),uuid4(), ValidationError,id="empty_model_data")
])
def test_get_current_user_negative(self,current_user_service:CurrentUser, jwt_service:Jwt, monkeypatch, user_data:SimpleNamespace, expected_exception, uuid)->None:
with allure.step("create token"):
token=jwt_service.create_access_token({"sub":str(uuid)})
with allure.step("patching db call functions"):
monkeypatch.setattr(current_user_service.crud_db_actions, "get_user_by_id", lambda user:user_data)
with allure.step("test get_current_user_with_fake_token"), pytest.raises(expected_exception):
current_user_service.get_current_user(token)
@pytest.mark.parametrize("user_data, form_data_email,form_data_password",[
pytest.param(SimpleNamespace(id=uuid4(),hashed_password="1234", first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=True), "d@d.d", "1234", id="correct_data"),
])
def test_login_positive(self, jwt_service:Jwt,current_user_service:CurrentUser, monkeypatch, user_data:SimpleNamespace, hash_service:Hashes, form_data_email:str, form_data_password:str, requests)->None:
with allure.step("patching db call functions"):
user_data.hashed_password=hash_service.plain_to_hash(user_data.hashed_password)
monkeypatch.setattr(current_user_service.crud_db_actions, "get_user_by_email", lambda user:user_data)
monkeypatch.setattr(current_user_service.jwt_db_actions, "revoke_all", lambda user_id: True)
monkeypatch.setattr(current_user_service.jwt_db_actions, "create_token", lambda token: True )
fake_request = requests
with allure.step("test login_with_fake_data"):
access, refresh=current_user_service.login(form_data_email, form_data_password,fake_request)
parts_a=access.split(".")
parts_b=refresh.split(".")
assert isinstance(access, str)
assert len(parts_a)==3
assert isinstance(refresh, str)
assert len(parts_b)==3
@pytest.mark.parametrize("user_data, form_data_email,form_data_password, expected_exception",[
pytest.param(SimpleNamespace(id=uuid4(),hashed_password="1234", first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=True), "d@d.d", "wrong_password", HTTPException, id="wrong_password"),
pytest.param(SimpleNamespace(id=uuid4(),hashed_password="1234", first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=False), "d@d.d", "1234",HTTPException, id="false_status"),
pytest.param(SimpleNamespace(id=1234,hashed_password="1234", first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=True), "d@d.d", "1234",ValidationError, id="wrong_id"),
])
def test_login_negative(self, current_user_service:CurrentUser, user_data:SimpleNamespace, jwt_service:Jwt, monkeypatch, requests, hash_service:Hashes, form_data_email:str, form_data_password:str, expected_exception):
with allure.step("patching db call functions"):
user_data.hashed_password=hash_service.plain_to_hash(user_data.hashed_password)
monkeypatch.setattr(current_user_service.crud_db_actions, "get_user_by_email", lambda user:user_data)
monkeypatch.setattr(current_user_service.jwt_db_actions, "revoke_all", lambda user_id: True)
monkeypatch.setattr(current_user_service.jwt_db_actions, "create_token", lambda token: True )
fake_request = requests
with allure.step("test login_with_fake_data"), pytest.raises(expected_exception):
current_user_service.login(form_data_email, form_data_password,fake_request)
def test_logout_positive(self, jwt_service:Jwt, monkeypatch, current_user_service:CurrentUser)->None:
with allure.step("create fake refresh token"):
token=jwt_service.create_refresh_token({"sub":str(uuid4())})
with allure.step("patching db call functions"):
monkeypatch.setattr(current_user_service.jwt_db_actions, "get_token_by_id", lambda jti: "test")
monkeypatch.setattr(current_user_service.jwt_db_actions, "logout", lambda jti: True )
with allure.step("test logout with fake data"):
status=current_user_service.logout(token[0])
assert status is True
@pytest.mark.parametrize("jti,db_result, expected_exception",[
pytest.param(None, True, HTTPException, id="jti_none"),
pytest.param(1234, True, HTTPException, id="jti_int"),
pytest.param(str(uuid4()), None, HTTPException, id="db_result_none"),
])
def test_logout_negative(self, jwt_service:Jwt, monkeypatch, current_user_service:CurrentUser, expected_exception, jti, db_result)->None:
with allure.step("patching db call functions"):
monkeypatch.setattr(current_user_service.jwt_db_actions, "get_token_by_id", lambda jti: db_result)
monkeypatch.setattr(current_user_service.jwt_db_actions, "logout", lambda jti: True )
def fake_create_refresh_token(data:dict)->str:
return jwt.encode(data, env_settings.SECRET_KEY, env_settings.ALGORITHM)
monkeypatch.setattr(jwt_service, "create_refresh_token", fake_create_refresh_token)
with allure.step("create fake refresh token"):
token=fake_create_refresh_token({"sub":str(uuid4()), "jti":jti, "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)})
with allure.step("test logout with fake data"), pytest.raises(expected_exception):
current_user_service.logout(token)
@pytest.mark.parametrize("db_result_token, user_data_result_db", [
pytest.param(SimpleNamespace(is_revoked=False, expires_at=datetime.now(UTC)+timedelta(days=15)),SimpleNamespace(status=True), id="correct_data")
])
def test_refresh_token_positive(self, monkeypatch, current_user_service:CurrentUser, db_result_token, requests:Request, jwt_service:Jwt,user_data_result_db )->None:
with allure.step("patching db call functions"):
monkeypatch.setattr(current_user_service.jwt_db_actions,"get_token_by_id", lambda jti: db_result_token)
monkeypatch.setattr(current_user_service.jwt_db_actions, "revoke_all", lambda data:True)
monkeypatch.setattr(current_user_service.crud_db_actions, "get_user_by_id", lambda user:user_data_result_db)
monkeypatch.setattr(current_user_service.jwt_db_actions,"create_and_update_token", lambda data0, data1, data2: True)
fake_request = requests
def fake_create_refresh_token(data:dict)->str:
return jwt.encode(data, env_settings.SECRET_KEY, env_settings.ALGORITHM)
monkeypatch.setattr(jwt_service, "create_refresh_token", fake_create_refresh_token)
with allure.step("create fake refresh token"):
token=fake_create_refresh_token({"sub":str(uuid4()), "jti":str(uuid4()), "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)})
with allure.step("test refresh token with fake data"):
new_access_token, new_refresh_token = current_user_service.refresh_token(token, fake_request)
parts_a=new_access_token.split(".")
parts_b=new_refresh_token.split(".")
assert isinstance(new_access_token, str)
assert len(parts_a)==3
assert isinstance(new_refresh_token, str)
assert len(parts_b)==3
assert new_access_token!=new_refresh_token
assert new_access_token!=token
assert new_refresh_token!=token
@pytest.mark.parametrize("db_result_token, user_data_result_db, fake_token_data,expected_exception", [
pytest.param(SimpleNamespace(is_revoked=True,user_id="123", expires_at=datetime.now(UTC)+timedelta(days=15)),SimpleNamespace(status=True),{"sub":str(uuid4()), "jti":str(uuid4()), "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)}, HTTPException,id="false_revoke_status"),
pytest.param(SimpleNamespace(is_revoked=False,user_id="123", expires_at=datetime.now(UTC)+timedelta(days=15)),SimpleNamespace(status=True),{"sub":str(uuid4()), "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)}, HTTPException, id="jti_missing"),
pytest.param(None,SimpleNamespace(status=True),{"sub":str(uuid4()), "jti":str(uuid4()),"token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)}, HTTPException, id="token_missing"),
pytest.param(SimpleNamespace(is_revoked=False,user_id="123", expires_at=datetime.now(UTC)+timedelta(days=15)),SimpleNamespace(status=False),{"sub":str(uuid4()), "jti":str(uuid4()), "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)}, HTTPException,id="false_user_status"),
pytest.param(SimpleNamespace(is_revoked=False, user_id="123",expires_at=datetime.now(UTC)+timedelta(days=15)),None,{"sub":str(uuid4()), "jti":str(uuid4()), "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)}, HTTPException,id="user_missing"),
pytest.param(SimpleNamespace(is_revoked=False,user_id="123", expires_at=datetime.now(UTC)-timedelta(days=15)),SimpleNamespace(status=True),{"sub":str(uuid4()), "jti":str(uuid4()), "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)}, HTTPException,id="wrong_exp")
])
def test_refresh_token_negative(self, monkeypatch, current_user_service:CurrentUser, db_result_token, requests, jwt_service:Jwt,user_data_result_db, expected_exception, fake_token_data)->None:
with allure.step("patching db call functions"):
monkeypatch.setattr(current_user_service.jwt_db_actions,"get_token_by_id", lambda jti: db_result_token)
monkeypatch.setattr(current_user_service.jwt_db_actions, "revoke_all", lambda user_id:True)
monkeypatch.setattr(current_user_service.crud_db_actions, "get_user_by_id", lambda user:user_data_result_db)
monkeypatch.setattr(current_user_service.jwt_db_actions, "create_token", lambda new_token_record:True)
monkeypatch.setattr(current_user_service.jwt_db_actions,"update_token", lambda old_jti, new_jti: True )
fake_request = requests
def fake_create_refresh_token(data:dict)->str:
return jwt.encode(data, env_settings.SECRET_KEY, env_settings.ALGORITHM)
monkeypatch.setattr(jwt_service, "create_refresh_token", fake_create_refresh_token)
with allure.step("create fake refresh token"):
token=fake_create_refresh_token(fake_token_data)
with allure.step("test refresh token with fake data"), pytest.raises(expected_exception):
current_user_service.refresh_token(token, fake_request)

0
tests/unit/__init__.py Normal file
View File

2
tests/unit/conftest.py Normal file
View File

@@ -0,0 +1,2 @@

144
tests/unit/test_jwt.py Normal file
View File

@@ -0,0 +1,144 @@
from datetime import UTC, datetime, timedelta
import allure
import pytest
from fastapi import HTTPException
from jose import jwt
from src.models.configs_read.env import env_settings
from src.service.auth.jwt import Hashes, Jwt
@pytest.mark.unit
class TestJwt:
@pytest.mark.parametrize("data", [
pytest.param({"sub": "123"}, id="full_sub")
])
def test_access_create_positive(self, jwt_service:Jwt, data:dict)->None:
with allure.step("create correct access token"):
token = jwt_service.create_access_token(data)
parts=token.split(".")
assert isinstance(token, str)
assert len(parts)==3
@pytest.mark.parametrize("data, expected_exception",[
pytest.param("", AttributeError,id="not_dict_value"),
pytest.param({"sub":""},HTTPException, id="empty_value"),
pytest.param({"":""},HTTPException, id="empty_key_value")
])
def test_access_create_negative(self, jwt_service:Jwt, data:dict, expected_exception)->None:
with allure.step("create invalid access token"),pytest.raises(expected_exception):
jwt_service.create_access_token(data)
@pytest.mark.parametrize("data", [
pytest.param({"sub": "123"}, id="full_sub")
])
def test_refresh_create_positive(self, jwt_service:Jwt, data:dict)->None:
with allure.step("create correct access token"):
token = jwt_service.create_refresh_token(data)
parts=token[0].split(".")
assert isinstance(token[0], str)
assert isinstance(token[1], str)
assert len(parts)==3
@pytest.mark.parametrize("data, expected_exception",[
pytest.param("", AttributeError,id="not_dict_value"),
pytest.param({"sub":""},HTTPException, id="empty_value"),
pytest.param({"":""},HTTPException, id="empty_key_value")
])
def test_refresh_create_negative(self, jwt_service:Jwt, data, expected_exception)->None:
with allure.step("create invalid access token"), pytest.raises(expected_exception):
jwt_service.create_refresh_token(data)
@pytest.mark.parametrize("data", [
pytest.param({"sub": "123", "exp":datetime.now(UTC)+timedelta(minutes=15), "token_type":"access"}, id="correct_data"),
])
def test_jwt_decode_positive(self, data:dict, monkeypatch, jwt_service:Jwt)->None:
with allure.step("patch a create token function"):
def fake_create_access_token(data:dict)->str:
return jwt.encode(data, env_settings.SECRET_KEY, env_settings.ALGORITHM)
monkeypatch.setattr(jwt_service, "create_access_token", fake_create_access_token)
with allure.step("create and decode correct token"):
fake_token = jwt_service.create_access_token(data)
payload=jwt_service.jwt_decode(fake_token)
assert payload.get("sub")
assert payload.get("exp")
assert payload.get("token_type")
@pytest.mark.parametrize("data, expected_exception", [
pytest.param({"sub": "123", "exp":datetime.now(UTC)-timedelta(minutes=15), "token_type":"access"}, HTTPException, id="wrong_exp"),
pytest.param({"sub": "123", "exp":datetime.now(UTC)+timedelta(minutes=15)}, HTTPException, id="no_token_type"),
pytest.param({"sub": "123", "token_type":"access"}, HTTPException,id="no_exp"),
pytest.param({}, HTTPException, id="empty_data"),
pytest.param("", AttributeError, id="not_dict_data")
])
def test_jwt_decode_invalid(self,jwt_service:Jwt, expected_exception, data, monkeypatch)->None:
with allure.step("patch a create token function"):
def fake_create_access_token(data:dict)->str:
return jwt.encode(data, env_settings.SECRET_KEY, env_settings.ALGORITHM)
monkeypatch.setattr(jwt_service, "create_access_token", fake_create_access_token)
with allure.step("create and decode invalid token"), pytest.raises(expected_exception):
fake_token=jwt_service.create_access_token(data)
jwt_service.jwt_decode(fake_token)
@pytest.mark.parametrize("time, key, algorithm", [
pytest.param(15, "wrong_key", "HS256",id="wrong_key"),
pytest.param(-15, "correct_key", "HS256", id="wrong_time"),
pytest.param(15, "correct_key", "HS512", id="wrong_algorithm"),
])
def test_jwt_decode_wrong_env(self, monkeypatch, time:int, key:str, algorithm:str, jwt_service)->None:
with allure.step("patch a create token function"):
def fake_create_access_token(data:dict, key:str, algorithm:str)->str:
data.update({"exp":datetime.now(UTC)+timedelta(minutes=time)})
return jwt.encode(data, key, algorithm)
monkeypatch.setattr(jwt_service, "create_access_token", fake_create_access_token)
with allure.step("create and decode token with wrong data inside"), pytest.raises(HTTPException):
fake_token=jwt_service.create_access_token({"sub": "123", "token_type":"access"}, key, algorithm)
jwt_service.jwt_decode(fake_token)
@pytest.mark.parametrize("password",[
pytest.param("plain_password", id="correct_plain_password")
])
def test_hash_and_veryfy_positive(self, password:str, hash_service:Hashes)->None:
with allure.step("encode password"):
encoded_password=hash_service.plain_to_hash(password)
assert isinstance(encoded_password, str)
assert encoded_password!=password
with allure.step("decode password"):
assert hash_service.verify_password(password, encoded_password) is True
def test_verify_wrong_password(self, hash_service:Hashes)->None:
with allure.step("encode password"):
encoded_password=hash_service.plain_to_hash("plain_password")
with allure.step("decode password"):
assert hash_service.verify_password("wrong_password", encoded_password) is False
def test_token_to_hash_determistic(self, hash_service:Hashes)->None:
assert hash_service.token_to_hash("abc")==hash_service.token_to_hash("abc")
def test_token_to_hash_different_input(self, hash_service:Hashes)->None:
assert hash_service.token_to_hash("abc")!=hash_service.token_to_hash("xyz")