diff --git a/src/main/java/uk/ac/ngs/TaskConfig.java b/src/main/java/uk/ac/ngs/TaskConfig.java new file mode 100644 index 0000000..9b41629 --- /dev/null +++ b/src/main/java/uk/ac/ngs/TaskConfig.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2015 STFC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.ac.ngs; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.scheduling.annotation.SchedulingConfigurer; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; + +import uk.ac.ngs.service.CertificateService; + +@Configuration +@EnableScheduling +public class TaskConfig implements SchedulingConfigurer { + public static final Logger log = LoggerFactory.getLogger(TaskConfig.class); + + private CertificateService certificateService; + + public TaskConfig(CertificateService certificateService) { + this.certificateService = certificateService; + } + + + @Scheduled(cron = "${cert.expiry.reminder.cron:0 0 7 * * ?}") + public void runDailyCertificateExpiryReminderJob() { + + log.info("Starting daily certificate expiry reminder job"); + + long startTime = System.currentTimeMillis(); + + try { + certificateService.sendCertificateExpiryReminders(); + + long durationMs = System.currentTimeMillis() - startTime; + log.info("Completed daily certificate expiry reminder job successfully in {} ms", + durationMs); + + } catch (Exception ex) { + log.error("Daily certificate expiry reminder job failed", ex); + throw ex; + } + + } + + @Override + public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { + taskRegistrar.setScheduler(taskScheduler()); + } + + + @Bean + public ThreadPoolTaskScheduler taskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(5); + scheduler.setThreadNamePrefix("cert-expiry-scheduler-"); + scheduler.setWaitForTasksToCompleteOnShutdown(true); + scheduler.setAwaitTerminationSeconds(30); + return scheduler; + } +} diff --git a/src/main/java/uk/ac/ngs/WebConfig.java b/src/main/java/uk/ac/ngs/WebConfig.java index d9df489..ec142d5 100644 --- a/src/main/java/uk/ac/ngs/WebConfig.java +++ b/src/main/java/uk/ac/ngs/WebConfig.java @@ -120,6 +120,7 @@ public EmailService emailService() { emailService.setEmailOnRaopRoleRequestApprovalTemplate("emailOnRaopRoleRequestApprovalTemplate.html"); emailService.setEmailOnRaopRoleRequestRejectionTemplate("emailOnRaopRoleRequestRejectionTemplate.html"); emailService.setEmailOnRoleChangeToUserTemplate("emailOnRoleChangeToUserTemplate.html"); + emailService.setEmailUserCertExpiryReminderTemplate("emailUserCertExpiryReminderTemplate.html"); emailService.setBasePortalUrl(basePortalUrl); return emailService; } diff --git a/src/main/java/uk/ac/ngs/dao/JdbcCertificateDao.java b/src/main/java/uk/ac/ngs/dao/JdbcCertificateDao.java index 90f14c5..af79c5c 100644 --- a/src/main/java/uk/ac/ngs/dao/JdbcCertificateDao.java +++ b/src/main/java/uk/ac/ngs/dao/JdbcCertificateDao.java @@ -34,6 +34,9 @@ import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -458,6 +461,52 @@ public String updateDataCol_LastActionDateRaop(String data, long raopId) { } + /** + * Retrieves all valid certificates that are expiring exactly in the given + * number of days. + * + *

+ * This method is intended for reminder notifications (e.g. 7-day expiry + * reminder). + * A certificate is considered matching if its {@code notafter} timestamp falls + * within the full UTC day that is {@code daysToExpire} days from now. + *

+ * + * @param daysToExpire number of days from today (e.g. 7 for a 7-day reminder) + * @return list of certificates expiring exactly in the specified number of days + */ + public List getValidCertificatesExpiringInDays(LocalDate processingDate, int daysToExpire) { + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss").withZone(ZoneOffset.UTC); + + LocalDate targetDate = processingDate.plusDays(daysToExpire); + + // yyyyMMddHHmmss produces only digits → safe to parse as long + long startOfDay = Long.parseLong( + formatter.format(targetDate.atStartOfDay(ZoneOffset.UTC))); + + // yyyyMMddHHmmss produces only digits → safe to parse as long + long endOfDay = Long.parseLong( + formatter.format(targetDate.plusDays(1) + .atStartOfDay(ZoneOffset.UTC))); + + Map params = Map.of( + "startOfDay", startOfDay, + "endOfDay", endOfDay); + + String sql = """ + SELECT cert_key, 'data' as data, dn, cn, email, status, role, notafter + FROM certificate + WHERE status = 'VALID' + AND notafter >= :startOfDay + AND notafter < :endOfDay + ORDER BY notafter ASC + """; + + return jdbcTemplate.query(sql, params, new CertificateRowMapper()); + } + + /** * Build up the query using the given where by parameters in the map * and return the query and the named parameter map for subsequent parameter-binding/execution. diff --git a/src/main/java/uk/ac/ngs/dao/JobExecutionDao.java b/src/main/java/uk/ac/ngs/dao/JobExecutionDao.java new file mode 100644 index 0000000..d5a21fe --- /dev/null +++ b/src/main/java/uk/ac/ngs/dao/JobExecutionDao.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2015 STFC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.ac.ngs.dao; + +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.Map; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +@Repository +public class JobExecutionDao { + private NamedParameterJdbcTemplate jdbcTemplate; + private static final Log log = LogFactory.getLog(JobExecutionDao.class); + + public JobExecutionDao(NamedParameterJdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public LocalDate getLastRunDate(String jobName) { + + String sql = """ + SELECT last_run_date + FROM job_execution_tracker + WHERE job_name = :jobName + """; + + Map params = Map.of("jobName", jobName); + + try { + LocalDate result = jdbcTemplate.queryForObject(sql, params, + (rs, rowNum) -> rs.getDate("last_run_date").toLocalDate()); + + log.info("Last run date for job '" + jobName + "' is " + result); + return result; + + } catch (EmptyResultDataAccessException ex) { + log.warn("No last run date found for job '" + jobName + "'. Assuming first run."); + return null; // first run case + } + } + + public void updateLastRunDate(String jobName, LocalDate lastRunDate) { + // This query inserts a new job record if it doesn’t exist, + // or updates its last run date if it already exists, using PostgreSQL’s UPSERT mechanism. + String sql = """ + INSERT INTO job_execution_tracker (job_name, last_run_date) + VALUES (:jobName, :lastRunDate) + ON CONFLICT (job_name) + DO UPDATE SET last_run_date = EXCLUDED.last_run_date + """; + + Map params = Map.of( + "jobName", jobName, + "lastRunDate", java.sql.Date.valueOf(lastRunDate)); + + int rows = jdbcTemplate.update(sql, params); + + log.info("Updated last run date for job '" + jobName + "' to " + lastRunDate + " (rows affected: " + rows + ")"); + } + +} diff --git a/src/main/java/uk/ac/ngs/service/CertificateService.java b/src/main/java/uk/ac/ngs/service/CertificateService.java index 8dc7f81..dab9ccb 100644 --- a/src/main/java/uk/ac/ngs/service/CertificateService.java +++ b/src/main/java/uk/ac/ngs/service/CertificateService.java @@ -19,6 +19,7 @@ import org.springframework.validation.Errors; import org.springframework.validation.MapBindingResult; import uk.ac.ngs.common.MutableConfigParams; +import uk.ac.ngs.dao.JobExecutionDao; import uk.ac.ngs.dao.JdbcCertificateDao; import uk.ac.ngs.dao.RoleChangeRequestRepository; import uk.ac.ngs.domain.CertificateRow; @@ -29,6 +30,7 @@ import javax.inject.Inject; import java.io.IOException; import java.time.LocalDate; +import java.time.ZoneOffset; import java.util.Date; import java.util.EnumMap; import java.util.HashMap; @@ -48,6 +50,7 @@ public class CertificateService { private static final Log log = LogFactory.getLog(CertificateService.class); private JdbcCertificateDao jdbcCertDao; + private JobExecutionDao jobExecutionDao; private EmailService emailService; private MutableConfigParams mutableConfigParams; private RoleChangeRequestRepository roleChangeRequestRepository; @@ -245,11 +248,86 @@ private void sendEmailNotificationOnRoleChangeRequest(CertificateRow targetCert, this.emailService.sendUserOnRaopRoleRequest(requesterCN, targetCN, targetEmail); } + + /** + * + * Sends certificate expiry reminder emails for 7-day and 30-day intervals, + * including catch-up processing for any missed executions. + * + *

+ * This method is intended to be executed by a scheduled job + * once per day. + *

+ * + *

+ * If the scheduler did not run on previous days (e.g. due to downtime), + * this method processes all missed dates from the last successful run up to + * today, ensuring no expiry reminders are skipped. + *

+ * + */ + public void sendCertificateExpiryReminders() { + LocalDate today = LocalDate.now(ZoneOffset.UTC); + + LocalDate lastRun = jobExecutionDao.getLastRunDate("CERT_EXPIRY_REMINDER"); + + // First time case + if (lastRun == null) { + lastRun = today.minusDays(1); + } + + LocalDate processingDate = lastRun.plusDays(1); + + while (!processingDate.isAfter(today)) { + sendRemindersForDaysToExpire(processingDate, 7); + sendRemindersForDaysToExpire(processingDate, 30); + processingDate = processingDate.plusDays(1); + } + + jobExecutionDao.updateLastRunDate( + "CERT_EXPIRY_REMINDER", today); + } + + private void sendRemindersForDaysToExpire(LocalDate processingDate, int daysToExpire) { + + List certificates = jdbcCertDao.getValidCertificatesExpiringInDays(processingDate, + daysToExpire); + + int totalCertificates = certificates.size(); + + if (totalCertificates > 0) { + log.info(totalCertificates + " certificate(s) are going to expire after " + daysToExpire + " days."); + } else { + log.info("No certificates are going to expire after " + daysToExpire + " days."); + } + + int successCount = 0; + int failureCount = 0; + + for (CertificateRow cert : certificates) { + log.info("Sending " + daysToExpire + "-days expiry reminder for cert [" + cert.getCert_key() + "] to " + + cert.getEmail()); + if (this.emailService.sendEmailReminderToUserOnCertExpiry(cert, daysToExpire)) { + successCount++; + } else { + failureCount++; + } + } + log.info("Summary for " + daysToExpire + "-day expiry reminder (processingDate=" + processingDate + "): total=" + + totalCertificates + ", sent=" + successCount + ", failed=" + failureCount); + } + + @Inject public void setJdbcCertificateDao(JdbcCertificateDao jdbcCertDao) { this.jdbcCertDao = jdbcCertDao; } + @Inject + public void setJobExecutionDao(JobExecutionDao certExpiryReminderJobExecutionDao) { + this.jobExecutionDao = certExpiryReminderJobExecutionDao; + } + @Inject public void setEmailService(EmailService emailService) { this.emailService = emailService; diff --git a/src/main/java/uk/ac/ngs/service/email/EmailService.java b/src/main/java/uk/ac/ngs/service/email/EmailService.java index 023d1c9..3e9729a 100644 --- a/src/main/java/uk/ac/ngs/service/email/EmailService.java +++ b/src/main/java/uk/ac/ngs/service/email/EmailService.java @@ -18,6 +18,7 @@ import org.springframework.mail.SimpleMailMessage; import uk.ac.ngs.common.CertUtil; import uk.ac.ngs.domain.CSR_Flags; +import uk.ac.ngs.domain.CertificateRow; import javax.inject.Inject; import java.io.PrintWriter; @@ -55,6 +56,7 @@ public class EmailService { private String emailOnRaopRoleRequestApprovalTemplate; private String emailOnRaopRoleRequestRejectionTemplate; private String emailOnRoleChangeToUserTemplate; + private String emailUserCertExpiryReminderTemplate; private String basePortalUrl; @@ -490,6 +492,38 @@ public void sendEmailOnRaopRoleRequestRejection(String recipient, String actorCN } } + +/** + * Sends an email reminder to the user informing them of an upcoming + * certificate expiry. + * + * @param cert certificate + * @param daysToExpire number of days remaining before expiry + */ + + public boolean sendEmailReminderToUserOnCertExpiry(CertificateRow cert, int daysToExpire) { + SimpleMailMessage msg = new SimpleMailMessage(this.emailTemplate); + msg.setTo(cert.getEmail()); + msg.setSubject("Your e-Science User Certificate will expire in " + daysToExpire + " days!"); + Map vars = new HashMap<>(); + + vars.put("certKey", cert.getCert_key()); + vars.put("daysToExpire", daysToExpire); + vars.put("cn", cert.getCn()); + vars.put("dn", cert.getDn()); + vars.put("basePortalUrl", basePortalUrl); + + try { + this.mailSender.send(msg, vars, this.emailUserCertExpiryReminderTemplate); + log.debug("Certificate expiry reminder email sent to " + cert.getCn()); + return true; + } catch (MailException ex) { + log.error("Error while sending certificate expiry reminder email to " + cert.getCn() + ": " + + ex.getMessage()); + return false; + } + } + /** * Email on RAOP to user role change. * @@ -649,4 +683,11 @@ public void setEmailOnRoleChangeToUserTemplate(String emailOnRoleChangeToUserTem this.emailOnRoleChangeToUserTemplate = emailOnRoleChangeToUserTemplate; } + /** + * @param emailUserCertExpiryReminderTemplate the emailUserCertExpiryReminderTemplate to set + */ + public void setEmailUserCertExpiryReminderTemplate(String emailUserCertExpiryReminderTemplate) { + this.emailUserCertExpiryReminderTemplate = emailUserCertExpiryReminderTemplate; + } + } diff --git a/src/main/resources/application.propertiesTEMPLATE b/src/main/resources/application.propertiesTEMPLATE index bff4f4f..928c815 100644 --- a/src/main/resources/application.propertiesTEMPLATE +++ b/src/main/resources/application.propertiesTEMPLATE @@ -46,6 +46,9 @@ email.password=password # The portal can (optionally) send emails, this is used for the from address: email.from=support@grid-support.ac.uk +#Schedule to run certificate expiry reminder email +cert.expiry.reminder.cron=0 0 7 * * ? + # From https://blog.swdev.ed.ac.uk/2015/06/24/adding-embedded-tomcat-ajp-support-to-a-spring-boot-application/ tomcat.ajp.port=9090 diff --git a/src/main/webapp/WEB-INF/freemarker/email/emailUserCertExpiryReminderTemplate.html b/src/main/webapp/WEB-INF/freemarker/email/emailUserCertExpiryReminderTemplate.html new file mode 100644 index 0000000..9d09d43 --- /dev/null +++ b/src/main/webapp/WEB-INF/freemarker/email/emailUserCertExpiryReminderTemplate.html @@ -0,0 +1,41 @@ + + + + + + Dear Owner of UK e-Science Certificate number: ${certKey}, +
+
+ You have a certificate which will expire in ${daysToExpire} days. If you do not wish to renew this + certificate, or if you have renewed already, please ignore this message. +
+
+ Your certificate was issued for "${cn}" and has the subject: +
+
+ [${dn}] +
+
+ If you prefer a browser interface for your certificate management and you have your current certificate in your + browser, please go to the CA Portal ${basePortalUrl} and follow the instructions + for renewal. +
+
+ Other tools are available at https://ca.grid-support.ac.uk +
+
+ The server will then queue your renewal request for your RA to approve. + There is NO need to visit your RA. You will receive an e-mail when the certificate is available. +
+
+ If you have any questions, concerns or comments, then we shall be pleased to hear them. We are always aiming to + improve our service, and so your feedback will be appreciated. +
+
+ Regards, +
+
+ The UK eScience CA Support Centre + + + \ No newline at end of file diff --git a/src/test/java/uk/ac/ngs/dao/JdbcCertificateDaoTest.java b/src/test/java/uk/ac/ngs/dao/JdbcCertificateDaoTest.java index 22c3531..2a6bb79 100644 --- a/src/test/java/uk/ac/ngs/dao/JdbcCertificateDaoTest.java +++ b/src/test/java/uk/ac/ngs/dao/JdbcCertificateDaoTest.java @@ -12,6 +12,11 @@ */ package uk.ac.ngs.dao; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -28,6 +33,7 @@ import static org.junit.Assert.*; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -157,4 +163,109 @@ public void testFindActiveCAs_withValidInputs_returnsResults() { assertTrue(capturedQuery.contains("notafter > :current_time")); } + + @Test + public void shouldReturnCertificatesExpiringInSevenDays() { + // given + int daysToExpire = 7; + LocalDate processingDate = LocalDate.now(ZoneOffset.UTC); + + CertificateRow cert1 = new CertificateRow(); + cert1.setCert_key(201L); + cert1.setDn("dn1"); + cert1.setCn("cn1"); + cert1.setEmail("a@test.com"); + CertificateRow cert2 = new CertificateRow(); + cert1.setCert_key(202L); + cert1.setDn("dn2"); + cert1.setCn("cn2"); + cert1.setEmail("b@test.com"); + + List expectedRows = List.of(cert1, cert2); + + when(jdbcTemplate.query(anyString(), anyMap(), ArgumentMatchers.>any())) + .thenReturn(expectedRows); + + // when + List result = jdbcCertificateDao.getValidCertificatesExpiringInDays(processingDate, daysToExpire); + + // then + assertNotNull(result); + assertEquals(2, result.size()); + assertEquals(expectedRows, result); + } + + @Test + public void shouldReturnEmptyListWhenNoCertificatesExpiringInGivenDays() { + int daysToExpire = 7; + LocalDate processingDate = LocalDate.now(ZoneOffset.UTC); + + // given + when(jdbcTemplate.query(anyString(), anyMap(), ArgumentMatchers.>any())) + .thenReturn(List.of()); + + // when + List result = jdbcCertificateDao.getValidCertificatesExpiringInDays(processingDate, daysToExpire); + + // then + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + public void shouldUseCorrectStartAndEndOfDayForExpiryCheck() { + int daysToExpire = 7; + LocalDate processingDate = LocalDate.now(ZoneOffset.UTC); + + String EXPECTED_SQL = """ + SELECT cert_key, 'data' as data, dn, cn, email, status, role, notafter + FROM certificate + WHERE status = 'VALID' + AND notafter >= :startOfDay + AND notafter < :endOfDay + ORDER BY notafter ASC + """; + + // given + ArgumentCaptor> paramsCaptor = ArgumentCaptor.forClass(Map.class); + + when(jdbcTemplate.query( + eq(EXPECTED_SQL), + paramsCaptor.capture(), + ArgumentMatchers.>any())) + .thenReturn(List.of()); + + // when + jdbcCertificateDao.getValidCertificatesExpiringInDays(processingDate, daysToExpire); + + // then + Map params = paramsCaptor.getValue(); + + assertTrue(params.containsKey("startOfDay")); + assertTrue(params.containsKey("endOfDay")); + + Object startObj = params.get("startOfDay"); + Object endObj = params.get("endOfDay"); + + assertTrue(startObj instanceof Long); + assertTrue(endObj instanceof Long); + + long start = (Long) startObj; + long end = (Long) endObj; + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + LocalDateTime startDateTime = LocalDateTime.parse(String.valueOf(start), formatter); + + LocalDateTime endDateTime = LocalDateTime.parse(String.valueOf(end), formatter); + + // Sanity checks + assertTrue(startDateTime.isBefore(endDateTime)); + + // Exactly 1 day window + assertEquals( + Duration.ofDays(1), + Duration.between(startDateTime, endDateTime)); + + } } diff --git a/src/test/java/uk/ac/ngs/dao/JobExecutionDaoTests.java b/src/test/java/uk/ac/ngs/dao/JobExecutionDaoTests.java new file mode 100644 index 0000000..039302a --- /dev/null +++ b/src/test/java/uk/ac/ngs/dao/JobExecutionDaoTests.java @@ -0,0 +1,142 @@ +package uk.ac.ngs.dao; + +import static org.junit.Assert.assertNull; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.sql.ResultSet; +import java.time.LocalDate; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; + +@ExtendWith(MockitoExtension.class) +public class JobExecutionDaoTests { + + private NamedParameterJdbcTemplate jdbcTemplate; + private JobExecutionDao jobExecutionDao; + + @Before + public void setUp() { + jdbcTemplate = mock(NamedParameterJdbcTemplate.class); + jobExecutionDao = new JobExecutionDao(jdbcTemplate); + } + + @Test + public void shouldReturnLastRunDate() { + + String jobName = "CERT_EXPIRY_REMINDER"; + LocalDate expectedDate = LocalDate.of(2026, 5, 10); + + when(jdbcTemplate.queryForObject( + anyString(), + any(Map.class), + any(RowMapper.class))) + .thenReturn(expectedDate); + + LocalDate result = jobExecutionDao.getLastRunDate(jobName); + + assertNotNull(result); + assertEquals(expectedDate, result); + + verify(jdbcTemplate).queryForObject( + anyString(), + argThat((Map params) -> jobName.equals(params.get("jobName"))), + ArgumentMatchers.>any()); + } + + @Test + public void shouldReturnNullWhenNoRecordExists() { + + when(jdbcTemplate.queryForObject( + anyString(), + any(Map.class), + any(RowMapper.class))) + .thenThrow(new EmptyResultDataAccessException(1)); + + LocalDate result = jobExecutionDao.getLastRunDate("CERT_EXPIRY_REMINDER"); + + assertNull(result); + } + + @Test + public void shouldMapSqlDateToLocalDate() throws Exception { + + ResultSet rs = mock(ResultSet.class); + + java.sql.Date sqlDate = java.sql.Date.valueOf("2026-05-10"); + + when(rs.getDate("last_run_date")).thenReturn(sqlDate); + + RowMapper mapper = (r, rowNum) -> r.getDate("last_run_date").toLocalDate(); + + LocalDate result = mapper.mapRow(rs, 1); + + assertEquals(LocalDate.of(2026, 5, 10), result); + } + + @Test + public void shouldUpdateLastRunDate() { + + String jobName = "CERT_EXPIRY_REMINDER"; + LocalDate date = LocalDate.of(2026, 5, 11); + + when(jdbcTemplate.update(anyString(), any(Map.class))) + .thenReturn(1); + + jobExecutionDao.updateLastRunDate(jobName, date); + + verify(jdbcTemplate).update( + anyString(), + argThat((Map params) -> jobName.equals(params.get("jobName")) && + java.sql.Date.valueOf(date).equals(params.get("lastRunDate")))); + + } + + @Test + public void shouldExecuteCorrectSqlForUpdate() { + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + + when(jdbcTemplate.update(sqlCaptor.capture(), any(Map.class))) + .thenReturn(1); + + jobExecutionDao.updateLastRunDate( + "CERT_EXPIRY_REMINDER", + LocalDate.now()); + + String executedSql = sqlCaptor.getValue(); + + assertTrue(executedSql.contains("INSERT INTO job_execution_tracker")); + assertTrue(executedSql.contains("ON CONFLICT")); + } + + @Test + public void shouldStillExecuteUpdateEvenIfZeroRowsAffected() { + + when(jdbcTemplate.update(anyString(), any(Map.class))) + .thenReturn(0); + + jobExecutionDao.updateLastRunDate( + "CERT_EXPIRY_REMINDER", + LocalDate.now()); + + verify(jdbcTemplate).update(anyString(), any(Map.class)); + } + +} diff --git a/src/test/java/uk/ac/ngs/service/email/EmailServiceTests.java b/src/test/java/uk/ac/ngs/service/email/EmailServiceTests.java new file mode 100644 index 0000000..fbb6edf --- /dev/null +++ b/src/test/java/uk/ac/ngs/service/email/EmailServiceTests.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2015 STFC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.ac.ngs.service.email; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.mail.MailSendException; +import org.springframework.mail.SimpleMailMessage; + +import uk.ac.ngs.domain.CertificateRow; + +@RunWith(MockitoJUnitRunner.class) +public class EmailServiceTests { + + @Mock + private Sender mailSender; + + @Mock + private SimpleMailMessage emailTemplate; + + @InjectMocks + private EmailService emailService; + + private CertificateRow cert; + + @Before + public void setUp() { + cert = new CertificateRow(); + cert.setCert_key(123); + cert.setEmail("user@test.com"); + cert.setCn("Test CN"); + cert.setDn("Test DN"); + + emailService.setBasePortalUrl("https://portal.test"); + emailService.setEmailUserCertExpiryReminderTemplate("emailUserCertExpiryReminderTemplate.html"); + } + + @Test + public void shouldSendEmailReminderSuccessfully() { + + int daysToExpire = 7; + + // when + emailService.sendEmailReminderToUserOnCertExpiry(cert, daysToExpire); + + // then + verify(mailSender).send( + argThat(msg -> "user@test.com".equals(msg.getTo()[0]) && + msg.getSubject().contains("7 days")), + argThat(vars -> vars.get("certKey").equals(123L) && + vars.get("daysToExpire").equals(7) && + vars.get("cn").equals("Test CN") && + vars.get("dn").equals("Test DN") && + vars.get("basePortalUrl").equals("https://portal.test")), + eq("emailUserCertExpiryReminderTemplate.html")); + } + + @Test + public void shouldHandleMailExceptionGracefully() { + + doThrow(new MailSendException("SMTP error")) + .when(mailSender) + .send(any(), any(), any()); + + // no exception should be thrown + assertDoesNotThrow(() -> emailService.sendEmailReminderToUserOnCertExpiry(cert, 7)); + + verify(mailSender).send(any(), any(), any()); + } + +} diff --git a/usefulStuff/sql/create_openca_ddl.sql b/usefulStuff/sql/create_openca_ddl.sql index 0cd4bc8..0bef3ef 100644 --- a/usefulStuff/sql/create_openca_ddl.sql +++ b/usefulStuff/sql/create_openca_ddl.sql @@ -327,6 +327,16 @@ CREATE TABLE role_change_request ( ); +-- +-- Name: job_execution_tracker; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE job_execution_tracker ( + job_name VARCHAR(100) PRIMARY KEY, + last_run_date DATE +); + + -- -- Name: seq_bulk; Type: SEQUENCE; Schema: public; Owner: - --