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 cache = new ConcurrentHashMap<>(); + + /** + * Creates a store rooted at the supplied directory, creating it when absent. + * + * @param rootDir directory that holds artifact bytes and metadata files + */ + public FileSystemArtifactStore(Path rootDir) { + this(rootDir, (path, bytes) -> Files.write(path, bytes), Files::readAllBytes); + } + + FileSystemArtifactStore(Path rootDir, BytesWriter bytesWriter, BytesReader bytesReader) { + this.rootDir = rootDir; + this.bytesWriter = bytesWriter; + this.bytesReader = bytesReader; + try { + Files.createDirectories(rootDir); + } catch (IOException ex) { + throw new IllegalStateException("failed to create artifact storage directory: " + rootDir, ex); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void putPdf(UUID docId, byte[] pdfBytes) { + byte[] copy = pdfBytes.clone(); + try { + bytesWriter.write(pdfPath(docId), copy); + bytesWriter.write(metadataPath(docId), metadataBytes(docId, copy)); + } catch (IOException ex) { + throw new IllegalStateException("failed to persist artifact for docId " + docId, ex); + } + cache.put(docId, copy); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional getPdf(UUID docId) { + byte[] cached = cache.get(docId); + if (cached != null) { + return Optional.of(cached.clone()); + } + + Path pdfPath = pdfPath(docId); + if (!Files.exists(pdfPath)) { + return Optional.empty(); + } + + try { + byte[] loaded = bytesReader.read(pdfPath); + cache.put(docId, loaded); + return Optional.of(loaded.clone()); + } catch (IOException ex) { + throw new IllegalStateException("failed to read artifact for docId " + docId, ex); + } + } + + private Path pdfPath(UUID docId) { + return rootDir.resolve(docId + PDF_SUFFIX); + } + + private Path metadataPath(UUID docId) { + return rootDir.resolve(docId + METADATA_SUFFIX); + } + + private static byte[] metadataBytes(UUID docId, byte[] pdfBytes) { + String metadata = "docId=" + docId + "\n" + + "sizeBytes=" + pdfBytes.length + "\n" + + "sha256=" + sha256Hex(pdfBytes) + "\n" + + "storedAt=" + Instant.now() + "\n"; + return metadata.getBytes(StandardCharsets.UTF_8); + } + + private static String sha256Hex(byte[] bytes) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] raw = digest.digest(bytes); + StringBuilder hex = new StringBuilder(raw.length * 2); + for (byte b : raw) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 digest unavailable", ex); + } + } +} diff --git a/src/main/java/com/clearfolio/viewer/artifact/InMemoryArtifactStore.java b/src/main/java/com/clearfolio/viewer/artifact/InMemoryArtifactStore.java index 7bc447b3..d76fd132 100644 --- a/src/main/java/com/clearfolio/viewer/artifact/InMemoryArtifactStore.java +++ b/src/main/java/com/clearfolio/viewer/artifact/InMemoryArtifactStore.java @@ -4,12 +4,13 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import org.springframework.stereotype.Component; - /** * In-memory implementation of {@link ArtifactStore}. + * + *

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 pdfByDocId = new ConcurrentHashMap<>(); diff --git a/src/main/java/com/clearfolio/viewer/config/ArtifactStoreConfig.java b/src/main/java/com/clearfolio/viewer/config/ArtifactStoreConfig.java new file mode 100644 index 00000000..d5f3fde1 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/config/ArtifactStoreConfig.java @@ -0,0 +1,32 @@ +package com.clearfolio.viewer.config; + +import java.nio.file.Path; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.clearfolio.viewer.artifact.ArtifactStore; +import com.clearfolio.viewer.artifact.FileSystemArtifactStore; +import com.clearfolio.viewer.artifact.InMemoryArtifactStore; + +/** + * Configures the artifact store implementation used by conversion and serving. + */ +@Configuration +public class ArtifactStoreConfig { + + /** + * Creates the artifact store selected by configuration; the disk-backed + * store is the default so artifacts survive application restarts. + * + * @param artifactStoreProperties artifact store configuration values + * @return configured artifact store + */ + @Bean + public ArtifactStore artifactStore(ArtifactStoreProperties artifactStoreProperties) { + if (artifactStoreProperties.isInMemoryMode()) { + return new InMemoryArtifactStore(); + } + return new FileSystemArtifactStore(Path.of(artifactStoreProperties.getRootDir())); + } +} diff --git a/src/main/java/com/clearfolio/viewer/config/ArtifactStoreProperties.java b/src/main/java/com/clearfolio/viewer/config/ArtifactStoreProperties.java new file mode 100644 index 00000000..c7c829ae --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/config/ArtifactStoreProperties.java @@ -0,0 +1,84 @@ +package com.clearfolio.viewer.config; + +import java.util.Locale; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration values that control where conversion artifacts are stored. + */ +@ConfigurationProperties(prefix = "clearfolio.artifact-store") +public class ArtifactStoreProperties { + + /** + * Mode value selecting the disk-backed artifact store. + */ + public static final String MODE_FILESYSTEM = "filesystem"; + + /** + * Mode value selecting the in-memory artifact store. + */ + public static final String MODE_IN_MEMORY = "in-memory"; + + private static final String DEFAULT_ROOT_DIR = "data/artifacts"; + + private String mode = MODE_FILESYSTEM; + private String rootDir = DEFAULT_ROOT_DIR; + + /** + * Returns the configured artifact store mode. + * + * @return artifact store mode + */ + public String getMode() { + return mode; + } + + /** + * Sets the artifact store mode; blank or null values fall back to the + * disk-backed default, and any value other than {@link #MODE_IN_MEMORY} + * selects the disk-backed store. + * + * @param mode artifact store mode + */ + public void setMode(String mode) { + String sanitized = sanitize(mode); + this.mode = sanitized.isEmpty() ? MODE_FILESYSTEM : sanitized; + } + + /** + * Returns the root directory used by the disk-backed artifact store. + * + * @return artifact storage root directory + */ + public String getRootDir() { + return rootDir; + } + + /** + * Sets the root directory used by the disk-backed artifact store; blank or + * null values fall back to the default working data directory. + * + * @param rootDir artifact storage root directory + */ + public void setRootDir(String rootDir) { + String sanitized = rootDir == null ? "" : rootDir.replace("\u0000", "").strip(); + this.rootDir = sanitized.isEmpty() ? DEFAULT_ROOT_DIR : sanitized; + } + + /** + * Returns whether the in-memory artifact store mode is selected. + * + * @return true when mode selects the in-memory store + */ + public boolean isInMemoryMode() { + return MODE_IN_MEMORY.equals(mode); + } + + private static String sanitize(String value) { + if (value == null) { + return ""; + } + return value.replace("\u0000", "").strip().toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/com/clearfolio/viewer/service/DefaultConversionWorker.java b/src/main/java/com/clearfolio/viewer/service/DefaultConversionWorker.java index 13873f99..df66f736 100644 --- a/src/main/java/com/clearfolio/viewer/service/DefaultConversionWorker.java +++ b/src/main/java/com/clearfolio/viewer/service/DefaultConversionWorker.java @@ -21,6 +21,11 @@ /** * Default background worker that executes conversion jobs with retry backoff. + * + *

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();