diff --git a/docker/nwsc_proxy/dev/Dockerfile b/docker/nwsc_proxy/dev/Dockerfile index db0276d..7e9a1e8 100644 --- a/docker/nwsc_proxy/dev/Dockerfile +++ b/docker/nwsc_proxy/dev/Dockerfile @@ -5,7 +5,7 @@ ARG maintainer LABEL maintainer ${maintainer} # Install additional dependencies -#RUN conda config --add channels conda-forge && \ +# RUN conda config --add channels conda-forge && \ # conda install -y flask=2.3.2 WORKDIR /python/nwsc_proxy @@ -14,12 +14,13 @@ WORKDIR /python/nwsc_proxy COPY ./python/nwsc_proxy/*.py /python/nwsc_proxy/ COPY ./python/nwsc_proxy/src/*.py /python/nwsc_proxy/src/ -# (TEMPORARY) Copy canned criteria files. To be removed when integration with NWS Connect API exists +# Copy canned NWSC Profile files to always run in dev environment COPY ./python/nwsc_proxy/src/profiles/*.json /python/profiles/ +COPY ./python/nwsc_proxy/src/vulnerabilities/*.json /python/profiles/ # The volume mapping here is kind of strange for k8s deployment, because if we map an empty volume to /criteria # then the temp copy of json above will get blown away by the volume mapping...just omit it for k8s deployment # for now. -#VOLUME /python/profiles +# VOLUME /python/profiles ENTRYPOINT [ "python3", "/python/nwsc_proxy/ncp_web_service.py", "--base_dir", "/python/profiles"] diff --git a/python/nwsc_proxy/ncp_web_service.py b/python/nwsc_proxy/ncp_web_service.py index 9be050a..3423a41 100644 --- a/python/nwsc_proxy/ncp_web_service.py +++ b/python/nwsc_proxy/ncp_web_service.py @@ -16,7 +16,8 @@ from flask import Flask, Response, current_app, request, jsonify -from src.profile_store import ProfileStore +from src.support_profile_store import SupportProfileStore +from src.vulnerability_store import VulnerabilityStore # constants GSL_KEY = "8209c979-e3de-402e-a1f5-556d650ab889" @@ -48,11 +49,93 @@ def handler(self): ) -class EventsRoute: +class VulnerabilitiesRoute: + """Handle requests to /vulnerabilities endpoint""" + + def __init__(self, base_dir: str): + self._profile_store = VulnerabilityStore(base_dir) + + def documents(self): + """Logic for any HTTP request to /vulnerabilities.""" + # check that this request has proper key to get or add data + if request.headers.get("X-Api-Key") != current_app.config["GSL_KEY"]: + return jsonify({"message": "ERROR: Unauthorized"}), 401 + + if request.method == "POST": + return self._handle_create() + + # otherwise, must be 'GET' operation + + # let request control if `isDeleted: true` profiles are included in response. + # Default to False if param not present (only return profiles where isDeleted: false) + include_is_deleted = request.args.get("isDeleted", default=False, type=bool) + + profiles = self._profile_store.get_all(include_inactive=include_is_deleted) + return jsonify(profiles), 200 + + def document(self, profile_id): + """Logic for HTTP requests to /vulnerabilities/:profile_id""" + + # pylint: disable=duplicate-code + # check that this request has proper key to get or add data + if request.headers.get("X-Api-Key") != current_app.config["GSL_KEY"]: + return jsonify({"message": "ERROR: Unauthorized"}), 401 + # pylint: enable=duplicate-code + + if request.method == "DELETE": + return self._handle_delete(profile_id) + + if request.method == "PATCH": + return self._handle_update(profile_id) + + # otherwise, must be 'GET' operation + if profile := self._profile_store.get(profile_id): + return jsonify(profile), 200 + + return jsonify({"message": f"Profile {profile_id} not found"}), 404 + + def _handle_create(self) -> Response: + """Logic for POST requests to /vulnerabilities. Returns Response with status_code: 201 on + success, 400 otherwise.""" + profile_data: dict = request.json + + saved_profile = self._profile_store.save(profile_data) + if not saved_profile: + return jsonify({"message": "Error creating profile, may be malformed"}), 400 + + return jsonify(saved_profile), 201 + + def _handle_delete(self, profile_id: str) -> Response: + """Logic for DELETE requests to /vulnerabilities/:id. + Returns Response with status_code: 204 on success, 404 otherwise. + """ + is_deleted = self._profile_store.delete(profile_id) + if not is_deleted: + return jsonify({"message": f"Profile {profile_id} not found"}), 404 + + return jsonify({"message": f"Profile {profile_id} deleted"}), 204 + + def _handle_update(self, profile_id: str) -> Response: + if not request.data: + return jsonify({"message": "PUT requires request body"}), 400 + + request_body: dict = request.json + try: + updated_profile = self._profile_store.update(profile_id, request_body) + except FileNotFoundError: + return jsonify({"message": f"Profile {profile_id} not found"}), 404 + + if not updated_profile: + return jsonify({"message": "Internal Server Error"}), 500 + + return jsonify(updated_profile), 200 + + +class EventsRoute: # pylint: disable=duplicate-code """Handle requests to /all-events endpoint""" def __init__(self, base_dir: str): - self.profile_store = ProfileStore(base_dir) + self._sp_store = SupportProfileStore(base_dir) def handler(self): """Logic for requests to /all-events.""" @@ -71,39 +154,19 @@ def handler(self): # otherwise, must be 'GET' operation data_source = request.args.get("dataSource", None, type=str) - profile_status = request.args.get("status", default="existing", type=str) # let request control if `isLive: false` profiles are included in response. # Default to False if param not present (only return profiles where isLive: true) include_inactive = request.args.get("includeInactive", default=False, type=bool) - if profile_status == "existing": - profiles = self.profile_store.get_all(data_source, include_inactive=include_inactive) - - elif profile_status == "new": - profiles = self.profile_store.get_all( - data_source, is_new=True, include_inactive=include_inactive - ) - # update ProfileStore to label all queried events as no longer "new"; - # they've now been returned to IDSS Engine clients at least once - current_app.logger.info("Got all new profiles: %s", profiles) - for profile in profiles: - self.profile_store.mark_as_existing(profile["id"]) - - else: - # status query param should have been 'existing' or 'new' - return ( - jsonify({"profiles": [], "errors": [f"Invalid profile status: {profile_status}"]}), - 400, - ) - + profiles = self._sp_store.get_all(data_source, include_inactive=include_inactive) return jsonify({"profiles": profiles, "errors": []}), 200 def _handle_delete(self) -> Response: """Logic for DELETE requests to /all-events. Returns Response with status_code: 204 on success, 404 otherwise.""" profile_id = request.args.get("id", request.args.get("uuid")) - is_deleted = self.profile_store.delete(profile_id) + is_deleted = self._sp_store.delete(profile_id) if not is_deleted: return jsonify({"message": f"Profile {profile_id} not found"}), 404 return jsonify({"message": f"Profile {profile_id} deleted"}), 204 @@ -114,18 +177,10 @@ def _handle_create(self) -> Response: request_body: dict = request.json profile_data: dict | None = request_body.get("data") - status: str | None = request_body.get("status") - if not profile_data or not status: + if not profile_data: return jsonify({"message": "Missing one of required attributes: [data, status]"}), 400 - if status == "new": - is_new = True - elif status == "existing": - is_new = False - else: - return jsonify({"message": "Status must be one of [new, existing]"}), 400 - - profile_id = self.profile_store.save(profile_data, is_new) + profile_id = self._sp_store.save(profile_data) if not profile_id: return jsonify({"message": f'Profile {profile_data.get("id")} already exists'}), 400 @@ -142,7 +197,7 @@ def _handle_update(self) -> Response: return jsonify({"message": "Missing required query parameter: id"}), 400 try: - updated_profile = self.profile_store.update(profile_id, request_body) + updated_profile = self._sp_store.update(profile_id, request_body) except FileNotFoundError: return jsonify({"message": f"Profile {profile_id} not found"}), 404 @@ -162,14 +217,28 @@ def __init__(self, base_dir: str): health_route = HealthRoute() events_route = EventsRoute(base_dir) + vulnerabilities_route = VulnerabilitiesRoute(base_dir) self.app.add_url_rule("/health", "health", view_func=health_route.handler, methods=["GET"]) + # DEPRECATED: will be removed after NCG NewData Consumer migrates to vulnerabilities format self.app.add_url_rule( "/all-events", "events", view_func=events_route.handler, methods=["GET", "POST", "PUT", "DELETE"], ) + self.app.add_url_rule( + "/vulnerabilities", + "vulnerabilities", + view_func=vulnerabilities_route.documents, + methods=["GET", "POST"], + ) + self.app.add_url_rule( + "/vulnerabilities/", + "vulnerability", + view_func=vulnerabilities_route.document, + methods=["GET", "PATCH", "DELETE"], + ) def run(self, **kwargs): """Start up web server""" diff --git a/python/nwsc_proxy/src/profile_store.py b/python/nwsc_proxy/src/support_profile_store.py similarity index 70% rename from python/nwsc_proxy/src/profile_store.py rename to python/nwsc_proxy/src/support_profile_store.py index 858bdb1..100dd0d 100644 --- a/python/nwsc_proxy/src/profile_store.py +++ b/python/nwsc_proxy/src/support_profile_store.py @@ -1,4 +1,4 @@ -"""Profile store that does CRUD operations on filesystem to simulate NWS Connect storage""" +"""SupportProfile store that does CRUD operations on filesystem to simulate NWS Connect storage""" # ---------------------------------------------------------------------------------- # Created on Tues Dec 17 2024 @@ -9,6 +9,8 @@ # Mackenzie Grimes (1) # # ---------------------------------------------------------------------------------- +# pylint: disable=duplicate-code + import os import json import logging @@ -19,28 +21,23 @@ from dateutil.parser import parse as dt_parse -# constants controlling the subdirectory where new vs. existing Profiles are saved -NEW_SUBDIR = "new" -EXISTING_SUBDIR = "existing" -DEFAULT_DATA_SOURCE = "NBM" - logger = logging.getLogger(__name__) -class CachedProfile: - """Data class to hold Support Profile's data and metadata ("new" vs "existing" status), - as well as some derived properties extracted from the `data` JSON (e.g. - `CachedProfile.is_active` or `CachedProfile.start_timestamp`) that make it easier to query - and filter the Profiles. +class CachedSupportProfile: + """Data class to hold Support Profile's data as well as some derived properties extracted + from the `data` JSON (e.g. `CachedProfile.is_active` or `CachedProfile.start_timestamp`) that + make it easier to query and filter the Profiles. Args: data (dict): full JSON data of this Support Profile - is_new (bool): track if Support Profile has ever been processed. Ought to start as True """ - def __init__(self, data: dict, is_new: bool): + # class-wide constants + DEFAULT_DATA_SOURCE = "NBM" + + def __init__(self, data: dict): self.data = data - self.is_new = is_new @property def id(self) -> str: @@ -84,31 +81,31 @@ def data_sources(self) -> list[str]: try: return [ # treat any profiles with empty string dataSource as default 'NBM' - _map["dataSource"] if _map["dataSource"] != "" else DEFAULT_DATA_SOURCE + _map["dataSource"] if _map["dataSource"] != "" else self.DEFAULT_DATA_SOURCE for phrase in self.data["nonImpactThresholds"]["phrasesForAllSeverities"].values() for _map in phrase["map"].values() ] except KeyError: - return [DEFAULT_DATA_SOURCE] # couldn't lookup dataSources, so just default to NBM + return [self.DEFAULT_DATA_SOURCE] # couldn't lookup dataSources; just default to NBM def __str__(self): return ( - f"{self.__class__.__name__}(id='{self.id}', name='{self.name}', is_new={self.is_new}, " + f"{self.__class__.__name__}(id='{self.id}', name='{self.name}', " f"is_active={self.is_active}, start_timestamp={self.start_timestamp}, " f"end_timestamp={self.end_timestamp}, data_sources={self.data_sources})" ) -class ProfileStore: +class SupportProfileStore: """Data storage using JSON files on filesystem that simulates CRUD operations""" - def __init__(self, base_dir: str): - self._new_dir = os.path.join(base_dir, NEW_SUBDIR) - self._existing_dir = os.path.join(base_dir, EXISTING_SUBDIR) + # constants controlling the subdirectory where existing Profiles are saved + PROFILE_DIR = "supportprofiles" + def __init__(self, base_dir: str): # ensure that base directory and all expected subdirectories exist - for _dir in [self._new_dir, self._existing_dir]: - os.makedirs(_dir, exist_ok=True) + self._profile_dir = os.path.join(base_dir, self.PROFILE_DIR) + os.makedirs(self._profile_dir, exist_ok=True) # load any NWS Connect response files dumped into the base_dir logger.info("Scanning base directory for raw NWS Connect API response files: %s", base_dir) @@ -122,25 +119,19 @@ def __init__(self, base_dir: str): # loop through all profiles in this file, # save them to "existing" directory as individual profiles for profile in data.get("profiles", []): - profile_filepath = os.path.join(self._existing_dir, f'{profile["id"]}.json') + profile_filepath = os.path.join(self._profile_dir, f'{profile["id"]}.json') logger.info("Saving existing profile to file: %s", profile_filepath) with open(profile_filepath, "w", encoding="utf-8") as outfile: json.dump(profile, outfile) - # populate cache of JSON data of all Support Profiles, marked as new vs. existing - existing_profiles = { - profile["id"]: CachedProfile(profile, is_new=False) - for profile in self._load_profiles_from_filesystem(self._existing_dir) - } - new_profiles = { - profile["id"]: CachedProfile(profile, is_new=True) - for profile in self._load_profiles_from_filesystem(self._new_dir) + # populate cache of JSON data of all Support Profiles + self.profile_cache: dict[str, CachedSupportProfile] = { + profile["id"]: CachedSupportProfile(profile) + for profile in self._load_profiles_from_filesystem(self._profile_dir) } - self.profile_cache: dict[str, CachedProfile] = {**existing_profiles, **new_profiles} - - def get_all(self, data_source="ANY", is_new=False, include_inactive=False) -> list[dict]: + def get_all(self, data_source="ANY", include_inactive=False) -> list[dict]: """Get all Support Profile JSONs persisted in this API, filtering by status='new' (if Support Profile has never been returned in an API request before) or status='existing' otherwise. @@ -154,10 +145,8 @@ def get_all(self, data_source="ANY", is_new=False, include_inactive=False) -> li cached_profile for cached_profile in self.profile_cache.values() if ( - # is new, if client requested new profiles, or is existing - cached_profile.is_new == is_new # is "active", meaning no one has intentional disabled/deactivated it - and (include_inactive or cached_profile.is_active) + (include_inactive or cached_profile.is_active) # the end_dt has not yet passed (or profile is never-ending) and datetime.now(UTC).timestamp() <= cached_profile.end_timestamp ) @@ -169,7 +158,7 @@ def get_all(self, data_source="ANY", is_new=False, include_inactive=False) -> li profile.data for profile in profiles_by_status if data_source in profile.data_sources ] - def save(self, profile: dict, is_new=True) -> str | None: + def save(self, profile: dict) -> str | None: """Persist a new Support Profile Profile to this API Args: @@ -188,7 +177,7 @@ def save(self, profile: dict, is_new=True) -> str | None: logger.warning("Cannot save profile; already exists %s", existing_profile.id) return None - cached_profile = CachedProfile(profile, is_new=is_new) + cached_profile = CachedSupportProfile(profile) filepath = self._save_profile_to_filesystem(cached_profile) # add profile to in-memory cache @@ -196,38 +185,6 @@ def save(self, profile: dict, is_new=True) -> str | None: logger.info("Saved profile to cache, file location: %s", filepath) return cached_profile.id - def mark_as_existing(self, profile_id: str) -> bool: - """Mark a formerly "new" Support Profile as "existing", a.k.a. has been returned in - API response at least once and should no longer be processed as "new" - - Returns: - bool: True on success. False if JSON with this profile_id not found on filesystem - """ - # find the profile data from the new_profiles cache and move it to existing_profiles - cached_profile = self.profile_cache.get(profile_id) - if not cached_profile: - # profile is not in cache; it must not exist - logger.warning( - "Support Profile %s expected in profile_cache but not found", profile_id - ) - return False - - new_filepath = os.path.join(self._new_dir, f"{profile_id}.json") - if not os.path.exists(new_filepath): - logger.warning( - 'Attempt to mark as "existing" profile that is not found: %s', new_filepath - ) - return False - - # move the JSON file from the "new" to the "existing" directory and update cache - existing_filepath = os.path.join(self._existing_dir, f"{profile_id}.json") - os.rename(new_filepath, existing_filepath) - - cached_profile.is_new = False - self.profile_cache[profile_id] = cached_profile - - return True - def update(self, profile_id: str, data: dict) -> dict: """Update a Support Profile in storage based on its id. @@ -257,7 +214,7 @@ def update(self, profile_id: str, data: dict) -> dict: ) ) - updated_profile = CachedProfile(data, cached_profile.is_new) + updated_profile = CachedSupportProfile(data) # update disk with latest data; if the write fails, reject update saved_file = self._save_profile_to_filesystem(updated_profile) if not saved_file: @@ -276,19 +233,14 @@ def delete(self, profile_id: str) -> bool: """ logger.info("Deleting profile_id %s", profile_id) - filepath = os.path.join(self._existing_dir, f"{profile_id}.json") + filepath = os.path.join(self._profile_dir, f"{profile_id}.json") if not os.path.exists(filepath): - # profile does not in exist in "existing" subdirectory, maybe its in "new" - filepath = os.path.join(self._new_dir, f"{profile_id}.json") - - if not os.path.exists(filepath): - logger.warning( - "Cannot delete profile %s; JSON file not found in %s or %s", - profile_id, - self._existing_dir, - self._new_dir, - ) - return False + logger.warning( + "Cannot delete profile %s; JSON file not found in %s", + profile_id, + self._profile_dir, + ) + return False # drop profile from disk logger.debug("Attempting to delete profile at path: %s", filepath) @@ -297,14 +249,14 @@ def delete(self, profile_id: str) -> bool: del self.profile_cache[profile_id] return True - def _save_profile_to_filesystem(self, profile: CachedProfile) -> str | None: + def _save_profile_to_filesystem(self, profile: CachedSupportProfile) -> str | None: """Save CachedProfile data (dict) to filesystem so it persists through service restarts""" profile_id = profile.data.get("id") if not profile_id: raise ValueError("Cannot save CachedProfile to file that has no `id` attribute") - file_dir = self._new_dir if profile.is_new else self._existing_dir - filepath = os.path.join(file_dir, f"{profile_id}.json") + # file_dir = self._new_dir if profile.is_new else self._existing_dir + filepath = os.path.join(self._profile_dir, f"{profile_id}.json") logger.debug("Now saving profile to path: %s", filepath) try: with open(filepath, "w", encoding="utf-8") as file: @@ -315,7 +267,7 @@ def _save_profile_to_filesystem(self, profile: CachedProfile) -> str | None: profile_id, filepath, type(exc), - str(exc), + exc, ) return None @@ -335,8 +287,12 @@ def _load_profiles_from_filesystem(dir_: str) -> list[dict]: for filename in glob("*.json", root_dir=dir_): with open(os.path.join(dir_, filename), "r", encoding="utf-8") as file: json_data: dict = json.load(file) + + # this is a pure NWS Connect profiles[] response + if isinstance(json_data, list): + profile_list.extend(json_data) # if this is a pure NWS Connect response, profile data is nested inside `profiles` - if profiles := json_data.get("profiles", None) and isinstance(profiles, list): + elif profiles := json_data.get("profiles", None) and isinstance(profiles, list): profile_list.extend(profiles) else: # this file is assumed to be just a Support Profile diff --git a/python/nwsc_proxy/src/profiles/nwsc_test_response_1.json b/python/nwsc_proxy/src/supportprofiles/nwsc_test_response_1.json similarity index 100% rename from python/nwsc_proxy/src/profiles/nwsc_test_response_1.json rename to python/nwsc_proxy/src/supportprofiles/nwsc_test_response_1.json diff --git a/python/nwsc_proxy/src/profiles/nwsc_test_response_2.json b/python/nwsc_proxy/src/supportprofiles/nwsc_test_response_2.json similarity index 100% rename from python/nwsc_proxy/src/profiles/nwsc_test_response_2.json rename to python/nwsc_proxy/src/supportprofiles/nwsc_test_response_2.json diff --git a/python/nwsc_proxy/src/profiles/nwsc_test_response_3.json b/python/nwsc_proxy/src/supportprofiles/nwsc_test_response_3.json similarity index 100% rename from python/nwsc_proxy/src/profiles/nwsc_test_response_3.json rename to python/nwsc_proxy/src/supportprofiles/nwsc_test_response_3.json diff --git a/python/nwsc_proxy/src/vulnerabilities/nwsc_gsl_test_profiles.json b/python/nwsc_proxy/src/vulnerabilities/nwsc_gsl_test_profiles.json new file mode 100644 index 0000000..ca1e4f3 --- /dev/null +++ b/python/nwsc_proxy/src/vulnerabilities/nwsc_gsl_test_profiles.json @@ -0,0 +1,310 @@ +[ + { + "id": "fd35adec-d2a0-49a9-a320-df20a7b6d681", + "name": "GSL Test 1", + "description": "", + "primaryOfficeId": "GSL", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [-113.9585, 46.8571], + [-113.95879289321881, 46.85639289321882], + [-113.9595, 46.856100000000005], + [-113.9602071067812, 46.85639289321882], + [-113.96050000000001, 46.8571], + [-113.9602071067812, 46.85780710678119], + [-113.9595, 46.8581], + [-113.95879289321881, 46.85780710678119], + [-113.9585, 46.8571] + ] + ] + ] + }, + "activeTime": { + "startDate": null, + "endDate": null, + "recurrenceRule": "" + }, + "support": { + "briefings": [], + "summary": "", + "recipients": { + "partners": [], + "externals": [] + }, + "notes": "" + }, + "notes": "", + "hazards": [ + { + "id": "199d81e9-56d1-48b6-ade4-deb49b8c3dea", + "type": null, + "impactLevels": [ + { + "thresholdSet": [ + { + "weatherElement": "COLD", + "magnitude": 32, + "operator": "LESS_THAN", + "units": "DEG_F", + "minDurationHours": 0, + "source": "NBM" + } + ], + "impactLevelValue": 1, + "impactStatements": [] + } + ] + } + ], + "scheduledEventData": { + "url": "", + "dailyAttendance": 1, + "venueType": "OUTDOOR", + "venueAddress": { + "line1": "", + "line2": "", + "line3": "", + "city": "", + "state": "", + "zipCode": 0, + "countryCode": "US", + "geometry": null, + "elevation": 4124 + }, + "isNsseEvent": false, + "isSearEvent": false, + "searEventLevel": null, + "evacuationTimeMinutes": null, + "timezone": null, + "originalRequestId": "fd35adec-d2a0-49a9-a320-df20a7b6d681" + } + }, + { + "id": "a08370c6-ab87-4808-bd51-a8597e58410d", + "name": "GSL Test 2", + "description": "", + "primaryOfficeId": "GSL", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [-112.3037, 46.6939], + [-112.116, 46.8181], + [-111.7694, 46.8181], + [-111.6377, 46.6686], + [-111.6768, 46.4775], + [-111.9862, 46.3613], + [-112.2837, 46.5134], + [-112.3037, 46.6939] + ] + ] + ] + }, + "activeTime": { + "startDate": null, + "endDate": null, + "recurrenceRule": "" + }, + "support": { + "briefings": [], + "summary": "", + "recipients": { + "partners": [], + "externals": [] + }, + "notes": "" + }, + "notes": "", + "hazards": [ + { + "id": "1219ef7b-0da1-4f15-84b0-fa4b6d101324", + "type": null, + "impactLevels": [ + { + "thresholdSet": [ + { + "weatherElement": "COLD", + "magnitude": 40, + "operator": "LESS_THAN_OR_EQUAL_TO", + "units": "DEG_F", + "minDurationHours": 0, + "source": "NBM" + }, + { + "weatherElement": "WINDSPD", + "magnitude": 10, + "operator": "GREATER_THAN", + "units": "MPH", + "minDurationHours": 0, + "source": "NBM" + } + ], + "impactLevelValue": 2, + "impactStatements": [] + } + ] + } + ], + "scheduledEventData": { + "url": "", + "dailyAttendance": 2, + "venueType": "OUTDOOR", + "venueAddress": { + "line1": "", + "line2": "", + "line3": "", + "city": "", + "state": "", + "zipCode": 0, + "countryCode": "US", + "geometry": null, + "elevation": 3768 + }, + "isNsseEvent": false, + "isSearEvent": false, + "searEventLevel": null, + "evacuationTimeMinutes": null, + "timezone": null, + "originalRequestId": "a08370c6-ab87-4808-bd51-a8597e58410d" + } + }, + { + "id": "e1033860-f198-4c6a-a91b-beaec905132f", + "name": "GSL Test 3", + "description": "", + "primaryOfficeId": "GSL", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [-109.76739360358141, 41.55798987729523], + [-108.0818592038758, 41.662998250363394], + [-106.8990457012592, 41.72961027410284], + [-105.2463416104927, 41.108708737249856], + [-104.086247863721, 41.1642988538753], + [-104.08552754841305, 41.16404014111033], + [-104.0852011461247, 41.16334786372099], + [-104.08545985888968, 41.16262754841304], + [-104.08615213627901, 41.1623011461247], + [-105.24665838950729, 41.10669126275015], + [-106.89935429874079, 41.72758972589716], + [-108.08174079612421, 41.661001749636604], + [-109.7670063964186, 41.55601012270478], + [-111.01988157154878, 41.13515205310198], + [-111.02064513676261, 41.13520453740303], + [-111.02114794689801, 41.13578157154878], + [-111.02109546259697, 41.136545136762614], + [-111.02051842845123, 41.137047946898015], + [-109.76739360358141, 41.55798987729523] + ] + ] + ] + }, + "activeTime": { + "startDate": null, + "endDate": null, + "recurrenceRule": "" + }, + "support": { + "briefings": [], + "summary": "", + "recipients": { + "partners": [], + "externals": [] + }, + "notes": "" + }, + "notes": "", + "hazards": [ + { + "id": "2b009516-fce4-4c99-b784-f988f8e63740", + "type": null, + "impactLevels": [ + { + "thresholdSet": [ + { + "weatherElement": "WINDGST", + "magnitude": 10, + "operator": "GREATER_THAN_OR_EQUAL_TO", + "units": "MPH", + "minDurationHours": 0, + "source": "NBM" + } + ], + "impactLevelValue": 1, + "impactStatements": [] + }, + { + "thresholdSet": [ + { + "weatherElement": "WINDGST", + "magnitude": 30, + "operator": "GREATER_THAN_OR_EQUAL_TO", + "units": "MPH", + "minDurationHours": 0, + "source": "NBM" + } + ], + "impactLevelValue": 2, + "impactStatements": [] + }, + { + "thresholdSet": [ + { + "weatherElement": "WINDGST", + "magnitude": 50, + "operator": "GREATER_THAN_OR_EQUAL_TO", + "units": "MPH", + "minDurationHours": 0, + "source": "NBM" + } + ], + "impactLevelValue": 3, + "impactStatements": [] + }, + { + "thresholdSet": [ + { + "weatherElement": "WINDGST", + "magnitude": 70, + "operator": "GREATER_THAN_OR_EQUAL_TO", + "units": "MPH", + "minDurationHours": 0, + "source": "NBM" + } + ], + "impactLevelValue": 4, + "impactStatements": [] + } + ] + } + ], + "scheduledEventData": { + "url": "", + "dailyAttendance": 3, + "venueType": "OUTDOOR", + "venueAddress": { + "line1": "", + "line2": "", + "line3": "", + "city": "", + "state": "", + "zipCode": 0, + "countryCode": "US", + "geometry": null, + "elevation": 5393 + }, + "isNsseEvent": false, + "isSearEvent": false, + "searEventLevel": null, + "evacuationTimeMinutes": null, + "timezone": null, + "originalRequestId": "e1033860-f198-4c6a-a91b-beaec905132f" + } + } +] diff --git a/python/nwsc_proxy/src/vulnerability_store.py b/python/nwsc_proxy/src/vulnerability_store.py new file mode 100644 index 0000000..cbde7e8 --- /dev/null +++ b/python/nwsc_proxy/src/vulnerability_store.py @@ -0,0 +1,340 @@ +"""Profile store that does CRUD operations on filesystem to simulate NWS Connect storage""" + +# ---------------------------------------------------------------------------------- +# Created on Fri Jul 10 2026 +# +# Copyright (c) 2026 Colorado State University. All rights reserved. (1) +# +# Contributors: +# Mackenzie Grimes (1) +# +# ---------------------------------------------------------------------------------- + +import os +import json +import logging +from uuid import uuid4 + +from datetime import datetime, UTC +from glob import glob +from math import inf + +from dateutil.parser import parse as dt_parse + +from src.utils import deep_update + +logger = logging.getLogger(__name__) + + +class CachedProfile: + """Data class to hold Profile's data, plus some derived properties extracted from the `data` + JSON (e.g. `CachedProfile.is_deleted` or `CachedProfile.start_timestamp`) that make it + easier to query and filter multiple Profiles. + + Args: + data (dict): full JSON data of this Profile + """ + + # properties at root of Profile object that are minimum required to assume acceptably formatted + REQUIRED_PROPERTIES = ["id", "name", "primaryOfficeId", "hazards", "activeTime"] + + DEFAULT_DATA_SOURCE = "NBM" + + def __init__(self, data: dict): + """ + Raises: + ValueError: if data is not dictionary with minimum expected properties to be Profile + """ + for prop in self.REQUIRED_PROPERTIES: + if not data.get(prop): + raise ValueError(f"JSON not valid Profile format; missing property {prop}") + + self.data = data + + @property + def id(self) -> str: + """The Profile UUID""" + # pylint: disable=invalid-name + return self.data["id"] + + @property + def name(self) -> str: + """The Profile name""" + return self.data["name"] + + @property + def office(self) -> str: + """The Profile's NWS 'office" tag""" + return self.data["primaryOfficeId"] + + @property + def is_deleted(self) -> bool: + """The Profile's active state (can be marked as deleted to halt processing)""" + return self.data.get("isDeleted", False) + + @property + def start_timestamp(self) -> float: + """The Profile event's start in Unix time (milliseconds since the epoch). + math.inf if Profile is never-ending + """ + profile_start: str | None = self.data["activeTime"]["startDate"] + return dt_parse(profile_start).timestamp() if profile_start else inf + + @property + def end_timestamp(self) -> float: + """The Profile event's end in Unix time (milliseconds since the epoch). + math.inf if Profile is never-ending + """ + if self.start_timestamp == inf: + return inf # infinite start time, so infinite end time as well + profile_end: str | None = self.data["activeTime"]["endDate"] + return dt_parse(profile_end).timestamp() if profile_end else inf + + @property + def data_sources(self) -> set[str]: + """The weather products used by any parts of this Profile (e.g. NBM, HRRR, MRMS)""" + try: + return set( + # treat any profiles with empty string dataSource as default 'NBM' + threshold["source"] if threshold["source"] != "" else self.DEFAULT_DATA_SOURCE + for hazard in self.data["hazards"] + for impact_level in hazard["impactLevels"] + for threshold in impact_level["thresholdSet"] + ) + except KeyError: + return set([self.DEFAULT_DATA_SOURCE]) # couldn't lookup dataSources; default to NBM + + def __str__(self): + return ( + f"{self.__class__.__name__}(id='{self.id}', name='{self.name}', " + f"is_deleted={self.is_deleted}, start_timestamp={self.start_timestamp}, " + f"end_timestamp={self.end_timestamp}, data_sources={list(self.data_sources)})" + ) + + +class VulnerabilityStore: + """Data storage using JSON files on filesystem that simulates CRUD operations of + NWS Connect Vulnerabilities API + """ + + # constants controlling the subdirectory where existing Profiles are saved + PROFILE_DIR = "profiles" + + def __init__(self, base_dir: str): + # ensure that base directory and all expected subdirectories exist + self._profile_dir = os.path.join(base_dir, self.PROFILE_DIR) + os.makedirs(self._profile_dir, exist_ok=True) + + # load any NWS Connect response files dumped into the base_dir + logger.info("Scanning base directory for raw NWS Connect API response files: %s", base_dir) + for filename in glob("*.json", root_dir=base_dir): + abs_path = os.path.join(base_dir, filename) + logger.info("Loading profiles from raw API response file: %s", abs_path) + + with open(abs_path, "r", encoding="utf-8") as infile: + data: dict = json.load(infile) + + if isinstance(data, dict): + profiles = data.get("profiles", []) + elif isinstance(data, list): + profiles = data + else: + raise RuntimeError( + f"Expected list, or object with `profiles` property, in JSON: {abs_path}" + ) + + # loop through all profiles in this file, + # save them to subdirectory as individual profiles + for profile_data in profiles: + try: + _ = CachedProfile(profile_data) + except ValueError: + logger.warning( + "Rejecting profile in file %s: not expected Profile format. ID: %s", + abs_path, + profile_data.get("id"), + ) + continue + + profile_filepath = os.path.join( + self._profile_dir, f'{profile_data["id"]}.json' + ) + logger.info("Saving existing profile to file: %s", profile_filepath) + with open(profile_filepath, "w", encoding="utf-8") as outfile: + json.dump(profile_data, outfile) + + # populate cache of JSON data of all Profiles + self._cache: dict[str, CachedProfile] = { + profile["id"]: CachedProfile(profile) + for profile in self._load_profiles_from_filesystem(self._profile_dir) + } + + def get_all(self, data_source="ANY", include_inactive=False) -> list[dict]: + """Get all Profile JSONs persisted in this API. + + Args: + data_source (optional, str): `data_source` of Profiles to filter on. Defaults to "ANY" + (return all discovered Profiles). + include_inactive (optional, bool): if True, return all Profiles, even those marked as + `isDeleted: False`. Defaults to False (hide deleted profiles). + """ + # compare all Profiles to the same now() value + current_timestamp = datetime.now(UTC).timestamp() + profiles_by_status = [ + cached_profile + for cached_profile in self._cache.values() + # is "active", meaning no one has intentional disabled/deactivated it + if (include_inactive or not cached_profile.is_deleted) + # the end_dt has not yet passed (or profile is never-ending) + and current_timestamp <= cached_profile.end_timestamp + ] + + if data_source == "ANY": + # all data sources requested, so do not filter by products used + return [profile.data for profile in profiles_by_status] + + return [ + profile.data for profile in profiles_by_status if data_source in profile.data_sources + ] + + def get(self, profile_id: str) -> dict | None: + """Get a single Profile JSON persisted in this API. + + Args: + profile_id (str): the Profile UUID. + + Returns: + dict | None: The Profile JSON data, or None if `profile_id` does not exist. + """ + cached_profile = self._cache.get(profile_id, None) + return cached_profile.data if cached_profile else None + + def save(self, profile_data: dict) -> dict | None: + """Persist a new Profile to this API + + Args: + profile (dict): the JSON data of the Profile to store. + + Returns: + dict | None: content of saved Profile on success, otherwise None + """ + logger.debug("Now saving new profile: %s", profile_data) + # generate a UUID, ignoring whaterver client provided + profile_id = str(uuid4()) + + # overwrite any `isDeleted` attribute from client (shouldn't set this on creation) + profile_data = {**profile_data, "id": profile_id, "isDeleted": False} + + try: + cached_profile = CachedProfile(profile_data) + filepath = self._save_profile_to_filesystem(cached_profile) + except Exception as exc: # pylint: disable=broad-exception-caught + logger.error("Unable to save Vulnerability due to error: (%s) %s", type(exc), exc) + return None + + # add profile to in-memory cache + self._cache[cached_profile.id] = cached_profile + logger.info("Saved profile to cache, file location: %s", filepath) + return cached_profile.data + + def update(self, profile_id: str, data: dict) -> dict: + """Update a Profile in storage based on its ID. + + Args: + profile_id (str): The UUID of the Profile to update + data (dict): The JSON attributes to apply (partial update). + + Returns: + dict: the latest version of the Profile, with the profile attributes overwritten by + `data` arg, or `None` on error (existing profile was unchanged) + + Raises: + FileNotFoundError: if no Profile exists with the provided ID + """ + logger.info("Updating profile_id %s with new data: %s", profile_id, data) + + # find the profile data from the new_profiles cache, then save over it + cached_profile = self._cache.get(profile_id) + if not cached_profile: + raise FileNotFoundError # Profile with this ID does not exist in cache + + profile_data_post_update = deep_update(cached_profile.data, data) + updated_profile = CachedProfile(profile_data_post_update) + # update disk with latest data; if the write fails, reject update + saved_file = self._save_profile_to_filesystem(updated_profile) + if not saved_file: + logger.warning("Unable to update Profile ID %s for some reason", profile_id) + return None + + # update in-memory cache to overwrite previous profile by ID + self._cache[profile_id] = updated_profile + return updated_profile.data + + def delete(self, profile_id: str) -> bool: + """Delete a Profile from storage, based on its UUID. + + Returns: + bool: True on success + """ + logger.info("Deleting profile_id %s", profile_id) + + filepath = os.path.join(self._profile_dir, f"{profile_id}.json") + if not os.path.exists(filepath): + logger.warning( + "Cannot delete profile %s; file not found in %s", profile_id, self._profile_dir + ) + return False + + # drop profile from disk + logger.debug("Attempting to delete profile at path: %s", filepath) + os.remove(filepath) + # drop profile from cache + del self._cache[profile_id] + return True + + def _save_profile_to_filesystem(self, profile: CachedProfile) -> str | None: + """Save CachedProfile data (dict) to filesystem so it persists through service restarts""" + profile_id = profile.data.get("id") + if not profile_id: + raise ValueError("Cannot save CachedProfile to file that has no `id` attribute") + + filepath = os.path.join(self._profile_dir, f"{profile_id}.json") + logger.debug("Now saving profile to path: %s", filepath) + try: + with open(filepath, "w", encoding="utf-8") as file: + json.dump(profile.data, file) + except (PermissionError, json.JSONDecodeError, TypeError) as exc: + logger.error( + "Failed to save Profile %s to file %s: (%s) %s", + profile_id, + filepath, + type(exc), + exc, + ) + return None + + return filepath + + @staticmethod + def _load_profiles_from_filesystem(dir_: str) -> list[dict]: + """Read all JSON files from one of this ProfileStore's subdirectories, and return list of + the discovered files' json data. + + Args: + dir_ (str): path to scan for Profile or NWS Connect API response JSON files + """ + logger.info("Loading Profiles JSON files from path: %s", dir_) + + profile_list: list[dict] = [] + for filename in glob("*.json", root_dir=dir_): + with open(os.path.join(dir_, filename), "r", encoding="utf-8") as file: + json_data: dict = json.load(file) + + # this is a pure NWS Connect profiles[] response + if isinstance(json_data, list): + profile_list.extend(json_data) + else: + # this file is assumed to be just a single Profile + profile_list.append(json_data) + + return profile_list diff --git a/python/nwsc_proxy/test/test_ncp_web_service.py b/python/nwsc_proxy/test/test_ncp_web_service.py index 736af1b..9917356 100644 --- a/python/nwsc_proxy/test/test_ncp_web_service.py +++ b/python/nwsc_proxy/test/test_ncp_web_service.py @@ -22,7 +22,8 @@ AppWrapper, Flask, Namespace, - ProfileStore, + SupportProfileStore, + VulnerabilityStore, create_app, datetime, GSL_KEY, @@ -44,9 +45,17 @@ def mock_datetime(monkeypatch: MonkeyPatch) -> Mock: @fixture -def mock_profile_store(monkeypatch: MonkeyPatch) -> Mock: - mock_obj = Mock(name="MockProfileStore", spec=ProfileStore) - monkeypatch.setattr("python.nwsc_proxy.ncp_web_service.ProfileStore", mock_obj) +def mock_sp_store(monkeypatch: MonkeyPatch) -> Mock: + # class for local ProfileStore with legacy object format + mock_obj = Mock(name="MockSupportProfileStore", spec=SupportProfileStore) + monkeypatch.setattr("python.nwsc_proxy.ncp_web_service.SupportProfileStore", mock_obj) + return mock_obj + + +@fixture +def mock_store(monkeypatch: MonkeyPatch) -> Mock: + mock_obj = Mock(name="MockProfileStore", spec=VulnerabilityStore) + monkeypatch.setattr("python.nwsc_proxy.ncp_web_service.VulnerabilityStore", mock_obj) return mock_obj @@ -82,19 +91,20 @@ def mock_request(monkeypatch: MonkeyPatch, mock_current_app, mock_jsonify) -> Mo @fixture -def wrapper(mock_profile_store, mock_datetime, mock_request) -> AppWrapper: +def wrapper(mock_sp_store, mock_store, mock_datetime, mock_request) -> AppWrapper: return AppWrapper("/fake/base/dir") -def test_create_app(mock_profile_store: Mock): +def test_create_app(mock_store, mock_sp_store): args = Namespace() args.base_dir = "/fake/base/dir" + expected_endpoints = ["events", "health", "vulnerabilities", "vulnerability"] _app = create_app(args) assert isinstance(_app, Flask) endpoint_dict = _app.view_functions - assert sorted(list(endpoint_dict.keys())) == ["events", "health"] + assert sorted(list(endpoint_dict.keys())) == expected_endpoints def test_health_route(wrapper: AppWrapper, mock_datetime: Mock): @@ -116,20 +126,10 @@ def test_events_bad_key(wrapper: AppWrapper, mock_request: Mock): assert result[1] == 401 -def test_get_bad_status(wrapper: AppWrapper, mock_request: Mock): - mock_request.args = MultiDict({"dataSource": "NBM", "status": "NOT REAL STATUS"}) - - result: tuple[Response, int] = wrapper.app.view_functions["events"]() - - response, status_code = result - assert status_code == 400 - assert response.json == {"profiles": [], "errors": ["Invalid profile status: NOT REAL STATUS"]} - - -def test_get_existing_profiles(wrapper: AppWrapper, mock_request: Mock, mock_profile_store: Mock): - mock_request.args = MultiDict({"dataSource": "NBM", "status": "existing"}) +def test_get_existing_profiles(wrapper: AppWrapper, mock_request: Mock, mock_sp_store: Mock): + mock_request.args = MultiDict({"dataSource": "NBM"}) example_profile_list = [{"id": EXAMPLE_UUID, "name": "My Profile"}] - mock_profile_store.return_value.get_all.return_value = example_profile_list + mock_sp_store.return_value.get_all.return_value = example_profile_list result: tuple[Response, int] = wrapper.app.view_functions["events"]() @@ -137,107 +137,60 @@ def test_get_existing_profiles(wrapper: AppWrapper, mock_request: Mock, mock_pro assert status_code == 200 assert response.json == {"profiles": example_profile_list, "errors": []} # filter_new_profiles not set - mock_profile_store.return_value.get_all.assert_called_with("NBM", include_inactive=False) - - -def test_get_new_profiles(wrapper: AppWrapper, mock_request: Mock, mock_profile_store: Mock): - mock_request.args = MultiDict({"dataSource": "NBM", "status": "new"}) - example_profile = {"id": EXAMPLE_UUID, "name": "My Profile"} - mock_profile_store.return_value.get_all.return_value = [example_profile] - - result: tuple[Response, int] = wrapper.app.view_functions["events"]() - - response, status_code = result - assert status_code == 200 - assert response.json == {"profiles": [example_profile], "errors": []} - - get_call_args = mock_profile_store.return_value.get_all.mock_calls - # called with is_new set to True - assert get_call_args[0][1:] == (("NBM",), {"is_new": True, "include_inactive": False}) + mock_sp_store.return_value.get_all.assert_called_with("NBM", include_inactive=False) - # expect that we told ProfileStore to label this profile as not new - mark_existing_call_args = mock_profile_store.return_value.mark_as_existing.mock_calls - assert mark_existing_call_args[0][1][0] == example_profile["id"] - -def test_create_profile_new(wrapper: AppWrapper, mock_request: Mock, mock_profile_store: Mock): - mock_request.method = "POST" - example_profile = {"id": EXAMPLE_UUID, "name": "My Profile"} - mock_request.json = {"status": "new", "data": example_profile} - mock_profile_store.return_value.save.return_value = EXAMPLE_UUID # save() success - - result: tuple[Response, int] = wrapper.app.view_functions["events"]() - - assert result[1] == 201 - # should have saved profile with is_new: True - mock_profile_store.return_value.save.assert_called_once_with(example_profile, True) - - -def test_create_profile_existing( - wrapper: AppWrapper, mock_request: Mock, mock_profile_store: Mock -): +def test_create_profile_existing(wrapper: AppWrapper, mock_request: Mock, mock_sp_store: Mock): mock_request.method = "POST" example_profile = {"id": EXAMPLE_UUID, "name": "My Profile"} - mock_request.json = {"status": "existing", "data": example_profile} - mock_profile_store.return_value.save.return_value = EXAMPLE_UUID # save() success + mock_request.json = {"data": example_profile} + mock_sp_store.return_value.save.return_value = EXAMPLE_UUID # save() success result: tuple[Response, int] = wrapper.app.view_functions["events"]() assert result[1] == 201 - # should have saved profile with is_new: False - mock_profile_store.return_value.save.assert_called_once_with(example_profile, False) - - -def test_create_profile_invalid(wrapper: AppWrapper, mock_request: Mock, mock_profile_store: Mock): - mock_request.method = "POST" - example_profile = {"id": EXAMPLE_UUID, "name": "My Profile"} - mock_request.json = {"status": "foobar", "data": example_profile} - - result: tuple[Response, int] = wrapper.app.view_functions["events"]() - - assert result[1] == 400 - mock_profile_store.return_value.save.assert_not_called() + mock_sp_store.return_value.save.assert_called_once_with(example_profile) def test_create_previous_profile_failure( - wrapper: AppWrapper, mock_request: Mock, mock_profile_store: Mock + wrapper: AppWrapper, mock_request: Mock, mock_sp_store: Mock ): mock_request.method = "POST" mock_request.json = {"id": EXAMPLE_UUID, "name": "My Profile"} - mock_profile_store.return_value.save.return_value = None # save() rejected, profile must exist + mock_sp_store.return_value.save.return_value = None # save() rejected, profile must exist result: tuple[Response, int] = wrapper.app.view_functions["events"]() assert result[1] == 400 -def test_delete_profile_success(wrapper: AppWrapper, mock_request: Mock, mock_profile_store: Mock): +def test_delete_profile_success(wrapper: AppWrapper, mock_request: Mock, mock_sp_store: Mock): mock_request.method = "DELETE" mock_request.args = MultiDict({"id": EXAMPLE_UUID}) - mock_profile_store.return_value.delete.return_value = True # delete worked + mock_sp_store.return_value.delete.return_value = True # delete worked result: tuple[Response, int] = wrapper.app.view_functions["events"]() assert result[1] == 204 -def test_delete_profile_failure(wrapper: AppWrapper, mock_request: Mock, mock_profile_store: Mock): +def test_delete_profile_failure(wrapper: AppWrapper, mock_request: Mock, mock_sp_store: Mock): mock_request.method = "DELETE" mock_request.args = MultiDict({"uuid": EXAMPLE_UUID}) # delete() was rejected, profile must exist - mock_profile_store.return_value.delete.return_value = False + mock_sp_store.return_value.delete.return_value = False result: tuple[Response, int] = wrapper.app.view_functions["events"]() assert result[1] == 404 -def test_update_profile_success(wrapper: AppWrapper, mock_request: Mock, mock_profile_store: Mock): +def test_update_profile_success(wrapper: AppWrapper, mock_request: Mock, mock_sp_store: Mock): mock_request.method = "PUT" mock_request.args = MultiDict({"id": EXAMPLE_UUID}) expected_data = {"id": EXAMPLE_UUID, "name": "Some new name"} mock_request.json = expected_data - mock_profile_store.return_value.update.return_value = expected_data + mock_sp_store.return_value.update.return_value = expected_data result: tuple[Response, int] = wrapper.app.view_functions["events"]() @@ -245,7 +198,7 @@ def test_update_profile_success(wrapper: AppWrapper, mock_request: Mock, mock_pr assert result[0].json["profile"] == expected_data -def test_update_no_body(wrapper: AppWrapper, mock_request: Mock, mock_profile_store: Mock): +def test_update_no_body(wrapper: AppWrapper, mock_request: Mock, mock_sp_store: Mock): mock_request.method = "PUT" mock_request.args = MultiDict({"uuid": EXAMPLE_UUID}) mock_request.data = None @@ -255,11 +208,110 @@ def test_update_no_body(wrapper: AppWrapper, mock_request: Mock, mock_profile_st assert result[1] == 400 -def test_update_profile_missing(wrapper: AppWrapper, mock_request: Mock, mock_profile_store: Mock): +def test_update_profile_missing(wrapper: AppWrapper, mock_request: Mock, mock_sp_store: Mock): mock_request.method = "PUT" mock_request.args = MultiDict({"uuid": EXAMPLE_UUID}) - mock_profile_store.return_value.update.side_effect = FileNotFoundError + mock_sp_store.return_value.update.side_effect = FileNotFoundError result: tuple[Response, int] = wrapper.app.view_functions["events"]() assert result[1] == 404 + + +# test /vulnerabilities and /vulnerabilities/:profile_id endpoints +def test_get_vulnerabilities(wrapper: AppWrapper, mock_store: Mock, mock_sp_store: Mock): + example_profile_list = [{"id": EXAMPLE_UUID, "name": "My Profile"}] + mock_store.return_value.get_all.return_value = example_profile_list + + result: tuple[Response, int] = wrapper.app.view_functions["vulnerabilities"]() + + response, status = result + assert status == 200 + actual_profile_list = response.json + assert len(actual_profile_list) == 1 + assert actual_profile_list[0]["id"] == EXAMPLE_UUID + mock_store.return_value.get_all.assert_called_once() + mock_sp_store.return_value.get_all.assert_not_called() + + +def test_post_vulnerabilities(wrapper: AppWrapper, mock_store: Mock, mock_request: Mock): + example_profile = {"id": EXAMPLE_UUID, "name": "My Profile", "hazards": []} + mock_request.json = example_profile + mock_request.method = "POST" + mock_store.return_value.save.return_value = EXAMPLE_UUID # save() success + + result: tuple[Response, int] = wrapper.app.view_functions["vulnerabilities"]() + + _, status = result + assert status == 201 + # request body JSON should have been passed in full to store's save() method + mock_store.return_value.save.assert_called_with(example_profile) + + +def test_get_vulnerability(wrapper: AppWrapper, mock_store: Mock, mock_request: Mock): + expected_id = EXAMPLE_UUID + mock_store.return_value.get.return_value = {"id": expected_id, "name": "My Vulnerability"} + + result: tuple[Response, int] = wrapper.app.view_functions["vulnerability"](expected_id) + + assert result[1] == 200 + + +def test_delete_vulnerability(wrapper: AppWrapper, mock_store: Mock, mock_request: Mock): + expected_id = EXAMPLE_UUID + mock_request.method = "DELETE" + mock_store.return_value.delete.return_value = True # delete() succeeds + + result: tuple[Response, int] = wrapper.app.view_functions["vulnerability"](expected_id) + + _, status = result + assert status == 204 + + +def test_delete_vulnerability_missing(wrapper: AppWrapper, mock_store: Mock, mock_request: Mock): + expected_id = EXAMPLE_UUID + mock_request.method = "DELETE" + mock_store.return_value.delete.return_value = False # delete() fails + + result: tuple[Response, int] = wrapper.app.view_functions["vulnerability"](expected_id) + + _, status = result + assert status == 404 + + +def test_patch_vulnerability(wrapper: AppWrapper, mock_store: Mock, mock_request: Mock): + expected_id = EXAMPLE_UUID + expected_request_body = {"name": "A different name", "hazards": []} + updated_profile = {**expected_request_body, "activeTime": {"startDate": "2026-01-01T12:00Z"}} + mock_request.method = "PATCH" + mock_request.json = expected_request_body + # update() succeeds + mock_store.return_value.update.return_value = updated_profile + + result: tuple[Response, int] = wrapper.app.view_functions["vulnerability"](expected_id) + + assert result[1] == 200 + assert result[0].json == updated_profile + mock_store.return_value.update.assert_called_with(expected_id, expected_request_body) + + +def test_patch_vulnerability_not_found(wrapper: AppWrapper, mock_store: Mock, mock_request: Mock): + expected_id = EXAMPLE_UUID + mock_request.method = "PATCH" + mock_request.json = {"name": "A different name", "hazards": []} + mock_store.return_value.update.side_effect = FileNotFoundError # profile_id doesn't exist + + result: tuple[Response, int] = wrapper.app.view_functions["vulnerability"](expected_id) + + assert result[1] == 404 + + +def test_patch_vulnerability_fails(wrapper: AppWrapper, mock_store: Mock, mock_request: Mock): + expected_id = EXAMPLE_UUID + mock_request.method = "PATCH" + mock_request.json = {"name": "A different name", "hazards": []} + mock_store.return_value.update.return_value = None # update() fails + + result: tuple[Response, int] = wrapper.app.view_functions["vulnerability"](expected_id) + + assert result[1] == 500 diff --git a/python/nwsc_proxy/test/test_profile_store.py b/python/nwsc_proxy/test/test_profile_store.py deleted file mode 100644 index 4342b3e..0000000 --- a/python/nwsc_proxy/test/test_profile_store.py +++ /dev/null @@ -1,227 +0,0 @@ -"""Tests for src/profile_store.py""" - -# ---------------------------------------------------------------------------------- -# Created on Wed Dec 18 2024 -# -# Copyright (c) 2024 Colorado State University. All rights reserved. (1) -# -# Contributors: -# Mackenzie Grimes (1) -# -# ---------------------------------------------------------------------------------- -# pylint: disable=missing-function-docstring,redefined-outer-name -import json -import os -import shutil -from copy import deepcopy -from datetime import datetime, UTC -from glob import glob - -from pytest import fixture, raises - -from python.nwsc_proxy.src.profile_store import ProfileStore, NEW_SUBDIR, EXISTING_SUBDIR, dt_parse - -# constants -STORE_BASE_DIR = os.path.join(os.path.dirname(__file__), "temp") -RAW_JSON_PATH = os.path.join(os.path.dirname(__file__), "..", "src", "profiles") - -EXAMPLE_UUID = "9835b194-74de-4321-aa6b-d769972dc7cb" - -with open(os.path.join(RAW_JSON_PATH, "nwsc_test_response_1.json"), "r", encoding="utf-8") as f: - EXAMPLE_SUPPORT_PROFILE: dict = json.load(f)["profiles"][0] - - -def _empty_directory(dir_path: str): - for filename in os.listdir(dir_path): - filepath = os.path.join(dir_path, filename) - if os.path.isdir(filepath): - if len(os.listdir(filepath)) > 0: - _empty_directory(filepath) # recursively delete child directories - os.rmdir(filepath) - else: - os.remove(filepath) - - -# fixtures -def startup(): - """Runs before each test is executed. Create test resource file structure""" - os.makedirs(STORE_BASE_DIR, exist_ok=True) - _empty_directory(STORE_BASE_DIR) # delete any existing files/directories - - # copy all JSON files from ../src/profiles/ to the ProfileStore's base dir - for response_file in glob("*.json", root_dir=RAW_JSON_PATH): - shutil.copy(os.path.join(RAW_JSON_PATH, response_file), STORE_BASE_DIR) - - -def teardown(): - """Clean up any files/directories created during test""" - _empty_directory(STORE_BASE_DIR) - os.rmdir(STORE_BASE_DIR) - - -@fixture(autouse=True) -def startup_and_teardown(): - startup() - yield # run test - teardown() - - -@fixture -def store(): - return ProfileStore(STORE_BASE_DIR) - - -# tests -def test_profile_store_loads_api_responses(store: ProfileStore): - assert sorted(list(store.profile_cache.keys())) == [ - "a08370c6-ab87-4808-bd51-a8597e58410d", - "e1033860-f198-4c6a-a91b-beaec905132f", - "fd35adec-d2a0-49a9-a320-df20a7b6d681", - ] - - for cache_id, cache_obj in store.profile_cache.items(): - # should have loaded all profiles as status "existing", file should exist in that subdir - assert not cache_obj.is_new - filepath = os.path.join(STORE_BASE_DIR, EXISTING_SUBDIR, f"{cache_id}.json") - assert os.path.exists(filepath) - - # new directory should be empty to begin with - assert os.listdir(os.path.join(STORE_BASE_DIR, NEW_SUBDIR)) == [] - - -def test_store_loads_jsons_from_new(store: ProfileStore): - # create a pre-existing "new" profile as well as the 3 "existing" profiles - profile = deepcopy(store.get_all()[0]) - profile["id"] = EXAMPLE_UUID # give copied profile a unique identifier - store.save(profile) - - # simulate starting ProfileStore process fresh, with existing JSONs on filesystem - new_store = ProfileStore(STORE_BASE_DIR) - - # newly created ProfileStore should have correctly loaded and labeled "new" Profile - new_profile_list = new_store.get_all(is_new=True) - assert len(new_profile_list) == 1 - assert len(new_store.profile_cache) == 4 # 3 existing, 1 new - - -def test_get_all_profiles(store: ProfileStore): - result = store.get_all() - assert len(result) == 3 - - result = store.get_all(is_new=True) - assert len(result) == 0 - - -def test_save_adds_to_new_profiles(store: ProfileStore): - new_profile = deepcopy(EXAMPLE_SUPPORT_PROFILE) - new_profile["id"] = EXAMPLE_UUID - - new_profile_id = store.save(new_profile) - - assert new_profile_id == EXAMPLE_UUID - # profile should now be returned by get() request for new profiles - new_profile_list = store.get_all(is_new=True) - assert [p.get("id") for p in new_profile_list] == [EXAMPLE_UUID] - - # profile should not be returned by get() request for existing profiles - existing_profile_list = store.get_all() - assert EXAMPLE_UUID not in [p.get("id") for p in existing_profile_list] - - # file should exist in the "new" subdirectory - assert os.path.exists(os.path.join(STORE_BASE_DIR, NEW_SUBDIR, f"{new_profile_id}.json")) - - -def test_save_rejects_existing_profile(store: ProfileStore): - new_profile = deepcopy(EXAMPLE_SUPPORT_PROFILE) # use Support Profile that already exists - - new_profile_id = store.save(new_profile) - - assert not new_profile_id - # no new profile should have been added - new_profile_list = store.get_all(is_new=True) - assert new_profile_list == [] - # file should not exist in the "new" subdirectory - assert not os.path.exists( - os.path.join(STORE_BASE_DIR, NEW_SUBDIR, f'{new_profile["id"]}.json') - ) - - -def test_move_to_existing_success(store: ProfileStore): - new_profile = deepcopy(EXAMPLE_SUPPORT_PROFILE) - new_profile["id"] = EXAMPLE_UUID - store.save(new_profile) - - new_profiles = store.get_all(is_new=True) - assert [p["id"] for p in new_profiles] == [EXAMPLE_UUID] - - store.mark_as_existing(EXAMPLE_UUID) - - new_profiles = store.get_all(is_new=True) - assert new_profiles == [] # Support Profile has vanished from list of new - existing_profiles = store.get_all() - assert EXAMPLE_UUID in [p["id"] for p in existing_profiles] # now Profile is in existing list - - -def test_delete_profile(store: ProfileStore): - existing_profile_list = store.get_all() - profile_id = existing_profile_list[0]["id"] - - success = store.delete(profile_id) - - # after delete, profile should not be returned to get() request, and JSON file should be gone - assert success - existing_profile_list = store.get_all() - assert profile_id not in [p["id"] for p in existing_profile_list] - assert not os.path.exists(os.path.join(STORE_BASE_DIR, EXISTING_SUBDIR, f"{profile_id}.json")) - - -def test_delete_profile_failure(store: ProfileStore): - profile_id = "11111111-2222-3333-444444444444" # fake ID does not exist in ProfileStore - - success = store.delete(profile_id) - assert not success - - -def test_update_profile_success(store: ProfileStore): - profile_id = EXAMPLE_SUPPORT_PROFILE["id"] - new_start_dt = "2026-01-01T12:00:00Z" - new_name = "A different name" - new_profile = deepcopy(EXAMPLE_SUPPORT_PROFILE) - new_profile["name"] = new_name - new_profile["setting"] = {"timing": {"start": new_start_dt}} - - updated_profile = store.update(profile_id, new_profile) - - # data returned should have updated attributes - assert updated_profile["name"] == new_name - assert updated_profile["setting"]["timing"]["start"] == new_start_dt - # profile in cache should have indeed been changed - refetched_profile = store.profile_cache.get(profile_id) - assert refetched_profile.name == new_name - assert datetime.fromtimestamp(refetched_profile.start_timestamp, UTC) == dt_parse(new_start_dt) - - -def test_update_profile_error_rollback(store: ProfileStore): - profile_id = EXAMPLE_SUPPORT_PROFILE["id"] - new_name = "A different name" - new_profile = deepcopy(EXAMPLE_SUPPORT_PROFILE) - new_profile["name"] = new_name - # inject unhashable content into JSON data, will cause save() to fail and return None - new_profile["unhashable"] = set(["foo", "bar"]) - - updated_profile = store.update(profile_id, new_profile) - - assert updated_profile is None - # profile in cache is still there, no changes were made to data - refetched_profile = store.profile_cache.get(profile_id) - assert refetched_profile.name != new_name - - -def test_update_profile_not_found(store: ProfileStore): - profile_id = "11111111-2222-3333-444444444444" # fake ID does not exist in ProfileStore - new_profile_data = {"name": "A different name"} - - with raises(FileNotFoundError) as exc: - _ = store.update(profile_id, new_profile_data) - - assert exc is not None diff --git a/python/nwsc_proxy/test/test_support_profile_store.py b/python/nwsc_proxy/test/test_support_profile_store.py new file mode 100644 index 0000000..da64033 --- /dev/null +++ b/python/nwsc_proxy/test/test_support_profile_store.py @@ -0,0 +1,160 @@ +"""Tests for src/support_profile_store.py""" + +# ---------------------------------------------------------------------------------- +# Created on Wed Dec 18 2024 +# +# Copyright (c) 2024 Colorado State University. All rights reserved. (1) +# +# Contributors: +# Mackenzie Grimes (1) +# +# ---------------------------------------------------------------------------------- +# pylint: disable=missing-function-docstring,redefined-outer-name,duplicate-code +import json +import os +import shutil +from copy import deepcopy +from datetime import datetime, UTC +from glob import glob + +from pytest import fixture, raises + +from python.nwsc_proxy.src.support_profile_store import SupportProfileStore, dt_parse + +# constants +RAW_JSON_PATH = os.path.join(os.path.dirname(__file__), "..", "src", "supportprofiles") +EXAMPLE_UUID = "9835b194-74de-4321-aa6b-d769972dc7cb" + +with open(os.path.join(RAW_JSON_PATH, "nwsc_test_response_1.json"), "r", encoding="utf-8") as file: + EXAMPLE_SUPPORT_PROFILE: dict = json.load(file)["profiles"][0] + + +# fixtures +@fixture +def base_dir(tmpdir_factory) -> str: + return tmpdir_factory.mktemp("temp") + + +@fixture(autouse=True) +def startup(base_dir: str): + """Runs before each test is executed. Create test resource file structure""" + # copy all JSON files from ../src/profiles/ to the ProfileStore's base dir + for response_file in glob("*.json", root_dir=RAW_JSON_PATH): + shutil.copy(os.path.join(RAW_JSON_PATH, response_file), base_dir) + + yield # run test + + +@fixture +def store(base_dir: str): + return SupportProfileStore(base_dir) + + +# tests +def test_profile_store_loads_api_responses(store: SupportProfileStore, base_dir: str): + assert sorted(list(store.profile_cache.keys())) == [ + "a08370c6-ab87-4808-bd51-a8597e58410d", + "e1033860-f198-4c6a-a91b-beaec905132f", + "fd35adec-d2a0-49a9-a320-df20a7b6d681", + ] + + for cache_id in store.profile_cache: + # should have loaded all profiles, file should exist in known profile subdir + filepath = os.path.join(base_dir, store.PROFILE_DIR, f"{cache_id}.json") + assert os.path.exists(filepath) + + +def test_get_all_profiles(store: SupportProfileStore): + result = store.get_all() + assert len(result) == 3 + + +def test_save_adds_to_profiles(store: SupportProfileStore, base_dir: str): + new_profile = deepcopy(EXAMPLE_SUPPORT_PROFILE) + new_profile["id"] = EXAMPLE_UUID + + new_profile_id = store.save(new_profile) + + assert new_profile_id == EXAMPLE_UUID + # profile should now be returned by get() request + new_profile_list = store.get_all() + assert EXAMPLE_UUID in [p.get("id") for p in new_profile_list] + # file should exist in profile subdirectory + assert os.path.exists(os.path.join(base_dir, store.PROFILE_DIR, f"{new_profile_id}.json")) + + +def test_save_rejects_existing_profile(store: SupportProfileStore): + new_profile = deepcopy(EXAMPLE_SUPPORT_PROFILE) # use Support Profile that already exists + expected_ids = sorted(p["id"] for p in store.get_all()) + + new_profile_id = store.save(new_profile) + + assert not new_profile_id + # no new profile should have been added + new_profile_list = store.get_all() + assert sorted([p["id"] for p in new_profile_list]) == expected_ids + + +def test_delete_profile(store: SupportProfileStore, base_dir: str): + existing_profile_list = store.get_all() + profile_id = existing_profile_list[0]["id"] + + success = store.delete(profile_id) + + # after delete, profile should not be returned to get() request, and JSON file should be gone + assert success + existing_profile_list = store.get_all() + assert profile_id not in [p["id"] for p in existing_profile_list] + assert not os.path.exists(os.path.join(base_dir, store.PROFILE_DIR, f"{profile_id}.json")) + + +def test_delete_profile_failure(store: SupportProfileStore): + profile_id = "11111111-2222-3333-444444444444" # fake ID does not exist in ProfileStore + + success = store.delete(profile_id) + assert not success + + +def test_update_profile_success(store: SupportProfileStore): + profile_id = EXAMPLE_SUPPORT_PROFILE["id"] + new_start_dt = "2026-01-01T12:00:00Z" + new_name = "A different name" + new_profile = deepcopy(EXAMPLE_SUPPORT_PROFILE) + new_profile["name"] = new_name + new_profile["setting"] = {"timing": {"start": new_start_dt}} + + updated_profile = store.update(profile_id, new_profile) + + # data returned should have updated attributes + assert updated_profile["name"] == new_name + assert updated_profile["setting"]["timing"]["start"] == new_start_dt + # profile in cache should have indeed been changed + refetched_profile = store.profile_cache.get(profile_id) + assert refetched_profile.name == new_name + assert datetime.fromtimestamp(refetched_profile.start_timestamp, UTC) == dt_parse(new_start_dt) + + +def test_update_profile_error_rollback(store: SupportProfileStore): + profile_id = EXAMPLE_SUPPORT_PROFILE["id"] + new_name = "A different name" + new_profile = deepcopy(EXAMPLE_SUPPORT_PROFILE) + new_profile["name"] = new_name + # inject unhashable content into JSON data, will cause save() to fail and return None + new_profile["unhashable"] = set(["foo", "bar"]) + + updated_profile = store.update(profile_id, new_profile) + + assert updated_profile is None + # profile in cache is still there, no changes were made to data + refetched_profile = store.profile_cache.get(profile_id) + assert refetched_profile.name != new_name + + +def test_update_profile_not_found(store: SupportProfileStore): + profile_id = "11111111-2222-3333-444444444444" # fake ID does not exist in ProfileStore + new_profile_data = {"name": "A different name"} + + with raises(FileNotFoundError) as exc: + _ = store.update(profile_id, new_profile_data) + + assert exc is not None diff --git a/python/nwsc_proxy/test/test_vulnerability_store.py b/python/nwsc_proxy/test/test_vulnerability_store.py new file mode 100644 index 0000000..616724d --- /dev/null +++ b/python/nwsc_proxy/test/test_vulnerability_store.py @@ -0,0 +1,206 @@ +"""Tests for src/vulnerability_store.py""" + +# ---------------------------------------------------------------------------------- +# Created on Mon Jul 13 2026 +# +# Copyright (c) 2026 Colorado State University. All rights reserved. (1) +# +# Contributors: +# Mackenzie Grimes (1) +# +# ---------------------------------------------------------------------------------- +# pylint: disable=missing-function-docstring,redefined-outer-name,unused-argument + +import json +import os +import shutil +from copy import deepcopy +from datetime import datetime, timedelta, UTC +from glob import glob +from unittest.mock import Mock +from uuid import uuid4, UUID + +from pytest import fixture, raises, MonkeyPatch + +from python.nwsc_proxy.ncp_web_service import to_iso +from python.nwsc_proxy.src.vulnerability_store import VulnerabilityStore, dt_parse + +# constants +RAW_JSON_PATH = os.path.join(os.path.dirname(__file__), "..", "src", "vulnerabilities") + +with open(f"{RAW_JSON_PATH}/nwsc_gsl_test_profiles.json", "r", encoding="utf-8") as file: + EXAMPLE_PROFILE: dict = json.load(file)[0] + + +# fixtures +@fixture +def base_dir(tmpdir_factory) -> str: + return tmpdir_factory.mktemp("temp") + + +@fixture(autouse=True) +def startup(base_dir: str): + """Runs before each test is executed. Create test resource file structure""" + # copy all JSON files from ../src/profiles/ to the ProfileStore's base dir + for response_file in glob("*.json", root_dir=RAW_JSON_PATH): + shutil.copy(os.path.join(RAW_JSON_PATH, response_file), base_dir) + + +@fixture +def mock_uuid(monkeypatch: MonkeyPatch) -> Mock: + # mock uuid.uuid4() to always return our UUID from static .json file + mock_func = Mock(name="mock_uuid", spec=uuid4) + mock_func.return_value = UUID(EXAMPLE_PROFILE["id"]) + monkeypatch.setattr("python.nwsc_proxy.src.vulnerability_store.uuid4", mock_func) + return mock_func + + +@fixture +def store(base_dir: str, mock_uuid) -> VulnerabilityStore: + return VulnerabilityStore(base_dir) + + +# tests +def test_profile_store_loads_api_responses(store: VulnerabilityStore, base_dir: str): + # pylint: disable=protected-access + assert sorted(list(store._cache.keys())) == [ + "a08370c6-ab87-4808-bd51-a8597e58410d", + "e1033860-f198-4c6a-a91b-beaec905132f", + "fd35adec-d2a0-49a9-a320-df20a7b6d681", + ] + + for cache_id in store._cache: + # should have loaded all profiles, file should exist in known profile subdir + filepath = os.path.join(base_dir, store.PROFILE_DIR, f"{cache_id}.json") + assert os.path.exists(filepath) + + +def test_get_all_profiles(store: VulnerabilityStore): + result = store.get_all() + assert len(result) == 3 + + +def test_skips_expired_profile(store: VulnerabilityStore, mock_uuid: Mock): + expected_id = str(uuid4()) + mock_uuid.return_value = expected_id + # create new Vulnerability with startDate and endDate in the past + new_profile = deepcopy(EXAMPLE_PROFILE) + new_profile["id"] = expected_id + current_time = datetime.now(UTC) + new_profile["activeTime"]["startDate"] = to_iso(current_time - timedelta(days=4)) + new_profile["activeTime"]["endDate"] = to_iso(current_time - timedelta(hours=2)) + # actually commit expired Profile to store + store.save(new_profile) + + actual_profile_list = [p["id"] for p in store.get_all()] + + # get_all() ignores profile that has already ended + assert expected_id not in actual_profile_list + + +def test_get_one_profile(store: VulnerabilityStore): + expected_id = EXAMPLE_PROFILE["id"] + + result = store.get(expected_id) + + assert result is not None and result["id"] == expected_id + + +def test_save_adds_to_profiles(store: VulnerabilityStore, base_dir: str, mock_uuid: Mock): + expected_id = "9835b194-74de-4321-aa6b-d769972dc7cb" + new_profile = deepcopy(EXAMPLE_PROFILE) + new_profile["id"] = expected_id + mock_uuid.return_value = expected_id + + new_profile = store.save(new_profile) + # profile should now be returned by get() request + latest_profile_list = store.get_all() + + new_profile_id = new_profile["id"] + assert new_profile_id == expected_id + assert expected_id in [p.get("id") for p in latest_profile_list] + # file should exist in profile subdirectory + assert os.path.exists(os.path.join(base_dir, store.PROFILE_DIR, f"{new_profile_id}.json")) + + +def test_save_ignores_profile_id(store: VulnerabilityStore, mock_uuid: Mock): + expected_id = str(uuid4()) + mock_uuid.return_value = expected_id + # attempt to create Vulnerability with a client-created UUID + new_profile = deepcopy(EXAMPLE_PROFILE) + new_profile["id"] = str(uuid4()) + + actual_profile = store.save(new_profile) + updated_profile_list = [p["id"] for p in store.get_all()] + + assert actual_profile["id"] != new_profile["id"] + # backend ignored client uuid, made its own + assert expected_id in updated_profile_list + assert new_profile["id"] not in updated_profile_list + + +def test_delete_profile(store: VulnerabilityStore, base_dir: str): + existing_profile_list = store.get_all() + profile_id = existing_profile_list[0]["id"] + + success = store.delete(profile_id) + + # after delete, profile should not be returned to get() request, and JSON file should be gone + assert success + existing_profile_list = store.get_all() + assert profile_id not in [p["id"] for p in existing_profile_list] + assert not os.path.exists(os.path.join(base_dir, store.PROFILE_DIR, f"{profile_id}.json")) + + +def test_delete_profile_failure(store: VulnerabilityStore): + profile_id = "11111111-2222-3333-444444444444" # fake ID does not exist in ProfileStore + + success = store.delete(profile_id) + assert not success + + +def test_update_profile_success(store: VulnerabilityStore): + profile_id = EXAMPLE_PROFILE["id"] + expected_office = EXAMPLE_PROFILE["primaryOfficeId"] + new_start_dt = "2026-01-01T12:00:00Z" + new_name = "A different name" + update_data = {"name": new_name, "activeTime": {"startDate": new_start_dt}} + + updated_profile = store.update(profile_id, update_data) + + # data returned should have updated attributes + assert updated_profile["name"] == new_name + assert updated_profile["activeTime"]["startDate"] == new_start_dt + # profile in cache should have indeed been changed + refetched_profile = store._cache.get(profile_id) # pylint: disable=protected-access + assert refetched_profile.name == new_name + assert datetime.fromtimestamp(refetched_profile.start_timestamp, UTC) == dt_parse(new_start_dt) + # attribute unrelated to the update was unmodified + assert refetched_profile.office == expected_office + + +def test_update_profile_error_rollback(store: VulnerabilityStore): + profile_id = EXAMPLE_PROFILE["id"] + expected_office = EXAMPLE_PROFILE["primaryOfficeId"] + new_name = "A different name" + # inject unhashable content into JSON data, will cause save() to fail and return None + update_data = {"name": new_name, "unhashable_thing": set(["foo", "bar"])} + + updated_profile = store.update(profile_id, update_data) + + assert updated_profile is None + # profile in cache is still there, no changes were made to data + refetched_profile = store._cache.get(profile_id) # pylint: disable=protected-access + assert refetched_profile.name != new_name + # attribute unrelated to the update was unmodified + assert refetched_profile.office == expected_office + + +def test_update_profile_not_found(store: VulnerabilityStore): + profile_id = "11111111-2222-3333-444444444444" # fake ID does not exist in ProfileStore + new_profile_data = {"name": "A different name"} + + with raises(FileNotFoundError) as exc: + _ = store.update(profile_id, new_profile_data) + + assert exc is not None