diff --git a/src/main/environment/bengen_docker.properties b/src/main/environment/bengen_docker.properties index 06e8fe8..06755fc 100644 --- a/src/main/environment/bengen_docker.properties +++ b/src/main/environment/bengen_docker.properties @@ -37,6 +37,9 @@ bengen.pool-watcher-initial-delay-ms=60000 ### Health API warns (status DEGRADED) when available Ben IDs fall below this health.min-available-beneficiary-ids=5000 +### Health API warns (status DEGRADED) when available Ben IDs fall below this +health.min-available-beneficiary-ids=5000 + ### Redis IP spring.redis.host=${REDIS_HOST} diff --git a/src/main/environment/bengen_example.properties b/src/main/environment/bengen_example.properties index e3d7027..7b566db 100644 --- a/src/main/environment/bengen_example.properties +++ b/src/main/environment/bengen_example.properties @@ -16,6 +16,9 @@ start-bengen-scheduler=true # To Run scheduler Every Day cron-scheduler-bengen=0 1 0 * * ? * +# To Run scheduler Every Day +# cron-scheduler-bengen=0 1 0 * * ? * + # To Run scheduler Every Minute #cron-scheduler-bengen=0 0/1 * * * ? * @@ -37,6 +40,9 @@ bengen.pool-watcher-initial-delay-ms=60000 ### Health API warns (status DEGRADED) when available Ben IDs fall below this health.min-available-beneficiary-ids=5000 +### Health API warns (status DEGRADED) when available Ben IDs fall below this +health.min-available-beneficiary-ids=5000 + ### Redis IP spring.redis.host=localhost jwt.secret=my-32-character-ultra-secure-and-ultra-long-secret diff --git a/src/test/java/com/iemr/common/bengen/BeneficiaryGenApplicationTest.java b/src/test/java/com/iemr/common/bengen/BeneficiaryGenApplicationTest.java new file mode 100644 index 0000000..574d51e --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/BeneficiaryGenApplicationTest.java @@ -0,0 +1,97 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.RestTemplate; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@ExtendWith(MockitoExtension.class) +@DisplayName("BeneficiaryGenApplication Test Suite") +class BeneficiaryGenApplicationTest { + + @Mock + private RedisConnectionFactory connectionFactory; + + private BeneficiaryGenApplication application; + + @BeforeEach + @DisplayName("Create the application configuration before each test") + void setUp() { + application = new BeneficiaryGenApplication(); + } + + @Test + @DisplayName("restTemplate should supply a RestTemplate bean for outbound calls") + void restTemplate_shouldSupplyRestTemplateBean() { + RestTemplate restTemplate = application.restTemplate(); + + assertNotNull(restTemplate); + } + + @Test + @DisplayName("redisTemplate should bind the supplied connection factory") + void redisTemplate_shouldBindSuppliedConnectionFactory() { + RedisTemplate template = application.redisTemplate(connectionFactory); + + assertNotNull(template); + assertSame(connectionFactory, template.getConnectionFactory()); + } + + @Test + @DisplayName("redisTemplate should serialise keys as plain strings and values as User JSON") + void redisTemplate_shouldSerialiseKeysAsStringsAndValuesAsJson() { + RedisTemplate template = application.redisTemplate(connectionFactory); + + assertTrue(template.getKeySerializer() instanceof StringRedisSerializer); + assertTrue(template.getValueSerializer() instanceof Jackson2JsonRedisSerializer); + } + + @Test + @DisplayName("configure should register the application class as the WAR deployment source") + void configure_shouldRegisterApplicationClassAsSource() { + SpringApplicationBuilder builder = new SpringApplicationBuilder(); + + SpringApplicationBuilder configured = application.configure(builder); + + assertSame(builder, configured, "configure should keep building on the supplied builder"); + // SpringApplicationBuilder stages sources until build(), so read them back directly. + Set> sources = (Set>) ReflectionTestUtils.getField(configured, "sources"); + assertTrue(sources.contains(BeneficiaryGenApplication.class)); + } +} diff --git a/src/test/java/com/iemr/common/bengen/config/CorsConfigTest.java b/src/test/java/com/iemr/common/bengen/config/CorsConfigTest.java new file mode 100644 index 0000000..8f72d73 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/config/CorsConfigTest.java @@ -0,0 +1,112 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.config; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@ExtendWith(MockitoExtension.class) +@DisplayName("CorsConfig Test Suite") +class CorsConfigTest { + + private static final String ALLOWED_ORIGINS = "https://amrit.example.org, http://localhost:*"; + + private CorsConfig corsConfig; + + @BeforeEach + @DisplayName("Configure the allow-list before each test") + void setUp() { + corsConfig = new CorsConfig(); + ReflectionTestUtils.setField(corsConfig, "allowedOrigins", ALLOWED_ORIGINS); + } + + // CorsRegistry.getCorsConfigurations() is protected, so read the registrations + // back reflectively — the same map Spring MVC itself consumes. + @SuppressWarnings("unchecked") + private Map corsConfigurations(CorsRegistry registry) { + return (Map) + ReflectionTestUtils.invokeMethod(registry, "getCorsConfigurations"); + } + + @Test + @DisplayName("addCorsMappings should register a mapping for every path") + void addCorsMappings_shouldRegisterMappingForEveryPath() { + CorsRegistry registry = new CorsRegistry(); + + corsConfig.addCorsMappings(registry); + + Map configurations = corsConfigurations(registry); + assertEquals(1, configurations.size()); + assertNotNull(configurations.get("/**")); + } + + @Test + @DisplayName("addCorsMappings should trim and register each configured origin pattern") + void addCorsMappings_shouldTrimAndRegisterOriginPatterns() { + CorsRegistry registry = new CorsRegistry(); + + corsConfig.addCorsMappings(registry); + + CorsConfiguration configuration = corsConfigurations(registry).get("/**"); + assertEquals(java.util.List.of("https://amrit.example.org", "http://localhost:*"), + configuration.getAllowedOriginPatterns()); + } + + @Test + @DisplayName("addCorsMappings should permit the documented methods and headers with credentials") + void addCorsMappings_shouldPermitDocumentedMethodsAndHeaders() { + CorsRegistry registry = new CorsRegistry(); + + corsConfig.addCorsMappings(registry); + + CorsConfiguration configuration = corsConfigurations(registry).get("/**"); + assertEquals(java.util.List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"), + configuration.getAllowedMethods()); + assertTrue(configuration.getAllowedHeaders().contains("Jwttoken")); + assertTrue(configuration.getExposedHeaders().contains("Authorization")); + assertTrue(configuration.getExposedHeaders().contains("Jwttoken")); + assertEquals(Boolean.TRUE, configuration.getAllowCredentials()); + assertEquals(3600L, configuration.getMaxAge()); + } + + @Test + @DisplayName("addCorsMappings should fail fast when no origin allow-list is configured") + void addCorsMappings_shouldFailFastWithoutAllowList() { + ReflectionTestUtils.setField(corsConfig, "allowedOrigins", null); + CorsRegistry registry = new CorsRegistry(); + + assertThrows(NullPointerException.class, () -> corsConfig.addCorsMappings(registry)); + } +} diff --git a/src/test/java/com/iemr/common/bengen/config/InterceptorConfigTest.java b/src/test/java/com/iemr/common/bengen/config/InterceptorConfigTest.java new file mode 100644 index 0000000..708ca3d --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/config/InterceptorConfigTest.java @@ -0,0 +1,67 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.config; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; + +import com.iemr.common.bengen.utils.http.HTTPRequestInterceptor; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +@ExtendWith(MockitoExtension.class) +@DisplayName("InterceptorConfig Test Suite") +class InterceptorConfigTest { + + @Mock + private HTTPRequestInterceptor requestInterceptor; + + private InterceptorConfig interceptorConfig; + + @BeforeEach + @DisplayName("Wire the configuration with a mocked interceptor before each test") + void setUp() { + interceptorConfig = new InterceptorConfig(); + ReflectionTestUtils.setField(interceptorConfig, "requestInterceptor", requestInterceptor); + } + + @Test + @DisplayName("addInterceptors should register the HTTP request interceptor exactly once") + void addInterceptors_shouldRegisterRequestInterceptorOnce() { + InterceptorRegistry registry = new InterceptorRegistry(); + + interceptorConfig.addInterceptors(registry); + + List interceptors = (List) ReflectionTestUtils.invokeMethod(registry, "getInterceptors"); + assertEquals(1, interceptors.size()); + assertSame(requestInterceptor, interceptors.get(0)); + } +} diff --git a/src/test/java/com/iemr/common/bengen/config/RedisConfigTest.java b/src/test/java/com/iemr/common/bengen/config/RedisConfigTest.java new file mode 100644 index 0000000..59b3d80 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/config/RedisConfigTest.java @@ -0,0 +1,80 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.config; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.session.data.redis.config.ConfigureRedisAction; + +import com.iemr.common.bengen.data.user.User; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RedisConfig Test Suite") +class RedisConfigTest { + + @Mock + private RedisConnectionFactory connectionFactory; + + private RedisConfig redisConfig; + + @BeforeEach + @DisplayName("Create the configuration before each test") + void setUp() { + redisConfig = new RedisConfig(); + } + + @Test + @DisplayName("configureRedisAction should disable Spring Session's CONFIG command probing") + void configureRedisAction_shouldDisableConfigCommandProbing() { + assertSame(ConfigureRedisAction.NO_OP, redisConfig.configureRedisAction()); + } + + @Test + @DisplayName("redisTemplate should bind the supplied connection factory") + void redisTemplate_shouldBindSuppliedConnectionFactory() { + RedisTemplate template = redisConfig.redisTemplate(connectionFactory); + + assertNotNull(template); + assertSame(connectionFactory, template.getConnectionFactory()); + } + + @Test + @DisplayName("redisTemplate should serialise keys as plain strings and values as User JSON") + void redisTemplate_shouldSerialiseKeysAsStringsAndValuesAsJson() { + RedisTemplate template = redisConfig.redisTemplate(connectionFactory); + + assertTrue(template.getKeySerializer() instanceof StringRedisSerializer); + assertTrue(template.getValueSerializer() instanceof Jackson2JsonRedisSerializer); + } +} diff --git a/src/test/java/com/iemr/common/bengen/config/SwaggerConfigTest.java b/src/test/java/com/iemr/common/bengen/config/SwaggerConfigTest.java new file mode 100644 index 0000000..c3a82e4 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/config/SwaggerConfigTest.java @@ -0,0 +1,101 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.config; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.security.SecurityScheme; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("SwaggerConfig Test Suite") +class SwaggerConfigTest { + + private static final String SECURITY_SCHEME_NAME = "my security"; + private static final String DEFAULT_URL = "http://localhost:9090"; + + private SwaggerConfig swaggerConfig; + private MockEnvironment environment; + + @BeforeEach + @DisplayName("Create the configuration and an empty environment before each test") + void setUp() { + swaggerConfig = new SwaggerConfig(); + environment = new MockEnvironment(); + } + + @Test + @DisplayName("customOpenAPI should describe the beneficiary generation API") + void customOpenAPI_shouldDescribeTheApi() { + OpenAPI openAPI = swaggerConfig.customOpenAPI(environment); + + assertNotNull(openAPI.getInfo()); + assertEquals("BeneficiaryID-Generation API", openAPI.getInfo().getTitle()); + assertEquals("1.0", openAPI.getInfo().getVersion()); + assertTrue(openAPI.getInfo().getDescription().contains("unique beneficiary registration Id")); + } + + @Test + @DisplayName("customOpenAPI should declare a bearer security scheme and require it") + void customOpenAPI_shouldDeclareBearerSecurityScheme() { + OpenAPI openAPI = swaggerConfig.customOpenAPI(environment); + + SecurityScheme scheme = openAPI.getComponents().getSecuritySchemes().get(SECURITY_SCHEME_NAME); + assertNotNull(scheme); + assertEquals(SecurityScheme.Type.HTTP, scheme.getType()); + assertEquals("bearer", scheme.getScheme()); + assertEquals(1, openAPI.getSecurity().size()); + assertTrue(openAPI.getSecurity().get(0).containsKey(SECURITY_SCHEME_NAME)); + } + + @Test + @DisplayName("customOpenAPI should fall back to localhost for every unset server url") + void customOpenAPI_shouldFallBackToLocalhostForUnsetUrls() { + OpenAPI openAPI = swaggerConfig.customOpenAPI(environment); + + assertEquals(3, openAPI.getServers().size()); + openAPI.getServers().forEach(server -> assertEquals(DEFAULT_URL, server.getUrl())); + assertEquals("Dev", openAPI.getServers().get(0).getDescription()); + assertEquals("UAT", openAPI.getServers().get(1).getDescription()); + assertEquals("Demo", openAPI.getServers().get(2).getDescription()); + } + + @Test + @DisplayName("customOpenAPI should use the configured dev, UAT and demo urls when present") + void customOpenAPI_shouldUseConfiguredUrls() { + environment.setProperty("api.dev.url", "https://dev.amrit.example.org"); + environment.setProperty("api.uat.url", "https://uat.amrit.example.org"); + environment.setProperty("api.demo.url", "https://demo.amrit.example.org"); + + OpenAPI openAPI = swaggerConfig.customOpenAPI(environment); + + assertEquals("https://dev.amrit.example.org", openAPI.getServers().get(0).getUrl()); + assertEquals("https://uat.amrit.example.org", openAPI.getServers().get(1).getUrl()); + assertEquals("https://demo.amrit.example.org", openAPI.getServers().get(2).getUrl()); + } +} diff --git a/src/test/java/com/iemr/common/bengen/config/quartz/AutowiringSpringBeanJobFactoryTest.java b/src/test/java/com/iemr/common/bengen/config/quartz/AutowiringSpringBeanJobFactoryTest.java new file mode 100644 index 0000000..1558128 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/config/quartz/AutowiringSpringBeanJobFactoryTest.java @@ -0,0 +1,104 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.config.quartz; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.quartz.JobDetail; +import org.quartz.JobDataMap; +import org.quartz.spi.OperableTrigger; +import org.quartz.spi.TriggerFiredBundle; +import org.springframework.beans.factory.config.AutowireCapableBeanFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.scheduling.quartz.JobDetailFactoryBean; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AutowiringSpringBeanJobFactory Test Suite") +class AutowiringSpringBeanJobFactoryTest { + + @Mock + private ApplicationContext applicationContext; + + @Mock + private AutowireCapableBeanFactory beanFactory; + + @Mock + private TriggerFiredBundle triggerFiredBundle; + + @Mock + private OperableTrigger trigger; + + private AutowiringSpringBeanJobFactory jobFactory; + + @BeforeEach + @DisplayName("Create the job factory before each test") + void setUp() { + jobFactory = new AutowiringSpringBeanJobFactory(); + } + + private void stubBundle() { + lenient().when(triggerFiredBundle.getJobDetail()).thenReturn(benGenJobDetail()); + lenient().when(triggerFiredBundle.getTrigger()).thenReturn(trigger); + lenient().when(trigger.getJobDataMap()).thenReturn(new JobDataMap()); + } + + private JobDetail benGenJobDetail() { + JobDetailFactoryBean factory = new JobDetailFactoryBean(); + factory.setJobClass(ScheduleJobServiceForBenGen.class); + factory.setName("bengen-job"); + factory.afterPropertiesSet(); + return factory.getObject(); + } + + @Test + @DisplayName("createJobInstance should autowire the new job through the application context") + void createJobInstance_shouldAutowireJobThroughApplicationContext() throws Exception { + when(applicationContext.getAutowireCapableBeanFactory()).thenReturn(beanFactory); + stubBundle(); + jobFactory.setApplicationContext(applicationContext); + + Object job = jobFactory.createJobInstance(triggerFiredBundle); + + assertNotNull(job); + assertTrue(job instanceof ScheduleJobServiceForBenGen); + verify(beanFactory).autowireBean(job); + } + + @Test + @DisplayName("createJobInstance should fail when no application context has been supplied") + void createJobInstance_shouldFailWithoutApplicationContext() { + stubBundle(); + + assertThrows(NullPointerException.class, () -> jobFactory.createJobInstance(triggerFiredBundle)); + } +} diff --git a/src/test/java/com/iemr/common/bengen/config/quartz/QuartzConfigTest.java b/src/test/java/com/iemr/common/bengen/config/quartz/QuartzConfigTest.java new file mode 100644 index 0000000..9f4bfb5 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/config/quartz/QuartzConfigTest.java @@ -0,0 +1,183 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.config.quartz; + +import java.util.Properties; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.quartz.CronTrigger; +import org.quartz.JobDetail; +import org.springframework.beans.factory.config.AutowireCapableBeanFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.scheduling.quartz.CronTriggerFactoryBean; +import org.springframework.scheduling.quartz.JobDetailFactoryBean; +import org.springframework.scheduling.quartz.SchedulerFactoryBean; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.PlatformTransactionManager; + +import com.iemr.common.bengen.utils.config.ConfigProperties; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("QuartzConfig Test Suite") +class QuartzConfigTest { + + private static final String DEFAULT_CRON = "1 0 0 * * ?"; + + @Mock + private PlatformTransactionManager transactionManager; + + @Mock + private ApplicationContext applicationContext; + + @Mock + private AutowireCapableBeanFactory beanFactory; + + private QuartzConfig quartzConfig; + private Properties originalConfigProperties; + + @BeforeEach + @DisplayName("Wire the configuration with mocked Spring collaborators before each test") + void setUp() { + quartzConfig = new QuartzConfig(); + ReflectionTestUtils.setField(quartzConfig, "transactionManager", transactionManager); + ReflectionTestUtils.setField(quartzConfig, "applicationContext", applicationContext); + new ConfigProperties(); + originalConfigProperties = (Properties) ReflectionTestUtils.getField(ConfigProperties.class, "properties"); + } + + @AfterEach + @DisplayName("Restore the shared ConfigProperties statics after each test") + void tearDown() { + ReflectionTestUtils.setField(ConfigProperties.class, "properties", originalConfigProperties); + } + + @Nested + @DisplayName("Job and trigger beans") + class JobAndTriggerTests { + + @Test + @DisplayName("processMQJobForBenGen should build the beneficiary generation job in the spring-quartz group") + void processMQJobForBenGen_shouldBuildJobInSpringQuartzGroup() { + JobDetailFactoryBean factory = quartzConfig.processMQJobForBenGen(); + factory.afterPropertiesSet(); + + JobDetail jobDetail = factory.getObject(); + assertNotNull(jobDetail); + assertEquals(ScheduleJobServiceForBenGen.class, jobDetail.getJobClass()); + assertEquals("spring-quartz", jobDetail.getKey().getGroup()); + } + + @Test + @DisplayName("processMQTriggerForBenGen should use the midnight default when the scheduler is disabled") + void processMQTriggerForBenGen_shouldUseDefaultCronWhenSchedulerDisabled() throws Exception { + Properties stub = new Properties(); + stub.setProperty("start-bengen-scheduler", "false"); + ReflectionTestUtils.setField(ConfigProperties.class, "properties", stub); + + CronTriggerFactoryBean factory = quartzConfig.processMQTriggerForBenGen(); + factory.afterPropertiesSet(); + + CronTrigger trigger = factory.getObject(); + assertEquals(DEFAULT_CRON, trigger.getCronExpression()); + assertEquals("spring-quartz", trigger.getKey().getGroup()); + } + + @Test + @DisplayName("processMQTriggerForBenGen should use the configured cron when the scheduler is enabled") + void processMQTriggerForBenGen_shouldUseConfiguredCronWhenSchedulerEnabled() throws Exception { + Properties stub = new Properties(); + stub.setProperty("start-bengen-scheduler", "true"); + stub.setProperty("cron-scheduler-bengen", "0 0 2 * * ?"); + ReflectionTestUtils.setField(ConfigProperties.class, "properties", stub); + + CronTriggerFactoryBean factory = quartzConfig.processMQTriggerForBenGen(); + factory.afterPropertiesSet(); + + assertEquals("0 0 2 * * ?", factory.getObject().getCronExpression()); + } + } + + @Nested + @DisplayName("Scheduler bean") + class SchedulerTests { + + @Test + @DisplayName("quartzScheduler should overwrite existing jobs and carry the configured scheduler name") + void quartzScheduler_shouldOverwriteExistingJobsWithConfiguredName() { + SchedulerFactoryBean scheduler = quartzConfig.quartzScheduler(); + + assertNotNull(scheduler); + assertEquals(Boolean.TRUE, ReflectionTestUtils.getField(scheduler, "overwriteExistingJobs")); + assertEquals("jelies-quartz-scheduler", ReflectionTestUtils.getField(scheduler, "schedulerName")); + } + + @Test + @DisplayName("quartzScheduler should install an autowiring job factory bound to the application context") + void quartzScheduler_shouldInstallAutowiringJobFactory() { + when(applicationContext.getAutowireCapableBeanFactory()).thenReturn(beanFactory); + + SchedulerFactoryBean scheduler = quartzConfig.quartzScheduler(); + + Object jobFactory = ReflectionTestUtils.getField(scheduler, "jobFactory"); + assertTrue(jobFactory instanceof AutowiringSpringBeanJobFactory); + assertSame(beanFactory, ReflectionTestUtils.getField(jobFactory, "beanFactory"), + "the job factory must autowire jobs from the application context"); + } + + @Test + @DisplayName("quartzScheduler should use the injected transaction manager") + void quartzScheduler_shouldUseInjectedTransactionManager() { + SchedulerFactoryBean scheduler = quartzConfig.quartzScheduler(); + + assertSame(transactionManager, ReflectionTestUtils.getField(scheduler, "transactionManager")); + } + } + + @Nested + @DisplayName("Quartz properties bean") + class QuartzPropertiesTests { + + @Test + @DisplayName("quartzProperties should load the Quartz thread pool settings from application.properties") + void quartzProperties_shouldLoadThreadPoolSettings() { + Properties properties = quartzConfig.quartzProperties(); + + assertNotNull(properties); + assertEquals("org.quartz.simpl.SimpleThreadPool", + properties.getProperty("org.quartz.threadPool.class")); + assertEquals("true", properties.getProperty("org.quartz.threadPool.makeThreadsDaemons")); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/controller/health/HealthControllerTest.java b/src/test/java/com/iemr/common/bengen/controller/health/HealthControllerTest.java new file mode 100644 index 0000000..7e0c17f --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/controller/health/HealthControllerTest.java @@ -0,0 +1,106 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.controller.health; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.iemr.common.bengen.service.health.HealthService; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +@DisplayName("HealthController Test Suite") +class HealthControllerTest { + + @Mock + private HealthService healthService; + + private MockMvc mockMvc; + + @BeforeEach + @DisplayName("Set up standalone MockMvc before each test") + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(new HealthController(healthService)).build(); + } + + private Map healthResponse(String overallStatus) { + Map response = new LinkedHashMap<>(); + response.put("status", overallStatus); + response.put("checkedAt", "2025-06-25T10:00:00Z"); + return response; + } + + @Test + @DisplayName("checkHealth should return 200 with the payload when all services are UP") + void checkHealth_shouldReturnOkWhenStatusIsUp() throws Exception { + when(healthService.checkHealth()).thenReturn(healthResponse("UP")); + + mockMvc.perform(get("/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("UP")) + .andExpect(jsonPath("$.checkedAt").value("2025-06-25T10:00:00Z")); + } + + @Test + @DisplayName("checkHealth should return 200 when DEGRADED, since the instance is still operational") + void checkHealth_shouldReturnOkWhenStatusIsDegraded() throws Exception { + when(healthService.checkHealth()).thenReturn(healthResponse("DEGRADED")); + + mockMvc.perform(get("/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("DEGRADED")); + } + + @Test + @DisplayName("checkHealth should return 503 when a critical service is DOWN") + void checkHealth_shouldReturnServiceUnavailableWhenStatusIsDown() throws Exception { + when(healthService.checkHealth()).thenReturn(healthResponse("DOWN")); + + mockMvc.perform(get("/health")) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.status").value("DOWN")); + } + + @Test + @DisplayName("checkHealth should return 503 with a DOWN payload when the service throws unexpectedly") + void checkHealth_shouldReturnServiceUnavailableWhenServiceThrows() throws Exception { + when(healthService.checkHealth()).thenThrow(new IllegalStateException("unexpected failure")); + + mockMvc.perform(get("/health")) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.status").value("DOWN")) + .andExpect(jsonPath("$.checkedAt").exists()); + } +} diff --git a/src/test/java/com/iemr/common/bengen/data/user/UserTest.java b/src/test/java/com/iemr/common/bengen/data/user/UserTest.java new file mode 100644 index 0000000..a4e4c36 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/data/user/UserTest.java @@ -0,0 +1,151 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.data.user; + +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("User Test Suite") +class UserTest { + + private User populated() { + User user = new User(); + user.setUserID(42L); + user.setUserName("amrit-user"); + user.setDeleted(false); + return user; + } + + /** One mutator per field, so every generated equality branch is exercised. */ + private List>> differingValues() { + return List.of( + Map.entry("userID", (Consumer) user -> user.setUserID(99L)), + Map.entry("userName", (Consumer) user -> user.setUserName("someone-else")), + Map.entry("deleted", (Consumer) user -> user.setDeleted(true))); + } + + private List>> nullValues() { + return List.of( + Map.entry("userID", (Consumer) user -> user.setUserID(null)), + Map.entry("userName", (Consumer) user -> user.setUserName(null)), + Map.entry("deleted", (Consumer) user -> user.setDeleted(null))); + } + + @Nested + @DisplayName("Field access") + class FieldTests { + + @Test + @DisplayName("a new user should leave every field unset") + void newUser_shouldLeaveEveryFieldUnset() { + User user = new User(); + + assertNull(user.getUserID()); + assertNull(user.getUserName()); + assertNull(user.getDeleted()); + } + + @Test + @DisplayName("every field should round-trip through its accessors") + void everyField_shouldRoundTrip() { + User user = populated(); + + assertEquals(42L, user.getUserID()); + assertEquals("amrit-user", user.getUserName()); + assertEquals(false, user.getDeleted()); + } + } + + @Nested + @DisplayName("Value semantics") + class ValueSemanticsTests { + + @Test + @DisplayName("two users holding the same values should be equal and share a hash code") + void usersWithSameValues_shouldBeEqual() { + assertEquals(populated(), populated()); + assertEquals(populated().hashCode(), populated().hashCode()); + } + + @Test + @DisplayName("a user should equal itself and never equal null or an unrelated type") + void user_shouldEqualItselfAndNotOtherTypes() { + User user = populated(); + + assertEquals(user, user); + assertNotEquals(user, null); + assertNotEquals(user, "not a User"); + } + + @Test + @DisplayName("a difference in any single field should break equality") + void equals_shouldDetectDifferenceInEveryField() { + assertAll(differingValues().stream().map(field -> () -> { + User variant = populated(); + field.getValue().accept(variant); + assertNotEquals(populated(), variant, + "changing " + field.getKey() + " must break equality"); + })); + } + + @Test + @DisplayName("a null in any single field should break equality in both directions") + void equals_shouldDetectNullInEveryField() { + assertAll(nullValues().stream().map(field -> () -> { + User variant = populated(); + field.getValue().accept(variant); + assertNotEquals(populated(), variant, + "nulling " + field.getKey() + " must break equality"); + assertNotEquals(variant, populated(), + "equality must stay symmetric when " + field.getKey() + " is null"); + })); + } + + @Test + @DisplayName("two unset users should be equal and share a hash code") + void unsetUsers_shouldBeEqual() { + assertEquals(new User(), new User()); + assertEquals(new User().hashCode(), new User().hashCode()); + } + + @Test + @DisplayName("toString should name the type and expose the user identity") + void toString_shouldNameTypeAndExposeIdentity() { + String text = populated().toString(); + + assertTrue(text.startsWith("User(")); + assertTrue(text.contains("42")); + assertTrue(text.contains("amrit-user")); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/domain/BeneficiaryIdTest.java b/src/test/java/com/iemr/common/bengen/domain/BeneficiaryIdTest.java new file mode 100644 index 0000000..9c72e10 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/domain/BeneficiaryIdTest.java @@ -0,0 +1,304 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.domain; + +import java.math.BigInteger; +import java.sql.Timestamp; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("BeneficiaryId Test Suite") +class BeneficiaryIdTest { + + private static final BigInteger BEN_REG_ID = new BigInteger("753812721192"); + private static final BigInteger BENEFICIARY_ID = new BigInteger("796702837334"); + private static final Timestamp RESERVED_ON = Timestamp.valueOf("2025-06-25 10:00:00"); + private static final Timestamp RESERVED_UNTIL = Timestamp.valueOf("2025-06-26 10:00:00"); + private static final Timestamp PROVISIONED_ON = Timestamp.valueOf("2025-06-25 11:00:00"); + private static final Timestamp CREATED_DATE = Timestamp.valueOf("2025-06-01 09:00:00"); + private static final Timestamp OTHER_TIMESTAMP = Timestamp.valueOf("2030-01-01 00:00:00"); + + private BeneficiaryId beneficiaryId; + + @BeforeEach + @DisplayName("Create an unpopulated mapping row before each test") + void setUp() { + beneficiaryId = new BeneficiaryId(); + } + + private BeneficiaryId populated() { + BeneficiaryId row = new BeneficiaryId(); + row.setBenRegId(BEN_REG_ID); + row.setBeneficiaryId(BENEFICIARY_ID); + row.setProvisioned(true); + row.setProvisionedById(7); + row.setProvisionedBy("admin"); + row.setProvisionedOn(PROVISIONED_ON); + row.setReserved(true); + row.setReservedForId(11); + row.setReservedForName("MMU"); + row.setReservedForCountryId(1); + row.setReservedForCountryName("India"); + row.setReservedForStateId(2); + row.setReservedForStateName("Karnataka"); + row.setReservedForDistrictId(3); + row.setReservedForDistrictName("Bengaluru Urban"); + row.setReservedForPSMapId(4); + row.setReservedForPSMapName("PS-Map-4"); + row.setReservedById(5); + row.setReservedByName("scheduler"); + row.setReservedOn(RESERVED_ON); + row.setReservedUntil(RESERVED_UNTIL); + row.setCreatedBy("admin"); + row.setCreatedDate(CREATED_DATE); + return row; + } + + @Nested + @DisplayName("Field round-tripping") + class FieldTests { + + @Test + @DisplayName("a new mapping row should leave every field unset") + void newRow_shouldLeaveEveryFieldUnset() { + assertNull(beneficiaryId.getBenRegId()); + assertNull(beneficiaryId.getBeneficiaryId()); + assertNull(beneficiaryId.getProvisioned()); + assertNull(beneficiaryId.getReserved()); + assertNull(beneficiaryId.getCreatedBy()); + } + + @Test + @DisplayName("the identity and provisioning fields should round-trip through their accessors") + void identityAndProvisioningFields_shouldRoundTrip() { + BeneficiaryId row = populated(); + + assertEquals(BEN_REG_ID, row.getBenRegId()); + assertEquals(BENEFICIARY_ID, row.getBeneficiaryId()); + assertTrue(row.getProvisioned()); + assertEquals(7, row.getProvisionedById()); + assertEquals("admin", row.getProvisionedBy()); + assertEquals(PROVISIONED_ON, row.getProvisionedOn()); + } + + @Test + @DisplayName("the reservation fields should round-trip through their accessors") + void reservationFields_shouldRoundTrip() { + BeneficiaryId row = populated(); + + assertTrue(row.getReserved()); + assertEquals(11, row.getReservedForId()); + assertEquals("MMU", row.getReservedForName()); + assertEquals(1, row.getReservedForCountryId()); + assertEquals("India", row.getReservedForCountryName()); + assertEquals(2, row.getReservedForStateId()); + assertEquals("Karnataka", row.getReservedForStateName()); + assertEquals(3, row.getReservedForDistrictId()); + assertEquals("Bengaluru Urban", row.getReservedForDistrictName()); + assertEquals(4, row.getReservedForPSMapId()); + assertEquals("PS-Map-4", row.getReservedForPSMapName()); + assertEquals(5, row.getReservedById()); + assertEquals("scheduler", row.getReservedByName()); + assertEquals(RESERVED_ON, row.getReservedOn()); + assertEquals(RESERVED_UNTIL, row.getReservedUntil()); + } + + @Test + @DisplayName("the audit fields should round-trip through their accessors") + void auditFields_shouldRoundTrip() { + BeneficiaryId row = populated(); + + assertEquals("admin", row.getCreatedBy()); + assertEquals(CREATED_DATE, row.getCreatedDate()); + } + } + + @Nested + @DisplayName("Value semantics") + class ValueSemanticsTests { + + @Test + @DisplayName("two rows holding the same values should be equal and share a hash code") + void rowsWithSameValues_shouldBeEqual() { + assertEquals(populated(), populated()); + assertEquals(populated().hashCode(), populated().hashCode()); + } + + @Test + @DisplayName("two unpopulated rows should be equal") + void unpopulatedRows_shouldBeEqual() { + assertEquals(new BeneficiaryId(), new BeneficiaryId()); + } + + @Test + @DisplayName("rows differing in the beneficiary id should not be equal") + void rowsDifferingInBeneficiaryId_shouldNotBeEqual() { + BeneficiaryId other = populated(); + other.setBeneficiaryId(new BigInteger("111111111111")); + + assertNotEquals(populated(), other); + } + + @Test + @DisplayName("rows differing in reservation state should not be equal") + void rowsDifferingInReservationState_shouldNotBeEqual() { + BeneficiaryId other = populated(); + other.setReserved(false); + + assertNotEquals(populated(), other); + } + + @Test + @DisplayName("a row should not equal an unrelated object") + void row_shouldNotEqualUnrelatedObject() { + assertNotEquals(populated(), "not a BeneficiaryId"); + } + + @Test + @DisplayName("toString should name the type and expose the identity fields") + void toString_shouldNameTypeAndExposeIdentityFields() { + String text = populated().toString(); + + assertTrue(text.startsWith("BeneficiaryId(")); + assertTrue(text.contains(BEN_REG_ID.toString())); + assertTrue(text.contains(BENEFICIARY_ID.toString())); + assertTrue(text.contains("scheduler")); + } + } + + @Nested + @DisplayName("Generated equality across every field") + class PerFieldEqualityTests { + + /** One mutator per field, so every generated equality branch is exercised. */ + private List>> differingValues() { + return List.of( + Map.entry("benRegId", (Consumer) r -> r.setBenRegId(BigInteger.ONE)), + Map.entry("beneficiaryId", (Consumer) r -> r.setBeneficiaryId(BigInteger.TWO)), + Map.entry("provisioned", (Consumer) r -> r.setProvisioned(false)), + Map.entry("provisionedById", (Consumer) r -> r.setProvisionedById(99)), + Map.entry("provisionedBy", (Consumer) r -> r.setProvisionedBy("someone")), + Map.entry("provisionedOn", (Consumer) r -> r.setProvisionedOn(OTHER_TIMESTAMP)), + Map.entry("reserved", (Consumer) r -> r.setReserved(false)), + Map.entry("reservedForId", (Consumer) r -> r.setReservedForId(99)), + Map.entry("reservedForName", (Consumer) r -> r.setReservedForName("Other")), + Map.entry("reservedForCountryId", (Consumer) r -> r.setReservedForCountryId(99)), + Map.entry("reservedForCountryName", (Consumer) r -> r.setReservedForCountryName("Nepal")), + Map.entry("reservedForStateId", (Consumer) r -> r.setReservedForStateId(99)), + Map.entry("reservedForStateName", (Consumer) r -> r.setReservedForStateName("Kerala")), + Map.entry("reservedForDistrictId", (Consumer) r -> r.setReservedForDistrictId(99)), + Map.entry("reservedForDistrictName", (Consumer) r -> r.setReservedForDistrictName("Mysuru")), + Map.entry("reservedForPSMapId", (Consumer) r -> r.setReservedForPSMapId(99)), + Map.entry("reservedForPSMapName", (Consumer) r -> r.setReservedForPSMapName("PS-Map-99")), + Map.entry("reservedById", (Consumer) r -> r.setReservedById(99)), + Map.entry("reservedByName", (Consumer) r -> r.setReservedByName("operator")), + Map.entry("reservedOn", (Consumer) r -> r.setReservedOn(OTHER_TIMESTAMP)), + Map.entry("reservedUntil", (Consumer) r -> r.setReservedUntil(OTHER_TIMESTAMP)), + Map.entry("createdBy", (Consumer) r -> r.setCreatedBy("someone")), + Map.entry("createdDate", (Consumer) r -> r.setCreatedDate(OTHER_TIMESTAMP))); + } + + private List>> nullValues() { + return List.of( + Map.entry("benRegId", (Consumer) r -> r.setBenRegId(null)), + Map.entry("beneficiaryId", (Consumer) r -> r.setBeneficiaryId(null)), + Map.entry("provisioned", (Consumer) r -> r.setProvisioned(null)), + Map.entry("provisionedById", (Consumer) r -> r.setProvisionedById(null)), + Map.entry("provisionedBy", (Consumer) r -> r.setProvisionedBy(null)), + Map.entry("provisionedOn", (Consumer) r -> r.setProvisionedOn(null)), + Map.entry("reserved", (Consumer) r -> r.setReserved(null)), + Map.entry("reservedForId", (Consumer) r -> r.setReservedForId(null)), + Map.entry("reservedForName", (Consumer) r -> r.setReservedForName(null)), + Map.entry("reservedForCountryId", (Consumer) r -> r.setReservedForCountryId(null)), + Map.entry("reservedForCountryName", (Consumer) r -> r.setReservedForCountryName(null)), + Map.entry("reservedForStateId", (Consumer) r -> r.setReservedForStateId(null)), + Map.entry("reservedForStateName", (Consumer) r -> r.setReservedForStateName(null)), + Map.entry("reservedForDistrictId", (Consumer) r -> r.setReservedForDistrictId(null)), + Map.entry("reservedForDistrictName", (Consumer) r -> r.setReservedForDistrictName(null)), + Map.entry("reservedForPSMapId", (Consumer) r -> r.setReservedForPSMapId(null)), + Map.entry("reservedForPSMapName", (Consumer) r -> r.setReservedForPSMapName(null)), + Map.entry("reservedById", (Consumer) r -> r.setReservedById(null)), + Map.entry("reservedByName", (Consumer) r -> r.setReservedByName(null)), + Map.entry("reservedOn", (Consumer) r -> r.setReservedOn(null)), + Map.entry("reservedUntil", (Consumer) r -> r.setReservedUntil(null)), + Map.entry("createdBy", (Consumer) r -> r.setCreatedBy(null)), + Map.entry("createdDate", (Consumer) r -> r.setCreatedDate(null))); + } + + @Test + @DisplayName("a difference in any single field should break equality") + void equals_shouldDetectDifferenceInEveryField() { + assertAll(differingValues().stream().map(field -> () -> { + BeneficiaryId variant = populated(); + field.getValue().accept(variant); + assertNotEquals(populated(), variant, + "changing " + field.getKey() + " must break equality"); + })); + } + + @Test + @DisplayName("a null in any single field should break equality in both directions") + void equals_shouldDetectNullInEveryField() { + assertAll(nullValues().stream().map(field -> () -> { + BeneficiaryId variant = populated(); + field.getValue().accept(variant); + assertNotEquals(populated(), variant, + "nulling " + field.getKey() + " must break equality"); + assertNotEquals(variant, populated(), + "equality must stay symmetric when " + field.getKey() + " is null"); + })); + } + + @Test + @DisplayName("hashCode should tolerate a null in any single field") + void hashCode_shouldTolerateNullInEveryField() { + assertAll(nullValues().stream().map(field -> () -> { + BeneficiaryId variant = populated(); + field.getValue().accept(variant); + assertDoesNotThrow(variant::hashCode, + "hashCode must not fail when " + field.getKey() + " is null"); + })); + } + + @Test + @DisplayName("a row should equal itself and never equal null") + void row_shouldEqualItselfAndNotNull() { + BeneficiaryId row = populated(); + + assertEquals(row, row); + assertNotEquals(row, null); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/domain/M_BeneficiaryRegidMappingTest.java b/src/test/java/com/iemr/common/bengen/domain/M_BeneficiaryRegidMappingTest.java new file mode 100644 index 0000000..c218b83 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/domain/M_BeneficiaryRegidMappingTest.java @@ -0,0 +1,246 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.domain; + +import java.sql.Timestamp; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("M_BeneficiaryRegidMapping Test Suite") +class M_BeneficiaryRegidMappingTest { + + private static final Long BEN_REG_ID = 753812721192L; + private static final Long BENEFICIARY_ID = 796702837334L; + private static final Timestamp CREATED_DATE = Timestamp.valueOf("2025-06-25 10:00:00"); + private static final Timestamp OTHER_DATE = Timestamp.valueOf("2030-01-01 00:00:00"); + + private M_BeneficiaryRegidMapping row() { + return new M_BeneficiaryRegidMapping(BEN_REG_ID, BENEFICIARY_ID, CREATED_DATE, "admin"); + } + + private M_BeneficiaryRegidMapping fullyPopulated() { + M_BeneficiaryRegidMapping row = row(); + row.setProvisioned(false); + row.setReserved(false); + row.setVanID(12); + row.setBenIDRequired(500L); + return row; + } + + @Nested + @DisplayName("Construction and field access") + class FieldTests { + + @Test + @DisplayName("the constructor should populate the identity and audit fields") + void constructor_shouldPopulateIdentityAndAuditFields() { + M_BeneficiaryRegidMapping row = row(); + + assertEquals(BEN_REG_ID, row.getBenRegId()); + assertEquals(BENEFICIARY_ID, row.getBeneficiaryId()); + assertEquals(CREATED_DATE, row.getCreatedDate()); + assertEquals("admin", row.getCreatedBy()); + } + + @Test + @DisplayName("the constructor should leave the provisioning and reservation flags unset") + void constructor_shouldLeaveFlagsUnset() { + M_BeneficiaryRegidMapping row = row(); + + assertNull(row.getProvisioned()); + assertNull(row.getReserved()); + assertNull(row.getVanID()); + assertNull(row.getBenIDRequired()); + } + + @Test + @DisplayName("the provisioning, reservation and van fields should round-trip through their accessors") + void mutableFields_shouldRoundTrip() { + M_BeneficiaryRegidMapping row = fullyPopulated(); + + assertEquals(false, row.getProvisioned()); + assertEquals(false, row.getReserved()); + assertEquals(12, row.getVanID()); + assertEquals(500L, row.getBenIDRequired()); + } + } + + @Nested + @DisplayName("Value semantics and serialisation") + class ValueSemanticsTests { + + @Test + @DisplayName("two rows holding the same values should be equal and share a hash code") + void rowsWithSameValues_shouldBeEqual() { + assertEquals(fullyPopulated(), fullyPopulated()); + assertEquals(fullyPopulated().hashCode(), fullyPopulated().hashCode()); + } + + @Test + @DisplayName("rows differing in provisioning state should not be equal") + void rowsDifferingInProvisioningState_shouldNotBeEqual() { + M_BeneficiaryRegidMapping other = fullyPopulated(); + other.setProvisioned(true); + + assertNotEquals(fullyPopulated(), other); + } + + @Test + @DisplayName("a row should not equal an unrelated object") + void row_shouldNotEqualUnrelatedObject() { + assertNotEquals(fullyPopulated(), "not a mapping row"); + } + + @Test + @DisplayName("toString should render the exposed fields as JSON with ids serialised as strings") + void toString_shouldRenderExposedFieldsAsJson() { + String json = fullyPopulated().toString(); + + assertTrue(json.contains("\"benRegId\":\"753812721192\""), json); + assertTrue(json.contains("\"beneficiaryId\":\"796702837334\""), json); + assertTrue(json.contains("\"createdBy\":\"admin\""), json); + } + + @Test + @DisplayName("toString should omit the fields that are not exposed for serialisation") + void toString_shouldOmitUnexposedFields() { + String json = fullyPopulated().toString(); + + assertTrue(!json.contains("provisioned"), json); + assertTrue(!json.contains("reserved"), json); + assertTrue(!json.contains("vanID"), json); + assertTrue(!json.contains("benIDRequired"), json); + } + + @Test + @DisplayName("toString should serialise a null audit field rather than dropping it") + void toString_shouldSerialiseNullAuditField() { + M_BeneficiaryRegidMapping row = + new M_BeneficiaryRegidMapping(BEN_REG_ID, BENEFICIARY_ID, null, null); + + String json = row.toString(); + + assertTrue(json.contains("\"createdDate\":null"), json); + assertTrue(json.contains("\"createdBy\":null"), json); + } + } + + @Nested + @DisplayName("Generated equality across every field") + class PerFieldEqualityTests { + + /** One mutator per field, so every generated equality branch is exercised. */ + private List>> differingValues() { + return List.of( + Map.entry("benRegId", (Consumer) r -> r.setBenRegId(1L)), + Map.entry("beneficiaryId", (Consumer) r -> r.setBeneficiaryId(2L)), + Map.entry("provisioned", (Consumer) r -> r.setProvisioned(true)), + Map.entry("reserved", (Consumer) r -> r.setReserved(true)), + Map.entry("vanID", (Consumer) r -> r.setVanID(99)), + Map.entry("createdDate", (Consumer) r -> r.setCreatedDate(OTHER_DATE)), + Map.entry("createdBy", (Consumer) r -> r.setCreatedBy("someone")), + Map.entry("benIDRequired", (Consumer) r -> r.setBenIDRequired(1L))); + } + + private List>> nullValues() { + return List.of( + Map.entry("benRegId", (Consumer) r -> r.setBenRegId(null)), + Map.entry("beneficiaryId", (Consumer) r -> r.setBeneficiaryId(null)), + Map.entry("provisioned", (Consumer) r -> r.setProvisioned(null)), + Map.entry("reserved", (Consumer) r -> r.setReserved(null)), + Map.entry("vanID", (Consumer) r -> r.setVanID(null)), + Map.entry("createdDate", (Consumer) r -> r.setCreatedDate(null)), + Map.entry("createdBy", (Consumer) r -> r.setCreatedBy(null)), + Map.entry("benIDRequired", (Consumer) r -> r.setBenIDRequired(null))); + } + + @Test + @DisplayName("a difference in any single field should break equality") + void equals_shouldDetectDifferenceInEveryField() { + assertAll(differingValues().stream().map(field -> () -> { + M_BeneficiaryRegidMapping variant = fullyPopulated(); + field.getValue().accept(variant); + assertNotEquals(fullyPopulated(), variant, + "changing " + field.getKey() + " must break equality"); + })); + } + + @Test + @DisplayName("a null in any single field should break equality in both directions") + void equals_shouldDetectNullInEveryField() { + assertAll(nullValues().stream().map(field -> () -> { + M_BeneficiaryRegidMapping variant = fullyPopulated(); + field.getValue().accept(variant); + assertNotEquals(fullyPopulated(), variant, + "nulling " + field.getKey() + " must break equality"); + assertNotEquals(variant, fullyPopulated(), + "equality must stay symmetric when " + field.getKey() + " is null"); + })); + } + + @Test + @DisplayName("hashCode should tolerate a null in any single field") + void hashCode_shouldTolerateNullInEveryField() { + assertAll(nullValues().stream().map(field -> () -> { + M_BeneficiaryRegidMapping variant = fullyPopulated(); + field.getValue().accept(variant); + assertDoesNotThrow(variant::hashCode, + "hashCode must not fail when " + field.getKey() + " is null"); + })); + } + + @Test + @DisplayName("two rows sharing the same null field should still be equal") + void equals_shouldTreatMatchingNullFieldsAsEqual() { + assertAll(nullValues().stream().map(field -> () -> { + M_BeneficiaryRegidMapping first = fullyPopulated(); + M_BeneficiaryRegidMapping second = fullyPopulated(); + field.getValue().accept(first); + field.getValue().accept(second); + assertEquals(first, second, + "rows sharing a null " + field.getKey() + " must remain equal"); + assertEquals(first.hashCode(), second.hashCode()); + })); + } + + @Test + @DisplayName("a row should equal itself and never equal null") + void row_shouldEqualItselfAndNotNull() { + M_BeneficiaryRegidMapping row = fullyPopulated(); + + assertEquals(row, row); + assertNotEquals(row, null); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/service/health/HealthServiceTest.java b/src/test/java/com/iemr/common/bengen/service/health/HealthServiceTest.java new file mode 100644 index 0000000..c14d1d9 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/service/health/HealthServiceTest.java @@ -0,0 +1,422 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.service.health; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Map; +import java.util.concurrent.RejectedExecutionException; + +import javax.sql.DataSource; + +import com.zaxxer.hikari.HikariDataSource; +import com.zaxxer.hikari.HikariPoolMXBean; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("HealthService Test Suite") +class HealthServiceTest { + + private static final int THRESHOLD = 5000; + private static final String COUNT_SQL = + "SELECT COUNT(*) FROM m_beneficiaryregidmapping WHERE Provisioned = 0 AND Reserved = 0"; + + @Mock + private DataSource dataSource; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private Connection connection; + + @Mock + private PreparedStatement preparedStatement; + + @Mock + private ResultSet resultSet; + + private HealthService healthService; + + @BeforeEach + @DisplayName("Set up the service with mocked infrastructure before each test") + void setUp() { + healthService = new HealthService(dataSource, redisTemplate, THRESHOLD); + } + + /** + * Wires the JDBC mock chain so every query the service issues succeeds. + * getLong(1) backs the beneficiary pool COUNT, getInt(1) backs the advanced + * lock-wait and slow-query diagnostics. + */ + private void stubJdbc(long availableIds, int diagnosticCount) throws SQLException { + lenient().when(dataSource.getConnection()).thenReturn(connection); + lenient().when(connection.prepareStatement(anyString())).thenReturn(preparedStatement); + lenient().doNothing().when(preparedStatement).setQueryTimeout(anyInt()); + lenient().when(preparedStatement.executeQuery()).thenReturn(resultSet); + lenient().when(resultSet.next()).thenReturn(true); + lenient().when(resultSet.getLong(1)).thenReturn(availableIds); + lenient().when(resultSet.getInt(1)).thenReturn(diagnosticCount); + } + + private void stubRedisPong(String pong) { + lenient().when(redisTemplate.execute(any(RedisCallback.class))).thenReturn(pong); + } + + @SuppressWarnings("unchecked") + private Map component(Map response, String key) { + return (Map) response.get(key); + } + + @Nested + @DisplayName("Overall health aggregation") + class OverallStatusTests { + + @Test + @DisplayName("checkHealth should report UP when MySQL, Redis and the ID pool are all healthy") + void checkHealth_shouldReportUpWhenAllComponentsHealthy() throws SQLException { + stubJdbc(THRESHOLD * 2L, 0); + stubRedisPong("PONG"); + + Map response = healthService.checkHealth(); + + assertEquals("UP", response.get("status")); + assertNotNull(response.get("checkedAt")); + assertEquals("UP", component(response, "mysql").get("status")); + assertEquals("OK", component(response, "mysql").get("severity")); + assertEquals("UP", component(response, "redis").get("status")); + assertEquals("OK", component(response, "redis").get("severity")); + assertEquals("UP", component(response, "beneficiaryIdPool").get("status")); + } + + @Test + @DisplayName("checkHealth should expose only status and severity for MySQL and Redis") + void checkHealth_shouldExposeOnlyStatusAndSeverityForMysqlAndRedis() throws SQLException { + stubJdbc(THRESHOLD * 2L, 0); + stubRedisPong("PONG"); + + Map response = healthService.checkHealth(); + + assertEquals(2, component(response, "mysql").size()); + assertEquals(2, component(response, "redis").size()); + assertFalse(component(response, "mysql").containsKey("responseTimeMs")); + assertFalse(component(response, "redis").containsKey("responseTimeMs")); + } + + @Test + @DisplayName("checkHealth should report DOWN when MySQL cannot be reached") + void checkHealth_shouldReportDownWhenMysqlUnreachable() throws SQLException { + lenient().when(dataSource.getConnection()).thenThrow(new SQLException("connection refused")); + stubRedisPong("PONG"); + + Map response = healthService.checkHealth(); + + assertEquals("DOWN", response.get("status")); + assertEquals("DOWN", component(response, "mysql").get("status")); + assertEquals("CRITICAL", component(response, "mysql").get("severity")); + } + + @Test + @DisplayName("checkHealth should report DEGRADED when MySQL reports lock waits and slow queries") + void checkHealth_shouldReportDegradedWhenAdvancedChecksFlagIssues() throws SQLException { + stubJdbc(THRESHOLD * 2L, 25); + stubRedisPong("PONG"); + + Map response = healthService.checkHealth(); + + assertEquals("DEGRADED", response.get("status")); + assertEquals("DEGRADED", component(response, "mysql").get("status")); + assertEquals("WARNING", component(response, "mysql").get("severity")); + } + } + + @Nested + @DisplayName("Redis health check") + class RedisHealthTests { + + @Test + @DisplayName("checkRedisHealth should report DOWN when PING does not answer PONG") + void checkHealth_shouldReportDownWhenRedisPingFails() throws SQLException { + stubJdbc(THRESHOLD * 2L, 0); + stubRedisPong("nope"); + + Map response = healthService.checkHealth(); + + assertEquals("DOWN", response.get("status")); + assertEquals("DOWN", component(response, "redis").get("status")); + assertEquals("CRITICAL", component(response, "redis").get("severity")); + } + + @Test + @DisplayName("checkRedisHealth should report DOWN when the Redis call throws") + void checkHealth_shouldReportDownWhenRedisThrows() throws SQLException { + stubJdbc(THRESHOLD * 2L, 0); + lenient().when(redisTemplate.execute(any(RedisCallback.class))) + .thenThrow(new IllegalStateException("redis unavailable")); + + Map response = healthService.checkHealth(); + + assertEquals("DOWN", response.get("status")); + assertEquals("DOWN", component(response, "redis").get("status")); + } + + @Test + @DisplayName("checkRedisHealth should report UP and skip the check when Redis is not configured") + void checkHealth_shouldSkipRedisWhenNotConfigured() throws SQLException { + stubJdbc(THRESHOLD * 2L, 0); + HealthService serviceWithoutRedis = new HealthService(dataSource, null, THRESHOLD); + + Map response = serviceWithoutRedis.checkHealth(); + + assertEquals("UP", response.get("status")); + assertEquals("UP", component(response, "redis").get("status")); + verify(redisTemplate, never()).execute(any(RedisCallback.class)); + serviceWithoutRedis.shutdown(); + } + } + + @Nested + @DisplayName("Beneficiary ID pool check") + class BeneficiaryPoolTests { + + @Test + @DisplayName("checkBeneficiaryIdPool should report the available count and threshold when healthy") + void checkHealth_shouldReportPoolCountAndThreshold() throws SQLException { + stubJdbc(12345L, 0); + stubRedisPong("PONG"); + + Map pool = component(healthService.checkHealth(), "beneficiaryIdPool"); + + assertEquals("UP", pool.get("status")); + assertEquals("OK", pool.get("severity")); + assertEquals(12345L, pool.get("availableIds")); + assertEquals(THRESHOLD, pool.get("threshold")); + } + + @Test + @DisplayName("checkBeneficiaryIdPool should flag DEGRADED with a warning when the pool is below threshold") + void checkHealth_shouldFlagDegradedWhenPoolBelowThreshold() throws SQLException { + stubJdbc(10L, 0); + stubRedisPong("PONG"); + + Map response = healthService.checkHealth(); + Map pool = component(response, "beneficiaryIdPool"); + + assertEquals("DEGRADED", response.get("status")); + assertEquals("DEGRADED", pool.get("status")); + assertEquals("WARNING", pool.get("severity")); + assertEquals(10L, pool.get("availableIds")); + assertEquals("Available beneficiary ID pool is below the configured threshold", + pool.get("message")); + } + + @Test + @DisplayName("checkBeneficiaryIdPool should stay DEGRADED rather than DOWN when the count query yields no row") + void checkHealth_shouldStayDegradedWhenCountQueryReturnsNoRow() throws SQLException { + lenient().when(dataSource.getConnection()).thenReturn(connection); + lenient().when(connection.prepareStatement(anyString())).thenReturn(preparedStatement); + lenient().when(preparedStatement.executeQuery()).thenReturn(resultSet); + lenient().when(resultSet.next()).thenReturn(false); + stubRedisPong("PONG"); + + Map response = healthService.checkHealth(); + Map pool = component(response, "beneficiaryIdPool"); + + assertEquals("DEGRADED", pool.get("status")); + assertEquals("WARNING", pool.get("severity")); + assertEquals("Beneficiary ID pool count could not be determined", pool.get("error")); + } + + @Test + @DisplayName("countAvailableBeneficiaryIds should be throttled so repeated polls reuse the cached count") + void checkHealth_shouldThrottleRepeatedPoolCounts() throws SQLException { + stubJdbc(THRESHOLD * 2L, 0); + stubRedisPong("PONG"); + + healthService.checkHealth(); + healthService.checkHealth(); + + verify(connection, times(1)).prepareStatement( + "SELECT COUNT(*) FROM m_beneficiaryregidmapping WHERE Provisioned = 0 AND Reserved = 0"); + } + } + + @Nested + @DisplayName("Lifecycle") + class ShutdownTests { + + @Test + @DisplayName("shutdown should stop the executor so no further checks are accepted") + void shutdown_shouldStopTheExecutor() throws SQLException { + stubJdbc(THRESHOLD * 2L, 0); + stubRedisPong("PONG"); + healthService.checkHealth(); + + healthService.shutdown(); + + assertThrows(RejectedExecutionException.class, () -> healthService.checkHealth()); + } + + @Test + @DisplayName("shutdown should be safe to call twice") + void shutdown_shouldBeIdempotent() { + healthService.shutdown(); + + assertDoesNotThrow(() -> healthService.shutdown()); + } + } + + @Nested + @DisplayName("Advanced MySQL diagnostics") + class AdvancedDiagnosticsTests { + + @Test + @DisplayName("checkHealth should flag MySQL DEGRADED when the pool is more than 80% exhausted") + void checkHealth_shouldFlagDegradedWhenHikariPoolNearlyExhausted() throws SQLException { + HikariDataSource hikariDataSource = mock(HikariDataSource.class); + HikariPoolMXBean poolMXBean = mock(HikariPoolMXBean.class); + lenient().when(hikariDataSource.getConnection()).thenReturn(connection); + lenient().when(hikariDataSource.getHikariPoolMXBean()).thenReturn(poolMXBean); + lenient().when(hikariDataSource.getMaximumPoolSize()).thenReturn(10); + lenient().when(poolMXBean.getActiveConnections()).thenReturn(9); + lenient().when(connection.prepareStatement(anyString())).thenReturn(preparedStatement); + lenient().when(preparedStatement.executeQuery()).thenReturn(resultSet); + lenient().when(resultSet.next()).thenReturn(true); + lenient().when(resultSet.getLong(1)).thenReturn(THRESHOLD * 2L); + lenient().when(resultSet.getInt(1)).thenReturn(0); + stubRedisPong("PONG"); + HealthService service = new HealthService(hikariDataSource, redisTemplate, THRESHOLD); + + Map response = service.checkHealth(); + + assertEquals("DEGRADED", component(response, "mysql").get("status")); + assertEquals("WARNING", component(response, "mysql").get("severity")); + service.shutdown(); + } + + @Test + @DisplayName("checkHealth should stay UP when the Hikari pool is comfortably below the threshold") + void checkHealth_shouldStayUpWhenHikariPoolHasHeadroom() throws SQLException { + HikariDataSource hikariDataSource = mock(HikariDataSource.class); + HikariPoolMXBean poolMXBean = mock(HikariPoolMXBean.class); + lenient().when(hikariDataSource.getConnection()).thenReturn(connection); + lenient().when(hikariDataSource.getHikariPoolMXBean()).thenReturn(poolMXBean); + lenient().when(hikariDataSource.getMaximumPoolSize()).thenReturn(10); + lenient().when(poolMXBean.getActiveConnections()).thenReturn(2); + lenient().when(connection.prepareStatement(anyString())).thenReturn(preparedStatement); + lenient().when(preparedStatement.executeQuery()).thenReturn(resultSet); + lenient().when(resultSet.next()).thenReturn(true); + lenient().when(resultSet.getLong(1)).thenReturn(THRESHOLD * 2L); + lenient().when(resultSet.getInt(1)).thenReturn(0); + stubRedisPong("PONG"); + HealthService service = new HealthService(hikariDataSource, redisTemplate, THRESHOLD); + + Map response = service.checkHealth(); + + assertEquals("UP", component(response, "mysql").get("status")); + service.shutdown(); + } + + @Test + @DisplayName("checkHealth should stay UP when the Hikari pool exposes no MXBean") + void checkHealth_shouldStayUpWhenHikariMxBeanUnavailable() throws SQLException { + HikariDataSource hikariDataSource = mock(HikariDataSource.class); + lenient().when(hikariDataSource.getConnection()).thenReturn(connection); + lenient().when(hikariDataSource.getHikariPoolMXBean()).thenReturn(null); + lenient().when(connection.prepareStatement(anyString())).thenReturn(preparedStatement); + lenient().when(preparedStatement.executeQuery()).thenReturn(resultSet); + lenient().when(resultSet.next()).thenReturn(true); + lenient().when(resultSet.getLong(1)).thenReturn(THRESHOLD * 2L); + lenient().when(resultSet.getInt(1)).thenReturn(0); + stubRedisPong("PONG"); + HealthService service = new HealthService(hikariDataSource, redisTemplate, THRESHOLD); + + Map response = service.checkHealth(); + + assertEquals("UP", component(response, "mysql").get("status")); + service.shutdown(); + } + + @Test + @DisplayName("checkHealth should treat MySQL as DEGRADED when a diagnostic query fails outright") + void checkHealth_shouldTreatDiagnosticQueryFailureAsDegraded() throws SQLException { + lenient().when(dataSource.getConnection()).thenReturn(connection); + lenient().when(connection.prepareStatement("SELECT 1 as health_check")).thenReturn(preparedStatement); + lenient().when(connection.prepareStatement(COUNT_SQL)).thenReturn(preparedStatement); + lenient().when(connection.prepareStatement(argThat(sql -> + sql != null && sql.contains("INFORMATION_SCHEMA.PROCESSLIST")))) + .thenThrow(new SQLException("diagnostics denied")); + lenient().when(preparedStatement.executeQuery()).thenReturn(resultSet); + lenient().when(resultSet.next()).thenReturn(true); + lenient().when(resultSet.getLong(1)).thenReturn(THRESHOLD * 2L); + stubRedisPong("PONG"); + + Map response = healthService.checkHealth(); + + assertEquals("UP", component(response, "mysql").get("status"), + "a failed diagnostic query is swallowed and must not degrade the component"); + } + + @Test + @DisplayName("checkHealth should mark a component DOWN when its check does not finish in time") + void checkHealth_shouldMarkComponentDownWhenCheckTimesOut() throws SQLException { + lenient().when(dataSource.getConnection()).thenAnswer(invocation -> { + Thread.sleep(6_000); + return connection; + }); + stubRedisPong("PONG"); + + Map response = healthService.checkHealth(); + + assertEquals("DOWN", response.get("status")); + assertEquals("DOWN", component(response, "mysql").get("status")); + assertEquals("CRITICAL", component(response, "mysql").get("severity")); + assertEquals("DEGRADED", component(response, "beneficiaryIdPool").get("status")); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/CommonMainTest.java b/src/test/java/com/iemr/common/bengen/utils/CommonMainTest.java new file mode 100644 index 0000000..d926ad3 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/CommonMainTest.java @@ -0,0 +1,60 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.utils; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; + +@DisplayName("CommonMain Test Suite") +class CommonMainTest { + + private CommonMain commonMain; + + @BeforeEach + @DisplayName("Create the bean configuration before each test") + void setUp() { + commonMain = new CommonMain(); + } + + @Test + @DisplayName("configProperties should supply a fresh properties holder on each call") + void configProperties_shouldSupplyFreshHolder() { + assertNotNull(commonMain.configProperties()); + assertNotSame(commonMain.configProperties(), commonMain.configProperties()); + } + + @Test + @DisplayName("redisSession should supply a Spring Session Redis configuration") + void redisSession_shouldSupplySessionConfiguration() { + assertNotNull(commonMain.redisSession()); + } + + @Test + @DisplayName("redisStorage should supply a Redis store bean") + void redisStorage_shouldSupplyRedisStore() { + assertNotNull(commonMain.redisStorage()); + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/FilterConfigTest.java b/src/test/java/com/iemr/common/bengen/utils/FilterConfigTest.java new file mode 100644 index 0000000..7adab09 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/FilterConfigTest.java @@ -0,0 +1,84 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.utils; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.core.Ordered; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +@ExtendWith(MockitoExtension.class) +@DisplayName("FilterConfig Test Suite") +class FilterConfigTest { + + private static final String ALLOWED_ORIGINS = "https://amrit.example.org,http://localhost:*"; + + @Mock + private JwtAuthenticationUtil jwtAuthenticationUtil; + + private FilterConfig filterConfig; + + @BeforeEach + @DisplayName("Configure the allow-list before each test") + void setUp() { + filterConfig = new FilterConfig(); + ReflectionTestUtils.setField(filterConfig, "allowedOrigins", ALLOWED_ORIGINS); + } + + @Test + @DisplayName("jwtUserIdValidationFilter should register the JWT filter across every url pattern") + void jwtUserIdValidationFilter_shouldRegisterFilterForEveryUrlPattern() { + FilterRegistrationBean registration = + filterConfig.jwtUserIdValidationFilter(jwtAuthenticationUtil); + + assertNotNull(registration.getFilter()); + assertEquals(java.util.Set.of("/*"), registration.getUrlPatterns()); + } + + @Test + @DisplayName("jwtUserIdValidationFilter should run at the highest precedence so auth happens first") + void jwtUserIdValidationFilter_shouldRunAtHighestPrecedence() { + FilterRegistrationBean registration = + filterConfig.jwtUserIdValidationFilter(jwtAuthenticationUtil); + + assertEquals(Ordered.HIGHEST_PRECEDENCE, registration.getOrder()); + } + + @Test + @DisplayName("jwtUserIdValidationFilter should hand the filter the configured origins and auth util") + void jwtUserIdValidationFilter_shouldPassOriginsAndAuthUtilToFilter() { + JwtUserIdValidationFilter filter = + filterConfig.jwtUserIdValidationFilter(jwtAuthenticationUtil).getFilter(); + + assertEquals(ALLOWED_ORIGINS, ReflectionTestUtils.getField(filter, "allowedOrigins")); + assertSame(jwtAuthenticationUtil, ReflectionTestUtils.getField(filter, "jwtAuthenticationUtil")); + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/GeneratorTest.java b/src/test/java/com/iemr/common/bengen/utils/GeneratorTest.java new file mode 100644 index 0000000..c68ef69 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/GeneratorTest.java @@ -0,0 +1,174 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.utils; + +import java.math.BigInteger; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("Generator Test Suite") +class GeneratorTest { + + private static Level originalLevel; + + private Generator generator; + + @BeforeAll + @DisplayName("Enable debug logging so the generator's diagnostic branches are exercised") + static void enableDebugLogging() { + Logger logger = (Logger) LoggerFactory.getLogger(Generator.class); + originalLevel = logger.getLevel(); + logger.setLevel(Level.DEBUG); + } + + @AfterAll + @DisplayName("Restore the original log level") + static void restoreLogging() { + ((Logger) LoggerFactory.getLogger(Generator.class)).setLevel(originalLevel); + } + + @BeforeEach + @DisplayName("Create a generator before each test") + void setUp() { + generator = new Generator(); + } + + @Nested + @DisplayName("Beneficiary id generation") + class GenerateBeneficiaryIdTests { + + @RepeatedTest(value = 5, name = "run {currentRepetition} of {totalRepetitions}") + @DisplayName("generateBeneficiaryId should produce a 12-digit id") + void generateBeneficiaryId_shouldProduceTwelveDigitId() { + BigInteger id = generator.generateBeneficiaryId(); + + assertEquals(12, id.toString().length(), "beneficiary ids are 12 digits: " + id); + } + + @RepeatedTest(value = 5, name = "run {currentRepetition} of {totalRepetitions}") + @DisplayName("generateBeneficiaryId should end with the Verhoeff digit of its zero-terminated base") + void generateBeneficiaryId_shouldEndWithVerhoeffDigitOfZeroTerminatedBase() { + String id = generator.generateBeneficiaryId().toString(); + + // The generator derives the check digit from the base value while its last + // position is still a zero, then adds the digit into that position. Note this + // is NOT the standard convention, under which the digit would be derived from + // the 11-digit prefix alone, so Verhoeff.validateVerhoeff(id) usually fails. + String zeroTerminatedBase = id.substring(0, id.length() - 1) + "0"; + assertEquals(id.substring(id.length() - 1), Verhoeff.generateVerhoeff(zeroTerminatedBase), + "the trailing digit must be the Verhoeff digit of " + zeroTerminatedBase); + } + + @RepeatedTest(value = 5, name = "run {currentRepetition} of {totalRepetitions}") + @DisplayName("generateFirst should start with a digit between 2 and 9") + void generateFirst_shouldStartWithDigitBetweenTwoAndNine() { + char leading = generator.generateFirst().toString().charAt(0); + + assertTrue(leading >= '2' && leading <= '9', "leading digit was " + leading); + } + } + + @Nested + @DisplayName("Digit counting") + class DigitCountTests { + + @Test + @DisplayName("getDigitCount should count the digits of a single-digit number") + void getDigitCount_shouldCountSingleDigit() { + assertEquals(1, generator.getDigitCount(BigInteger.valueOf(7))); + } + + @Test + @DisplayName("getDigitCount should count the digits at a power-of-ten boundary") + void getDigitCount_shouldCountAtPowerOfTenBoundary() { + assertEquals(3, generator.getDigitCount(BigInteger.valueOf(100))); + assertEquals(3, generator.getDigitCount(BigInteger.valueOf(999))); + assertEquals(4, generator.getDigitCount(BigInteger.valueOf(1000))); + } + + @Test + @DisplayName("getDigitCount should count the digits of a twelve-digit beneficiary id") + void getDigitCount_shouldCountTwelveDigitId() { + assertEquals(12, generator.getDigitCount(new BigInteger("753812721192"))); + } + } + + @Nested + @DisplayName("Random helpers") + class RandomHelperTests { + + @RepeatedTest(value = 10, name = "run {currentRepetition} of {totalRepetitions}") + @DisplayName("getRandomNum should return a single decimal digit") + void getRandomNum_shouldReturnSingleDecimalDigit() { + int num = generator.getRandomNum(); + + assertTrue(num >= 0 && num <= 9, "expected a single digit but got " + num); + } + + @RepeatedTest(value = 10, name = "run {currentRepetition} of {totalRepetitions}") + @DisplayName("getRandomNumRad should return a value inside the requested radix") + void getRandomNumRad_shouldReturnValueInsideRadix() { + int num = generator.getRandomNumRad(5); + + assertTrue(num >= 0 && num < 5, "expected a value below the radix but got " + num); + } + + @RepeatedTest(value = 10, name = "run {currentRepetition} of {totalRepetitions}") + @DisplayName("getRandomNumRadRange should return a value inside the requested range") + void getRandomNumRadRange_shouldReturnValueInsideRange() { + int num = generator.getRandomNumRadRange(2, 9); + + assertTrue(num >= 2 && num <= 9, "expected a value within 2..9 but got " + num); + } + } + + @Nested + @DisplayName("Diagnostics") + class DiagnosticsTests { + + @Test + @DisplayName("displayArrays should log both arrays without failing") + void displayArrays_shouldLogBothArrays() { + assertDoesNotThrow(() -> generator.displayArrays(new int[] { 1, 2, 3 }, new int[] { 4, 5, 6 })); + } + + @Test + @DisplayName("displayArrays should tolerate empty arrays") + void displayArrays_shouldTolerateEmptyArrays() { + assertDoesNotThrow(() -> generator.displayArrays(new int[0], new int[0])); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/IEMRApplBeansTest.java b/src/test/java/com/iemr/common/bengen/utils/IEMRApplBeansTest.java new file mode 100644 index 0000000..35351fd --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/IEMRApplBeansTest.java @@ -0,0 +1,101 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.utils; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.test.util.ReflectionTestUtils; + +import com.iemr.common.bengen.utils.gateway.email.EmailService; +import com.iemr.common.bengen.utils.gateway.email.GenericEmailServiceImpl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("IEMRApplBeans Test Suite") +class IEMRApplBeansTest { + + private IEMRApplBeans beans; + + @BeforeEach + @DisplayName("Create the bean configuration with Redis coordinates before each test") + void setUp() { + beans = new IEMRApplBeans(); + ReflectionTestUtils.setField(beans, "redisHost", "redis.example.org"); + ReflectionTestUtils.setField(beans, "redisPort", 6379); + } + + @Test + @DisplayName("getVaidator should supply a validator bean") + void getVaidator_shouldSupplyValidatorBean() { + assertNotNull(beans.getVaidator()); + } + + @Test + @DisplayName("getEmailService should supply the generic email implementation") + void getEmailService_shouldSupplyGenericImplementation() { + EmailService emailService = beans.getEmailService(); + + assertTrue(emailService instanceof GenericEmailServiceImpl); + } + + @Test + @DisplayName("getJavaMailSender should supply a JavaMailSender implementation") + void getJavaMailSender_shouldSupplyMailSenderImplementation() { + JavaMailSender mailSender = beans.getJavaMailSender(); + + assertTrue(mailSender instanceof JavaMailSenderImpl); + } + + @Test + @DisplayName("configProperties should supply a fresh properties holder on each call") + void configProperties_shouldSupplyFreshHolder() { + assertNotSame(beans.configProperties(), beans.configProperties()); + } + + @Test + @DisplayName("sessionObject should supply a session holder bean") + void sessionObject_shouldSupplySessionHolder() { + assertNotNull(beans.sessionObject()); + } + + @Test + @DisplayName("redisStorage should supply a Redis store bean") + void redisStorage_shouldSupplyRedisStore() { + assertNotNull(beans.redisStorage()); + } + + @Test + @DisplayName("connectionFactory should point Lettuce at the configured host and port") + void connectionFactory_shouldPointAtConfiguredHostAndPort() { + LettuceConnectionFactory factory = beans.connectionFactory(); + + assertEquals("redis.example.org", factory.getHostName()); + assertEquals(6379, factory.getPort()); + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/JwtAuthenticationUtilTest.java b/src/test/java/com/iemr/common/bengen/utils/JwtAuthenticationUtilTest.java new file mode 100644 index 0000000..b3c8ffc --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/JwtAuthenticationUtilTest.java @@ -0,0 +1,253 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.utils; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; + +import com.iemr.common.bengen.data.user.User; +import com.iemr.common.bengen.repo.UserLoginRepo; + +import io.jsonwebtoken.Claims; +import jakarta.servlet.http.HttpServletRequest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("JwtAuthenticationUtil Test Suite") +class JwtAuthenticationUtilTest { + + private static final String JWT_TOKEN = "a.jwt.token"; + private static final String USER_ID = "42"; + private static final String REDIS_KEY = "user_42"; + + @Mock + private CookieUtil cookieUtil; + + @Mock + private JwtUtil jwtUtil; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + @Mock + private UserLoginRepo userLoginRepo; + + @Mock + private Claims claims; + + @Mock + private HttpServletRequest request; + + private JwtAuthenticationUtil jwtAuthenticationUtil; + + @BeforeEach + @DisplayName("Wire the util with mocked cookie, JWT, Redis and repository collaborators") + void setUp() { + jwtAuthenticationUtil = new JwtAuthenticationUtil(cookieUtil, jwtUtil); + ReflectionTestUtils.setField(jwtAuthenticationUtil, "redisTemplate", redisTemplate); + ReflectionTestUtils.setField(jwtAuthenticationUtil, "userLoginRepo", userLoginRepo); + } + + private User user(long id, String name) { + User user = new User(); + user.setUserID(id); + user.setUserName(name); + return user; + } + + @Nested + @DisplayName("validateJwtToken from the request cookie") + class ValidateJwtTokenTests { + + @Test + @DisplayName("validateJwtToken should return 401 when the Jwttoken cookie is absent") + void validateJwtToken_shouldReturnUnauthorizedWhenCookieMissing() { + when(cookieUtil.getCookieValue(request, "Jwttoken")).thenReturn(Optional.empty()); + + ResponseEntity result = jwtAuthenticationUtil.validateJwtToken(request); + + assertEquals(HttpStatus.UNAUTHORIZED, result.getStatusCode()); + assertEquals("Error 401: Unauthorized - JWT Token is not set!", result.getBody()); + verify(jwtUtil, never()).validateToken(anyString()); + } + + @Test + @DisplayName("validateJwtToken should return 401 when the token cannot be validated") + void validateJwtToken_shouldReturnUnauthorizedWhenTokenInvalid() { + when(cookieUtil.getCookieValue(request, "Jwttoken")).thenReturn(Optional.of(JWT_TOKEN)); + when(jwtUtil.validateToken(JWT_TOKEN)).thenReturn(null); + + ResponseEntity result = jwtAuthenticationUtil.validateJwtToken(request); + + assertEquals(HttpStatus.UNAUTHORIZED, result.getStatusCode()); + assertEquals("Error 401: Unauthorized - Invalid JWT Token!", result.getBody()); + } + + @Test + @DisplayName("validateJwtToken should return 401 when the token carries no subject") + void validateJwtToken_shouldReturnUnauthorizedWhenSubjectMissing() { + when(cookieUtil.getCookieValue(request, "Jwttoken")).thenReturn(Optional.of(JWT_TOKEN)); + when(jwtUtil.validateToken(JWT_TOKEN)).thenReturn(claims); + when(claims.getSubject()).thenReturn(null); + + ResponseEntity result = jwtAuthenticationUtil.validateJwtToken(request); + + assertEquals(HttpStatus.UNAUTHORIZED, result.getStatusCode()); + assertEquals("Error 401: Unauthorized - Username is missing!", result.getBody()); + } + + @Test + @DisplayName("validateJwtToken should return 401 when the subject is blank") + void validateJwtToken_shouldReturnUnauthorizedWhenSubjectIsBlank() { + when(cookieUtil.getCookieValue(request, "Jwttoken")).thenReturn(Optional.of(JWT_TOKEN)); + when(jwtUtil.validateToken(JWT_TOKEN)).thenReturn(claims); + when(claims.getSubject()).thenReturn(""); + + ResponseEntity result = jwtAuthenticationUtil.validateJwtToken(request); + + assertEquals(HttpStatus.UNAUTHORIZED, result.getStatusCode()); + } + + @Test + @DisplayName("validateJwtToken should return 200 with the username for a valid token") + void validateJwtToken_shouldReturnUsernameForValidToken() { + when(cookieUtil.getCookieValue(request, "Jwttoken")).thenReturn(Optional.of(JWT_TOKEN)); + when(jwtUtil.validateToken(JWT_TOKEN)).thenReturn(claims); + when(claims.getSubject()).thenReturn("amrit-user"); + + ResponseEntity result = jwtAuthenticationUtil.validateJwtToken(request); + + assertEquals(HttpStatus.OK, result.getStatusCode()); + assertEquals("amrit-user", result.getBody()); + } + } + + @Nested + @DisplayName("validateUserIdAndJwtToken") + class ValidateUserIdAndJwtTokenTests { + + @Test + @DisplayName("validateUserIdAndJwtToken should accept a token whose user is already cached in Redis") + void validateUserIdAndJwtToken_shouldAcceptUserFromRedisCache() throws Exception { + when(jwtUtil.validateToken(JWT_TOKEN)).thenReturn(claims); + when(claims.get("userId", String.class)).thenReturn(USER_ID); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get(REDIS_KEY)).thenReturn(user(42L, "amrit-user")); + + assertTrue(jwtAuthenticationUtil.validateUserIdAndJwtToken(JWT_TOKEN)); + verify(userLoginRepo, never()).getUserByUserID(anyLong()); + } + + @Test + @DisplayName("validateUserIdAndJwtToken should fall back to the database and cache the user on a Redis miss") + void validateUserIdAndJwtToken_shouldFallBackToDatabaseAndCacheUser() throws Exception { + when(jwtUtil.validateToken(JWT_TOKEN)).thenReturn(claims); + when(claims.get("userId", String.class)).thenReturn(USER_ID); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get(REDIS_KEY)).thenReturn(null); + when(userLoginRepo.getUserByUserID(42L)).thenReturn(user(42L, "amrit-user")); + + assertTrue(jwtAuthenticationUtil.validateUserIdAndJwtToken(JWT_TOKEN)); + verify(valueOperations).set(eq(REDIS_KEY), any(User.class), eq(30L), eq(TimeUnit.MINUTES)); + } + + @Test + @DisplayName("validateUserIdAndJwtToken should reject a token that cannot be validated") + void validateUserIdAndJwtToken_shouldRejectInvalidToken() { + when(jwtUtil.validateToken(JWT_TOKEN)).thenReturn(null); + + Exception thrown = assertThrows(Exception.class, + () -> jwtAuthenticationUtil.validateUserIdAndJwtToken(JWT_TOKEN)); + + assertTrue(thrown.getMessage().contains("Invalid JWT token.")); + } + + @Test + @DisplayName("validateUserIdAndJwtToken should reject when the user exists in neither Redis nor the database") + void validateUserIdAndJwtToken_shouldRejectUnknownUser() { + when(jwtUtil.validateToken(JWT_TOKEN)).thenReturn(claims); + when(claims.get("userId", String.class)).thenReturn(USER_ID); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get(REDIS_KEY)).thenReturn(null); + when(userLoginRepo.getUserByUserID(42L)).thenReturn(null); + + Exception thrown = assertThrows(Exception.class, + () -> jwtAuthenticationUtil.validateUserIdAndJwtToken(JWT_TOKEN)); + + assertTrue(thrown.getMessage().contains("Invalid User ID.")); + } + + @Test + @DisplayName("validateUserIdAndJwtToken should reject a non-numeric userId claim") + void validateUserIdAndJwtToken_shouldRejectNonNumericUserId() { + when(jwtUtil.validateToken(JWT_TOKEN)).thenReturn(claims); + when(claims.get("userId", String.class)).thenReturn("not-a-number"); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + lenient().when(valueOperations.get("user_not-a-number")).thenReturn(null); + + Exception thrown = assertThrows(Exception.class, + () -> jwtAuthenticationUtil.validateUserIdAndJwtToken(JWT_TOKEN)); + + assertTrue(thrown.getMessage().startsWith("Validation error: ")); + } + + @Test + @DisplayName("validateUserIdAndJwtToken should wrap a Redis outage as a validation error") + void validateUserIdAndJwtToken_shouldWrapRedisOutage() { + when(jwtUtil.validateToken(JWT_TOKEN)).thenReturn(claims); + when(claims.get("userId", String.class)).thenReturn(USER_ID); + when(redisTemplate.opsForValue()).thenThrow(new IllegalStateException("redis down")); + + Exception thrown = assertThrows(Exception.class, + () -> jwtAuthenticationUtil.validateUserIdAndJwtToken(JWT_TOKEN)); + + assertTrue(thrown.getMessage().contains("redis down")); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/JwtUserIdValidationFilterTest.java b/src/test/java/com/iemr/common/bengen/utils/JwtUserIdValidationFilterTest.java new file mode 100644 index 0000000..a08a6a6 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/JwtUserIdValidationFilterTest.java @@ -0,0 +1,446 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.utils; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import com.iemr.common.bengen.utils.http.AuthorizationHeaderRequestWrapper; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; + +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("JwtUserIdValidationFilter Test Suite") +class JwtUserIdValidationFilterTest { + + private static final String ALLOWED_ORIGINS = "https://amrit.example.org,http://localhost:*"; + private static final String ALLOWED_ORIGIN = "https://amrit.example.org"; + private static final String DISALLOWED_ORIGIN = "https://evil.example.com"; + + @Mock + private JwtAuthenticationUtil jwtAuthenticationUtil; + + @Mock + private FilterChain filterChain; + + private JwtUserIdValidationFilter filter; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @BeforeEach + @DisplayName("Set up the filter with a configured allow-list before each test") + void setUp() { + filter = new JwtUserIdValidationFilter(jwtAuthenticationUtil, ALLOWED_ORIGINS); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + } + + @Nested + @DisplayName("Origin validation and CORS") + class OriginValidationTests { + + @Test + @DisplayName("doFilter should reject an OPTIONS request that carries no Origin header") + void doFilter_shouldRejectOptionsWithoutOrigin() throws Exception { + request.setMethod("OPTIONS"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + + filter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_FORBIDDEN, response.getStatus()); + assertEquals("OPTIONS request requires Origin header", response.getErrorMessage()); + verify(filterChain, never()).doFilter(any(ServletRequest.class), any(ServletResponse.class)); + } + + @Test + @DisplayName("doFilter should reject an OPTIONS request from an origin outside the allow-list") + void doFilter_shouldRejectOptionsFromDisallowedOrigin() throws Exception { + request.setMethod("OPTIONS"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Origin", DISALLOWED_ORIGIN); + + filter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_FORBIDDEN, response.getStatus()); + assertEquals("Origin not allowed", response.getErrorMessage()); + verify(filterChain, never()).doFilter(any(ServletRequest.class), any(ServletResponse.class)); + } + + @Test + @DisplayName("doFilter should answer an allowed OPTIONS preflight with 200 and the CORS headers") + void doFilter_shouldAnswerAllowedPreflightWithCorsHeaders() throws Exception { + request.setMethod("OPTIONS"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Origin", ALLOWED_ORIGIN); + + filter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + assertEquals(ALLOWED_ORIGIN, response.getHeader("Access-Control-Allow-Origin")); + assertEquals("GET, POST, PUT, PATCH, DELETE, OPTIONS", + response.getHeader("Access-Control-Allow-Methods")); + assertEquals("true", response.getHeader("Access-Control-Allow-Credentials")); + assertEquals("3600", response.getHeader("Access-Control-Max-Age")); + assertNotNull(response.getHeader("Access-Control-Allow-Headers")); + verify(filterChain, never()).doFilter(any(ServletRequest.class), any(ServletResponse.class)); + } + + @Test + @DisplayName("doFilter should match a wildcard localhost origin pattern") + void doFilter_shouldMatchWildcardLocalhostOrigin() throws Exception { + request.setMethod("OPTIONS"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Origin", "http://localhost:4200"); + + filter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + assertEquals("http://localhost:4200", response.getHeader("Access-Control-Allow-Origin")); + } + + @Test + @DisplayName("doFilter should reject a non-OPTIONS request from an origin outside the allow-list") + void doFilter_shouldRejectNonOptionsFromDisallowedOrigin() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Origin", DISALLOWED_ORIGIN); + + filter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_FORBIDDEN, response.getStatus()); + assertEquals("Origin not allowed", response.getErrorMessage()); + verify(filterChain, never()).doFilter(any(ServletRequest.class), any(ServletResponse.class)); + } + + @Test + @DisplayName("doFilter should treat every origin as disallowed when no allow-list is configured") + void doFilter_shouldRejectAllOriginsWhenAllowListIsBlank() throws Exception { + JwtUserIdValidationFilter unconfiguredFilter = + new JwtUserIdValidationFilter(jwtAuthenticationUtil, " "); + request.setMethod("OPTIONS"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Origin", ALLOWED_ORIGIN); + + unconfiguredFilter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_FORBIDDEN, response.getStatus()); + } + + @Test + @DisplayName("doFilter should not add CORS headers when the request carries no Origin header") + void doFilter_shouldNotAddCorsHeadersWithoutOrigin() throws Exception { + request.setMethod("GET"); + request.setRequestURI("/health"); + + filter.doFilter(request, response, filterChain); + + assertNull(response.getHeader("Access-Control-Allow-Origin")); + verify(filterChain).doFilter(request, response); + } + } + + @Nested + @DisplayName("Public endpoints that bypass token validation") + class PublicEndpointTests { + + @Test + @DisplayName("doFilter should pass /health straight through without validating a token") + void doFilter_shouldSkipValidationForHealth() throws Exception { + request.setMethod("GET"); + request.setRequestURI("/health"); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + verify(jwtAuthenticationUtil, never()).validateUserIdAndJwtToken(anyString()); + } + + @Test + @DisplayName("doFilter should pass /version straight through without validating a token") + void doFilter_shouldSkipValidationForVersion() throws Exception { + request.setMethod("GET"); + request.setRequestURI("/version"); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + verify(jwtAuthenticationUtil, never()).validateUserIdAndJwtToken(anyString()); + } + + @Test + @DisplayName("doFilter should pass the login endpoint straight through without validating a token") + void doFilter_shouldSkipValidationForUserAuthenticate() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/user/userAuthenticate"); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + verify(jwtAuthenticationUtil, never()).validateUserIdAndJwtToken(anyString()); + } + + @Test + @DisplayName("doFilter should pass any /public path straight through without validating a token") + void doFilter_shouldSkipValidationForPublicPaths() throws Exception { + request.setMethod("GET"); + request.setRequestURI("/public/anything"); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + verify(jwtAuthenticationUtil, never()).validateUserIdAndJwtToken(anyString()); + } + + @Test + @DisplayName("doFilter should pass the concurrent-session logout endpoint straight through") + void doFilter_shouldSkipValidationForConcurrentSessionLogout() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/user/logOutUserFromConcurrentSession"); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + verify(jwtAuthenticationUtil, never()).validateUserIdAndJwtToken(anyString()); + } + } + + @Nested + @DisplayName("JWT token validation") + class TokenValidationTests { + + @Test + @DisplayName("doFilter should continue the chain when the cookie token is valid") + void doFilter_shouldContinueChainWhenCookieTokenIsValid() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.setCookies(new Cookie("Jwttoken", "cookie-token")); + when(jwtAuthenticationUtil.validateUserIdAndJwtToken("cookie-token")).thenReturn(true); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(any(AuthorizationHeaderRequestWrapper.class), any(ServletResponse.class)); + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + } + + @Test + @DisplayName("doFilter should reject with 401 when the cookie token is rejected") + void doFilter_shouldRejectWhenCookieTokenIsInvalid() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.setCookies(new Cookie("Jwttoken", "cookie-token")); + when(jwtAuthenticationUtil.validateUserIdAndJwtToken("cookie-token")).thenReturn(false); + + filter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + assertEquals("Unauthorized: Invalid or missing token", response.getErrorMessage()); + verify(filterChain, never()).doFilter(any(ServletRequest.class), any(ServletResponse.class)); + } + + @Test + @DisplayName("doFilter should continue the chain when the header token is valid") + void doFilter_shouldContinueChainWhenHeaderTokenIsValid() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("JwtToken", "header-token"); + when(jwtAuthenticationUtil.validateUserIdAndJwtToken("header-token")).thenReturn(true); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(any(AuthorizationHeaderRequestWrapper.class), any(ServletResponse.class)); + } + + @Test + @DisplayName("doFilter should reject with 401 when the header token is rejected") + void doFilter_shouldRejectWhenHeaderTokenIsInvalid() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("JwtToken", "header-token"); + when(jwtAuthenticationUtil.validateUserIdAndJwtToken("header-token")).thenReturn(false); + + filter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + verify(filterChain, never()).doFilter(any(ServletRequest.class), any(ServletResponse.class)); + } + + @Test + @DisplayName("doFilter should reject with 401 when no token is present at all") + void doFilter_shouldRejectWhenNoTokenPresent() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + + filter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + assertEquals("Unauthorized: Invalid or missing token", response.getErrorMessage()); + } + + @Test + @DisplayName("doFilter should surface a 401 carrying the message when validation throws") + void doFilter_shouldRejectWithErrorMessageWhenValidationThrows() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("JwtToken", "header-token"); + when(jwtAuthenticationUtil.validateUserIdAndJwtToken("header-token")) + .thenThrow(new IllegalStateException("token expired")); + + filter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + assertTrue(response.getErrorMessage().contains("token expired"), + "error message should carry the underlying cause"); + } + } + + @Nested + @DisplayName("Mobile client handling") + class MobileClientTests { + + @Test + @DisplayName("doFilter should let an okhttp client through on its Authorization header alone") + void doFilter_shouldAllowOkHttpClientWithAuthorizationHeader() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("User-Agent", "okhttp/4.9.0"); + request.addHeader("Authorization", "some-session-key"); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + verify(jwtAuthenticationUtil, never()).validateUserIdAndJwtToken(anyString()); + } + + @Test + @DisplayName("doFilter should let a java/ client through on its Authorization header alone") + void doFilter_shouldAllowJavaClientWithAuthorizationHeader() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("User-Agent", "Java/17.0.2"); + request.addHeader("Authorization", "some-session-key"); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + } + + @Test + @DisplayName("doFilter should clear the User-Agent context once the mobile request completes") + void doFilter_shouldClearUserAgentContextAfterMobileRequest() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("User-Agent", "okhttp/4.9.0"); + request.addHeader("Authorization", "some-session-key"); + + filter.doFilter(request, response, filterChain); + + assertNull(UserAgentContext.getUserAgent(), + "the thread-local User-Agent must not leak past the request"); + } + + @Test + @DisplayName("doFilter should reject a browser client that has no token") + void doFilter_shouldRejectBrowserClientWithoutToken() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("User-Agent", "Mozilla/5.0"); + request.addHeader("Authorization", "some-session-key"); + + filter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + verify(filterChain, never()).doFilter(any(ServletRequest.class), any(ServletResponse.class)); + } + + @Test + @DisplayName("doFilter should reject a mobile client that sends no Authorization header") + void doFilter_shouldRejectMobileClientWithoutAuthorizationHeader() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("User-Agent", "okhttp/4.9.0"); + + filter.doFilter(request, response, filterChain); + + assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + } + } + + @Nested + @DisplayName("userId cookie hygiene") + class UserIdCookieTests { + + @Test + @DisplayName("doFilter should expire any userId cookie the client sends") + void doFilter_shouldExpireUserIdCookie() throws Exception { + request.setMethod("GET"); + request.setRequestURI("/health"); + request.setCookies(new Cookie("userId", "1234")); + + filter.doFilter(request, response, filterChain); + + Cookie cleared = Arrays.stream(response.getCookies()) + .filter(cookie -> "userId".equals(cookie.getName())) + .findFirst() + .orElse(null); + assertNotNull(cleared, "a userId cookie should have been sent back to expire it"); + assertEquals(0, cleared.getMaxAge()); + assertEquals("/", cleared.getPath()); + assertTrue(cleared.isHttpOnly()); + assertTrue(cleared.getSecure()); + } + + @Test + @DisplayName("doFilter should leave unrelated cookies untouched") + void doFilter_shouldLeaveUnrelatedCookiesUntouched() throws Exception { + request.setMethod("GET"); + request.setRequestURI("/health"); + request.setCookies(new Cookie("theme", "dark")); + + filter.doFilter(request, response, filterChain); + + assertEquals(0, response.getCookies().length); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/JwtUtilTest.java b/src/test/java/com/iemr/common/bengen/utils/JwtUtilTest.java new file mode 100644 index 0000000..c44b2e6 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/JwtUtilTest.java @@ -0,0 +1,190 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.utils; + +import java.util.Date; + +import javax.crypto.SecretKey; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("JwtUtil Test Suite") +class JwtUtilTest { + + private static final String SECRET = "amrit-bengen-test-secret-key-that-is-long-enough-for-hs256"; + private static final String OTHER_SECRET = "a-completely-different-secret-key-also-long-enough-for-hs256"; + + @Mock + private TokenDenylist tokenDenylist; + + private JwtUtil jwtUtil; + + @BeforeEach + @DisplayName("Wire the util with a test secret and a mocked denylist before each test") + void setUp() { + jwtUtil = new JwtUtil(); + ReflectionTestUtils.setField(jwtUtil, "SECRET_KEY", SECRET); + ReflectionTestUtils.setField(jwtUtil, "tokenDenylist", tokenDenylist); + } + + private String token(String secret, String subject, String jti, Date expiry) { + SecretKey key = Keys.hmacShaKeyFor(secret.getBytes()); + var builder = Jwts.builder().subject(subject).signWith(key); + if (jti != null) { + builder.id(jti); + } + if (expiry != null) { + builder.expiration(expiry); + } + return builder.compact(); + } + + private String validToken(String subject, String jti) { + return token(SECRET, subject, jti, new Date(System.currentTimeMillis() + 600_000)); + } + + @Nested + @DisplayName("validateToken") + class ValidateTokenTests { + + @Test + @DisplayName("validateToken should return the claims for a correctly signed token") + void validateToken_shouldReturnClaimsForValidToken() { + when(tokenDenylist.isTokenDenylisted("jti-1")).thenReturn(false); + + Claims claims = jwtUtil.validateToken(validToken("amrit-user", "jti-1")); + + assertNotNull(claims); + assertEquals("amrit-user", claims.getSubject()); + assertEquals("jti-1", claims.getId()); + } + + @Test + @DisplayName("validateToken should skip the denylist check for a token without a jti") + void validateToken_shouldSkipDenylistCheckWithoutJti() { + Claims claims = jwtUtil.validateToken(validToken("amrit-user", null)); + + assertNotNull(claims); + assertEquals("amrit-user", claims.getSubject()); + } + + @Test + @DisplayName("validateToken should reject a token whose jti has been denylisted") + void validateToken_shouldRejectDenylistedToken() { + when(tokenDenylist.isTokenDenylisted("jti-1")).thenReturn(true); + + assertNull(jwtUtil.validateToken(validToken("amrit-user", "jti-1"))); + } + + @Test + @DisplayName("validateToken should reject a token signed with a different secret") + void validateToken_shouldRejectTokenSignedWithDifferentSecret() { + String foreign = token(OTHER_SECRET, "amrit-user", "jti-1", + new Date(System.currentTimeMillis() + 600_000)); + + assertNull(jwtUtil.validateToken(foreign)); + } + + @Test + @DisplayName("validateToken should reject an expired token") + void validateToken_shouldRejectExpiredToken() { + String expired = token(SECRET, "amrit-user", "jti-1", + new Date(System.currentTimeMillis() - 60_000)); + + assertNull(jwtUtil.validateToken(expired)); + } + + @Test + @DisplayName("validateToken should reject a malformed token") + void validateToken_shouldRejectMalformedToken() { + assertNull(jwtUtil.validateToken("not-a-jwt")); + } + + @Test + @DisplayName("validateToken should reject a null token") + void validateToken_shouldRejectNullToken() { + assertNull(jwtUtil.validateToken(null)); + } + + @Test + @DisplayName("validateToken should reject every token when no secret is configured") + void validateToken_shouldRejectWhenSecretIsNotConfigured() { + String signed = validToken("amrit-user", null); + ReflectionTestUtils.setField(jwtUtil, "SECRET_KEY", null); + + assertNull(jwtUtil.validateToken(signed)); + } + } + + @Nested + @DisplayName("Claim extraction") + class ClaimExtractionTests { + + @Test + @DisplayName("extractUsername should return the token subject") + void extractUsername_shouldReturnSubject() { + assertEquals("amrit-user", jwtUtil.extractUsername(validToken("amrit-user", null))); + } + + @Test + @DisplayName("extractClaim should apply the supplied resolver to the claims") + void extractClaim_shouldApplySuppliedResolver() { + lenient().when(tokenDenylist.isTokenDenylisted("jti-9")).thenReturn(false); + + assertEquals("jti-9", jwtUtil.extractClaim(validToken("amrit-user", "jti-9"), Claims::getId)); + } + + @Test + @DisplayName("extractClaim should raise when the token cannot be parsed") + void extractClaim_shouldRaiseForMalformedToken() { + assertThrows(Exception.class, () -> jwtUtil.extractClaim("not-a-jwt", Claims::getSubject)); + } + + @Test + @DisplayName("extractUsername should raise when no secret is configured") + void extractUsername_shouldRaiseWhenSecretIsNotConfigured() { + String signed = validToken("amrit-user", null); + ReflectionTestUtils.setField(jwtUtil, "SECRET_KEY", ""); + + assertThrows(IllegalStateException.class, () -> jwtUtil.extractUsername(signed)); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/OutputResponseBuilderTest.java b/src/test/java/com/iemr/common/bengen/utils/OutputResponseBuilderTest.java new file mode 100644 index 0000000..e59956f --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/OutputResponseBuilderTest.java @@ -0,0 +1,366 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.utils; + +import java.io.IOException; +import java.net.ConnectException; +import java.sql.SQLException; +import java.text.ParseException; + +import org.json.JSONException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.iemr.common.bengen.utils.exception.IEMRException; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("OutputResponse.Builder Test Suite") +class OutputResponseBuilderTest { + + private JsonObject build(OutputResponse.Builder builder) { + return builder.build().getResponse().getAsJsonObject(); + } + + @Nested + @DisplayName("Fluent field assembly") + class FieldAssemblyTests { + + @Test + @DisplayName("build should default every field to an empty value") + void build_shouldDefaultEveryFieldToEmptyValue() { + JsonObject response = build(new OutputResponse.Builder()); + + assertEquals("", response.get("methodName").getAsString()); + assertEquals("", response.get("dataObjectType").getAsString()); + assertEquals("", response.get("dataJsonType").getAsString()); + assertEquals("", response.get("data").getAsString()); + assertEquals(0, response.get("statusCode").getAsInt()); + assertEquals("", response.get("statusMessage").getAsString()); + assertEquals("", response.get("statusMessageLong").getAsString()); + } + + @Test + @DisplayName("build should carry every explicitly set field into the response") + void build_shouldCarryEverySetFieldIntoResponse() { + JsonObject response = build(new OutputResponse.Builder() + .setMethodName("generateBeneficiaryIDs") + .setDataObjectType("BeneficiaryId") + .setDataJsonType("array") + .setData("[1,2,3]") + .setStatusCode(OutputResponse.Builder.SUCCESS) + .setStatusMessage("Success") + .setStatusMessageLong("Generated successfully")); + + assertEquals("generateBeneficiaryIDs", response.get("methodName").getAsString()); + assertEquals("BeneficiaryId", response.get("dataObjectType").getAsString()); + assertEquals("array", response.get("dataJsonType").getAsString()); + assertEquals("[1,2,3]", response.get("data").getAsString()); + assertEquals(200, response.get("statusCode").getAsInt()); + assertEquals("Success", response.get("statusMessage").getAsString()); + assertEquals("Generated successfully", response.get("statusMessageLong").getAsString()); + } + + @Test + @DisplayName("each setter should return the same builder so calls can be chained") + void setters_shouldReturnSameBuilderForChaining() { + OutputResponse.Builder builder = new OutputResponse.Builder(); + + assertEquals(builder, builder.setMethodName("m")); + assertEquals(builder, builder.setDataObjectType("o")); + assertEquals(builder, builder.setDataJsonType("object")); + assertEquals(builder, builder.setData("{}")); + assertEquals(builder, builder.setStatusCode(200)); + assertEquals(builder, builder.setStatusMessage("s")); + assertEquals(builder, builder.setStatusMessageLong("l")); + assertEquals(builder, builder.setErrorMessage(new IEMRException("x"))); + } + + @Test + @DisplayName("toString should serialise only the exposed response field") + void toString_shouldSerialiseOnlyExposedResponseField() { + String json = new OutputResponse.Builder() + .setMethodName("generateBeneficiaryIDs") + .setStatusCode(OutputResponse.Builder.SUCCESS) + .build() + .toString(); + + assertTrue(json.startsWith("{\"response\":")); + assertTrue(json.contains("generateBeneficiaryIDs")); + assertTrue(json.contains("\"statusCode\":200")); + } + } + + @Nested + @DisplayName("setErrorMessage mapped from a throwable") + class ErrorMappingTests { + + @Test + @DisplayName("setErrorMessage should map IEMRException to a user login failure") + void setErrorMessage_shouldMapIemrExceptionToUserIdFailure() { + JsonObject response = build(new OutputResponse.Builder() + .setErrorMessage(new IEMRException("invalid credentials"))); + + assertEquals(OutputResponse.Builder.USERID_FAILURE, response.get("statusCode").getAsInt()); + assertEquals("User login failed", response.get("statusMessage").getAsString()); + assertEquals("invalid credentials", response.get("statusMessageLong").getAsString()); + } + + @Test + @DisplayName("setErrorMessage should map JSONException to an object conversion failure") + void setErrorMessage_shouldMapJsonExceptionToObjectFailure() { + JsonObject response = build(new OutputResponse.Builder() + .setErrorMessage(new JSONException("bad json"))); + + assertEquals(OutputResponse.Builder.OBJECT_FAILURE, response.get("statusCode").getAsInt()); + assertEquals("Invalid object conversion", response.get("statusMessage").getAsString()); + } + + @Test + @DisplayName("setErrorMessage should map SQLException to a code exception") + void setErrorMessage_shouldMapSqlExceptionToCodeException() { + JsonObject response = build(new OutputResponse.Builder() + .setErrorMessage(new SQLException("deadlock"))); + + assertEquals(OutputResponse.Builder.CODE_EXCEPTION, response.get("statusCode").getAsInt()); + assertTrue(response.get("statusMessage").getAsString().startsWith("Failed with critical errors at ")); + assertEquals("deadlock", response.get("statusMessageLong").getAsString()); + } + + @Test + @DisplayName("setErrorMessage should map ParseException to a code exception") + void setErrorMessage_shouldMapParseExceptionToCodeException() { + JsonObject response = build(new OutputResponse.Builder() + .setErrorMessage(new ParseException("bad date", 0))); + + assertEquals(OutputResponse.Builder.CODE_EXCEPTION, response.get("statusCode").getAsInt()); + } + + @Test + @DisplayName("setErrorMessage should map NullPointerException to a code exception") + void setErrorMessage_shouldMapNullPointerExceptionToCodeException() { + JsonObject response = build(new OutputResponse.Builder() + .setErrorMessage(new NullPointerException("npe"))); + + assertEquals(OutputResponse.Builder.CODE_EXCEPTION, response.get("statusCode").getAsInt()); + } + + @Test + @DisplayName("setErrorMessage should map ArrayIndexOutOfBoundsException to a code exception") + void setErrorMessage_shouldMapArrayIndexExceptionToCodeException() { + JsonObject response = build(new OutputResponse.Builder() + .setErrorMessage(new ArrayIndexOutOfBoundsException("index 5"))); + + assertEquals(OutputResponse.Builder.CODE_EXCEPTION, response.get("statusCode").getAsInt()); + } + + @Test + @DisplayName("setErrorMessage should map IOException to an environment exception") + void setErrorMessage_shouldMapIoExceptionToEnvironmentException() { + JsonObject response = build(new OutputResponse.Builder() + .setErrorMessage(new IOException("disk full"))); + + assertEquals(OutputResponse.Builder.ENVIRONMENT_EXCEPTION, response.get("statusCode").getAsInt()); + assertTrue(response.get("statusMessage").getAsString().startsWith("Failed with connection issues at ")); + } + + @Test + @DisplayName("setErrorMessage should map ConnectException to an environment exception") + void setErrorMessage_shouldMapConnectExceptionToEnvironmentException() { + JsonObject response = build(new OutputResponse.Builder() + .setErrorMessage(new ConnectException("refused"))); + + assertEquals(OutputResponse.Builder.ENVIRONMENT_EXCEPTION, response.get("statusCode").getAsInt()); + } + + @Test + @DisplayName("setErrorMessage should fall back to a generic failure for an unmapped exception") + void setErrorMessage_shouldFallBackToGenericFailureForUnmappedException() { + JsonObject response = build(new OutputResponse.Builder() + .setErrorMessage(new IllegalStateException("something odd"))); + + assertEquals(OutputResponse.Builder.GENERIC_FAILURE, response.get("statusCode").getAsInt()); + assertEquals("Failed with generic exception", response.get("statusMessage").getAsString()); + assertEquals("something odd", response.get("statusMessageLong").getAsString()); + } + } + + @Nested + @DisplayName("Value semantics of the built response") + class ValueSemanticsTests { + + private OutputResponse response(String methodName) { + return new OutputResponse.Builder().setMethodName(methodName).build(); + } + + @Test + @DisplayName("two responses built from the same fields should be equal and share a hash code") + void responsesFromSameFields_shouldBeEqual() { + assertEquals(response("m"), response("m")); + assertEquals(response("m").hashCode(), response("m").hashCode()); + } + + @Test + @DisplayName("responses built from different fields should not be equal") + void responsesFromDifferentFields_shouldNotBeEqual() { + assertNotEquals(response("first"), response("second")); + } + + @Test + @DisplayName("a response should equal itself and never equal null or an unrelated type") + void response_shouldEqualItselfAndNotOtherTypes() { + OutputResponse built = response("m"); + + assertEquals(built, built); + assertNotEquals(built, null); + assertNotEquals(built, "not an OutputResponse"); + } + + @Test + @DisplayName("getResponse should expose the assembled JSON element") + void getResponse_shouldExposeAssembledJsonElement() { + JsonElement element = response("generateBeneficiaryIDs").getResponse(); + + assertNotNull(element); + assertEquals("generateBeneficiaryIDs", + element.getAsJsonObject().get("methodName").getAsString()); + } + + @Test + @DisplayName("setResponse should replace the assembled payload") + void setResponse_shouldReplaceAssembledPayload() { + OutputResponse built = response("m"); + JsonObject replacement = new JsonObject(); + replacement.addProperty("methodName", "replaced"); + + built.setResponse(replacement); + + assertEquals("replaced", built.getResponse().getAsJsonObject().get("methodName").getAsString()); + } + + @Test + @DisplayName("a response with a null payload should differ from one carrying a payload") + void responseWithNullPayload_shouldDifferFromPopulated() { + OutputResponse built = response("m"); + OutputResponse blank = response("m"); + blank.setResponse(null); + + assertNotEquals(built, blank); + assertNotEquals(blank, built); + assertDoesNotThrow(blank::hashCode); + assertDoesNotThrow(blank::toString); + } + } + + @Nested + @DisplayName("Error mapping for exception types raised by other AMRIT modules") + class ExternalExceptionMappingTests { + + // The mapping switches on getClass().getSimpleName(), so locally declared types + // with the same simple names reach the arms meant for Hibernate/JDBC exceptions. + private static class MissingMandatoryFieldsException extends Exception { + MissingMandatoryFieldsException(String message) { + super(message); + } + } + + private static class IllegalActionException extends Exception { + IllegalActionException(String message) { + super(message); + } + } + + private static class JDBCException extends Exception { + JDBCException(String message) { + super(message); + } + } + + private static class SQLGrammarException extends Exception { + SQLGrammarException(String message) { + super(message); + } + } + + private static class ConstraintViolationException extends Exception { + ConstraintViolationException(String message) { + super(message); + } + } + + @Test + @DisplayName("setErrorMessage should map a missing mandatory field to the params-missing code") + void setErrorMessage_shouldMapMissingMandatoryFields() { + JsonObject response = build(new OutputResponse.Builder() + .setErrorMessage(new MissingMandatoryFieldsException("benCount is required"))); + + assertEquals(OutputResponse.Builder.MANDATORY_PARAMS_MISSING, response.get("statusCode").getAsInt()); + assertEquals("Missing Mandatory Parameters.", response.get("statusMessage").getAsString()); + assertEquals("benCount is required", response.get("statusMessageLong").getAsString()); + } + + @Test + @DisplayName("setErrorMessage should map an illegal action to the illegal-action code") + void setErrorMessage_shouldMapIllegalAction() { + JsonObject response = build(new OutputResponse.Builder() + .setErrorMessage(new IllegalActionException("not permitted"))); + + assertEquals(OutputResponse.Builder.ILLEGAL_ACTION, response.get("statusCode").getAsInt()); + assertTrue(response.get("statusMessage").getAsString().startsWith("Illegal Action performed")); + } + + @Test + @DisplayName("setErrorMessage should map a JDBC failure to the environment code") + void setErrorMessage_shouldMapJdbcFailure() { + JsonObject response = build(new OutputResponse.Builder() + .setErrorMessage(new JDBCException("pool exhausted"))); + + assertEquals(OutputResponse.Builder.ENVIRONMENT_EXCEPTION, response.get("statusCode").getAsInt()); + assertTrue(response.get("statusMessage").getAsString().startsWith("Failed with DB connection issues at ")); + } + + @Test + @DisplayName("setErrorMessage should map a SQL grammar failure to the code-exception code") + void setErrorMessage_shouldMapSqlGrammarFailure() { + JsonObject response = build(new OutputResponse.Builder() + .setErrorMessage(new SQLGrammarException("bad column"))); + + assertEquals(OutputResponse.Builder.CODE_EXCEPTION, response.get("statusCode").getAsInt()); + } + + @Test + @DisplayName("setErrorMessage should map a constraint violation to the code-exception code") + void setErrorMessage_shouldMapConstraintViolation() { + JsonObject response = build(new OutputResponse.Builder() + .setErrorMessage(new ConstraintViolationException("duplicate key"))); + + assertEquals(OutputResponse.Builder.CODE_EXCEPTION, response.get("statusCode").getAsInt()); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/RestTemplateUtilTest.java b/src/test/java/com/iemr/common/bengen/utils/RestTemplateUtilTest.java new file mode 100644 index 0000000..9e1d9f7 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/RestTemplateUtilTest.java @@ -0,0 +1,158 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.utils; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import jakarta.servlet.http.Cookie; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +@DisplayName("RestTemplateUtil Test Suite") +class RestTemplateUtilTest { + + private static final String AUTHORIZATION = "session-key-123"; + private static final String BODY = "{\"benCount\":5}"; + private static final String JSON_UTF8 = "application/json;charset=utf-8"; + + private MockHttpServletRequest request; + + @BeforeEach + @DisplayName("Bind a fresh mock request to the request context before each test") + void setUp() { + request = new MockHttpServletRequest(); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + + @AfterEach + @DisplayName("Clear the request context and User-Agent thread local after each test") + void tearDown() { + RequestContextHolder.resetRequestAttributes(); + UserAgentContext.clear(); + } + + @Nested + @DisplayName("Outside a web request") + class NoRequestContextTests { + + @Test + @DisplayName("createRequestEntity should build a minimal entity when no request is bound") + void createRequestEntity_shouldBuildMinimalEntityWithoutRequestContext() { + RequestContextHolder.resetRequestAttributes(); + + HttpEntity entity = RestTemplateUtil.createRequestEntity(BODY, AUTHORIZATION); + + assertSame(BODY, entity.getBody()); + assertEquals(JSON_UTF8, entity.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE)); + assertEquals(AUTHORIZATION, entity.getHeaders().getFirst(HttpHeaders.AUTHORIZATION)); + assertFalse(entity.getHeaders().containsKey("JwtToken")); + assertFalse(entity.getHeaders().containsKey(HttpHeaders.COOKIE)); + } + } + + @Nested + @DisplayName("Inside a web request") + class WithRequestContextTests { + + @Test + @DisplayName("createRequestEntity should carry the content type and authorization from the caller") + void createRequestEntity_shouldCarryContentTypeAndAuthorization() { + HttpEntity entity = RestTemplateUtil.createRequestEntity(BODY, AUTHORIZATION); + + assertSame(BODY, entity.getBody()); + assertEquals(JSON_UTF8, entity.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE)); + assertEquals(AUTHORIZATION, entity.getHeaders().getFirst(HttpHeaders.AUTHORIZATION)); + } + + @Test + @DisplayName("createRequestEntity should forward the inbound JwtToken header") + void createRequestEntity_shouldForwardInboundJwtTokenHeader() { + request.addHeader("JwtToken", "header-token"); + + HttpEntity entity = RestTemplateUtil.createRequestEntity(BODY, AUTHORIZATION); + + assertEquals("header-token", entity.getHeaders().getFirst("JwtToken")); + } + + @Test + @DisplayName("createRequestEntity should replay the Jwttoken cookie as a Cookie header") + void createRequestEntity_shouldReplayJwtTokenCookie() { + request.setCookies(new Cookie("Jwttoken", "cookie-token")); + + HttpEntity entity = RestTemplateUtil.createRequestEntity(BODY, AUTHORIZATION); + + assertEquals("Jwttoken=cookie-token", entity.getHeaders().getFirst(HttpHeaders.COOKIE)); + } + + @Test + @DisplayName("createRequestEntity should omit the Cookie header when no Jwttoken cookie is present") + void createRequestEntity_shouldOmitCookieHeaderWithoutJwtTokenCookie() { + request.setCookies(new Cookie("theme", "dark")); + + HttpEntity entity = RestTemplateUtil.createRequestEntity(BODY, AUTHORIZATION); + + assertNull(entity.getHeaders().getFirst(HttpHeaders.COOKIE)); + } + + @Test + @DisplayName("createRequestEntity should propagate the mobile User-Agent when one is in scope") + void createRequestEntity_shouldPropagateMobileUserAgent() { + UserAgentContext.setUserAgent("okhttp/4.9.0"); + + HttpEntity entity = RestTemplateUtil.createRequestEntity(BODY, AUTHORIZATION); + + assertEquals("okhttp/4.9.0", entity.getHeaders().getFirst(HttpHeaders.USER_AGENT)); + } + + @Test + @DisplayName("createRequestEntity should omit the User-Agent header when none is in scope") + void createRequestEntity_shouldOmitUserAgentWhenNoneInScope() { + HttpEntity entity = RestTemplateUtil.createRequestEntity(BODY, AUTHORIZATION); + + assertNull(entity.getHeaders().getFirst(HttpHeaders.USER_AGENT)); + } + + @Test + @DisplayName("createRequestEntity should carry both the cookie and header tokens together") + void createRequestEntity_shouldCarryBothCookieAndHeaderTokens() { + request.addHeader("JwtToken", "header-token"); + request.setCookies(new Cookie("Jwttoken", "cookie-token")); + + HttpEntity entity = RestTemplateUtil.createRequestEntity(BODY, AUTHORIZATION); + + assertEquals("header-token", entity.getHeaders().getFirst("JwtToken")); + assertEquals("Jwttoken=cookie-token", entity.getHeaders().getFirst(HttpHeaders.COOKIE)); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/TokenDenylistTest.java b/src/test/java/com/iemr/common/bengen/utils/TokenDenylistTest.java new file mode 100644 index 0000000..b34208a --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/TokenDenylistTest.java @@ -0,0 +1,185 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.utils; + +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("TokenDenylist Test Suite") +class TokenDenylistTest { + + private static final String JTI = "jti-1"; + private static final String KEY = "denied_jti-1"; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + private TokenDenylist tokenDenylist; + + @BeforeEach + @DisplayName("Wire the denylist with a mocked Redis template before each test") + void setUp() { + tokenDenylist = new TokenDenylist(); + ReflectionTestUtils.setField(tokenDenylist, "redisTemplate", redisTemplate); + } + + @Nested + @DisplayName("addTokenToDenylist") + class AddTokenTests { + + @Test + @DisplayName("addTokenToDenylist should store the prefixed key with the supplied expiry") + void addTokenToDenylist_shouldStorePrefixedKeyWithExpiry() { + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + + tokenDenylist.addTokenToDenylist(JTI, 60_000L); + + verify(valueOperations).set(KEY, " ", 60_000L, TimeUnit.MILLISECONDS); + } + + @Test + @DisplayName("addTokenToDenylist should ignore a null jti") + void addTokenToDenylist_shouldIgnoreNullJti() { + tokenDenylist.addTokenToDenylist(null, 60_000L); + + verifyNoInteractions(redisTemplate); + } + + @Test + @DisplayName("addTokenToDenylist should ignore a blank jti") + void addTokenToDenylist_shouldIgnoreBlankJti() { + tokenDenylist.addTokenToDenylist(" ", 60_000L); + + verifyNoInteractions(redisTemplate); + } + + @Test + @DisplayName("addTokenToDenylist should reject a null expiry") + void addTokenToDenylist_shouldRejectNullExpiry() { + IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, + () -> tokenDenylist.addTokenToDenylist(JTI, null)); + + assertTrue(thrown.getMessage().contains("Expiration time must be positive")); + verifyNoInteractions(redisTemplate); + } + + @Test + @DisplayName("addTokenToDenylist should reject a non-positive expiry") + void addTokenToDenylist_shouldRejectNonPositiveExpiry() { + assertThrows(IllegalArgumentException.class, () -> tokenDenylist.addTokenToDenylist(JTI, 0L)); + assertThrows(IllegalArgumentException.class, () -> tokenDenylist.addTokenToDenylist(JTI, -5L)); + verifyNoInteractions(redisTemplate); + } + + @Test + @DisplayName("addTokenToDenylist should surface a Redis failure as a runtime exception") + void addTokenToDenylist_shouldSurfaceRedisFailure() { + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + doThrow(new IllegalStateException("redis down")) + .when(valueOperations).set(anyString(), any(), anyLong(), any(TimeUnit.class)); + + RuntimeException thrown = assertThrows(RuntimeException.class, + () -> tokenDenylist.addTokenToDenylist(JTI, 60_000L)); + + assertTrue(thrown.getMessage().contains("Failed to denylist token")); + } + } + + @Nested + @DisplayName("isTokenDenylisted") + class IsTokenDenylistedTests { + + @Test + @DisplayName("isTokenDenylisted should report true when the prefixed key exists") + void isTokenDenylisted_shouldReportTrueWhenKeyExists() { + when(redisTemplate.hasKey(KEY)).thenReturn(true); + + assertTrue(tokenDenylist.isTokenDenylisted(JTI)); + } + + @Test + @DisplayName("isTokenDenylisted should report false when the key does not exist") + void isTokenDenylisted_shouldReportFalseWhenKeyAbsent() { + when(redisTemplate.hasKey(KEY)).thenReturn(false); + + assertFalse(tokenDenylist.isTokenDenylisted(JTI)); + } + + @Test + @DisplayName("isTokenDenylisted should report false when Redis answers null") + void isTokenDenylisted_shouldReportFalseWhenRedisAnswersNull() { + when(redisTemplate.hasKey(KEY)).thenReturn(null); + + assertFalse(tokenDenylist.isTokenDenylisted(JTI)); + } + + @Test + @DisplayName("isTokenDenylisted should report false for a null jti without touching Redis") + void isTokenDenylisted_shouldReportFalseForNullJti() { + assertFalse(tokenDenylist.isTokenDenylisted(null)); + + verify(redisTemplate, never()).hasKey(anyString()); + } + + @Test + @DisplayName("isTokenDenylisted should report false for a blank jti without touching Redis") + void isTokenDenylisted_shouldReportFalseForBlankJti() { + assertFalse(tokenDenylist.isTokenDenylisted(" ")); + + verify(redisTemplate, never()).hasKey(anyString()); + } + + @Test + @DisplayName("isTokenDenylisted should fail open rather than block requests when Redis is down") + void isTokenDenylisted_shouldFailOpenWhenRedisIsDown() { + when(redisTemplate.hasKey(KEY)).thenThrow(new IllegalStateException("redis down")); + + assertFalse(tokenDenylist.isTokenDenylisted(JTI)); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/UserAgentContextTest.java b/src/test/java/com/iemr/common/bengen/utils/UserAgentContextTest.java new file mode 100644 index 0000000..7e9cffe --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/UserAgentContextTest.java @@ -0,0 +1,88 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.utils; + +import java.util.concurrent.Executors; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +@DisplayName("UserAgentContext Test Suite") +class UserAgentContextTest { + + @AfterEach + @DisplayName("Clear the thread local after each test") + void tearDown() { + UserAgentContext.clear(); + } + + @Test + @DisplayName("getUserAgent should be empty before anything is set") + void getUserAgent_shouldBeEmptyByDefault() { + assertNull(UserAgentContext.getUserAgent()); + } + + @Test + @DisplayName("setUserAgent should make the value readable on the same thread") + void setUserAgent_shouldBeReadableOnSameThread() { + UserAgentContext.setUserAgent("okhttp/4.9.0"); + + assertEquals("okhttp/4.9.0", UserAgentContext.getUserAgent()); + } + + @Test + @DisplayName("setUserAgent should overwrite a previously stored value") + void setUserAgent_shouldOverwritePreviousValue() { + UserAgentContext.setUserAgent("okhttp/4.9.0"); + UserAgentContext.setUserAgent("Java/17.0.2"); + + assertEquals("Java/17.0.2", UserAgentContext.getUserAgent()); + } + + @Test + @DisplayName("clear should remove the stored value") + void clear_shouldRemoveStoredValue() { + UserAgentContext.setUserAgent("okhttp/4.9.0"); + + UserAgentContext.clear(); + + assertNull(UserAgentContext.getUserAgent()); + } + + @Test + @DisplayName("the stored value should not leak into another thread") + void storedValue_shouldNotLeakAcrossThreads() throws Exception { + UserAgentContext.setUserAgent("okhttp/4.9.0"); + ExecutorService executor = Executors.newSingleThreadExecutor(); + + Future otherThreadValue = executor.submit(UserAgentContext::getUserAgent); + + assertNull(otherThreadValue.get(), "the User-Agent is per-request, so must stay thread-confined"); + executor.shutdown(); + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/VerhoeffTest.java b/src/test/java/com/iemr/common/bengen/utils/VerhoeffTest.java new file mode 100644 index 0000000..5c14ec4 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/VerhoeffTest.java @@ -0,0 +1,82 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.utils; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("Verhoeff Test Suite") +class VerhoeffTest { + + @Test + @DisplayName("generateVerhoeff should produce the documented check digit for a known number") + void generateVerhoeff_shouldProduceCheckDigitForKnownNumber() { + assertEquals("3", Verhoeff.generateVerhoeff("236")); + } + + @Test + @DisplayName("generateVerhoeff should produce a single digit for a long number") + void generateVerhoeff_shouldProduceSingleDigitForLongNumber() { + String digit = Verhoeff.generateVerhoeff("75381272119"); + + assertEquals(1, digit.length()); + assertTrue(digit.charAt(0) >= '0' && digit.charAt(0) <= '9'); + } + + @Test + @DisplayName("validateVerhoeff should accept a number carrying its own generated check digit") + void validateVerhoeff_shouldAcceptNumberWithGeneratedCheckDigit() { + String base = "75381272119"; + + assertTrue(Verhoeff.validateVerhoeff(base + Verhoeff.generateVerhoeff(base))); + } + + @Test + @DisplayName("validateVerhoeff should reject a number whose check digit is wrong") + void validateVerhoeff_shouldRejectWrongCheckDigit() { + String base = "75381272119"; + int correct = Integer.parseInt(Verhoeff.generateVerhoeff(base)); + int wrong = (correct + 1) % 10; + + assertFalse(Verhoeff.validateVerhoeff(base + wrong)); + } + + @Test + @DisplayName("validateVerhoeff should reject a number with a transposed pair of digits") + void validateVerhoeff_shouldRejectTransposedDigits() { + String valid = "236" + Verhoeff.generateVerhoeff("236"); + String transposed = "" + valid.charAt(1) + valid.charAt(0) + valid.substring(2); + + assertFalse(Verhoeff.validateVerhoeff(transposed), + "Verhoeff must catch adjacent transposition, the error it exists to detect"); + } + + @Test + @DisplayName("generateVerhoeff should be stable across repeated calls") + void generateVerhoeff_shouldBeStableAcrossCalls() { + assertEquals(Verhoeff.generateVerhoeff("123456789"), Verhoeff.generateVerhoeff("123456789")); + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/config/ConfigPropertiesTest.java b/src/test/java/com/iemr/common/bengen/utils/config/ConfigPropertiesTest.java new file mode 100644 index 0000000..90aa398 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/config/ConfigPropertiesTest.java @@ -0,0 +1,169 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.utils.config; + +import java.util.Base64; +import java.util.Properties; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("ConfigProperties Test Suite") +class ConfigPropertiesTest { + + private Properties originalProperties; + + @BeforeEach + @DisplayName("Instantiate the holder so application.properties is loaded, keeping the original statics") + void setUp() { + new ConfigProperties(); + originalProperties = (Properties) ReflectionTestUtils.getField(ConfigProperties.class, "properties"); + } + + @AfterEach + @DisplayName("Restore the shared static properties after each test") + void tearDown() { + ReflectionTestUtils.setField(ConfigProperties.class, "properties", originalProperties); + } + + @Nested + @DisplayName("Reading values from application.properties") + class PropertyLookupTests { + + @Test + @DisplayName("getPropertyByName should return the configured value for a known key") + void getPropertyByName_shouldReturnConfiguredValue() { + assertEquals("6379", ConfigProperties.getPropertyByName("spring.redis.port")); + } + + @Test + @DisplayName("getPropertyByName should return null for a key that is not configured") + void getPropertyByName_shouldReturnNullForUnknownKey() { + assertNull(ConfigProperties.getPropertyByName("no.such.key.configured")); + } + + @Test + @DisplayName("getBoolean should parse a boolean property") + void getBoolean_shouldParseBooleanProperty() { + assertTrue(ConfigProperties.getBoolean("iemr.extend.expiry.time")); + } + + @Test + @DisplayName("getBoolean should return false for a value that is not a boolean") + void getBoolean_shouldReturnFalseForNonBooleanValue() { + assertEquals(false, ConfigProperties.getBoolean("spring.redis.port")); + } + + @Test + @DisplayName("getInteger should parse an integer property") + void getInteger_shouldParseIntegerProperty() { + assertEquals(7200, ConfigProperties.getInteger("iemr.session.expiry.time")); + } + + @Test + @DisplayName("getInteger should fall back to zero when the value is not a number") + void getInteger_shouldFallBackToZeroForNonNumericValue() { + assertEquals(0, ConfigProperties.getInteger("spring.session.store-type")); + } + + @Test + @DisplayName("getLong should parse a long property") + void getLong_shouldParseLongProperty() { + assertEquals(7200L, ConfigProperties.getLong("iemr.session.expiry.time")); + } + + @Test + @DisplayName("getLong should fall back to zero when the value is not a number") + void getLong_shouldFallBackToZeroForNonNumericValue() { + assertEquals(0L, ConfigProperties.getLong("spring.session.store-type")); + } + + @Test + @DisplayName("getFloat should parse a numeric property") + void getFloat_shouldParseNumericProperty() { + assertEquals(6379F, ConfigProperties.getFloat("spring.redis.port")); + } + + @Test + @DisplayName("getFloat should fall back to zero when the value is not a number") + void getFloat_shouldFallBackToZeroForNonNumericValue() { + assertEquals(0F, ConfigProperties.getFloat("spring.session.store-type")); + } + } + + @Nested + @DisplayName("Session and Redis accessors") + class AccessorTests { + + @Test + @DisplayName("getSessionExpiryTime should resolve the configured session expiry") + void getSessionExpiryTime_shouldResolveConfiguredExpiry() { + assertEquals(7200, ConfigProperties.getSessionExpiryTime()); + } + + @Test + @DisplayName("getRedisPort should fall back to zero when no iemr.redis.port is configured") + void getRedisPort_shouldFallBackToZeroWhenUnconfigured() { + assertEquals(0, ConfigProperties.getRedisPort()); + } + + @Test + @DisplayName("getRedisUrl should return null when no iemr.redis.url is configured") + void getRedisUrl_shouldReturnNullWhenUnconfigured() { + assertNull(ConfigProperties.getRedisUrl()); + } + } + + @Nested + @DisplayName("Password handling") + class PasswordTests { + + @Test + @DisplayName("getPassword should return a plain-text password unchanged") + void getPassword_shouldReturnPlainTextUnchanged() { + Properties stub = new Properties(); + stub.setProperty("db.password", "plainSecret"); + ReflectionTestUtils.setField(ConfigProperties.class, "properties", stub); + + assertEquals("plainSecret", ConfigProperties.getPassword("db.password")); + } + + @Test + @DisplayName("getPassword should Base64-decode a password tagged with the 0X10 prefix") + void getPassword_shouldBase64DecodeTaggedPassword() { + String encoded = Base64.getEncoder().encodeToString("s3cr3t".getBytes()); + Properties stub = new Properties(); + stub.setProperty("db.password", "0X10:" + encoded); + ReflectionTestUtils.setField(ConfigProperties.class, "properties", stub); + + assertEquals("s3cr3t", ConfigProperties.getPassword("db.password")); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/exception/ExceptionsTest.java b/src/test/java/com/iemr/common/bengen/utils/exception/ExceptionsTest.java new file mode 100644 index 0000000..df453d8 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/exception/ExceptionsTest.java @@ -0,0 +1,114 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.utils.exception; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +@DisplayName("AMRIT exception types Test Suite") +class ExceptionsTest { + + private static final String MESSAGE = "Invalid session key"; + + private RuntimeException causeWithStackTrace() { + RuntimeException cause = new RuntimeException("root cause"); + cause.setStackTrace(new StackTraceElement[] { + new StackTraceElement("com.iemr.Origin", "failingMethod", "Origin.java", 42) }); + return cause; + } + + @Nested + @DisplayName("IEMRException") + class IEMRExceptionTests { + + @Test + @DisplayName("the message constructor should expose the message through both accessors") + void messageConstructor_shouldExposeMessage() { + IEMRException exception = new IEMRException(MESSAGE); + + assertEquals(MESSAGE, exception.getMessage()); + assertEquals(MESSAGE, exception.toString()); + } + + @Test + @DisplayName("the cause constructor should adopt the stack trace of the cause") + void causeConstructor_shouldAdoptCauseStackTrace() { + RuntimeException cause = causeWithStackTrace(); + + IEMRException exception = new IEMRException(MESSAGE, cause); + + assertEquals(MESSAGE, exception.getMessage()); + assertArrayEquals(cause.getStackTrace(), exception.getStackTrace()); + } + + @Test + @DisplayName("the cause constructor should not chain the cause itself") + void causeConstructor_shouldNotChainCause() { + IEMRException exception = new IEMRException(MESSAGE, causeWithStackTrace()); + + assertNull(exception.getCause(), + "only the stack trace is adopted; the cause is deliberately not chained"); + } + + @Test + @DisplayName("toString should return null when constructed with a null message") + void toString_shouldReturnNullForNullMessage() { + assertNull(new IEMRException(null).toString()); + } + } + + @Nested + @DisplayName("InventoryException") + class InventoryExceptionTests { + + @Test + @DisplayName("the message constructor should expose the message through both accessors") + void messageConstructor_shouldExposeMessage() { + InventoryException exception = new InventoryException(MESSAGE); + + assertEquals(MESSAGE, exception.getMessage()); + assertEquals(MESSAGE, exception.toString()); + } + + @Test + @DisplayName("the cause constructor should adopt the stack trace of the cause") + void causeConstructor_shouldAdoptCauseStackTrace() { + RuntimeException cause = causeWithStackTrace(); + + InventoryException exception = new InventoryException(MESSAGE, cause); + + assertEquals(MESSAGE, exception.getMessage()); + assertArrayEquals(cause.getStackTrace(), exception.getStackTrace()); + } + + @Test + @DisplayName("the cause constructor should not chain the cause itself") + void causeConstructor_shouldNotChainCause() { + assertNull(new InventoryException(MESSAGE, causeWithStackTrace()).getCause()); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/gateway/email/GenericEmailServiceImplTest.java b/src/test/java/com/iemr/common/bengen/utils/gateway/email/GenericEmailServiceImplTest.java new file mode 100644 index 0000000..4d8ee78 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/gateway/email/GenericEmailServiceImplTest.java @@ -0,0 +1,156 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.utils.gateway.email; + +import org.json.JSONException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mail.MailSendException; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("GenericEmailServiceImpl Test Suite") +class GenericEmailServiceImplTest { + + @Mock + private JavaMailSender javaMailSender; + + private GenericEmailServiceImpl emailService; + + @BeforeEach + @DisplayName("Wire the service with a mocked mail sender before each test") + void setUp() { + emailService = new GenericEmailServiceImpl(); + emailService.setJavaMailSender(javaMailSender); + } + + private String request(String to) { + return String.format( + "{\"to\":\"%s\",\"from\":\"no-reply@amrit.example.org\"," + + "\"subject\":\"Beneficiary ID pool low\"," + + "\"message\":\"Only 100 IDs remain in the pool.\"}", + to); + } + + private SimpleMailMessage captureSentMessage() { + ArgumentCaptor captor = ArgumentCaptor.forClass(SimpleMailMessage.class); + verify(javaMailSender).send(captor.capture()); + return captor.getValue(); + } + + @Nested + @DisplayName("sendEmail without a template") + class SendEmailTests { + + @Test + @DisplayName("sendEmail should populate every field of the message from the JSON request") + void sendEmail_shouldPopulateMessageFromJsonRequest() { + emailService.sendEmail(request("ops@amrit.example.org")); + + SimpleMailMessage sent = captureSentMessage(); + assertArrayEquals(new String[] { "ops@amrit.example.org" }, sent.getTo()); + assertEquals("no-reply@amrit.example.org", sent.getFrom()); + assertEquals("Beneficiary ID pool low", sent.getSubject()); + assertEquals("Only 100 IDs remain in the pool.", sent.getText()); + } + + @Test + @DisplayName("sendEmail should split a semicolon-separated recipient list into multiple addresses") + void sendEmail_shouldSplitSemicolonSeparatedRecipients() { + emailService.sendEmail(request("ops@amrit.example.org;admin@amrit.example.org")); + + assertArrayEquals(new String[] { "ops@amrit.example.org", "admin@amrit.example.org" }, + captureSentMessage().getTo()); + } + + @Test + @DisplayName("sendEmail should reject a request that is missing a mandatory field") + void sendEmail_shouldRejectRequestMissingMandatoryField() { + String incomplete = "{\"to\":\"ops@amrit.example.org\"}"; + + assertThrows(JSONException.class, () -> emailService.sendEmail(incomplete)); + verify(javaMailSender, never()).send(org.mockito.ArgumentMatchers.any(SimpleMailMessage.class)); + } + + @Test + @DisplayName("sendEmail should propagate a mail transport failure") + void sendEmail_shouldPropagateTransportFailure() { + doThrow(new MailSendException("smtp unreachable")) + .when(javaMailSender).send(org.mockito.ArgumentMatchers.any(SimpleMailMessage.class)); + + assertThrows(MailSendException.class, () -> emailService.sendEmail(request("ops@amrit.example.org"))); + } + } + + @Nested + @DisplayName("sendEmail with a template") + class SendEmailWithTemplateTests { + + @Test + @DisplayName("sendEmail with a template should populate the message from the JSON request") + void sendEmail_withTemplate_shouldPopulateMessageFromJsonRequest() { + emailService.sendEmail(request("ops@amrit.example.org"), "pool-warning-template"); + + SimpleMailMessage sent = captureSentMessage(); + assertArrayEquals(new String[] { "ops@amrit.example.org" }, sent.getTo()); + assertEquals("Beneficiary ID pool low", sent.getSubject()); + assertEquals("Only 100 IDs remain in the pool.", sent.getText()); + } + + @Test + @DisplayName("sendEmail with a template should keep a semicolon list as a single recipient") + void sendEmail_withTemplate_shouldKeepRecipientListUnsplit() { + emailService.sendEmail(request("ops@amrit.example.org;admin@amrit.example.org"), + "pool-warning-template"); + + assertArrayEquals(new String[] { "ops@amrit.example.org;admin@amrit.example.org" }, + captureSentMessage().getTo()); + } + } + + @Nested + @DisplayName("sendEmailWithAttachment") + class SendEmailWithAttachmentTests { + + @Test + @DisplayName("sendEmailWithAttachment is not implemented and should send nothing") + void sendEmailWithAttachment_shouldSendNothing() { + emailService.sendEmailWithAttachment(request("ops@amrit.example.org"), "template"); + + verify(javaMailSender, never()).send(org.mockito.ArgumentMatchers.any(SimpleMailMessage.class)); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/http/AuthorizationHeaderRequestWrapperTest.java b/src/test/java/com/iemr/common/bengen/utils/http/AuthorizationHeaderRequestWrapperTest.java new file mode 100644 index 0000000..85bcea5 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/http/AuthorizationHeaderRequestWrapperTest.java @@ -0,0 +1,128 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.utils.http; + +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("AuthorizationHeaderRequestWrapper Test Suite") +class AuthorizationHeaderRequestWrapperTest { + + private MockHttpServletRequest request; + + @BeforeEach + @DisplayName("Create a request carrying an inbound Authorization header before each test") + void setUp() { + request = new MockHttpServletRequest(); + request.addHeader("Authorization", "inbound-key"); + request.addHeader("JwtToken", "header-token"); + } + + @Test + @DisplayName("getHeader should return the overridden value for Authorization") + void getHeader_shouldReturnOverriddenAuthorization() { + AuthorizationHeaderRequestWrapper wrapper = + new AuthorizationHeaderRequestWrapper(request, "overridden-key"); + + assertEquals("overridden-key", wrapper.getHeader("Authorization")); + } + + @Test + @DisplayName("getHeader should match the Authorization name case-insensitively") + void getHeader_shouldMatchAuthorizationCaseInsensitively() { + AuthorizationHeaderRequestWrapper wrapper = + new AuthorizationHeaderRequestWrapper(request, "overridden-key"); + + assertEquals("overridden-key", wrapper.getHeader("authorization")); + assertEquals("overridden-key", wrapper.getHeader("AUTHORIZATION")); + } + + @Test + @DisplayName("getHeader should pass every other header through to the wrapped request") + void getHeader_shouldPassOtherHeadersThrough() { + AuthorizationHeaderRequestWrapper wrapper = + new AuthorizationHeaderRequestWrapper(request, "overridden-key"); + + assertEquals("header-token", wrapper.getHeader("JwtToken")); + assertNull(wrapper.getHeader("X-Not-Present")); + } + + @Test + @DisplayName("getHeader should return the blank override the JWT filter installs") + void getHeader_shouldReturnBlankOverride() { + AuthorizationHeaderRequestWrapper wrapper = new AuthorizationHeaderRequestWrapper(request, ""); + + assertEquals("", wrapper.getHeader("Authorization"), + "the filter blanks Authorization once the JWT has been validated"); + } + + @Test + @DisplayName("getHeaders should return the overridden Authorization as a single-valued enumeration") + void getHeaders_shouldReturnOverriddenAuthorizationAsSingleValue() { + AuthorizationHeaderRequestWrapper wrapper = + new AuthorizationHeaderRequestWrapper(request, "overridden-key"); + + assertEquals(List.of("overridden-key"), Collections.list(wrapper.getHeaders("Authorization"))); + } + + @Test + @DisplayName("getHeaders should pass every other header through to the wrapped request") + void getHeaders_shouldPassOtherHeadersThrough() { + AuthorizationHeaderRequestWrapper wrapper = + new AuthorizationHeaderRequestWrapper(request, "overridden-key"); + + assertEquals(List.of("header-token"), Collections.list(wrapper.getHeaders("JwtToken"))); + } + + @Test + @DisplayName("getHeaderNames should still list Authorization alongside the wrapped names") + void getHeaderNames_shouldListAuthorizationAlongsideWrappedNames() { + AuthorizationHeaderRequestWrapper wrapper = + new AuthorizationHeaderRequestWrapper(request, "overridden-key"); + + List names = Collections.list(wrapper.getHeaderNames()); + assertTrue(names.contains("Authorization")); + assertTrue(names.contains("JwtToken")); + assertEquals(1, names.stream().filter("Authorization"::equals).count(), + "Authorization must not be duplicated when the wrapped request already carries it"); + } + + @Test + @DisplayName("getHeaderNames should add Authorization when the wrapped request lacks it") + void getHeaderNames_shouldAddAuthorizationWhenWrappedRequestLacksIt() { + MockHttpServletRequest bare = new MockHttpServletRequest(); + bare.addHeader("JwtToken", "header-token"); + AuthorizationHeaderRequestWrapper wrapper = + new AuthorizationHeaderRequestWrapper(bare, "overridden-key"); + + assertTrue(Collections.list(wrapper.getHeaderNames()).contains("Authorization")); + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/http/HTTPRequestInterceptorTest.java b/src/test/java/com/iemr/common/bengen/utils/http/HTTPRequestInterceptorTest.java new file mode 100644 index 0000000..5d22c96 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/http/HTTPRequestInterceptorTest.java @@ -0,0 +1,303 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.utils.http; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.util.ReflectionTestUtils; + +import com.iemr.common.bengen.utils.exception.IEMRException; +import com.iemr.common.bengen.utils.sessionobject.SessionObject; +import com.iemr.common.bengen.utils.validator.Validator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("HTTPRequestInterceptor Test Suite") +class HTTPRequestInterceptorTest { + + private static final String ALLOWED_ORIGIN = "https://amrit.example.org"; + private static final String AUTHORIZATION = "session-key-123"; + + @Mock + private Validator validator; + + @Mock + private SessionObject sessionObject; + + private HTTPRequestInterceptor interceptor; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @BeforeEach + @DisplayName("Set up the interceptor with mocked collaborators before each test") + void setUp() { + interceptor = new HTTPRequestInterceptor(); + interceptor.setValidator(validator); + interceptor.setSessionObject(sessionObject); + ReflectionTestUtils.setField(interceptor, "allowedOrigins", ALLOWED_ORIGIN + ",http://localhost:*"); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + } + + @Nested + @DisplayName("preHandle authorization") + class PreHandleTests { + + @Test + @DisplayName("preHandle should proceed without validation when no Authorization header is present") + void preHandle_shouldProceedWhenAuthorizationHeaderIsMissing() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + + assertTrue(interceptor.preHandle(request, response, new Object())); + verify(validator, never()).checkKeyExists(anyString(), anyString()); + } + + @Test + @DisplayName("preHandle should proceed without validation when the Authorization header is empty") + void preHandle_shouldProceedWhenAuthorizationHeaderIsEmpty() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Authorization", ""); + + assertTrue(interceptor.preHandle(request, response, new Object())); + verify(validator, never()).checkKeyExists(anyString(), anyString()); + } + + @Test + @DisplayName("preHandle should skip validation entirely for OPTIONS requests") + void preHandle_shouldSkipValidationForOptions() throws Exception { + request.setMethod("OPTIONS"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Authorization", AUTHORIZATION); + + assertTrue(interceptor.preHandle(request, response, new Object())); + verify(validator, never()).checkKeyExists(anyString(), anyString()); + } + + @Test + @DisplayName("preHandle should validate the session key against the client address for a guarded API") + void preHandle_shouldValidateSessionKeyForGuardedApi() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Authorization", AUTHORIZATION); + request.setRemoteAddr("10.1.2.3"); + + assertTrue(interceptor.preHandle(request, response, new Object())); + verify(validator).checkKeyExists(AUTHORIZATION, "10.1.2.3"); + } + + @Test + @DisplayName("preHandle should prefer the X-FORWARDED-FOR address over the socket address") + void preHandle_shouldPreferForwardedForAddress() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Authorization", AUTHORIZATION); + request.addHeader("X-FORWARDED-FOR", "203.0.113.9"); + request.setRemoteAddr("10.1.2.3"); + + interceptor.preHandle(request, response, new Object()); + + verify(validator).checkKeyExists(AUTHORIZATION, "203.0.113.9"); + } + + @Test + @DisplayName("preHandle should fall back to the socket address when X-FORWARDED-FOR is blank") + void preHandle_shouldFallBackWhenForwardedForIsBlank() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Authorization", AUTHORIZATION); + request.addHeader("X-FORWARDED-FOR", " "); + request.setRemoteAddr("10.1.2.3"); + + interceptor.preHandle(request, response, new Object()); + + verify(validator).checkKeyExists(AUTHORIZATION, "10.1.2.3"); + } + + @Test + @DisplayName("preHandle should let whitelisted endpoints through without a session-key check") + void preHandle_shouldBypassValidationForWhitelistedEndpoints() throws Exception { + for (String api : new String[] { "userAuthenticate", "userAuthenticateNew", "userAuthenticateV1", + "forgetPassword", "setForgetPassword", "changePassword", "saveUserSecurityQuesAns", + "swagger-ui.html", "ui", "swagger-resources", "api-docs", "version" }) { + MockHttpServletRequest whitelisted = new MockHttpServletRequest(); + whitelisted.setMethod("POST"); + whitelisted.setRequestURI("/user/" + api); + whitelisted.addHeader("Authorization", AUTHORIZATION); + + assertTrue(interceptor.preHandle(whitelisted, new MockHttpServletResponse(), new Object()), + api + " should be allowed through"); + } + verify(validator, never()).checkKeyExists(anyString(), anyString()); + } + + @Test + @DisplayName("preHandle should halt the request for the error endpoint") + void preHandle_shouldHaltForErrorEndpoint() throws Exception { + request.setMethod("GET"); + request.setRequestURI("/error"); + request.addHeader("Authorization", AUTHORIZATION); + + assertFalse(interceptor.preHandle(request, response, new Object())); + verify(validator, never()).checkKeyExists(anyString(), anyString()); + } + } + + @Nested + @DisplayName("preHandle failure handling") + class PreHandleFailureTests { + + @Test + @DisplayName("preHandle should halt and write the error payload when the session key is rejected") + void preHandle_shouldHaltAndWriteErrorWhenSessionKeyRejected() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Authorization", AUTHORIZATION); + request.setRemoteAddr("10.1.2.3"); + doThrow(new IEMRException("Invalid session key")) + .when(validator).checkKeyExists(AUTHORIZATION, "10.1.2.3"); + + assertFalse(interceptor.preHandle(request, response, new Object())); + assertTrue(response.getContentAsString().contains("Invalid session key")); + assertEquals("application/json", response.getContentType()); + } + + @Test + @DisplayName("preHandle should echo CORS headers on the error response for an allowed origin") + void preHandle_shouldAddCorsHeadersOnErrorForAllowedOrigin() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Authorization", AUTHORIZATION); + request.addHeader("Origin", ALLOWED_ORIGIN); + request.setRemoteAddr("10.1.2.3"); + doThrow(new IEMRException("Invalid session key")) + .when(validator).checkKeyExists(AUTHORIZATION, "10.1.2.3"); + + interceptor.preHandle(request, response, new Object()); + + assertEquals(ALLOWED_ORIGIN, response.getHeader("Access-Control-Allow-Origin")); + assertEquals("true", response.getHeader("Access-Control-Allow-Credentials")); + } + + @Test + @DisplayName("preHandle should withhold CORS headers on the error response for an unauthorized origin") + void preHandle_shouldWithholdCorsHeadersOnErrorForUnauthorizedOrigin() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Authorization", AUTHORIZATION); + request.addHeader("Origin", "https://evil.example.com"); + request.setRemoteAddr("10.1.2.3"); + doThrow(new IEMRException("Invalid session key")) + .when(validator).checkKeyExists(AUTHORIZATION, "10.1.2.3"); + + interceptor.preHandle(request, response, new Object()); + + assertNull(response.getHeader("Access-Control-Allow-Origin")); + } + + @Test + @DisplayName("preHandle should withhold CORS headers when no origin allow-list is configured") + void preHandle_shouldWithholdCorsHeadersWhenAllowListIsBlank() throws Exception { + ReflectionTestUtils.setField(interceptor, "allowedOrigins", ""); + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Authorization", AUTHORIZATION); + request.addHeader("Origin", ALLOWED_ORIGIN); + request.setRemoteAddr("10.1.2.3"); + doThrow(new IEMRException("Invalid session key")) + .when(validator).checkKeyExists(AUTHORIZATION, "10.1.2.3"); + + interceptor.preHandle(request, response, new Object()); + + assertNull(response.getHeader("Access-Control-Allow-Origin")); + } + } + + @Nested + @DisplayName("postHandle and afterCompletion") + class PostHandleTests { + + @Test + @DisplayName("postHandle should refresh the session object when an Authorization header is present") + void postHandle_shouldRefreshSessionObject() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Authorization", AUTHORIZATION); + when(sessionObject.getSessionObject(AUTHORIZATION)).thenReturn("session-payload"); + + interceptor.postHandle(request, response, new Object(), null); + + verify(sessionObject).updateSessionObject(AUTHORIZATION, "session-payload"); + } + + @Test + @DisplayName("postHandle should do nothing when no Authorization header is present") + void postHandle_shouldDoNothingWithoutAuthorizationHeader() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + + interceptor.postHandle(request, response, new Object(), null); + + verify(sessionObject, never()).updateSessionObject(anyString(), anyString()); + } + + @Test + @DisplayName("postHandle should swallow a session store failure so the response still completes") + void postHandle_shouldSwallowSessionStoreFailure() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/beneficiary/generateBeneficiaryIDs"); + request.addHeader("Authorization", AUTHORIZATION); + when(sessionObject.getSessionObject(AUTHORIZATION)) + .thenThrow(new IllegalStateException("redis down")); + + interceptor.postHandle(request, response, new Object(), null); + + verify(sessionObject, never()).updateSessionObject(anyString(), anyString()); + } + + @Test + @DisplayName("afterCompletion should complete without touching the response") + void afterCompletion_shouldLeaveResponseUntouched() throws Exception { + interceptor.afterCompletion(request, response, new Object(), null); + + assertEquals(200, response.getStatus()); + assertEquals("", response.getContentAsString()); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/http/HttpUtilsTest.java b/src/test/java/com/iemr/common/bengen/utils/http/HttpUtilsTest.java new file mode 100644 index 0000000..4db5b88 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/http/HttpUtilsTest.java @@ -0,0 +1,243 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.utils.http; + +import java.util.HashMap; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("HttpUtils Test Suite") +class HttpUtilsTest { + + private static final String URI = "http://localhost:8080/api/resource"; + + @Mock + private RestTemplate restTemplate; + + private HttpUtils httpUtils; + + @BeforeEach + @DisplayName("Replace the internal RestTemplate with a mock before each test") + void setUp() { + httpUtils = new HttpUtils(); + ReflectionTestUtils.setField(httpUtils, "rest", restTemplate); + } + + @SuppressWarnings("unchecked") + private ArgumentCaptor> captureRequest(HttpMethod method, ResponseEntity reply) { + ArgumentCaptor> captor = ArgumentCaptor.forClass(HttpEntity.class); + when(restTemplate.exchange(eq(URI), eq(method), captor.capture(), eq(String.class))).thenReturn(reply); + return captor; + } + + @Nested + @DisplayName("GET requests") + class GetTests + + { + @Test + @DisplayName("get should return the response body and record the status") + void get_shouldReturnBodyAndRecordStatus() { + when(restTemplate.exchange(eq(URI), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) + .thenReturn(new ResponseEntity<>("{\"ok\":true}", HttpStatus.OK)); + + assertEquals("{\"ok\":true}", httpUtils.get(URI)); + assertEquals(HttpStatus.OK, httpUtils.getStatus()); + } + + @Test + @DisplayName("get should send the default JSON content type") + void get_shouldSendDefaultJsonContentType() { + ArgumentCaptor> captor = + captureRequest(HttpMethod.GET, new ResponseEntity<>("body", HttpStatus.OK)); + + httpUtils.get(URI); + + assertEquals("application/json", + captor.getValue().getHeaders().getFirst("Content-Type")); + } + + @Test + @DisplayName("get should record a non-OK status returned by the server") + void get_shouldRecordNonOkStatus() { + when(restTemplate.exchange(eq(URI), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) + .thenReturn(new ResponseEntity<>(null, HttpStatus.NOT_FOUND)); + + assertNull(httpUtils.get(URI)); + assertEquals(HttpStatus.NOT_FOUND, httpUtils.getStatus()); + } + + @Test + @DisplayName("get with headers should forward the supplied Authorization header") + void get_shouldForwardSuppliedAuthorizationHeader() { + HashMap header = new HashMap<>(); + header.put(HttpHeaders.AUTHORIZATION, "session-key-123"); + ArgumentCaptor> captor = + captureRequest(HttpMethod.GET, new ResponseEntity<>("body", HttpStatus.OK)); + + assertEquals("body", httpUtils.get(URI, header)); + assertEquals("session-key-123", + captor.getValue().getHeaders().getFirst(HttpHeaders.AUTHORIZATION)); + } + + @Test + @DisplayName("get with headers should forward an explicit Content-Type") + void get_shouldForwardExplicitContentType() { + HashMap header = new HashMap<>(); + header.put(HttpHeaders.CONTENT_TYPE, "application/xml"); + ArgumentCaptor> captor = + captureRequest(HttpMethod.GET, new ResponseEntity<>("body", HttpStatus.OK)); + + httpUtils.get(URI, header); + + assertEquals("application/xml", + captor.getValue().getHeaders().getFirst(HttpHeaders.CONTENT_TYPE)); + } + + @Test + @DisplayName("get with headers should default the Content-Type to JSON when none is supplied") + void get_shouldDefaultContentTypeToJson() { + ArgumentCaptor> captor = + captureRequest(HttpMethod.GET, new ResponseEntity<>("body", HttpStatus.OK)); + + httpUtils.get(URI, new HashMap<>()); + + assertEquals("application/json", + captor.getValue().getHeaders().getFirst(HttpHeaders.CONTENT_TYPE)); + } + + @Test + @DisplayName("get should propagate a transport failure to the caller") + void get_shouldPropagateTransportFailure() { + when(restTemplate.exchange(eq(URI), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) + .thenThrow(new RestClientException("connection refused")); + + assertThrows(RestClientException.class, () -> httpUtils.get(URI)); + } + } + + @Nested + @DisplayName("POST requests") + class PostTests { + + @Test + @DisplayName("post should send the JSON payload and return the response body") + void post_shouldSendPayloadAndReturnBody() { + ArgumentCaptor> captor = + captureRequest(HttpMethod.POST, new ResponseEntity<>("created", HttpStatus.CREATED)); + + assertEquals("created", httpUtils.post(URI, "{\"count\":5}")); + assertEquals("{\"count\":5}", captor.getValue().getBody()); + assertEquals(HttpStatus.CREATED, httpUtils.getStatus()); + } + + @Test + @DisplayName("post with headers should forward the supplied Authorization header") + void post_shouldForwardSuppliedAuthorizationHeader() { + HashMap header = new HashMap<>(); + header.put(HttpHeaders.AUTHORIZATION, "session-key-123"); + ArgumentCaptor> captor = + captureRequest(HttpMethod.POST, new ResponseEntity<>("created", HttpStatus.CREATED)); + + assertEquals("created", httpUtils.post(URI, "{\"count\":5}", header)); + assertEquals("session-key-123", + captor.getValue().getHeaders().getFirst(HttpHeaders.AUTHORIZATION)); + assertEquals("{\"count\":5}", captor.getValue().getBody()); + } + + @Test + @DisplayName("post with headers should omit the Authorization header when none is supplied") + void post_shouldOmitAuthorizationHeaderWhenNoneSupplied() { + ArgumentCaptor> captor = + captureRequest(HttpMethod.POST, new ResponseEntity<>("created", HttpStatus.CREATED)); + + httpUtils.post(URI, "{}", new HashMap<>()); + + assertNull(captor.getValue().getHeaders().getFirst(HttpHeaders.AUTHORIZATION)); + } + + @Test + @DisplayName("post should record a server error status") + void post_shouldRecordServerErrorStatus() { + when(restTemplate.exchange(eq(URI), eq(HttpMethod.POST), any(HttpEntity.class), eq(String.class))) + .thenReturn(new ResponseEntity<>("boom", HttpStatus.INTERNAL_SERVER_ERROR)); + + httpUtils.post(URI, "{}"); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, httpUtils.getStatus()); + } + + @Test + @DisplayName("post should issue the request against the supplied URI with the POST method") + void post_shouldIssueRequestWithPostMethod() { + when(restTemplate.exchange(eq(URI), eq(HttpMethod.POST), any(HttpEntity.class), eq(String.class))) + .thenReturn(new ResponseEntity<>("created", HttpStatus.CREATED)); + + httpUtils.post(URI, "{}"); + + verify(restTemplate).exchange(eq(URI), eq(HttpMethod.POST), any(HttpEntity.class), eq(String.class)); + } + } + + @Nested + @DisplayName("Status tracking") + class StatusTests { + + @Test + @DisplayName("getStatus should be null until a request has been made") + void getStatus_shouldBeNullBeforeAnyRequest() { + assertNull(httpUtils.getStatus()); + } + + @Test + @DisplayName("setStatus should record the supplied status code") + void setStatus_shouldRecordSuppliedStatusCode() { + httpUtils.setStatus(HttpStatus.ACCEPTED); + + assertEquals(HttpStatus.ACCEPTED, httpUtils.getStatus()); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/redis/RedisStorageTest.java b/src/test/java/com/iemr/common/bengen/utils/redis/RedisStorageTest.java new file mode 100644 index 0000000..79e8aea --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/redis/RedisStorageTest.java @@ -0,0 +1,189 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.utils.redis; + +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisStringCommands.SetOption; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.types.Expiration; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RedisStorage Test Suite") +class RedisStorageTest { + + private static final String KEY = "session-key"; + private static final int EXPIRY_SECONDS = 7200; + + @Mock + private LettuceConnectionFactory connectionFactory; + + @Mock + private RedisConnection redisConnection; + + private RedisStorage redisStorage; + + @BeforeEach + @DisplayName("Wire the store with a mocked Lettuce connection factory before each test") + void setUp() { + redisStorage = new RedisStorage(); + ReflectionTestUtils.setField(redisStorage, "connection", connectionFactory); + when(connectionFactory.getConnection()).thenReturn(redisConnection); + } + + private byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } + + @Nested + @DisplayName("setObject") + class SetObjectTests { + + @Test + @DisplayName("setObject should write the value when no session is stored yet") + void setObject_shouldWriteValueWhenKeyIsAbsent() throws RedisSessionException { + when(redisConnection.get(bytes(KEY))).thenReturn(null); + + assertEquals(KEY, redisStorage.setObject(KEY, "payload", EXPIRY_SECONDS)); + verify(redisConnection).set(eq(bytes(KEY)), eq(bytes("payload")), + eq(Expiration.seconds(EXPIRY_SECONDS)), eq(SetOption.UPSERT)); + } + + @Test + @DisplayName("setObject should write the value when the stored session is empty") + void setObject_shouldWriteValueWhenStoredSessionIsEmpty() throws RedisSessionException { + when(redisConnection.get(bytes(KEY))).thenReturn(bytes("")); + + assertEquals(KEY, redisStorage.setObject(KEY, "payload", EXPIRY_SECONDS)); + verify(redisConnection).set(any(byte[].class), any(byte[].class), any(Expiration.class), any(SetOption.class)); + } + + @Test + @DisplayName("setObject should leave an existing session untouched") + void setObject_shouldLeaveExistingSessionUntouched() throws RedisSessionException { + when(redisConnection.get(bytes(KEY))).thenReturn(bytes("existing")); + + assertEquals(KEY, redisStorage.setObject(KEY, "payload", EXPIRY_SECONDS)); + verify(redisConnection, never()).set(any(byte[].class), any(byte[].class), + any(Expiration.class), any(SetOption.class)); + } + } + + @Nested + @DisplayName("getObject") + class GetObjectTests { + + @Test + @DisplayName("getObject should return the stored session and extend its expiry") + void getObject_shouldReturnStoredSessionAndExtendExpiry() throws RedisSessionException { + when(redisConnection.get(bytes(KEY))).thenReturn(bytes("payload")); + + assertEquals("payload", redisStorage.getObject(KEY, true, EXPIRY_SECONDS)); + verify(redisConnection).expire(bytes(KEY), EXPIRY_SECONDS); + } + + @Test + @DisplayName("getObject should raise a session exception when the key is absent") + void getObject_shouldRaiseWhenKeyIsAbsent() { + when(redisConnection.get(bytes(KEY))).thenReturn(null); + + RedisSessionException thrown = assertThrows(RedisSessionException.class, + () -> redisStorage.getObject(KEY, true, EXPIRY_SECONDS)); + + assertEquals("Unable to fetch session object from Redis server", thrown.getMessage()); + } + + @Test + @DisplayName("getObject should raise a session exception when the stored value is blank") + void getObject_shouldRaiseWhenStoredValueIsBlank() { + when(redisConnection.get(bytes(KEY))).thenReturn(bytes(" ")); + + assertThrows(RedisSessionException.class, + () -> redisStorage.getObject(KEY, true, EXPIRY_SECONDS)); + verify(redisConnection, never()).expire(any(byte[].class), any(Long.class)); + } + } + + @Nested + @DisplayName("updateObject") + class UpdateObjectTests { + + @Test + @DisplayName("updateObject should overwrite an existing session") + void updateObject_shouldOverwriteExistingSession() throws RedisSessionException { + when(redisConnection.get(bytes(KEY))).thenReturn(bytes("old")); + + assertEquals(KEY, redisStorage.updateObject(KEY, "new", true, EXPIRY_SECONDS)); + verify(redisConnection).set(eq(bytes(KEY)), eq(bytes("new")), + eq(Expiration.seconds(EXPIRY_SECONDS)), eq(SetOption.UPSERT)); + } + + @Test + @DisplayName("updateObject should raise a session exception when there is nothing to update") + void updateObject_shouldRaiseWhenKeyIsAbsent() { + when(redisConnection.get(bytes(KEY))).thenReturn(null); + + RedisSessionException thrown = assertThrows(RedisSessionException.class, + () -> redisStorage.updateObject(KEY, "new", true, EXPIRY_SECONDS)); + + assertEquals("Unable to fetch session object from Redis server", thrown.getMessage()); + } + } + + @Nested + @DisplayName("deleteObject") + class DeleteObjectTests { + + @Test + @DisplayName("deleteObject should return the number of keys Redis removed") + void deleteObject_shouldReturnNumberOfKeysRemoved() throws RedisSessionException { + when(redisConnection.del(bytes(KEY))).thenReturn(1L); + + assertEquals(1L, redisStorage.deleteObject(KEY)); + } + + @Test + @DisplayName("deleteObject should return zero when the key was not present") + void deleteObject_shouldReturnZeroWhenKeyAbsent() throws RedisSessionException { + when(redisConnection.del(bytes(KEY))).thenReturn(0L); + + assertEquals(0L, redisStorage.deleteObject(KEY)); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/response/OutputResponseTest.java b/src/test/java/com/iemr/common/bengen/utils/response/OutputResponseTest.java new file mode 100644 index 0000000..9b48648 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/response/OutputResponseTest.java @@ -0,0 +1,299 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.utils.response; + +import java.io.IOException; +import java.net.ConnectException; +import java.sql.SQLException; +import java.text.ParseException; + +import org.json.JSONException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.iemr.common.bengen.utils.exception.IEMRException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("OutputResponse (response package) Test Suite") +class OutputResponseTest { + + private OutputResponse outputResponse; + + @BeforeEach + @DisplayName("Create a fresh response object before each test") + void setUp() { + outputResponse = new OutputResponse(); + } + + @Nested + @DisplayName("Default state") + class DefaultStateTests { + + @Test + @DisplayName("a new response should default to a generic failure") + void newResponse_shouldDefaultToGenericFailure() { + assertEquals(OutputResponse.GENERIC_FAILURE, outputResponse.getStatusCode()); + assertEquals("Failed with generic error", outputResponse.getErrorMessage()); + assertEquals("FAILURE", outputResponse.getStatus()); + assertFalse(outputResponse.isSuccess()); + } + + @Test + @DisplayName("getData should return null when no data has been set") + void getData_shouldReturnNullWhenNoDataSet() { + assertNull(outputResponse.getData()); + } + } + + @Nested + @DisplayName("setResponse") + class SetResponseTests { + + @Test + @DisplayName("setResponse should mark the response successful") + void setResponse_shouldMarkResponseSuccessful() { + outputResponse.setResponse("done"); + + assertEquals(OutputResponse.SUCCESS, outputResponse.getStatusCode()); + assertEquals("Success", outputResponse.getErrorMessage()); + assertEquals("Success", outputResponse.getStatus()); + assertTrue(outputResponse.isSuccess()); + } + + @Test + @DisplayName("setResponse should keep a JSON object payload as an object") + void setResponse_shouldKeepJsonObjectPayload() { + outputResponse.setResponse("{\"beneficiaryId\":12345}"); + + assertTrue(outputResponse.getData().contains("\"beneficiaryId\"")); + assertTrue(outputResponse.getData().startsWith("{")); + } + + @Test + @DisplayName("setResponse should keep a JSON array payload as an array") + void setResponse_shouldKeepJsonArrayPayload() { + outputResponse.setResponse("[1,2,3]"); + + assertTrue(outputResponse.getData().startsWith("[")); + assertTrue(outputResponse.getData().contains("1")); + } + + @Test + @DisplayName("setResponse should wrap a plain string payload under a response key") + void setResponse_shouldWrapPlainStringPayload() { + outputResponse.setResponse("plain text"); + + assertTrue(outputResponse.getData().contains("response")); + assertTrue(outputResponse.getData().contains("plain text")); + } + + @Test + @DisplayName("toString should serialise the exposed fields as JSON") + void toString_shouldSerialiseExposedFields() { + outputResponse.setResponse("done"); + + String json = outputResponse.toString(); + + assertTrue(json.contains("\"statusCode\":200")); + assertTrue(json.contains("\"status\":\"Success\"")); + assertTrue(json.contains("\"errorMessage\":\"Success\"")); + assertTrue(json.contains("\"data\"")); + } + + @Test + @DisplayName("toString should omit null fields while toStringWithSerialization keeps them") + void toString_shouldOmitNullsUnlikeToStringWithSerialization() { + assertFalse(outputResponse.toString().contains("\"data\"")); + assertTrue(outputResponse.toStringWithSerialization().contains("\"data\":null")); + } + } + + @Nested + @DisplayName("setError with an explicit code") + class SetErrorWithCodeTests { + + @Test + @DisplayName("setError should apply the supplied code, message and status") + void setError_shouldApplySuppliedCodeMessageAndStatus() { + outputResponse.setError(OutputResponse.PREVILAGE_FAILURE, "not permitted", "PRIVILEGE"); + + assertEquals(OutputResponse.PREVILAGE_FAILURE, outputResponse.getStatusCode()); + assertEquals("not permitted", outputResponse.getErrorMessage()); + assertEquals("PRIVILEGE", outputResponse.getStatus()); + assertFalse(outputResponse.isSuccess()); + } + + @Test + @DisplayName("setError should reuse the message as the status when only a message is supplied") + void setError_shouldReuseMessageAsStatus() { + outputResponse.setError(OutputResponse.PASSWORD_FAILURE, "bad password"); + + assertEquals(OutputResponse.PASSWORD_FAILURE, outputResponse.getStatusCode()); + assertEquals("bad password", outputResponse.getErrorMessage()); + assertEquals("bad password", outputResponse.getStatus()); + } + } + + @Nested + @DisplayName("setError mapped from a throwable") + class SetErrorFromThrowableTests { + + @Test + @DisplayName("setError should map IEMRException to a user login failure") + void setError_shouldMapIemrExceptionToUserIdFailure() { + outputResponse.setError(new IEMRException("invalid credentials")); + + assertEquals(OutputResponse.USERID_FAILURE, outputResponse.getStatusCode()); + assertEquals("User login failed", outputResponse.getStatus()); + assertEquals("invalid credentials", outputResponse.getErrorMessage()); + } + + @Test + @DisplayName("setError should map JSONException to an object conversion failure") + void setError_shouldMapJsonExceptionToObjectFailure() { + outputResponse.setError(new JSONException("bad json")); + + assertEquals(OutputResponse.OBJECT_FAILURE, outputResponse.getStatusCode()); + assertEquals("Invalid object conversion", outputResponse.getStatus()); + assertEquals("Invalid object conversion", outputResponse.getErrorMessage()); + } + + @Test + @DisplayName("setError should map SQLException to a code exception") + void setError_shouldMapSqlExceptionToCodeException() { + outputResponse.setError(new SQLException("deadlock")); + + assertEquals(OutputResponse.CODE_EXCEPTION, outputResponse.getStatusCode()); + assertTrue(outputResponse.getStatus().startsWith("Failed with critical errors at ")); + assertEquals("deadlock", outputResponse.getErrorMessage()); + } + + @Test + @DisplayName("setError should map NullPointerException to a code exception") + void setError_shouldMapNullPointerExceptionToCodeException() { + outputResponse.setError(new NullPointerException("npe")); + + assertEquals(OutputResponse.CODE_EXCEPTION, outputResponse.getStatusCode()); + } + + @Test + @DisplayName("setError should map ParseException to a code exception") + void setError_shouldMapParseExceptionToCodeException() { + outputResponse.setError(new ParseException("bad date", 0)); + + assertEquals(OutputResponse.CODE_EXCEPTION, outputResponse.getStatusCode()); + } + + @Test + @DisplayName("setError should map ArrayIndexOutOfBoundsException to a code exception") + void setError_shouldMapArrayIndexExceptionToCodeException() { + outputResponse.setError(new ArrayIndexOutOfBoundsException("index 5")); + + assertEquals(OutputResponse.CODE_EXCEPTION, outputResponse.getStatusCode()); + } + + @Test + @DisplayName("setError should map IOException to an environment exception") + void setError_shouldMapIoExceptionToEnvironmentException() { + outputResponse.setError(new IOException("disk full")); + + assertEquals(OutputResponse.ENVIRONMENT_EXCEPTION, outputResponse.getStatusCode()); + assertTrue(outputResponse.getStatus().startsWith("Failed with connection issues at ")); + assertEquals("disk full", outputResponse.getErrorMessage()); + } + + @Test + @DisplayName("setError should map ConnectException to an environment exception") + void setError_shouldMapConnectExceptionToEnvironmentException() { + outputResponse.setError(new ConnectException("refused")); + + assertEquals(OutputResponse.ENVIRONMENT_EXCEPTION, outputResponse.getStatusCode()); + } + + @Test + @DisplayName("setError should fall back to a generic failure for an unmapped exception") + void setError_shouldFallBackToGenericFailureForUnmappedException() { + outputResponse.setError(new IllegalStateException("something odd")); + + assertEquals(OutputResponse.GENERIC_FAILURE, outputResponse.getStatusCode()); + assertTrue(outputResponse.getStatus().startsWith("Failed with something odd at ")); + assertEquals("something odd", outputResponse.getErrorMessage()); + } + } + + @Nested + @DisplayName("Error mapping for exception types raised by other AMRIT modules") + class ExternalExceptionMappingTests { + + // setError switches on getClass().getSimpleName(), so locally declared types with + // the same simple names reach the arms meant for Hibernate/JDBC exceptions. + private static class JDBCException extends Exception { + JDBCException(String message) { + super(message); + } + } + + private static class SQLGrammarException extends Exception { + SQLGrammarException(String message) { + super(message); + } + } + + private static class ConstraintViolationException extends Exception { + ConstraintViolationException(String message) { + super(message); + } + } + + @Test + @DisplayName("setError should map a JDBC failure to a DB connection environment error") + void setError_shouldMapJdbcFailure() { + outputResponse.setError(new JDBCException("pool exhausted")); + + assertEquals(OutputResponse.ENVIRONMENT_EXCEPTION, outputResponse.getStatusCode()); + assertTrue(outputResponse.getStatus().startsWith("Failed with DB connection issues at ")); + assertEquals("pool exhausted", outputResponse.getErrorMessage()); + } + + @Test + @DisplayName("setError should map a SQL grammar failure to a code exception") + void setError_shouldMapSqlGrammarFailure() { + outputResponse.setError(new SQLGrammarException("bad column")); + + assertEquals(OutputResponse.CODE_EXCEPTION, outputResponse.getStatusCode()); + } + + @Test + @DisplayName("setError should map a constraint violation to a code exception") + void setError_shouldMapConstraintViolation() { + outputResponse.setError(new ConstraintViolationException("duplicate key")); + + assertEquals(OutputResponse.CODE_EXCEPTION, outputResponse.getStatusCode()); + } + } +} diff --git a/src/test/java/com/iemr/common/bengen/utils/sessionobject/SessionObjectTest.java b/src/test/java/com/iemr/common/bengen/utils/sessionobject/SessionObjectTest.java new file mode 100644 index 0000000..0b96034 --- /dev/null +++ b/src/test/java/com/iemr/common/bengen/utils/sessionobject/SessionObjectTest.java @@ -0,0 +1,143 @@ +/* +* AMRIT - Accessible Medical Records via Integrated Technologies +* 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.common.bengen.utils.sessionobject; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.iemr.common.bengen.utils.config.ConfigProperties; +import com.iemr.common.bengen.utils.redis.RedisSessionException; +import com.iemr.common.bengen.utils.redis.RedisStorage; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("SessionObject Test Suite") +class SessionObjectTest { + + private static final String KEY = "session-key"; + private static final String VALUE = "{\"userName\":\"amrit-user\"}"; + + @Mock + private RedisStorage objectStore; + + private SessionObject sessionObject; + private int expectedExpiry; + private boolean expectedExtend; + + @BeforeEach + @DisplayName("Wire the session holder with a mocked Redis store before each test") + void setUp() { + sessionObject = new SessionObject(); + sessionObject.setObjectStore(objectStore); + expectedExpiry = ConfigProperties.getSessionExpiryTime(); + expectedExtend = ConfigProperties.getExtendExpiryTime(); + } + + @Nested + @DisplayName("Reading and writing the session") + class ReadWriteTests { + + @Test + @DisplayName("getSessionObject should delegate to the store with the configured expiry settings") + void getSessionObject_shouldDelegateWithConfiguredExpiry() throws RedisSessionException { + when(objectStore.getObject(KEY, expectedExtend, expectedExpiry)).thenReturn(VALUE); + + assertEquals(VALUE, sessionObject.getSessionObject(KEY)); + verify(objectStore).getObject(KEY, expectedExtend, expectedExpiry); + } + + @Test + @DisplayName("setSessionObject should delegate to the store with the configured expiry") + void setSessionObject_shouldDelegateWithConfiguredExpiry() throws RedisSessionException { + when(objectStore.setObject(KEY, VALUE, expectedExpiry)).thenReturn(KEY); + + assertEquals(KEY, sessionObject.setSessionObject(KEY, VALUE)); + verify(objectStore).setObject(KEY, VALUE, expectedExpiry); + } + + @Test + @DisplayName("updateSessionObject should delegate to the store with the configured expiry settings") + void updateSessionObject_shouldDelegateWithConfiguredExpiry() throws RedisSessionException { + when(objectStore.updateObject(KEY, VALUE, expectedExtend, expectedExpiry)).thenReturn(KEY); + + assertEquals(KEY, sessionObject.updateSessionObject(KEY, VALUE)); + verify(objectStore).updateObject(KEY, VALUE, expectedExtend, expectedExpiry); + } + + @Test + @DisplayName("deleteSessionObject should delegate the removal to the store") + void deleteSessionObject_shouldDelegateRemoval() throws RedisSessionException { + when(objectStore.deleteObject(KEY)).thenReturn(1L); + + sessionObject.deleteSessionObject(KEY); + + verify(objectStore).deleteObject(KEY); + } + } + + @Nested + @DisplayName("Propagating store failures") + class FailureTests { + + @Test + @DisplayName("getSessionObject should propagate a missing-session failure") + void getSessionObject_shouldPropagateMissingSessionFailure() throws RedisSessionException { + when(objectStore.getObject(anyString(), anyBoolean(), anyInt())) + .thenThrow(new RedisSessionException("Unable to fetch session object from Redis server")); + + RedisSessionException thrown = assertThrows(RedisSessionException.class, + () -> sessionObject.getSessionObject(KEY)); + + assertEquals("Unable to fetch session object from Redis server", thrown.getMessage()); + } + + @Test + @DisplayName("updateSessionObject should propagate a missing-session failure") + void updateSessionObject_shouldPropagateMissingSessionFailure() throws RedisSessionException { + when(objectStore.updateObject(eq(KEY), eq(VALUE), anyBoolean(), anyInt())) + .thenThrow(new RedisSessionException("Unable to fetch session object from Redis server")); + + assertThrows(RedisSessionException.class, () -> sessionObject.updateSessionObject(KEY, VALUE)); + } + + @Test + @DisplayName("deleteSessionObject should propagate a store failure") + void deleteSessionObject_shouldPropagateStoreFailure() throws RedisSessionException { + when(objectStore.deleteObject(KEY)).thenThrow(new RedisSessionException("redis down")); + + assertThrows(RedisSessionException.class, () -> sessionObject.deleteSessionObject(KEY)); + } + } +}