diff --git a/api/openapi.generated.yml b/api/openapi.generated.yml index ceabdda5e..ab83a8d54 100644 --- a/api/openapi.generated.yml +++ b/api/openapi.generated.yml @@ -315,6 +315,38 @@ paths: sort_direction: descending security: - ApiKeyAuth: [] + /v1/users/{user_id}: + get: + parameters: + - in: path + name: user_id + schema: + type: string + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/UserGetResponse' + description: Successful response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: Authentication error + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: Not found + tags: + - User v1 + summary: User Get + security: + - ApiJwtAuth: [] /v1/opportunities/{opportunity_id}: get: parameters: @@ -1490,6 +1522,22 @@ components: - object allOf: - $ref: '#/components/schemas/OpportunityFacetV1' + UserGetResponse: + type: object + properties: + message: + type: string + description: The message to return + example: Success + data: + type: + - object + allOf: + - $ref: '#/components/schemas/User' + status_code: + type: integer + description: The HTTP status code + example: 200 OpportunityAttachmentV1: type: object properties: diff --git a/api/src/api/users/user_routes.py b/api/src/api/users/user_routes.py index 2e06ad05e..d60601c01 100644 --- a/api/src/api/users/user_routes.py +++ b/api/src/api/users/user_routes.py @@ -1,4 +1,5 @@ import logging +from uuid import UUID from src.adapters import db from src.adapters.db import flask_db @@ -6,10 +7,15 @@ from src.api.route_utils import raise_flask_error from src.api.users import user_schemas from src.api.users.user_blueprint import user_blueprint -from src.api.users.user_schemas import UserTokenLogoutResponseSchema, UserTokenRefreshResponseSchema +from src.api.users.user_schemas import ( + UserGetResponseSchema, + UserTokenLogoutResponseSchema, + UserTokenRefreshResponseSchema, +) from src.auth.api_jwt_auth import api_jwt_auth, refresh_token_expiration from src.auth.api_key_auth import api_key_auth from src.db.models.user_models import UserTokenSession +from src.services.users.get_user import get_user logger = logging.getLogger(__name__) @@ -41,22 +47,21 @@ def user_token(x_oauth_login_gov: dict) -> response.ApiResponse: raise_flask_error(400, message) -@user_blueprint.post("/token/refresh") -@user_blueprint.output(UserTokenRefreshResponseSchema) +@user_blueprint.post("/token/logout") +@user_blueprint.output(UserTokenLogoutResponseSchema) @user_blueprint.doc(responses=[200, 401]) @user_blueprint.auth_required(api_jwt_auth) @flask_db.with_db_session() -def user_token_refresh(db_session: db.Session) -> response.ApiResponse: - logger.info("POST /v1/users/token/refresh") +def user_token_logout(db_session: db.Session) -> response.ApiResponse: + logger.info("POST /v1/users/token/logout") user_token_session: UserTokenSession = api_jwt_auth.current_user # type: ignore - with db_session.begin(): - refresh_token_expiration(user_token_session) + user_token_session.is_valid = False db_session.add(user_token_session) logger.info( - "Refreshed a user token", + "Logged out a user", extra={ "user_token_session.token_id": str(user_token_session.token_id), "user_token_session.user_id": str(user_token_session.user_id), @@ -66,21 +71,22 @@ def user_token_refresh(db_session: db.Session) -> response.ApiResponse: return response.ApiResponse(message="Success") -@user_blueprint.post("/token/logout") -@user_blueprint.output(UserTokenLogoutResponseSchema) +@user_blueprint.post("/token/refresh") +@user_blueprint.output(UserTokenRefreshResponseSchema) @user_blueprint.doc(responses=[200, 401]) @user_blueprint.auth_required(api_jwt_auth) @flask_db.with_db_session() -def user_token_logout(db_session: db.Session) -> response.ApiResponse: - logger.info("POST /v1/users/token/logout") +def user_token_refresh(db_session: db.Session) -> response.ApiResponse: + logger.info("POST /v1/users/token/refresh") user_token_session: UserTokenSession = api_jwt_auth.current_user # type: ignore + with db_session.begin(): - user_token_session.is_valid = False + refresh_token_expiration(user_token_session) db_session.add(user_token_session) logger.info( - "Logged out a user", + "Refreshed a user token", extra={ "user_token_session.token_id": str(user_token_session.token_id), "user_token_session.user_id": str(user_token_session.user_id), @@ -88,3 +94,22 @@ def user_token_logout(db_session: db.Session) -> response.ApiResponse: ) return response.ApiResponse(message="Success") + + +@user_blueprint.get("/") +@user_blueprint.output(UserGetResponseSchema) +@user_blueprint.doc(responses=[200, 401]) +@user_blueprint.auth_required(api_jwt_auth) +@flask_db.with_db_session() +def user_get(db_session: db.Session, user_id: UUID) -> response.ApiResponse: + logger.info("GET /v1/users/:user_id") + + user_token_session: UserTokenSession = api_jwt_auth.current_user # type: ignore + + if user_token_session.user_id == user_id: + with db_session.begin(): + user = get_user(db_session, user_id) + + return response.ApiResponse(message="Success", data=user) + + raise_flask_error(401, "Unauthorized user") diff --git a/api/src/api/users/user_schemas.py b/api/src/api/users/user_schemas.py index 82fdc644a..2555a7286 100644 --- a/api/src/api/users/user_schemas.py +++ b/api/src/api/users/user_schemas.py @@ -61,3 +61,7 @@ class UserTokenRefreshResponseSchema(AbstractResponseSchema): class UserTokenLogoutResponseSchema(AbstractResponseSchema): # No data returned data = fields.MixinField(metadata={"example": None}) + + +class UserGetResponseSchema(AbstractResponseSchema): + data = fields.Nested(UserSchema) diff --git a/api/src/services/users/__init__.py b/api/src/services/users/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/src/services/users/get_user.py b/api/src/services/users/get_user.py new file mode 100644 index 000000000..125390574 --- /dev/null +++ b/api/src/services/users/get_user.py @@ -0,0 +1,18 @@ +from uuid import UUID + +from sqlalchemy import select + +from src.adapters import db +from src.db.models.user_models import LinkExternalUser + + +def _fetch_user(db_session: db.Session, user_id: UUID) -> LinkExternalUser | None: + stmt = select(LinkExternalUser).where(LinkExternalUser.user_id == user_id) + + user = db_session.execute(stmt).scalar_one_or_none() + + return user + + +def get_user(db_session: db.Session, user_id: UUID) -> LinkExternalUser | None: + return _fetch_user(db_session, user_id) diff --git a/api/tests/src/api/users/test_user_route_get.py b/api/tests/src/api/users/test_user_route_get.py new file mode 100644 index 000000000..8a24beda6 --- /dev/null +++ b/api/tests/src/api/users/test_user_route_get.py @@ -0,0 +1,30 @@ +import uuid + +from src.auth.api_jwt_auth import create_jwt_for_user +from tests.src.db.models.factories import LinkExternalUserFactory + +################ +# GET user tests +################ + + +def test_get_user_200(enable_factory_create, client, db_session, api_auth_token): + external_user = LinkExternalUserFactory.create() + token, _ = create_jwt_for_user(external_user.user, db_session) + db_session.commit() + + resp = client.get(f"/v1/users/{external_user.user_id}", headers={"X-SGG-Token": token}) + + assert resp.status_code == 200 + assert resp.get_json()["data"]["user_id"] == str(external_user.user_id) + + +def test_get_user_401(enable_factory_create, client, db_session, api_auth_token): + external_user = LinkExternalUserFactory.create() + token, _ = create_jwt_for_user(external_user.user, db_session) + db_session.commit() + + random_uuid = str(uuid.uuid4()) + resp = client.get(f"/v1/users/{random_uuid}", headers={"X-SGG-Token": token}) + + assert resp.status_code == 401 diff --git a/api/tests/src/db/models/factories.py b/api/tests/src/db/models/factories.py index 167a026cf..ebad78f2b 100644 --- a/api/tests/src/db/models/factories.py +++ b/api/tests/src/db/models/factories.py @@ -1858,7 +1858,7 @@ class Meta: user = factory.SubFactory(UserFactory) user_id = factory.LazyAttribute(lambda s: s.user.user_id) - external_user_type_id = factory.fuzzy.FuzzyChoice(ExternalUserType) + external_user_type = factory.fuzzy.FuzzyChoice(ExternalUserType) email = factory.Faker("email")