Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/service.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -160,4 +160,9 @@ spec:
valueFrom:
secretKeyRef:
name: google-pubsub-service-account
key: latest
- name: CRON_SECRET
valueFrom:
secretKeyRef:
name: cron-secret
key: latest
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,20 @@

import jakarta.transaction.Transactional;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

import java.time.LocalDateTime;
import java.util.List;

@Component
@RestController
@RequestMapping("/api/cron")
@Slf4j
public class JobScheduler {

Expand All @@ -28,6 +35,9 @@ public class JobScheduler {

private final PasswordResetTokenRepository passwordTokenRepo;
private final VerificationTokenRepository verificationTokenRepo;

@Value("${app.cron.secret}")
private String expectedCronSecret;

public JobScheduler(JobService jobService,
UserRepository userRepository,
Expand All @@ -44,66 +54,77 @@ public JobScheduler(JobService jobService,
}

/**
* Daily Maintenance: Rejects stale applications (>60 days).
* Runs at Midnight UTC.
* Daily Maintenance Endpoint: Replaces all internal @Scheduled jobs.
* Validates the X-Cron-Secret header and executes jobs sequentially.
*/
@Scheduled(cron = "0 0 0 * * *")
public void runStaleJobCleanup() {
log.info("Maintenance: Starting stale job cleanup...");
@PostMapping("/daily-maintenance")
@Transactional
public ResponseEntity<String> runDailyMaintenance(@RequestHeader(value = "X-Cron-Secret", required = false) String cronSecret) {
if (cronSecret == null || !cronSecret.equals(expectedCronSecret)) {
log.warn("Unauthorized access attempt to daily-maintenance cron endpoint");
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid cron secret");
}

log.info("Starting Daily Maintenance cron jobs...");

try {
runStaleJobCleanup();
renewGmailWatches();
runSystemCleanup();
processScheduledDeletions();

log.info("Daily Maintenance cron jobs completed successfully.");
return ResponseEntity.ok("Maintenance completed successfully.");
} catch (Exception e) {
log.error("Error during daily maintenance execution: {}", e.getMessage(), e);
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Maintenance failed");
}
}

private void runStaleJobCleanup() {
log.info("Maintenance Step 1: Starting stale job cleanup...");
try {
jobService.cleanupStaleApplications();
log.info("Maintenance: Stale job cleanup completed.");
log.info("Maintenance Step 1: Stale job cleanup completed.");
} catch (Exception e) {
log.error("Maintenance Error: Stale job cleanup failed: {}", e.getMessage());
log.error("Maintenance Step 1 Error: Stale job cleanup failed: {}", e.getMessage());
}
}

/**
* Gmail Security: Renews the 7-day watch lease every 5 days.
*/
@Scheduled(cron = "0 30 0 */5 * *")
public void renewGmailWatches() {
log.info("Gmail Sync: Starting bulk watch renewal...");
private void renewGmailWatches() {
log.info("Maintenance Step 2: Starting bulk watch renewal...");

List<User> users = userRepository.findByGmailConnectedTrue();

if (users.isEmpty()) {
log.info("Gmail Sync: No connected users found for renewal.");
log.info("Maintenance Step 2: No connected users found for renewal.");
return;
}

users.parallelStream().forEach(user -> {
try {
gmailIntegrationService.renewWatch(user);
} catch (Exception e) {
log.error("Gmail Sync Error: Renewal failed for {}: {}", user.getEmail(), e.getMessage());
log.error("Maintenance Step 2 Error: Renewal failed for {}: {}", user.getEmail(), e.getMessage());
}
});

log.info("Gmail Sync: Finished bulk watch renewal for {} users.", users.size());
log.info("Maintenance Step 2: Finished bulk watch renewal for {} users.", users.size());
}

@Scheduled(cron = "0 0 1 * * *")
@Transactional
public void runSystemCleanup() {
log.info("Starting system-wide security cleanup...");
private void runSystemCleanup() {
log.info("Maintenance Step 3: Starting system-wide security cleanup...");
LocalDateTime now = LocalDateTime.now();

passwordTokenRepo.deleteAllExpired(now);

verificationTokenRepo.deleteAllExpired(now);

userRepository.deleteUnverifiedUsers(now.minusDays(3));

log.info("System cleanup completed. Database pruned of expired security entries.");
log.info("Maintenance Step 3: System cleanup completed. Database pruned of expired security entries.");
}

/*
* Scheduled task to process user deletions after the 3-day grace period.
*/
@Scheduled(cron = "0 30 1 * * *")
public void processScheduledDeletions() {
log.info("Starting scheduled user deletion cleanup...");
private void processScheduledDeletions() {
log.info("Maintenance Step 4: Starting scheduled user deletion cleanup...");

List<User> usersToDelete = userRepository.findAllByPendingDeletionTrueAndDeletionRequestedAtBefore(
LocalDateTime.now().minusDays(3)
Expand All @@ -113,10 +134,10 @@ public void processScheduledDeletions() {
try {
userDeletionService.deleteUserCompletely(user.getEmail());
} catch (Exception e) {
log.error("Failed to delete user: {}", user.getEmail(), e);
log.error("Maintenance Step 4 Error: Failed to delete user: {}", user.getEmail(), e);
}
}

log.info("Scheduled deletion cleanup completed. Deleted {} users.", usersToDelete.size());
log.info("Maintenance Step 4: Scheduled deletion cleanup completed. Deleted {} users.", usersToDelete.size());
}
}
3 changes: 2 additions & 1 deletion backend/src/main/resources/application-dev.properties
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ spring.security.oauth2.client.registration.google.redirect-uri={baseUrl}/login/o
# config
app.allowed.cors=http://localhost:4200
app.allowed.methods=GET,POST,PUT,DELETE,OPTIONS
app.public.endpoints=/api/auth/signup,/api/auth/login,/api/auth/refresh,/api/auth/logout,/api/auth/forgot-password,/api/auth/verify-email,/api/auth/resend-verification,/api/auth/reset-password,/oauth2/**,/api/webhooks/inbound-email,/api/webhooks/gmail/push
app.public.endpoints=/api/auth/signup,/api/auth/login,/api/auth/refresh,/api/auth/logout,/api/auth/forgot-password,/api/auth/verify-email,/api/auth/resend-verification,/api/auth/reset-password,/oauth2/**,/api/webhooks/inbound-email,/api/webhooks/gmail/push,/api/cron/**
app.cron.secret=${CRON_SECRET:default-dev-cron-secret-123}

# JWT Secret (Must be long and secure)
app.jwt.secret=${JWT_SECRET}
Expand Down
3 changes: 2 additions & 1 deletion backend/src/main/resources/application-local.properties
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ app.jwt.refresh-cookie-same-site=Lax
# config
app.allowed.cors=http://localhost:4200
app.allowed.methods=GET,POST,PUT,DELETE,OPTIONS
app.public.endpoints=/api/auth/signup,/api/auth/login,/api/auth/refresh,/api/auth/logout,/api/auth/forgot-password,/api/auth/verify-email,/api/auth/resend-verification,/api/auth/reset-password,/oauth2/**,/api/webhooks/inbound-email,/api/webhooks/gmail/push,/api/storage/files/**
app.public.endpoints=/api/auth/signup,/api/auth/login,/api/auth/refresh,/api/auth/logout,/api/auth/forgot-password,/api/auth/verify-email,/api/auth/resend-verification,/api/auth/reset-password,/oauth2/**,/api/webhooks/inbound-email,/api/webhooks/gmail/push,/api/storage/files/**,/api/cron/**
app.cron.secret=local-cron-secret-123

# Hibernate
spring.jpa.hibernate.ddl-auto=update
Expand Down
3 changes: 2 additions & 1 deletion backend/src/main/resources/application-prod.properties
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ spring.security.oauth2.client.registration.google.redirect-uri={baseUrl}/login/o
# config
app.allowed.cors=https://jobtrackerpro.in
app.allowed.methods=GET,POST,PUT,DELETE,OPTIONS
app.public.endpoints=/api/auth/signup,/api/auth/login,/api/auth/refresh,/api/auth/logout,/api/auth/forgot-password,/api/auth/verify-email,/api/auth/resend-verification,/api/auth/reset-password,/oauth2/**,/api/webhooks/inbound-email,/api/webhooks/gmail/push
app.public.endpoints=/api/auth/signup,/api/auth/login,/api/auth/refresh,/api/auth/logout,/api/auth/forgot-password,/api/auth/verify-email,/api/auth/resend-verification,/api/auth/reset-password,/oauth2/**,/api/webhooks/inbound-email,/api/webhooks/gmail/push,/api/cron/**
app.cron.secret=${CRON_SECRET}

spring.threads.virtual.enabled=true

Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
package com.thughari.jobtrackerpro.scheduler;

import com.thughari.jobtrackerpro.entity.User;
import com.thughari.jobtrackerpro.repo.PasswordResetTokenRepository;
import com.thughari.jobtrackerpro.repo.UserRepository;
import com.thughari.jobtrackerpro.repo.VerificationTokenRepository;
import com.thughari.jobtrackerpro.service.GmailIntegrationService;
import com.thughari.jobtrackerpro.service.JobService;
import com.thughari.jobtrackerpro.service.UserDeletionService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;

import java.util.List;

import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
Expand All @@ -20,73 +27,77 @@ class JobSchedulerTest {
@Mock private JobService jobService;
@Mock private UserRepository userRepository;
@Mock private GmailIntegrationService gmailIntegrationService;
@Mock private UserDeletionService userDeletionService;
@Mock private PasswordResetTokenRepository passwordTokenRepo;
@Mock private VerificationTokenRepository verificationTokenRepo;

@InjectMocks
private JobScheduler scheduler;

@Test
void runStaleJobCleanup_InvokesService() {
scheduler.runStaleJobCleanup();
verify(jobService, times(1)).cleanupStaleApplications();
private final String VALID_SECRET = "test-secret";

@BeforeEach
void setUp() {
ReflectionTestUtils.setField(scheduler, "expectedCronSecret", VALID_SECRET);
}

@Test
void runStaleJobCleanup_HandlesServiceException() {
// Verification that an exception in the service doesn't propagate and crash the scheduler thread
doThrow(new RuntimeException("DB Timeout")).when(jobService).cleanupStaleApplications();

scheduler.runStaleJobCleanup();

verify(jobService).cleanupStaleApplications();
void runDailyMaintenance_UnauthorizedWhenSecretIsMissingOrInvalid() {
assertThrows(ResponseStatusException.class, () -> scheduler.runDailyMaintenance(null));
assertThrows(ResponseStatusException.class, () -> scheduler.runDailyMaintenance("wrong-secret"));
}

@Test
void renewGmailWatches_ProcessesAllConnectedUsers() {
// Setup: Mocking connected users
void runDailyMaintenance_ExecutesAllJobsSequentially() {
// Setup users for Gmail watch renewal
User user1 = new User();
user1.setEmail("user1@test.com");
User user2 = new User();
user2.setEmail("user2@test.com");

when(userRepository.findByGmailConnectedTrue()).thenReturn(List.of(user1, user2));
when(userRepository.findByGmailConnectedTrue()).thenReturn(List.of(user1));

// Act
scheduler.renewGmailWatches();
scheduler.runDailyMaintenance(VALID_SECRET);

// Assert: High Performance check
// Verify that the integration service was called for every user returned by the repo
// Assert Step 1: Stale Job Cleanup
verify(jobService, times(1)).cleanupStaleApplications();

// Assert Step 2: Gmail Sync
verify(gmailIntegrationService, times(1)).renewWatch(user1);
verify(gmailIntegrationService, times(1)).renewWatch(user2);

// Assert Step 3: System Cleanup
verify(passwordTokenRepo, times(1)).deleteAllExpired(any());
verify(verificationTokenRepo, times(1)).deleteAllExpired(any());
verify(userRepository, times(1)).deleteUnverifiedUsers(any());

// Assert Step 4: Scheduled Deletions
verify(userRepository, times(1)).findAllByPendingDeletionTrueAndDeletionRequestedAtBefore(any());
}

@Test
void renewGmailWatches_HandlesPartialFailures() {
// Setup: One user succeeds, one fails
void runDailyMaintenance_HandlesPartialFailuresAcrossJobs() {
// Verification that an exception in one step doesn't crash the entire maintenance run

// Step 1 throws error
doThrow(new RuntimeException("DB Timeout")).when(jobService).cleanupStaleApplications();

// Step 2 mock setup
User user1 = new User();
user1.setEmail("fail@test.com");
User user2 = new User();
user2.setEmail("success@test.com");

when(userRepository.findByGmailConnectedTrue()).thenReturn(List.of(user1, user2));

// Mocking an error for the first user
doThrow(new RuntimeException("Token Revoked")).when(gmailIntegrationService).renewWatch(user1);

// Act
scheduler.renewGmailWatches();
scheduler.runDailyMaintenance(VALID_SECRET);

// Assert: Robustness check
// Even though user1 failed, user2 MUST still be processed (Fault Tolerance)
// Assert: Step 1 executed and failed
verify(jobService).cleanupStaleApplications();

// Assert: Step 2 still executed, and even though user1 failed, user2 MUST still be processed
verify(gmailIntegrationService).renewWatch(user1);
verify(gmailIntegrationService).renewWatch(user2);
}

@Test
void renewGmailWatches_SkipsIfNoUsersConnected() {
when(userRepository.findByGmailConnectedTrue()).thenReturn(List.of());

scheduler.renewGmailWatches();

verify(gmailIntegrationService, never()).renewWatch(any());

// Assert: Subsequent steps still run
verify(passwordTokenRepo, times(1)).deleteAllExpired(any());
}
}
3 changes: 2 additions & 1 deletion backend/src/test/resources/application-test.properties
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ app.storage.type=local

app.allowed.cors=http://localhost:4200
app.allowed.methods=GET,POST,PUT,DELETE,OPTIONS
app.public.endpoints=/api/auth/signup,/api/auth/login,/api/auth/refresh,/api/auth/logout,/api/auth/forgot-password,/api/auth/reset-password,/oauth2/**,/api/webhooks/inbound-email,/api/storage/files/**
app.public.endpoints=/api/auth/signup,/api/auth/login,/api/auth/refresh,/api/auth/logout,/api/auth/forgot-password,/api/auth/reset-password,/oauth2/**,/api/webhooks/inbound-email,/api/storage/files/**,/api/cron/**
app.cron.secret=test-cron-secret-123

app.jwt.secret=012345678901234567890123456789012345678901234567890123456789
app.jwt.expiration-ms=900000
Expand Down
Loading