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..d153c478 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"); + markUnableToModify(response); } - 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); + markUnableToModify(response); } - 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"); + markUnableToModify(response); } - 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); + markUnableToModify(response); } - 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"); + markUnableToModify(response); } - 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); + markUnableToModify(response); } - 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,18 @@ public String updateGeneralOPDDoctorData(@RequestBody String requestObj, String responseJson = gson.toJson(responseData); response.setResponse(responseJson); } else { - response.setError(500, "Unable to modify data"); + markUnableToModify(response); } - 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); + markUnableToModify(response); } - return response.toString(); + return response.toStringWithHttpStatus(); + } + + private void markUnableToModify(OutputResponse response) { + response.setError(OutputResponse.GENERIC_FAILURE, "Unable to modify data"); } } 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..fafb62c3 --- /dev/null +++ b/src/main/java/com/iemr/hwc/utils/exception/GlobalExceptionHandler.java @@ -0,0 +1,118 @@ +/* +* 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) { + return respond(ex, "IEMRException raised at controller boundary"); + } + + @ExceptionHandler(TMException.class) + public ResponseEntity handleTMException(TMException ex) { + return respond(ex, "TMException raised at controller boundary"); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadable(HttpMessageNotReadableException ex) { + return respond(ex, "Malformed request body received", + OutputResponse.BAD_REQUEST, GENERIC_BAD_REQUEST); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity handleMissingParameter(MissingServletRequestParameterException ex) { + return respond(ex, "Missing required request parameter", + OutputResponse.BAD_REQUEST, "Missing required parameter: " + ex.getParameterName()); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException ex) { + return respond(ex, "Request payload failed validation", + OutputResponse.BAD_REQUEST, "Request payload failed validation"); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity handleMethodNotSupported(HttpRequestMethodNotSupportedException ex) { + return respond(ex, "Unsupported HTTP method", + OutputResponse.BAD_REQUEST, "HTTP method not supported"); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleAny(Exception 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(statusCode, publicMessage); + 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..26c9bb0f 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; @@ -232,16 +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 GENERIC_FAILURE: - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(output); - case BAD_REQUEST: - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(output); - default: - return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).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) 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."); + } +}