diff --git a/src/server/messages/error.py b/src/server/messages/error.py index fab10355..acb1b480 100644 --- a/src/server/messages/error.py +++ b/src/server/messages/error.py @@ -657,7 +657,7 @@ FAILED_CREATE_FILE_RECORD = LogMessage( "E620", - "Failed to create file record for file (id: %(file_id)s) in database.", + "Failed to create file record for file (path: %(file_path)s) in database.", ) INVALID_FILE_STRUCTURE = LogMessage( diff --git a/tests/unit/api/test_search.py b/tests/unit/api/test_search.py new file mode 100644 index 00000000..c098149a --- /dev/null +++ b/tests/unit/api/test_search.py @@ -0,0 +1,52 @@ +import inspect + +from flask import Flask +from pytest_mock import MockerFixture + +from server.api import search as search_api +from server.api.schemas import ErrorResponse, GlobalSearchQuery, GlobalSearchResult +from server.entities.search_request import SearchResult +from server.exc import InvalidQueryError + + +def test_get_success(app: Flask, mocker: MockerFixture) -> None: + query = GlobalSearchQuery(q="test", l=10) + excepted_status = 200 + result_repo = SearchResult(total=1, page_size=10, offset=0, resources=[]) + result_group = SearchResult(total=2, page_size=10, offset=0, resources=[]) + result_user = SearchResult(total=3, page_size=10, offset=0, resources=[]) + mocker.patch("server.services.repositories.search", return_value=result_repo) + mocker.patch("server.services.groups.search", return_value=result_group) + mocker.patch("server.services.users.search", return_value=result_user) + original_func = inspect.unwrap(search_api.get) + resp, status = original_func(query) + assert status == excepted_status + assert isinstance(resp, GlobalSearchResult) + + +def test_get_partial_invalid_query_error(app: Flask, mocker: MockerFixture) -> None: + query = GlobalSearchQuery(q="test", l=10) + excepted_status = 200 + + result_repo = SearchResult(total=1, page_size=10, offset=0, resources=[]) + mocker.patch("server.services.repositories.search", return_value=result_repo) + mocker.patch("server.services.groups.search", side_effect=InvalidQueryError("fail")) + result_user = SearchResult(total=3, page_size=10, offset=0, resources=[]) + mocker.patch("server.services.users.search", return_value=result_user) + original_func = inspect.unwrap(search_api.get) + resp, status = original_func(query) + assert status == excepted_status + assert isinstance(resp, GlobalSearchResult) + + +def test_get_all_invalid_query_error(app: Flask, mocker: MockerFixture) -> None: + query = GlobalSearchQuery(q="test", l=10) + excepted_status = 400 + mocker.patch("server.services.repositories.search", side_effect=InvalidQueryError("fail")) + mocker.patch("server.services.groups.search", side_effect=InvalidQueryError("fail")) + mocker.patch("server.services.users.search", side_effect=InvalidQueryError("fail")) + original_func = inspect.unwrap(search_api.get) + resp, status = original_func(query) + assert status == excepted_status + assert isinstance(resp, ErrorResponse) + assert resp.message == "Failed to get search results" diff --git a/tests/unit/api/test_users.py b/tests/unit/api/test_users.py index d380b0c3..fa5e8adb 100644 --- a/tests/unit/api/test_users.py +++ b/tests/unit/api/test_users.py @@ -10,7 +10,7 @@ from server.entities.login_user import LoginUser from server.entities.search_request import SearchResult from server.entities.user_detail import RepositoryRole, UserDetail -from server.exc import InvalidFormError, InvalidQueryError, ResourceInvalid, ResourceNotFound +from server.exc import InvalidExportError, InvalidFormError, InvalidQueryError, ResourceInvalid, ResourceNotFound from server.messages import E @@ -286,36 +286,6 @@ def test_id_put_invalid_form_error_400(app: Flask, mocker: MockerFixture) -> Non assert result == expected_result -def test_id_put_sys_exc_info_branch(app: Flask, mocker: MockerFixture): - """Covers id_put: sys.exc_info()[0] is not None -> traceback.print_exc() branch (without raising exception).""" - expected_status = 200 - user = UserDetail( - id="dummy", - user_name="user20", - emails=["user20@example.com"], - eppns=["eppn20"], - preferred_language="en", - repository_roles=[RepositoryRole(id="repo20", user_role=USER_ROLES.REPOSITORY_ADMIN)], - is_system_admin=False, - ) - dummy_user = LoginUser( - eppn="dummy", - is_member_of="", - user_name="dummy", - map_id="not_u20", - session_id="dummy", - ) - mocker.patch("server.api.users.current_user", dummy_user) - mocker.patch("server.services.users.update", return_value=user) - mocker.patch("sys.exc_info", return_value=(Exception, None, None)) - print_exc_mock = mocker.patch("traceback.print_exc") - original_func = inspect.unwrap(users_api.id_put) - result, status = original_func("u20", user) - assert result == user - assert status == expected_status - assert print_exc_mock.called - - def test_id_put_not_found(app: Flask, mocker: MockerFixture) -> None: """Tests id_put returns ErrorResponse and 404 when user not found.""" @@ -379,6 +349,36 @@ def test_id_put_resource_invalid(app: Flask, mocker: MockerFixture) -> None: assert not result.message +def test_id_put_sys_exc_info_branch(app: Flask, mocker: MockerFixture): + """Covers id_put: sys.exc_info()[0] is not None -> traceback.print_exc() branch (without raising exception).""" + expected_status = 200 + user = UserDetail( + id="dummy", + user_name="user20", + emails=["user20@example.com"], + eppns=["eppn20"], + preferred_language="en", + repository_roles=[RepositoryRole(id="repo20", user_role=USER_ROLES.REPOSITORY_ADMIN)], + is_system_admin=False, + ) + dummy_user = LoginUser( + eppn="dummy", + is_member_of="", + user_name="dummy", + map_id="not_u20", + session_id="dummy", + ) + mocker.patch("server.api.users.current_user", dummy_user) + mocker.patch("server.services.users.update", return_value=user) + mocker.patch("sys.exc_info", return_value=(Exception, None, None)) + print_exc_mock = mocker.patch("traceback.print_exc") + original_func = inspect.unwrap(users_api.id_put) + result, status = original_func("u20", user) + assert result == user + assert status == expected_status + assert print_exc_mock.called + + def test_has_permission_system_admin(mocker: MockerFixture) -> None: """Tests has_permission returns True for system admin.""" user = UserDetail( @@ -436,3 +436,62 @@ def test_filter_options_returns_search_users_options_unit(mocker: MockerFixture) result = original_func() assert result == mock_return + + +def test_export_get_success(app, mocker: MockerFixture) -> None: + + query = {"foo": "bar"} + mock_current_user = mocker.patch("server.api.users.current_user") + mock_current_user.map_id = "mapid" + mock_current_user.name = "username" + mock_file = object() + mocker.patch("server.api.users.users.make_export_file", return_value=mock_file) + mock_send_file = mocker.patch("server.api.users.send_file", return_value="sent-file-response") + original_func = inspect.unwrap(users_api.export_get) + with app.test_request_context(): + resp = original_func(query) + mock_send_file.assert_called_once_with(mock_file) + assert resp == "sent-file-response" + + +def test_export_get_invalid_export_error(app, mocker: MockerFixture) -> None: + excepted_status = 403 + query = {"foo": "bar"} + mock_current_user = mocker.patch("server.api.users.current_user") + mock_current_user.map_id = "mapid" + mock_current_user.name = "username" + mocker.patch("server.api.users.users.make_export_file", side_effect=InvalidExportError("failmsg")) + original_func = inspect.unwrap(users_api.export_get) + with app.test_request_context(): + result, state = original_func(query) + assert state == excepted_status + assert isinstance(result, ErrorResponse) + + +def test_export_post_success(app, mocker: MockerFixture) -> None: + query = {"foo": "bar"} + mock_current_user = mocker.patch("server.api.users.current_user") + mock_current_user.map_id = "mapid" + mock_current_user.name = "username" + mock_file = object() + mocker.patch("server.api.users.users.make_export_file", return_value=mock_file) + mock_send_file = mocker.patch("server.api.users.send_file", return_value="sent-file-response") + original_func = inspect.unwrap(users_api.export_post) + with app.test_request_context(): + resp = original_func(query) + mock_send_file.assert_called_once_with(mock_file) + assert resp == "sent-file-response" + + +def test_export_post_invalid_export_error(app, mocker: MockerFixture) -> None: + excepted_status = 403 + query = {"foo": "bar"} + mock_current_user = mocker.patch("server.api.users.current_user") + mock_current_user.map_id = "mapid" + mock_current_user.name = "username" + mocker.patch("server.api.users.users.make_export_file", side_effect=InvalidExportError("failmsg")) + original_func = inspect.unwrap(users_api.export_post) + with app.test_request_context(): + result, state = original_func(query) + assert state == excepted_status + assert isinstance(result, ErrorResponse) diff --git a/tests/unit/cli/test_app.py b/tests/unit/cli/test_app.py new file mode 100644 index 00000000..1767b2b8 --- /dev/null +++ b/tests/unit/cli/test_app.py @@ -0,0 +1,15 @@ +from click.testing import CliRunner + +from server.cli.app import version + + +def test_version_logs_version_(app, mocker): + fake_version = "9.9.9" + fake_pyproject = {"project": {"version": fake_version}} + mocker.patch("tomllib.load", return_value=fake_pyproject) + mocker_log = mocker.patch("flask.current_app.logger.info") + + runner = CliRunner() + runner.invoke(version, []) + + mocker_log.assert_called_once_with(fake_version) diff --git a/tests/unit/cli/test_token.py b/tests/unit/cli/test_token.py index b79e1556..93e6dec5 100644 --- a/tests/unit/cli/test_token.py +++ b/tests/unit/cli/test_token.py @@ -1,6 +1,8 @@ import typing as t -from server.cli.token import issue, refresh +from server.cli.token import check, issue, refresh, whoami +from server.entities.user_detail import UserDetail +from server.messages import E, I if t.TYPE_CHECKING: @@ -28,3 +30,33 @@ def test_token_refresh_calls_refresh_access_token(app: Flask, mocker: MockerFixt refresh.main(args=[], standalone_mode=False) refresh_mock.assert_called_once() + + +def test_token_check_invalid(app, mocker): + mocker.patch("server.cli.token.get_access_token", return_value="dummy_token") + mocker.patch("server.cli.token.check_token_validity", return_value=False) + logger_mock = mocker.patch("server.cli.token.current_app.logger.info") + + check.main(args=[], standalone_mode=False) + logger_mock.assert_called_once_with(E.ACCESS_TOKEN_NOT_AVAILABLE) + + +def test_token_check_valid(app, mocker): + mocker.patch("server.cli.token.get_access_token", return_value="dummy_token") + mocker.patch("server.cli.token.check_token_validity", return_value=True) + logger_mock = mocker.patch("server.cli.token.current_app.logger.info") + + check.main(args=[], standalone_mode=False) + logger_mock.assert_called_once_with(I.ACCESS_TOKEN_AVAILABLE) + + +def test_token_whoami_logs_owner_userdetail(app, mocker): + dummy_owner = UserDetail(id="dummy", user_name="dummy", emails=[], eppns=[]) + mocker.patch("server.cli.token.get_token_owner", return_value=dummy_owner) + logger_mock = mocker.patch("server.cli.token.current_app.logger.info") + + whoami.main(args=[], standalone_mode=False) + logger_mock.assert_called_once_with( + I.SUCCESS_GET_TOKEN_OWNER, + {"user": dummy_owner.model_dump_json(indent=2, ensure_ascii=False)}, + ) diff --git a/tests/unit/clients/test_services.py b/tests/unit/clients/test_services.py index da53fe5b..6419a0ca 100644 --- a/tests/unit/clients/test_services.py +++ b/tests/unit/clients/test_services.py @@ -889,39 +889,6 @@ def test_patch_by_id_with_exclude(app: Flask, mocker: MockerFixture, service_dat assert called_kwargs["json"]["request"] == expected_request -def test_patch_by_id_with_exclude_param(app, mocker: MockerFixture, service_data): - json_data, _ = service_data - service_id = json_data["id"] - exclude = {"meta", "serviceName"} - time_stamp = str(int(time.time())) - signature = hashlib.sha256(b"hash").hexdigest() - operations = [ReplaceOperation(op="replace", path="serviceName", value="NewName")] - response_data = { - "schemas": json_data["schemas"], - "id": json_data["id"], - "serviceName": json_data["serviceName"], - } - mocker.patch("server.clients.services.get_time_stamp", return_value=time_stamp) - mocker.patch("server.clients.services.compute_signature", return_value=signature) - mock_patch = mocker.patch("server.clients.services.requests.patch") - mock_patch.return_value.text = json.dumps(response_data) - mock_patch.return_value.status_code = 200 - mocker.patch.object(services, "alias_generator", side_effect=lambda x: x) - mocker.patch("server.clients.services.repository_updated") - original_func = inspect.unwrap(services.patch_by_id) - result = original_func( - service_id, - operations, - exclude=exclude, - access_token="token", - client_secret="secret", - ) - _, called_kwargs = mock_patch.call_args - assert "excluded_attributes" in called_kwargs["params"] - assert set(called_kwargs["params"]["excluded_attributes"].split(",")) == exclude - assert isinstance(result, MapService) - - def test_patch_by_id_not_found(app: Flask, mocker: MockerFixture) -> None: error_data = load_json_data("data/map_error.json") operations: list[ReplaceOperation] = [ReplaceOperation(op="replace", path="serviceName", value="NewName")] @@ -950,18 +917,19 @@ def test_patch_by_id_http_error(app: Flask, mocker: MockerFixture, service_data) services.patch_by_id(service_id, operations, access_token="token", client_secret="secret") -def test_patch_by_id_bad_request(app, mocker: MockerFixture): +def test_delete_by_id_success(app: Flask, mocker: MockerFixture) -> None: - service_id = "dummy_id" - operations = [ReplaceOperation(op="replace", path="serviceName", value="NewName")] - error_data = load_json_data("data/map_error.json") - mock_patch = mocker.patch("server.clients.services.requests.patch") - mock_patch.return_value.text = json.dumps(error_data) - mock_patch.return_value.status_code = HTTPStatus.BAD_REQUEST - original_func = inspect.unwrap(services.patch_by_id) - result = original_func(service_id, operations, access_token="token", client_secret="secret") - assert isinstance(result, MapError) - assert "Not Found" in result.detail + service_id = "s1" + access_token = "token" + client_secret = "secret" + mock_delete = mocker.patch("server.clients.services.requests.delete") + mock_delete.return_value.text = "" + mock_delete.return_value.status_code = 200 + mocker.patch("server.clients.services.repository_deleted.send") + mocker.patch("server.clients.services.repository_updated") + result = services.delete_by_id(service_id, access_token=access_token, client_secret=client_secret) + mock_delete.assert_called_once() + assert result is None def test_delete_by_id_error_response(app: Flask, mocker: MockerFixture) -> None: @@ -981,21 +949,6 @@ def test_delete_by_id_error_response(app: Flask, mocker: MockerFixture) -> None: assert "Not Found" in result.detail -def test_delete_by_id_success(app: Flask, mocker: MockerFixture) -> None: - - service_id = "s1" - access_token = "token" - client_secret = "secret" - mock_delete = mocker.patch("server.clients.services.requests.delete") - mock_delete.return_value.text = "" - mock_delete.return_value.status_code = 200 - mocker.patch("server.clients.services.repository_deleted.send") - mocker.patch("server.clients.services.repository_updated") - result = services.delete_by_id(service_id, access_token=access_token, client_secret=client_secret) - mock_delete.assert_called_once() - assert result is None - - def test_delete_by_id_http_error(app: Flask, mocker: MockerFixture) -> None: service_id = "s1" access_token = "token" @@ -1043,6 +996,13 @@ def test_handle_repository_updated_clears_cache(mocker): mock_clear.assert_called_once_with(service.id) +def test_handle_repository_updated_no_service_id(mocker): + """Covers branch where service or service.id is falsy.""" + mock_clear = mocker.patch("server.clients.services.get_by_id.clear_cache") + handle_repository_updated(_sender=None, service=None) + mock_clear.assert_not_called() + + def test_handle_repository_updated_by_id_clears_cache(mocker): """Covers get_by_id.clear_cache(service_id) branch for handle_repository_updated_by_id.""" mock_clear = mocker.patch("server.clients.services.get_by_id.clear_cache") @@ -1051,13 +1011,6 @@ def test_handle_repository_updated_by_id_clears_cache(mocker): mock_clear.assert_called_once_with(service_id) -def test_handle_repository_updated_no_service_id(mocker): - """Covers branch where service or service.id is falsy.""" - mock_clear = mocker.patch("server.clients.services.get_by_id.clear_cache") - handle_repository_updated(_sender=None, service=None) - mock_clear.assert_not_called() - - def test_handle_repository_updated_by_id_no_service_id(mocker): """Covers branch where service_id is falsy.""" mock_clear = mocker.patch("server.clients.services.get_by_id.clear_cache") diff --git a/tests/unit/clients/test_users.py b/tests/unit/clients/test_users.py index 936a5db9..a9edd25a 100644 --- a/tests/unit/clients/test_users.py +++ b/tests/unit/clients/test_users.py @@ -379,7 +379,7 @@ def test_get_by_id_not_found(app: Flask, mocker: MockerFixture) -> None: mock_response = mocker.patch("server.clients.users.requests.get") mock_response.return_value.text = expected_error.model_dump_json() - mock_response.return_value.status_code = 200 + mock_response.return_value.status_code = 404 original_func = inspect.unwrap(users.get_by_id) result = original_func(user_id, access_token="token", client_secret="secret") @@ -1246,13 +1246,6 @@ def test_get_self_with_include_exclude(app: Flask, mocker: MockerFixture): assert isinstance(result, MapUser) -@pytest.fixture -def user_data() -> tuple[dict[str, t.Any], MapUser]: - json_data = load_json_data("data/map_user.json") - user = MapUser.model_validate(json_data) - return json_data, user - - def test__get_alias_generator_with_serialization_alias(monkeypatch): """Covers the branch where generator has serialization_alias attribute.""" @@ -1337,3 +1330,10 @@ def test_search_cache_identifier(app, mocker, is_logged_in, is_admin, permitted, mocker.patch("server.clients.users.is_user_logged_in", return_value=is_logged_in) result = users_mod._search_cache_identifier() # noqa: SLF001 assert result == expected + + +@pytest.fixture +def user_data() -> tuple[dict[str, t.Any], MapUser]: + json_data = load_json_data("data/map_user.json") + user = MapUser.model_validate(json_data) + return json_data, user diff --git a/tests/unit/services/test_groups.py b/tests/unit/services/test_groups.py index 9fa7fb6a..673cabdc 100644 --- a/tests/unit/services/test_groups.py +++ b/tests/unit/services/test_groups.py @@ -10,6 +10,7 @@ from pytest_mock import MockerFixture from requests import HTTPError, Response +from server.config import config from server.entities.bulk_request import BulkOperation, BulkResponse from server.entities.group_detail import GroupDetail, Repository from server.entities.map_error import MapError @@ -1981,9 +1982,18 @@ def test_delete_multiple_all_failure(app, gen_group_id, mocker: MockerFixture) - assert result == {f"/Groups/{gen_group_id('g1')}", f"/Groups/{gen_group_id('g2')}", f"/Groups/{gen_group_id('g3')}"} +def test_delete_multiple_calls_sequentially_when_bulk_disabled(app, mocker): + group_ids = {"g1", "g2"} + mocker.patch("server.config.config.FEATURES.enable_bulk_operation", new=False) + mock_delete_seq = mocker.patch("server.services.groups.delete_multiple_sequentially", return_value={"g2"}) + result = groups.delete_multiple(group_ids) + mock_delete_seq.assert_called_once_with(group_ids) + assert result == {"g2"} + + def test_delete_multiple_raises_resource_invalid_and_logs(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Test delete_multiple raises ResourceInvalid and logs when MapError is returned.""" - mocker.patch("server.config.config.FEATURES.enable_bulk_operation", return_value=True) + mocker.patch.object(config.FEATURES, "enable_bulk_operation", return_value=True) group_ids: set[str] = {gen_group_id("g1"), gen_group_id("g2")} mocker.patch("server.services.groups.get_access_token", return_value="token") mocker.patch("server.services.groups.get_client_secret", return_value="secret") @@ -2131,6 +2141,37 @@ def test_delete_multiple_raises_unexpected_exception_propagation( groups.delete_multiple(group_ids) +def test_delete_multiple_sequentially_bulk_enabled(app, mocker): + group_ids = {"g1", "g2"} + mocker.patch("server.config.config.FEATURES.enable_bulk_operation", return_value=True) + mock_delete_multiple = mocker.patch("server.services.groups.delete_multiple", return_value={"g2"}) + + result = groups.delete_multiple_sequentially(group_ids) + mock_delete_multiple.assert_called_once_with(group_ids) + assert result == {"g2"} + + +def test_delete_multiple_sequentially_bulk_disabled_with_failures(app, mocker): + group_ids = {"g1", "g2"} + mocker.patch("server.config.config.FEATURES.enable_bulk_operation", new=False) + mocker.patch("server.services.groups.delete_by_id", side_effect=ResourceNotFound("not found")) + + result = groups.delete_multiple_sequentially(group_ids) + assert result == {"g1", "g2"} + + +def test_delete_multiple_sequentially_raises_oauth_or_credentials_error(app, mocker): + + mocker.patch("server.services.groups.delete_by_id", side_effect=OAuthTokenError("oauth error")) + mocker.patch.object(config.FEATURES, "enable_bulk_operation", return_value=False) + mocker.patch("server.config.config.FEATURES.enable_bulk_operation", new=False) + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + + with pytest.raises(OAuthTokenError): + groups.delete_multiple_sequentially({"g1"}) + + def test_delete_by_id_success(gen_group_id, mocker: MockerFixture) -> None: """Test delete_by_id returns None when group deletion succeeds.""" group_id: str = gen_group_id("g1") diff --git a/tests/unit/services/test_history_table.py b/tests/unit/services/test_history_table.py index d47a6b25..a62dcd7e 100644 --- a/tests/unit/services/test_history_table.py +++ b/tests/unit/services/test_history_table.py @@ -1,5 +1,6 @@ import typing as t +from unittest.mock import patch from uuid import uuid7 import pytest @@ -58,6 +59,20 @@ def test_get_upload_results_with_exception(app, mocker: MockerFixture): assert str(exc.value) == str(E.FAILED_GET_UPLOAD_HISTORY_RECORD % {"history_id": history_id}) +def test_get_upload_results_result_none(app, mocker: MockerFixture): + """Test get_upload_results: result is None, should log and raise RecordNotFound.""" + history_id = uuid7() + attribute = "results" + mock_query = mocker.patch("server.db.db.session.query") + mock_filter = mock_query.return_value.filter.return_value + mock_filter.first.return_value = None + mock_logger = mocker.patch("flask.current_app.logger.error") + mocker.patch("server.services.history_table.E.FAILED_GET_FILE_RECORD", "FAILED_GET_FILE_RECORD") + with pytest.raises(RecordNotFound): + history_table.get_upload_results(history_id, attribute) + assert mock_logger.called + + @pytest.mark.parametrize( ("status_filter"), [(["S", "F"]), ([])], @@ -188,6 +203,23 @@ def test_update_upload_status_no_file_id(app, mocker: MockerFixture): assert obj.status == status +def test_update_upload_status_results_with_exception(app, mocker: MockerFixture): + history_id = uuid7() + status = "S" + new_results = {"summary": {}, "results": [], "missing_users": []} + file_id = uuid7() + mock_logger = mocker.patch("flask.current_app.logger.error") + mocker.patch( + "server.db.db.session.query", + return_value=mocker.MagicMock(filter=mocker.MagicMock(first=None)), + side_effect=SQLAlchemyError, + ) + with pytest.raises(DatabaseError) as exc: + history_table.update_upload_status(history_id, status, new_results, file_id) + assert str(exc.value) == str(E.FAILED_UPDATE_HISTORY_RECORD_STATUS % {"history_id": history_id}) + assert mock_logger.called + + def test_get_history_by_file_id(app, mocker: MockerFixture): file_id = uuid7() mock_query = mocker.MagicMock() @@ -213,6 +245,22 @@ def test_get_history_by_file_id_not_found(app, mocker: MockerFixture): assert str(exc.value) == str(E.FAILED_GET_UPLOAD_HISTORY_RECORD_BY_FILE_ID % {"file_id": file_id}) +def test_get_history_by_file_id_with_exception(app, mocker: MockerFixture) -> None: + file_id = uuid7() + mock_logger = mocker.patch("flask.current_app.logger.error") + mock_query = mocker.MagicMock() + mock_filter_by = mocker.MagicMock() + mock_query.filter_by.return_value = mock_filter_by + mock_filter_by.one_or_none.side_effect = SQLAlchemyError + mocker.patch("server.db.db.session.query", return_value=mock_query) + with pytest.raises(DatabaseError) as exc: + history_table.get_history_by_file_id(file_id) + assert str(exc.value) == str(E.FAILED_GET_UPLOAD_HISTORY_RECORD_BY_FILE_ID % {"file_id": file_id}) + assert mock_logger.called + assert mock_query.filter_by.called + assert mock_filter_by.one_or_none.called + + def test_get_file_by_id(app, mocker): file_id = uuid7() mock_query = mocker.MagicMock() @@ -238,6 +286,20 @@ def test_get_file_by_id_not_found(app, mocker): assert str(exc.value) == str(E.FAILED_GET_FILE_RECORD % {"file_id": file_id}) +def test_get_file_by_id_with_exception(app, mocker: MockerFixture) -> None: + file_id = uuid7() + mock_logger = mocker.patch("flask.current_app.logger.error") + mock_query = mocker.MagicMock() + mock_filter_by = mocker.MagicMock() + mock_query.filter_by.return_value = mock_filter_by + mock_filter_by.one_or_none.side_effect = SQLAlchemyError + mocker.patch("server.db.db.session.query", return_value=mock_query) + with pytest.raises(DatabaseError) as exc: + history_table.get_file_by_id(file_id) + assert str(exc.value) == str(E.FAILED_GET_FILE_RECORD % {"file_id": file_id}) + assert mock_logger.called + + def test_delete_file_by_id(app, mocker: MockerFixture): file_id = uuid7() mock_files = mocker.patch( @@ -248,6 +310,22 @@ def test_delete_file_by_id(app, mocker: MockerFixture): mock_files.query.filter.assert_called_once() +def test_delete_file_by_id_with_exception(app, mocker: MockerFixture) -> None: + file_id = uuid7() + mock_logger = mocker.patch("flask.current_app.logger.error") + mock_filter = mocker.MagicMock() + mock_filter.delete.side_effect = SQLAlchemyError + mock_query = mocker.MagicMock() + mock_query.filter.return_value = mock_filter + + with patch("server.services.history_table.Files.query", new=mock_query), pytest.raises(DatabaseError) as exc: + history_table.delete_file_by_id(file_id) + assert str(exc.value) == str(E.FAILED_DELETE_FILE_RECORD % {"file_id": file_id}) + assert mock_logger.called + assert mock_query.filter.called + assert mock_filter.delete.called + + def test_create_file(app, mocker: MockerFixture): file_path = "/var/tmp/test_file.csv" # noqa: S108 file_content = {"repositories": [], "groups": [], "users": []} @@ -278,6 +356,21 @@ def test_create_file_without_id(app, mocker: MockerFixture): assert result.file_content == file_content +def test_create_file_with_exception(app, mocker: MockerFixture) -> None: + file_path = "/var/tmp/test_file.csv" # noqa: S108 + file_content = {"repositories": [], "groups": [], "users": []} + file_id = uuid7() + mocker.patch("flask.current_app.logger.error") + mock_files = mocker.patch("server.services.history_table.Files", autospec=True) + instance = mock_files.return_value + instance.id = file_id + mocker.patch("server.db.db.session.add", side_effect=SQLAlchemyError) + with pytest.raises(DatabaseError) as exc: + history_table.create_file(file_path, file_content, file_id) + assert str(exc.value) == str(E.FAILED_CREATE_FILE_RECORD % {"file_path": file_path}) + assert mock_files.called + + def test_create_download_history(app, mocker: MockerFixture): file_id = uuid7() file_path = "test/path" diff --git a/tests/unit/services/test_repositories.py b/tests/unit/services/test_repositories.py index c623bd62..f585f7c8 100644 --- a/tests/unit/services/test_repositories.py +++ b/tests/unit/services/test_repositories.py @@ -150,29 +150,6 @@ def test_search_raises_unexpected_response_error_on_internal_server_error(app: F repositories.search(criteria) -def test_search_raises_unexpected_response_error_on_other_http_error(app: Flask, mocker: MockerFixture) -> None: - """Tests that UnexpectedResponseError is raised on non-500 HTTP errors during search.""" - criteria = make_criteria_object("repositories", q="test", i=["repo1"]) - build_search_query = SearchRequestParameter( - filter='(serviceName co "test") or (entity_ids.value co "repo1")', - start_index=1, - count=10, - sort_by="serviceName", - sort_order="ascending", - ) - mocker.patch("server.services.repositories.build_search_query", return_value=build_search_query) - mocker.patch("server.services.repositories.get_access_token", return_value="token") - mocker.patch("server.services.repositories.get_client_secret", return_value="secret") - response = Response() - response.status_code = HTTPStatus.BAD_REQUEST - http_error = requests.HTTPError(response=response) - mocker.patch("server.clients.services.search", side_effect=http_error) - - msg = "E031 | Received unexpected response from mAP Core API." - with pytest.raises(UnexpectedResponseError, match=msg): - repositories.search(criteria) - - def test_search_raises_unexpected_response_error_on_request_exception(app: Flask, mocker: MockerFixture) -> None: """Tests that UnexpectedResponseError is raised on request exception during search.""" criteria = make_criteria_object("repositories", q="test", i=["repo1"]) @@ -404,6 +381,20 @@ def test_get_by_id_more_detail(app, mocker: MockerFixture, test_config) -> None: assert str(result.service_url) == str(service_url) +def test_get_by_id_returns_none_on_map_error(app, mocker: MockerFixture, test_config) -> None: + """Tests that get_by_id returns None when MapError is returned.""" + service_id = test_config.REPOSITORIES.id_patterns.sp_connector.format(repository_id="repo1") + map_error = MapError(detail="not found", status="404", scim_type="invalidSyntax") + mocker.patch("server.services.repositories.resolve_service_id", return_value=service_id) + mocker.patch("server.services.repositories.get_access_token", return_value="token") + mocker.patch("server.services.repositories.get_client_secret", return_value="secret") + mocker.patch("server.clients.services.get_by_id", return_value=map_error) + + result = repositories.get_by_id("repo1") + + assert result is None + + def test_get_by_id_raises_oauth_token_error_on_unauthorized(app: Flask, mocker: MockerFixture) -> None: """Tests that OAuthTokenError is raised when get_by_id receives an unauthorized response.""" mocker.patch("server.services.repositories.resolve_service_id", return_value="repo1") @@ -497,24 +488,11 @@ def test_get_by_id_raises_unexpected_response_error_on_other_http_error(app: Fla repositories.get_by_id("repo1") -def test_get_by_id_returns_none_on_map_error(app, mocker: MockerFixture, test_config) -> None: - """Tests that get_by_id returns None when MapError is returned.""" - service_id = test_config.REPOSITORIES.id_patterns.sp_connector.format(repository_id="repo1") - map_error = MapError(detail="not found", status="404", scim_type="invalidSyntax") - mocker.patch("server.services.repositories.resolve_service_id", return_value=service_id) - mocker.patch("server.services.repositories.get_access_token", return_value="token") - mocker.patch("server.services.repositories.get_client_secret", return_value="secret") - mocker.patch("server.clients.services.get_by_id", return_value=map_error) - - result = repositories.get_by_id("repo1") - - assert result is None - - def test_create_success(app, mocker: MockerFixture, test_config) -> None: """Tests successful creation of a repository and validates the returned RepositoryDetail.""" repository_id = "repo1" + role_groups: list = [["group1"], ["group2"]] service_id = test_config.REPOSITORIES.id_patterns.sp_connector.format(repository_id=repository_id) service_name = test_config.SP.entity_id service_url: HttpUrl = HttpUrl(f"https://{test_config.POSTGRES.host}/repo1") @@ -525,7 +503,7 @@ def test_create_success(app, mocker: MockerFixture, test_config) -> None: ) mocker.patch("server.services.repositories.users.get_system_admins", return_value=["admin"]) mocker.patch("server.services.repositories.prepare_service", return_value=(map_service, service_id)) - mocker.patch("server.services.repositories.prepare_role_groups", return_value=[]) + mocker.patch("server.services.repositories.prepare_role_groups", return_value=role_groups) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.groups.post") @@ -624,26 +602,6 @@ def test_create_raises_unexpected_response_error_on_validation_error( repositories.create(repo) -def test_create_raises_resource_invalid_on_map_error(app, mocker: MockerFixture, test_config) -> None: - """Tests that ResourceInvalid is raised when MapError is returned from create.""" - - service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) - repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) - map_error = MapError(detail=r"Duplicate id '(.*)'", status="400", scim_type="invalidSyntax") - mocker.patch("server.services.repositories.users.get_system_admins", return_value=["admin"]) - mocker.patch("server.services.repositories.prepare_role_groups", return_value=[]) - mocker.patch("server.services.repositories.prepare_service", return_value=(mocker.MagicMock(), "repo1")) - mocker.patch("server.services.repositories.get_access_token", return_value="token") - mocker.patch("server.services.repositories.get_client_secret", return_value="secret") - mocker.patch("server.clients.groups.post") - mocker.patch("server.clients.services.post", return_value=map_error) - mocker.patch("server.services.repositories.current_app") - - msg: str = "E111 | A Service resource for Repository (id: %(id)s) already exists." - with pytest.raises(ResourceInvalid, match=msg): - repositories.create(repo) - - def test_create_raises_oauth_token_error_direct(app: Flask, mocker: MockerFixture, test_config) -> None: """Tests that OAuthTokenError is raised directly from create.""" @@ -720,67 +678,12 @@ def test_create_raises_system_admin_not_found_direct(app: Flask, mocker: MockerF repositories.create(repo) -def test_create_raises_unexpected_response_error_on_other_http_error( - app: Flask, mocker: MockerFixture, test_config -) -> None: - """Tests that UnexpectedResponseError is raised on non-401/500 HTTP errors during create.""" - service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) - repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) - mocker.patch("server.services.repositories.users.get_system_admins", return_value=["admin"]) - mocker.patch("server.services.repositories.prepare_role_groups", return_value=[]) - mocker.patch("server.services.repositories.prepare_service", return_value=(mocker.MagicMock(), "repo1")) - mocker.patch("server.services.repositories.get_access_token", return_value="token") - mocker.patch("server.services.repositories.get_client_secret", return_value="secret") - mocker.patch("server.clients.groups.post") - mocker.patch("server.services.repositories.current_app") - response = Response() - response.status_code = HTTPStatus.BAD_REQUEST - http_error = requests.HTTPError(response=response) - mocker.patch("server.clients.services.post", side_effect=http_error) - - msg: str = "E031 | Received unexpected response from mAP Core API." - with pytest.raises(UnexpectedResponseError, match=msg): - repositories.create(repo) - - -@pytest.mark.parametrize( - "role_groups", - [ - [["group1"], ["group2"]], - [[]], - ], - ids=["multiple-groups", "empty-groups"], -) -def test_create_calls_groups_post_for_each_group(app, mocker: MockerFixture, test_config, role_groups: list) -> None: - """Tests that groups.post is called for each group in role_groups during create.""" - repository_id = "repo1" - service_id = test_config.REPOSITORIES.id_patterns.sp_connector.format(repository_id=repository_id) - service_name = test_config.SP.entity_id - service_url: HttpUrl = HttpUrl(f"https://{test_config.POSTGRES.host}/repo1") - service_schema = const.MAP_SERVICE_SCHEMA - repo = RepositoryDetail(id=service_id, service_name=service_name, service_url=service_url, entity_ids=[]) - map_service = MapService( - id=service_id, service_name=service_name, service_url=service_url, schemas=[service_schema], entity_ids=[] - ) - mocker.patch("server.services.repositories.users.get_system_admins", return_value=["admin"]) - mocker.patch("server.services.repositories.prepare_role_groups", return_value=role_groups) - mocker.patch("server.services.repositories.prepare_service", return_value=(map_service, service_id)) - mocker.patch("server.services.repositories.get_access_token", return_value="token") - mocker.patch("server.services.repositories.get_client_secret", return_value="secret") - mocker.patch("server.services.repositories.current_app") - mock_groups_post = mocker.patch("server.clients.groups.post") - mocker.patch("server.clients.services.post", return_value=map_service) - - repositories.create(repo) - - assert mock_groups_post.call_count == len(role_groups) - +def test_create_raises_resource_invalid_on_map_error(app, mocker: MockerFixture, test_config) -> None: + """Tests that ResourceInvalid is raised when MapError is returned from create.""" -def test_create_map_error_unexpected_response(app, mocker: MockerFixture, test_config) -> None: - """Test create raises UnexpectedResponseError when MapError.detail does not match known patterns.""" service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) - map_error = MapError(detail="some unknown error", status="400", scim_type="invalidSyntax") + map_error = MapError(detail=r"Duplicate id '(.*)'", status="400", scim_type="invalidSyntax") mocker.patch("server.services.repositories.users.get_system_admins", return_value=["admin"]) mocker.patch("server.services.repositories.prepare_role_groups", return_value=[]) mocker.patch("server.services.repositories.prepare_service", return_value=(mocker.MagicMock(), "repo1")) @@ -790,8 +693,8 @@ def test_create_map_error_unexpected_response(app, mocker: MockerFixture, test_c mocker.patch("server.clients.services.post", return_value=map_error) mocker.patch("server.services.repositories.current_app") - msg: str = "Received unexpected response from mAP Core API" - with pytest.raises(UnexpectedResponseError, match=msg): + msg: str = "E111 | A Service resource for Repository (id: %(id)s) already exists." + with pytest.raises(ResourceInvalid, match=msg): repositories.create(repo) @@ -814,22 +717,23 @@ def test_create_map_error_no_rights_create(app, mocker: MockerFixture, test_conf repositories.create(repo) -def test_update_map_error_no_rights_update(app, mocker: MockerFixture, test_config) -> None: - """Test update raises OAuthTokenError when MapError.detail matches MAP_NO_RIGHTS_UPDATE_PATTERN.""" +def test_create_map_error_unexpected_response(app, mocker: MockerFixture, test_config) -> None: + """Test create raises UnexpectedResponseError when MapError.detail does not match known patterns.""" service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) - map_error = MapError(detail="No update rights for 'repo1'", status="400", scim_type="invalidSyntax") - mocker.patch("server.services.repositories.validate_repository_to_map_service", return_value=repo) - mocker.patch("server.services.repositories.get_by_id", return_value=repo) + map_error = MapError(detail="some unknown error", status="400", scim_type="invalidSyntax") + mocker.patch("server.services.repositories.users.get_system_admins", return_value=["admin"]) + mocker.patch("server.services.repositories.prepare_role_groups", return_value=[]) + mocker.patch("server.services.repositories.prepare_service", return_value=(mocker.MagicMock(), "repo1")) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") - mocker.patch("server.services.repositories.build_patch_operations", return_value=[]) - mocker.patch("server.clients.services.patch_by_id", return_value=map_error) + mocker.patch("server.clients.groups.post") + mocker.patch("server.clients.services.post", return_value=map_error) mocker.patch("server.services.repositories.current_app") - msg: str = "E123 | No update rights for Repository (id: repo1) with current access token." - with pytest.raises(OAuthTokenError, match=msg): - repositories.update(repo) + msg: str = "Received unexpected response from mAP Core API" + with pytest.raises(UnexpectedResponseError, match=msg): + repositories.create(repo) def test_update_success(app, mocker: MockerFixture, test_config) -> None: @@ -871,6 +775,34 @@ def test_update_calls_update_put_when_strategy_put(app, mocker: MockerFixture, t assert mock_update_put.called +def test_update_raises_resource_not_found_on_none(app, mocker: MockerFixture, test_config) -> None: + """Tests that ResourceNotFound is raised when get_by_id returns None during update.""" + + service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) + repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) + mocker.patch("server.services.repositories.validate_repository_to_map_service", return_value=repo) + mocker.patch("server.services.repositories.get_by_id", return_value=None) + + msg: str = "E104 | Service resource for Repository (id: repo1) not found." + with pytest.raises(ResourceNotFound, match=msg): + repositories.update(repo) + + +def test_update_raises_invalid_form_error_on_service_url_update(app, mocker: MockerFixture, test_config) -> None: + """Tests that InvalidFormError is raised when service_url is updated during update.""" + + service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) + current_url: HttpUrl = HttpUrl("https://other.com") + repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) + current = RepositoryDetail(id="repo1", service_name="s", service_url=current_url, entity_ids=[]) + mocker.patch("server.services.repositories.validate_repository_to_map_service", return_value=repo) + mocker.patch("server.services.repositories.get_by_id", return_value=current) + + msg: str = "E150 | Service URL of Repository cannot be updated." + with pytest.raises(InvalidFormError, match=msg): + repositories.update(repo) + + def test_update_raises_oauth_token_error_on_unauthorized(app, mocker: MockerFixture, test_config) -> None: """Tests that OAuthTokenError is raised when update receives an unauthorized response.""" @@ -981,36 +913,8 @@ def test_update_raises_credentials_error_direct(app, mocker: MockerFixture, test repositories.update(repo) -def test_update_raises_resource_not_found_on_none(app, mocker: MockerFixture, test_config) -> None: - """Tests that ResourceNotFound is raised when get_by_id returns None during update.""" - - service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) - repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) - mocker.patch("server.services.repositories.validate_repository_to_map_service", return_value=repo) - mocker.patch("server.services.repositories.get_by_id", return_value=None) - - msg: str = "E104 | Service resource for Repository (id: repo1) not found." - with pytest.raises(ResourceNotFound, match=msg): - repositories.update(repo) - - -def test_update_raises_invalid_form_error_on_service_url_update(app, mocker: MockerFixture, test_config) -> None: - """Tests that InvalidFormError is raised when service_url is updated during update.""" - - service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) - current_url: HttpUrl = HttpUrl("https://other.com") - repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) - current = RepositoryDetail(id="repo1", service_name="s", service_url=current_url, entity_ids=[]) - mocker.patch("server.services.repositories.validate_repository_to_map_service", return_value=repo) - mocker.patch("server.services.repositories.get_by_id", return_value=current) - - msg: str = "E150 | Service URL of Repository cannot be updated." - with pytest.raises(InvalidFormError, match=msg): - repositories.update(repo) - - -def test_update_raises_unexpected_response_error_on_other_http_error(app, mocker: MockerFixture, test_config) -> None: - """Tests that UnexpectedResponseError is raised on non-401/500 HTTP errors during update.""" +def test_update_raises_resource_not_found_on_map_error(app, mocker: MockerFixture, test_config) -> None: + """Tests that ResourceNotFound is raised when MapError.detail matches MAP_NOT_FOUND_PATTERN during update.""" service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) mocker.patch("server.services.repositories.validate_repository_to_map_service", return_value=repo) @@ -1018,30 +922,29 @@ def test_update_raises_unexpected_response_error_on_other_http_error(app, mocker mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.services.repositories.build_patch_operations", return_value=[]) - response = Response() - response.status_code = HTTPStatus.BAD_REQUEST - http_error = requests.HTTPError(response=response) - mocker.patch("server.clients.services.patch_by_id", side_effect=http_error) + map_error = MapError(detail="Repository 'repo1' Not Found", status="404", scim_type="invalidSyntax") + mocker.patch("server.clients.services.patch_by_id", return_value=map_error) - msg: str = "E500 | An unexpected error occurred in the server application." - with pytest.raises(UnexpectedResponseError, match=msg): + msg: str = "not found" + with pytest.raises(ResourceNotFound, match=msg): repositories.update(repo) -def test_update_raises_resource_not_found_on_map_error(app, mocker: MockerFixture, test_config) -> None: - """Tests that ResourceNotFound is raised when MapError.detail matches MAP_NOT_FOUND_PATTERN during update.""" +def test_update_map_error_no_rights_update(app, mocker: MockerFixture, test_config) -> None: + """Test update raises OAuthTokenError when MapError.detail matches MAP_NO_RIGHTS_UPDATE_PATTERN.""" service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) + map_error = MapError(detail="No update rights for 'repo1'", status="400", scim_type="invalidSyntax") mocker.patch("server.services.repositories.validate_repository_to_map_service", return_value=repo) mocker.patch("server.services.repositories.get_by_id", return_value=repo) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.services.repositories.build_patch_operations", return_value=[]) - map_error = MapError(detail="Repository 'repo1' Not Found", status="404", scim_type="invalidSyntax") mocker.patch("server.clients.services.patch_by_id", return_value=map_error) + mocker.patch("server.services.repositories.current_app") - msg: str = "not found" - with pytest.raises(ResourceNotFound, match=msg): + msg: str = "E123 | No update rights for Repository (id: repo1) with current access token." + with pytest.raises(OAuthTokenError, match=msg): repositories.update(repo) @@ -1102,6 +1005,36 @@ def test_update_put_calls_update_when_strategy_patch(app, mocker: MockerFixture, assert mock_update.called +def test_update_put_raises_resource_not_found_on_none(app, mocker: MockerFixture, test_config) -> None: + """Tests that ResourceNotFound is raised when get_by_id returns None during update_put.""" + mocker.patch("server.config.config.MAP_CORE.update_strategy", "put") + + service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) + repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) + mocker.patch("server.services.repositories.validate_repository_to_map_service", return_value=repo) + mocker.patch("server.services.repositories.get_by_id", return_value=None) + + msg: str = "E104 | Service resource for Repository (id: repo1) not found." + with pytest.raises(ResourceNotFound, match=msg): + repositories.update_put(repo) + + +def test_update_put_raises_invalid_form_error_on_service_url_update(app, mocker: MockerFixture, test_config) -> None: + """Tests that InvalidFormError is raised when service_url is updated during update_put.""" + mocker.patch("server.config.config.MAP_CORE.update_strategy", "put") + + service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) + current_url: HttpUrl = HttpUrl("https://other.com") + repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) + current = RepositoryDetail(id="repo1", service_name="s", service_url=current_url, entity_ids=[]) + mocker.patch("server.services.repositories.validate_repository_to_map_service", return_value=repo) + mocker.patch("server.services.repositories.get_by_id", return_value=current) + + msg: str = "E150 | Service URL of Repository cannot be updated." + with pytest.raises(InvalidFormError, match=msg): + repositories.update_put(repo) + + def test_update_put_raises_oauth_token_error_on_unauthorized(app, mocker: MockerFixture, test_config) -> None: """Tests that OAuthTokenError is raised when update_put receives an unauthorized response.""" mocker.patch("server.config.config.MAP_CORE.update_strategy", "put") @@ -1218,57 +1151,6 @@ def test_update_put_raises_credentials_error_direct(app, mocker: MockerFixture, repositories.update_put(repo) -def test_update_put_raises_resource_not_found_on_none(app, mocker: MockerFixture, test_config) -> None: - """Tests that ResourceNotFound is raised when get_by_id returns None during update_put.""" - mocker.patch("server.config.config.MAP_CORE.update_strategy", "put") - - service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) - repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) - mocker.patch("server.services.repositories.validate_repository_to_map_service", return_value=repo) - mocker.patch("server.services.repositories.get_by_id", return_value=None) - - msg: str = "E104 | Service resource for Repository (id: repo1) not found." - with pytest.raises(ResourceNotFound, match=msg): - repositories.update_put(repo) - - -def test_update_put_raises_invalid_form_error_on_service_url_update(app, mocker: MockerFixture, test_config) -> None: - """Tests that InvalidFormError is raised when service_url is updated during update_put.""" - mocker.patch("server.config.config.MAP_CORE.update_strategy", "put") - - service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) - current_url: HttpUrl = HttpUrl("https://other.com") - repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) - current = RepositoryDetail(id="repo1", service_name="s", service_url=current_url, entity_ids=[]) - mocker.patch("server.services.repositories.validate_repository_to_map_service", return_value=repo) - mocker.patch("server.services.repositories.get_by_id", return_value=current) - - msg: str = "E150 | Service URL of Repository cannot be updated." - with pytest.raises(InvalidFormError, match=msg): - repositories.update_put(repo) - - -def test_update_put_raises_unexpected_response_error_on_other_http_error( - app, mocker: MockerFixture, test_config -) -> None: - """Tests that UnexpectedResponseError is raised on non-401/500 HTTP errors during update_put.""" - mocker.patch("server.config.config.MAP_CORE.update_strategy", "put") - service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) - repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) - mocker.patch("server.services.repositories.validate_repository_to_map_service", return_value=repo) - mocker.patch("server.services.repositories.get_by_id", return_value=repo) - mocker.patch("server.services.repositories.get_access_token", return_value="token") - mocker.patch("server.services.repositories.get_client_secret", return_value="secret") - response = Response() - response.status_code = HTTPStatus.BAD_REQUEST - http_error = requests.HTTPError(response=response) - mocker.patch("server.clients.services.put_by_id", side_effect=http_error) - - msg: str = "E500 | An unexpected error occurred in the server application." - with pytest.raises(UnexpectedResponseError, match=msg): - repositories.update_put(repo) - - def test_update_put_raises_resource_not_found_on_map_error(app, mocker: MockerFixture, test_config) -> None: """Tests that ResourceNotFound is raised when MapError.detail matches MAP_NOT_FOUND_PATTERN during update_put.""" mocker.patch("server.config.config.MAP_CORE.update_strategy", "put") @@ -1343,6 +1225,28 @@ def test_delete_by_id_success(app, test_config, mocker: MockerFixture) -> None: mock_delete.assert_called_once_with("repo1", access_token="token", client_secret="secret") +def test_delete_by_id_raises_resource_not_found_if_repo_missing(app, test_config, mocker: MockerFixture) -> None: + """Tests that ResourceNotFound is raised if get_by_id returns None in delete_by_id.""" + mocker.patch("server.services.repositories.get_by_id", return_value=None) + + msg: str = "E104 | Service resource for Repository (id: missing_repo) not found." + with pytest.raises(ResourceNotFound, match=msg): + repositories.delete_by_id("missing_repo", test_config.SP.entity_id) + + +def test_delete_by_id_raises_invalid_form_error_if_service_name_mismatch(app, mocker: MockerFixture) -> None: + """Tests that InvalidFormError is raised if service_name does not match in delete_by_id.""" + dummy_map_service = MapService( + id="repo1", + service_name="actual_service", + ) + mocker.patch("server.services.repositories.get_by_id", return_value=dummy_map_service) + + msg: str = "does not match" + with pytest.raises(InvalidFormError, match=msg): + repositories.delete_by_id("repo1", "wrong_service") + + def test_delete_by_id_raises_oauth_token_error_on_unauthorized(app, test_config, mocker: MockerFixture) -> None: """Tests that OAuthTokenError is raised when delete_by_id receives an unauthorized response.""" dummy_map_service = MapService( @@ -1404,16 +1308,20 @@ def test_delete_by_id_raises_unexpected_response_error_on_request_exception( repositories.delete_by_id("repo1", test_config.SP.entity_id) -def test_delete_by_id_raises_unexpected_response_error_on_validation_error( +def test_delete_by_id_raises_unexpected_response_error_on_validation_error_( app, test_config, mocker: MockerFixture ) -> None: - """Tests that UnexpectedResponseError is raised on validation error during delete_by_id.""" + """Tests that UnexpectedResponseError is raised on ValidationError during delete_by_id.""" + dummy_repo = RepositoryDetail(id="repo1", service_name=test_config.SP.entity_id) + dummy_repo._groups = [] # noqa: SLF001 + dummy_repo._rolegroups = [] # noqa: SLF001 + mocker.patch("server.services.repositories.get_by_id", return_value=dummy_repo) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.services.repositories.resolve_service_id", return_value="repo1") mocker.patch("server.clients.services.delete_by_id", side_effect=ValidationError("fail", [])) - msg: str = "E033 | Failed to communicate with mAP Core API." + msg: str = "Failed to parse response from mAP Core API." with pytest.raises(UnexpectedResponseError, match=msg): repositories.delete_by_id("repo1", test_config.SP.entity_id) @@ -1470,86 +1378,6 @@ def test_delete_by_id_raises_resource_not_found_on_map_error(app, test_config, m repositories.delete_by_id("repo1", test_config.SP.entity_id) -def test_delete_by_id_raises_resource_not_found_if_repo_missing(app, test_config, mocker: MockerFixture) -> None: - """Tests that ResourceNotFound is raised if get_by_id returns None in delete_by_id.""" - mocker.patch("server.services.repositories.get_by_id", return_value=None) - - msg: str = "E104 | Service resource for Repository (id: missing_repo) not found." - with pytest.raises(ResourceNotFound, match=msg): - repositories.delete_by_id("missing_repo", test_config.SP.entity_id) - - -def test_delete_by_id_raises_invalid_form_error_if_service_name_mismatch( - app, test_config, mocker: MockerFixture -) -> None: - """Tests that InvalidFormError is raised if service_name does not match in delete_by_id.""" - dummy_map_service = MapService( - id="repo1", - service_name="actual_service", - ) - mocker.patch("server.services.repositories.get_by_id", return_value=dummy_map_service) - - msg: str = "does not match" - with pytest.raises(InvalidFormError, match=msg): - repositories.delete_by_id("repo1", "wrong_service") - - -def test_delete_by_id_raises_unexpected_response_error_on_other_http_error( - app, test_config, mocker: MockerFixture -) -> None: - """Tests that UnexpectedResponseError is raised on non-401/500 HTTP errors during delete_by_id.""" - dummy_repo = RepositoryDetail( - id="repo1", - service_name=test_config.SP.entity_id, - ) - dummy_repo._groups = [] # noqa: SLF001 - dummy_repo._rolegroups = [] # noqa: SLF001 - mocker.patch("server.services.repositories.get_by_id", return_value=dummy_repo) - mocker.patch("server.services.repositories.get_access_token", return_value="token") - mocker.patch("server.services.repositories.get_client_secret", return_value="secret") - mocker.patch("server.services.repositories.resolve_service_id", return_value="repo1") - response = Response() - response.status_code = HTTPStatus.BAD_REQUEST - http_error = requests.HTTPError(response=response) - mocker.patch("server.clients.services.delete_by_id", side_effect=http_error) - - msg: str = "E500 | An unexpected error occurred in the server application." - with pytest.raises(UnexpectedResponseError, match=msg): - repositories.delete_by_id("repo1", test_config.SP.entity_id) - - -def test_delete_by_id_raises_unexpected_response_error_on_validation_error_( - app, test_config, mocker: MockerFixture -) -> None: - """Tests that UnexpectedResponseError is raised on ValidationError during delete_by_id.""" - dummy_repo = RepositoryDetail(id="repo1", service_name=test_config.SP.entity_id) - dummy_repo._groups = [] # noqa: SLF001 - dummy_repo._rolegroups = [] # noqa: SLF001 - mocker.patch("server.services.repositories.get_by_id", return_value=dummy_repo) - mocker.patch("server.services.repositories.get_access_token", return_value="token") - mocker.patch("server.services.repositories.get_client_secret", return_value="secret") - mocker.patch("server.services.repositories.resolve_service_id", return_value="repo1") - mocker.patch("server.clients.services.delete_by_id", side_effect=ValidationError("fail", [])) - - msg: str = "Failed to parse response from mAP Core API." - with pytest.raises(UnexpectedResponseError, match=msg): - repositories.delete_by_id("repo1", test_config.SP.entity_id) - - -def test_delete_by_id_map_error_unexpected_response(app, test_config, mocker: MockerFixture) -> None: - """Test delete_by_id raises UnexpectedResponseError when MapError.detail does not match known patterns.""" - - map_error = MapError(detail="some unknown delete error", status="400", scim_type="invalidSyntax") - mocker.patch("server.services.repositories.get_access_token", return_value="token") - mocker.patch("server.services.repositories.get_client_secret", return_value="secret") - mocker.patch("server.clients.services.delete_by_id", return_value=map_error) - mocker.patch("server.services.repositories.current_app") - - msg: str = "E033 | Failed to communicate with mAP Core API." - with pytest.raises(UnexpectedResponseError, match=msg): - repositories.delete_by_id("repo1", test_config.SP.entity_id) - - def test_delete_by_id_map_error_e031(app, test_config, mocker: MockerFixture) -> None: """Test delete_by_id raises UnexpectedResponseErrorwhen MapError.detail does not match MAP_NOT_FOUND_PATTERN.""" map_error = MapError(detail="unexpected error", status="400", scim_type="invalidSyntax") diff --git a/tests/unit/services/test_users.py b/tests/unit/services/test_users.py index c0c70c82..1dc5e3d2 100644 --- a/tests/unit/services/test_users.py +++ b/tests/unit/services/test_users.py @@ -515,22 +515,6 @@ def test_create_reraises_credentials_error(mocker: MockerFixture) -> None: users.create(user) -def test_create_raises_resource_invalid_on_map_error(app, mocker: MockerFixture) -> None: - - user = UserDetail(id="u1", user_name="u", emails=[]) - map_user = MapUser(id="u1", user_name="u", schemas=["a"], emails=[]) - map_error = MapError(detail="invalid", status="400", scim_type="invalidSyntax") - mocker.patch("server.services.users.prepare_user", return_value=map_user) - mocker.patch("server.services.users.get_access_token", return_value="token") - mocker.patch("server.services.users.get_client_secret", return_value="secret") - mocker.patch("server.clients.users.post", return_value=map_error) - mock_logger = mocker.patch("flask.current_app.logger.error") - msg = "E031 | Received unexpected response from mAP Core API." - with pytest.raises(users.UnexpectedResponseError, match=msg): - users.create(user) - assert mock_logger.called - - def test_create_duplicate_id_pattern_raises_resource_invalid(app, mocker: MockerFixture) -> None: """Test create raises ResourceInvalid when MAP_DUPLICATE_ID_PATTERN is matched.""" @@ -584,6 +568,22 @@ def test_create_illegal_eppn_pattern_raises_resource_invalid(app, mocker: Mocker users.create(user) +def test_create_raises_resource_invalid_on_map_error(app, mocker: MockerFixture) -> None: + + user = UserDetail(id="u1", user_name="u", emails=[]) + map_user = MapUser(id="u1", user_name="u", schemas=["a"], emails=[]) + map_error = MapError(detail="invalid", status="400", scim_type="invalidSyntax") + mocker.patch("server.services.users.prepare_user", return_value=map_user) + mocker.patch("server.services.users.get_access_token", return_value="token") + mocker.patch("server.services.users.get_client_secret", return_value="secret") + mocker.patch("server.clients.users.post", return_value=map_error) + mock_logger = mocker.patch("flask.current_app.logger.error") + msg = "E031 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): + users.create(user) + assert mock_logger.called + + def test_update_success(app, mocker: MockerFixture) -> None: user = UserDetail(id="u1", user_name="u", emails=[]) @@ -599,12 +599,31 @@ def test_update_success(app, mocker: MockerFixture) -> None: assert result.id == "u1" +def test_update_delegates_to_update_affiliations(app, test_config, mocker): + user = MagicMock(spec=UserDetail) + mocker.patch.object(test_config.MAP_CORE, "user_editable", new=False) + mock_update_affiliations = mocker.patch.object(users, "update_affiliations", return_value="affiliated") + result = update(user) + assert result == "affiliated" + mock_update_affiliations.assert_called_once_with(user) + + +def test_update_delegates_to_update_put(app, test_config, mocker): + user = MagicMock(spec=UserDetail) + mocker.patch.object(test_config.MAP_CORE, "user_editable", new=True) + mocker.patch.object(test_config.MAP_CORE, "update_strategy", new="put") + mock_update_affiliations = mocker.patch.object(users, "update_put", return_value="put_result") + result = update(user) + assert result == "put_result" + mock_update_affiliations.assert_called_once_with(user) + + def test_update_raises_resource_not_found_on_none(app, mocker: MockerFixture) -> None: user = UserDetail(id="u1", user_name="u", emails=[]) mocker.patch("server.services.users.get_by_id", return_value=None) msg = "E304 | User resource (id: u1) not found." - with pytest.raises(users.ResourceNotFound, match=msg): + with pytest.raises(ResourceNotFound, match=msg): users.update(user) @@ -657,24 +676,6 @@ def test_update_raises_unexpected_response_error_on_internal_server_error(app, m users.update(user) -def test_update_raises_unexpected_response_error_on_other_http_error(app, mocker: MockerFixture) -> None: - - user = UserDetail(id="u1", user_name="u", emails=[]) - map_user = MapUser(id="u1", user_name="u", schemas=["a"], emails=[]) - mocker.patch("server.services.users.get_access_token", return_value="token") - mocker.patch("server.services.users.get_client_secret", return_value="secret") - mocker.patch("server.services.users.get_by_id", return_value=user) - mocker.patch("server.services.users.validate_user_to_map_user", return_value=map_user) - mocker.patch("server.services.users.build_patch_operations", return_value=["patchop"]) - response = Response() - response.status_code = HTTPStatus.BAD_REQUEST - http_error = requests.HTTPError(response=response) - mocker.patch("server.clients.users.patch_by_id", side_effect=http_error) - msg = "E320 | Failed to update User resource (id: %(id)s, ePPN: %(eppn)s)." - with pytest.raises(UnexpectedResponseError, match=msg): - users.update(user) - - def test_update_raises_unexpected_response_error_on_request_exception(app, mocker: MockerFixture) -> None: user = UserDetail(id="u1", user_name="u", emails=[]) @@ -725,9 +726,9 @@ def test_update_reraises_credentials_error(app, mocker: MockerFixture) -> None: mocker.patch("server.services.users.get_by_id", return_value=user) mocker.patch("server.services.users.validate_user_to_map_user", return_value=map_user) mocker.patch("server.services.users.build_patch_operations", return_value=["patchop"]) - mocker.patch("server.services.users.get_access_token", side_effect=users.CredentialsError("fail")) + mocker.patch("server.services.users.get_access_token", side_effect=CredentialsError("fail")) msg = "fail" - with pytest.raises(users.CredentialsError, match=msg): + with pytest.raises(CredentialsError, match=msg): users.update(user) @@ -779,39 +780,11 @@ def test_update_raises_resource_invalid_on_map_error(app, mocker: MockerFixture) mock_logger = mocker.patch("flask.current_app.logger.error") mocker.patch("server.clients.users.patch_by_id", return_value=map_error) msg = "invalid" - with pytest.raises(users.ResourceInvalid, match=msg): + with pytest.raises(ResourceInvalid, match=msg): users.update(user) assert mock_logger.called -@pytest.mark.parametrize( - ("editable", "strategy"), [(False, "patch"), (False, "put")], ids=["editable_false_patch", "editable_false_put"] -) -def test_update_put_affiliations_called( - app, test_config, mocker: MockerFixture, *, editable: bool, strategy: str -) -> None: - user = MagicMock(spec=UserDetail) - mocker.patch.object(test_config.MAP_CORE, "user_editable", new=editable) - mocker.patch.object(test_config.MAP_CORE, "update_strategy", new=strategy) - update_affiliations = mocker.patch("server.services.users.update_affiliations", return_value="updated") - - result = update_put(user) - assert result == "updated" - update_affiliations.assert_called_once_with(user) - - -@pytest.mark.parametrize(("editable", "strategy"), [(True, "patch")], ids=["editable_true_patch"]) -def test_update_put_patch_called(app, test_config, mocker: MockerFixture, *, editable: bool, strategy: str) -> None: - user = MagicMock(spec=UserDetail) - mocker.patch.object(test_config.MAP_CORE, "user_editable", new=editable) - mocker.patch.object(test_config.MAP_CORE, "update_strategy", new=strategy) - update = mocker.patch("server.services.users.update", return_value="patched") - - result = update_put(user) - assert result == "patched" - update.assert_called_once_with(user) - - def test_update_put_success(app, test_config, mocker: MockerFixture) -> None: repo_role = RepositoryRole(id="repo1", user_role=USER_ROLES.SYSTEM_ADMIN) @@ -835,6 +808,27 @@ def test_update_put_success(app, test_config, mocker: MockerFixture) -> None: assert result == "user_detail" +def test_update_put_affiliations_called(app, test_config, mocker: MockerFixture) -> None: + user = MagicMock(spec=UserDetail) + mocker.patch.object(test_config.MAP_CORE, "user_editable", new=False) + update_affiliations = mocker.patch("server.services.users.update_affiliations", return_value="updated") + + result = update_put(user) + assert result == "updated" + update_affiliations.assert_called_once_with(user) + + +def test_update_put_patch_called(app, test_config, mocker: MockerFixture) -> None: + user = MagicMock(spec=UserDetail) + mocker.patch.object(test_config.MAP_CORE, "user_editable", new=True) + mocker.patch.object(test_config.MAP_CORE, "update_strategy", new="patch") + update = mocker.patch("server.services.users.update", return_value="patched") + + result = update_put(user) + assert result == "patched" + update.assert_called_once_with(user) + + def test_update_put_user_not_found(app, test_config, mocker: MockerFixture) -> None: """Tests update_put raises ResourceNotFound when current user is None.""" repo_role = RepositoryRole(id="repo1", user_role=USER_ROLES.SYSTEM_ADMIN) @@ -1000,80 +994,12 @@ def test_update_put_map_error_branches( assert exc_msg in str(exc_info.value) -@pytest.mark.parametrize("editable", [False], ids=["user_editable_false"]) -def test_update_delegates_to_update_affiliations(app, test_config, mocker, editable): - user = MagicMock(spec=UserDetail) - mocker.patch.object(test_config.MAP_CORE, "user_editable", new=editable) - mock_update_affiliations = mocker.patch.object(users, "update_affiliations", return_value="affiliated") - result = update(user) - assert result == "affiliated" - mock_update_affiliations.assert_called_once_with(user) - - -@pytest.mark.parametrize("strategy", ["put"], ids=["update_strategy_put"]) -def test_update_delegates_to_update_put(app, test_config, mocker, strategy): - user = MagicMock(spec=UserDetail) - mocker.patch.object(test_config.MAP_CORE, "user_editable", new=True) - mocker.patch.object(test_config.MAP_CORE, "update_strategy", new=strategy) - mock_update_affiliations = mocker.patch.object(users, "update_put", return_value="put_result") - result = update(user) - assert result == "put_result" - mock_update_affiliations.assert_called_once_with(user) - - -@pytest.mark.parametrize("detail", ["User 'u1' Not Found"], ids=["map_not_found_pattern"]) -def test_update_put_map_error_not_found(app, test_config, mocker, detail): - repo_role = RepositoryRole(id="repo1", user_role=USER_ROLES.SYSTEM_ADMIN) - user = UserDetail(id="u1", user_name="u", emails=[], repository_roles=[repo_role]) - mocker.patch.object(test_config.MAP_CORE, "user_editable", new=True) - mocker.patch.object(test_config.MAP_CORE, "update_strategy", new="put") - mocker.patch("server.services.utils.transformers.validate_user_to_map_user", return_value=MagicMock()) - mocker.patch("server.services.users.get_access_token", return_value="token") - mocker.patch("server.services.users.get_client_secret", return_value="secret") - map_error = MagicMock(spec=users.MapError) - map_error.detail = detail - mocker.patch("server.services.users.users.put_by_id", return_value=map_error) - mocker.patch("flask.current_app.logger.info") - mocker.patch("server.services.repositories.get_by_id", return_value=True) - msg = "E053 | Failed to communicate with mAP Core API." - with pytest.raises(UnexpectedResponseError, match=msg): - update_put(user) - - -@pytest.mark.parametrize("detail", ["invalid"], ids=["map_error_invalid"]) -def test_update_put_map_error_invalid(app, test_config, mocker, detail): - repo_role = RepositoryRole(id="repo1", user_role=USER_ROLES.SYSTEM_ADMIN) - user = UserDetail(id="u1", user_name="u", emails=[], repository_roles=[repo_role]) - mocker.patch.object(test_config.MAP_CORE, "user_editable", new=True) - mocker.patch.object(test_config.MAP_CORE, "update_strategy", new="put") - mocker.patch("server.services.utils.transformers.validate_user_to_map_user", return_value=MagicMock()) - mocker.patch("server.services.users.get_access_token", return_value="token") - mocker.patch("server.services.users.get_client_secret", return_value="secret") - mocker.patch("server.services.repositories.get_by_id", return_value=True) - map_error = MagicMock(spec=users.MapError) - map_error.detail = detail - mocker.patch("server.services.users.users.put_by_id", return_value=map_error) - mocker.patch("flask.current_app.logger.info") - msg = "E053 | Failed to communicate with mAP Core API." - with pytest.raises(UnexpectedResponseError, match=msg): - update_put(user) - - -def test_update_affiliations_get_by_id_none(app, mocker): - user = MagicMock(spec=UserDetail) - user.id = "u1" - mocker.patch("server.services.users.get_by_id", return_value=None) - msg = "E304 | User resource (id: u1) not found." - with pytest.raises(ResourceNotFound, match=msg): - update_affiliations(user) - - -def test_update_affiliations_add_op_success(app, mocker): +def test_update_affiliations_replace_op_skipped(app, mocker): user = UserDetail( - id="u1", - user_name="u", + id="u2", + user_name="u2", emails=[], - repository_roles=[RepositoryRole(id="repo1", user_role=USER_ROLES.SYSTEM_ADMIN)], + repository_roles=[RepositoryRole(id="repo2", user_role=USER_ROLES.SYSTEM_ADMIN)], is_system_admin=False, ) current = MagicMock(spec=UserDetail) @@ -1082,68 +1008,68 @@ def test_update_affiliations_add_op_success(app, mocker): mocker.patch("server.services.token.get_oauth_token", return_value=None) mocker.patch("server.services.users.get_by_id", return_value=current) mocker.patch("server.services.users.validate_user_to_map_user", return_value=MagicMock()) + patch_op = MagicMock() - patch_op.op = "add" - patch_op.value = MagicMock() + patch_op.op = "replace" mocker.patch("server.services.users.build_patch_operations", return_value=[patch_op]) mock_logger = mocker.patch("flask.current_app.logger.info") mock_groups = mocker.patch("server.services.groups.update_member") mock_user_updated = mocker.patch("server.services.users.user_updated.send") mocker.patch("server.services.users.get_by_id", return_value=current) result = users.update_affiliations(user) - mock_groups.assert_called() + mock_groups.assert_not_called() assert mock_logger.called mock_user_updated.assert_called() assert result == current -def test_update_affiliations_replace_op_skipped(app, mocker): +def test_update_affiliations_remove_op_regex_success(app, mocker): user = UserDetail( - id="u2", - user_name="u2", + id="u3", + user_name="u3", emails=[], - repository_roles=[RepositoryRole(id="repo2", user_role=USER_ROLES.SYSTEM_ADMIN)], + repository_roles=[RepositoryRole(id="repo3", user_role=USER_ROLES.SYSTEM_ADMIN)], is_system_admin=False, ) current = MagicMock(spec=UserDetail) mocker.patch("server.services.repositories.get_by_id", return_value=True) mocker.patch("server.services.token.get_access_token", return_value="dummy_token") mocker.patch("server.services.token.get_oauth_token", return_value=None) + mocker.patch("server.services.users.get_by_id", return_value=current) mocker.patch("server.services.users.validate_user_to_map_user", return_value=MagicMock()) - patch_op = MagicMock() - patch_op.op = "replace" + patch_op.op = "remove" + patch_op.path = 'groups[value eq "group1"]' mocker.patch("server.services.users.build_patch_operations", return_value=[patch_op]) mock_logger = mocker.patch("flask.current_app.logger.info") mock_groups = mocker.patch("server.services.groups.update_member") mock_user_updated = mocker.patch("server.services.users.user_updated.send") mocker.patch("server.services.users.get_by_id", return_value=current) result = users.update_affiliations(user) - mock_groups.assert_not_called() + mock_groups.assert_called() assert mock_logger.called mock_user_updated.assert_called() assert result == current -def test_update_affiliations_remove_op_regex_success(app, mocker): +def test_update_affiliations_add_op_success(app, mocker): user = UserDetail( - id="u3", - user_name="u3", + id="u1", + user_name="u", emails=[], - repository_roles=[RepositoryRole(id="repo3", user_role=USER_ROLES.SYSTEM_ADMIN)], + repository_roles=[RepositoryRole(id="repo1", user_role=USER_ROLES.SYSTEM_ADMIN)], is_system_admin=False, ) current = MagicMock(spec=UserDetail) mocker.patch("server.services.repositories.get_by_id", return_value=True) mocker.patch("server.services.token.get_access_token", return_value="dummy_token") mocker.patch("server.services.token.get_oauth_token", return_value=None) - mocker.patch("server.services.users.get_by_id", return_value=current) mocker.patch("server.services.users.validate_user_to_map_user", return_value=MagicMock()) patch_op = MagicMock() - patch_op.op = "remove" - patch_op.path = 'groups[value eq "group1"]' + patch_op.op = "add" + patch_op.value = MagicMock() mocker.patch("server.services.users.build_patch_operations", return_value=[patch_op]) mock_logger = mocker.patch("flask.current_app.logger.info") mock_groups = mocker.patch("server.services.groups.update_member") @@ -1184,18 +1110,13 @@ def test_update_affiliations_remove_op_regex_fail(app, mocker): assert result == current -def test_update_affiliations_not_found(app, mocker): - user = UserDetail( - id="u6", - user_name="u6", - emails=[], - repository_roles=[RepositoryRole(id="repo6", user_role=USER_ROLES.SYSTEM_ADMIN)], - is_system_admin=False, - ) +def test_update_affiliations_get_by_id_none(app, mocker): + user = MagicMock(spec=UserDetail) + user.id = "u1" mocker.patch("server.services.users.get_by_id", return_value=None) - msg = "E304 | User resource (id: u6) not found." + msg = "E304 | User resource (id: u1) not found." with pytest.raises(ResourceNotFound, match=msg): - users.update_affiliations(user) + update_affiliations(user) def test_update_affiliations_raises_oauth_token_error(app, mocker): @@ -1248,7 +1169,6 @@ def test_update_affiliations_raises_typeerror_on_exception_group(app, mocker): def test_get_system_admins_success(app, mocker: MockerFixture) -> None: - map_user1 = MapUser(id="u1", user_name="u1", schemas=["a"], emails=[]) map_user2 = MapUser(id="u2", user_name="u2", schemas=["a"], emails=[]) mocker.patch("server.services.users.search", return_value=type("obj", (), {"resources": [map_user1, map_user2]})()) @@ -1311,21 +1231,6 @@ def test_count_raises_unexpected_response_error_on_internal_server_error(app, mo users.count(criteria) -def test_count_raises_unexpected_response_error_on_other_http_error(app, mocker: MockerFixture) -> None: - """Test count raises UnexpectedResponseError on other HTTP errors.""" - criteria = make_criteria_object("users", q='userName eq "u"') - mocker.patch("server.services.users.build_search_query", return_value=SearchRequestParameter(filter="dummy")) - mocker.patch("server.services.users.get_access_token", return_value="token") - mocker.patch("server.services.users.get_client_secret", return_value="secret") - response = Response() - response.status_code = HTTPStatus.BAD_REQUEST - http_error = requests.HTTPError(response=response) - mocker.patch("server.clients.users.search", side_effect=http_error) - msg = "E051 | Received unexpected response from mAP Core API." - with pytest.raises(UnexpectedResponseError, match=msg): - users.count(criteria) - - def test_count_raises_unexpected_response_error_on_request_exception(app, mocker: MockerFixture) -> None: """Test count raises UnexpectedResponseError on requests.RequestException.""" criteria = make_criteria_object("users", q='userName eq "u"') @@ -1350,15 +1255,6 @@ def test_count_raises_unexpected_response_error_on_validation_error(app, mocker: users.count(criteria) -def test_count_reraises_invalid_query_error(app, mocker: MockerFixture) -> None: - """Test count re-raises InvalidQueryError directly from try block.""" - criteria = make_criteria_object("users", q='userName eq "u"') - mocker.patch("server.services.users.build_search_query", side_effect=InvalidQueryError("fail")) - msg = "fail" - with pytest.raises(InvalidQueryError, match=msg): - users.count(criteria) - - def test_count_reraises_oauth_token_error(mocker: MockerFixture) -> None: """Test count re-raises OAuthTokenError directly from try block.""" criteria = make_criteria_object("users", q='userName eq "u"') @@ -1399,6 +1295,13 @@ def test_count_raises_invalid_query_error_on_map_error(app, mocker: MockerFixtur assert mock_logger.called +def test_handle_user_updated_type_check(mocker: MockerFixture) -> None: + """Tests handle_user_updated returns immediately if user is not UserDetail.""" + + result = users.handle_user_updated(_sender=None, user=None) + assert result is None + + def test_handle_user_updated_eppns_true(mocker: MockerFixture) -> None: user = UserDetail(id="u1", user_name="u", emails=[], eppns=["eppn1"]) diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py new file mode 100644 index 00000000..9ba3994b --- /dev/null +++ b/tests/unit/test_app.py @@ -0,0 +1,8 @@ +from server.app import app + + +def test_app_initialization(): + """Tests that the Flask app is initialized correctly.""" + + assert app is not None + assert hasattr(app, "config") diff --git a/tests/unit/test_celery_app.py b/tests/unit/test_celery_app.py new file mode 100644 index 00000000..5d5ef073 --- /dev/null +++ b/tests/unit/test_celery_app.py @@ -0,0 +1,8 @@ +from server.celery_app import celery_app + + +def test_celery_app_initialization(): + """Tests that the Celery app is initialized correctly.""" + + assert celery_app is not None + assert hasattr(celery_app, "send_task") diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 00000000..7366ad30 --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,123 @@ +from unittest.mock import MagicMock + +import pytest + +from server.config import ( + USER_ROLES, + E, + GroupIdPatternsConfig, + GroupNamePatternsConfig, + GroupsConfig, + RepositoriesConfig, + config, + safe_eval, +) +from server.factory import celery_init_app, create_app + + +def test_celery_init_app_once(): + app = create_app(__name__) + celery = celery_init_app(app) + assert celery is app.extensions["celery"] + + +def test_celery_property_redis_sentinel_cache_type(app, mocker): + """CELERY property returns correct config when cache_type is RedisSentinelCache (mocked).""" + mocker.patch("pydantic.main._check_frozen") + mock_redis_config = MagicMock(cache_type="RedisSentinelCache") + mocker.patch.object(config, "REDIS", mock_redis_config) + _ = config.CELERY + assert config.REDIS.cache_type == "RedisSentinelCache" + + mocker.stopall() + + +def test_celery_property_redis_sentinel_cache_type_no_sentinel(app, mocker): + """CELERY property: cache_type=RedisSentinelCache but REDIS.sentinel is None (False branch).""" + mocker.patch("pydantic.main._check_frozen") + mock_redis_config = MagicMock(cache_type="RedisSentinelCache_false") + mocker.patch.object(config, "REDIS", mock_redis_config) + _ = config.CELERY + assert config.REDIS.cache_type == "RedisSentinelCache_false" + + mocker.stopall() + + +def test_remember_cookie_duration_absolute(app, mocker): + mocker.patch.object(config.SESSION, "strategy", "absolute") + _ = config.REMEMBER_COOKIE_DURATION + assert config.REDIS.cache_type == "RedisCache" + + mocker.stopall() + + +def test_remember_cookie_duration_other(app, mocker): + + mocker.patch.object(config.SESSION, "strategy", "invalid") + with pytest.raises(UnboundLocalError): + _ = config.REMEMBER_COOKIE_DURATION + assert config.REDIS.cache_type == "RedisCache" + + mocker.stopall() + + +def test_validate_max_url_length_syntax_error(): + invalid_expr = "1 + * 2" + msg = "E003 | Invalid syntax in expression in server configuration." + + with pytest.raises(ValueError, match=msg): + RepositoriesConfig.validate_max_url_length(invalid_expr) + + +def test_validate_max_id_length_syntax_error(): + invalid_expr = "abc + * 1" + msg = "E003 | Invalid syntax in expression in server configuration." + + with pytest.raises(ValueError, match=msg): + GroupsConfig.validate_max_id_length(invalid_expr) + + +def test_group_patterns_config_validation_success(): + + id_patterns = GroupNamePatternsConfig( + system_admin="sysadmin", + repository_admin="repo_admin_{repository_name}", + community_admin="community_admin_{repository_name}", + contributor="contributor_{repository_name}", + general_user="general_user_{repository_name}", + ) + name_patterns = GroupIdPatternsConfig( + system_admin="sysadmin_id", + repository_admin="repo_admin_{repository_id}", + community_admin="community_admin_{repository_id}", + contributor="contributor_{repository_id}", + general_user="general_user_{repository_id}", + user_defined="user_defined_{repository_id}_{user_defined_id}", + ) + assert id_patterns[USER_ROLES.SYSTEM_ADMIN] == "sysadmin" + assert name_patterns[USER_ROLES.SYSTEM_ADMIN] == "sysadmin_id" + + +def test_safe_eval_unsupported_function(): + with pytest.raises(ValueError, match=str(E.UNSUPPORTED_EXPRESSION) % {"exp": "sum(1,2)"}): + safe_eval("sum(1,2)") + + +def test_safe_eval_syntax_error(): + with pytest.raises(SyntaxError): + safe_eval("1 + * 2") + + +def test_safe_eval_max_branch(): + excepted_value = 10 + assert safe_eval("max(10, 3, 7)") == excepted_value + + +def test_safe_eval_min_branch(): + excepted_value = 3 + assert safe_eval("min(10, 3, 7)") == excepted_value + + +def test_safe_eval_unsupported_ast_node(): + with pytest.raises(ValueError, match=str(E.UNSUPPORTED_EXPRESSION) % {"exp": "[1, 2, 3]"}): + safe_eval("[1, 2, 3]") diff --git a/tests/unit/test_datastore.py b/tests/unit/test_datastore.py new file mode 100644 index 00000000..22942b54 --- /dev/null +++ b/tests/unit/test_datastore.py @@ -0,0 +1,28 @@ +import pytest + +from redis.exceptions import ConnectionError as RedisConnectionError + +from server.datastore import connection +from server.exc import ConfigurationError + + +def test_redis_connection_error_logs_warning_1(app, mocker): + mocker.patch("server.config.config.REDIS.cache_type", new="DummyCache") + sentinel_mock = mocker.patch("server.datastore.sentinel.Sentinel") + instance = sentinel_mock.return_value + store_mock = mocker.Mock() + store_mock.ping.side_effect = RedisConnectionError("connection failed") + instance.master_for.return_value = store_mock + logger_mock = mocker.patch.object(app.logger, "warning") + connection(db=1) + logger_mock.assert_called() + assert "connection failed" in logger_mock.call_args[0][0] + + +def test_redis_connection_error_logs_warning(app, mocker): + + mocker.patch("server.config.config.REDIS.cache_type", new="RedisCache") + mocker.patch("server.datastore.Redis.from_url", side_effect=ValueError("invalid config")) + with pytest.raises(ConfigurationError) as exc_info: + connection(db=1) + assert "invalid config" in str(exc_info.value)