Skip to content

Commit

Permalink
Feature/137 incorporate regtech exceptions (#142)
Browse files Browse the repository at this point in the history
closes #137
  • Loading branch information
lchen-2101 authored Apr 23, 2024
1 parent 20573bf commit 1ffb8b9
Show file tree
Hide file tree
Showing 6 changed files with 56 additions and 36 deletions.
5 changes: 1 addition & 4 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 20 additions & 7 deletions src/regtech_user_fi_management/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from http import HTTPStatus
from typing import Annotated
from fastapi import Depends, Query, HTTPException, Request, Response
from fastapi import Depends, Query, Request, Response
from fastapi.types import DecoratedCallable
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List, Optional
Expand All @@ -13,13 +13,18 @@
import regtech_user_fi_management.entities.repos.institutions_repo as repo
from starlette.authentication import AuthCredentials
from regtech_api_commons.models.auth import AuthenticatedUser
from regtech_api_commons.api.exceptions import RegTechHttpException


async def check_domain(request: Request, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
if not request.user.is_authenticated:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN)
raise RegTechHttpException(
status_code=HTTPStatus.FORBIDDEN, name="Request Forbidden", detail="unauthenticated user"
)
if await email_domain_denied(session, get_email_domain(request.user.email)):
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="email domain denied")
raise RegTechHttpException(
status_code=HTTPStatus.FORBIDDEN, name="Request Forbidden", detail="email domain denied"
)


async def email_domain_denied(session: AsyncSession, email: str) -> bool:
Expand Down Expand Up @@ -58,7 +63,9 @@ async def wrapper(request: Request, *args, **kwargs) -> Response:
user: AuthenticatedUser = request.user
auth: AuthCredentials = request.auth
if not is_admin(auth) and lei not in user.institutions:
raise HTTPException(HTTPStatus.FORBIDDEN, detail=f"LEI {lei} is not associated with the user.")
raise RegTechHttpException(
HTTPStatus.FORBIDDEN, name="Request Forbidden", detail=f"LEI {lei} is not associated with the user."
)
return await func(request, *args, **kwargs)

return wrapper # type: ignore[return-value]
Expand All @@ -67,15 +74,17 @@ async def wrapper(request: Request, *args, **kwargs) -> Response:
def fi_search_association_check(func: DecoratedCallable) -> DecoratedCallable:
def verify_leis(user: AuthenticatedUser, leis: List[str]) -> None:
if not set(filter(len, leis)).issubset(set(filter(len, user.institutions))):
raise HTTPException(
raise RegTechHttpException(
HTTPStatus.FORBIDDEN,
name="Request Forbidden",
detail=f"Institutions query with LEIs ({leis}) not associated with user is forbidden.",
)

def verify_domain(user: AuthenticatedUser, domain: str) -> None:
if domain != get_email_domain(user.email):
raise HTTPException(
raise RegTechHttpException(
HTTPStatus.FORBIDDEN,
name="Request Forbidden",
detail=f"Institutions query with domain ({domain}) not associated with user is forbidden.",
)

Expand All @@ -91,7 +100,11 @@ async def wrapper(request: Request, *args, **kwargs) -> Response:
elif domain:
verify_domain(user, domain)
elif not leis and not domain:
raise HTTPException(HTTPStatus.FORBIDDEN, detail="Retrieving institutions without filter is forbidden.")
raise RegTechHttpException(
HTTPStatus.FORBIDDEN,
name="Request Forbidden",
detail="Retrieving institutions without filter is forbidden.",
)
return await func(request=request, *args, **kwargs)

return wrapper # type: ignore[return-value]
30 changes: 14 additions & 16 deletions src/regtech_user_fi_management/main.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
from contextlib import asynccontextmanager
import os
import logging
from http import HTTPStatus
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.security import OAuth2AuthorizationCodeBearer
from fastapi.middleware.cors import CORSMiddleware
from starlette.exceptions import HTTPException
from starlette.middleware.authentication import AuthenticationMiddleware
from alembic.config import Config
from alembic import command

from regtech_api_commons.oauth2.oauth2_backend import BearerTokenAuthBackend
from regtech_api_commons.oauth2.oauth2_admin import OAuth2Admin
from regtech_api_commons.api.exceptions import RegTechHttpException
from regtech_api_commons.api.exception_handlers import (
regtech_http_exception_handler,
request_validation_error_handler,
http_exception_handler,
general_exception_handler,
)

from regtech_user_fi_management.config import kc_settings
from regtech_user_fi_management.entities.listeners import setup_dao_listeners
Expand Down Expand Up @@ -42,19 +49,10 @@ async def lifespan(app_: FastAPI):
app = FastAPI(lifespan=lifespan)


@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exception: HTTPException) -> JSONResponse:
log.error(exception, exc_info=True, stack_info=True)
return JSONResponse(status_code=exception.status_code, content={"detail": exception.detail})


@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exception: Exception) -> JSONResponse:
log.error(exception, exc_info=True, stack_info=True)
return JSONResponse(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
content={"detail": "server error"},
)
app.add_exception_handler(RegTechHttpException, regtech_http_exception_handler) # type: ignore[type-arg] # noqa: E501
app.add_exception_handler(RequestValidationError, request_validation_error_handler) # type: ignore[type-arg] # noqa: E501
app.add_exception_handler(HTTPException, http_exception_handler) # type: ignore[type-arg] # noqa: E501
app.add_exception_handler(Exception, general_exception_handler) # type: ignore[type-arg] # noqa: E501


oauth2_scheme = OAuth2AuthorizationCodeBearer(
Expand Down
15 changes: 10 additions & 5 deletions src/regtech_user_fi_management/routers/institutions.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from fastapi import Depends, Request, HTTPException, Response
from fastapi import Depends, Request, Response
from http import HTTPStatus
from regtech_api_commons.oauth2.oauth2_admin import OAuth2Admin
from regtech_user_fi_management.config import kc_settings
Expand Down Expand Up @@ -29,6 +29,7 @@
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.authentication import requires
from regtech_api_commons.models.auth import AuthenticatedUser
from regtech_api_commons.api.exceptions import RegTechHttpException

oauth2_admin = OAuth2Admin(kc_settings)

Expand Down Expand Up @@ -112,7 +113,7 @@ async def get_institution(
):
res = await repo.get_institution(request.state.db_session, lei)
if not res:
raise HTTPException(HTTPStatus.NOT_FOUND, f"{lei} not found.")
raise RegTechHttpException(HTTPStatus.NOT_FOUND, name="Institution Not Found", detail=f"{lei} not found.")
return res


Expand All @@ -127,7 +128,9 @@ async def get_types(request: Request, response: Response, lei: str, type: Instit
else:
response.status_code = HTTPStatus.NO_CONTENT
case "hmda":
raise HTTPException(status_code=HTTPStatus.NOT_IMPLEMENTED, detail="HMDA type not yet supported")
raise RegTechHttpException(
status_code=HTTPStatus.NOT_IMPLEMENTED, name="Not Supported", detail="HMDA type not yet supported"
)


@router.put("/{lei}/types/{type}", response_model=VersionedData[List[SblTypeAssociationDetailsDto]] | None)
Expand All @@ -141,11 +144,13 @@ async def update_types(
if fi := await repo.update_sbl_types(
request.state.db_session, request.user, lei, types_patch.sbl_institution_types
):
return VersionedData(version=fi.version, data=fi.sbl_institution_types) if fi else None
return VersionedData(version=fi.version, data=fi.sbl_institution_types)
else:
response.status_code = HTTPStatus.NO_CONTENT
case "hmda":
raise HTTPException(status_code=HTTPStatus.NOT_IMPLEMENTED, detail="HMDA type not yet supported")
raise RegTechHttpException(
status_code=HTTPStatus.NOT_IMPLEMENTED, name="Not Supported", detail="HMDA type not yet supported"
)


@router.post("/{lei}/domains/", response_model=List[FinancialInsitutionDomainDto], dependencies=[Depends(check_domain)])
Expand Down
11 changes: 7 additions & 4 deletions tests/api/routers/test_institutions_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ def test_invalid_tax_id(self, mocker: MockerFixture, app_fixture: FastAPI, authe
},
)
assert (
res.json()["detail"][0]["msg"] == f"Value error, Invalid tax_id 123456789. {regex_configs.tin.error_text}"
res.json()["error_detail"][0]["msg"]
== f"Value error, Invalid tax_id 123456789. {regex_configs.tin.error_text}"
)
assert res.status_code == 422

Expand Down Expand Up @@ -119,7 +120,9 @@ def test_invalid_lei(self, mocker: MockerFixture, app_fixture: FastAPI, authed_u
"top_holder_rssd_id": 123456,
},
)
assert res.json()["detail"][0]["msg"] == f"Value error, Invalid lei test_Lei. {regex_configs.lei.error_text}"
assert (
res.json()["error_detail"][0]["msg"] == f"Value error, Invalid lei test_Lei. {regex_configs.lei.error_text}"
)
assert res.status_code == 422

def test_create_institution_authed(self, mocker: MockerFixture, app_fixture: FastAPI, authed_user_mock: Mock):
Expand Down Expand Up @@ -254,7 +257,7 @@ def test_create_institution_missing_sbl_type_free_form(
},
)
assert res.status_code == 422
assert "requires additional details." in res.json()["detail"][0]["msg"]
assert "requires additional details." in res.json()["error_detail"][0]["msg"]

def test_create_institution_authed_no_permission(self, app_fixture: FastAPI, auth_mock: Mock):
claims = {
Expand Down Expand Up @@ -392,7 +395,7 @@ def test_add_domains_authed_with_denied_email_domain(
lei_path = "testLeiPath"
res = client.post(f"/v1/institutions/{lei_path}/domains/", json=[{"domain": "testDomain"}])
assert res.status_code == 403
assert "domain denied" in res.json()["detail"]
assert "domain denied" in res.json()["error_detail"]

def test_check_domain_allowed(self, mocker: MockerFixture, app_fixture: FastAPI, authed_user_mock: Mock):
domain_allowed_mock = mocker.patch(
Expand Down
4 changes: 4 additions & 0 deletions tests/app/test_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from pytest_mock import MockerFixture
from sqlalchemy.ext.asyncio import AsyncSession
from regtech_user_fi_management.dependencies import lei_association_check, fi_search_association_check
from regtech_api_commons.api.exceptions import RegTechHttpException
from starlette.authentication import AuthCredentials

import pytest
Expand Down Expand Up @@ -63,6 +64,7 @@ async def method_to_wrap(request: Request, lei: str):
await method_to_wrap(mock_request, lei="NOTMYBANK")
assert e.value.status_code == HTTPStatus.FORBIDDEN
assert "not associated" in e.value.detail
assert isinstance(e.value, RegTechHttpException)


async def test_fi_search_association_check_matching_lei(mock_request: Request):
Expand All @@ -82,6 +84,7 @@ async def method_to_wrap(request: Request, leis: List[str] = [], domain: str = "
await method_to_wrap(mock_request, leis=["NOTMYBANK"])
assert e.value.status_code == HTTPStatus.FORBIDDEN
assert "not associated" in e.value.detail
assert isinstance(e.value, RegTechHttpException)


async def test_fi_search_association_check_matching_domain(mock_request: Request):
Expand Down Expand Up @@ -112,6 +115,7 @@ async def method_to_wrap(request: Request, leis: List[str] = [], domain: str = "
await method_to_wrap(mock_request)
assert e.value.status_code == HTTPStatus.FORBIDDEN
assert "without filter" in e.value.detail
assert isinstance(e.value, RegTechHttpException)


async def test_fi_search_association_check_lei_admin(mock_request: Request):
Expand Down

0 comments on commit 1ffb8b9

Please sign in to comment.