diff --git a/fr-batch-service/docs/job-plugins/developer-guide.md b/fr-batch-service/docs/job-plugins/developer-guide.md
index 32e1592f..5b3f2147 100644
--- a/fr-batch-service/docs/job-plugins/developer-guide.md
+++ b/fr-batch-service/docs/job-plugins/developer-guide.md
@@ -182,6 +182,7 @@ Roles below).
| `GET` | `/jobs/definitions/{id}` | `PLUGIN_VIEWER`+ | Get a single definition |
| `PUT` | `/jobs/definitions/{id}/enable` | `PLUGIN_ADMIN` | Enable a definition |
| `PUT` | `/jobs/definitions/{id}/disable` | `PLUGIN_ADMIN` | Disable a definition |
+| `PUT` | `/jobs/definitions/{id}/jar` | `PLUGIN_ADMIN` | Replace the JAR in place (see [Replace JAR](#replace-jar)) |
| `DELETE` | `/jobs/definitions/{id}[?force=true]` | `PLUGIN_ADMIN` | Unload, delete definition, then delete JAR file (see [Delete](#delete)) |
| `GET` | `/jobs/plugins` | Public | List merged plugin metadata |
@@ -297,6 +298,51 @@ curl -u admin:admin123 -X DELETE \
"http://localhost:8080/api/batch-service/jobs/definitions/1?force=true"
```
+### Replace JAR
+
+`PUT /jobs/definitions/{id}/jar` replaces the JAR of an existing definition in place, for example to
+roll back to a previous working version. The definition keeps its ID, so its execution history and
+parameter templates stay linked. It is a multipart request with the same fields as an upload except
+`jobName`, which cannot change: `file`, `version`, and `mainClassName`.
+
+The operation runs under the same per-definition lock as load, unload, reload, and delete:
+
+1. The new JAR is validated first, like an upload (`.jar` extension, ZIP magic bytes,
+ fully-qualified main class name, signature policy), and must contain the main class entry. No
+ plugin code runs at this point. It is stored under a unique file name
+ (`{jobName}-{version}-{random}.jar`), so the current JAR is never overwritten. An invalid JAR
+ changes nothing: the plugin stays loaded if it was loaded.
+1. The plugin is then unloaded, without force. Running executions reject the replacement; the new
+ JAR is discarded and nothing else changes.
+1. One database transaction updates the JAR path, checksum, version, and main class, and resets the
+ approval to `PENDING`. The definition stays `UNLOADED`; `enabled` is unchanged. In non-production
+ profiles the auto-approval is applied again after commit, as on upload.
+1. The previous JAR is deleted after that transaction commits.
+
+After a successful replacement, approve the definition (unless it was auto-approved) and load it
+again. Loading still checks that the plugin's job name matches the definition.
+
+| Outcome | Response |
+| --------------------- | ------------------------------------------------------------------------ |
+| Replaced | `200 OK` with the updated definition |
+| Unknown ID | `404 Not Found` |
+| Running executions | `409 Conflict`; new JAR discarded, nothing is unloaded or changed |
+| Invalid JAR | `400 Bad Request`; the definition and the plugin are unchanged |
+| Database update fails | Error response; rolled back, new JAR deleted, current JAR kept, unloaded |
+
+A missing previous JAR does not fail the request. If the previous JAR cannot be removed after
+commit, the request still returns `200`, a `WARN` is logged, and the `JAR_REPLACED` success audit
+names the orphan JAR path. `JAR_REPLACED` is audited `SUCCESS` only after the whole operation;
+otherwise it is audited `FAILURE` with the definition ID, the reason, and what was kept.
+
+```bash
+curl -u admin:admin123 -X PUT \
+ -F "file=@target/example-payment-job-1.0.0.jar" \
+ -F "version=1.0.0" \
+ -F "mainClassName=com.example.payment.PaymentJobPlugin" \
+ http://localhost:8080/api/batch-service/jobs/definitions/1/jar
+```
+
### Job Execution
| Method | Path | Auth | Description |
diff --git a/fr-batch-service/docs/production-checklist.md b/fr-batch-service/docs/production-checklist.md
index ac9c8036..dc5ab33d 100644
--- a/fr-batch-service/docs/production-checklist.md
+++ b/fr-batch-service/docs/production-checklist.md
@@ -188,10 +188,14 @@ ______________________________________________________________________
definition in one operation (running executions are not stopped; the JAR is deleted after the
database commit, and an orphan JAR is logged and named in the `JOB_DELETED` audit)
1. If DELETE returns `409` because the definition has execution history (`force=true` does not
- bypass this), disable it with `PUT /jobs/definitions/{id}/disable` and unload it with
- `POST /jobs/definitions/{id}/unload?force=true` instead. Note that a replacement upload with
- the same job name is rejected as a duplicate while that definition exists.
- 1. Upload the previous working version
+ bypass this), keep the definition and replace its JAR in place instead:
+ `PUT /jobs/definitions/{id}/jar` with the previous working JAR (`file`, `version`,
+ `mainClassName`). It returns `409` while executions are running; wait for them to finish. The
+ definition keeps its ID and execution history, stays unloaded, and its approval is reset to
+ `PENDING`.
+ 1. Approve it with `PUT /jobs/definitions/{id}/approve` and load it with
+ `POST /jobs/definitions/{id}/load`. After a successful DELETE, upload the previous working
+ version with `POST /jobs/upload` instead.
- [ ] **Rollback tested**: A rollback drill has been completed within the RTO window.
______________________________________________________________________
diff --git a/fr-batch-service/docs/runbook.md b/fr-batch-service/docs/runbook.md
index 350d6ad0..dc867a1c 100644
--- a/fr-batch-service/docs/runbook.md
+++ b/fr-batch-service/docs/runbook.md
@@ -256,7 +256,9 @@ docker compose exec mysql mysql -u root -p"${DB_PASSWORD}" -e \
**Resolution**:
-1. Corrupted JAR → re-upload: `POST /jobs/upload`
+1. Corrupted JAR → replace it in place: `PUT /jobs/definitions/{id}/jar` (multipart `file`,
+ `version`, `mainClassName`), then approve (`PUT /jobs/definitions/{id}/approve`) and load
+ (`POST /jobs/definitions/{id}/load`). The definition keeps its ID and execution history.
1. Missing dependency in JAR → check `MANIFEST.MF` classpath
1. Signature mismatch → re-sign JAR or temporarily set `app.plugins.signature.mode=permissive`
during investigation
diff --git a/fr-batch-service/src/main/java/com/fronzec/frbatchservice/batchjobs/plugins/audit/AuditEventType.java b/fr-batch-service/src/main/java/com/fronzec/frbatchservice/batchjobs/plugins/audit/AuditEventType.java
index 212350cc..bbd930f8 100644
--- a/fr-batch-service/src/main/java/com/fronzec/frbatchservice/batchjobs/plugins/audit/AuditEventType.java
+++ b/fr-batch-service/src/main/java/com/fronzec/frbatchservice/batchjobs/plugins/audit/AuditEventType.java
@@ -5,10 +5,11 @@
* Lifecycle event types tracked by the audit trail.
*
*
Every event maps to a specific operation in the plugin lifecycle:
- * upload → approve/reject → load → (run) → unload → delete.
+ * upload → approve/reject → load → (run) → unload → replace JAR → delete.
*/
public enum AuditEventType {
JAR_UPLOADED,
+ JAR_REPLACED,
JOB_LOADED,
JOB_UNLOADED,
JOB_DELETED,
diff --git a/fr-batch-service/src/main/java/com/fronzec/frbatchservice/batchjobs/plugins/loader/DynamicJobLoaderService.java b/fr-batch-service/src/main/java/com/fronzec/frbatchservice/batchjobs/plugins/loader/DynamicJobLoaderService.java
index e0af4129..9be07b45 100644
--- a/fr-batch-service/src/main/java/com/fronzec/frbatchservice/batchjobs/plugins/loader/DynamicJobLoaderService.java
+++ b/fr-batch-service/src/main/java/com/fronzec/frbatchservice/batchjobs/plugins/loader/DynamicJobLoaderService.java
@@ -11,6 +11,9 @@
import com.fronzec.frbatchservice.batchjobs.plugins.repository.JobDefinitionRepository;
import com.fronzec.frbatchservice.batchjobs.plugins.repository.JobExecutionAuditRepository;
import com.fronzec.frbatchservice.batchjobs.plugins.repository.JobParameterTemplateRepository;
+import com.fronzec.frbatchservice.batchjobs.plugins.service.InvalidJarException;
+import com.fronzec.frbatchservice.batchjobs.plugins.service.JarUploadService;
+import com.fronzec.frbatchservice.batchjobs.plugins.service.JarUploadService.StoredJar;
import com.fronzec.frbatchservice.batchjobs.plugins.util.ChecksumUtil;
import java.io.File;
import java.io.IOException;
@@ -35,10 +38,11 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
+import org.springframework.web.multipart.MultipartFile;
/**
* Orchestrates the full lifecycle of dynamically-loaded batch job plugins: load from JAR,
- * instantiate, configure, register, unload, and delete.
+ * instantiate, configure, register, unload, replace the JAR, and delete.
*
*
Owns the {@link DynamicJobClassLoader} lifecycle via a {@link ConcurrentHashMap} keyed by
* definition ID. Delegates registry operations to {@link PluginRegistryService}.
@@ -57,9 +61,13 @@ public class DynamicJobLoaderService {
private final PluginMetrics pluginMetrics;
private final JobParameterTemplateRepository jobParameterTemplateRepository;
private final JobExecutionAuditRepository jobExecutionAuditRepository;
+ private final JarUploadService jarUploadService;
- /** Runs the database part of {@link #deleteJob} (templates + definition) as one transaction. */
- private final TransactionTemplate deleteTransaction;
+ /**
+ * Runs the database part of {@link #deleteJob} (templates + definition) and of {@link
+ * #replaceJar} (definition update), each as one transaction.
+ */
+ private final TransactionTemplate lifecycleTransaction;
/** Tracks active classloaders by definition ID for cleanup on unload. */
private final Map classLoaders = new ConcurrentHashMap<>();
@@ -82,7 +90,8 @@ public DynamicJobLoaderService(
AuditService auditService,
PluginMetrics pluginMetrics,
JobParameterTemplateRepository jobParameterTemplateRepository,
- JobExecutionAuditRepository jobExecutionAuditRepository) {
+ JobExecutionAuditRepository jobExecutionAuditRepository,
+ JarUploadService jarUploadService) {
this.jobDefinitionRepository = jobDefinitionRepository;
this.pluginRegistryService = pluginRegistryService;
this.applicationContext = applicationContext;
@@ -92,7 +101,8 @@ public DynamicJobLoaderService(
this.pluginMetrics = pluginMetrics;
this.jobParameterTemplateRepository = jobParameterTemplateRepository;
this.jobExecutionAuditRepository = jobExecutionAuditRepository;
- this.deleteTransaction = new TransactionTemplate(transactionManager);
+ this.jarUploadService = jarUploadService;
+ this.lifecycleTransaction = new TransactionTemplate(transactionManager);
}
/**
@@ -517,7 +527,7 @@ private void doDeleteJob(Long definitionId, boolean force) {
unloaded = true;
// doUnloadJob persisted the entity as UNLOADED, so delete by id inside the transaction
// instead of deleting the now-stale instance read above.
- deleteTransaction.executeWithoutResult(
+ lifecycleTransaction.executeWithoutResult(
status -> {
jobParameterTemplateRepository.deleteByJobDefinitionId(definitionId);
jobDefinitionRepository.deleteById(definitionId);
@@ -566,8 +576,9 @@ private void doDeleteJob(Long definitionId, boolean force) {
}
/**
- * Removes the JAR file after the delete transaction committed. File-system problems never fail
- * the delete: the definition is already gone, so the JAR is reported as an orphan instead.
+ * Removes a JAR file that no definition references any more, after the delete or replace
+ * transaction committed. File-system problems never fail the operation: the database change is
+ * already committed, so the JAR is reported as an orphan instead.
*
* @return a human-readable JAR outcome for the success audit detail
*/
@@ -585,8 +596,8 @@ private String deleteJarAfterCommit(Long definitionId, String jarFilePath) {
return "JAR already absent: " + jarFilePath;
} catch (IOException | InvalidPathException | SecurityException e) {
log.warn(
- "Job definition id={} deleted but its JAR could not be removed; orphan JAR left on"
- + " disk: {} ({})",
+ "Job definition id={} committed but its unreferenced JAR could not be removed; orphan"
+ + " JAR left on disk: {} ({})",
definitionId,
jarFilePath,
e.toString());
@@ -594,6 +605,223 @@ private String deleteJarAfterCommit(Long definitionId, String jarFilePath) {
}
}
+ /**
+ * Replaces the JAR of an existing job definition in place, keeping its ID so execution history
+ * and parameter templates stay linked.
+ *
+ * The whole operation runs under the per-definition lifecycle lock. It first validates and
+ * stores the new JAR through {@link JarUploadService#storeReplacementJar} under a unique file
+ * name, so the current JAR is never overwritten and a rejected JAR never touches the plugin.
+ * It then unloads the plugin without force, so running executions reject the replacement with
+ * {@link JobUnloadConflictException}; the new JAR is discarded and nothing else changes. One
+ * database transaction updates the JAR path, checksum, version, and main class, resets the
+ * approval to {@code PENDING} (clearing the approver), and keeps the definition {@code
+ * UNLOADED}; {@code enabled} is unchanged. After commit, the previous JAR is deleted and then
+ * the non-production auto-approval is applied as on upload.
+ *
+ *
A missing previous JAR is not an error. If it cannot be removed after commit, the
+ * replacement still succeeds, a warning is logged, and the {@code JAR_REPLACED} success audit
+ * names the orphan JAR path. Any failure before or at commit deletes the newly stored JAR (best
+ * effort), emits a {@code JAR_REPLACED} failure audit, and propagates the original exception.
+ * A failure before the unload (an invalid JAR or running executions) changes nothing: the
+ * definition, its current JAR, and the plugin stay as they were, loaded if they were loaded.
+ * Only a failure after the unload (the database update) leaves the plugin unloaded; the
+ * definition still keeps its current JAR and metadata.
+ *
+ * @param definitionId the database ID of the job definition whose JAR is replaced
+ * @param file the replacement JAR
+ * @param version the version of the replacement
+ * @param mainClassName fully-qualified class name of the {@code BatchJobPlugin} implementation
+ * @return the updated definition
+ * @throws NoSuchElementException if the definition does not exist
+ * @throws JobUnloadConflictException if the job has running executions; the new JAR is
+ * discarded and nothing else is changed
+ * @throws InvalidJarException if the replacement JAR fails validation; nothing is changed and
+ * the plugin stays as it was
+ * @throws org.springframework.dao.DataAccessException if the database update fails; the
+ * transaction is rolled back, the new JAR is deleted, the previous JAR is kept, and the
+ * plugin stays unloaded
+ */
+ public JobDefinitionEntity replaceJar(
+ Long definitionId, MultipartFile file, String version, String mainClassName) {
+ if (jobDefinitionRepository.findById(definitionId).isEmpty()) {
+ throw new NoSuchElementException("Job definition not found for id: " + definitionId);
+ }
+ return withDefinitionLock(
+ definitionId, () -> doReplaceJar(definitionId, file, version, mainClassName));
+ }
+
+ /** Performs the actual replacement while the caller owns the per-definition lifecycle lock. */
+ private JobDefinitionEntity doReplaceJar(
+ Long definitionId, MultipartFile file, String version, String mainClassName) {
+ JobDefinitionEntity entity =
+ jobDefinitionRepository
+ .findById(definitionId)
+ .orElseThrow(
+ () ->
+ new NoSuchElementException(
+ "Job definition not found for id: " + definitionId));
+ String jobName = entity.getJobName();
+ String previousJarPath = entity.getJarFilePath();
+ String previousVersion = entity.getVersion();
+
+ boolean unloaded = false;
+ StoredJar storedJar = null;
+ JobDefinitionEntity updated;
+ try {
+ // Validate and store first so a rejected JAR never unloads a working plugin.
+ storedJar = jarUploadService.storeReplacementJar(file, jobName, version, mainClassName);
+ doUnloadJob(definitionId, false);
+ unloaded = true;
+ StoredJar newJar = storedJar;
+ updated =
+ lifecycleTransaction.execute(
+ status -> applyReplacement(definitionId, newJar, version, mainClassName));
+ } catch (RuntimeException e) {
+ String outcome;
+ String newJarOutcome =
+ storedJar == null ? "no new JAR was stored" : discardStoredJar(storedJar.path(), e);
+ if (!unloaded) {
+ outcome = "Nothing was changed; " + newJarOutcome + ".";
+ } else {
+ outcome =
+ "The plugin was unloaded and stays unloaded; the definition and its current JAR "
+ + previousJarPath
+ + " were kept; "
+ + newJarOutcome
+ + ".";
+ }
+ log.error(
+ "Failed to replace JAR of job definition id={} jobName='{}' version={}: {}",
+ definitionId,
+ jobName,
+ version,
+ e.getMessage(),
+ e);
+ auditService.logEvent(
+ new AuditEvent(
+ AuditEventType.JAR_REPLACED,
+ jobName,
+ AuditService.currentUserId(),
+ "Replace JAR failed for job definition id="
+ + definitionId
+ + " (version "
+ + version
+ + "): "
+ + e.getMessage()
+ + ". "
+ + outcome,
+ AuditEvent.FAILURE,
+ LocalDateTime.now()));
+ throw e;
+ }
+
+ String previousJarOutcome =
+ storedJar.path().toString().equals(previousJarPath)
+ ? "previous JAR path unchanged"
+ : deleteJarAfterCommit(definitionId, previousJarPath);
+ String approvalOutcome = autoApproveAfterCommit(updated);
+ log.info(
+ "JAR replaced: id={}, jobName='{}', version {} -> {}, jar={}, {}; previous {}",
+ definitionId,
+ jobName,
+ previousVersion,
+ version,
+ updated.getJarFilePath(),
+ approvalOutcome,
+ previousJarOutcome);
+ auditService.logEvent(
+ new AuditEvent(
+ AuditEventType.JAR_REPLACED,
+ jobName,
+ AuditService.currentUserId(),
+ "JAR replaced for job definition id="
+ + definitionId
+ + ": version "
+ + previousVersion
+ + " -> "
+ + version
+ + ", jar "
+ + updated.getJarFilePath()
+ + " (checksum "
+ + updated.getJarChecksum()
+ + "), "
+ + approvalOutcome
+ + "; previous "
+ + previousJarOutcome,
+ AuditEvent.SUCCESS,
+ LocalDateTime.now()));
+ return updated;
+ }
+
+ /**
+ * Points the definition at the replacement JAR and resets its approval. Runs inside the replace
+ * transaction; re-reads the definition because {@link #doUnloadJob} already saved it.
+ */
+ private JobDefinitionEntity applyReplacement(
+ Long definitionId, StoredJar newJar, String version, String mainClassName) {
+ JobDefinitionEntity current =
+ jobDefinitionRepository
+ .findById(definitionId)
+ .orElseThrow(
+ () ->
+ new NoSuchElementException(
+ "Job definition not found for id: " + definitionId));
+ current.setJarFilePath(newJar.path().toString());
+ current.setJarChecksum(newJar.checksum());
+ current.setVersion(version);
+ current.setMainClassName(mainClassName);
+ current.setApprovalStatus("PENDING");
+ current.setApprovedBy(null);
+ current.setApprovedAt(null);
+ current.setLoadStatus(LoadResult.UNLOADED);
+ current.setLoadError(null);
+ return jobDefinitionRepository.save(current);
+ }
+
+ /**
+ * Applies the non-production auto-approval after the replace transaction committed. A failure
+ * does not fail the replacement: the definition keeps its committed {@code PENDING} approval.
+ *
+ * @return a human-readable approval outcome for the success audit detail
+ */
+ private String autoApproveAfterCommit(JobDefinitionEntity updated) {
+ try {
+ return jarUploadService.autoApproveIfEnabled(updated)
+ ? "auto-approved (non-production)"
+ : "approval reset to PENDING";
+ } catch (RuntimeException e) {
+ // The in-memory entity may already carry the approval; mirror the committed state.
+ updated.setApprovalStatus("PENDING");
+ updated.setApprovedBy(null);
+ updated.setApprovedAt(null);
+ log.warn(
+ "Job definition id={} JAR replaced but auto-approval failed; approval stays PENDING: {}",
+ updated.getId(),
+ e.toString());
+ return "auto-approval failed, approval stays PENDING (" + e + ")";
+ }
+ }
+
+ /**
+ * Deletes a replacement JAR that was stored but never committed as the definition's JAR.
+ *
+ * @return a human-readable outcome for the failure audit detail
+ */
+ private String discardStoredJar(Path storedJar, RuntimeException failure) {
+ try {
+ Files.deleteIfExists(storedJar);
+ return "the new JAR " + storedJar + " was deleted";
+ } catch (IOException | SecurityException e) {
+ failure.addSuppressed(e);
+ log.warn(
+ "Could not delete uncommitted replacement JAR; orphan JAR left on disk: {} ({})",
+ storedJar,
+ e.toString());
+ return "the new JAR could not be deleted; orphan JAR left on disk: " + storedJar;
+ }
+ }
+
/**
* Reloads a job by unloading (forced) then loading sequentially. The operation is sequential, so
* the status never appears partially ready.
diff --git a/fr-batch-service/src/main/java/com/fronzec/frbatchservice/batchjobs/plugins/service/JarUploadService.java b/fr-batch-service/src/main/java/com/fronzec/frbatchservice/batchjobs/plugins/service/JarUploadService.java
index 6a6f8d57..ea3676b5 100644
--- a/fr-batch-service/src/main/java/com/fronzec/frbatchservice/batchjobs/plugins/service/JarUploadService.java
+++ b/fr-batch-service/src/main/java/com/fronzec/frbatchservice/batchjobs/plugins/service/JarUploadService.java
@@ -12,6 +12,8 @@
import com.fronzec.frbatchservice.web.dto.JarUploadResponse;
import java.io.IOException;
import java.io.InputStream;
+import java.nio.file.CopyOption;
+import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.nio.file.Path;
@@ -20,6 +22,8 @@
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
+import java.util.UUID;
+import java.util.jar.JarFile;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -35,6 +39,10 @@
*
*
Uploaded JARs default to {@code enabled=false} and {@code loadStatus=UNLOADED}.
* Dynamic registration from the stored JAR is handled in Phase 4.
+ *
+ *
Also validates and stores replacement JARs for existing definitions (see
+ * {@link #storeReplacementJar}); the replacement itself is coordinated by
+ * {@code DynamicJobLoaderService#replaceJar}.
*/
@Service
public class JarUploadService {
@@ -63,6 +71,14 @@ public class JarUploadService {
private final boolean signatureStrict;
private final String jarDir;
+ /**
+ * A validated replacement JAR stored on disk but not yet referenced by any definition.
+ *
+ * @param path the absolute path of the stored file
+ * @param checksum the SHA-256 checksum computed from the stored file
+ */
+ public record StoredJar(Path path, String checksum) {}
+
public JarUploadService(
JobDefinitionRepository jobDefinitionRepository,
Optional autoApproveConfig,
@@ -108,32 +124,13 @@ public JarUploadResponse uploadJar(
// stored digest matches the bytes that load-time integrity verification re-checks.
String checksum = ChecksumUtil.computeSha256(storedPath);
- // ── signature verification ───────────────────────────────────────────────
- SignatureResult sigResult = jarSignatureVerifier.verify(storedPath);
- if (!sigResult.valid()) {
- List warnings = sigResult.warnings();
- if (signatureStrict) {
- throw new InvalidJarException(
- "JAR signature verification failed in strict mode: " + String.join("; ", warnings));
- }
- // Permissive: log and allow upload
- log.warn(
- "JAR signature verification issues (permissive mode — upload allowed): {}",
- warnings);
- }
+ verifySignature(storedPath);
JobDefinitionEntity entity = persistMetadata(jobName, version, mainClassName, checksum, storedPath);
pluginMetrics.incrementUpload();
- // Auto-approve in non-production profiles (no-op when AutoApproveConfig is absent).
- // approve() only mutates the entity in memory, and persistMetadata's save has
- // already committed it as PENDING, so re-save to persist the approval.
- autoApproveConfig.ifPresent(
- ac -> {
- ac.approve(entity);
- jobDefinitionRepository.save(entity);
- });
+ autoApproveIfEnabled(entity);
log.info(
"JAR uploaded: job={}, version={}, id={}, checksum={}",
@@ -164,6 +161,70 @@ public JarUploadResponse uploadJar(
}
}
+ /**
+ * Validates a replacement JAR for an existing definition and stores it under a unique
+ * file name, {@code {jarDir}/{jobName}-{version}-{random}.jar}.
+ *
+ * Runs the same checks as {@link #uploadJar}: {@code .jar} extension, ZIP magic bytes,
+ * fully-qualified main class name, a checksum computed from the stored bytes, and the
+ * configured signature policy. In addition, the JAR must contain the main class entry.
+ * No plugin code is executed. The file is created without replacing any existing file,
+ * so the definition's current JAR is never overwritten. If a check fails after the file
+ * was stored, the file is deleted before the exception propagates.
+ *
+ *
This method does not touch the database or the audit trail; the caller owns both.
+ *
+ * @param file the uploaded replacement JAR
+ * @param jobName the job name of the definition being replaced
+ * @param version the version of the replacement
+ * @param mainClassName fully-qualified class name of the {@code BatchJobPlugin} impl
+ * @return the stored file and its checksum
+ * @throws InvalidJarException if the file fails validation
+ */
+ public StoredJar storeReplacementJar(
+ MultipartFile file, String jobName, String version, String mainClassName) {
+ validateJar(file);
+ validateMainClassName(mainClassName);
+
+ String uniqueSuffix = "-" + UUID.randomUUID().toString().substring(0, 8);
+ // No REPLACE_EXISTING: a name collision fails instead of overwriting a stored JAR.
+ Path storedPath = storeFile(file, jobName, version, uniqueSuffix);
+ try {
+ requireMainClassEntry(storedPath, mainClassName);
+ String checksum = ChecksumUtil.computeSha256(storedPath);
+ verifySignature(storedPath);
+ log.debug("Replacement JAR stored at {} (checksum={})", storedPath, checksum);
+ return new StoredJar(storedPath, checksum);
+ } catch (RuntimeException e) {
+ try {
+ Files.deleteIfExists(storedPath);
+ } catch (IOException deleteEx) {
+ e.addSuppressed(deleteEx);
+ log.warn("Could not delete rejected replacement JAR {}: {}", storedPath, deleteEx.toString());
+ }
+ throw e;
+ }
+ }
+
+ /**
+ * Applies the non-production auto-approval to a persisted definition and saves it again.
+ *
+ *
A no-op when {@link AutoApproveConfig} is absent (production, or auto-approval
+ * disabled). {@code approve()} only mutates the entity in memory, so the entity is
+ * re-saved to persist the approval.
+ *
+ * @param entity the persisted definition to approve
+ * @return {@code true} if the definition was auto-approved
+ */
+ public boolean autoApproveIfEnabled(JobDefinitionEntity entity) {
+ if (autoApproveConfig.isEmpty()) {
+ return false;
+ }
+ autoApproveConfig.get().approve(entity);
+ jobDefinitionRepository.save(entity);
+ return true;
+ }
+
// ─── validation ────────────────────────────────────────────────────────────
/** Rejects files without {@code .jar} extension or without ZIP magic bytes. */
@@ -205,6 +266,44 @@ private void validateMainClassName(String mainClassName) {
}
}
+ /**
+ * Requires the stored JAR to contain the class file of {@code mainClassName}, without
+ * loading or executing it.
+ *
+ * @throws InvalidJarException if the entry is missing or the JAR cannot be read
+ */
+ private void requireMainClassEntry(Path jarPath, String mainClassName) {
+ String entryName = mainClassName.replace('.', '/') + ".class";
+ try (JarFile jar = new JarFile(jarPath.toFile())) {
+ if (jar.getEntry(entryName) == null) {
+ throw new InvalidJarException(
+ "Main class " + mainClassName + " not found in JAR (expected entry " + entryName + ")");
+ }
+ } catch (IOException e) {
+ throw new InvalidJarException("File is not a readable JAR: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Applies the configured signature policy: strict mode rejects an invalid signature,
+ * permissive mode logs the issues and allows the JAR.
+ *
+ * @throws InvalidJarException if verification fails in strict mode
+ */
+ private void verifySignature(Path storedPath) {
+ SignatureResult sigResult = jarSignatureVerifier.verify(storedPath);
+ if (!sigResult.valid()) {
+ List warnings = sigResult.warnings();
+ if (signatureStrict) {
+ throw new InvalidJarException(
+ "JAR signature verification failed in strict mode: " + String.join("; ", warnings));
+ }
+ log.warn(
+ "JAR signature verification issues (permissive mode — JAR allowed): {}",
+ warnings);
+ }
+ }
+
/** Reads up to {@code n} bytes from the beginning of the file. */
private byte[] readHeader(MultipartFile file, int n) {
try (InputStream in = file.getInputStream()) {
@@ -240,8 +339,17 @@ private void checkDuplicate(String jobName) {
// ─── file storage ──────────────────────────────────────────────────────────
- /** Stores the JAR to {@code {jarDir}/{jobName}-{version}.jar}. */
+ /** Stores the JAR to {@code {jarDir}/{jobName}-{version}.jar}, replacing an existing file. */
private Path storeFile(MultipartFile file, String jobName, String version) {
+ return storeFile(file, jobName, version, "", StandardCopyOption.REPLACE_EXISTING);
+ }
+
+ /**
+ * Stores the JAR to {@code {jarDir}/{jobName}-{version}{suffix}.jar} with the given copy
+ * options; without {@code REPLACE_EXISTING} an existing file makes the copy fail.
+ */
+ private Path storeFile(
+ MultipartFile file, String jobName, String version, String suffix, CopyOption... options) {
String safeJobName = sanitizeNameComponent(jobName);
String safeVersion = sanitizeNameComponent(version);
@@ -252,7 +360,7 @@ private Path storeFile(MultipartFile file, String jobName, String version) {
throw new RuntimeException("Failed to create JAR storage directory: " + jarDir, e);
}
- String filename = safeJobName + "-" + safeVersion + ".jar";
+ String filename = safeJobName + "-" + safeVersion + suffix + ".jar";
Path target = dir.resolve(filename);
// Defence-in-depth: verify the resolved path is still under jarDir
@@ -263,8 +371,17 @@ private Path storeFile(MultipartFile file, String jobName, String version) {
}
try {
- Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);
+ Files.copy(file.getInputStream(), target, options);
+ } catch (FileAlreadyExistsException e) {
+ // Nothing was written: the existing file belongs to someone else and must be kept.
+ throw new RuntimeException("Failed to store JAR at " + target, e);
} catch (IOException e) {
+ // A copy that fails mid-stream can leave a partial file; remove it before propagating.
+ try {
+ Files.deleteIfExists(target);
+ } catch (IOException | SecurityException deleteEx) {
+ e.addSuppressed(deleteEx);
+ }
throw new RuntimeException("Failed to store JAR at " + target, e);
}
diff --git a/fr-batch-service/src/main/java/com/fronzec/frbatchservice/web/JobManagementController.java b/fr-batch-service/src/main/java/com/fronzec/frbatchservice/web/JobManagementController.java
index b4ff2702..8c94caf3 100644
--- a/fr-batch-service/src/main/java/com/fronzec/frbatchservice/web/JobManagementController.java
+++ b/fr-batch-service/src/main/java/com/fronzec/frbatchservice/web/JobManagementController.java
@@ -47,6 +47,7 @@
* {@code GET /definitions/{id}} — get single definition
* {@code PUT /definitions/{id}/enable} — enable a definition
* {@code PUT /definitions/{id}/disable} — disable a definition
+ * {@code PUT /definitions/{id}/jar} — replace the JAR in place (same id, re-approval)
* {@code DELETE /definitions/{id}[?force=true]} — unload, delete DB rows, then remove JAR
* {@code POST /definitions/{id}/load} — load plugin from JAR into registry
* {@code POST /definitions/{id}/unload} — unregister plugin and close classloader
@@ -297,6 +298,41 @@ public ResponseEntity> rejectDefinition(
return ResponseEntity.ok(JobDefinitionResponse.fromEntity(saved));
}
+ // ─── JAR replacement ───────────────────────────────────────────────────────
+
+ /**
+ * Replaces the JAR of an existing job definition in place, delegated to
+ * {@link DynamicJobLoaderService#replaceJar}. The definition keeps its ID, so its
+ * execution history and parameter templates stay linked.
+ *
+ * The new JAR is first validated like an upload and stored under a unique file name,
+ * then the plugin is unloaded, and one transaction updates the JAR path, checksum,
+ * version, and main class and resets the approval to {@code PENDING} (non-production
+ * profiles re-apply auto-approval). The definition stays unloaded and must be approved
+ * and loaded again. The previous JAR is deleted after commit.
+ *
+ *
Returns {@code 200 OK} with the updated definition, {@code 404 Not Found} if the
+ * definition does not exist, {@code 409 Conflict} if the job has running executions
+ * (the new JAR is discarded and nothing else is changed), and {@code 400 Bad Request}
+ * if the JAR fails validation (nothing is changed; the plugin stays loaded if it was
+ * loaded).
+ *
+ * @param id the job definition ID
+ * @param file the replacement JAR
+ * @param version the version of the replacement
+ * @param mainClassName fully-qualified class name of the {@code BatchJobPlugin} impl
+ * @return {@code 200 OK} with the updated definition
+ */
+ @PutMapping("/definitions/{id}/jar")
+ public ResponseEntity replaceJar(
+ @PathVariable long id,
+ @RequestParam("file") MultipartFile file,
+ @RequestParam("version") String version,
+ @RequestParam("mainClassName") String mainClassName) {
+ JobDefinitionEntity updated = loaderService.replaceJar(id, file, version, mainClassName);
+ return ResponseEntity.ok(JobDefinitionResponse.fromEntity(updated));
+ }
+
// ─── CRUD: delete ──────────────────────────────────────────────────────────
/**
diff --git a/fr-batch-service/src/test/java/com/fronzec/frbatchservice/batchjobs/plugins/loader/DynamicJobLoaderServiceTest.java b/fr-batch-service/src/test/java/com/fronzec/frbatchservice/batchjobs/plugins/loader/DynamicJobLoaderServiceTest.java
index ef698ea3..f30afef9 100644
--- a/fr-batch-service/src/test/java/com/fronzec/frbatchservice/batchjobs/plugins/loader/DynamicJobLoaderServiceTest.java
+++ b/fr-batch-service/src/test/java/com/fronzec/frbatchservice/batchjobs/plugins/loader/DynamicJobLoaderServiceTest.java
@@ -17,6 +17,8 @@
import com.fronzec.frbatchservice.batchjobs.plugins.repository.JobDefinitionRepository;
import com.fronzec.frbatchservice.batchjobs.plugins.repository.JobExecutionAuditRepository;
import com.fronzec.frbatchservice.batchjobs.plugins.repository.JobParameterTemplateRepository;
+import com.fronzec.frbatchservice.batchjobs.plugins.service.InvalidJarException;
+import com.fronzec.frbatchservice.batchjobs.plugins.service.JarUploadService;
import com.fronzec.frbatchservice.batchjobs.plugins.util.ChecksumUtil;
import java.io.IOException;
import java.nio.file.Files;
@@ -44,6 +46,7 @@
import org.springframework.batch.infrastructure.repeat.RepeatStatus;
import org.springframework.context.ApplicationContext;
import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.mock.web.MockMultipartFile;
import org.springframework.transaction.PlatformTransactionManager;
@ExtendWith(MockitoExtension.class)
@@ -58,6 +61,7 @@ class DynamicJobLoaderServiceTest {
@Mock private PluginMetrics pluginMetrics;
@Mock private JobParameterTemplateRepository jobParameterTemplateRepository;
@Mock private JobExecutionAuditRepository jobExecutionAuditRepository;
+ @Mock private JarUploadService jarUploadService;
@InjectMocks private DynamicJobLoaderService service;
@@ -811,6 +815,364 @@ void deleteJob_holdsDefinitionLockThroughDatabaseDelete() throws Exception {
}
}
+ // ── Replace JAR ───────────────────────────────────────────────────────────
+
+ private static final String NEW_VERSION = "2.0.0";
+ private static final String NEW_MAIN_CLASS = "com.test.ReplacementPlugin";
+
+ private final MockMultipartFile replacementUpload =
+ new MockMultipartFile("file", "replacement.jar", "application/java-archive", new byte[] {1});
+
+ /** Returns the single {@code JAR_REPLACED} audit event emitted by the service. */
+ private AuditEvent singleReplacedAuditEvent() {
+ ArgumentCaptor captor = ArgumentCaptor.forClass(AuditEvent.class);
+ verify(auditService, atLeastOnce()).logEvent(captor.capture());
+ List replaced =
+ captor.getAllValues().stream()
+ .filter(event -> event.type() == AuditEventType.JAR_REPLACED)
+ .toList();
+ assertEquals(1, replaced.size(), "Expected exactly one JAR_REPLACED audit event: " + replaced);
+ return replaced.get(0);
+ }
+
+ /** Creates a stored replacement JAR on disk, as {@link JarUploadService} would. */
+ private JarUploadService.StoredJar storedReplacement(String jobName) throws IOException {
+ Path stored = tempDir.resolve(jobName + "-" + NEW_VERSION + "-0a1b2c3d.jar");
+ Files.write(stored, new byte[] {1});
+ return new JarUploadService.StoredJar(stored, "new-checksum");
+ }
+
+ private void stubSaveReturnsArgument() {
+ when(jobDefinitionRepository.save(any(JobDefinitionEntity.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+ }
+
+ @Test
+ void replaceJar_loadedPlugin_storesThenUnloadsUpdatesDefinitionResetsApprovalThenDeletesOldJar()
+ throws Exception {
+ JobDefinitionEntity entity = createEntity(60L, "replace-job", true, LoadResult.LOADED);
+ entity.setVersion("1.0.0");
+ entity.setApprovedBy("alice");
+ entity.setApprovedAt(java.time.LocalDateTime.now());
+ DynamicJobClassLoader classLoader = mock(DynamicJobClassLoader.class);
+ classLoadersOf(service).put(60L, classLoader);
+ Path oldJar = Path.of(validJarPath);
+ JarUploadService.StoredJar newJar = storedReplacement("replace-job");
+
+ when(jobDefinitionRepository.findById(60L)).thenReturn(Optional.of(entity));
+ when(jobRepository.findRunningJobExecutions("replace-job")).thenReturn(Collections.emptySet());
+ when(jarUploadService.storeReplacementJar(
+ replacementUpload, "replace-job", NEW_VERSION, NEW_MAIN_CLASS))
+ .thenReturn(newJar);
+ stubSaveReturnsArgument();
+ doAnswer(
+ invocation -> {
+ assertTrue(Files.exists(oldJar), "Old JAR must still exist when the update commits");
+ return null;
+ })
+ .when(transactionManager)
+ .commit(any());
+
+ JobDefinitionEntity result =
+ service.replaceJar(60L, replacementUpload, NEW_VERSION, NEW_MAIN_CLASS);
+
+ InOrder order = inOrder(pluginRegistryService, jarUploadService, transactionManager);
+ order.verify(jarUploadService)
+ .storeReplacementJar(replacementUpload, "replace-job", NEW_VERSION, NEW_MAIN_CLASS);
+ order.verify(pluginRegistryService).unregisterDynamicPlugin("replace-job");
+ order.verify(transactionManager).commit(any());
+ order.verify(jarUploadService).autoApproveIfEnabled(result);
+ verify(classLoader).cleanup();
+ assertFalse(classLoadersOf(service).containsKey(60L));
+
+ assertEquals(60L, result.getId());
+ assertEquals("replace-job", result.getJobName());
+ assertEquals(newJar.path().toString(), result.getJarFilePath());
+ assertEquals("new-checksum", result.getJarChecksum());
+ assertEquals(NEW_VERSION, result.getVersion());
+ assertEquals(NEW_MAIN_CLASS, result.getMainClassName());
+ assertEquals("PENDING", result.getApprovalStatus());
+ assertNull(result.getApprovedBy());
+ assertNull(result.getApprovedAt());
+ assertEquals(LoadResult.UNLOADED, result.getLoadStatus());
+ assertTrue(result.getEnabled(), "enabled must be unchanged");
+
+ assertFalse(Files.exists(oldJar), "Old JAR must be deleted after commit");
+ assertTrue(Files.exists(newJar.path()));
+ assertTrue(lifecycleLocksOf(service).isEmpty());
+
+ AuditEvent replaced = singleReplacedAuditEvent();
+ assertEquals(AuditEvent.SUCCESS, replaced.outcome());
+ assertEquals("replace-job", replaced.jobName());
+ assertTrue(replaced.details().contains("id=60"), replaced.details());
+ assertTrue(replaced.details().contains(NEW_VERSION), replaced.details());
+ assertTrue(replaced.details().contains("PENDING"), replaced.details());
+ assertTrue(replaced.details().contains(validJarPath), replaced.details());
+ }
+
+ @Test
+ void replaceJar_autoApproveFails_stillSucceedsWithPendingApproval() throws Exception {
+ JobDefinitionEntity entity = createEntity(61L, "replace-approve-job", false, null);
+ JarUploadService.StoredJar newJar = storedReplacement("replace-approve-job");
+
+ when(jobDefinitionRepository.findById(61L)).thenReturn(Optional.of(entity));
+ when(jobRepository.findRunningJobExecutions("replace-approve-job"))
+ .thenReturn(Collections.emptySet());
+ when(jarUploadService.storeReplacementJar(any(), any(), any(), any())).thenReturn(newJar);
+ stubSaveReturnsArgument();
+ when(jarUploadService.autoApproveIfEnabled(any()))
+ .thenAnswer(
+ invocation -> {
+ JobDefinitionEntity target = invocation.getArgument(0);
+ target.setApprovalStatus("APPROVED");
+ target.setApprovedBy("system");
+ throw new DataIntegrityViolationException("approve save failed");
+ });
+
+ JobDefinitionEntity result =
+ service.replaceJar(61L, replacementUpload, NEW_VERSION, NEW_MAIN_CLASS);
+
+ assertEquals("PENDING", result.getApprovalStatus());
+ assertNull(result.getApprovedBy());
+ assertFalse(result.getEnabled(), "enabled must be unchanged");
+ AuditEvent replaced = singleReplacedAuditEvent();
+ assertEquals(AuditEvent.SUCCESS, replaced.outcome());
+ assertTrue(replaced.details().contains("auto-approval failed"), replaced.details());
+ }
+
+ @Test
+ void replaceJar_runningExecutions_throwsConflictDiscardsNewJarAndChangesNothing()
+ throws Exception {
+ JobDefinitionEntity entity = createEntity(62L, "replace-running-job", true, LoadResult.LOADED);
+ DynamicJobClassLoader classLoader = mock(DynamicJobClassLoader.class);
+ classLoadersOf(service).put(62L, classLoader);
+ JarUploadService.StoredJar newJar = storedReplacement("replace-running-job");
+
+ when(jobDefinitionRepository.findById(62L)).thenReturn(Optional.of(entity));
+ when(jobRepository.findRunningJobExecutions("replace-running-job"))
+ .thenReturn(Set.of(mock(JobExecution.class)));
+ // The new JAR is validated and stored before the unload rejects the running executions.
+ when(jarUploadService.storeReplacementJar(any(), any(), any(), any())).thenReturn(newJar);
+
+ assertThrows(
+ JobUnloadConflictException.class,
+ () -> service.replaceJar(62L, replacementUpload, NEW_VERSION, NEW_MAIN_CLASS));
+
+ verifyNoInteractions(transactionManager);
+ verify(jarUploadService, never()).autoApproveIfEnabled(any());
+ verify(pluginRegistryService, never()).unregisterDynamicPlugin(any());
+ verify(classLoader, never()).cleanup();
+ assertSame(classLoader, classLoadersOf(service).get(62L));
+ verify(jobDefinitionRepository, never()).save(any());
+ assertEquals(validJarPath, entity.getJarFilePath());
+ assertEquals(LoadResult.LOADED, entity.getLoadStatus(), "the plugin must stay loaded");
+ assertTrue(Files.exists(Path.of(validJarPath)), "Current JAR must be kept on conflict");
+ assertFalse(Files.exists(newJar.path()), "The stored new JAR must be discarded on conflict");
+ try (var files = Files.list(tempDir)) {
+ assertEquals(1, files.count(), "No new JAR may be left behind");
+ }
+ assertTrue(lifecycleLocksOf(service).isEmpty());
+
+ AuditEvent replaced = singleReplacedAuditEvent();
+ assertEquals(AuditEvent.FAILURE, replaced.outcome());
+ assertTrue(replaced.details().contains("id=62"), replaced.details());
+ assertTrue(replaced.details().contains("running execution"), replaced.details());
+ assertTrue(replaced.details().contains("Nothing was changed"), replaced.details());
+ assertTrue(replaced.details().contains(newJar.path() + " was deleted"), replaced.details());
+ }
+
+ @Test
+ void replaceJar_invalidJar_throwsAndKeepsLoadedPluginDefinitionAndCurrentJar() throws Exception {
+ JobDefinitionEntity entity = createEntity(63L, "replace-invalid-job", true, LoadResult.LOADED);
+ entity.setVersion("1.0.0");
+ DynamicJobClassLoader classLoader = mock(DynamicJobClassLoader.class);
+ classLoadersOf(service).put(63L, classLoader);
+ InvalidJarException invalid = new InvalidJarException("Main class not found in JAR");
+
+ when(jobDefinitionRepository.findById(63L)).thenReturn(Optional.of(entity));
+ when(jarUploadService.storeReplacementJar(any(), any(), any(), any())).thenThrow(invalid);
+
+ InvalidJarException thrown =
+ assertThrows(
+ InvalidJarException.class,
+ () -> service.replaceJar(63L, replacementUpload, NEW_VERSION, NEW_MAIN_CLASS));
+
+ assertSame(invalid, thrown);
+ verifyNoInteractions(transactionManager);
+ verify(jarUploadService, never()).autoApproveIfEnabled(any());
+ verify(pluginRegistryService, never()).unregisterDynamicPlugin(any());
+ verify(classLoader, never()).cleanup();
+ assertSame(classLoader, classLoadersOf(service).get(63L), "the plugin must stay loaded");
+ verify(jobDefinitionRepository, never()).save(any());
+ assertEquals(validJarPath, entity.getJarFilePath());
+ assertEquals(validJarChecksum, entity.getJarChecksum());
+ assertEquals("1.0.0", entity.getVersion());
+ assertEquals("APPROVED", entity.getApprovalStatus());
+ assertEquals(LoadResult.LOADED, entity.getLoadStatus(), "the load status is unchanged");
+ assertTrue(Files.exists(Path.of(validJarPath)), "Current JAR must be kept");
+ try (var files = Files.list(tempDir)) {
+ assertEquals(1, files.count(), "No new JAR may be left behind");
+ }
+ assertTrue(lifecycleLocksOf(service).isEmpty());
+
+ AuditEvent replaced = singleReplacedAuditEvent();
+ assertEquals(AuditEvent.FAILURE, replaced.outcome());
+ assertTrue(replaced.details().contains("id=63"), replaced.details());
+ assertTrue(replaced.details().contains("Main class not found"), replaced.details());
+ assertTrue(replaced.details().contains("Nothing was changed"), replaced.details());
+ assertTrue(replaced.details().contains("no new JAR was stored"), replaced.details());
+ assertFalse(replaced.details().contains("stays unloaded"), replaced.details());
+ }
+
+ @Test
+ void replaceJar_databaseUpdateFails_deletesNewJarKeepsOldJarAndAuditsFailure() throws Exception {
+ JobDefinitionEntity entity = createEntity(64L, "replace-db-failure-job", true, null);
+ JarUploadService.StoredJar newJar = storedReplacement("replace-db-failure-job");
+ DataIntegrityViolationException failure = new DataIntegrityViolationException("db down");
+
+ when(jobDefinitionRepository.findById(64L)).thenReturn(Optional.of(entity));
+ when(jobRepository.findRunningJobExecutions("replace-db-failure-job"))
+ .thenReturn(Collections.emptySet());
+ when(jarUploadService.storeReplacementJar(any(), any(), any(), any())).thenReturn(newJar);
+ // First save persists the unload; the second one is the replacement update.
+ when(jobDefinitionRepository.save(any(JobDefinitionEntity.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0))
+ .thenThrow(failure);
+
+ DataIntegrityViolationException thrown =
+ assertThrows(
+ DataIntegrityViolationException.class,
+ () -> service.replaceJar(64L, replacementUpload, NEW_VERSION, NEW_MAIN_CLASS));
+
+ assertSame(failure, thrown);
+ verify(transactionManager).rollback(any());
+ verify(transactionManager, never()).commit(any());
+ verify(jarUploadService, never()).autoApproveIfEnabled(any());
+ assertFalse(Files.exists(newJar.path()), "New JAR must be deleted when the update fails");
+ assertTrue(Files.exists(Path.of(validJarPath)), "Current JAR must be kept");
+
+ AuditEvent replaced = singleReplacedAuditEvent();
+ assertEquals(AuditEvent.FAILURE, replaced.outcome());
+ assertEquals("replace-db-failure-job", replaced.jobName());
+ assertTrue(replaced.details().contains("id=64"), replaced.details());
+ assertTrue(replaced.details().contains("db down"), replaced.details());
+ assertTrue(replaced.details().contains(newJar.path().toString()), replaced.details());
+ }
+
+ @Test
+ void replaceJar_oldJarDeleteFails_stillSucceedsAndNamesOrphanJarInAudit() throws Exception {
+ // A non-empty directory cannot be removed by Files.deleteIfExists, which simulates an
+ // I/O failure on the previous JAR path after the update committed.
+ Path undeletable = tempDir.resolve("undeletable-old.jar");
+ Files.createDirectory(undeletable);
+ Files.createFile(undeletable.resolve("blocker"));
+ JobDefinitionEntity entity = createEntity(65L, "replace-orphan-job", true, null);
+ entity.setJarFilePath(undeletable.toString());
+ JarUploadService.StoredJar newJar = storedReplacement("replace-orphan-job");
+
+ when(jobDefinitionRepository.findById(65L)).thenReturn(Optional.of(entity));
+ when(jobRepository.findRunningJobExecutions("replace-orphan-job"))
+ .thenReturn(Collections.emptySet());
+ when(jarUploadService.storeReplacementJar(any(), any(), any(), any())).thenReturn(newJar);
+ stubSaveReturnsArgument();
+
+ JobDefinitionEntity result =
+ assertDoesNotThrow(
+ () -> service.replaceJar(65L, replacementUpload, NEW_VERSION, NEW_MAIN_CLASS));
+
+ assertEquals(newJar.path().toString(), result.getJarFilePath());
+ assertTrue(Files.exists(undeletable));
+ AuditEvent replaced = singleReplacedAuditEvent();
+ assertEquals(AuditEvent.SUCCESS, replaced.outcome());
+ assertTrue(replaced.details().contains("orphan"), replaced.details());
+ assertTrue(replaced.details().contains(undeletable.toString()), replaced.details());
+ }
+
+ @Test
+ void replaceJar_oldJarAlreadyMissing_succeeds() throws Exception {
+ JobDefinitionEntity entity = createEntity(66L, "replace-missing-job", true, null);
+ String missingJar = tempDir.resolve("already-gone.jar").toString();
+ entity.setJarFilePath(missingJar);
+ JarUploadService.StoredJar newJar = storedReplacement("replace-missing-job");
+
+ when(jobDefinitionRepository.findById(66L)).thenReturn(Optional.of(entity));
+ when(jobRepository.findRunningJobExecutions("replace-missing-job"))
+ .thenReturn(Collections.emptySet());
+ when(jarUploadService.storeReplacementJar(any(), any(), any(), any())).thenReturn(newJar);
+ stubSaveReturnsArgument();
+
+ service.replaceJar(66L, replacementUpload, NEW_VERSION, NEW_MAIN_CLASS);
+
+ AuditEvent replaced = singleReplacedAuditEvent();
+ assertEquals(AuditEvent.SUCCESS, replaced.outcome());
+ assertTrue(replaced.details().contains("already absent"), replaced.details());
+ }
+
+ @Test
+ void replaceJar_nonexistentId_throwsNotFoundAndRetainsNoLock() throws Exception {
+ when(jobDefinitionRepository.findById(999L)).thenReturn(Optional.empty());
+
+ assertThrows(
+ NoSuchElementException.class,
+ () -> service.replaceJar(999L, replacementUpload, NEW_VERSION, NEW_MAIN_CLASS));
+
+ assertTrue(lifecycleLocksOf(service).isEmpty());
+ verifyNoInteractions(pluginRegistryService, jarUploadService, transactionManager);
+ }
+
+ @Test
+ void replaceJar_holdsDefinitionLockWhileStoringAndUpdating() throws Exception {
+ JobDefinitionEntity entity = createEntity(67L, "replace-lock-job", true, LoadResult.LOADED);
+ JarUploadService.StoredJar newJar = storedReplacement("replace-lock-job");
+ when(jobDefinitionRepository.findById(67L)).thenReturn(Optional.of(entity));
+ when(jobRepository.findRunningJobExecutions("replace-lock-job"))
+ .thenReturn(Collections.emptySet());
+ stubSaveReturnsArgument();
+
+ AtomicInteger unregisterCalls = new AtomicInteger();
+ CountDownLatch secondUnregisterEntered = new CountDownLatch(1);
+ doAnswer(
+ invocation -> {
+ if (unregisterCalls.incrementAndGet() == 2) {
+ secondUnregisterEntered.countDown();
+ }
+ return null;
+ })
+ .when(pluginRegistryService)
+ .unregisterDynamicPlugin("replace-lock-job");
+
+ CountDownLatch storeEntered = new CountDownLatch(1);
+ CountDownLatch releaseStore = new CountDownLatch(1);
+ when(jarUploadService.storeReplacementJar(any(), any(), any(), any()))
+ .thenAnswer(
+ invocation -> {
+ storeEntered.countDown();
+ assertTrue(releaseStore.await(5, TimeUnit.SECONDS));
+ return newJar;
+ });
+
+ ExecutorService pool = Executors.newFixedThreadPool(2);
+ try {
+ Future> replace =
+ pool.submit(() -> service.replaceJar(67L, replacementUpload, NEW_VERSION, NEW_MAIN_CLASS));
+ assertTrue(storeEntered.await(5, TimeUnit.SECONDS));
+
+ Future> concurrentUnload = pool.submit(() -> service.unloadJob(67L, true));
+ assertFalse(
+ secondUnregisterEntered.await(250, TimeUnit.MILLISECONDS),
+ "Concurrent unload must not enter while replace owns the lifecycle lock");
+
+ releaseStore.countDown();
+ replace.get(5, TimeUnit.SECONDS);
+ concurrentUnload.get(5, TimeUnit.SECONDS);
+ assertEquals(2, unregisterCalls.get());
+ assertTrue(lifecycleLocksOf(service).isEmpty());
+ } finally {
+ releaseStore.countDown();
+ pool.shutdownNow();
+ }
+ }
+
// ── Reload ────────────────────────────────────────────────────────────────
@Test
diff --git a/fr-batch-service/src/test/java/com/fronzec/frbatchservice/batchjobs/plugins/loader/DynamicJobLoadingIntegrationTest.java b/fr-batch-service/src/test/java/com/fronzec/frbatchservice/batchjobs/plugins/loader/DynamicJobLoadingIntegrationTest.java
index 3768c231..66b9c158 100644
--- a/fr-batch-service/src/test/java/com/fronzec/frbatchservice/batchjobs/plugins/loader/DynamicJobLoadingIntegrationTest.java
+++ b/fr-batch-service/src/test/java/com/fronzec/frbatchservice/batchjobs/plugins/loader/DynamicJobLoadingIntegrationTest.java
@@ -16,6 +16,7 @@
import com.fronzec.frbatchservice.batchjobs.plugins.entity.JobExecutionAuditEntity;
import com.fronzec.frbatchservice.batchjobs.plugins.repository.JobDefinitionRepository;
import com.fronzec.frbatchservice.batchjobs.plugins.repository.JobExecutionAuditRepository;
+import com.fronzec.frbatchservice.batchjobs.plugins.util.ChecksumUtil;
import com.fronzec.frbatchservice.restclients.ApiClient;
import com.fronzec.frbatchservice.restclients.DataCalculatedResponse;
import com.fronzec.frbatchservice.web.SingleJobDataRequest;
@@ -39,6 +40,7 @@
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.annotation.DirtiesContext;
@@ -51,7 +53,7 @@
/**
* End-to-end integration tests for the dynamic job loading pipeline: upload, enable, load, unload,
- * reload, load-all, merged plugin listing, and job execution.
+ * reload, load-all, merged plugin listing, job execution, JAR replacement, and delete.
*
* Uses a pre-compiled {@code DynamicTestPlugin} whose {@code .class} files are packaged into a
* JAR at test-runtime and uploaded through the real {@code /jobs/upload} multipart endpoint.
@@ -77,11 +79,14 @@ class DynamicJobLoadingIntegrationTest {
private static final String VERSION = "1.0.0";
private static final String MAIN_CLASS = "com.fronzec.test.dynamic.DynamicTestPlugin";
private static final String JAR_FILENAME = "dynamic-test-plugin.jar";
+ private static final String REPLACEMENT_VERSION = "2.0.0";
+ private static final String REPLACEMENT_JAR_FILENAME = "dynamic-test-plugin-replacement.jar";
/* ── Static state (shared across @BeforeAll / @AfterAll / ordered tests) ── */
private static Path tempDir;
private static Path testJarPath;
+ private static Path replacementJarPath;
static Long definitionId;
/* ── Spring wiring ─────────────────────────────────────────────────────── */
@@ -104,6 +109,7 @@ class DynamicJobLoadingIntegrationTest {
void createTestJar() throws Exception {
tempDir = Files.createTempDirectory("dyn-load-test-");
testJarPath = tempDir.resolve(JAR_FILENAME);
+ replacementJarPath = tempDir.resolve(REPLACEMENT_JAR_FILENAME);
// Locate the pre-compiled DynamicTestPlugin.class on the test classpath
String classResource = MAIN_CLASS.replace('.', '/') + ".class";
@@ -114,16 +120,29 @@ void createTestJar() throws Exception {
// Walk up from the .class file to find the test-classes root
Path classFile = Path.of(classUrl.toURI());
Path packageDir = classFile.getParent(); // .../com/fronzec/test/dynamic/
- // Compute the base directory by walking up to the package root
+ // Compute the base directory by walking up one level per package segment, so JAR entries
+ // are rooted at the package (com/fronzec/...) and the main class entry is resolvable.
Path computed = packageDir;
- for (int i = 0; i < MAIN_CLASS.split("\\.").length; i++) {
+ for (int i = 0; i < MAIN_CLASS.split("\\.").length - 1; i++) {
computed = computed.getParent();
}
final Path baseDir = computed;
- // Package all .class files from the package into a JAR
+ packagePluginJar(testJarPath, packageDir, baseDir, false);
+ // Same plugin classes plus a marker entry, so the replacement JAR has a different checksum.
+ packagePluginJar(replacementJarPath, packageDir, baseDir, true);
+ }
+
+ /** Packages all {@code .class} files under {@code packageDir} into a JAR at {@code target}. */
+ private static void packagePluginJar(
+ Path target, Path packageDir, Path baseDir, boolean withReplacementMarker) throws IOException {
try (JarOutputStream jos =
- new JarOutputStream(new BufferedOutputStream(new FileOutputStream(testJarPath.toFile())))) {
+ new JarOutputStream(new BufferedOutputStream(new FileOutputStream(target.toFile())))) {
+ if (withReplacementMarker) {
+ jos.putNextEntry(new JarEntry("META-INF/replacement-marker.txt"));
+ jos.write("replacement".getBytes());
+ jos.closeEntry();
+ }
try (var files = Files.walk(packageDir)) {
files
.filter(p -> p.toString().endsWith(".class"))
@@ -177,11 +196,16 @@ void tearDown() {
// best-effort cleanup
}
}
- // Clean up uploaded JAR in the plugin storage directory
- try {
- Files.deleteIfExists(Path.of("./target/test-plugins/jars/" + JOB_NAME + "-" + VERSION + ".jar"));
- } catch (IOException ignored) {
- // best-effort cleanup
+ // Clean up uploaded and replacement JARs in the plugin storage directory
+ Path jarDir = Path.of("./target/test-plugins/jars/");
+ if (Files.isDirectory(jarDir)) {
+ try (DirectoryStream jars = Files.newDirectoryStream(jarDir, JOB_NAME + "-*.jar")) {
+ for (Path jar : jars) {
+ Files.deleteIfExists(jar);
+ }
+ } catch (IOException ignored) {
+ // best-effort cleanup
+ }
}
}
@@ -200,6 +224,14 @@ private MockMultipartFile testJar() throws IOException {
Files.readAllBytes(testJarPath));
}
+ private MockMultipartFile replacementJar() throws IOException {
+ return new MockMultipartFile(
+ "file",
+ REPLACEMENT_JAR_FILENAME,
+ MediaType.APPLICATION_OCTET_STREAM_VALUE,
+ Files.readAllBytes(replacementJarPath));
+ }
+
/* ── Helpers: execution audit lookups ──────────────────────────────────── */
private static JobParameters identifyingParams(String attemptNumber) {
@@ -537,10 +569,123 @@ void deleteWithExecutionHistory_returnsConflict_andKeepsPluginRowAndJar() throws
assertThat(pluginRegistryService.getRegisteredJobNames()).contains(JOB_NAME);
}
- /* ── 16. DELETE a loaded plugin → 204, unregistered, row and JAR removed ── */
+ /* ── 16. Replace the JAR of a definition with history → 200, same id ──── */
@Test
@Order(16)
+ void replaceJarWithExecutionHistory_keepsIdAndHistory_thenReapprovedLoadUsesNewJar()
+ throws Exception {
+ // Tests 11 and 12 left execution history; replacement must keep it linked to the same id.
+ List historyBefore =
+ jobExecutionAuditRepository.findByJobDefinitionId(definitionId);
+ assertThat(historyBefore).isNotEmpty();
+ JobDefinitionEntity before = jobDefinitionRepository.findById(definitionId).orElseThrow();
+ Path oldJar = Path.of(before.getJarFilePath());
+ assertThat(oldJar).exists();
+ assertThat(pluginRegistryService.getRegisteredJobNames()).contains(JOB_NAME);
+
+ mockMvc
+ .perform(
+ multipart(HttpMethod.PUT, JOBS_BASE + "/definitions/" + definitionId + "/jar")
+ .file(replacementJar())
+ .param("version", REPLACEMENT_VERSION)
+ .param("mainClassName", MAIN_CLASS))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id").value(definitionId))
+ .andExpect(jsonPath("$.job_name").value(JOB_NAME))
+ .andExpect(jsonPath("$.version").value(REPLACEMENT_VERSION))
+ .andExpect(
+ jsonPath("$.jar_file_name")
+ .value(containsString(JOB_NAME + "-" + REPLACEMENT_VERSION + "-")))
+ // Auto-approval is disabled in this suite, so the replacement needs a fresh approval.
+ .andExpect(jsonPath("$.approval_status").value("PENDING"))
+ .andExpect(jsonPath("$.approved_by").doesNotExist())
+ .andExpect(jsonPath("$.load_status").value(LoadResult.UNLOADED))
+ .andExpect(jsonPath("$.enabled").value(true));
+
+ JobDefinitionEntity after = jobDefinitionRepository.findById(definitionId).orElseThrow();
+ Path newJar = Path.of(after.getJarFilePath());
+ assertThat(newJar).exists().isNotEqualTo(oldJar);
+ assertThat(oldJar).doesNotExist();
+ assertThat(after.getJarChecksum())
+ .isNotEqualTo(before.getJarChecksum())
+ .isEqualTo(ChecksumUtil.computeSha256(newJar));
+ assertThat(pluginRegistryService.getRegisteredJobNames()).doesNotContain(JOB_NAME);
+ assertThat(jobExecutionAuditRepository.findByJobDefinitionId(definitionId))
+ .hasSameSizeAs(historyBefore);
+
+ // Loading is blocked until the replacement is approved, then uses the new JAR.
+ mockMvc
+ .perform(post(JOBS_BASE + "/definitions/" + definitionId + "/load"))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.message").value(containsString("must be approved")));
+ mockMvc
+ .perform(put(JOBS_BASE + "/definitions/" + definitionId + "/approve"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.approval_status").value("APPROVED"));
+ mockMvc
+ .perform(post(JOBS_BASE + "/definitions/" + definitionId + "/load"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.status").value(LoadResult.LOADED));
+ assertThat(pluginRegistryService.getRegisteredJobNames()).contains(JOB_NAME);
+ }
+
+ /* ── 17. Replace the JAR of an unknown definition → 404 ───────────────── */
+
+ @Test
+ @Order(17)
+ void replaceJarOfNonExistentDefinition_returnsNotFound() throws Exception {
+ mockMvc
+ .perform(
+ multipart(HttpMethod.PUT, JOBS_BASE + "/definitions/99999/jar")
+ .file(replacementJar())
+ .param("version", REPLACEMENT_VERSION)
+ .param("mainClassName", MAIN_CLASS))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.status").value(404))
+ .andExpect(jsonPath("$.error").value("Not Found"));
+ }
+
+ /* ── 18. Replace with an invalid JAR → 400, definition, JAR, plugin kept ─ */
+
+ @Test
+ @Order(18)
+ void replaceJarWithInvalidJar_returnsBadRequest_andKeepsDefinitionJarAndLoadedPlugin()
+ throws Exception {
+ // Test 16 leaves the replacement approved and loaded; load it if that is not the case.
+ if (!pluginRegistryService.getRegisteredJobNames().contains(JOB_NAME)) {
+ loaderService.loadJob(definitionId);
+ }
+ assertThat(pluginRegistryService.getRegisteredJobNames()).contains(JOB_NAME);
+ JobDefinitionEntity before = jobDefinitionRepository.findById(definitionId).orElseThrow();
+ MockMultipartFile notAJar =
+ new MockMultipartFile(
+ "file", "broken.jar", MediaType.APPLICATION_OCTET_STREAM_VALUE, "not a jar".getBytes());
+
+ mockMvc
+ .perform(
+ multipart(HttpMethod.PUT, JOBS_BASE + "/definitions/" + definitionId + "/jar")
+ .file(notAJar)
+ .param("version", "3.0.0")
+ .param("mainClassName", MAIN_CLASS))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.status").value(400));
+
+ JobDefinitionEntity after = jobDefinitionRepository.findById(definitionId).orElseThrow();
+ assertThat(after.getJarFilePath()).isEqualTo(before.getJarFilePath());
+ assertThat(after.getJarChecksum()).isEqualTo(before.getJarChecksum());
+ assertThat(after.getVersion()).isEqualTo(REPLACEMENT_VERSION);
+ assertThat(after.getApprovalStatus()).isEqualTo("APPROVED");
+ assertThat(Path.of(after.getJarFilePath())).exists();
+ // The replace validates before unloading, so a rejected JAR leaves the plugin loaded.
+ assertThat(after.getLoadStatus()).isEqualTo(before.getLoadStatus());
+ assertThat(pluginRegistryService.getRegisteredJobNames()).contains(JOB_NAME);
+ }
+
+ /* ── 19. DELETE a loaded plugin → 204, unregistered, row and JAR removed ── */
+
+ @Test
+ @Order(19)
void deleteLoadedPlugin_returnsNoContent_andUnregistersJob() throws Exception {
// Remove the execution history that blocked test 15 so this test isolates the
// lifecycle behaviour of deleting a loaded plugin.
@@ -563,10 +708,10 @@ void deleteLoadedPlugin_returnsNoContent_andUnregistersJob() throws Exception {
assertThat(storedJar).doesNotExist();
}
- /* ── 17. DELETE an unknown definition → 404 error envelope ────────────── */
+ /* ── 20. DELETE an unknown definition → 404 error envelope ────────────── */
@Test
- @Order(17)
+ @Order(20)
void deleteNonExistentDefinition_returnsNotFound() throws Exception {
mockMvc
.perform(delete(JOBS_BASE + "/definitions/99999"))
diff --git a/fr-batch-service/src/test/java/com/fronzec/frbatchservice/batchjobs/plugins/service/JarUploadServiceTest.java b/fr-batch-service/src/test/java/com/fronzec/frbatchservice/batchjobs/plugins/service/JarUploadServiceTest.java
index 4e2d9c02..77314376 100644
--- a/fr-batch-service/src/test/java/com/fronzec/frbatchservice/batchjobs/plugins/service/JarUploadServiceTest.java
+++ b/fr-batch-service/src/test/java/com/fronzec/frbatchservice/batchjobs/plugins/service/JarUploadServiceTest.java
@@ -3,11 +3,16 @@
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+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.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import com.fronzec.frbatchservice.batchjobs.plugins.audit.AuditService;
@@ -19,10 +24,13 @@
import com.fronzec.frbatchservice.web.dto.JarUploadResponse;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
+import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
+import java.util.stream.Stream;
import java.util.Optional;
+import java.util.UUID;
import java.util.jar.Attributes;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;
@@ -115,6 +123,191 @@ void uploadJar_checksumIsComputedFromStoredFile_notMultipartStream() throws IOEx
assertEquals("disk-digest", response.jarChecksum(), "response must expose the same checksum");
}
+ // ─── replacement JAR storage ───────────────────────────────────────────────
+
+ @Test
+ @DisplayName("stores a replacement JAR under a unique name without overwriting the current file")
+ void storeReplacementJar_storesUniqueFileWithoutOverwritingCurrentJar() throws IOException {
+ Path currentJar = jarDir.resolve("test-job-1.0.0.jar");
+ Files.write(currentJar, "current".getBytes());
+ byte[] jarBytes = minimalJar();
+ when(jarSignatureVerifier.verify(any()))
+ .thenReturn(new SignatureResult(true, null, List.of()));
+
+ JarUploadService.StoredJar first =
+ service.storeReplacementJar(
+ replacementFile(jarBytes), "test-job", "1.0.0", "com.example.MyPlugin");
+ JarUploadService.StoredJar second =
+ service.storeReplacementJar(
+ replacementFile(jarBytes), "test-job", "1.0.0", "com.example.MyPlugin");
+
+ assertNotEquals(currentJar, first.path());
+ assertNotEquals(first.path(), second.path(), "each replacement must get a unique file name");
+ assertTrue(
+ first.path().getFileName().toString().matches("test-job-1\\.0\\.0-[0-9a-f]{8}\\.jar"),
+ first.path().toString());
+ assertEquals(jarDir.toAbsolutePath().normalize(), first.path().getParent());
+ assertArrayEquals(jarBytes, Files.readAllBytes(first.path()));
+ assertArrayEquals(
+ "current".getBytes(), Files.readAllBytes(currentJar), "current JAR must be untouched");
+ assertEquals(ChecksumUtil.computeSha256(first.path()), first.checksum());
+ verifyNoInteractions(jobDefinitionRepository, auditService);
+ }
+
+ @Test
+ @DisplayName("rejects a replacement JAR that does not contain the main class and stores nothing")
+ void storeReplacementJar_missingMainClassEntry_rejectsAndLeavesNoFile() throws IOException {
+ InvalidJarException ex =
+ assertThrows(
+ InvalidJarException.class,
+ () ->
+ service.storeReplacementJar(
+ replacementFile(minimalJar()), "test-job", "2.0.0", "com.example.Other"));
+
+ assertTrue(ex.getMessage().contains("com.example.Other"), ex.getMessage());
+ assertEquals(0, filesIn(jarDir));
+ verifyNoInteractions(jarSignatureVerifier);
+ }
+
+ @Test
+ @DisplayName("rejects a replacement that is not a JAR before storing anything")
+ void storeReplacementJar_notAJar_rejectsWithoutStoring() throws IOException {
+ MockMultipartFile notAJar =
+ new MockMultipartFile("file", "plugin.jar", "application/java-archive", "hello".getBytes());
+
+ assertThrows(
+ InvalidJarException.class,
+ () -> service.storeReplacementJar(notAJar, "test-job", "2.0.0", "com.example.MyPlugin"));
+
+ assertEquals(0, filesIn(jarDir));
+ verifyNoInteractions(jarSignatureVerifier);
+ }
+
+ @Test
+ @DisplayName("deletes the stored replacement when strict signature verification fails")
+ void storeReplacementJar_strictSignatureFailure_rejectsAndDeletesStoredFile() throws IOException {
+ JarUploadService strictService =
+ new JarUploadService(
+ jobDefinitionRepository,
+ Optional.empty(),
+ jarSignatureVerifier,
+ auditService,
+ pluginMetrics,
+ "strict",
+ jarDir.toString());
+ when(jarSignatureVerifier.verify(any()))
+ .thenReturn(new SignatureResult(false, null, List.of("unsigned")));
+
+ InvalidJarException ex =
+ assertThrows(
+ InvalidJarException.class,
+ () ->
+ strictService.storeReplacementJar(
+ replacementFile(minimalJar()), "test-job", "2.0.0", "com.example.MyPlugin"));
+
+ assertTrue(ex.getMessage().contains("strict"), ex.getMessage());
+ assertEquals(0, filesIn(jarDir));
+ }
+
+ // ─── auto-approval ─────────────────────────────────────────────────────────
+
+ @Test
+ @DisplayName("auto-approves and re-saves a definition when auto-approval is configured")
+ void autoApproveIfEnabled_withConfig_approvesAndSaves() {
+ AutoApproveConfig autoApprove = mock(AutoApproveConfig.class);
+ JarUploadService autoApproveService =
+ new JarUploadService(
+ jobDefinitionRepository,
+ Optional.of(autoApprove),
+ jarSignatureVerifier,
+ auditService,
+ pluginMetrics,
+ "permissive",
+ jarDir.toString());
+ JobDefinitionEntity entity = new JobDefinitionEntity();
+
+ assertTrue(autoApproveService.autoApproveIfEnabled(entity));
+
+ verify(autoApprove).approve(entity);
+ verify(jobDefinitionRepository).save(entity);
+ }
+
+ @Test
+ @DisplayName("leaves a definition untouched when auto-approval is not configured")
+ void autoApproveIfEnabled_withoutConfig_returnsFalseWithoutSaving() {
+ assertFalse(service.autoApproveIfEnabled(new JobDefinitionEntity()));
+
+ verifyNoInteractions(jobDefinitionRepository);
+ }
+
+ @Test
+ @DisplayName("deletes a partially written replacement JAR when the copy fails mid-stream")
+ void storeReplacementJar_copyFailsMidStream_leavesNoPartialFile() throws IOException {
+ byte[] jarBytes = minimalJar();
+ MultipartFile failingFile = mock(MultipartFile.class);
+ when(failingFile.getOriginalFilename()).thenReturn("plugin.jar");
+ // Each stream yields the first bytes (enough for the header check) and then fails.
+ when(failingFile.getInputStream()).thenAnswer(invocation -> failingAfter(jarBytes, 16));
+
+ RuntimeException ex =
+ assertThrows(
+ RuntimeException.class,
+ () ->
+ service.storeReplacementJar(
+ failingFile, "test-job", "2.0.0", "com.example.MyPlugin"));
+
+ assertTrue(ex.getMessage().contains("Failed to store JAR"), ex.getMessage());
+ assertEquals(0, filesIn(jarDir), "no partial replacement JAR may remain");
+ }
+
+ @Test
+ @DisplayName("never deletes an existing file when the unique replacement name collides")
+ void storeReplacementJar_nameCollision_keepsExistingFile() throws IOException {
+ UUID fixed = UUID.fromString("abcdef12-0000-0000-0000-000000000000");
+ Path existing = jarDir.resolve("test-job-2.0.0-abcdef12.jar");
+ Files.write(existing, "someone-else".getBytes());
+
+ try (MockedStatic uuid = mockStatic(UUID.class)) {
+ uuid.when(UUID::randomUUID).thenReturn(fixed);
+
+ assertThrows(
+ RuntimeException.class,
+ () ->
+ service.storeReplacementJar(
+ replacementFile(minimalJar()), "test-job", "2.0.0", "com.example.MyPlugin"));
+ }
+
+ assertArrayEquals(
+ "someone-else".getBytes(),
+ Files.readAllBytes(existing),
+ "a colliding file that this call did not write must be kept");
+ }
+
+ /** Returns a stream that yields the first {@code limit} bytes and then throws. */
+ private static InputStream failingAfter(byte[] bytes, int limit) {
+ return new InputStream() {
+ private int position;
+
+ @Override
+ public int read() throws IOException {
+ if (position >= limit) {
+ throw new IOException("simulated stream failure");
+ }
+ return position < bytes.length ? bytes[position++] & 0xFF : -1;
+ }
+ };
+ }
+
+ private static MockMultipartFile replacementFile(byte[] jarBytes) {
+ return new MockMultipartFile("file", "plugin.jar", "application/java-archive", jarBytes);
+ }
+
+ private static long filesIn(Path dir) throws IOException {
+ try (Stream files = Files.list(dir)) {
+ return files.count();
+ }
+ }
+
/** Builds a minimal valid (unsigned) JAR with a manifest and one entry. */
private static byte[] minimalJar() throws IOException {
Manifest manifest = new Manifest();
diff --git a/fr-batch-service/src/test/java/com/fronzec/frbatchservice/web/PluginManagementIntegrationTest.java b/fr-batch-service/src/test/java/com/fronzec/frbatchservice/web/PluginManagementIntegrationTest.java
index bfab122a..1f1c851e 100644
--- a/fr-batch-service/src/test/java/com/fronzec/frbatchservice/web/PluginManagementIntegrationTest.java
+++ b/fr-batch-service/src/test/java/com/fronzec/frbatchservice/web/PluginManagementIntegrationTest.java
@@ -25,6 +25,7 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
+import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.annotation.DirtiesContext;
@@ -330,4 +331,77 @@ void uploadUnderNonProductionProfile_persistsAutoApproval() throws Exception {
assertThat(persisted.getApprovedBy()).isEqualTo("system");
assertThat(persisted.getApprovedAt()).isNotNull();
}
+
+ // ─── JAR replacement ────────────────────────────────────────────────────────
+
+ @Test
+ @Order(13)
+ void replaceJarUnderNonProductionProfile_keepsIdAndReappliesAutoApproval() throws Exception {
+ String jobName = "replace-auto-approve-job";
+ String uploadBody =
+ mockMvc
+ .perform(
+ multipart(JOBS_BASE + "/upload")
+ .file(testJar("replace-auto-approve.jar"))
+ .param("jobName", jobName)
+ .param("version", TEST_VERSION)
+ .param("mainClassName", TEST_MAIN_CLASS))
+ .andExpect(status().isCreated())
+ .andReturn()
+ .getResponse()
+ .getContentAsString();
+ int idStart = uploadBody.indexOf("\"id\":") + 5;
+ int idEnd = uploadBody.indexOf(",", idStart);
+ long id = Long.parseLong(uploadBody.substring(idStart, idEnd).trim());
+ String oldJarPath = jobDefinitionRepository.findById(id).orElseThrow().getJarFilePath();
+
+ // The replacement must contain its main class entry.
+ MockMultipartFile replacement =
+ new MockMultipartFile(
+ "file",
+ "replace-auto-approve-1.1.0.jar",
+ MediaType.APPLICATION_OCTET_STREAM_VALUE,
+ createMinimalJarBytes(TEST_MAIN_CLASS.replace('.', '/') + ".class"));
+
+ mockMvc
+ .perform(
+ multipart(HttpMethod.PUT, JOBS_BASE + "/definitions/" + id + "/jar")
+ .file(replacement)
+ .param("version", "1.1.0")
+ .param("mainClassName", TEST_MAIN_CLASS))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id").value(id))
+ .andExpect(jsonPath("$.version").value("1.1.0"))
+ // AutoApproveConfig is active under the test profile, so the reset PENDING approval
+ // is re-applied after commit, exactly as on upload.
+ .andExpect(jsonPath("$.approval_status").value("APPROVED"))
+ .andExpect(jsonPath("$.approved_by").value("system"));
+
+ JobDefinitionEntity persisted = jobDefinitionRepository.findById(id).orElseThrow();
+ assertThat(persisted.getApprovalStatus()).isEqualTo("APPROVED");
+ assertThat(persisted.getJarFilePath()).isNotEqualTo(oldJarPath);
+ assertThat(java.nio.file.Path.of(oldJarPath)).doesNotExist();
+ assertThat(java.nio.file.Path.of(persisted.getJarFilePath())).exists();
+ }
+
+ @Test
+ @Order(14)
+ void replaceJarWithoutMainClassEntry_returnsBadRequest() throws Exception {
+ JobDefinitionEntity existing =
+ jobDefinitionRepository.findByJobName("replace-auto-approve-job").orElseThrow();
+
+ mockMvc
+ .perform(
+ multipart(HttpMethod.PUT, JOBS_BASE + "/definitions/" + existing.getId() + "/jar")
+ .file(testJar("no-main-class.jar"))
+ .param("version", "1.2.0")
+ .param("mainClassName", TEST_MAIN_CLASS))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.status").value(400))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString(TEST_MAIN_CLASS)));
+
+ JobDefinitionEntity after = jobDefinitionRepository.findById(existing.getId()).orElseThrow();
+ assertThat(after.getVersion()).isEqualTo("1.1.0");
+ assertThat(after.getJarFilePath()).isEqualTo(existing.getJarFilePath());
+ }
}