diff --git a/Dockerfile b/Dockerfile index a15c2ed..9fd0282 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,9 @@ COPY ./ /kb/module RUN mkdir -p /kb/module/work WORKDIR /kb/module -RUN pip install --upgrade pip && pip install -r requirements.txt +RUN pip install --upgrade pip && \ + pip install -r requirements.txt && \ + pip install -r requirements-dev.txt ENV PYTHONPATH="/kb/module/lib:$PYTHONPATH" diff --git a/lib/NarrativeService/NarrativeServiceImpl.py b/lib/NarrativeService/NarrativeServiceImpl.py index e4b053f..19c75a9 100644 --- a/lib/NarrativeService/NarrativeServiceImpl.py +++ b/lib/NarrativeService/NarrativeServiceImpl.py @@ -6,7 +6,7 @@ from NarrativeService.DynamicServiceCache import DynamicServiceClient from NarrativeService.NarrativeListUtils import NarrativeListUtils, NarratorialUtils from NarrativeService.NarrativeManager import NarrativeManager -from NarrativeService.ReportFetcher import ReportFetcher +from NarrativeService.reportfetcher import ReportFetcher from NarrativeService.SearchServiceClient import SearchServiceClient from NarrativeService.sharing.sharemanager import ShareRequester @@ -860,7 +860,7 @@ def get_narrative_doc(self, ctx, params): """ Intended to return data of previous versions of a given narrative in the same format returned from Search. Formats a call to workspace service to fit the appropriate schema that is intended for use in UI displays - in the narrative navigator. Raises error is "narrative_upa" param is not in specified format. + in the narrative navigator. Raises error is "narrative_upa" param is not in specified format. Note that this method is currently to support the UI only, and does not return the full result of a search call, and the following fields are omitted: boolean copied, boolean is_narratorial, boolean is_temporary, string obj_name, string obj_type_module, string obj_type_version, list tags. diff --git a/lib/NarrativeService/ReportFetcher.py b/lib/NarrativeService/ReportFetcher.py deleted file mode 100644 index aac3dc6..0000000 --- a/lib/NarrativeService/ReportFetcher.py +++ /dev/null @@ -1,51 +0,0 @@ -from NarrativeService.ServiceUtils import ServiceUtils - - -class ReportFetcher: - def __init__(self, ws_client): - self.ws_client = ws_client - - def find_report_from_object(self, upa): - #TODO: - # 1. make sure upa's real. - - # first, fetch object references (without data) - ref_list = self.ws_client.list_referencing_objects([{"ref": upa}])[0] - # scan it for a report. - # if we find at least one, return them - # if we find 0, test if it's a copy, and search upstream. - if len(ref_list): - report_upas = list() - for ref_info in ref_list: - if "KBaseReport.Report" in ref_info[2]: - report_upas.append(ServiceUtils.object_info_to_object(ref_info)["ref"]) - if len(report_upas): - return self.build_output(upa, report_upas) - else: - return self.find_report_from_copy_source(upa) - else: - return self.find_report_from_copy_source(upa) - - def find_report_from_copy_source(self, upa): - """ - Fetch the info about this object. If it's a copy, run find_report_from_object on its source. - If it's not, return an error state, or just an empty list for the upas. - """ - obj_data = self.ws_client.get_objects2({"objects": [{"ref": upa}], "no_data": 1})["data"][0] - if obj_data.get("copy_source_inaccessible", 0) == 1: - err = "No report found. This object is a copy, and its source is inaccessible." - return self.build_output(upa, [], inaccessible=1, error=err) - elif "copied" in obj_data: - return self.find_report_from_object(obj_data["copied"]) - return self.build_output(upa, []) - - def build_output(self, upa, report_upas=[], inaccessible=0, error=None): - retVal = { - "report_upas": report_upas, - "object_upa": upa - } - if inaccessible != 0: - retVal["inaccessible"] = inaccessible - if error is not None: - retVal["error"] = error - return retVal diff --git a/lib/NarrativeService/reportfetcher.py b/lib/NarrativeService/reportfetcher.py new file mode 100644 index 0000000..a4daf5f --- /dev/null +++ b/lib/NarrativeService/reportfetcher.py @@ -0,0 +1,72 @@ +from NarrativeService.ServiceUtils import ServiceUtils + +from lib.installed_clients.WorkspaceClient import Workspace + +REPORT_TYPE: str = "KBaseReport.Report" +class ReportFetcher: + def __init__(self, ws_client: Workspace) -> None: + self.ws_client = ws_client + + def find_report_from_object(self, upa: str) -> dict[str, list|str]: + """ + Given the UPA of an object, this attempts to find any reports that it references. + If the object doesn't have any referencing reports, but it was a copy of another + object, this tries to find the copy's reports. + """ + # TODO: make sure upa's real. + + # first, fetch object references (without data) + ref_list = self.ws_client.list_referencing_objects([{"ref": upa}])[0] + # scan it for a report. + # if we find at least one, return them + # if we find 0, test if it's a copy, and search upstream. + report_upas = [ + ServiceUtils.object_info_to_object(ref_info)["ref"] + for ref_info in ref_list + if len(ref_info) and REPORT_TYPE in ref_info[2] + ] + if len(report_upas): + return self.build_output(upa, report_upas) + return self.find_report_from_copy_source(upa) + + def find_report_from_copy_source(self, upa: str) -> dict[str, list|str]: + """ + Fetch the info about this object. If it's a copy, run find_report_from_object on its source. + If it's not, return an error state, or just an empty list for the upas. + """ + obj_data = self.ws_client.get_objects2({"objects": [{"ref": upa}], "no_data": 1})["data"][0] + if obj_data.get("copy_source_inaccessible", 0) == 1: + err = "No report found. This object is a copy, and its source is inaccessible." + return self.build_output(upa, [], inaccessible=1, error=err) + if "copied" in obj_data: + return self.find_report_from_object(obj_data["copied"]) + return self.build_output(upa, []) + + def build_output( + self, + upa: str, + report_upas: list[str] | None=None, + inaccessible: int=0, + error: str | None=None + ) -> dict[str, list|str|int]: + """ + Builds the output dictionary for the report fetching. + Definitely has keys: + report_upas - list[str] - list of report object UPAs, if they exist + object_upa - str - the UPA of the object referencing the reports. + Might have keys: + inaccessible - int (1) if the given object in object_upa was copied, and its source + (which might reference reports) is inaccessible + error - str - if an error occurred. + """ + if report_upas is None: + report_upas = [] + ret_val = { + "report_upas": report_upas, + "object_upa": upa + } + if inaccessible != 0: + ret_val["inaccessible"] = inaccessible + if error is not None: + ret_val["error"] = error + return ret_val diff --git a/lib/NarrativeService/sharing/sharemanager.py b/lib/NarrativeService/sharing/sharemanager.py index 0460c28..226d5e2 100644 --- a/lib/NarrativeService/sharing/sharemanager.py +++ b/lib/NarrativeService/sharing/sharemanager.py @@ -2,28 +2,36 @@ from installed_clients.baseclient import ServerError from NarrativeService import feeds -# from storage.mongo import ( -# save_share_request, -# find_existing_share_request -# ) -SERVICE_TOKEN_KEY = "service-token" -WS_TOKEN_KEY = "ws-admin-token" +SERVICE_TOKEN_KEY = "service-token" # noqa: S105 +WS_TOKEN_KEY = "ws-admin-token" # noqa: S105 class ShareRequester: - def __init__(self, params, config): + def __init__(self, params: dict[str, str], config: dict[str, str | int]) -> None: + """This class handles requesting that a Narrative is shared with another + user. + + params is a dict with keys: + * ws_id - the (int) workspace to share with + * share_level - the requested sharing level + * user - the user to be shared with (not necessarily the user requesting the share) + + config is a dict with expected keys + * SERVICE_TOKEN_KEY + * WS_TOKEN_KEY + * workspace-url + """ self.validate_request_params(params) self.ws_id = params["ws_id"] self.user = params["user"] self.share_level = params["share_level"] self.config = config - def request_share(self): + def request_share(self) -> dict[str, str | int]: """ - params is a dict with keys: - ws_id - the (int) workspace to share with - share_level - the requested sharing level - user - the user to be shared with (not necessarily the user requesting the share) + This requests that a narrative is shared with a user, and returns a small dictionary + with sharing request results. The request is made via the Feeds service, which notifies + the owner(s) of the requested narrative that someone else wants to view it. """ service_token = self.config.get(SERVICE_TOKEN_KEY) if service_token is None: @@ -65,16 +73,21 @@ def request_share(self): "level": self.share_level }, "level": "request", - "users": [{"id": u, "type": "user"} for u in requestees + [self.user]] + "users": [{"id": u, "type": "user"} for u in [*requestees, self.user]] } feeds.make_notification(note, self.config["feeds-url"], service_token) - # Store that we made the request, uh, somewhere - # save_share_request(self.ws_id, self.user, self.level, note_id) return ret_value - def validate_request_params(self, params): + def validate_request_params(self, params: dict[str, str]) -> None: + """Checks the given set of parameters for missing values or, in the case of share_level, + incorrect values. + + Expected keys should be ws_id, share_level, and user. share_level should be "a", "n", or "r". + + If value is missing, or if share_level is incorrect, this raises a ValueError. + """ reqd = ["ws_id", "share_level", "user"] for r in reqd: if r not in params or params[r] is None: diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/requirements-dev.txt b/requirements-dev.txt index a2b8b13..7e68b32 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,6 @@ coverage==7.5.3 pytest-cov==5.0.0 pytest==8.2.1 +pytest-mock==3.14.0 requests-mock==1.12.1 ruff==0.4.6 diff --git a/test/DataFetch_test.py b/test/DataFetch_test.py index bfcb40c..5f1dc61 100644 --- a/test/DataFetch_test.py +++ b/test/DataFetch_test.py @@ -7,7 +7,7 @@ from NarrativeService.data.fetcher import DataFetcher from NarrativeService.NarrativeServiceImpl import NarrativeService from NarrativeService.NarrativeServiceServer import MethodContext -from workspace_mock import EmptyWorkspaceMock, WorkspaceMock +from .workspace_mock import EmptyWorkspaceMock, WorkspaceMock class WsMock: diff --git a/test/NarrativeManager_test.py b/test/NarrativeManager_test.py index c8f29aa..5eb5b6f 100644 --- a/test/NarrativeManager_test.py +++ b/test/NarrativeManager_test.py @@ -5,7 +5,7 @@ from unittest import mock from NarrativeService.NarrativeManager import NarrativeManager -from workspace_mock import WorkspaceMock +from .workspace_mock import WorkspaceMock class NarrativeManagerTestCase(unittest.TestCase): diff --git a/test/ReportFetcher_test.py b/test/ReportFetcher_test.py deleted file mode 100644 index 0ffe72f..0000000 --- a/test/ReportFetcher_test.py +++ /dev/null @@ -1,177 +0,0 @@ -import os -import time -import unittest -from configparser import ConfigParser - -from installed_clients.authclient import KBaseAuth as _KBaseAuth -from installed_clients.FakeObjectsForTestsClient import FakeObjectsForTests -from installed_clients.KBaseReportClient import KBaseReport -from installed_clients.WorkspaceClient import Workspace -from NarrativeService.NarrativeServiceImpl import NarrativeService -from NarrativeService.NarrativeServiceServer import MethodContext - - -class ReportFetcherTestCase(unittest.TestCase): - @classmethod - def setUpClass(cls): - token = os.environ.get("KB_AUTH_TOKEN", None) - config_file = os.environ.get("KB_DEPLOYMENT_CONFIG", None) - cls.cfg = {} - config = ConfigParser() - config.read(config_file) - for nameval in config.items("NarrativeService"): - cls.cfg[nameval[0]] = nameval[1] - authServiceUrl = cls.cfg.get( - "auth-service-url", - "https://kbase.us/services/authorization/Sessions/Login") - auth_client = _KBaseAuth(authServiceUrl) - user_id = auth_client.get_user(token) - # WARNING: don't call any logging methods on the context object, - # it'll result in a NoneType error - cls.ctx = MethodContext(None) - cls.ctx.update({"token": token, - "user_id": user_id, - "provenance": [ - {"service": "NarrativeService", - "method": "please_never_use_it_in_production", - "method_params": [] - }], - "authenticated": 1}) - # Set up test Workspace - cls.ws_url = cls.cfg["workspace-url"] - cls.ws_client = Workspace(cls.ws_url, token=token) - cls.test_ws_info = cls._make_workspace() - cls.test_ws_name = cls.test_ws_info[1] - # Build test data stuff. - # 1. Make a fake reads object - test for report (should be null) - cls.fake_reads_upa = cls._make_fake_reads(cls.test_ws_name, "FakeReads") - - # 2. Make a report, give it that reads object - test for report, should find it - cls.fake_report_upa = cls._make_fake_report(cls.fake_reads_upa, cls.test_ws_name) - - cls.service_impl = NarrativeService(cls.cfg) - - @classmethod - def tearDownClass(cls): - # delete main test workspace - # cls.ws_client.delete_workspace({'workspace': cls.test_ws_name}) - print(f"deleted test workspace: {cls.test_ws_info[0]} - {cls.test_ws_name}") - - @classmethod - def _make_workspace(cls): - """ - make a workspace - return ws info - """ - suffix = int(time.time() * 1000) - ws_name = "test_NarrativeService_" + str(suffix) - ws_info = cls.ws_client.create_workspace({"workspace": ws_name}) - return ws_info - - @classmethod - def _make_fake_report(cls, ref_obj, ws_name): - """ - Make a dummy report, referring to ref_obj, returns report ref - """ - report_params = { - "message": "dummy report for testing", - "objects_created": [{ - "ref": ref_obj, - "description": "dummy reads lib" - }], - "report_object_name": "NarrativeServiceTest_report_" + str(int(time.time() * 1000)), - "workspace_name": ws_name - } - kr = KBaseReport(os.environ["SDK_CALLBACK_URL"]) - report_output = kr.create_extended_report(report_params) - return report_output["ref"] - - @classmethod - def _make_fake_reads(cls, ws_name, reads_name): - """ - Make fake reads in the workspace with the given name. - Return the UPA for those reads. - """ - foft = FakeObjectsForTests(os.environ["SDK_CALLBACK_URL"]) - info = foft.create_fake_reads({ - "ws_name": ws_name, - "obj_names": [reads_name]})[0] - reads_ref = f"{info[6]}/{info[0]}/{info[4]}" - return reads_ref - - def get_ws_client(self): - return self.__class__.ws_client - - def get_impl(self): - return self.__class__.service_impl - - def get_context(self): - return self.__class__.ctx - - def test_fetch_report_ok(self): - ret = self.get_impl().find_object_report(self.get_context(), {"upa": self.fake_reads_upa})[0] - self.assertIn("report_upas", ret) - self.assertEqual(len(ret["report_upas"]), 1) - self.assertEqual(self.fake_report_upa, ret["report_upas"][0]) - self.assertIn("object_upa", ret) - self.assertEqual(self.fake_reads_upa, ret["object_upa"]) - self.assertNotIn("copy_inaccessible", ret) - self.assertNotIn("error", ret) - - def test_fetch_report_copy(self): - copy_info = self.ws_client.copy_object({ - "from": { - "ref": self.fake_reads_upa - }, - "to": { - "workspace": self.test_ws_name, - "name": "FakeReadsCopy" - } - }) - upa = f"{copy_info[6]}/{copy_info[0]}/{copy_info[4]}" - source_upa = self.fake_reads_upa - ret = self.get_impl().find_object_report(self.get_context(), {"upa": upa})[0] - self.assertIn("report_upas", ret) - self.assertEqual(len(ret["report_upas"]), 1) - self.assertEqual(self.fake_report_upa, ret["report_upas"][0]) - self.assertIn("object_upa", ret) - self.assertEqual(source_upa, ret["object_upa"]) - self.assertNotIn("copy_inaccessible", ret) - self.assertNotIn("error", ret) - - def test_fetch_report_copy_inaccessible(self): - # Make a new workspace copy new reads to older WS, delete new WS - test for report, should fail and error. - new_ws_info = self.__class__._make_workspace() - # Make reads - new_reads_upa = self.__class__._make_fake_reads(new_ws_info[1], "NewFakeReads") - # Make report to new reads - self.__class__._make_fake_report(new_reads_upa, new_ws_info[1]) - # Copy new reads to old WS - copy_info = self.ws_client.copy_object({ - "from": { - "ref": new_reads_upa - }, - "to": { - "workspace": self.test_ws_name, - "name": "NewFakeReadsCopy" - } - }) - new_reads_copy_upa = f"{copy_info[6]}/{copy_info[0]}/{copy_info[4]}" - # delete new WS - self.ws_client.delete_workspace({"id": new_ws_info[0]}) - # now test for report and find the error - ret = self.get_impl().find_object_report(self.get_context(), {"upa": new_reads_copy_upa})[0] - self.assertIn("report_upas", ret) - self.assertEqual(len(ret["report_upas"]), 0) - self.assertIn("object_upa", ret) - self.assertEqual(new_reads_copy_upa, ret["object_upa"]) - self.assertIn("inaccessible", ret) - self.assertIn("error", ret) - - def test_fetch_report_none(self): - upa = self.fake_report_upa # just use the report as an upa. it doesn't have a report, right? - ret = self.get_impl().find_object_report(self.get_context(), {"upa": upa})[0] - self.assertIn("report_upas", ret) - self.assertEqual(len(ret["report_upas"]), 0) - self.assertNotIn("copy_inaccessible", ret) - self.assertNotIn("error", ret) diff --git a/test/ShareManager_test.py b/test/ShareManager_test.py deleted file mode 100644 index e2ca4a5..0000000 --- a/test/ShareManager_test.py +++ /dev/null @@ -1,73 +0,0 @@ -import os -import unittest -from configparser import ConfigParser -from unittest import mock - -import requests -from NarrativeService.sharing import sharemanager - - -class WsMock: - def __init__(self, *args, **kwargs): - pass - - def administer(self, *args, **kwargs): - return {"perms": [{"foo": "a", "bar": "w"}]} - -def mock_feed_post(*args, **kwargs): - class MockResponse: - def __init__(self, json_data, status_code): - self.json_data = json_data - self.status_code = status_code - - def json(self): - return self.json_data - - def raise_for_status(self): - if self.status_code != 200: - raise requests.HTTPError() - return MockResponse({"id": "foo"}, 200) - -class ShareRequesterTestCase(unittest.TestCase): - @classmethod - def setUpClass(cls): - config_file = os.environ.get("KB_DEPLOYMENT_CONFIG", None) - cls.NARRATIVE_TYPE = "KBaseNarrative.Narrative-4.0" - cls.config = {} - config = ConfigParser() - config.read(config_file) - for nameval in config.items("NarrativeService"): - cls.config[nameval[0]] = nameval[1] - - def test_valid_params(self): - p = { - "user": "foo", - "ws_id": 123, - "share_level": "a" - } - sharemanager.ShareRequester(p, self.config) - - def test_invalid_params(self): - with self.assertRaises(ValueError) as e: - sharemanager.ShareRequester({"user": "foo", "share_level": "a"}, self.config) - self.assertIn('Missing required parameter "ws_id"', str(e.exception)) - - with self.assertRaises(ValueError) as e: - sharemanager.ShareRequester({"ws_id": 123, "share_level": "a"}, self.config) - self.assertIn('Missing required parameter "user"', str(e.exception)) - - with self.assertRaises(ValueError) as e: - sharemanager.ShareRequester({"user": "foo", "ws_id": 123}, self.config) - self.assertIn('Missing required parameter "share_level"', str(e.exception)) - - with self.assertRaises(ValueError) as e: - sharemanager.ShareRequester({"user": "foo", "share_level": "x", "ws_id": 123}, self.config) - self.assertIn("Invalid share level: x. Should be one of a, n, r.", str(e.exception)) - - @mock.patch("NarrativeService.sharing.sharemanager.feeds.requests.post", side_effect=mock_feed_post) - @mock.patch("NarrativeService.sharing.sharemanager.ws.Workspace", side_effect=WsMock) - def test_make_notification(self, mock_ws, mock_post): - req = sharemanager.ShareRequester({"user": "kbasetest", "ws_id": 123, "share_level": "r"}, self.config) - res = req.request_share() - self.assertIn("ok", res) - self.assertEqual(res["ok"], 1) diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..0f13b49 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,108 @@ +import os +from collections.abc import Generator +from configparser import ConfigParser +from time import time + +import pytest +from installed_clients.authclient import KBaseAuth +from NarrativeService.NarrativeServiceImpl import NarrativeService +from NarrativeService.NarrativeServiceServer import MethodContext + +from lib.installed_clients.FakeObjectsForTestsClient import FakeObjectsForTests +from lib.installed_clients.WorkspaceClient import Workspace + + +@pytest.fixture(scope="session") +def config() -> Generator[dict[str, str | int], None, None]: + """Load and return the test config as a dictionary.""" + config_file = os.environ.get("KB_DEPLOYMENT_CONFIG", None) + config = ConfigParser() + config.read(config_file) + yield dict(config.items("NarrativeService")) + +@pytest.fixture(scope="session") +def auth_token() -> Generator[str, None, None]: + token = os.environ.get("KB_AUTH_TOKEN") + yield token + +@pytest.fixture(scope="session") +def workspace(workspace_client: Workspace) -> Generator[list[any], None, None]: + ws_name = f"test_NarrativeService_{int(time()*1000)}" + ws_info = workspace_client.create_workspace({"workspace": ws_name}) + yield ws_info + workspace_client.delete_workspace({"id": ws_info[0]}) + +@pytest.fixture(scope="session") +def workspace_client( + config: dict[str, str | int], + auth_token: str +) -> Generator[Workspace, None, None]: + if auth_token is None: + err = "A valid auth token is needed to make a real workspace client for integration tests" + raise RuntimeError(err) + yield Workspace(config["workspace-url"], token=auth_token) + +@pytest.fixture(scope="session") +def fake_obj_for_tests_client(auth_token: str) -> Generator[FakeObjectsForTests, None, None]: + if auth_token is None: + err = "A valid auth token is needed to make a FOFT client for integration tests" + raise RuntimeError(err) + yield FakeObjectsForTests(os.environ["SDK_CALLBACK_URL"], token=auth_token) + +@pytest.fixture(scope="session") +def auth_client(config: dict[str, str | int]) -> Generator[KBaseAuth, None, None]: + yield KBaseAuth(config["auth-service-url"]) + +@pytest.fixture(scope="session") +def context(auth_token: str, auth_client: KBaseAuth) -> Generator[dict[str, any], None, None]: + ctx = MethodContext(None) + user_id = auth_client.get_user(auth_token) + ctx.update({ + "token": auth_token, + "user_id": user_id, + "provenance": [{ + "service": "NarrativeService", + "method": "please_never_use_it_in_production", + "method_params": [] + }], + "authenticated": 1 + }) + yield ctx + +@pytest.fixture(scope="session") +def service_impl(config: dict[str, str | int]) -> Generator[NarrativeService, None, None]: + impl = NarrativeService(config) + yield impl + +MOCK_AUTH_TOKEN: str = "mock_auth_token" +MOCK_USER_ID: str = "mock_user" +MOCK_WS_ID: int = 123 +MOCK_WS_INFO: list[str | int | dict[str, str]] = [ + MOCK_WS_ID, + "mock_workspace", + MOCK_USER_ID, + "2024-05-30T15:22:20+0000", + 1, + "a", + "n", + "unlocked", + { + "cell_count": "0", + "narrative_nice_name": "A New Narrative", + "searchtags": "narrative", + "is_temporary": "false", + "narrative": "1" + } +] + +@pytest.fixture +def mock_workspace(): + return MOCK_WS_INFO.copy() + +@pytest.fixture +def mock_token(): + return MOCK_AUTH_TOKEN + +@pytest.fixture +def mock_user(): + return MOCK_USER_ID diff --git a/test/integration/__init__.py b/test/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/integration/test_reportfetcher.py b/test/integration/test_reportfetcher.py new file mode 100644 index 0000000..c6dcae1 --- /dev/null +++ b/test/integration/test_reportfetcher.py @@ -0,0 +1,152 @@ +import os +import time + +import pytest +from installed_clients.FakeObjectsForTestsClient import FakeObjectsForTests +from installed_clients.KBaseReportClient import KBaseReport +from installed_clients.WorkspaceClient import Workspace +from NarrativeService.NarrativeServiceImpl import NarrativeService + + +@pytest.fixture(scope="module") +def fake_reads(workspace: list[any], fake_obj_for_tests_client: FakeObjectsForTests): + """Make a fake reads object and return the UPA for it.""" + reads_name = f"some_fake_reads_{int(time.time()*1000)}" + info = fake_obj_for_tests_client.create_fake_reads({ + "ws_name": workspace[1], + "obj_names": [reads_name] + })[0] + return f"{info[6]}/{info[0]}/{info[4]}" + +@pytest.fixture(scope="module") +def fake_report(workspace: list[any], fake_reads: str) -> tuple[str, str]: + """Makes a dummy report and a fake reads object to link to it. Return both the + report UPA and reads UPA.""" + report_params = { + "message": "dummy report for testing", + "objects_created": [{ + "ref": fake_reads, + "description": "dummy reads lib" + }], + "report_object_name": f"NarrativeServiceTest_report_{int(time.time() * 1000)}", + "workspace_name": workspace[1] + } + kr = KBaseReport(os.environ["SDK_CALLBACK_URL"]) + report_output = kr.create_extended_report(report_params) + return (report_output["ref"], fake_reads) + + +def test_fetch_report_ok( + context: dict[str, any], + service_impl: NarrativeService, + fake_report: str +) -> None: + report_upa, reads_upa = fake_report + ret = service_impl.find_object_report(context, {"upa": reads_upa})[0] + assert "report_upas" in ret + assert len(ret["report_upas"]) == 1 + assert report_upa == ret["report_upas"][0] + assert "object_upa" in ret + assert reads_upa == ret["object_upa"] + assert "copy_inaccessible" not in ret + assert "error" not in ret + + +def test_fetch_report_copy( + context: dict[str, any], + service_impl: NarrativeService, + fake_report: str, + workspace: list[any], + workspace_client: Workspace +) -> None: + report_upa, reads_upa = fake_report + copy_info = workspace_client.copy_object({ + "from": { + "ref": reads_upa + }, + "to": { + "wsid": workspace[0], + "name": "copied_reads" + } + }) + copy_upa = f"{copy_info[6]}/{copy_info[0]}/{copy_info[4]}" + ret = service_impl.find_object_report(context, {"upa": copy_upa})[0] + assert "report_upas" in ret + assert len(ret["report_upas"]) == 1 + assert report_upa == ret["report_upas"][0] + assert "object_upa" in ret + assert reads_upa == ret["object_upa"] + assert "copy_inaccessible" not in ret + assert "error" not in ret + + +def test_fetch_report_copy_inaccessible( + context: dict[str, any], + service_impl: NarrativeService, + workspace: list[any], + workspace_client: Workspace, + fake_reads: str +) -> None: + """Test that the report fetcher fails properly when the object is visible, but the report + is in an inaccessible workspace. + Steps to do here: + 0. Given: existing workspace (see fixtures) + 1. Make a new workspace + 2. Make a new reads object + 3. Make a report attached to the reads + 4. Copy reads to old workspace + 5. Delete the new workspace + 6. Try to access the report from the copied reads. + 7. Cleanup: delete the copied reads + """ + # steps to make a report fetch throw an error + # Look for a report from a reads object, but the report is in an inaccessible workspace + # + # 1. new ws + new_ws = workspace_client.create_workspace({"name": f"new_fake_ws_{int(time.time()*1000)}"}) + # 2. new reads + new_reads_upa = fake_reads + # 3. new report with those reads in the new ws + report_params = { + "message": "dummy report for testing", + "objects_created": [{ + "ref": new_reads_upa, + "description": "dummy reads lib" + }], + "report_object_name": f"NarrativeServiceTest_report_{int(time.time() * 1000)}", + "workspace_name": new_ws[1] + } + kr = KBaseReport(os.environ["SDK_CALLBACK_URL"]) + kr.create_extended_report(report_params) + # 4. copy the reads to the old (fixture'd) workspace + copy_info = workspace_client.copy_object({ + "from": { + "ref": new_reads_upa + }, + "to": { + "wsid": workspace[0], + "name": "reads_copy_report_inaccessible" + } + }) + reads_copy_upa = f"{copy_info[6]}/{copy_info[0]}/{copy_info[4]}" + # 5. delete the new workspace + workspace_client.delete_workspace({"id": new_ws[0]}) + # 6. Try to get the report. + ret = service_impl(context, {"upa": reads_copy_upa})[0] + for key in ["report_upas", "object_upa", "inaccessible", "error"]: + assert key in ret + assert len(ret["report_upas"]) == 0 + assert reads_copy_upa == ret["object_upa"] + + +def test_fetch_report_none( + context: dict[str, any], + service_impl: NarrativeService, + fake_report: str +) -> None: + upa = fake_report[0] # just use the report as an upa. it doesn't have a report, right? + ret = service_impl.find_object_report(context, {"upa": upa})[0] + assert "report_upas" in ret + assert len(ret["report_upas"]) == 0 + assert "copy_inaccessible" not in ret + assert "error" not in ret diff --git a/test/unit/__init__.py b/test/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/test_reportfetcher.py b/test/unit/test_reportfetcher.py new file mode 100644 index 0000000..8510b87 --- /dev/null +++ b/test/unit/test_reportfetcher.py @@ -0,0 +1,64 @@ +from NarrativeService.reportfetcher import ReportFetcher + +REPORT_OBJ_INFO = [ + 2, + "some_report", + "KBaseReport.Report-1.0", + "2024-05-30T15:22:20+0000", + 1, + "some-user", + 123, + "some_workspace", + 123, + 123, + None +] + +def test_fetch_report_ok(mocker): + mock_ws = mocker.MagicMock() + mock_ws.list_referencing_objects.return_value = [[REPORT_OBJ_INFO]] + rf = ReportFetcher(mock_ws) + result = rf.find_report_from_object("123/1/1") + assert result == { + "report_upas": ["123/2/1"], + "object_upa": "123/1/1" + } + +def test_fetch_report_from_copy(mocker): + def list_ref_objects_effect(ref_list): + if ref_list[0]["ref"] == "123/5/1": + return [[REPORT_OBJ_INFO]] + return [[[]]] + + mock_ws = mocker.MagicMock() + mock_ws.list_referencing_objects.side_effect = list_ref_objects_effect + mock_ws.get_objects2.return_value = { "data": [{"copied": "123/5/1"}] } + + rf = ReportFetcher(mock_ws) + result = rf.find_report_from_object("123/1/1") + assert result == { + "report_upas": ["123/2/1"], + "object_upa": "123/5/1" + } + +def test_fetch_report_none(mocker): + mock_ws = mocker.MagicMock() + mock_ws.list_referencing_objects.return_value = [[[]]] + mock_ws.get_objects2.return_value = { "data": [{}] } + rf = ReportFetcher(mock_ws) + assert rf.find_report_from_object("123/1/1") == { + "report_upas": [], + "object_upa": "123/1/1" + } + +def test_fetch_report_copy_inaccessible(mocker): + mock_ws = mocker.MagicMock() + mock_ws.list_referencing_objects.return_value = [[[]]] + mock_ws.get_objects2.return_value = { "data": [{"copy_source_inaccessible": 1}] } + rf = ReportFetcher(mock_ws) + assert rf.find_report_from_object("123/1/1") == { + "report_upas": [], + "object_upa": "123/1/1", + "inaccessible": 1, + "error": "No report found. This object is a copy, and its source is inaccessible." + } diff --git a/test/unit/test_sharemanager.py b/test/unit/test_sharemanager.py new file mode 100644 index 0000000..dc0e155 --- /dev/null +++ b/test/unit/test_sharemanager.py @@ -0,0 +1,84 @@ +from unittest import mock +from unittest.mock import MagicMock + +import pytest +from installed_clients.baseclient import ServerError +from NarrativeService.sharing.sharemanager import ShareRequester + +NARRATIVE_TYPE = "KBaseNarrative.Narrative-4.0" +FAKE_ADMINS = ["some_user"] + + +def test_valid_params(config: dict[str, str]): + params = { + "user": "foo", + "ws_id": 123, + "share_level": "a" + } + requester = ShareRequester(params, config) + assert isinstance(requester, ShareRequester) + assert requester.ws_id == params["ws_id"] + + +invalid_combos = [ + ({"user": "foo", "share_level": "a"}, "ws_id"), + ({"ws_id": 123, "share_level": "a"}, "user"), + ({"user": "foo", "ws_id": 123}, "share_level"), +] + + +@pytest.mark.parametrize("params,missing", invalid_combos) +def test_invalid_params(params: dict[str, str | int], missing: str, config: dict[str, str]): + with pytest.raises(ValueError, match=f'Missing required parameter "{missing}"'): + ShareRequester(params, config) + + +def test_invalid_share_level(config: dict[str, str]): + params = { + "user": "foo", + "share_level": "lol", + "ws_id": 123 + } + with pytest.raises(ValueError, match=f"Invalid share level: {params['share_level']}. Should be one of a, n, r."): + ShareRequester(params, config) + + +@mock.patch("NarrativeService.sharing.sharemanager.feeds") +@mock.patch("NarrativeService.sharing.sharemanager.ws.get_ws_admins", return_value=FAKE_ADMINS) +def test_make_notification_ok(mock_ws, mock_post, config: dict[str, str]): # noqa: ARG001 + config["service-token"] = "fake-service-token" + config["ws-admin-token"] = "fake-admin-token" + req = ShareRequester({"user": "kbasetest", "ws_id": 123, "share_level": "r"}, config) + res = req.request_share() + assert "ok" in res + assert res["ok"] == 1 + + +token_allowance = [ + ("service-token", "missing permission to find Narrative owners."), + ("ws-admin-token", "missing authorization to make request.") +] + + +@pytest.mark.parametrize("token_name,expected_error", token_allowance) +def test_make_notification_token_fail(token_name: str, expected_error: str, config: dict[str, str]): + config[token_name] = "fake-token" + req = ShareRequester({"user": "kbasetest", "ws_id": 123, "share_level": "r"}, config) + res = req.request_share() + assert "ok" in res + assert res["ok"] == 0 + assert "error" in res + assert res["error"] == f"Unable to request share - NarrativeService is {expected_error}" + + +@mock.patch("NarrativeService.sharing.sharemanager.ws.get_ws_admins") +def test_make_notification_fail(mock_ws: MagicMock, config: dict[str, str]): + mock_ws.side_effect = ServerError("error", 500, "not working") + config["service-token"] = "fake-token" + config["ws-admin-token"] = "fake-ws-token" + req = ShareRequester({"user": "kbasetest", "ws_id": 123, "share_level": "r"}, config) + res = req.request_share() + assert res == { + "ok": 0, + "error": "Unable to request share - couldn't get Narrative owners!" + }