diff --git a/dev-requirements.txt b/dev-requirements.txt index 0e8b396b8..e2dad4a52 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -4,6 +4,5 @@ pytest==6.1.2 flake8==3.8.4 pytest-timeout==1.4.2 pytest-asyncio==0.14.0 -asgi-lifespan==1.0.1 requests==2.24.0 httpx==0.16.1 diff --git a/gns3server/api/routes/controller/dependencies/authentication.py b/gns3server/api/routes/controller/dependencies/authentication.py index f50d89bbb..c4986577e 100644 --- a/gns3server/api/routes/controller/dependencies/authentication.py +++ b/gns3server/api/routes/controller/dependencies/authentication.py @@ -23,12 +23,15 @@ from gns3server import schemas from gns3server.db.repositories.users import UsersRepository from gns3server.services import auth_service +from .database import get_repository oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v3/users/login") # FIXME: URL prefix -async def get_user_from_token(token: str = Depends(oauth2_scheme), - user_repo: UsersRepository = Depends()) -> schemas.User: +async def get_user_from_token( + token: str = Depends(oauth2_scheme), + user_repo: UsersRepository = Depends(get_repository(UsersRepository)) +) -> schemas.User: username = auth_service.get_username_from_token(token) user = await user_repo.get_user_by_username(username) diff --git a/gns3server/db/database.py b/gns3server/api/routes/controller/dependencies/database.py similarity index 53% rename from gns3server/db/database.py rename to gns3server/api/routes/controller/dependencies/database.py index 7d131caca..b1dbaa126 100644 --- a/gns3server/db/database.py +++ b/gns3server/api/routes/controller/dependencies/database.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +# -*- coding: utf-8 -*- # # Copyright (C) 2020 GNS3 Technologies Inc. # @@ -15,12 +15,24 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import os +from typing import Callable, Type +from fastapi import Depends, Request +from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.ext.asyncio import create_async_engine -from sqlalchemy.orm import declarative_base +from gns3server.db.repositories.base import BaseRepository -SQLALCHEMY_DATABASE_URL = os.environ.get("DATABASE_URI", "sqlite:///./sql_app.db") -engine = create_async_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) -Base = declarative_base() +async def get_db_session(request: Request) -> AsyncSession: + + session = AsyncSession(request.app.state._db_engine) + try: + yield session + finally: + await session.close() + + +def get_repository(repo: Type[BaseRepository]) -> Callable: + + def get_repo(db_session: AsyncSession = Depends(get_db_session)) -> Type[BaseRepository]: + return repo(db_session) + return get_repo diff --git a/gns3server/api/routes/controller/users.py b/gns3server/api/routes/controller/users.py index e832a8e80..75f9c3fb7 100644 --- a/gns3server/api/routes/controller/users.py +++ b/gns3server/api/routes/controller/users.py @@ -20,7 +20,7 @@ API routes for users. """ from fastapi import APIRouter, Depends, HTTPException, status -from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from fastapi.security import OAuth2PasswordRequestForm from uuid import UUID from typing import List @@ -30,6 +30,7 @@ from gns3server.db.repositories.users import UsersRepository from gns3server.services import auth_service from .dependencies.authentication import get_current_active_user +from .dependencies.database import get_repository import logging log = logging.getLogger(__name__) @@ -38,7 +39,7 @@ router = APIRouter() @router.get("", response_model=List[schemas.User]) -async def get_users(user_repo: UsersRepository = Depends()) -> List[schemas.User]: +async def get_users(user_repo: UsersRepository = Depends(get_repository(UsersRepository))) -> List[schemas.User]: """ Get all users. """ @@ -48,7 +49,10 @@ async def get_users(user_repo: UsersRepository = Depends()) -> List[schemas.User @router.post("", response_model=schemas.User, status_code=status.HTTP_201_CREATED) -async def create_user(new_user: schemas.UserCreate, user_repo: UsersRepository = Depends()) -> schemas.User: +async def create_user( + new_user: schemas.UserCreate, + user_repo: UsersRepository = Depends(get_repository(UsersRepository)) +) -> schemas.User: """ Create a new user. """ @@ -63,7 +67,10 @@ async def create_user(new_user: schemas.UserCreate, user_repo: UsersRepository = @router.get("/{user_id}", response_model=schemas.User) -async def get_user(user_id: UUID, user_repo: UsersRepository = Depends()) -> schemas.User: +async def get_user( + user_id: UUID, + user_repo: UsersRepository = Depends(get_repository(UsersRepository)) +) -> schemas.User: """ Get an user. """ @@ -75,9 +82,11 @@ async def get_user(user_id: UUID, user_repo: UsersRepository = Depends()) -> sch @router.put("/{user_id}", response_model=schemas.User) -async def update_user(user_id: UUID, - update_user: schemas.UserUpdate, - user_repo: UsersRepository = Depends()) -> schemas.User: +async def update_user( + user_id: UUID, + update_user: schemas.UserUpdate, + user_repo: UsersRepository = Depends(get_repository(UsersRepository)) +) -> schemas.User: """ Update an user. """ @@ -89,7 +98,7 @@ async def update_user(user_id: UUID, @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_user(user_id: UUID, user_repo: UsersRepository = Depends()): +async def delete_user(user_id: UUID, user_repo: UsersRepository = Depends(get_repository(UsersRepository))): """ Delete an user. """ @@ -100,8 +109,10 @@ async def delete_user(user_id: UUID, user_repo: UsersRepository = Depends()): @router.post("/login", response_model=schemas.Token) -async def login(user_repo: UsersRepository = Depends(), - form_data: OAuth2PasswordRequestForm = Depends()) -> schemas.Token: +async def login( + user_repo: UsersRepository = Depends(get_repository(UsersRepository)), + form_data: OAuth2PasswordRequestForm = Depends() +) -> schemas.Token: """ User login. """ diff --git a/gns3server/api/server.py b/gns3server/api/server.py index a01a772dc..f63bb3cfd 100644 --- a/gns3server/api/server.py +++ b/gns3server/api/server.py @@ -45,34 +45,43 @@ import logging log = logging.getLogger(__name__) -app = FastAPI(title="GNS3 controller API", - description="This page describes the public controller API for GNS3", - version="v3") +def get_application() -> FastAPI: -origins = [ - "http://127.0.0.1", - "http://localhost", - "http://127.0.0.1:8080", - "http://localhost:8080", - "http://127.0.0.1:3080", - "http://localhost:3080", - "http://gns3.github.io", - "https://gns3.github.io" -] + application = FastAPI( + title="GNS3 controller API", + description="This page describes the public controller API for GNS3", + version="v3" + ) -app.add_middleware( - CORSMiddleware, - allow_origins=origins, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) + origins = [ + "http://127.0.0.1", + "http://localhost", + "http://127.0.0.1:8080", + "http://localhost:8080", + "http://127.0.0.1:3080", + "http://localhost:3080", + "http://gns3.github.io", + "https://gns3.github.io" + ] -app.add_event_handler("startup", tasks.create_startup_handler(app)) -app.add_event_handler("shutdown", tasks.create_shutdown_handler(app)) -app.include_router(index.router, tags=["Index"]) -app.include_router(controller.router, prefix="/v3") -app.mount("/v3/compute", compute_api) + application.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + application.add_event_handler("startup", tasks.create_startup_handler(application)) + application.add_event_handler("shutdown", tasks.create_shutdown_handler(application)) + application.include_router(index.router, tags=["Index"]) + application.include_router(controller.router, prefix="/v3") + application.mount("/v3/compute", compute_api) + + return application + + +app = get_application() @app.exception_handler(ControllerError) diff --git a/gns3server/core/tasks.py b/gns3server/core/tasks.py index 4c134564a..6f1b59c5a 100644 --- a/gns3server/core/tasks.py +++ b/gns3server/core/tasks.py @@ -55,7 +55,7 @@ def create_startup_handler(app: FastAPI) -> Callable: loop.set_debug(True) # connect to the database - await connect_to_db() + await connect_to_db(app) await Controller.instance().start() # Because with a large image collection diff --git a/gns3server/db/models.py b/gns3server/db/models.py index dddfa9a64..a211859f6 100644 --- a/gns3server/db/models.py +++ b/gns3server/db/models.py @@ -21,7 +21,10 @@ from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, DateTime, f from sqlalchemy.orm import relationship from sqlalchemy.types import TypeDecorator, CHAR from sqlalchemy.dialects.postgresql import UUID -from .database import Base + +from sqlalchemy.orm import declarative_base + +Base = declarative_base() class GUID(TypeDecorator): @@ -68,11 +71,14 @@ class BaseTable(Base): onupdate=func.current_timestamp()) +def generate_uuid(): + return str(uuid.uuid4()) + class User(BaseTable): __tablename__ = "users" - user_id = Column(GUID, primary_key=True, default=str(uuid.uuid4())) + user_id = Column(GUID, primary_key=True, default=generate_uuid) username = Column(String, unique=True, index=True) email = Column(String, unique=True, index=True) full_name = Column(String) diff --git a/gns3server/db/repositories/base.py b/gns3server/db/repositories/base.py index 9ab53e117..ab7c5ca3b 100644 --- a/gns3server/db/repositories/base.py +++ b/gns3server/db/repositories/base.py @@ -16,14 +16,10 @@ # along with this program. If not, see . from sqlalchemy.ext.asyncio import AsyncSession -from ..database import engine class BaseRepository: - async def db(self): - session = AsyncSession(engine) - try: - yield session - finally: - await session.close() + def __init__(self, db_session: AsyncSession) -> None: + + self._db_session = db_session diff --git a/gns3server/db/repositories/users.py b/gns3server/db/repositories/users.py index a8b6f7686..587d12a5a 100644 --- a/gns3server/db/repositories/users.py +++ b/gns3server/db/repositories/users.py @@ -20,7 +20,6 @@ from typing import Optional, List from sqlalchemy import select, update, delete from sqlalchemy.ext.asyncio import AsyncSession -from ..database import engine from .base import BaseRepository import gns3server.db.models as models @@ -30,73 +29,66 @@ from gns3server.services import auth_service class UsersRepository(BaseRepository): - def __init__(self) -> None: + def __init__(self, db_session: AsyncSession) -> None: - super().__init__() + super().__init__(db_session) self._auth_service = auth_service async def get_user(self, user_id: UUID) -> Optional[models.User]: - async with AsyncSession(engine) as session: - result = await session.execute(select(models.User).where(models.User.user_id == user_id)) - return result.scalars().first() + result = await self._db_session.execute(select(models.User).where(models.User.user_id == user_id)) + return result.scalars().first() async def get_user_by_username(self, username: str) -> Optional[models.User]: - async with AsyncSession(engine) as session: - result = await session.execute(select(models.User).where(models.User.username == username)) - return result.scalars().first() + result = await self._db_session.execute(select(models.User).where(models.User.username == username)) + return result.scalars().first() async def get_user_by_email(self, email: str) -> Optional[models.User]: - async with AsyncSession(engine) as session: - result = await session.execute(select(models.User).where(models.User.email == email)) - return result.scalars().first() + result = await self._db_session.execute(select(models.User).where(models.User.email == email)) + return result.scalars().first() async def get_users(self) -> List[models.User]: - async with AsyncSession(engine) as session: - result = await session.execute(select(models.User)) - return result.scalars().all() + result = await self._db_session.execute(select(models.User)) + return result.scalars().all() async def create_user(self, user: schemas.UserCreate) -> models.User: - async with AsyncSession(engine) as session: - hashed_password = self._auth_service.hash_password(user.password) - db_user = models.User(username=user.username, - email=user.email, - full_name=user.full_name, - hashed_password=hashed_password) - session.add(db_user) - await session.commit() - await session.refresh(db_user) - return db_user + hashed_password = self._auth_service.hash_password(user.password) + db_user = models.User( + username=user.username, + email=user.email, + full_name=user.full_name, + hashed_password=hashed_password + ) + self._db_session.add(db_user) + await self._db_session.commit() + await self._db_session.refresh(db_user) + return db_user async def update_user(self, user_id: UUID, user_update: schemas.UserUpdate) -> Optional[models.User]: - async with AsyncSession(engine) as session: + update_values = user_update.dict(exclude_unset=True) + password = update_values.pop("password", None) + if password: + update_values["hashed_password"] = self._auth_service.hash_password(password=password) - update_values = user_update.dict(exclude_unset=True) - password = update_values.pop("password", None) - if password: - update_values["hashed_password"] = self._auth_service.hash_password(password=password) + query = update(models.User) \ + .where(models.User.user_id == user_id) \ + .values(update_values) - print(update_values) - query = update(models.User) \ - .where(models.User.user_id == user_id) \ - .values(update_values) - - await session.execute(query) - await session.commit() - return await self.get_user(user_id) + await self._db_session.execute(query) + await self._db_session.commit() + return await self.get_user(user_id) async def delete_user(self, user_id: UUID) -> bool: - async with AsyncSession(engine) as session: - query = delete(models.User).where(models.User.user_id == user_id) - result = await session.execute(query) - await session.commit() - return result.rowcount > 0 + query = delete(models.User).where(models.User.user_id == user_id) + result = await self._db_session.execute(query) + await self._db_session.commit() + return result.rowcount > 0 #except: # await session.rollback() diff --git a/gns3server/db/tasks.py b/gns3server/db/tasks.py index 615ea6360..092834451 100644 --- a/gns3server/db/tasks.py +++ b/gns3server/db/tasks.py @@ -15,20 +15,26 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from sqlalchemy.exc import SQLAlchemyError +import os + +from fastapi import FastAPI +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import create_async_engine -from .database import engine from .models import Base import logging log = logging.getLogger(__name__) -async def connect_to_db() -> None: +async def connect_to_db(app: FastAPI) -> None: + db_url = os.environ.get("GNS3_DATABASE_URI", "sqlite:///./sql_app.db") + engine = create_async_engine(db_url, connect_args={"check_same_thread": False}, future=True) try: async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) log.info("Successfully connected to the database") + app.state._db_engine = engine except SQLAlchemyError as e: log.error(f"Error while connecting to the database: {e}") diff --git a/gns3server/services/authentication.py b/gns3server/services/authentication.py index 2a9d097e6..785607273 100644 --- a/gns3server/services/authentication.py +++ b/gns3server/services/authentication.py @@ -35,10 +35,6 @@ ACCESS_TOKEN_EXPIRE_MINUTES = 30 pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") -#class AuthException(BaseException): -# pass - - class AuthService: def hash_password(self, password: str) -> str: @@ -49,14 +45,19 @@ class AuthService: return pwd_context.verify(password, hashed_password) - def create_access_token(self, username): + def create_access_token( + self, + username, + secret_key: str = SECRET_KEY, + expires_in: int = ACCESS_TOKEN_EXPIRE_MINUTES + ) -> str: - expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + expire = datetime.utcnow() + timedelta(minutes=expires_in) to_encode = {"sub": username, "exp": expire} - encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + encoded_jwt = jwt.encode(to_encode, secret_key, algorithm=ALGORITHM) return encoded_jwt - def get_username_from_token(self, token: str) -> Optional[str]: + def get_username_from_token(self, token: str, secret_key: str = SECRET_KEY) -> Optional[str]: credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -64,7 +65,7 @@ class AuthService: headers={"WWW-Authenticate": "Bearer"}, ) try: - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + payload = jwt.decode(token, secret_key, algorithms=[ALGORITHM]) username: str = payload.get("sub") if username is None: raise credentials_exception diff --git a/requirements.txt b/requirements.txt index 1ebd35fe7..078af8627 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ uvicorn==0.11.8 # force version to 0.11.8 because of https://github.com/encode/uvicorn/issues/841 -fastapi==0.61.2 +fastapi==0.62.0 websockets==8.1 python-multipart==0.0.5 aiohttp==3.7.2 diff --git a/tests/api/routes/controller/test_users.py b/tests/api/routes/controller/test_users.py index 522703bea..4038ece06 100644 --- a/tests/api/routes/controller/test_users.py +++ b/tests/api/routes/controller/test_users.py @@ -17,46 +17,237 @@ import pytest -from fastapi import FastAPI, status -from fastapi.encoders import jsonable_encoder +from typing import Optional, Union +from fastapi import FastAPI, HTTPException, status +from starlette.datastructures import Secret from httpx import AsyncClient +from jose import jwt +from sqlalchemy.ext.asyncio import AsyncSession from gns3server.db.repositories.users import UsersRepository +from gns3server.services import auth_service +from gns3server.services.authentication import SECRET_KEY, ALGORITHM from gns3server.schemas.users import User pytestmark = pytest.mark.asyncio -# async def test_route_exist(app: FastAPI, client: AsyncClient) -> None: -# -# params = {"username": "test_username", "email": "user@email.com", "password": "test_password"} -# response = await client.post(app.url_path_for("create_user"), json=params) -# assert response.status_code != status.HTTP_404_NOT_FOUND -# -# -# async def test_users_can_register_successfully(app: FastAPI, client: AsyncClient) -> None: -# -# user_repo = UsersRepository() -# params = {"username": "test_username2", "email": "user2@email.com", "password": "test_password2"} -# -# # make sure the user doesn't exist in the database -# user_in_db = await user_repo.get_user_by_username(params["username"]) -# assert user_in_db is None -# -# # register the user -# res = await client.post(app.url_path_for("create_user"), json=params) -# assert res.status_code == status.HTTP_201_CREATED -# -# # make sure the user does exists in the database now -# user_in_db = await user_repo.get_user_by_username(params["username"]) -# assert user_in_db is not None -# assert user_in_db.email == params["email"] -# assert user_in_db.username == params["username"] -# -# # check that the user returned in the response is equal to the user in the database -# created_user = User(**res.json()).json() -# print(created_user) -# #print(user_in_db.__dict__) -# test = jsonable_encoder(user_in_db.__dict__, exclude={"_sa_instance_state", "hashed_password"}) -# print(test) -# assert created_user == test +class TestUserRoutes: + + async def test_route_exist(self, app: FastAPI, client: AsyncClient) -> None: + + new_user = {"username": "test_user1", "email": "user1@email.com", "password": "test_password"} + response = await client.post(app.url_path_for("create_user"), json=new_user) + assert response.status_code != status.HTTP_404_NOT_FOUND + + async def test_users_can_register_successfully( + self, + app: FastAPI, + client: AsyncClient, + db_session: AsyncSession + ) -> None: + + user_repo = UsersRepository(db_session) + params = {"username": "test_user2", "email": "user2@email.com", "password": "test_password"} + + # make sure the user doesn't exist in the database + user_in_db = await user_repo.get_user_by_username(params["username"]) + assert user_in_db is None + + # register the user + res = await client.post(app.url_path_for("create_user"), json=params) + assert res.status_code == status.HTTP_201_CREATED + + # make sure the user does exists in the database now + user_in_db = await user_repo.get_user_by_username(params["username"]) + assert user_in_db is not None + assert user_in_db.email == params["email"] + assert user_in_db.username == params["username"] + + # check that the user returned in the response is equal to the user in the database + created_user = User(**res.json()).json() + assert created_user == User.from_orm(user_in_db).json() + + @pytest.mark.parametrize( + "attr, value, status_code", + ( + ("email", "user2@email.com", status.HTTP_400_BAD_REQUEST), + ("username", "test_user2", status.HTTP_400_BAD_REQUEST), + ("email", "invalid_email@one@two.io", status.HTTP_422_UNPROCESSABLE_ENTITY), + ("password", "short", status.HTTP_422_UNPROCESSABLE_ENTITY), + ("username", "user2@#$%^<>", status.HTTP_422_UNPROCESSABLE_ENTITY), + ("username", "ab", status.HTTP_422_UNPROCESSABLE_ENTITY), + ) + ) + async def test_user_registration_fails_when_credentials_are_taken( + self, + app: FastAPI, + client: AsyncClient, + attr: str, + value: str, + status_code: int, + ) -> None: + + new_user = {"email": "not_taken@email.com", "username": "not_taken_username", "password": "test_password"} + new_user[attr] = value + res = await client.post(app.url_path_for("create_user"), json=new_user) + assert res.status_code == status_code + + async def test_users_saved_password_is_hashed( + self, + app: FastAPI, + client: AsyncClient, + db_session: AsyncSession + ) -> None: + + user_repo = UsersRepository(db_session) + new_user = {"username": "test_user3", "email": "user3@email.com", "password": "test_password"} + + # send post request to create user and ensure it is successful + res = await client.post(app.url_path_for("create_user"), json=new_user) + assert res.status_code == status.HTTP_201_CREATED + + # ensure that the users password is hashed in the db + # and that we can verify it using our auth service + user_in_db = await user_repo.get_user_by_username(new_user["username"]) + assert user_in_db is not None + assert user_in_db.hashed_password != new_user["password"] + assert auth_service.verify_password(new_user["password"], user_in_db.hashed_password) + + +class TestAuthTokens: + + async def test_can_create_token_successfully( + self, + app: FastAPI, + client: AsyncClient, + test_user: User + ) -> None: + + token = auth_service.create_access_token(test_user.username) + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username = payload.get("sub") + assert username == test_user.username + + async def test_token_missing_user_is_invalid(self, app: FastAPI, client: AsyncClient) -> None: + + token = auth_service.create_access_token(None) + with pytest.raises(jwt.JWTError): + jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + + async def test_can_retrieve_username_from_token( + self, + app: FastAPI, + client: AsyncClient, + test_user: User + ) -> None: + + token = auth_service.create_access_token(test_user.username) + username = auth_service.get_username_from_token(token) + assert username == test_user.username + + + @pytest.mark.parametrize( + "secret, wrong_token", + ( + (SECRET_KEY, "asdf"), # use wrong token + (SECRET_KEY, ""), # use wrong token + ("ABC123", "use correct token"), # use wrong secret + ), + ) + async def test_error_when_token_or_secret_is_wrong( + self, + app: FastAPI, + client: AsyncClient, + test_user: User, + secret: Union[Secret, str], + wrong_token: Optional[str], + ) -> None: + + token = auth_service.create_access_token(test_user.username) + if wrong_token == "use correct token": + wrong_token = token + with pytest.raises(HTTPException): + auth_service.get_username_from_token(wrong_token, secret_key=str(secret)) + + +class TestUserLogin: + + async def test_user_can_login_successfully_and_receives_valid_token( + self, + app: FastAPI, + client: AsyncClient, + test_user: User, + ) -> None: + + client.headers["content-type"] = "application/x-www-form-urlencoded" + login_data = { + "username": test_user.username, + "password": "user1_password", + } + res = await client.post(app.url_path_for("login"), data=login_data) + assert res.status_code == status.HTTP_200_OK + + # check that token exists in response and has user encoded within it + token = res.json().get("access_token") + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + assert "sub" in payload + username = payload.get("sub") + assert username == test_user.username + + # check that token is proper type + assert "token_type" in res.json() + assert res.json().get("token_type") == "bearer" + + @pytest.mark.parametrize( + "username, password, status_code", + ( + ("wrong_username", "user1_password", status.HTTP_401_UNAUTHORIZED), + ("user1", "wrong_password", status.HTTP_401_UNAUTHORIZED), + ("user1", None, status.HTTP_401_UNAUTHORIZED), + ), + ) + async def test_user_with_wrong_creds_doesnt_receive_token( + self, + app: FastAPI, + client: AsyncClient, + test_user: User, + username: str, + password: str, + status_code: int, + ) -> None: + + client.headers["content-type"] = "application/x-www-form-urlencoded" + login_data = { + "username": username, + "password": password, + } + res = await client.post(app.url_path_for("login"), data=login_data) + assert res.status_code == status_code + assert "access_token" not in res.json() + + +class TestUserMe: + + async def test_authenticated_user_can_retrieve_own_data( + self, + app: FastAPI, + authorized_client: AsyncClient, + test_user: User, + ) -> None: + + res = await authorized_client.get(app.url_path_for("get_current_active_user")) + assert res.status_code == status.HTTP_200_OK + user = User(**res.json()) + assert user.username == test_user.username + assert user.email == test_user.email + assert user.user_id == test_user.user_id + + async def test_user_cannot_access_own_data_if_not_authenticated( + self, app: FastAPI, + client: AsyncClient, + test_user: User, + ) -> None: + + res = await client.get(app.url_path_for("get_current_active_user")) + assert res.status_code == status.HTTP_401_UNAUTHORIZED diff --git a/tests/conftest.py b/tests/conftest.py index b91392d32..4e0241c4f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,7 +7,6 @@ import os from fastapi import FastAPI from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine -from asgi_lifespan import LifespanManager from httpx import AsyncClient from unittest.mock import MagicMock, patch from pathlib import Path @@ -17,25 +16,16 @@ from gns3server.config import Config from gns3server.compute import MODULES from gns3server.compute.port_manager import PortManager from gns3server.compute.project_manager import ProjectManager -from gns3server.db.database import Base +from gns3server.db.models import Base, User +from gns3server.db.repositories.users import UsersRepository +from gns3server.api.routes.controller.dependencies.database import get_db_session +from gns3server.schemas.users import UserCreate +from gns3server.services import auth_service sys._called_from_test = True sys.original_platform = sys.platform -SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db" - -engine = create_async_engine( - SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} -) - - -async def start_db(): - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.drop_all) - await conn.run_sync(Base.metadata.create_all) - - if sys.platform.startswith("win") and sys.version_info < (3, 8): @pytest.yield_fixture(scope="session") def event_loop(request): @@ -49,34 +39,64 @@ if sys.platform.startswith("win") and sys.version_info < (3, 8): yield loop asyncio.set_event_loop(None) +# https://github.com/pytest-dev/pytest-asyncio/issues/68 +# this event_loop is used by pytest-asyncio, and redefining it +# is currently the only way of changing the scope of this fixture +@pytest.yield_fixture(scope="session") +def event_loop(request): -# @pytest.mark.asyncio -# @pytest.fixture(scope="session", autouse=True) -# async def database_connection() -> None: -# -# from gns3server.db.tasks import connect_to_db -# os.environ["DATABASE_URI"] = "sqlite:///./sql_app_test.db" -# await connect_to_db() -# yield + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() -@pytest.fixture#(scope="session") -async def app() -> FastAPI: +@pytest.fixture(scope="session") +async def app(db_engine) -> FastAPI: - from gns3server.api.server import app as gns3_app - gns3_app.add_event_handler("startup", start_db()) - return gns3_app + async with db_engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + from gns3server.api.server import app as gns3app + yield gns3app -# Grab a reference to our database when needed -#@pytest.fixture -#def db(app: FastAPI) -> Database: -# return app.state._db +@pytest.fixture(scope="session") +def db_engine(): + + db_url = os.getenv("GNS3_TEST_DATABASE_URI", "sqlite:///:memory:") # "sqlite:///./sql_test_app.db" + engine = create_async_engine(db_url, connect_args={"check_same_thread": False}, future=True) + yield engine + engine.sync_engine.dispose() + + +@pytest.fixture(scope="class") +async def db_session(app: FastAPI, db_engine): + + # recreate database tables for each class + # preferred and faster way would be to rollback the session/transaction + # but it doesn't work for some reason + async with db_engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + + session = AsyncSession(db_engine) + try: + yield session + finally: + await session.close() + @pytest.fixture -async def client(app: FastAPI) -> AsyncClient: +async def client(app: FastAPI, db_session: AsyncSession) -> AsyncClient: + + async def _get_test_db(): + try: + yield db_session + finally: + pass + + app.dependency_overrides[get_db_session] = _get_test_db - #async with LifespanManager(app): async with AsyncClient( app=app, base_url="http://test-api", @@ -85,6 +105,32 @@ async def client(app: FastAPI) -> AsyncClient: yield client +@pytest.fixture +async def test_user(db_session: AsyncSession) -> User: + + new_user = UserCreate( + username="user1", + email="user1@email.com", + password="user1_password", + ) + user_repo = UsersRepository(db_session) + existing_user = await user_repo.get_user_by_username(new_user.username) + if existing_user: + return existing_user + return await user_repo.create_user(new_user) + + +@pytest.fixture +def authorized_client(client: AsyncClient, test_user: User) -> AsyncClient: + + access_token = auth_service.create_access_token(test_user.username) + client.headers = { + **client.headers, + "Authorization": f"Bearer {access_token}", + } + return client + + @pytest.fixture def controller_config_path(tmpdir):