diff --git a/auth-api/migrations/versions/2024_09_20_aa74003de9d8_.py b/auth-api/migrations/versions/2024_09_20_aa74003de9d8_.py index 01d592db9..055640280 100644 --- a/auth-api/migrations/versions/2024_09_20_aa74003de9d8_.py +++ b/auth-api/migrations/versions/2024_09_20_aa74003de9d8_.py @@ -118,7 +118,7 @@ def upgrade(): sa.Column("country", sa.String(length=20), autoincrement=False, nullable=True), sa.Column("postal_code", sa.String(length=15), autoincrement=False, nullable=True), sa.Column("delivery_instructions", sa.String(length=4096), autoincrement=False, nullable=True), - sa.Column("phone", sa.String(length=15), autoincrement=False, nullable=True), + sa.Column("phone", sa.String(length=20), autoincrement=False, nullable=True), sa.Column("phone_extension", sa.String(length=10), autoincrement=False, nullable=True), sa.Column("email", sa.String(length=100), autoincrement=False, nullable=True), sa.Column("entity_id", sa.Integer(), autoincrement=False, nullable=True), diff --git a/auth-api/migrations/versions/414773385d34_initialization_mvp.py b/auth-api/migrations/versions/414773385d34_initialization_mvp.py index b67484543..54df4733d 100644 --- a/auth-api/migrations/versions/414773385d34_initialization_mvp.py +++ b/auth-api/migrations/versions/414773385d34_initialization_mvp.py @@ -73,7 +73,7 @@ def upgrade(): sa.Column('country', sa.String(length=2), nullable=True), sa.Column('postal_code', sa.String(length=10), nullable=True), sa.Column('delivery_instructions', sa.String(length=4096), nullable=True), - sa.Column('phone', sa.String(length=15), nullable=True), + sa.Column('phone', sa.String(length=20), nullable=True), sa.Column('phone_extension', sa.String(length=10), nullable=True), sa.Column('email', sa.String(length=100), nullable=True), sa.Column('entity_id', sa.Integer(), nullable=True), diff --git a/auth-api/migrations/versions/5cf63f567a62_adding_version.py b/auth-api/migrations/versions/5cf63f567a62_adding_version.py index c612e6f9b..80caf75ba 100644 --- a/auth-api/migrations/versions/5cf63f567a62_adding_version.py +++ b/auth-api/migrations/versions/5cf63f567a62_adding_version.py @@ -133,7 +133,7 @@ def upgrade(): sa.Column('country', sa.String(length=20), autoincrement=False, nullable=True), sa.Column('postal_code', sa.String(length=10), autoincrement=False, nullable=True), sa.Column('delivery_instructions', sa.String(length=4096), autoincrement=False, nullable=True), - sa.Column('phone', sa.String(length=15), autoincrement=False, nullable=True), + sa.Column('phone', sa.String(length=20), autoincrement=False, nullable=True), sa.Column('phone_extension', sa.String(length=10), autoincrement=False, nullable=True), sa.Column('email', sa.String(length=100), autoincrement=False, nullable=True), sa.Column('entity_id', sa.Integer(), autoincrement=False, nullable=True), diff --git a/auth-api/src/auth_api/models/contact.py b/auth-api/src/auth_api/models/contact.py index 4fad87fdd..970e995ec 100644 --- a/auth-api/src/auth_api/models/contact.py +++ b/auth-api/src/auth_api/models/contact.py @@ -37,7 +37,7 @@ class Contact(Versioned, BaseModel): # pylint: disable=too-few-public-methods country = Column("country", String(20)) postal_code = Column("postal_code", String(15)) delivery_instructions = Column("delivery_instructions", String(4096)) - phone = Column("phone", String(15)) + phone = Column("phone", String(20)) phone_extension = Column("phone_extension", String(10)) email = Column("email", String(100)) # MVP contact has been migrated over to the contact linking table (revised data model) diff --git a/auth-api/src/auth_api/resources/endpoints.py b/auth-api/src/auth_api/resources/endpoints.py index 208bf0dbe..acaf1d674 100644 --- a/auth-api/src/auth_api/resources/endpoints.py +++ b/auth-api/src/auth_api/resources/endpoints.py @@ -18,6 +18,7 @@ from flask import Blueprint, Flask # noqa: I001 from .v1 import v1_endpoint +from .v2 import v2_endpoint TEST_BLUEPRINT = Blueprint("TEST", __name__, url_prefix="/test") @@ -39,6 +40,7 @@ def init_app(self, app: Flask): def _mount_endpoints(self): """Mount the endpoints of the system.""" v1_endpoint.init_app(self.app) + v2_endpoint.init_app(self.app) if os.getenv("FLASK_ENV", "production") in ["development", "testing"]: self.app.register_blueprint(TEST_BLUEPRINT) diff --git a/auth-api/src/auth_api/resources/org_utils.py b/auth-api/src/auth_api/resources/org_utils.py new file mode 100644 index 000000000..9e5fd8273 --- /dev/null +++ b/auth-api/src/auth_api/resources/org_utils.py @@ -0,0 +1,86 @@ +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Shared utility functions for org endpoints.""" + +from dataclasses import dataclass +from http import HTTPStatus +from typing import Self + +from auth_api.exceptions import BusinessException +from auth_api.schemas import utils as schema_utils +from auth_api.services import Org as OrgService +from auth_api.services import User as UserService + + +@dataclass +class Result[T]: + """Encapsulates success value or error response.""" + + value: T | None = None + error: dict | None = None + status: HTTPStatus = HTTPStatus.OK + + @property + def is_success(self) -> bool: + """Return True if the result is successful.""" + return self.error is None + + @property + def is_failure(self) -> bool: + """Return True if the result is a failure.""" + return self.error is not None + + @classmethod + def success(cls, value: T) -> Self: + """Create a successful result with the given value.""" + return cls(value=value) + + @classmethod + def failure(cls, message: str, status: HTTPStatus, **extras) -> Self: + """Create a failure result with the given error message and status.""" + return cls(error={"message": message, **extras}, status=status) + + +def validate_and_get_user() -> Result: + """Validate request and get authenticated user.""" + user = UserService.find_by_jwt_token() + if not user: + return Result.failure("Not authorized to perform this action", HTTPStatus.UNAUTHORIZED) + return Result.success(user) + + +def validate_schema(data: dict, schema_name: str) -> Result: + """Validate data against a schema.""" + valid, errors = schema_utils.validate(data, schema_name) + if not valid: + return Result.failure(schema_utils.serialize(errors), HTTPStatus.BAD_REQUEST) + return Result.success(True) + + +def create_org(org_info: dict, user_id: int) -> Result: + """Create an organization.""" + try: + org = OrgService.create_org(org_info, user_id) + return Result.success(org.as_dict()) + except BusinessException as e: + return Result.failure(e.message, e.status_code, code=e.code, detail=e.detail) + + +def add_contact(org_id: int, contact_info: dict) -> Result: + """Add contact to organization.""" + try: + contact = OrgService.add_contact(org_id, contact_info) + return Result.success(contact.as_dict()) + except BusinessException as e: + return Result.failure(e.message, e.status_code, code=e.code) diff --git a/auth-api/src/auth_api/resources/v1/org.py b/auth-api/src/auth_api/resources/v1/org.py index fb254c539..e7d541a45 100644 --- a/auth-api/src/auth_api/resources/v1/org.py +++ b/auth-api/src/auth_api/resources/v1/org.py @@ -27,6 +27,7 @@ from auth_api.models.dataclass import Affiliation as AffiliationData from auth_api.models.dataclass import AffiliationSearchDetails, DeleteAffiliationRequest, SimpleOrgSearch from auth_api.models.org import OrgSearch # noqa: I001 +from auth_api.resources import org_utils from auth_api.schemas import InvitationSchema, MembershipSchema from auth_api.schemas import utils as schema_utils from auth_api.services import Affidavit as AffidavitService @@ -158,25 +159,17 @@ def post_organization(): If the org already exists, update the attributes. """ request_json = request.get_json() - valid_format, errors = schema_utils.validate(request_json, "org") - if not valid_format: - return {"message": schema_utils.serialize(errors)}, HTTPStatus.BAD_REQUEST - try: - user = UserService.find_by_jwt_token() - if user is None: - response, status = {"message": "Not authorized to perform this action"}, HTTPStatus.UNAUTHORIZED - return response, status - response, status = OrgService.create_org(request_json, user.identifier).as_dict(), HTTPStatus.CREATED - except BusinessException as exception: - response, status = ( - { - "code": exception.code, - "message": exception.message, - "detail": exception.detail, - }, - exception.status_code, - ) - return response, status + if (result := org_utils.validate_schema(request_json, "org")).is_failure: + return result.error, result.status + + if (result := org_utils.validate_and_get_user()).is_failure: + return result.error, result.status + user = result.value + + if (result := org_utils.create_org(request_json, user.identifier)).is_failure: + return result.error, result.status + + return result.value, HTTPStatus.CREATED @bp.route("/", methods=["GET", "OPTIONS"]) @@ -326,15 +319,13 @@ def get(org_id): def post_organization_contact(org_id): """Create a new contact for the specified org.""" request_json = request.get_json() - valid_format, errors = schema_utils.validate(request_json, "contact") - if not valid_format: - return {"message": schema_utils.serialize(errors)}, HTTPStatus.BAD_REQUEST + if (result := org_utils.validate_schema(request_json, "contact")).is_failure: + return result.error, result.status - try: - response, status = OrgService.add_contact(org_id, request_json).as_dict(), HTTPStatus.CREATED - except BusinessException as exception: - response, status = {"code": exception.code, "message": exception.message}, exception.status_code - return response, status + if (result := org_utils.add_contact(org_id, request_json)).is_failure: + return result.error, result.status + + return result.value, HTTPStatus.CREATED @bp.route("//contacts", methods=["PUT"]) diff --git a/auth-api/src/auth_api/resources/v2/__init__.py b/auth-api/src/auth_api/resources/v2/__init__.py new file mode 100644 index 000000000..64556e41e --- /dev/null +++ b/auth-api/src/auth_api/resources/v2/__init__.py @@ -0,0 +1,37 @@ +# Copyright © 2025 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Exposes all of the resource endpoints mounted in Flask-Blueprints - V2.""" + +from flask import Flask # noqa: TC002 + +from .org import bp as org_bp + + +class V2Endpoint: + """Setup all the V2 Endpoints.""" + + def __init__(self): + """Create the endpoint setup, without initializations.""" + self.app: Flask | None = None + + def init_app(self, app): + """Register and initialize the Endpoint setup.""" + if not app: + raise Exception("Cannot initialize without a Flask App.") # pylint: disable=broad-exception-raised + + self.app = app + self.app.register_blueprint(org_bp) + + +v2_endpoint = V2Endpoint() diff --git a/auth-api/src/auth_api/resources/v2/org.py b/auth-api/src/auth_api/resources/v2/org.py new file mode 100644 index 000000000..f2a3986e3 --- /dev/null +++ b/auth-api/src/auth_api/resources/v2/org.py @@ -0,0 +1,73 @@ +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""API endpoints for managing an Org resource - V2.""" + +from http import HTTPStatus + +from flask import Blueprint, request +from flask_cors import cross_origin + +from auth_api.resources import org_utils +from auth_api.utils.auth import jwt as _jwt +from auth_api.utils.endpoints_enums import EndpointEnum +from auth_api.utils.role_validator import validate_roles +from auth_api.utils.roles import Role + +bp = Blueprint("ORGS_V2", __name__, url_prefix=f"{EndpointEnum.API_V2.value}/orgs") + + +@bp.route("", methods=["POST"]) +@cross_origin(origins="*") +@validate_roles( + allowed_roles=[Role.PUBLIC_USER.value, Role.STAFF_CREATE_ACCOUNTS.value, Role.SYSTEM.value], + not_allowed_roles=[Role.ANONYMOUS_USER.value], +) +@_jwt.has_one_of_roles([Role.PUBLIC_USER.value, Role.STAFF_CREATE_ACCOUNTS.value, Role.SYSTEM.value]) +def post_organization(): + """Post a new org with contact using the request body. + + Creates an organization and then adds a contact if provided. + Validates both org and contact schemas before processing. + """ + request_json = request.get_json() + if not request_json: + return {"message": "Request body cannot be empty"}, HTTPStatus.BAD_REQUEST + + org_info = request_json.copy() + contact_info = org_info.pop("contact", None) + + if contact_info: + org_info.pop("mailingAddress", None) + + if (result := org_utils.validate_schema(org_info, "org")).is_failure: + return result.error, result.status + + if contact_info: + if (result := org_utils.validate_schema(contact_info, "contact")).is_failure: + return result.error, result.status + + if (result := org_utils.validate_and_get_user()).is_failure: + return result.error, result.status + user = result.value + + if (result := org_utils.create_org(org_info, user.identifier)).is_failure: + return result.error, result.status + org_dict = result.value + + if contact_info and (org_id := org_dict.get("id")): + if (result := org_utils.add_contact(org_id, contact_info)).is_failure: + return result.error, result.status + org_dict["contact"] = result.value + + return org_dict, HTTPStatus.CREATED diff --git a/auth-api/src/auth_api/schemas/schemas/contact.json b/auth-api/src/auth_api/schemas/schemas/contact.json index f1c9aec9c..5a8676db2 100644 --- a/auth-api/src/auth_api/schemas/schemas/contact.json +++ b/auth-api/src/auth_api/schemas/schemas/contact.json @@ -14,7 +14,8 @@ "examples": [ "123 Roundabout Lane" ], - "pattern": "^(.*)$" + "pattern": "^(.*)$", + "maxLength": 250 }, "streetAdditional": { "$id": "#/properties/street_additional", @@ -24,7 +25,8 @@ "examples": [ "Unit 1" ], - "pattern": "^(.*)$" + "pattern": "^(.*)$", + "maxLength": 250 }, "city": { "$id": "#/properties/city", @@ -34,7 +36,8 @@ "examples": [ "Victoria" ], - "pattern": "^(.*)$" + "pattern": "^(.*)$", + "maxLength": 100 }, "region": { "$id": "#/properties/region", @@ -44,7 +47,8 @@ "examples": [ "British Columbia" ], - "pattern": "^(.*)$" + "pattern": "^(.*)$", + "maxLength": 100 }, "country": { "$id": "#/properties/country", @@ -54,7 +58,8 @@ "examples": [ "CA" ], - "pattern": "^(.*)$" + "pattern": "^(.*)$", + "maxLength": 20 }, "postalCode": { "$id": "#/properties/postal_code", @@ -64,7 +69,8 @@ "examples": [ "V1A 1A1" ], - "pattern": "^(.*)$" + "pattern": "^(.*)$", + "maxLength": 15 }, "deliveryInstructions": { "$id": "#/properties/delivery_instructions", @@ -74,7 +80,8 @@ "examples": [ "Ring buzzer 123" ], - "pattern": "^(.*)$" + "pattern": "^(.*)$", + "maxLength": 4096 }, "phone": { "$id": "#/properties/phone", @@ -84,7 +91,8 @@ "examples": [ "111-222-3333" ], - "pattern": "^(.*)$" + "pattern": "^(.*)$", + "maxLength": 20 }, "phoneExtension": { "$id": "#/properties/phone_extension", @@ -94,7 +102,8 @@ "examples": [ "123" ], - "pattern": "^(.*)$" + "pattern": "^(.*)$", + "maxLength": 10 }, "email": { "$id": "#/properties/email", @@ -104,7 +113,8 @@ "examples": [ "abc123@mail.com" ], - "pattern": "(^[a-zA-Z0-9!#$%&'*+-/=?^_`{|.]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$)" + "pattern": "(^[a-zA-Z0-9!#$%&'*+-/=?^_`{|.]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$)", + "maxLength": 100 } } } diff --git a/auth-api/src/auth_api/services/keycloak.py b/auth-api/src/auth_api/services/keycloak.py index da0c32323..f44dd2126 100644 --- a/auth-api/src/auth_api/services/keycloak.py +++ b/auth-api/src/auth_api/services/keycloak.py @@ -46,6 +46,7 @@ class KCRequestConfig: realm: str timeout: int + class KeycloakService: """For Keycloak services.""" @@ -313,7 +314,6 @@ def kc_user_to_dict(user: dict) -> dict: """Return a dict representation of the KC user.""" return {"firstName": user["firstName"], "lastName": user["lastName"], "email": user["email"]} - @staticmethod def get_default_kc_request_config(): """Return default request configuration for Keycloak API calls.""" @@ -322,7 +322,7 @@ def get_default_kc_request_config(): return KCRequestConfig( base_url=config.get("KEYCLOAK_BASE_URL"), realm=config.get("KEYCLOAK_REALMNAME"), - timeout=config.get("CONNECT_TIMEOUT", 60) + timeout=config.get("CONNECT_TIMEOUT", 60), ) @staticmethod @@ -380,8 +380,8 @@ def get_user_emails_with_role(role: str): for kc_group in kc_groups: group_members.extend( [ - KeycloakService.kc_user_to_dict(member) for member in - KeycloakService.get_keycloak_members_for_group(kc_group["id"]) + KeycloakService.kc_user_to_dict(member) + for member in KeycloakService.get_keycloak_members_for_group(kc_group["id"]) ] ) users.extend(group_members) diff --git a/auth-api/src/auth_api/utils/endpoints_enums.py b/auth-api/src/auth_api/utils/endpoints_enums.py index cd284d1d5..d690431f6 100644 --- a/auth-api/src/auth_api/utils/endpoints_enums.py +++ b/auth-api/src/auth_api/utils/endpoints_enums.py @@ -20,6 +20,7 @@ class EndpointEnum(str, Enum): """Endpoint route url paths.""" API_V1 = "/api/v1" + API_V2 = "/api/v2" API = "/api" TEST_API = "/test" DEFAULT_API = API_V1 diff --git a/auth-api/tests/unit/api/test_org.py b/auth-api/tests/unit/api/test_org.py index bf7f924ac..b629aa57b 100644 --- a/auth-api/tests/unit/api/test_org.py +++ b/auth-api/tests/unit/api/test_org.py @@ -101,6 +101,56 @@ def test_add_org(client, jwt, session, keycloak_mock, org_info): # pylint:disab assert schema_utils.validate(rv.json, "org_response")[0] +@pytest.mark.parametrize( + "has_contact, expected_status_code", [("with_contact", HTTPStatus.CREATED), ("no_contact", HTTPStatus.CREATED)] +) +def test_add_org_v2_with_contact(client, jwt, session, keycloak_mock, has_contact, expected_status_code): # pylint:disable=unused-argument + """Assert that an org can be POSTed with contact using v2 endpoint.""" + headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.public_user_role) + rv = client.post("/api/v1/users", headers=headers, content_type="application/json") + + org_data = TestOrgInfo.org1.copy() + + if has_contact == "with_contact": + org_data["contact"] = TestContactInfo.contact1 + + rv = client.post("/api/v2/orgs", data=json.dumps(org_data), headers=headers, content_type="application/json") + assert rv.status_code == expected_status_code + dictionary = json.loads(rv.data) + assert dictionary["name"] == TestOrgInfo.org1["name"] + if has_contact == "with_contact": + assert "contact" in dictionary + assert dictionary["contact"]["email"] == TestContactInfo.contact1["email"] + assert dictionary["contact"]["phone"] == TestContactInfo.contact1["phone"] + else: + assert "contact" not in dictionary # Ensure no contact is present in the response + assert schema_utils.validate(rv.json, "org_response")[0] + + +def test_add_org_v2_with_contact_fields_too_long(client, jwt, session, keycloak_mock): # pylint:disable=unused-argument + """Assert that an org with contact fields exceeding maxLength returns 400.""" + headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.public_user_role) + rv = client.post("/api/v1/users", headers=headers, content_type="application/json") + + org_data = TestOrgInfo.org1.copy() + org_data["contact"] = { + "email": "a" * 101, + "phone": "1" * 16, + "phoneExtension": "1" * 11, + "street": "a" * 251, + "streetAdditional": "a" * 251, + "city": "a" * 101, + "region": "a" * 101, + "country": "a" * 21, + "postalCode": "a" * 16, + "deliveryInstructions": "a" * 4097, + } + + rv = client.post("/api/v2/orgs", data=json.dumps(org_data), headers=headers, content_type="application/json") + assert rv.status_code == HTTPStatus.BAD_REQUEST + assert "message" in rv.json + + @pytest.mark.parametrize( "org_info", [ diff --git a/auth-api/tests/unit/services/test_keycloak.py b/auth-api/tests/unit/services/test_keycloak.py index e7d136c13..e7606a5f0 100644 --- a/auth-api/tests/unit/services/test_keycloak.py +++ b/auth-api/tests/unit/services/test_keycloak.py @@ -16,6 +16,7 @@ Test-Suite to ensure that the Business Service is working as expected. """ + from unittest.mock import patch import pytest @@ -287,60 +288,30 @@ def test_service_account_by_client_name(session): @patch("auth_api.services.keycloak.KeycloakService.get_keycloak_groups_for_role") @patch("auth_api.services.keycloak.KeycloakService.get_keycloak_members_for_group") def test_get_user_emails_with_role( - mock_get_group_members, - mock_get_groups_for_role, - mock_get_users_for_role, - mock_get_admin_token, - session, + mock_get_group_members, + mock_get_groups_for_role, + mock_get_users_for_role, + mock_get_admin_token, + session, ): """Test get_user_emails_with_role returns users from both direct role assignment and group membership.""" mock_get_admin_token.return_value = "mock_token" # Set up for direct role assignment to a user and test handling logic when users are returned kc_users = [ - { - "id": "user1", - "firstName": "John", - "lastName": "Doe", - "email": "john.doe@example.com" - }, - { - "id": "user2", - "firstName": "Jane", - "lastName": "Smith", - "email": "jane.smith@example.com" - } + {"id": "user1", "firstName": "John", "lastName": "Doe", "email": "john.doe@example.com"}, + {"id": "user2", "firstName": "Jane", "lastName": "Smith", "email": "jane.smith@example.com"}, ] # Groups and group members to test logic when inherited group roles exist - kc_groups = [ - {"id": "group1", "name": "test_group_1"}, - {"id": "group2", "name": "test_group_2"} - ] + kc_groups = [{"id": "group1", "name": "test_group_1"}, {"id": "group2", "name": "test_group_2"}] group1_members = [ - { - "id": "user3", - "firstName": "Bob", - "lastName": "Johnson", - "email": "bob.johnson@example.com" - }, - { - "id": "user1", - "firstName": "John", - "lastName": "Doe", - "email": "john.doe@example.com" - } + {"id": "user3", "firstName": "Bob", "lastName": "Johnson", "email": "bob.johnson@example.com"}, + {"id": "user1", "firstName": "John", "lastName": "Doe", "email": "john.doe@example.com"}, ] - group2_members = [ - { - "id": "user4", - "firstName": "Alice", - "lastName": "Brown", - "email": "alice.brown@example.com" - } - ] + group2_members = [{"id": "user4", "firstName": "Alice", "lastName": "Brown", "email": "alice.brown@example.com"}] mock_get_users_for_role.return_value = kc_users mock_get_groups_for_role.return_value = kc_groups @@ -367,7 +338,7 @@ def mock_get_members(group_id): "john.doe@example.com", "jane.smith@example.com", "bob.johnson@example.com", - "alice.brown@example.com" + "alice.brown@example.com", ] for email in expected_emails: @@ -385,11 +356,11 @@ def mock_get_members(group_id): @patch("auth_api.services.keycloak.KeycloakService.get_keycloak_groups_for_role") @patch("auth_api.services.keycloak.KeycloakService.get_keycloak_members_for_group") def test_get_user_emails_with_role_empty_results( - mock_get_group_members, - mock_get_groups_for_role, - mock_get_users_for_role, - mock_get_admin_token, - session, + mock_get_group_members, + mock_get_groups_for_role, + mock_get_users_for_role, + mock_get_admin_token, + session, ): """Test get_user_emails_with_role handles empty results correctly.""" mock_get_admin_token.return_value = "mock_token" @@ -409,11 +380,11 @@ def test_get_user_emails_with_role_empty_results( @patch("auth_api.services.keycloak.KeycloakService.get_keycloak_groups_for_role") @patch("auth_api.services.keycloak.KeycloakService.get_keycloak_members_for_group") def test_get_user_emails_with_role_none_users( - mock_get_group_members, - mock_get_groups_for_role, - mock_get_users_for_role, - mock_get_admin_token, - session, + mock_get_group_members, + mock_get_groups_for_role, + mock_get_users_for_role, + mock_get_admin_token, + session, ): """Test get_user_emails_with_role handles None return from get_keycloak_users_for_role.""" mock_get_admin_token.return_value = "mock_token" @@ -422,14 +393,7 @@ def test_get_user_emails_with_role_none_users( role_groups = [{"id": "group1", "name": "test_group"}] mock_get_groups_for_role.return_value = role_groups - group_members = [ - { - "id": "user1", - "firstName": "Test", - "lastName": "User", - "email": "test.user@example.com" - } - ] + group_members = [{"id": "user1", "firstName": "Test", "lastName": "User", "email": "test.user@example.com"}] mock_get_group_members.return_value = group_members result = KEYCLOAK_SERVICE.get_user_emails_with_role("test_role") @@ -439,13 +403,12 @@ def test_get_user_emails_with_role_none_users( assert result[0]["lastName"] == "User" - @patch("auth_api.services.keycloak.KeycloakService._get_admin_token") @patch("auth_api.services.keycloak.KeycloakService.get_keycloak_users_for_role") def test_get_user_emails_with_role_nonexistent_role( - mock_get_users_for_role, - mock_get_admin_token, - session, + mock_get_users_for_role, + mock_get_admin_token, + session, ): """Test get_user_emails_with_role raises BusinessException when role doesn't exist in Keycloak.""" mock_get_admin_token.return_value = "mock_token"