diff --git a/docs/usage/routes.md b/docs/usage/routes.md index cc0aeb83..34fcad3b 100644 --- a/docs/usage/routes.md +++ b/docs/usage/routes.md @@ -129,3 +129,96 @@ Update the current authenticated active user. !!! fail "`401 Unauthorized`" Missing token or inactive user. + +## Superuser + +### `GET /` + +Return the list of registered users. + +!!! success "`200 OK`" + ```json + [{ + "id": "57cbb51a-ab71-4009-8802-3f54b4f2e23", + "email": "king.arthur@camelot.bt", + "is_active": true, + "is_superuser": false + }] + ``` + +!!! fail "`401 Unauthorized`" + Missing token or inactive user. + +!!! fail "`403 Forbidden`" + Not a superuser. + +### `GET /{user_id}` + +Return the user with id `user_id`. + +!!! success "`200 OK`" + ```json + { + "id": "57cbb51a-ab71-4009-8802-3f54b4f2e23", + "email": "king.arthur@camelot.bt", + "is_active": true, + "is_superuser": false + } + ``` + +!!! fail "`401 Unauthorized`" + Missing token or inactive user. + +!!! fail "`403 Forbidden`" + Not a superuser. + +!!! fail "`404 Not found`" + The user does not exist. + +### `PATCH /{user_id}` + +Update the user with id `user_id`. + +!!! abstract "Payload" + ```json + { + "email": "king.arthur@tintagel.bt", + "password": "merlin", + "is_active": false, + "is_superuser": true + } + ``` + +!!! success "`200 OK`" + ```json + { + "id": "57cbb51a-ab71-4009-8802-3f54b4f2e23", + "email": "king.arthur@camelot.bt", + "is_active": false, + "is_superuser": true + } + ``` + +!!! fail "`401 Unauthorized`" + Missing token or inactive user. + +!!! fail "`403 Forbidden`" + Not a superuser. + +!!! fail "`404 Not found`" + The user does not exist. + +### `DELETE /{user_id}` + +Delete the user with id `user_id`. + +!!! success "`204 No content`" + +!!! fail "`401 Unauthorized`" + Missing token or inactive user. + +!!! fail "`403 Forbidden`" + Not a superuser. + +!!! fail "`404 Not found`" + The user does not exist. diff --git a/fastapi_users/db/base.py b/fastapi_users/db/base.py index 60384170..ba565e06 100644 --- a/fastapi_users/db/base.py +++ b/fastapi_users/db/base.py @@ -29,6 +29,10 @@ class BaseUserDatabase: """Update a user.""" raise NotImplementedError() + async def delete(self, user: BaseUserDB) -> None: + """Delete a user.""" + raise NotImplementedError() + async def authenticate( self, credentials: OAuth2PasswordRequestForm ) -> Optional[BaseUserDB]: diff --git a/fastapi_users/db/mongodb.py b/fastapi_users/db/mongodb.py index 44646033..ee99d28b 100644 --- a/fastapi_users/db/mongodb.py +++ b/fastapi_users/db/mongodb.py @@ -38,3 +38,6 @@ class MongoDBUserDatabase(BaseUserDatabase): async def update(self, user: BaseUserDB) -> BaseUserDB: await self.collection.replace_one({"id": user.id}, user.dict()) return user + + async def delete(self, user: BaseUserDB) -> None: + await self.collection.delete_one({"id": user.id}) diff --git a/fastapi_users/db/sqlalchemy.py b/fastapi_users/db/sqlalchemy.py index 6bfabbf9..7414fcb1 100644 --- a/fastapi_users/db/sqlalchemy.py +++ b/fastapi_users/db/sqlalchemy.py @@ -57,3 +57,7 @@ class SQLAlchemyUserDatabase(BaseUserDatabase): ) await self.database.execute(query) return user + + async def delete(self, user: BaseUserDB) -> None: + query = self.users.delete().where(self.users.c.id == user.id) + await self.database.execute(query) diff --git a/fastapi_users/models.py b/fastapi_users/models.py index b4e2200d..c0ad0795 100644 --- a/fastapi_users/models.py +++ b/fastapi_users/models.py @@ -23,6 +23,9 @@ class BaseUser(BaseModel): skip_defaults=True, exclude={"id", "is_superuser", "is_active"} ) + def create_update_dict_superuser(self): + return self.dict(skip_defaults=True, exclude={"id"}) + class BaseUserCreate(BaseUser): email: EmailStr diff --git a/fastapi_users/router.py b/fastapi_users/router.py index db6ec268..027c7a19 100644 --- a/fastapi_users/router.py +++ b/fastapi_users/router.py @@ -54,6 +54,24 @@ def get_user_router( reset_password_token_audience = "fastapi-users:reset" get_current_active_user = auth.get_current_active_user(user_db) + get_current_superuser = auth.get_current_superuser(user_db) + + async def _get_or_404(id: str) -> models.UserDB: # type: ignore + user = await user_db.get(id) + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + return user + + async def _update_user( + user: models.UserDB, update_dict: typing.Dict[str, typing.Any] # type: ignore + ): + for field in update_dict: + if field == "password": + hashed_password = get_password_hash(update_dict[field]) + user.hashed_password = hashed_password + else: + setattr(user, field, update_dict[field]) + return await user_db.update(user) @router.post( "/register", response_model=models.User, status_code=status.HTTP_201_CREATED @@ -136,13 +154,38 @@ def get_user_router( user: models.UserDB = Depends(get_current_active_user), # type: ignore ): updated_user_data = updated_user.create_update_dict() - for field in updated_user_data: - if field == "password": - hashed_password = get_password_hash(updated_user_data[field]) - user.hashed_password = hashed_password - else: - setattr(user, field, updated_user_data[field]) + return await _update_user(user, updated_user_data) - return await user_db.update(user) + @router.get("/", response_model=typing.List[models.User]) # type: ignore + async def list_users( + superuser: models.UserDB = Depends(get_current_superuser), # type: ignore + ): + return await user_db.list() + + @router.get("/{id}", response_model=models.User) + async def get_user( + id: str, + superuser: models.UserDB = Depends(get_current_superuser), # type: ignore + ): + return await _get_or_404(id) + + @router.patch("/{id}", response_model=models.User) + async def update_user( + id: str, + updated_user: models.UserUpdate, # type: ignore + superuser: models.UserDB = Depends(get_current_superuser), # type: ignore + ): + user = await _get_or_404(id) + updated_user_data = updated_user.create_update_dict_superuser() + return await _update_user(user, updated_user_data) + + @router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT) + async def delete_user( + id: str, + superuser: models.UserDB = Depends(get_current_superuser), # type: ignore + ): + user = await _get_or_404(id) + await user_db.delete(user) + return None return router diff --git a/tests/conftest.py b/tests/conftest.py index c3c3993b..a6dd00ef 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -75,6 +75,9 @@ def mock_user_db(user, inactive_user, superuser) -> BaseUserDatabase: async def update(self, user: BaseUserDB) -> BaseUserDB: return user + async def delete(self, user: BaseUserDB) -> None: + pass + return MockUserDatabase() diff --git a/tests/test_db_base.py b/tests/test_db_base.py index fee574a8..24c0bf2b 100644 --- a/tests/test_db_base.py +++ b/tests/test_db_base.py @@ -31,6 +31,9 @@ async def test_not_implemented_methods(user): with pytest.raises(NotImplementedError): await base_user_db.update(user) + with pytest.raises(NotImplementedError): + await base_user_db.delete(user) + class TestAuthenticate: @pytest.mark.asyncio diff --git a/tests/test_db_mongodb.py b/tests/test_db_mongodb.py index ac59b332..ce933006 100644 --- a/tests/test_db_mongodb.py +++ b/tests/test_db_mongodb.py @@ -61,3 +61,8 @@ async def test_queries(mongodb_user_db): # Unknown user unknown_user = await mongodb_user_db.get_by_email("galahad@camelot.bt") assert unknown_user is None + + # Delete user + await mongodb_user_db.delete(user) + deleted_user = await mongodb_user_db.get(user.id) + assert deleted_user is None diff --git a/tests/test_db_sqlalchemy.py b/tests/test_db_sqlalchemy.py index 47a4d589..cd8fee22 100644 --- a/tests/test_db_sqlalchemy.py +++ b/tests/test_db_sqlalchemy.py @@ -79,3 +79,8 @@ async def test_queries(sqlalchemy_user_db): # Unknown user unknown_user = await sqlalchemy_user_db.get_by_email("galahad@camelot.bt") assert unknown_user is None + + # Delete user + await sqlalchemy_user_db.delete(user) + deleted_user = await sqlalchemy_user_db.get(user.id) + assert deleted_user is None diff --git a/tests/test_fastapi_users.py b/tests/test_fastapi_users.py index 1cfac3ee..bd9122fd 100644 --- a/tests/test_fastapi_users.py +++ b/tests/test_fastapi_users.py @@ -37,7 +37,7 @@ def fastapi_users(request, mock_user_db, mock_authentication) -> FastAPIUsers: @pytest.fixture() def test_app_client(fastapi_users) -> TestClient: app = FastAPI() - app.include_router(fastapi_users.router) + app.include_router(fastapi_users.router, prefix="/users") @app.get("/current-user") def current_user(user=Depends(fastapi_users.get_current_user)): @@ -63,16 +63,25 @@ class TestFastAPIUsers: class TestRouter: def test_routes_exist(self, test_app_client: TestClient): - response = test_app_client.post("/register") + response = test_app_client.post("/users/register") assert response.status_code != status.HTTP_404_NOT_FOUND - response = test_app_client.post("/login") + response = test_app_client.post("/users/login") assert response.status_code != status.HTTP_404_NOT_FOUND - response = test_app_client.post("/forgot-password") + response = test_app_client.post("/users/forgot-password") assert response.status_code != status.HTTP_404_NOT_FOUND - response = test_app_client.post("/reset-password") + response = test_app_client.post("/users/reset-password") + assert response.status_code != status.HTTP_404_NOT_FOUND + + response = test_app_client.get("/users") + assert response.status_code != status.HTTP_404_NOT_FOUND + + response = test_app_client.get("/users/aaa") + assert response.status_code != status.HTTP_404_NOT_FOUND + + response = test_app_client.patch("/users/aaa") assert response.status_code != status.HTTP_404_NOT_FOUND diff --git a/tests/test_router.py b/tests/test_router.py index 4edf82c6..b0744a7a 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -357,3 +357,195 @@ class TestUpdateMe: updated_user = mock_user_db.update.call_args[0][0] assert updated_user.hashed_password != current_hashed_passord + + +class TestListUsers: + def test_missing_token(self, test_app_client: TestClient): + response = test_app_client.get("/") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_regular_user(self, test_app_client: TestClient, user: BaseUserDB): + response = test_app_client.get( + "/", headers={"Authorization": f"Bearer {user.id}"} + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_superuser(self, test_app_client: TestClient, superuser: BaseUserDB): + response = test_app_client.get( + "/", headers={"Authorization": f"Bearer {superuser.id}"} + ) + assert response.status_code == status.HTTP_200_OK + + response_json = response.json() + assert len(response_json) == 3 + for user in response_json: + assert "id" in user + assert "hashed_password" not in user + + +class TestGetUser: + def test_missing_token(self, test_app_client: TestClient): + response = test_app_client.get("/000") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_regular_user(self, test_app_client: TestClient, user: BaseUserDB): + response = test_app_client.get( + "/000", headers={"Authorization": f"Bearer {user.id}"} + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_not_existing_user( + self, test_app_client: TestClient, superuser: BaseUserDB + ): + response = test_app_client.get( + "/000", headers={"Authorization": f"Bearer {superuser.id}"} + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_superuser( + self, test_app_client: TestClient, user: BaseUserDB, superuser: BaseUserDB + ): + response = test_app_client.get( + f"/{user.id}", headers={"Authorization": f"Bearer {superuser.id}"} + ) + assert response.status_code == status.HTTP_200_OK + + response_json = response.json() + assert response_json["id"] == user.id + assert "hashed_password" not in response_json + + +class TestUpdateUser: + def test_missing_token(self, test_app_client: TestClient): + response = test_app_client.patch("/000") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_regular_user(self, test_app_client: TestClient, user: BaseUserDB): + response = test_app_client.patch( + "/000", headers={"Authorization": f"Bearer {user.id}"} + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_not_existing_user( + self, test_app_client: TestClient, superuser: BaseUserDB + ): + response = test_app_client.patch( + "/000", json={}, headers={"Authorization": f"Bearer {superuser.id}"} + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_empty_body( + self, test_app_client: TestClient, user: BaseUserDB, superuser: BaseUserDB + ): + response = test_app_client.patch( + f"/{user.id}", json={}, headers={"Authorization": f"Bearer {superuser.id}"} + ) + assert response.status_code == status.HTTP_200_OK + + response_json = response.json() + assert response_json["email"] == user.email + + def test_valid_body( + self, test_app_client: TestClient, user: BaseUserDB, superuser: BaseUserDB + ): + json = {"email": "king.arthur@tintagel.bt"} + response = test_app_client.patch( + f"/{user.id}", + json=json, + headers={"Authorization": f"Bearer {superuser.id}"}, + ) + assert response.status_code == status.HTTP_200_OK + + response_json = response.json() + assert response_json["email"] == "king.arthur@tintagel.bt" + + def test_valid_body_is_superuser( + self, test_app_client: TestClient, user: BaseUserDB, superuser: BaseUserDB + ): + json = {"is_superuser": True} + response = test_app_client.patch( + f"/{user.id}", + json=json, + headers={"Authorization": f"Bearer {superuser.id}"}, + ) + assert response.status_code == status.HTTP_200_OK + + response_json = response.json() + assert response_json["is_superuser"] is True + + def test_valid_body_is_active( + self, test_app_client: TestClient, user: BaseUserDB, superuser: BaseUserDB + ): + json = {"is_active": False} + response = test_app_client.patch( + f"/{user.id}", + json=json, + headers={"Authorization": f"Bearer {superuser.id}"}, + ) + assert response.status_code == status.HTTP_200_OK + + response_json = response.json() + assert response_json["is_active"] is False + + def test_valid_body_password( + self, + mocker, + mock_user_db, + test_app_client: TestClient, + user: BaseUserDB, + superuser: BaseUserDB, + ): + mocker.spy(mock_user_db, "update") + current_hashed_passord = user.hashed_password + + json = {"password": "merlin"} + response = test_app_client.patch( + f"/{user.id}", + json=json, + headers={"Authorization": f"Bearer {superuser.id}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert mock_user_db.update.called is True + + updated_user = mock_user_db.update.call_args[0][0] + assert updated_user.hashed_password != current_hashed_passord + + +class TestDeleteUser: + def test_missing_token(self, test_app_client: TestClient): + response = test_app_client.delete("/000") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_regular_user(self, test_app_client: TestClient, user: BaseUserDB): + response = test_app_client.delete( + "/000", headers={"Authorization": f"Bearer {user.id}"} + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_not_existing_user( + self, test_app_client: TestClient, superuser: BaseUserDB + ): + response = test_app_client.delete( + "/000", headers={"Authorization": f"Bearer {superuser.id}"} + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_superuser( + self, + mocker, + mock_user_db, + test_app_client: TestClient, + user: BaseUserDB, + superuser: BaseUserDB, + ): + mocker.spy(mock_user_db, "delete") + + response = test_app_client.delete( + f"/{user.id}", headers={"Authorization": f"Bearer {superuser.id}"} + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert response.json() is None + assert mock_user_db.delete.called is True + + deleted_user = mock_user_db.delete.call_args[0][0] + assert deleted_user.id == user.id