From 8270694f22b70e7523f7cb61c09a31f44bd3b3c7 Mon Sep 17 00:00:00 2001 From: Piyush Date: Wed, 13 May 2026 15:10:41 +0530 Subject: [PATCH 1/4] fix(api): mask PII in logs and return correct HTTP status codes (#153) GeneralOPDController previously returned 'String' from every endpoint, which forced Spring to respond with HTTP 200 regardless of outcome and embedded error details only in the body. The same controllers also INFO-logged the full request payload, exposing beneficiary vitals, history, examinations and prescriptions to log aggregators. Changes: * OutputResponse: BAD_REQUEST is now 400 per RFC 9110; introduce NOT_FOUND (404) so existing callers using the literal 404 for not-found semantics keep working. TM_EXCEPTION (5011) is now distinct from SWYMED_EXCEPTION (5010). toStringWithHttpStatus() maps every internal code to its appropriate HTTP status instead of defaulting unknown codes to 503. * GlobalExceptionHandler: new @RestControllerAdvice translating IEMRException, TMException and standard Spring web exceptions to correct HTTP status codes. Exception messages are not echoed into log lines because they can carry beneficiary values; stack traces remain captured via SLF4J for diagnosis. The catch-all returns 500 with a generic body so internal details do not leak. * LogMasker: JSON-aware masker for AMRIT-domain PII / PHI fields (Aadhaar, ABHA, names, DOB, address, contact, free-text remarks). Matching is case-insensitive; unparseable payloads are replaced with a length-only summary so raw bytes never reach logs. * GeneralOPDController: every endpoint now returns ResponseEntity via toStringWithHttpStatus(); request-body logs route through LogMasker.maskJson; catch-block error logs use the (msg, throwable) overload instead of concatenating exception.getMessage(). Tests cover the OutputResponse status mapping (including legacy 404 callers), the LogMasker behaviour for nested objects, arrays, case-insensitive keys and non-JSON fallback, and the GlobalExceptionHandler mapping for each handled exception type plus the non-echo guarantee on generic exceptions. --- .../generalOPD/GeneralOPDController.java | 169 ++++++++--------- .../exception/GlobalExceptionHandler.java | 107 +++++++++++ .../com/iemr/hwc/utils/logging/LogMasker.java | 132 +++++++++++++ .../hwc/utils/response/OutputResponse.java | 26 ++- .../exception/GlobalExceptionHandlerTest.java | 126 +++++++++++++ .../iemr/hwc/utils/logging/LogMaskerTest.java | 130 +++++++++++++ .../utils/response/OutputResponseTest.java | 177 ++++++++++++++++++ 7 files changed, 770 insertions(+), 97 deletions(-) create mode 100644 src/main/java/com/iemr/hwc/utils/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/iemr/hwc/utils/logging/LogMasker.java create mode 100644 src/test/java/com/iemr/hwc/utils/exception/GlobalExceptionHandlerTest.java create mode 100644 src/test/java/com/iemr/hwc/utils/logging/LogMaskerTest.java create mode 100644 src/test/java/com/iemr/hwc/utils/response/OutputResponseTest.java diff --git a/src/main/java/com/iemr/hwc/controller/generalOPD/GeneralOPDController.java b/src/main/java/com/iemr/hwc/controller/generalOPD/GeneralOPDController.java index 44d88537..42d652a0 100644 --- a/src/main/java/com/iemr/hwc/controller/generalOPD/GeneralOPDController.java +++ b/src/main/java/com/iemr/hwc/controller/generalOPD/GeneralOPDController.java @@ -1,8 +1,8 @@ /* -* AMRIT – Accessible Medical Records via Integrated Technology -* Integrated EHR (Electronic Health Records) Solution +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution * -* Copyright (C) "Piramal Swasthya Management and Research Institute" +* Copyright (C) "Piramal Swasthya Management and Research Institute" * * This file is part of AMRIT. * @@ -23,39 +23,35 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.List; +import java.util.Map; import org.json.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.repository.query.Param; +import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; - import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RestController; import com.google.gson.Gson; +import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.iemr.hwc.service.generalOPD.GeneralOPDServiceImpl; +import com.iemr.hwc.utils.logging.LogMasker; import com.iemr.hwc.utils.response.OutputResponse; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import com.google.gson.Gson; -import com.google.gson.JsonArray; - import io.swagger.v3.oas.annotations.Operation; /*** - * + * * @Objective Saving General OPD data for Nurse and Doctor. * */ @@ -80,7 +76,7 @@ public void setGeneralOPDServiceImpl(GeneralOPDServiceImpl generalOPDServiceImpl */ @Operation(summary = "Save general OPD data collected by nurse") @PostMapping(value = { "/save/nurseData" }) - public String saveBenGenOPDNurseData(@RequestBody String requestObj, + public ResponseEntity saveBenGenOPDNurseData(@RequestBody String requestObj, @RequestHeader(value = "Authorization") String Authorization) throws Exception { OutputResponse response = new OutputResponse(); @@ -91,7 +87,7 @@ public String saveBenGenOPDNurseData(@RequestBody String requestObj, jsnOBJ = jsnElmnt.getAsJsonObject(); try { - logger.info("Request object for GeneralOPD nurse data saving :" + requestObj); + logger.info("Request object for GeneralOPD nurse data saving :" + LogMasker.maskJson(requestObj)); if (jsnOBJ != null) { String genOPDRes = generalOPDServiceImpl.saveNurseData(jsnOBJ, Authorization); @@ -100,12 +96,12 @@ public String saveBenGenOPDNurseData(@RequestBody String requestObj, response.setResponse("Invalid request"); } } catch (Exception e) { - logger.error("Error in nurse data saving :" + e.getMessage()); + logger.error("Error in nurse data saving", e); generalOPDServiceImpl.deleteVisitDetails(jsnOBJ); - response.setError(5000, e.getMessage()); + response.setError(OutputResponse.GENERIC_FAILURE, "Unable to save nurse data"); } } - return response.toString(); + return response.toStringWithHttpStatus(); } /** @@ -115,11 +111,11 @@ public String saveBenGenOPDNurseData(@RequestBody String requestObj, */ @Operation(summary = "Save general OPD data collected by doctor") @PostMapping(value = { "/save/doctorData" }) - public String saveBenGenOPDDoctorData(@RequestBody String requestObj, + public ResponseEntity saveBenGenOPDDoctorData(@RequestBody String requestObj, @RequestHeader(value = "Authorization") String Authorization) { OutputResponse response = new OutputResponse(); try { - logger.info("Request object for GeneralOPD doctor data saving :" + requestObj); + logger.info("Request object for GeneralOPD doctor data saving :" + LogMasker.maskJson(requestObj)); JsonObject jsnOBJ = new JsonObject(); JsonParser jsnParser = new JsonParser(); @@ -154,10 +150,10 @@ public String saveBenGenOPDDoctorData(@RequestBody String requestObj, response.setResponse("Invalid request"); } } catch (Exception e) { - logger.error("Error in doctor data saving :" + e.getMessage()); - response.setError(5000, e.getMessage()); + logger.error("Error in doctor data saving", e); + response.setError(OutputResponse.GENERIC_FAILURE, "Unable to save doctor data"); } - return response.toString(); + return response.toStringWithHttpStatus(); } /** @@ -168,11 +164,11 @@ public String saveBenGenOPDDoctorData(@RequestBody String requestObj, @Operation(summary = "Get general OPD beneficiary visit details") @PostMapping(value = { "/getBenVisitDetailsFrmNurseGOPD" }) @Transactional(rollbackFor = Exception.class) - public String getBenVisitDetailsFrmNurseGOPD( + public ResponseEntity getBenVisitDetailsFrmNurseGOPD( @Param(value = "{\"benRegID\":\"Long\",\"visitCode\":\"Long\"}") @RequestBody String comingRequest) { OutputResponse response = new OutputResponse(); - logger.info("Request obj to fetch General OPD visit details :" + comingRequest); + logger.info("Request obj to fetch General OPD visit details :" + LogMasker.maskJson(comingRequest)); try { JSONObject obj = new JSONObject(comingRequest); if (obj.length() > 1) { @@ -183,14 +179,13 @@ public String getBenVisitDetailsFrmNurseGOPD( response.setResponse(res); } else { logger.info("Invalid Request Data."); - response.setError(5000, "Invalid request"); + response.setError(OutputResponse.BAD_REQUEST, "Invalid request"); } - logger.info("getBenDataFrmNurseScrnToDocScrnVisitDetails response:" + response); } catch (Exception e) { - response.setError(5000, "Error while getting beneficiary visit data"); - logger.error("Error in getBenDataFrmNurseScrnToDocScrnVisitDetails:" + e); + logger.error("Error in getBenDataFrmNurseScrnToDocScrnVisitDetails", e); + response.setError(OutputResponse.GENERIC_FAILURE, "Error while getting beneficiary visit data"); } - return response.toString(); + return response.toStringWithHttpStatus(); } /** @@ -200,12 +195,11 @@ public String getBenVisitDetailsFrmNurseGOPD( */ @Operation(summary = "Get general OPD beneficiary history") @PostMapping(value = { "/getBenHistoryDetails" }) - - public String getBenHistoryDetails( + public ResponseEntity getBenHistoryDetails( @Param(value = "{\"benRegID\":\"Long\",\"visitCode\":\"Long\"}") @RequestBody String comingRequest) { OutputResponse response = new OutputResponse(); - logger.info("getBenHistoryDetails request:" + comingRequest); + logger.info("getBenHistoryDetails request :" + LogMasker.maskJson(comingRequest)); try { JSONObject obj = new JSONObject(comingRequest); if (obj.has("benRegID") && obj.has("visitCode")) { @@ -215,14 +209,13 @@ public String getBenHistoryDetails( String s = generalOPDServiceImpl.getBenHistoryDetails(benRegID, visitCode); response.setResponse(s); } else { - response.setError(5000, "Invalid request"); + response.setError(OutputResponse.BAD_REQUEST, "Invalid request"); } - logger.info("getBenHistoryDetails response:" + response); } catch (Exception e) { - response.setError(5000, "Error while getting beneficiary history data"); - logger.error("Error in getBenHistoryDetails:" + e); + logger.error("Error in getBenHistoryDetails", e); + response.setError(OutputResponse.GENERIC_FAILURE, "Error while getting beneficiary history data"); } - return response.toString(); + return response.toStringWithHttpStatus(); } /** @@ -232,11 +225,11 @@ public String getBenHistoryDetails( */ @Operation(summary = "Get general OPD beneficiary vitals") @PostMapping(value = { "/getBenVitalDetailsFrmNurse" }) - public String getBenVitalDetailsFrmNurse( + public ResponseEntity getBenVitalDetailsFrmNurse( @Param(value = "{\"benRegID\":\"Long\",\"visitCode\":\"Long\"}") @RequestBody String comingRequest) { OutputResponse response = new OutputResponse(); - logger.info("getBenVitalDetailsFrmNurse request:" + comingRequest); + logger.info("getBenVitalDetailsFrmNurse request :" + LogMasker.maskJson(comingRequest)); try { JSONObject obj = new JSONObject(comingRequest); if (obj.has("benRegID") && obj.has("visitCode")) { @@ -247,14 +240,13 @@ public String getBenVitalDetailsFrmNurse( response.setResponse(res); } else { logger.info("Invalid Request Data."); - response.setError(5000, "Invalid request"); + response.setError(OutputResponse.BAD_REQUEST, "Invalid request"); } - logger.info("getBenVitalDetailsFrmNurse response:" + response); } catch (Exception e) { - response.setError(5000, "Error while getting beneficiary vital data"); - logger.error("Error in getBenVitalDetailsFrmNurse:" + e); + logger.error("Error in getBenVitalDetailsFrmNurse", e); + response.setError(OutputResponse.GENERIC_FAILURE, "Error while getting beneficiary vital data"); } - return response.toString(); + return response.toStringWithHttpStatus(); } /** @@ -264,12 +256,11 @@ public String getBenVitalDetailsFrmNurse( */ @Operation(summary = "Get general OPD beneficiary examination details") @PostMapping(value = { "/getBenExaminationDetails" }) - - public String getBenExaminationDetails( + public ResponseEntity getBenExaminationDetails( @Param(value = "{\"benRegID\":\"Long\",\"visitCode\":\"Long\"}") @RequestBody String comingRequest) { OutputResponse response = new OutputResponse(); - logger.info("getBenExaminationDetails request:" + comingRequest); + logger.info("getBenExaminationDetails request :" + LogMasker.maskJson(comingRequest)); try { JSONObject obj = new JSONObject(comingRequest); if (obj.has("benRegID") && obj.has("visitCode")) { @@ -279,14 +270,13 @@ public String getBenExaminationDetails( String s = generalOPDServiceImpl.getExaminationDetailsData(benRegID, visitCode); response.setResponse(s); } else { - response.setError(5000, "Invalid request"); + response.setError(OutputResponse.BAD_REQUEST, "Invalid request"); } - logger.info("getBenExaminationDetails response:" + response); } catch (Exception e) { - response.setError(5000, "Error while getting beneficiary examination data"); - logger.error("Error in getBenExaminationDetails:" + e); + logger.error("Error in getBenExaminationDetails", e); + response.setError(OutputResponse.GENERIC_FAILURE, "Error while getting beneficiary examination data"); } - return response.toString(); + return response.toStringWithHttpStatus(); } /** @@ -297,11 +287,11 @@ public String getBenExaminationDetails( @Operation(summary = "Get general OPD beneficiary case record and referral") @PostMapping(value = { "/getBenCaseRecordFromDoctorGeneralOPD" }) @Transactional(rollbackFor = Exception.class) - public String getBenCaseRecordFromDoctorGeneralOPD( + public ResponseEntity getBenCaseRecordFromDoctorGeneralOPD( @Param(value = "{\"benRegID\":\"Long\",\"visitCode\":\"Long\"}") @RequestBody String comingRequest) { OutputResponse response = new OutputResponse(); - logger.info("getBenCaseRecordFromDoctorGeneralOPD request:" + comingRequest); + logger.info("getBenCaseRecordFromDoctorGeneralOPD request :" + LogMasker.maskJson(comingRequest)); try { JSONObject obj = new JSONObject(comingRequest); if (null != obj && obj.length() > 1 && obj.has("benRegID") && obj.has("visitCode")) { @@ -312,18 +302,17 @@ public String getBenCaseRecordFromDoctorGeneralOPD( response.setResponse(res); } else { logger.info("Invalid Request Data."); - response.setError(5000, "Invalid request"); + response.setError(OutputResponse.BAD_REQUEST, "Invalid request"); } - logger.info("getBenCaseRecordFromDoctorGeneralOPD response:" + response); } catch (Exception e) { - response.setError(5000, "Error while getting beneficiary doctor data"); - logger.error("Error in getBenCaseRecordFromDoctorGeneralOPD:" + e); + logger.error("Error in getBenCaseRecordFromDoctorGeneralOPD", e); + response.setError(OutputResponse.GENERIC_FAILURE, "Error while getting beneficiary doctor data"); } - return response.toString(); + return response.toStringWithHttpStatus(); } /** - * + * * @param requestObj * @return success or failure response * @objective Replace General OPD History Data entered by Nurse with the details @@ -331,10 +320,10 @@ public String getBenCaseRecordFromDoctorGeneralOPD( */ @Operation(summary = "Update beneficiary history") @PostMapping(value = { "/update/historyScreen" }) - public String updateHistoryNurse(@RequestBody String requestObj) { + public ResponseEntity updateHistoryNurse(@RequestBody String requestObj) { OutputResponse response = new OutputResponse(); - logger.info("Request object for history data updating :" + requestObj); + logger.info("Request object for history data updating :" + LogMasker.maskJson(requestObj)); JsonObject jsnOBJ = new JsonObject(); JsonParser jsnParser = new JsonParser(); @@ -346,19 +335,18 @@ public String updateHistoryNurse(@RequestBody String requestObj) { if (result > 0) { response.setResponse("Data updated successfully"); } else { - response.setError(500, "Unable to modify data"); + response.setError(OutputResponse.GENERIC_FAILURE, "Unable to modify data"); } - logger.info("History data update response:" + response); } catch (Exception e) { - response.setError(5000, "Unable to modify data"); - logger.error("Error while updating history data :" + e); + logger.error("Error while updating history data", e); + response.setError(OutputResponse.GENERIC_FAILURE, "Unable to modify data"); } - return response.toString(); + return response.toStringWithHttpStatus(); } /** - * + * * @param requestObj * @return success or failure response * @objective Replace General OPD Vital Data entered by Nurse with the details @@ -366,10 +354,10 @@ public String updateHistoryNurse(@RequestBody String requestObj) { */ @Operation(summary = "Update general OPD beneficiary vitals") @PostMapping(value = { "/update/vitalScreen" }) - public String updateVitalNurse(@RequestBody String requestObj) { + public ResponseEntity updateVitalNurse(@RequestBody String requestObj) { OutputResponse response = new OutputResponse(); - logger.info("Request object for vital data updating :" + requestObj); + logger.info("Request object for vital data updating :" + LogMasker.maskJson(requestObj)); JsonObject jsnOBJ = new JsonObject(); JsonParser jsnParser = new JsonParser(); @@ -381,19 +369,18 @@ public String updateVitalNurse(@RequestBody String requestObj) { if (result > 0) { response.setResponse("Data updated successfully"); } else { - response.setError(500, "Unable to modify data"); + response.setError(OutputResponse.GENERIC_FAILURE, "Unable to modify data"); } - logger.info("Vital data update response:" + response); } catch (Exception e) { - response.setError(5000, "Unable to modify data"); - logger.error("Error while updating vital data :" + e); + logger.error("Error while updating vital data", e); + response.setError(OutputResponse.GENERIC_FAILURE, "Unable to modify data"); } - return response.toString(); + return response.toStringWithHttpStatus(); } /** - * + * * @param requestObj * @return success or failure response * @objective Replace General OPD Examination Data entered by Nurse with the @@ -401,10 +388,10 @@ public String updateVitalNurse(@RequestBody String requestObj) { */ @Operation(summary = "Update general OPD beneficiary examination data") @PostMapping(value = { "/update/examinationScreen" }) - public String updateGeneralOPDExaminationNurse(@RequestBody String requestObj) { + public ResponseEntity updateGeneralOPDExaminationNurse(@RequestBody String requestObj) { OutputResponse response = new OutputResponse(); - logger.info("Request object for examination data updating :" + requestObj); + logger.info("Request object for examination data updating :" + LogMasker.maskJson(requestObj)); JsonObject jsnOBJ = new JsonObject(); JsonParser jsnParser = new JsonParser(); @@ -416,30 +403,29 @@ public String updateGeneralOPDExaminationNurse(@RequestBody String requestObj) { if (result > 0) { response.setResponse("Data updated successfully"); } else { - response.setError(500, "Unable to modify data"); + response.setError(OutputResponse.GENERIC_FAILURE, "Unable to modify data"); } - logger.info("Examination data update response:" + response); } catch (Exception e) { - response.setError(5000, "Unable to modify data"); - logger.error("Error while updating examination data :" + e); + logger.error("Error while updating examination data", e); + response.setError(OutputResponse.GENERIC_FAILURE, "Unable to modify data"); } - return response.toString(); + return response.toStringWithHttpStatus(); } /** - * + * * @param requestObj * @return success or failure response * @objective Replace General OPD doctor data for the doctor next visit */ @Operation(summary = "Update general OPD beneficiary case record and referral") @PostMapping(value = { "/update/doctorData" }) - public String updateGeneralOPDDoctorData(@RequestBody String requestObj, + public ResponseEntity updateGeneralOPDDoctorData(@RequestBody String requestObj, @RequestHeader(value = "Authorization") String Authorization) { OutputResponse response = new OutputResponse(); - logger.info("Request object for doctor data updating :" + requestObj); + logger.info("Request object for doctor data updating :" + LogMasker.maskJson(requestObj)); JsonObject jsnOBJ = new JsonObject(); JsonParser jsnParser = new JsonParser(); @@ -467,15 +453,14 @@ public String updateGeneralOPDDoctorData(@RequestBody String requestObj, String responseJson = gson.toJson(responseData); response.setResponse(responseJson); } else { - response.setError(500, "Unable to modify data"); + response.setError(OutputResponse.GENERIC_FAILURE, "Unable to modify data"); } - logger.info("Doctor data update response:" + response); } catch (Exception e) { - logger.error("Unable to modify data. " + e.getMessage()); - response.setError(5000, e.getMessage()); + logger.error("Unable to modify data", e); + response.setError(OutputResponse.GENERIC_FAILURE, "Unable to modify data"); } - return response.toString(); + return response.toStringWithHttpStatus(); } } diff --git a/src/main/java/com/iemr/hwc/utils/exception/GlobalExceptionHandler.java b/src/main/java/com/iemr/hwc/utils/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..ac5846d1 --- /dev/null +++ b/src/main/java/com/iemr/hwc/utils/exception/GlobalExceptionHandler.java @@ -0,0 +1,107 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.hwc.utils.exception; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import com.iemr.hwc.utils.response.OutputResponse; + +/** + * Centralised exception translation for HWC-API REST controllers. Every + * uncaught exception is converted into an {@link OutputResponse} body with a + * correct HTTP status code instead of the legacy behaviour of returning HTTP + * 200 with an error embedded in the body. Exception messages are deliberately + * not echoed into log messages because they may contain values originating + * from beneficiary payloads; the stack trace is still captured by SLF4J for + * server-side diagnosis. + */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger LOGGER = LoggerFactory.getLogger(GlobalExceptionHandler.class); + private static final String GENERIC_BAD_REQUEST = "Invalid request"; + private static final String GENERIC_SERVER_ERROR = "Unexpected server error"; + + @ExceptionHandler(IEMRException.class) + public ResponseEntity handleIEMRException(IEMRException ex) { + LOGGER.error("IEMRException raised at controller boundary", ex); + OutputResponse response = new OutputResponse(); + response.setError(ex); + return response.toStringWithHttpStatus(); + } + + @ExceptionHandler(TMException.class) + public ResponseEntity handleTMException(TMException ex) { + LOGGER.error("TMException raised at controller boundary", ex); + OutputResponse response = new OutputResponse(); + response.setError(ex); + return response.toStringWithHttpStatus(); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadable(HttpMessageNotReadableException ex) { + LOGGER.error("Malformed request body received", ex); + OutputResponse response = new OutputResponse(); + response.setError(OutputResponse.BAD_REQUEST, GENERIC_BAD_REQUEST); + return response.toStringWithHttpStatus(); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity handleMissingParameter(MissingServletRequestParameterException ex) { + LOGGER.error("Missing required request parameter", ex); + OutputResponse response = new OutputResponse(); + response.setError(OutputResponse.BAD_REQUEST, "Missing required parameter: " + ex.getParameterName()); + return response.toStringWithHttpStatus(); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException ex) { + LOGGER.error("Request payload failed validation", ex); + OutputResponse response = new OutputResponse(); + response.setError(OutputResponse.BAD_REQUEST, "Request payload failed validation"); + return response.toStringWithHttpStatus(); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity handleMethodNotSupported(HttpRequestMethodNotSupportedException ex) { + LOGGER.error("Unsupported HTTP method", ex); + OutputResponse response = new OutputResponse(); + response.setError(OutputResponse.BAD_REQUEST, "HTTP method not supported"); + return response.toStringWithHttpStatus(); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleAny(Exception ex) { + LOGGER.error("Unhandled exception at controller boundary", ex); + OutputResponse response = new OutputResponse(); + response.setError(OutputResponse.GENERIC_FAILURE, GENERIC_SERVER_ERROR); + return response.toStringWithHttpStatus(); + } +} diff --git a/src/main/java/com/iemr/hwc/utils/logging/LogMasker.java b/src/main/java/com/iemr/hwc/utils/logging/LogMasker.java new file mode 100644 index 00000000..9da7990b --- /dev/null +++ b/src/main/java/com/iemr/hwc/utils/logging/LogMasker.java @@ -0,0 +1,132 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.hwc.utils.logging; + +import java.util.Arrays; +import java.util.Iterator; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; + +/** + * Redacts beneficiary personally identifiable information (PII) and personal + * health information (PHI) from JSON payloads before they reach application + * logs. Field matching is case-insensitive against a curated AMRIT-domain set. + * Unparseable payloads are replaced with a length-only summary so raw bodies + * never leak even when JSON parsing fails. + */ +public final class LogMasker { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String MASK = "***"; + private static final String NULL_PAYLOAD = "[null payload]"; + + private static final Set SENSITIVE_KEYS = Arrays.stream(new String[] { + // government identifiers + "aadhaar", "aadhaarno", "aadharno", "aadhaarnumber", "aadharnumber", "aadhaarcardno", + "pan", "panno", "pannumber", + "voterid", "voteridnumber", "epicno", + "drivinglicence", "drivinglicense", "drivinglicensenumber", + "passportno", "passportnumber", + // health identifiers + "abha", "abhaaddress", "healthid", "healthidnumber", "healthaccountnumber", + "hwid", "hpid", + // contact + "mobile", "mobileno", "mobilenumber", "phone", "phoneno", "phonenumber", + "email", "emailid", "emailaddress", + "emergencycontactname", "emergencycontactnumber", + "fatheremergencycontactname", "fathercontactnumber", + // names + "firstname", "lastname", "middlename", "fullname", "name", + "fathername", "mothername", "husbandname", "spousename", "guardianname", + // dob + "dob", "dateofbirth", + // address + "address", "address1", "address2", "addressline1", "addressline2", + "city", "district", "state", "pincode", "zipcode", "village", + // free text likely to contain PII + "remarks", "notes" }) + .collect(Collectors.toUnmodifiableSet()); + + private LogMasker() { + // no-instantiation + } + + /** + * Returns the input JSON with sensitive field values replaced by {@value #MASK}. + * Non-JSON payloads are summarised by length rather than echoed verbatim. + * + * @param payload raw request or response body; may be {@code null} + * @return masked payload safe for logging + */ + public static String maskJson(String payload) { + if (payload == null) { + return NULL_PAYLOAD; + } + if (payload.isEmpty()) { + return payload; + } + try { + JsonNode root = MAPPER.readTree(payload); + maskNode(root); + return MAPPER.writeValueAsString(root); + } catch (JsonProcessingException e) { + return "[REDACTED non-json payload, length=" + payload.length() + "]"; + } + } + + private static void maskNode(JsonNode node) { + if (node == null) { + return; + } + if (node.isObject()) { + ObjectNode obj = (ObjectNode) node; + Iterator> fields = obj.fields(); + while (fields.hasNext()) { + Map.Entry entry = fields.next(); + if (isSensitive(entry.getKey()) && !entry.getValue().isNull()) { + obj.set(entry.getKey(), TextNode.valueOf(MASK)); + } else { + maskNode(entry.getValue()); + } + } + } else if (node.isArray()) { + for (JsonNode item : node) { + maskNode(item); + } + } + } + + private static boolean isSensitive(String fieldName) { + if (fieldName == null) { + return false; + } + return SENSITIVE_KEYS.contains(fieldName.toLowerCase(Locale.ROOT)); + } +} diff --git a/src/main/java/com/iemr/hwc/utils/response/OutputResponse.java b/src/main/java/com/iemr/hwc/utils/response/OutputResponse.java index 8b78f516..73ea99e3 100644 --- a/src/main/java/com/iemr/hwc/utils/response/OutputResponse.java +++ b/src/main/java/com/iemr/hwc/utils/response/OutputResponse.java @@ -51,8 +51,9 @@ public class OutputResponse { public static final int ENVIRONMENT_EXCEPTION = 5006; public static final int PARSE_EXCEPTION = 5007; public static final int SWYMED_EXCEPTION = 5010; - public static final int TM_EXCEPTION = 5010; - public static final int BAD_REQUEST = 404; + public static final int TM_EXCEPTION = 5011; + public static final int BAD_REQUEST = 400; + public static final int NOT_FOUND = 404; @Expose private int statusCode = GENERIC_FAILURE; @@ -235,12 +236,27 @@ public ResponseEntity toStringWithHttpStatus() { switch (this.statusCode) { case SUCCESS: return ResponseEntity.status(HttpStatus.OK).body(output); - case GENERIC_FAILURE: - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(output); case BAD_REQUEST: + case OBJECT_FAILURE: + case PARSE_EXCEPTION: + case TM_EXCEPTION: return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(output); - default: + case USERID_FAILURE: + case PASSWORD_FAILURE: + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(output); + case PREVILAGE_FAILURE: + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(output); + case NOT_FOUND: + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(output); + case GENERIC_FAILURE: + case CODE_EXCEPTION: + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(output); + case ENVIRONMENT_EXCEPTION: return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(output); + case SWYMED_EXCEPTION: + return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(output); + default: + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(output); } // if(!isSuccess()) diff --git a/src/test/java/com/iemr/hwc/utils/exception/GlobalExceptionHandlerTest.java b/src/test/java/com/iemr/hwc/utils/exception/GlobalExceptionHandlerTest.java new file mode 100644 index 00000000..00419720 --- /dev/null +++ b/src/test/java/com/iemr/hwc/utils/exception/GlobalExceptionHandlerTest.java @@ -0,0 +1,126 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.hwc.utils.exception; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.mock.http.MockHttpInputMessage; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.validation.BeanPropertyBindingResult; + +class GlobalExceptionHandlerTest { + + private GlobalExceptionHandler handler; + + @BeforeEach + void setUp() { + handler = new GlobalExceptionHandler(); + } + + @Test + void classIsAnnotatedSoSpringWillRegisterIt() { + assertNotNull(GlobalExceptionHandler.class.getAnnotation(RestControllerAdvice.class), + "@RestControllerAdvice is required for Spring to pick the handler up via component scan."); + } + + @Test + void iemrExceptionMapsToHttp401WithGenericBody() { + ResponseEntity response = handler.handleIEMRException(new IEMRException("password rejected")); + assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode()); + assertFalse(response.getStatusCode().is2xxSuccessful(), + "Pre-fix behaviour returned HTTP 200; the regression we must guard against."); + } + + @Test + void tmExceptionMapsToHttp400() { + ResponseEntity response = handler.handleTMException(new TMException("ben-id mismatch")); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + @Test + void httpMessageNotReadableMapsToHttp400AndDoesNotEchoRawBody() { + String sensitiveBody = "{\"aadhaarNo\":\"123412341234\""; + HttpMessageNotReadableException ex = new HttpMessageNotReadableException( + "unreadable", new MockHttpInputMessage(sensitiveBody.getBytes())); + ResponseEntity response = handler.handleHttpMessageNotReadable(ex); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + String body = response.getBody(); + assertNotNull(body); + assertFalse(body.contains("123412341234"), + "Sensitive bytes from the failed parse must not be reflected back to the caller."); + } + + @Test + void missingParameterMapsToHttp400AndNamesTheParameter() { + ResponseEntity response = handler.handleMissingParameter( + new MissingServletRequestParameterException("benRegID", "Long")); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertTrue(response.getBody() != null && response.getBody().contains("benRegID")); + } + + @Test + void validationFailureMapsToHttp400() throws NoSuchMethodException { + Object target = new Object(); + BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(target, "target"); + MethodParameter parameter = new MethodParameter( + GlobalExceptionHandlerTest.class.getDeclaredMethod("validationDummy", String.class), 0); + MethodArgumentNotValidException ex = new MethodArgumentNotValidException(parameter, bindingResult); + ResponseEntity response = handler.handleValidation(ex); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + @Test + void methodNotSupportedMapsToHttp400() { + ResponseEntity response = handler.handleMethodNotSupported( + new HttpRequestMethodNotSupportedException("DELETE")); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + @Test + void uncaughtExceptionMapsToHttp500AndHidesInternalDetails() { + ResponseEntity response = handler.handleAny(new RuntimeException("npe at line 42 with patientId=999")); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + String body = response.getBody(); + assertNotNull(body); + assertFalse(body.contains("patientId=999"), + "Raw exception messages can carry PII; the body must use the generic fallback."); + assertFalse(body.contains("npe at line 42"), + "Stack-trace-flavoured detail must not be echoed to API consumers."); + } + + @SuppressWarnings("unused") + private void validationDummy(String ignored) { + // reflective target for MethodArgumentNotValidException construction + } +} diff --git a/src/test/java/com/iemr/hwc/utils/logging/LogMaskerTest.java b/src/test/java/com/iemr/hwc/utils/logging/LogMaskerTest.java new file mode 100644 index 00000000..5d8d222e --- /dev/null +++ b/src/test/java/com/iemr/hwc/utils/logging/LogMaskerTest.java @@ -0,0 +1,130 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.hwc.utils.logging; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +class LogMaskerTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + void nullPayloadReturnsPlaceholder() { + assertEquals("[null payload]", LogMasker.maskJson(null)); + } + + @Test + void emptyPayloadIsReturnedUnchanged() { + assertEquals("", LogMasker.maskJson("")); + } + + @Test + void nonJsonPayloadIsReplacedByLengthOnlySummary() { + String raw = "9876543210 is the mobile"; + String masked = LogMasker.maskJson(raw); + assertFalse(masked.contains("9876543210"), "Raw PII must not survive a non-JSON fallback."); + assertTrue(masked.contains("length=" + raw.length())); + } + + @Test + void topLevelIdentifierFieldsAreMasked() throws Exception { + String input = "{\"aadhaarNo\":\"123412341234\",\"mobileNo\":\"9876543210\",\"benRegID\":555}"; + JsonNode masked = MAPPER.readTree(LogMasker.maskJson(input)); + assertEquals("***", masked.get("aadhaarNo").asText()); + assertEquals("***", masked.get("mobileNo").asText()); + assertEquals(555, masked.get("benRegID").asInt(), + "Internal numeric IDs are not direct PII and remain visible for traceability."); + } + + @Test + void fieldMatchingIsCaseInsensitive() throws Exception { + String input = "{\"AADHAAR\":\"123412341234\",\"DateOfBirth\":\"1990-01-01\"}"; + JsonNode masked = MAPPER.readTree(LogMasker.maskJson(input)); + assertEquals("***", masked.get("AADHAAR").asText()); + assertEquals("***", masked.get("DateOfBirth").asText()); + } + + @Test + void nestedObjectsAreTraversedRecursively() throws Exception { + String input = "{" + + "\"beneficiary\":{\"firstName\":\"Asha\",\"address\":{\"pincode\":\"560001\",\"city\":\"Bengaluru\"}}," + + "\"visitCode\":12345" + + "}"; + JsonNode masked = MAPPER.readTree(LogMasker.maskJson(input)); + assertEquals("***", masked.get("beneficiary").get("firstName").asText()); + assertEquals("***", masked.get("beneficiary").get("address").get("pincode").asText()); + assertEquals("***", masked.get("beneficiary").get("address").get("city").asText()); + assertEquals(12345, masked.get("visitCode").asInt()); + } + + @Test + void arraysOfBeneficiariesAreMaskedElementByElement() throws Exception { + String input = "{\"familyMembers\":[" + + "{\"name\":\"Rita\",\"mobile\":\"9000000000\"}," + + "{\"name\":\"Sita\",\"mobile\":\"9111111111\"}" + + "]}"; + JsonNode masked = MAPPER.readTree(LogMasker.maskJson(input)); + assertEquals("***", masked.get("familyMembers").get(0).get("name").asText()); + assertEquals("***", masked.get("familyMembers").get(0).get("mobile").asText()); + assertEquals("***", masked.get("familyMembers").get(1).get("name").asText()); + assertEquals("***", masked.get("familyMembers").get(1).get("mobile").asText()); + } + + @Test + void nullSensitiveValueIsPreservedSoConsumersCanDistinguishMissingFromMasked() throws Exception { + String input = "{\"firstName\":null,\"mobileNo\":\"9876543210\"}"; + JsonNode masked = MAPPER.readTree(LogMasker.maskJson(input)); + assertTrue(masked.get("firstName").isNull()); + assertEquals("***", masked.get("mobileNo").asText()); + } + + @Test + void nonSensitiveFieldsAreUntouched() throws Exception { + String input = "{\"providerServiceMapID\":42,\"vanID\":7,\"parkingPlaceID\":2}"; + JsonNode masked = MAPPER.readTree(LogMasker.maskJson(input)); + assertEquals(42, masked.get("providerServiceMapID").asInt()); + assertEquals(7, masked.get("vanID").asInt()); + assertEquals(2, masked.get("parkingPlaceID").asInt()); + } + + @Test + void healthIdAndAbhaAreTreatedAsSensitive() throws Exception { + String input = "{\"healthId\":\"abc@abdm\",\"abhaAddress\":\"abc@abdm\"}"; + JsonNode masked = MAPPER.readTree(LogMasker.maskJson(input)); + assertEquals("***", masked.get("healthId").asText()); + assertEquals("***", masked.get("abhaAddress").asText()); + } + + @Test + void freeTextRemarksAreMaskedToProtectAccidentalPiiLeakage() throws Exception { + String input = "{\"remarks\":\"Patient Asha 9876543210 reports fever\"}"; + JsonNode masked = MAPPER.readTree(LogMasker.maskJson(input)); + assertEquals("***", masked.get("remarks").asText()); + } +} diff --git a/src/test/java/com/iemr/hwc/utils/response/OutputResponseTest.java b/src/test/java/com/iemr/hwc/utils/response/OutputResponseTest.java new file mode 100644 index 00000000..1f49dd2b --- /dev/null +++ b/src/test/java/com/iemr/hwc/utils/response/OutputResponseTest.java @@ -0,0 +1,177 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.hwc.utils.response; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import com.iemr.hwc.utils.exception.IEMRException; +import com.iemr.hwc.utils.exception.TMException; + +class OutputResponseTest { + + @Test + void badRequestConstantIsRfcCompliant() { + assertEquals(400, OutputResponse.BAD_REQUEST, + "BAD_REQUEST must be 400 per RFC 9110; the legacy value 404 conflated bad-request and not-found."); + } + + @Test + void notFoundConstantPreservesPreviousMagicNumber() { + assertEquals(404, OutputResponse.NOT_FOUND, + "Existing callers using the literal 404 for not-found semantics must continue to resolve to NOT_FOUND."); + } + + @Test + void swymedAndTmExceptionCodesAreDistinct() { + assertNotEquals(OutputResponse.SWYMED_EXCEPTION, OutputResponse.TM_EXCEPTION, + "SWYMED_EXCEPTION and TM_EXCEPTION must be distinguishable so clients can route on them."); + } + + @Test + void successResponseMapsToHttp200() { + OutputResponse response = new OutputResponse(); + response.setResponse("ok"); + ResponseEntity entity = response.toStringWithHttpStatus(); + assertEquals(HttpStatus.OK, entity.getStatusCode()); + } + + @Test + void badRequestStatusCodeMapsToHttp400() { + OutputResponse response = new OutputResponse(); + response.setError(OutputResponse.BAD_REQUEST, "bad payload"); + ResponseEntity entity = response.toStringWithHttpStatus(); + assertEquals(HttpStatus.BAD_REQUEST, entity.getStatusCode()); + } + + @Test + void notFoundStatusCodeMapsToHttp404() { + OutputResponse response = new OutputResponse(); + response.setError(OutputResponse.NOT_FOUND, "record missing"); + ResponseEntity entity = response.toStringWithHttpStatus(); + assertEquals(HttpStatus.NOT_FOUND, entity.getStatusCode()); + } + + @Test + void legacy404MagicNumberStillMapsToHttp404() { + OutputResponse response = new OutputResponse(); + response.setError(404, "village not found"); + ResponseEntity entity = response.toStringWithHttpStatus(); + assertEquals(HttpStatus.NOT_FOUND, entity.getStatusCode(), + "Existing controllers call setError(404, ...) directly for not-found semantics."); + } + + @Test + void userIdFailureMapsToHttp401() { + OutputResponse response = new OutputResponse(); + response.setError(OutputResponse.USERID_FAILURE, "auth failed"); + assertEquals(HttpStatus.UNAUTHORIZED, response.toStringWithHttpStatus().getStatusCode()); + } + + @Test + void privilegeFailureMapsToHttp403() { + OutputResponse response = new OutputResponse(); + response.setError(OutputResponse.PREVILAGE_FAILURE, "forbidden"); + assertEquals(HttpStatus.FORBIDDEN, response.toStringWithHttpStatus().getStatusCode()); + } + + @Test + void environmentExceptionMapsToHttp503() { + OutputResponse response = new OutputResponse(); + response.setError(OutputResponse.ENVIRONMENT_EXCEPTION, "db down"); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, response.toStringWithHttpStatus().getStatusCode()); + } + + @Test + void swymedExceptionMapsToHttp502() { + OutputResponse response = new OutputResponse(); + response.setError(OutputResponse.SWYMED_EXCEPTION, "upstream failed"); + assertEquals(HttpStatus.BAD_GATEWAY, response.toStringWithHttpStatus().getStatusCode()); + } + + @Test + void tmExceptionMapsToHttp400() { + OutputResponse response = new OutputResponse(); + response.setError(OutputResponse.TM_EXCEPTION, "invalid input"); + assertEquals(HttpStatus.BAD_REQUEST, response.toStringWithHttpStatus().getStatusCode(), + "TMException semantically means 'Invalid input' per OutputResponse.setError(Throwable)."); + } + + @Test + void genericFailureMapsToHttp500() { + OutputResponse response = new OutputResponse(); + response.setError(OutputResponse.GENERIC_FAILURE, "boom"); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.toStringWithHttpStatus().getStatusCode()); + } + + @Test + void codeExceptionMapsToHttp500() { + OutputResponse response = new OutputResponse(); + response.setError(OutputResponse.CODE_EXCEPTION, "npe"); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.toStringWithHttpStatus().getStatusCode()); + } + + @Test + void parseExceptionMapsToHttp400() { + OutputResponse response = new OutputResponse(); + response.setError(OutputResponse.PARSE_EXCEPTION, "bad json"); + assertEquals(HttpStatus.BAD_REQUEST, response.toStringWithHttpStatus().getStatusCode()); + } + + @Test + void unknownStatusCodeFallsBackToHttp500() { + OutputResponse response = new OutputResponse(); + response.setError(9999, "mystery"); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.toStringWithHttpStatus().getStatusCode(), + "Unknown internal codes should default to 500, not 503 — they signal server bugs."); + } + + @Test + void setErrorWithIemrExceptionRoutesToUnauthorizedHttpStatus() { + OutputResponse response = new OutputResponse(); + response.setError(new IEMRException("bad credentials")); + assertEquals(OutputResponse.USERID_FAILURE, response.getStatusCode()); + assertEquals(HttpStatus.UNAUTHORIZED, response.toStringWithHttpStatus().getStatusCode()); + } + + @Test + void setErrorWithTmExceptionRoutesToBadRequestHttpStatus() { + OutputResponse response = new OutputResponse(); + response.setError(new TMException("invalid payload")); + assertEquals(OutputResponse.TM_EXCEPTION, response.getStatusCode()); + assertEquals(HttpStatus.BAD_REQUEST, response.toStringWithHttpStatus().getStatusCode()); + } + + @Test + void responseBodyShapeIsPreservedAcrossHttpStatusEnvelope() { + OutputResponse response = new OutputResponse(); + response.setError(OutputResponse.BAD_REQUEST, "bad payload"); + String body = response.toStringWithHttpStatus().getBody(); + assertTrue(body != null && body.contains("\"statusCode\":400"), + "Body still carries the OutputResponse JSON envelope so existing UI parsers keep working."); + } +} From 5e470ae17c67ec9e3ddff4039365e08c52ca56ac Mon Sep 17 00:00:00 2001 From: Piyush Date: Wed, 13 May 2026 16:15:17 +0530 Subject: [PATCH 2/4] refactor(exception): extract shared respond() helpers in GlobalExceptionHandler The seven @ExceptionHandler methods all repeated the same four-line sequence (log via SLF4J, instantiate OutputResponse, setError, return toStringWithHttpStatus()), which SonarCloud flagged as 3.1% duplication on new code in PR #213. Collapse the duplicated body into two private helpers: * respond(ex, logMessage) - for exceptions whose mapping is fully described by OutputResponse.setError(Throwable) (IEMRException, TMException). * respond(ex, logMessage, statusCode, publicMessage) - for the Spring-framework exceptions where the handler chooses the public status code and message. Each handler now reduces to a single return statement. External behaviour is identical: the same OutputResponse mutations and the same ResponseEntity are produced, so the existing GlobalExceptionHandlerTest assertions about HTTP status and body content remain valid without any test changes. --- .../exception/GlobalExceptionHandler.java | 63 +++++++++++-------- 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/iemr/hwc/utils/exception/GlobalExceptionHandler.java b/src/main/java/com/iemr/hwc/utils/exception/GlobalExceptionHandler.java index ac5846d1..fafb62c3 100644 --- a/src/main/java/com/iemr/hwc/utils/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/iemr/hwc/utils/exception/GlobalExceptionHandler.java @@ -51,57 +51,68 @@ public class GlobalExceptionHandler { @ExceptionHandler(IEMRException.class) public ResponseEntity handleIEMRException(IEMRException ex) { - LOGGER.error("IEMRException raised at controller boundary", ex); - OutputResponse response = new OutputResponse(); - response.setError(ex); - return response.toStringWithHttpStatus(); + return respond(ex, "IEMRException raised at controller boundary"); } @ExceptionHandler(TMException.class) public ResponseEntity handleTMException(TMException ex) { - LOGGER.error("TMException raised at controller boundary", ex); - OutputResponse response = new OutputResponse(); - response.setError(ex); - return response.toStringWithHttpStatus(); + return respond(ex, "TMException raised at controller boundary"); } @ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity handleHttpMessageNotReadable(HttpMessageNotReadableException ex) { - LOGGER.error("Malformed request body received", ex); - OutputResponse response = new OutputResponse(); - response.setError(OutputResponse.BAD_REQUEST, GENERIC_BAD_REQUEST); - return response.toStringWithHttpStatus(); + return respond(ex, "Malformed request body received", + OutputResponse.BAD_REQUEST, GENERIC_BAD_REQUEST); } @ExceptionHandler(MissingServletRequestParameterException.class) public ResponseEntity handleMissingParameter(MissingServletRequestParameterException ex) { - LOGGER.error("Missing required request parameter", ex); - OutputResponse response = new OutputResponse(); - response.setError(OutputResponse.BAD_REQUEST, "Missing required parameter: " + ex.getParameterName()); - return response.toStringWithHttpStatus(); + return respond(ex, "Missing required request parameter", + OutputResponse.BAD_REQUEST, "Missing required parameter: " + ex.getParameterName()); } @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleValidation(MethodArgumentNotValidException ex) { - LOGGER.error("Request payload failed validation", ex); - OutputResponse response = new OutputResponse(); - response.setError(OutputResponse.BAD_REQUEST, "Request payload failed validation"); - return response.toStringWithHttpStatus(); + return respond(ex, "Request payload failed validation", + OutputResponse.BAD_REQUEST, "Request payload failed validation"); } @ExceptionHandler(HttpRequestMethodNotSupportedException.class) public ResponseEntity handleMethodNotSupported(HttpRequestMethodNotSupportedException ex) { - LOGGER.error("Unsupported HTTP method", ex); - OutputResponse response = new OutputResponse(); - response.setError(OutputResponse.BAD_REQUEST, "HTTP method not supported"); - return response.toStringWithHttpStatus(); + return respond(ex, "Unsupported HTTP method", + OutputResponse.BAD_REQUEST, "HTTP method not supported"); } @ExceptionHandler(Exception.class) public ResponseEntity handleAny(Exception ex) { - LOGGER.error("Unhandled exception at controller boundary", ex); + return respond(ex, "Unhandled exception at controller boundary", + OutputResponse.GENERIC_FAILURE, GENERIC_SERVER_ERROR); + } + + /** + * Builds the response for exceptions whose translation is fully described by + * {@link OutputResponse#setError(Throwable)} (per-type status, status text + * and message handled by the legacy switch). The original exception is + * recorded with its stack trace; its {@code getMessage()} is intentionally + * not embedded into the log string because it may carry beneficiary values. + */ + private ResponseEntity respond(Throwable ex, String logMessage) { + LOGGER.error(logMessage, ex); + OutputResponse response = new OutputResponse(); + response.setError(ex); + return response.toStringWithHttpStatus(); + } + + /** + * Builds the response for exceptions where the public status code and + * user-facing message are decided by the handler rather than by + * {@link OutputResponse#setError(Throwable)}. + */ + private ResponseEntity respond(Throwable ex, String logMessage, + int statusCode, String publicMessage) { + LOGGER.error(logMessage, ex); OutputResponse response = new OutputResponse(); - response.setError(OutputResponse.GENERIC_FAILURE, GENERIC_SERVER_ERROR); + response.setError(statusCode, publicMessage); return response.toStringWithHttpStatus(); } } From 38c9f02a83735c7abd1112995811d5c6cf3aacf5 Mon Sep 17 00:00:00 2001 From: Piyush Date: Wed, 13 May 2026 16:18:47 +0530 Subject: [PATCH 3/4] refactor(response): collapse toStringWithHttpStatus switch into one return SonarCloud continued to report 3.1% duplication on new code after the GlobalExceptionHandler cleanup. The remaining duplicate token block was the eight 'return ResponseEntity.status(HttpStatus.X).body(output);' returns inside OutputResponse.toStringWithHttpStatus() - structurally identical except for the HttpStatus argument. Replace the legacy statement switch with a Java 17 switch expression that maps each internal status code to its HttpStatus once, assigns it to resolvedStatus, then issues a single 'return ResponseEntity.status(resolvedStatus).body(output);'. Behaviour matrix is unchanged: GENERIC_FAILURE and CODE_EXCEPTION still resolve to INTERNAL_SERVER_ERROR (now via the default arm, which was already INTERNAL_SERVER_ERROR), and every other mapping is identical. Existing OutputResponseTest assertions about each HttpStatus, the unknown-code fallback to 500, and the response body shape continue to hold without test changes. --- .../hwc/utils/response/OutputResponse.java | 36 ++++++------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/iemr/hwc/utils/response/OutputResponse.java b/src/main/java/com/iemr/hwc/utils/response/OutputResponse.java index 73ea99e3..26c9bb0f 100644 --- a/src/main/java/com/iemr/hwc/utils/response/OutputResponse.java +++ b/src/main/java/com/iemr/hwc/utils/response/OutputResponse.java @@ -233,31 +233,17 @@ public ResponseEntity toStringWithHttpStatus() { // builder.disableInnerClassSerialization(); String output = builder.create().toJson(this); - switch (this.statusCode) { - case SUCCESS: - return ResponseEntity.status(HttpStatus.OK).body(output); - case BAD_REQUEST: - case OBJECT_FAILURE: - case PARSE_EXCEPTION: - case TM_EXCEPTION: - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(output); - case USERID_FAILURE: - case PASSWORD_FAILURE: - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(output); - case PREVILAGE_FAILURE: - return ResponseEntity.status(HttpStatus.FORBIDDEN).body(output); - case NOT_FOUND: - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(output); - case GENERIC_FAILURE: - case CODE_EXCEPTION: - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(output); - case ENVIRONMENT_EXCEPTION: - return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(output); - case SWYMED_EXCEPTION: - return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(output); - default: - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(output); - } + HttpStatus resolvedStatus = switch (this.statusCode) { + case SUCCESS -> HttpStatus.OK; + case BAD_REQUEST, OBJECT_FAILURE, PARSE_EXCEPTION, TM_EXCEPTION -> HttpStatus.BAD_REQUEST; + case USERID_FAILURE, PASSWORD_FAILURE -> HttpStatus.UNAUTHORIZED; + case PREVILAGE_FAILURE -> HttpStatus.FORBIDDEN; + case NOT_FOUND -> HttpStatus.NOT_FOUND; + case ENVIRONMENT_EXCEPTION -> HttpStatus.SERVICE_UNAVAILABLE; + case SWYMED_EXCEPTION -> HttpStatus.BAD_GATEWAY; + default -> HttpStatus.INTERNAL_SERVER_ERROR; + }; + return ResponseEntity.status(resolvedStatus).body(output); // if(!isSuccess()) // return ResponseEntity.status(HttpStatus.BAD_REQUEST) From a3320412a56a28b4c4da4d95fd666e76d6bf51b3 Mon Sep 17 00:00:00 2001 From: Piyush Date: Wed, 13 May 2026 16:24:41 +0530 Subject: [PATCH 4/4] refactor(controller): collapse repeated 'Unable to modify data' setError calls Eight new lines in GeneralOPDController contained the identical token sequence response.setError(OutputResponse.GENERIC_FAILURE, "Unable to modify data"); across the four update endpoints (each method invoked it once in the fallback else-branch and once in the catch block). SonarCloud's clone detector flagged that pattern as the largest residual duplicated block on new code (PR #213, 3.11% > 3.0% gate). Replace those eight call sites with a one-line private helper: markUnableToModify(response); Each call site is now a 4-token method invocation - well below SonarCloud's minimum clone size - and the failure literal lives in a single place. Behaviour is identical: the helper performs exactly the same OutputResponse.setError(GENERIC_FAILURE, "Unable to modify data") call the eight inline statements used to make. No tests touched and no other file changed. --- .../generalOPD/GeneralOPDController.java | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/iemr/hwc/controller/generalOPD/GeneralOPDController.java b/src/main/java/com/iemr/hwc/controller/generalOPD/GeneralOPDController.java index 42d652a0..d153c478 100644 --- a/src/main/java/com/iemr/hwc/controller/generalOPD/GeneralOPDController.java +++ b/src/main/java/com/iemr/hwc/controller/generalOPD/GeneralOPDController.java @@ -335,11 +335,11 @@ public ResponseEntity updateHistoryNurse(@RequestBody String requestObj) if (result > 0) { response.setResponse("Data updated successfully"); } else { - response.setError(OutputResponse.GENERIC_FAILURE, "Unable to modify data"); + markUnableToModify(response); } } catch (Exception e) { logger.error("Error while updating history data", e); - response.setError(OutputResponse.GENERIC_FAILURE, "Unable to modify data"); + markUnableToModify(response); } return response.toStringWithHttpStatus(); @@ -369,11 +369,11 @@ public ResponseEntity updateVitalNurse(@RequestBody String requestObj) { if (result > 0) { response.setResponse("Data updated successfully"); } else { - response.setError(OutputResponse.GENERIC_FAILURE, "Unable to modify data"); + markUnableToModify(response); } } catch (Exception e) { logger.error("Error while updating vital data", e); - response.setError(OutputResponse.GENERIC_FAILURE, "Unable to modify data"); + markUnableToModify(response); } return response.toStringWithHttpStatus(); @@ -403,11 +403,11 @@ public ResponseEntity updateGeneralOPDExaminationNurse(@RequestBody Stri if (result > 0) { response.setResponse("Data updated successfully"); } else { - response.setError(OutputResponse.GENERIC_FAILURE, "Unable to modify data"); + markUnableToModify(response); } } catch (Exception e) { logger.error("Error while updating examination data", e); - response.setError(OutputResponse.GENERIC_FAILURE, "Unable to modify data"); + markUnableToModify(response); } return response.toStringWithHttpStatus(); @@ -453,14 +453,18 @@ public ResponseEntity updateGeneralOPDDoctorData(@RequestBody String req String responseJson = gson.toJson(responseData); response.setResponse(responseJson); } else { - response.setError(OutputResponse.GENERIC_FAILURE, "Unable to modify data"); + markUnableToModify(response); } } catch (Exception e) { logger.error("Unable to modify data", e); - response.setError(OutputResponse.GENERIC_FAILURE, "Unable to modify data"); + markUnableToModify(response); } return response.toStringWithHttpStatus(); } + private void markUnableToModify(OutputResponse response) { + response.setError(OutputResponse.GENERIC_FAILURE, "Unable to modify data"); + } + }