diff --git a/.gitignore b/.gitignore index 761b4af6..05bc1144 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ target/ *.log /evidence/ +# Local artifact store data (clearfolio.artifact-store.root-dir default) +/data/ + # Local agent/tool artifacts tmp/ registered_agents.json diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e5f2a5ce..13dbeda7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -24,16 +24,21 @@ Current state: viewer/state API is implemented in this repository; downstream S2 - `GET /artifacts/{docId}.pdf`: serves PDF bytes for SUCCEEDED jobs with basic HTTP Range support. - `DefaultDocumentConversionService` (`src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java`) - Validation, content hash generation, dedupe lookup, repository persistence, worker enqueue. + - PDF passthrough: uploads that declare PDF (extension/content type) and carry the `%PDF-` magic header are seeded into the artifact store as-is, so the original bytes are served instead of a generated placeholder. - `DefaultDocumentValidationService` (`src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java`) - Enforces extension blocklist and size limits, including auditable policy-override exception lane. - `DefaultConversionWorker` (`src/main/java/com/clearfolio/viewer/service/DefaultConversionWorker.java`) - Runs conversion on a bounded executor with retry scheduling and dead-letter fallback. - `ArtifactStore` (`src/main/java/com/clearfolio/viewer/artifact/ArtifactStore.java`) - Stores converted PDF bytes by docId. +- `FileSystemArtifactStore` (`src/main/java/com/clearfolio/viewer/artifact/FileSystemArtifactStore.java`) + - Default disk-backed artifact store; persists bytes plus minimal metadata under `clearfolio.artifact-store.root-dir` (default `data/artifacts`) so artifacts survive restarts, with an in-memory cache on the read path. - `InMemoryArtifactStore` (`src/main/java/com/clearfolio/viewer/artifact/InMemoryArtifactStore.java`) - - In-memory artifact store implementation. + - In-memory artifact store implementation used by tests and `clearfolio.artifact-store.mode=in-memory`. +- `ArtifactStoreConfig` (`src/main/java/com/clearfolio/viewer/config/ArtifactStoreConfig.java`) + - Selects the artifact store implementation from `ArtifactStoreProperties`. - `PdfBoxArtifactGenerator` (`src/main/java/com/clearfolio/viewer/artifact/PdfBoxArtifactGenerator.java`) - - Generates a simple one-page PDF via PDFBox for the default conversion path. + - Generates a placeholder one-page PDF via PDFBox for non-PDF sources; real docx/hwp conversion remains future work. - `InMemoryConversionJobRepository` (`src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java`) - In-memory job store and content-hash dedupe index. - `ConversionJob` (`src/main/java/com/clearfolio/viewer/model/ConversionJob.java`) diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..442f1213 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 ContextualWisdomLab + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/src/main/java/com/clearfolio/viewer/artifact/ArtifactStore.java b/src/main/java/com/clearfolio/viewer/artifact/ArtifactStore.java index f40858af..9d3dcb50 100644 --- a/src/main/java/com/clearfolio/viewer/artifact/ArtifactStore.java +++ b/src/main/java/com/clearfolio/viewer/artifact/ArtifactStore.java @@ -6,8 +6,9 @@ /** * Stores and retrieves preview artifacts produced by conversion jobs. * - *
This MVP abstraction intentionally keeps operations in-memory and CPU-only - * so request paths remain non-blocking (no filesystem/network I/O). + *
Implementations keep the hot request path CPU-only where possible; the + * disk-backed implementation caches bytes in memory after the first access so + * filesystem reads are limited to cache misses (for example, after a restart). */ public interface ArtifactStore { diff --git a/src/main/java/com/clearfolio/viewer/artifact/FileSystemArtifactStore.java b/src/main/java/com/clearfolio/viewer/artifact/FileSystemArtifactStore.java new file mode 100644 index 00000000..bff3ffbc --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/artifact/FileSystemArtifactStore.java @@ -0,0 +1,131 @@ +package com.clearfolio.viewer.artifact; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Disk-backed {@link ArtifactStore} that persists artifact bytes and minimal + * metadata under a configurable root directory so artifacts survive process + * restarts. + * + *
Artifact bytes are cached in memory after the first write or read, so the
+ * request-serving path only touches the filesystem when an artifact is not yet
+ * cached (for example, on the first read after a restart).
+ */
+public final class FileSystemArtifactStore implements ArtifactStore {
+
+ @FunctionalInterface
+ interface BytesWriter {
+ void write(Path path, byte[] bytes) throws IOException;
+ }
+
+ @FunctionalInterface
+ interface BytesReader {
+ byte[] read(Path path) throws IOException;
+ }
+
+ private static final String PDF_SUFFIX = ".pdf";
+ private static final String METADATA_SUFFIX = ".meta.properties";
+
+ private final Path rootDir;
+ private final BytesWriter bytesWriter;
+ private final BytesReader bytesReader;
+ private final ConcurrentHashMap Contents do not survive restarts; this implementation backs tests and the
+ * {@code clearfolio.artifact-store.mode=in-memory} configuration, while the
+ * disk-backed {@link FileSystemArtifactStore} is the runtime default.
*/
-@Component
public class InMemoryArtifactStore implements ArtifactStore {
private final ConcurrentHashMap PDF uploads are served passthrough: the original bytes seeded into the
+ * artifact store at submit time become the artifact unchanged. Non-PDF sources
+ * still produce a placeholder preview PDF because real document conversion
+ * (docx, hwp, and similar formats) remains future work.
*/
@Component
public class DefaultConversionWorker implements ConversionWorker {
@@ -224,8 +229,9 @@ private String performDefaultConversion(UUID jobId) {
ConversionJob job = repository.findById(jobId)
.orElseThrow(() -> new IllegalStateException("job not found"));
- byte[] pdfBytes = pdfArtifactGenerator.generatePdf(job);
- artifactStore.putPdf(jobId, pdfBytes);
+ if (artifactStore.getPdf(jobId).isEmpty()) {
+ artifactStore.putPdf(jobId, pdfArtifactGenerator.generatePdf(job));
+ }
return "/artifacts/" + jobId + ".pdf";
}
diff --git a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java
index b8787f35..c501c20f 100644
--- a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java
+++ b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java
@@ -1,5 +1,6 @@
package com.clearfolio.viewer.service;
+import java.util.Locale;
import java.util.Optional;
import java.util.UUID;
@@ -12,6 +13,8 @@
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
+import com.clearfolio.viewer.artifact.ArtifactStore;
+import com.clearfolio.viewer.artifact.InMemoryArtifactStore;
import com.clearfolio.viewer.auth.TenantContext;
import com.clearfolio.viewer.model.ConversionJob;
import com.clearfolio.viewer.repository.ConversionJobRepository;
@@ -22,23 +25,35 @@
/**
* Default implementation that validates uploads, deduplicates by content hash,
* and enqueues newly created conversion jobs.
+ *
+ * When the uploaded source is already a PDF (declared by extension or
+ * content type and confirmed by the {@code %PDF-} magic header), the original
+ * bytes are seeded into the artifact store so the worker serves the uploaded
+ * document as-is instead of generating a placeholder.
*/
@Service
public class DefaultDocumentConversionService implements DocumentConversionService {
+ private static final String PDF_CONTENT_TYPE = "application/pdf";
+ private static final String PDF_EXTENSION_SUFFIX = ".pdf";
+ private static final byte[] PDF_MAGIC_HEADER = {'%', 'P', 'D', 'F', '-'};
+
private final ConversionJobRepository repository;
private final ConversionJobStateStore stateStore;
private final DocumentValidationService validationService;
private final ConversionWorker conversionWorker;
+ private final ArtifactStore artifactStore;
private final int maxRetryAttempts;
/**
- * Creates the conversion service with repository, validation, and worker dependencies.
+ * Creates the conversion service with repository, validation, worker, and
+ * artifact store dependencies.
*
* @param repository conversion job repository
* @param stateStore conversion job lifecycle state store
* @param validationService document validation service
* @param conversionWorker conversion worker
+ * @param artifactStore artifact store used for PDF passthrough seeding
* @param conversionProperties conversion configuration values
*/
@Autowired
@@ -47,14 +62,42 @@ public DefaultDocumentConversionService(
ConversionJobStateStore stateStore,
DocumentValidationService validationService,
ConversionWorker conversionWorker,
+ ArtifactStore artifactStore,
ConversionProperties conversionProperties) {
this.repository = repository;
this.stateStore = stateStore;
this.validationService = validationService;
this.conversionWorker = conversionWorker;
+ this.artifactStore = artifactStore;
this.maxRetryAttempts = conversionProperties.getMaxRetryAttempts();
}
+ /**
+ * Creates the conversion service with an isolated in-memory artifact store
+ * for PDF passthrough seeding; intended for tests and legacy wiring.
+ *
+ * @param repository conversion job repository
+ * @param stateStore conversion job lifecycle state store
+ * @param validationService document validation service
+ * @param conversionWorker conversion worker
+ * @param conversionProperties conversion configuration values
+ */
+ public DefaultDocumentConversionService(
+ ConversionJobRepository repository,
+ ConversionJobStateStore stateStore,
+ DocumentValidationService validationService,
+ ConversionWorker conversionWorker,
+ ConversionProperties conversionProperties) {
+ this(
+ repository,
+ stateStore,
+ validationService,
+ conversionWorker,
+ new InMemoryArtifactStore(),
+ conversionProperties
+ );
+ }
+
public DefaultDocumentConversionService(
ConversionJobRepository repository,
DocumentValidationService validationService,
@@ -116,6 +159,7 @@ public UUID submit(MultipartFile file, PolicyOverrideRequest overrideRequest, Te
ConversionJobRepository.FindOrStoreResult result = repository.findOrStoreByContentHash(job);
if (result.created()) {
+ seedPdfPassthroughArtifact(result.canonicalJob(), file);
conversionWorker.enqueue(result.canonicalJob().getJobId());
}
@@ -149,6 +193,55 @@ public RetryDeadLetterResult retryDeadLettered(UUID jobId, String operatorId) {
return RetryDeadLetterResult.ACCEPTED;
}
+ private void seedPdfPassthroughArtifact(ConversionJob job, MultipartFile file) {
+ if (!declaresPdfSource(job.getOriginalFileName(), job.getContentType())) {
+ return;
+ }
+
+ byte[] sourceBytes;
+ try {
+ sourceBytes = file.getBytes();
+ } catch (IOException ex) {
+ // Fall back to the worker's placeholder conversion path.
+ return;
+ }
+
+ if (!hasPdfMagicHeader(sourceBytes)) {
+ return;
+ }
+
+ artifactStore.putPdf(job.getJobId(), sourceBytes);
+ }
+
+ static boolean declaresPdfSource(String fileName, String contentType) {
+ if (contentType != null) {
+ String normalized = contentType.strip().toLowerCase(Locale.ROOT);
+ if (normalized.equals(PDF_CONTENT_TYPE) || normalized.startsWith(PDF_CONTENT_TYPE + ";")) {
+ return true;
+ }
+ }
+
+ if (fileName == null) {
+ return false;
+ }
+
+ return fileName.strip().toLowerCase(Locale.ROOT).endsWith(PDF_EXTENSION_SUFFIX);
+ }
+
+ static boolean hasPdfMagicHeader(byte[] bytes) {
+ if (bytes == null || bytes.length < PDF_MAGIC_HEADER.length) {
+ return false;
+ }
+
+ for (int index = 0; index < PDF_MAGIC_HEADER.length; index++) {
+ if (bytes[index] != PDF_MAGIC_HEADER[index]) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
private String contentHash(MultipartFile file) {
try (InputStream stream = file.getInputStream()) {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index b98dcbe2..2274fd53 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -16,6 +16,13 @@ conversion:
retry-backoff-multiplier: 2.0
max-upload-size-bytes: 5242880
+clearfolio:
+ artifact-store:
+ # filesystem (default): persist artifacts under root-dir so they survive
+ # restarts. in-memory: volatile store for tests and ephemeral demos.
+ mode: filesystem
+ root-dir: data/artifacts
+
viewer:
security:
# Default is safe and non-breaking for same-origin usage.
diff --git a/src/test/java/com/clearfolio/viewer/artifact/FileSystemArtifactStoreTest.java b/src/test/java/com/clearfolio/viewer/artifact/FileSystemArtifactStoreTest.java
new file mode 100644
index 00000000..754d0a69
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/artifact/FileSystemArtifactStoreTest.java
@@ -0,0 +1,170 @@
+package com.clearfolio.viewer.artifact;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.Provider;
+import java.security.Security;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class FileSystemArtifactStoreTest {
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void putAndGetRoundTripsPdfBytes() {
+ FileSystemArtifactStore store = new FileSystemArtifactStore(tempDir.resolve("artifacts"));
+ UUID docId = UUID.randomUUID();
+ byte[] original = "%PDF-1.7\noriginal-sheet-music".getBytes(StandardCharsets.UTF_8);
+
+ store.putPdf(docId, original);
+
+ byte[] stored = store.getPdf(docId).orElseThrow();
+ assertArrayEquals(original, stored);
+ assertEquals("%PDF-", new String(stored, 0, 5, StandardCharsets.UTF_8));
+ }
+
+ @Test
+ void storedBytesSurviveStoreReinstantiation() {
+ Path root = tempDir.resolve("artifacts");
+ UUID docId = UUID.randomUUID();
+ byte[] original = "%PDF-1.4\npersisted-artifact".getBytes(StandardCharsets.UTF_8);
+
+ new FileSystemArtifactStore(root).putPdf(docId, original);
+
+ FileSystemArtifactStore reopened = new FileSystemArtifactStore(root);
+ byte[] reloaded = reopened.getPdf(docId).orElseThrow();
+ assertArrayEquals(original, reloaded);
+ assertEquals("%PDF-", new String(reloaded, 0, 5, StandardCharsets.UTF_8));
+
+ byte[] cachedRead = reopened.getPdf(docId).orElseThrow();
+ assertArrayEquals(original, cachedRead);
+ }
+
+ @Test
+ void putPdfWritesMinimalMetadataSidecar() throws Exception {
+ Path root = tempDir.resolve("artifacts");
+ FileSystemArtifactStore store = new FileSystemArtifactStore(root);
+ UUID docId = UUID.randomUUID();
+ byte[] original = "%PDF-1.7\nmetadata-check".getBytes(StandardCharsets.UTF_8);
+
+ store.putPdf(docId, original);
+
+ Path metadataPath = root.resolve(docId + ".meta.properties");
+ String metadata = Files.readString(metadataPath, StandardCharsets.UTF_8);
+ assertTrue(metadata.contains("docId=" + docId));
+ assertTrue(metadata.contains("sizeBytes=" + original.length));
+ assertTrue(metadata.contains("sha256="));
+ assertTrue(metadata.contains("storedAt="));
+ }
+
+ @Test
+ void getPdfReturnsEmptyWhenArtifactIsMissing() {
+ FileSystemArtifactStore store = new FileSystemArtifactStore(tempDir.resolve("artifacts"));
+
+ assertEquals(Optional.empty(), store.getPdf(UUID.randomUUID()));
+ }
+
+ @Test
+ void returnedBytesAreIsolatedFromInternalState() {
+ FileSystemArtifactStore store = new FileSystemArtifactStore(tempDir.resolve("artifacts"));
+ UUID docId = UUID.randomUUID();
+ byte[] original = "%PDF-1.7\nisolation".getBytes(StandardCharsets.UTF_8);
+
+ store.putPdf(docId, original);
+ original[0] = 'X';
+
+ byte[] first = store.getPdf(docId).orElseThrow();
+ assertEquals('%', first[0]);
+
+ first[0] = 'Y';
+ byte[] second = store.getPdf(docId).orElseThrow();
+ assertEquals('%', second[0]);
+ }
+
+ @Test
+ void constructorThrowsWhenRootDirectoryCannotBeCreated() throws Exception {
+ Path blockingFile = tempDir.resolve("blocking-file");
+ Files.writeString(blockingFile, "not-a-directory");
+ Path invalidRoot = blockingFile.resolve("artifacts");
+
+ IllegalStateException error = assertThrows(
+ IllegalStateException.class,
+ () -> new FileSystemArtifactStore(invalidRoot)
+ );
+ assertTrue(error.getMessage().contains("failed to create artifact storage directory"));
+ }
+
+ @Test
+ void putPdfWrapsWriteFailures() {
+ UUID docId = UUID.randomUUID();
+ FileSystemArtifactStore store = new FileSystemArtifactStore(
+ tempDir.resolve("artifacts"),
+ (path, bytes) -> {
+ throw new IOException("disk full");
+ },
+ Files::readAllBytes
+ );
+
+ IllegalStateException error = assertThrows(
+ IllegalStateException.class,
+ () -> store.putPdf(docId, "%PDF-1.7".getBytes(StandardCharsets.UTF_8))
+ );
+ assertTrue(error.getMessage().contains("failed to persist artifact for docId " + docId));
+ assertEquals(Optional.empty(), store.getPdf(docId));
+ }
+
+ @Test
+ void getPdfWrapsReadFailures() {
+ Path root = tempDir.resolve("artifacts");
+ UUID docId = UUID.randomUUID();
+ new FileSystemArtifactStore(root).putPdf(docId, "%PDF-1.7".getBytes(StandardCharsets.UTF_8));
+
+ FileSystemArtifactStore failingReadStore = new FileSystemArtifactStore(
+ root,
+ (path, bytes) -> Files.write(path, bytes),
+ path -> {
+ throw new IOException("read denied");
+ }
+ );
+
+ IllegalStateException error = assertThrows(
+ IllegalStateException.class,
+ () -> failingReadStore.getPdf(docId)
+ );
+ assertTrue(error.getMessage().contains("failed to read artifact for docId " + docId));
+ }
+
+ @Test
+ void putPdfThrowsWhenSha256DigestIsUnavailable() {
+ FileSystemArtifactStore store = new FileSystemArtifactStore(tempDir.resolve("artifacts"));
+
+ Provider[] providers = Security.getProviders();
+ for (Provider provider : providers) {
+ Security.removeProvider(provider.getName());
+ }
+
+ try {
+ IllegalStateException error = assertThrows(
+ IllegalStateException.class,
+ () -> store.putPdf(UUID.randomUUID(), "%PDF-1.7".getBytes(StandardCharsets.UTF_8))
+ );
+ assertEquals("SHA-256 digest unavailable", error.getMessage());
+ } finally {
+ for (int index = 0; index < providers.length; index++) {
+ Security.insertProviderAt(providers[index], index + 1);
+ }
+ }
+ }
+}
diff --git a/src/test/java/com/clearfolio/viewer/config/ArtifactStoreConfigTest.java b/src/test/java/com/clearfolio/viewer/config/ArtifactStoreConfigTest.java
new file mode 100644
index 00000000..87da07f9
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/config/ArtifactStoreConfigTest.java
@@ -0,0 +1,45 @@
+package com.clearfolio.viewer.config;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import com.clearfolio.viewer.artifact.ArtifactStore;
+import com.clearfolio.viewer.artifact.FileSystemArtifactStore;
+import com.clearfolio.viewer.artifact.InMemoryArtifactStore;
+
+class ArtifactStoreConfigTest {
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void filesystemModeCreatesDiskBackedStore() {
+ ArtifactStoreProperties properties = new ArtifactStoreProperties();
+ properties.setRootDir(tempDir.resolve("artifacts").toString());
+
+ ArtifactStore store = new ArtifactStoreConfig().artifactStore(properties);
+
+ assertInstanceOf(FileSystemArtifactStore.class, store);
+ UUID docId = UUID.randomUUID();
+ byte[] bytes = "%PDF-1.7".getBytes(StandardCharsets.UTF_8);
+ store.putPdf(docId, bytes);
+ assertArrayEquals(bytes, store.getPdf(docId).orElseThrow());
+ }
+
+ @Test
+ void inMemoryModeCreatesVolatileStore() {
+ ArtifactStoreProperties properties = new ArtifactStoreProperties();
+ properties.setMode(ArtifactStoreProperties.MODE_IN_MEMORY);
+
+ ArtifactStore store = new ArtifactStoreConfig().artifactStore(properties);
+
+ assertInstanceOf(InMemoryArtifactStore.class, store);
+ }
+}
diff --git a/src/test/java/com/clearfolio/viewer/config/ArtifactStorePropertiesTest.java b/src/test/java/com/clearfolio/viewer/config/ArtifactStorePropertiesTest.java
new file mode 100644
index 00000000..5dc99d2c
Binary files /dev/null and b/src/test/java/com/clearfolio/viewer/config/ArtifactStorePropertiesTest.java differ
diff --git a/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTest.java b/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTest.java
index 28f0af0e..c577552c 100644
--- a/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTest.java
+++ b/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTest.java
@@ -274,6 +274,27 @@ void retryDeadLetteredRecordsAcceptedEventOnlyWhenTransitionSucceeds() {
assertEquals(0, events.get(3).attemptCount());
}
+ @Test
+ void stateStoreClaimForProcessingReturnsEmptyForMissingOrBusyJobs() {
+ InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository();
+ ConversionJob processing = newJob("hash-claim-busy");
+ assertTrue(processing.markProcessing("started"));
+ repository.save(processing);
+ ConversionJobStateStore stateStore = repository;
+
+ assertTrue(stateStore.claimForProcessing(UUID.randomUUID(), Instant.now()).isEmpty());
+ assertTrue(stateStore.claimForProcessing(processing.getJobId(), Instant.now()).isEmpty());
+ assertEquals(ConversionJobStatus.PROCESSING, processing.getStatus());
+ }
+
+ @Test
+ void stateStoreRetryDeadLetteredReturnsFalseForMissingJob() {
+ InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository();
+ ConversionJobStateStore stateStore = repository;
+
+ assertFalse(stateStore.retryDeadLettered(UUID.randomUUID(), "operator-1"));
+ }
+
@Test
void stateStoreClaimForProcessingUpdatesStoredJob() {
InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository();
diff --git a/src/test/java/com/clearfolio/viewer/repository/RepositoryBackedConversionJobStateStoreTest.java b/src/test/java/com/clearfolio/viewer/repository/RepositoryBackedConversionJobStateStoreTest.java
index 6fa6c85a..37faea8f 100644
--- a/src/test/java/com/clearfolio/viewer/repository/RepositoryBackedConversionJobStateStoreTest.java
+++ b/src/test/java/com/clearfolio/viewer/repository/RepositoryBackedConversionJobStateStoreTest.java
@@ -50,6 +50,51 @@ void claimForProcessingSavesReadyJob() {
assertEquals(1, repository.saveCount());
}
+ @Test
+ void claimForProcessingReturnsEmptyWhenTransitionIsLostToRace() {
+ CountingRepository repository = new CountingRepository();
+ ConversionJob job = new ConversionJob(
+ UUID.randomUUID(),
+ "report.docx",
+ "application/octet-stream",
+ "hash-claim-race",
+ 12L,
+ 3
+ ) {
+ @Override
+ public synchronized boolean isReadyForProcessing(Instant now) {
+ return true;
+ }
+
+ @Override
+ public synchronized boolean markProcessing(String message) {
+ return false;
+ }
+ };
+ repository.save(job);
+ repository.resetSaveCount();
+ RepositoryBackedConversionJobStateStore stateStore = new RepositoryBackedConversionJobStateStore(repository);
+
+ assertTrue(stateStore.claimForProcessing(job.getJobId(), Instant.now()).isEmpty());
+ assertEquals(0, repository.saveCount());
+ }
+
+ @Test
+ void markDeadLetteredUpdatesProcessingJob() {
+ CountingRepository repository = new CountingRepository();
+ ConversionJob job = newJob("hash-processing-dead");
+ assertTrue(job.markProcessing("started"));
+ repository.save(job);
+ repository.resetSaveCount();
+ RepositoryBackedConversionJobStateStore stateStore = new RepositoryBackedConversionJobStateStore(repository);
+
+ stateStore.markDeadLettered(job.getJobId(), "dead");
+
+ assertEquals(ConversionJobStatus.FAILED, job.getStatus());
+ assertTrue(job.isDeadLettered());
+ assertEquals(1, repository.saveCount());
+ }
+
@Test
void scheduleRetryMarksSubmittedAndSaves() {
CountingRepository repository = new CountingRepository();
diff --git a/src/test/java/com/clearfolio/viewer/service/DefaultConversionWorkerTest.java b/src/test/java/com/clearfolio/viewer/service/DefaultConversionWorkerTest.java
index 65bbf190..8198ae14 100644
--- a/src/test/java/com/clearfolio/viewer/service/DefaultConversionWorkerTest.java
+++ b/src/test/java/com/clearfolio/viewer/service/DefaultConversionWorkerTest.java
@@ -1,5 +1,6 @@
package com.clearfolio.viewer.service;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -91,6 +92,80 @@ void defaultConversionGeneratesAndStoresPdfArtifact() {
assertEquals("/artifacts/" + jobId + ".pdf", job.getConvertedResourcePath());
}
+ @Test
+ void autowiredConstructorRunsDefaultConversionTask() {
+ InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository();
+ ConversionProperties conversionProperties = new ConversionProperties();
+ conversionProperties.setMaxRetryAttempts(1);
+
+ UUID jobId = UUID.randomUUID();
+ ConversionJob job = new ConversionJob(
+ jobId,
+ "report.docx",
+ "application/octet-stream",
+ "hash-autowired-ctor",
+ 12L,
+ 1
+ );
+ repository.save(job);
+
+ InMemoryArtifactStore artifactStore = new InMemoryArtifactStore();
+ DefaultConversionWorker worker = new DefaultConversionWorker(
+ repository,
+ repository,
+ Runnable::run,
+ artifactStore,
+ new PdfBoxArtifactGenerator(),
+ conversionProperties
+ );
+
+ worker.enqueue(jobId);
+
+ assertEquals(ConversionJobStatus.SUCCEEDED, job.getStatus());
+ byte[] stored = artifactStore.getPdf(jobId).orElseThrow();
+ assertEquals("%PDF", new String(stored, 0, 4));
+ }
+
+ @Test
+ void workerServesSeededPdfPassthroughBytesWithoutGeneratingPlaceholder() {
+ ConversionJobRepository repository = new InMemoryConversionJobRepository();
+ ConversionProperties conversionProperties = new ConversionProperties();
+ conversionProperties.setMaxRetryAttempts(1);
+
+ UUID jobId = UUID.randomUUID();
+ ConversionJob job = new ConversionJob(
+ jobId,
+ "score.pdf",
+ "application/pdf",
+ "hash-pdf-passthrough",
+ 24L,
+ 1
+ );
+ repository.save(job);
+
+ InMemoryArtifactStore artifactStore = new InMemoryArtifactStore();
+ byte[] original = "%PDF-1.7\noriginal-upload".getBytes();
+ artifactStore.putPdf(jobId, original);
+
+ DefaultConversionWorker worker = new DefaultConversionWorker(
+ repository,
+ Runnable::run,
+ artifactStore,
+ seedTarget -> {
+ throw new AssertionError("placeholder generator must not run for PDF passthrough");
+ },
+ conversionProperties
+ );
+
+ worker.enqueue(jobId);
+
+ assertEquals(ConversionJobStatus.SUCCEEDED, job.getStatus());
+ byte[] served = artifactStore.getPdf(jobId).orElseThrow();
+ assertEquals("%PDF-", new String(served, 0, 5));
+ assertArrayEquals(original, served);
+ assertEquals("/artifacts/" + jobId + ".pdf", job.getConvertedResourcePath());
+ }
+
@Test
void workerRoutesSuccessfulLifecycleThroughStateStore() {
InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository();
diff --git a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentConversionServiceTest.java b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentConversionServiceTest.java
index ca51807a..c5276ee3 100644
--- a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentConversionServiceTest.java
+++ b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentConversionServiceTest.java
@@ -1,6 +1,8 @@
package com.clearfolio.viewer.service;
+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.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -34,6 +36,7 @@
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
+import com.clearfolio.viewer.artifact.InMemoryArtifactStore;
import com.clearfolio.viewer.auth.TenantContext;
import com.clearfolio.viewer.auth.TenantPermissions;
import com.clearfolio.viewer.config.ConversionProperties;
@@ -565,6 +568,185 @@ void retryDeadLetteredConcurrentAttemptsAcceptExactlyOneAndEnqueueOnce() throws
assertEquals(job.getJobId(), worker.lastEnqueuedJobId());
}
+ @Test
+ void submitSeedsArtifactStoreWithOriginalBytesForPdfUpload() {
+ InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository();
+ RecordingConversionWorker worker = new RecordingConversionWorker();
+ InMemoryArtifactStore artifactStore = new InMemoryArtifactStore();
+ DocumentConversionService service = new DefaultDocumentConversionService(
+ repository,
+ repository,
+ file -> {
+ },
+ worker,
+ artifactStore,
+ new ConversionProperties()
+ );
+
+ byte[] original = "%PDF-1.7\noriginal-sheet-music".getBytes(StandardCharsets.UTF_8);
+ MockMultipartFile file = new MockMultipartFile(
+ "file",
+ "score.pdf",
+ "application/pdf",
+ original
+ );
+
+ UUID jobId = service.submit(file);
+
+ byte[] seeded = artifactStore.getPdf(jobId).orElseThrow();
+ assertArrayEquals(original, seeded);
+ assertEquals("%PDF-", new String(seeded, 0, 5, StandardCharsets.UTF_8));
+ assertEquals(1, worker.enqueuedCount());
+ }
+
+ @Test
+ void submitSeedsArtifactStoreWhenOnlyExtensionDeclaresPdf() {
+ InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository();
+ RecordingConversionWorker worker = new RecordingConversionWorker();
+ InMemoryArtifactStore artifactStore = new InMemoryArtifactStore();
+ DocumentConversionService service = new DefaultDocumentConversionService(
+ repository,
+ repository,
+ file -> {
+ },
+ worker,
+ artifactStore,
+ new ConversionProperties()
+ );
+
+ byte[] original = "%PDF-1.4\nextension-only".getBytes(StandardCharsets.UTF_8);
+ MockMultipartFile file = new MockMultipartFile(
+ "file",
+ "Score.PDF",
+ "application/octet-stream",
+ original
+ );
+
+ UUID jobId = service.submit(file);
+
+ assertArrayEquals(original, artifactStore.getPdf(jobId).orElseThrow());
+ assertEquals(1, worker.enqueuedCount());
+ }
+
+ @Test
+ void submitSkipsPassthroughWhenPdfDeclarationLacksMagicHeader() {
+ InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository();
+ RecordingConversionWorker worker = new RecordingConversionWorker();
+ InMemoryArtifactStore artifactStore = new InMemoryArtifactStore();
+ DocumentConversionService service = new DefaultDocumentConversionService(
+ repository,
+ repository,
+ file -> {
+ },
+ worker,
+ artifactStore,
+ new ConversionProperties()
+ );
+
+ MockMultipartFile file = new MockMultipartFile(
+ "file",
+ "fake.pdf",
+ "application/pdf",
+ "MZ-not-actually-a-pdf".getBytes(StandardCharsets.UTF_8)
+ );
+
+ UUID jobId = service.submit(file);
+
+ assertTrue(artifactStore.getPdf(jobId).isEmpty());
+ assertEquals(1, worker.enqueuedCount());
+ }
+
+ @Test
+ void submitSkipsPassthroughForNonPdfUpload() {
+ InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository();
+ RecordingConversionWorker worker = new RecordingConversionWorker();
+ InMemoryArtifactStore artifactStore = new InMemoryArtifactStore();
+ DocumentConversionService service = new DefaultDocumentConversionService(
+ repository,
+ repository,
+ file -> {
+ },
+ worker,
+ artifactStore,
+ new ConversionProperties()
+ );
+
+ MockMultipartFile file = new MockMultipartFile(
+ "file",
+ "report.docx",
+ "application/octet-stream",
+ "hello-viewer".getBytes(StandardCharsets.UTF_8)
+ );
+
+ UUID jobId = service.submit(file);
+
+ assertTrue(artifactStore.getPdf(jobId).isEmpty());
+ assertEquals(1, worker.enqueuedCount());
+ }
+
+ @Test
+ void submitSkipsPassthroughWhenSourceBytesCannotBeRead() throws Exception {
+ InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository();
+ RecordingConversionWorker worker = new RecordingConversionWorker();
+ InMemoryArtifactStore artifactStore = new InMemoryArtifactStore();
+ DocumentConversionService service = new DefaultDocumentConversionService(
+ repository,
+ repository,
+ file -> {
+ },
+ worker,
+ artifactStore,
+ new ConversionProperties()
+ );
+
+ MultipartFile file = mock(MultipartFile.class);
+ when(file.getOriginalFilename()).thenReturn("broken.pdf");
+ when(file.getContentType()).thenReturn("application/pdf");
+ when(file.getSize()).thenReturn(5L);
+ when(file.getInputStream())
+ .thenReturn(new ByteArrayInputStream("%PDF-".getBytes(StandardCharsets.UTF_8)));
+ when(file.getBytes()).thenThrow(new IOException("bytes unavailable"));
+
+ UUID jobId = service.submit(file);
+
+ assertTrue(artifactStore.getPdf(jobId).isEmpty());
+ assertEquals(1, worker.enqueuedCount());
+ }
+
+ @Test
+ void declaresPdfSourceMatchesContentTypeAndExtensionVariants() {
+ assertTrue(DefaultDocumentConversionService.declaresPdfSource(null, "application/pdf"));
+ assertTrue(DefaultDocumentConversionService.declaresPdfSource(
+ null,
+ " APPLICATION/PDF; charset=UTF-8 "
+ ));
+ assertTrue(DefaultDocumentConversionService.declaresPdfSource("Score.PDF", null));
+ assertTrue(DefaultDocumentConversionService.declaresPdfSource("score.pdf", "text/plain"));
+ assertFalse(DefaultDocumentConversionService.declaresPdfSource(null, null));
+ assertFalse(DefaultDocumentConversionService.declaresPdfSource(null, "application/pdfx"));
+ assertFalse(DefaultDocumentConversionService.declaresPdfSource(
+ "report.docx",
+ "application/octet-stream"
+ ));
+ }
+
+ @Test
+ void hasPdfMagicHeaderChecksLeadingBytes() {
+ assertTrue(DefaultDocumentConversionService.hasPdfMagicHeader(
+ "%PDF-1.7".getBytes(StandardCharsets.UTF_8)
+ ));
+ assertFalse(DefaultDocumentConversionService.hasPdfMagicHeader(null));
+ assertFalse(DefaultDocumentConversionService.hasPdfMagicHeader(
+ "%PDF".getBytes(StandardCharsets.UTF_8)
+ ));
+ assertFalse(DefaultDocumentConversionService.hasPdfMagicHeader(
+ "XPDF-1.7".getBytes(StandardCharsets.UTF_8)
+ ));
+ assertFalse(DefaultDocumentConversionService.hasPdfMagicHeader(
+ "%PDX-1.7".getBytes(StandardCharsets.UTF_8)
+ ));
+ }
+
@Test
void submitThrowsWhenUploadCannotBeReadForHashing() throws Exception {
ConversionJobRepository repository = new InMemoryConversionJobRepository();