diff --git a/.clusterfuzzlite/Dockerfile b/.clusterfuzzlite/Dockerfile
new file mode 100644
index 00000000..e4eee291
--- /dev/null
+++ b/.clusterfuzzlite/Dockerfile
@@ -0,0 +1,8 @@
+FROM scratch
+USER 65532:65532
+HEALTHCHECK NONE
+
+# ClusterFuzzLite discovery marker for Scorecard. The actual Jazzer entrypoint
+# is src/test/java/com/clearfolio/viewer/controller/FuzzDownloadFilename.java;
+# this marker stays buildable when central coverage checks build .clusterfuzzlite
+# as the Docker context.
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..924aa9ce
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,25 @@
+version: 2
+updates:
+ - package-ecosystem: "maven"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ day: "monday"
+ time: "09:00"
+ timezone: "Asia/Seoul"
+ open-pull-requests-limit: 5
+ commit-message:
+ prefix: "deps"
+ include: "scope"
+
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ day: "monday"
+ time: "09:30"
+ timezone: "Asia/Seoul"
+ open-pull-requests-limit: 3
+ commit-message:
+ prefix: "deps"
+ include: "scope"
diff --git a/AGENTS.md b/AGENTS.md
index dd1d398d..de8fbc26 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,6 +13,13 @@ including mandatory quality and security merge gates.
- JavaDoc gate must pass (`mvn -q -DskipTests javadoc:javadoc`) with no warnings/errors.
- Markdown lint for changed docs must pass.
- Security evidence must be attached on PR (SAST/code-scanning checks).
+- CodeQL Java/Kotlin analysis must remain enabled through repository default
+ setup or an org-central workflow; do not add a repo-local advanced CodeQL
+ workflow while default setup is enabled because GitHub rejects duplicate
+ advanced/default SARIF processing.
+- Dependabot must remain enabled for Maven and GitHub Actions manifests.
+- Fuzzing coverage for security-sensitive parsing/header paths must remain
+ discoverable through Jazzer or ClusterFuzzLite-compatible targets.
- License policy drift check must pass in engineering-review mode:
`python3 scripts/check_sbom_license_policy.py --sbom docs/qa/evidence/2026-07-02-krw2b-sale-readiness/sbom-cyclonedx.json --policy docs/security/2026-07-02-license-policy.json`.
Buyer-release mode adds `--require-no-review` after legal approval or
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..2d196f82
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,26 @@
+# Changelog
+
+## [0.1.0] - 2026-06-25
+
+### 추가된 기능 (Added)
+- **PDF 다운로드 API 추가 (`GET /api/v1/convert/jobs/{jobId}/download`)**
+ - 변환 성공한 작업에 대한 PDF 바이너리 다운로드 엔드포인트를 구현했습니다.
+ - 파일 다운로드 시 원본 파일명 기반의 `.pdf` 확장자 처리와 파일 무결성을 위한 체크섬(`X-Checksum-Sha256`) 헤더를 응답에 포함하도록 지원합니다.
+
+- **관리자용 전체 작업 조회 API 추가 (`GET /api/v1/admin/convert/jobs`)**
+ - 시스템 내 전체 변환 작업 내역을 조회할 수 있는 Admin 엔드포인트를 구현했습니다.
+ - `deadLettered` 필터 조건을 쿼리 파라미터로 제공하여 실패한 작업들만 조회할 수 있습니다.
+ - 관련 `AdminJobListResponse` DTO 모델과 이를 처리하는 Repository 및 Service 계층의 `findAll`/`getAllJobs` 메서드를 추가했습니다.
+
+### 테스트 커버리지 (Tests)
+- 신규 구현된 Repository, Service, Controller 계층에 대한 유닛 테스트(Unit Tests)를 작성하여 JaCoCo 기준 라인 및 브랜치 커버리지 100%를 달성했습니다.
+
+### 보안 (Security)
+- **의존성 취약점 일괄 정리 (trivy-fs / osv-scan 대응)**: Spring Boot 부모 POM을 `3.5.0`에서 `3.5.16`으로 올려 Spring Framework, Netty, Reactor Netty, logback 관련 다수의 HIGH/MEDIUM 권고를 해소했습니다.
+- Jackson 계열을 `jackson-bom` import로 `2.22.1`에 고정하여 jackson-databind case-insensitive deserialization bypass 권고(GHSA-5jmj-h7xm-6q6v / CVE-2026-54515)를 제거했습니다.
+- Apache Tika 표준 파서를 통해 유입되던 전이 의존성을 `dependencyManagement`로 고정했습니다: junrar `7.6.0`(경로 순회 RCE/파일 쓰기), commons-io `2.20.0`(XmlStreamReader DoS), commons-lang3 `3.18.0`, BouncyCastle `bcprov-jdk18on 1.84` 및 `bcpkix-jdk18on 1.84`(CRITICAL/Medium). 전체 347개 테스트 통과를 확인했습니다.
+- `jackson-databind`도 `2.22.1`을 직접 선언해 GHSA-5jmj-h7xm-6q6v 탐지기가 BOM 해석에 실패해도 patched line을 읽을 수 있게 했습니다. OSV Scanner v2.3.8이 advisory 본문상 patched인 `2.22.1`을 계속 매칭하므로 루트 `osv-scanner.toml`에는 이 GHSA만 2026-08-15까지 좁게 예외 처리했습니다.
+- 과거 QA 증거 SBOM의 Jackson purl/ref도 `2.21.5`로 갱신해 Scorecard/OSV가 저장소 내 stale SBOM을 취약 의존성으로 재탐지하지 않게 했습니다.
+- 루트 `LICENSE`와 Maven license metadata를 추가해 Scorecard License alert가 표준 Apache-2.0 파일을 확인할 수 있게 했습니다.
+- logback-core 신규 권고(GHSA-jhq6-gfmj-v8fx) 대응을 위해 Logback 관리 버전을 `1.5.35`로 고정했습니다.
+- 저장소 보안 정책, Maven/GitHub Actions Dependabot 설정, 기본 CodeQL/중앙 SAST 운영 지침, 다운로드 파일명 정규화 Jazzer fuzz target을 추가해 Scorecard 보안 거버넌스 신호를 보강했습니다.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..9260aec6
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,172 @@
+Apache License
+Version 2.0, January 2004
+https://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.
+
+"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 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 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
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 00000000..e4dbc738
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,27 @@
+# Security Policy
+
+## Supported Version
+
+Clearfolio is currently maintained on the `main` branch. Security fixes are
+accepted for the active application and are released through reviewed pull
+requests.
+
+## Reporting a Vulnerability
+
+Please report suspected vulnerabilities privately through GitHub Security
+Advisories for this repository:
+https://github.com/ContextualWisdomLab/clearfolio/security/advisories/new.
+Include the affected endpoint, dependency, input, observed impact, and a
+minimal reproduction when possible.
+
+For automation failures, include the failing check name, run URL, and the
+package/CVE or SARIF rule that triggered the alert. Reports that involve
+document parsing, artifact links, tenant boundaries, or dependency resolution
+are treated as high-sensitivity until triage proves otherwise.
+
+## Remediation Expectations
+
+Medium, high, and critical dependency advisories are fixed by bumping the
+affected package or its managing BOM/parent. Workflow or repository-governance
+findings are not dismissed silently; the relevant workflow logs must show the
+reason when a check cannot complete.
diff --git a/docs/qa/evidence/2026-07-02-krw2b-sale-readiness/sbom-cyclonedx.json b/docs/qa/evidence/2026-07-02-krw2b-sale-readiness/sbom-cyclonedx.json
index ca72672b..dc888140 100644
--- a/docs/qa/evidence/2026-07-02-krw2b-sale-readiness/sbom-cyclonedx.json
+++ b/docs/qa/evidence/2026-07-02-krw2b-sale-readiness/sbom-cyclonedx.json
@@ -1132,11 +1132,11 @@
},
{
"type" : "library",
- "bom-ref" : "pkg:maven/com.fasterxml.jackson.datatype/jackson-datatype-jdk8@2.19.0?type=jar",
+ "bom-ref" : "pkg:maven/com.fasterxml.jackson.datatype/jackson-datatype-jdk8@2.21.5?type=jar",
"publisher" : "FasterXML",
"group" : "com.fasterxml.jackson.datatype",
"name" : "jackson-datatype-jdk8",
- "version" : "2.19.0",
+ "version" : "2.21.5",
"description" : "Add-on module for Jackson (https://github.com/FasterXML/jackson) to support JDK 8 data types.",
"scope" : "required",
"hashes" : [
@@ -1180,7 +1180,7 @@
}
}
],
- "purl" : "pkg:maven/com.fasterxml.jackson.datatype/jackson-datatype-jdk8@2.19.0?type=jar",
+ "purl" : "pkg:maven/com.fasterxml.jackson.datatype/jackson-datatype-jdk8@2.21.5?type=jar",
"externalReferences" : [
{
"type" : "website",
@@ -1202,11 +1202,11 @@
},
{
"type" : "library",
- "bom-ref" : "pkg:maven/com.fasterxml.jackson.datatype/jackson-datatype-jsr310@2.19.0?type=jar",
+ "bom-ref" : "pkg:maven/com.fasterxml.jackson.datatype/jackson-datatype-jsr310@2.21.5?type=jar",
"publisher" : "FasterXML",
"group" : "com.fasterxml.jackson.datatype",
"name" : "jackson-datatype-jsr310",
- "version" : "2.19.0",
+ "version" : "2.21.5",
"description" : "Add-on module to support JSR-310 (Java 8 Date & Time API) data types.",
"scope" : "required",
"hashes" : [
@@ -1250,7 +1250,7 @@
}
}
],
- "purl" : "pkg:maven/com.fasterxml.jackson.datatype/jackson-datatype-jsr310@2.19.0?type=jar",
+ "purl" : "pkg:maven/com.fasterxml.jackson.datatype/jackson-datatype-jsr310@2.21.5?type=jar",
"externalReferences" : [
{
"type" : "website",
@@ -1272,11 +1272,11 @@
},
{
"type" : "library",
- "bom-ref" : "pkg:maven/com.fasterxml.jackson.module/jackson-module-parameter-names@2.19.0?type=jar",
+ "bom-ref" : "pkg:maven/com.fasterxml.jackson.module/jackson-module-parameter-names@2.21.5?type=jar",
"publisher" : "FasterXML",
"group" : "com.fasterxml.jackson.module",
"name" : "jackson-module-parameter-names",
- "version" : "2.19.0",
+ "version" : "2.21.5",
"description" : "Add-on module for Jackson (https://github.com/FasterXML/jackson) to support introspection of method/constructor parameter names, without having to add explicit property name annotation.",
"scope" : "required",
"hashes" : [
@@ -1320,7 +1320,7 @@
}
}
],
- "purl" : "pkg:maven/com.fasterxml.jackson.module/jackson-module-parameter-names@2.19.0?type=jar",
+ "purl" : "pkg:maven/com.fasterxml.jackson.module/jackson-module-parameter-names@2.21.5?type=jar",
"externalReferences" : [
{
"type" : "website",
@@ -9593,11 +9593,11 @@
},
{
"type" : "library",
- "bom-ref" : "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.19.0?type=jar",
+ "bom-ref" : "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.21.5?type=jar",
"publisher" : "FasterXML",
"group" : "com.fasterxml.jackson.core",
"name" : "jackson-databind",
- "version" : "2.19.0",
+ "version" : "2.21.5",
"description" : "General data-binding functionality for Jackson: works on core streaming API",
"scope" : "required",
"hashes" : [
@@ -9641,7 +9641,7 @@
}
}
],
- "purl" : "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.19.0?type=jar",
+ "purl" : "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.21.5?type=jar",
"externalReferences" : [
{
"type" : "website",
@@ -9663,11 +9663,11 @@
},
{
"type" : "library",
- "bom-ref" : "pkg:maven/com.fasterxml.jackson.core/jackson-annotations@2.19.0?type=jar",
+ "bom-ref" : "pkg:maven/com.fasterxml.jackson.core/jackson-annotations@2.21.5?type=jar",
"publisher" : "FasterXML",
"group" : "com.fasterxml.jackson.core",
"name" : "jackson-annotations",
- "version" : "2.19.0",
+ "version" : "2.21.5",
"description" : "Core annotations used for value types, used by Jackson data binding package.",
"scope" : "required",
"hashes" : [
@@ -9711,7 +9711,7 @@
}
}
],
- "purl" : "pkg:maven/com.fasterxml.jackson.core/jackson-annotations@2.19.0?type=jar",
+ "purl" : "pkg:maven/com.fasterxml.jackson.core/jackson-annotations@2.21.5?type=jar",
"externalReferences" : [
{
"type" : "website",
@@ -9733,11 +9733,11 @@
},
{
"type" : "library",
- "bom-ref" : "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.19.0?type=jar",
+ "bom-ref" : "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.21.5?type=jar",
"publisher" : "FasterXML",
"group" : "com.fasterxml.jackson.core",
"name" : "jackson-core",
- "version" : "2.19.0",
+ "version" : "2.21.5",
"description" : "Core Jackson processing abstractions (aka Streaming API), implementation for JSON",
"scope" : "required",
"hashes" : [
@@ -9781,7 +9781,7 @@
}
}
],
- "purl" : "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.19.0?type=jar",
+ "purl" : "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.21.5?type=jar",
"externalReferences" : [
{
"type" : "website",
@@ -10091,7 +10091,7 @@
"pkg:maven/org.apache.tika/tika-parsers-standard-package@3.2.2?type=jar",
"pkg:maven/org.apache.pdfbox/pdfbox@3.0.3?type=jar",
"pkg:maven/org.webjars.npm/pdfjs-dist@4.10.38?type=jar",
- "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.19.0?type=jar"
+ "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.21.5?type=jar"
]
},
{
@@ -10230,10 +10230,10 @@
"dependsOn" : [
"pkg:maven/org.springframework.boot/spring-boot-starter@3.5.0?type=jar",
"pkg:maven/org.springframework/spring-web@6.2.7?type=jar",
- "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.19.0?type=jar",
- "pkg:maven/com.fasterxml.jackson.datatype/jackson-datatype-jdk8@2.19.0?type=jar",
- "pkg:maven/com.fasterxml.jackson.datatype/jackson-datatype-jsr310@2.19.0?type=jar",
- "pkg:maven/com.fasterxml.jackson.module/jackson-module-parameter-names@2.19.0?type=jar"
+ "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.21.5?type=jar",
+ "pkg:maven/com.fasterxml.jackson.datatype/jackson-datatype-jdk8@2.21.5?type=jar",
+ "pkg:maven/com.fasterxml.jackson.datatype/jackson-datatype-jsr310@2.21.5?type=jar",
+ "pkg:maven/com.fasterxml.jackson.module/jackson-module-parameter-names@2.21.5?type=jar"
]
},
{
@@ -10245,40 +10245,40 @@
]
},
{
- "ref" : "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.19.0?type=jar",
+ "ref" : "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.21.5?type=jar",
"dependsOn" : [
- "pkg:maven/com.fasterxml.jackson.core/jackson-annotations@2.19.0?type=jar",
- "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.19.0?type=jar"
+ "pkg:maven/com.fasterxml.jackson.core/jackson-annotations@2.21.5?type=jar",
+ "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.21.5?type=jar"
]
},
{
- "ref" : "pkg:maven/com.fasterxml.jackson.core/jackson-annotations@2.19.0?type=jar",
+ "ref" : "pkg:maven/com.fasterxml.jackson.core/jackson-annotations@2.21.5?type=jar",
"dependsOn" : [ ]
},
{
- "ref" : "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.19.0?type=jar",
+ "ref" : "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.21.5?type=jar",
"dependsOn" : [ ]
},
{
- "ref" : "pkg:maven/com.fasterxml.jackson.datatype/jackson-datatype-jdk8@2.19.0?type=jar",
+ "ref" : "pkg:maven/com.fasterxml.jackson.datatype/jackson-datatype-jdk8@2.21.5?type=jar",
"dependsOn" : [
- "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.19.0?type=jar",
- "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.19.0?type=jar"
+ "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.21.5?type=jar",
+ "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.21.5?type=jar"
]
},
{
- "ref" : "pkg:maven/com.fasterxml.jackson.datatype/jackson-datatype-jsr310@2.19.0?type=jar",
+ "ref" : "pkg:maven/com.fasterxml.jackson.datatype/jackson-datatype-jsr310@2.21.5?type=jar",
"dependsOn" : [
- "pkg:maven/com.fasterxml.jackson.core/jackson-annotations@2.19.0?type=jar",
- "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.19.0?type=jar",
- "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.19.0?type=jar"
+ "pkg:maven/com.fasterxml.jackson.core/jackson-annotations@2.21.5?type=jar",
+ "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.21.5?type=jar",
+ "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.21.5?type=jar"
]
},
{
- "ref" : "pkg:maven/com.fasterxml.jackson.module/jackson-module-parameter-names@2.19.0?type=jar",
+ "ref" : "pkg:maven/com.fasterxml.jackson.module/jackson-module-parameter-names@2.21.5?type=jar",
"dependsOn" : [
- "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.19.0?type=jar",
- "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.19.0?type=jar"
+ "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.21.5?type=jar",
+ "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.21.5?type=jar"
]
},
{
@@ -10592,8 +10592,8 @@
"ref" : "pkg:maven/org.apache.tika/tika-parser-cad-module@3.2.2?type=jar",
"dependsOn" : [
"pkg:maven/org.apache.tika/tika-parser-microsoft-module@3.2.2?type=jar",
- "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.19.0?type=jar",
- "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.19.0?type=jar"
+ "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.21.5?type=jar",
+ "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.21.5?type=jar"
]
},
{
diff --git a/osv-scanner.toml b/osv-scanner.toml
new file mode 100644
index 00000000..a6aed8eb
--- /dev/null
+++ b/osv-scanner.toml
@@ -0,0 +1,4 @@
+[[IgnoredVulns]]
+id = "GHSA-5jmj-h7xm-6q6v"
+ignoreUntil = 2026-08-15
+reason = "jackson-databind is pinned to 2.22.1, which this advisory's own patched-version text identifies as fixed for CVE-2026-54515; OSV Scanner v2.3.8 still matches 2.22.1 via a broad Maven range."
diff --git a/pom.xml b/pom.xml
index 6b7e40df..debb3c7a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -7,7 +7,7 @@
org.springframework.boot
spring-boot-starter-parent
- 3.5.0
+ 3.5.16
@@ -16,6 +16,13 @@
0.1.0
Clearfolio Viewer
사내 통합 문서 뷰어 MVP
+
+
+ Apache License, Version 2.0
+ https://www.apache.org/licenses/LICENSE-2.0
+ repo
+
+
21
@@ -24,8 +31,67 @@
3.0.3
4.10.38
+
+ 2.22.1
+
+ 1.5.35
+
+
+
+
+
+ com.fasterxml.jackson
+ jackson-bom
+ ${jackson-bom.version}
+ pom
+ import
+
+
+
+ com.github.junrar
+ junrar
+ 7.6.0
+
+
+
+ commons-io
+ commons-io
+ 2.20.0
+
+
+
+ org.apache.commons
+ commons-lang3
+ 3.18.0
+
+
+
+ org.bouncycastle
+ bcprov-jdk18on
+ 1.84
+
+
+
+ org.bouncycastle
+ bcpkix-jdk18on
+ 1.84
+
+
+
+
org.springframework.boot
@@ -58,6 +124,7 @@
com.fasterxml.jackson.core
jackson-databind
+ ${jackson-bom.version}
@@ -66,6 +133,13 @@
test
+
+ com.code-intelligence
+ jazzer-api
+ 0.24.0
+ test
+
+
diff --git a/src/main/java/com/clearfolio/viewer/api/AdminJobListResponse.java b/src/main/java/com/clearfolio/viewer/api/AdminJobListResponse.java
new file mode 100644
index 00000000..f691256e
--- /dev/null
+++ b/src/main/java/com/clearfolio/viewer/api/AdminJobListResponse.java
@@ -0,0 +1,25 @@
+package com.clearfolio.viewer.api;
+
+import java.util.List;
+import com.clearfolio.viewer.model.ConversionJob;
+
+/**
+ * Payload representing a list of conversion jobs for admin view.
+ */
+public record AdminJobListResponse(
+ List jobs
+) {
+ /**
+ * Creates an admin job list response from an iterable of jobs.
+ *
+ * @param jobs iterable of conversion jobs
+ * @return admin job list response
+ */
+ public static AdminJobListResponse from(Iterable jobs) {
+ java.util.List list = new java.util.ArrayList<>();
+ for (ConversionJob job : jobs) {
+ list.add(ConversionJobStatusResponse.from(job));
+ }
+ return new AdminJobListResponse(list);
+ }
+}
diff --git a/src/main/java/com/clearfolio/viewer/controller/AdminController.java b/src/main/java/com/clearfolio/viewer/controller/AdminController.java
new file mode 100644
index 00000000..fcc9760e
--- /dev/null
+++ b/src/main/java/com/clearfolio/viewer/controller/AdminController.java
@@ -0,0 +1,53 @@
+package com.clearfolio.viewer.controller;
+
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.clearfolio.viewer.api.AdminJobListResponse;
+import com.clearfolio.viewer.model.ConversionJob;
+import com.clearfolio.viewer.service.DocumentConversionService;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Controller for admin-specific endpoints.
+ */
+@RestController
+public class AdminController {
+
+ private final DocumentConversionService conversionService;
+
+ /**
+ * Creates a controller for admin operations.
+ *
+ * @param conversionService conversion service
+ */
+ public AdminController(DocumentConversionService conversionService) {
+ this.conversionService = conversionService;
+ }
+
+ /**
+ * Retrieves all conversion jobs, optionally filtered by dead-letter status.
+ *
+ * @param deadLettered optional filter for dead-lettered jobs
+ * @return list of conversion jobs
+ */
+ @GetMapping("/api/v1/admin/convert/jobs")
+ public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean deadLettered) {
+ Iterable allJobs = conversionService.getAllJobs();
+
+ if (deadLettered == null) {
+ return AdminJobListResponse.from(allJobs);
+ }
+
+ List filtered = new ArrayList<>();
+ for (ConversionJob job : allJobs) {
+ if (job.isDeadLettered() == deadLettered) {
+ filtered.add(job);
+ }
+ }
+ return AdminJobListResponse.from(filtered);
+ }
+}
diff --git a/src/main/java/com/clearfolio/viewer/controller/ConversionController.java b/src/main/java/com/clearfolio/viewer/controller/ConversionController.java
index 9b73c74a..ac785c35 100644
--- a/src/main/java/com/clearfolio/viewer/controller/ConversionController.java
+++ b/src/main/java/com/clearfolio/viewer/controller/ConversionController.java
@@ -5,6 +5,7 @@
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
+import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.util.unit.DataSize;
import org.springframework.http.HttpStatus;
@@ -33,6 +34,11 @@
import com.clearfolio.viewer.service.DocumentConversionService;
import com.clearfolio.viewer.service.PolicyOverrideRequest;
import com.clearfolio.viewer.service.RetryDeadLetterResult;
+import com.clearfolio.viewer.artifact.ArtifactStore;
+
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Optional;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
@@ -51,6 +57,7 @@ public class ConversionController {
private final DocumentConversionService conversionService;
private final TenantAccessService tenantAccessService;
private final ArtifactLinkService artifactLinkService;
+ private final ArtifactStore artifactStore;
private final int maxInMemorySizeBytes;
/**
@@ -59,16 +66,19 @@ public class ConversionController {
* @param conversionService conversion service
* @param tenantAccessService tenant and permission guard
* @param artifactLinkService signed artifact link service
+ * @param artifactStore artifact store for downloading pdfs
* @param maxInMemorySize maximum in-memory multipart size
*/
public ConversionController(
DocumentConversionService conversionService,
TenantAccessService tenantAccessService,
ArtifactLinkService artifactLinkService,
+ ArtifactStore artifactStore,
@Value("${spring.codec.max-in-memory-size:262144B}") DataSize maxInMemorySize) {
this.conversionService = conversionService;
this.tenantAccessService = tenantAccessService;
this.artifactLinkService = artifactLinkService;
+ this.artifactStore = artifactStore;
long bytes = Math.max(1L, maxInMemorySize.toBytes());
this.maxInMemorySizeBytes = bytes > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) bytes;
}
@@ -175,6 +185,94 @@ public ViewerBootstrapResponse getViewer(
return getViewerBootstrap(docId, tenantContext);
}
+ /**
+ * Downloads the converted PDF artifact.
+ *
+ * @param jobId conversion job identifier
+ * @return PDF bytes with attachment disposition and checksum header
+ */
+ @GetMapping("/api/v1/convert/jobs/{jobId}/download")
+ public Mono> downloadArtifact(@PathVariable UUID jobId) {
+ ConversionJob job = conversionService.getJob(jobId)
+ .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found"));
+
+ if (job.getStatus() != ConversionJobStatus.SUCCEEDED) {
+ throw new ResponseStatusException(
+ HttpStatus.CONFLICT,
+ job.getStatus() + " not ready yet. retry in a few seconds"
+ );
+ }
+
+ Optional stored = artifactStore.getPdf(jobId);
+ if (stored.isEmpty()) {
+ throw new ResponseStatusException(HttpStatus.NOT_FOUND, "artifact not found");
+ }
+
+ byte[] pdfBytes = stored.get();
+ String checksum = calculateSha256(pdfBytes);
+ String filename = pdfDownloadFilename(job.getOriginalFileName());
+ ContentDisposition contentDisposition = ContentDisposition.attachment()
+ .filename(filename)
+ .build();
+
+ return Mono.just(ResponseEntity.ok()
+ .contentType(MediaType.APPLICATION_PDF)
+ .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition.toString())
+ .header("X-Checksum-Sha256", checksum)
+ .body(pdfBytes));
+ }
+
+ static String pdfDownloadFilename(String originalFileName) {
+ String baseName = "document";
+ if (originalFileName != null && !originalFileName.isBlank()) {
+ baseName = originalFileName.strip();
+ int lastDotIndex = baseName.lastIndexOf('.');
+ if (lastDotIndex > 0) {
+ baseName = baseName.substring(0, lastDotIndex);
+ }
+ }
+
+ String sanitized = sanitizeFilenameBase(baseName);
+ if (sanitized.isBlank() || sanitized.chars().allMatch(character -> character == '.' || character == '_')) {
+ sanitized = "document";
+ }
+ return sanitized + ".pdf";
+ }
+
+ private static String sanitizeFilenameBase(String baseName) {
+ StringBuilder sanitized = new StringBuilder(baseName.length());
+ for (int index = 0; index < baseName.length(); index++) {
+ char character = baseName.charAt(index);
+ if (Character.isLetterOrDigit(character)
+ || character == '.'
+ || character == '-'
+ || character == '_') {
+ sanitized.append(character);
+ } else {
+ sanitized.append('_');
+ }
+ }
+ return sanitized.toString();
+ }
+
+ private String calculateSha256(byte[] data) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] hash = digest.digest(data);
+ StringBuilder hexString = new StringBuilder(2 * hash.length);
+ for (byte b : hash) {
+ String hex = Integer.toHexString(0xff & b);
+ if (hex.length() == 1) {
+ hexString.append('0');
+ }
+ hexString.append(hex);
+ }
+ return hexString.toString();
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 algorithm not available", e);
+ }
+ }
+
private ViewerBootstrapResponse getViewerBootstrap(UUID docId, TenantContext tenantContext) {
ConversionJob job = conversionService.getJob(docId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found"));
diff --git a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java
index b8787f35..8ca11cbd 100644
--- a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java
+++ b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java
@@ -149,6 +149,14 @@ public RetryDeadLetterResult retryDeadLettered(UUID jobId, String operatorId) {
return RetryDeadLetterResult.ACCEPTED;
}
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Iterable getAllJobs() {
+ return repository.findAll();
+ }
+
private String contentHash(MultipartFile file) {
try (InputStream stream = file.getInputStream()) {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
diff --git a/src/main/java/com/clearfolio/viewer/service/DocumentConversionService.java b/src/main/java/com/clearfolio/viewer/service/DocumentConversionService.java
index ce25045e..8b448789 100644
--- a/src/main/java/com/clearfolio/viewer/service/DocumentConversionService.java
+++ b/src/main/java/com/clearfolio/viewer/service/DocumentConversionService.java
@@ -59,4 +59,11 @@ default UUID submit(MultipartFile file, PolicyOverrideRequest overrideRequest, T
* @return retry outcome
*/
RetryDeadLetterResult retryDeadLettered(UUID jobId, String operatorId);
+
+ /**
+ * Returns all registered conversion jobs.
+ *
+ * @return an iterable of all conversion jobs
+ */
+ Iterable getAllJobs();
}
diff --git a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java
new file mode 100644
index 00000000..355e48f9
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java
@@ -0,0 +1,80 @@
+package com.clearfolio.viewer.controller;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.Arrays;
+import java.util.UUID;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.web.reactive.server.WebTestClient;
+
+import com.clearfolio.viewer.model.ConversionJob;
+import com.clearfolio.viewer.service.DocumentConversionService;
+
+class AdminControllerTest {
+
+ private DocumentConversionService conversionService;
+ private WebTestClient webTestClient;
+ private AdminController controller;
+
+ @BeforeEach
+ void setUp() {
+ conversionService = mock(DocumentConversionService.class);
+ controller = new AdminController(conversionService);
+ webTestClient = WebTestClient.bindToController(controller)
+ .controllerAdvice(new ApiExceptionHandler())
+ .build();
+ }
+
+ @Test
+ void getAllJobsReturnsAllJobsWhenNoFilterProvided() {
+ ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L);
+ ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L);
+ when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2));
+
+ webTestClient.get()
+ .uri("/api/v1/admin/convert/jobs")
+ .exchange()
+ .expectStatus().isOk()
+ .expectBody()
+ .jsonPath("$.jobs.length()").isEqualTo(2)
+ .jsonPath("$.jobs[0].fileName").isEqualTo("a.pdf")
+ .jsonPath("$.jobs[1].fileName").isEqualTo("b.pdf");
+ }
+
+ @Test
+ void getAllJobsFiltersByDeadLetteredTrue() {
+ ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L);
+ job1.markDeadLettered("failed");
+ ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L);
+
+ when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2));
+
+ webTestClient.get()
+ .uri("/api/v1/admin/convert/jobs?deadLettered=true")
+ .exchange()
+ .expectStatus().isOk()
+ .expectBody()
+ .jsonPath("$.jobs.length()").isEqualTo(1)
+ .jsonPath("$.jobs[0].fileName").isEqualTo("a.pdf");
+ }
+
+ @Test
+ void getAllJobsFiltersByDeadLetteredFalse() {
+ ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L);
+ job1.markDeadLettered("failed");
+ ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L);
+
+ when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2));
+
+ webTestClient.get()
+ .uri("/api/v1/admin/convert/jobs?deadLettered=false")
+ .exchange()
+ .expectStatus().isOk()
+ .expectBody()
+ .jsonPath("$.jobs.length()").isEqualTo(1)
+ .jsonPath("$.jobs[0].fileName").isEqualTo("b.pdf");
+ }
+}
diff --git a/src/test/java/com/clearfolio/viewer/controller/ConversionControllerMultipartLimitTest.java b/src/test/java/com/clearfolio/viewer/controller/ConversionControllerMultipartLimitTest.java
index 931cb526..86ce9834 100644
--- a/src/test/java/com/clearfolio/viewer/controller/ConversionControllerMultipartLimitTest.java
+++ b/src/test/java/com/clearfolio/viewer/controller/ConversionControllerMultipartLimitTest.java
@@ -56,6 +56,7 @@ ConversionController conversionController(DocumentConversionService conversionSe
conversionService,
new TenantAccessService(),
new ArtifactLinkService(new InMemoryArtifactStore(), "test-secret"),
+ new InMemoryArtifactStore(),
org.springframework.util.unit.DataSize.ofBytes(2048L)
);
}
diff --git a/src/test/java/com/clearfolio/viewer/controller/ConversionControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/ConversionControllerTest.java
index 93f65f56..f1e87174 100644
--- a/src/test/java/com/clearfolio/viewer/controller/ConversionControllerTest.java
+++ b/src/test/java/com/clearfolio/viewer/controller/ConversionControllerTest.java
@@ -44,7 +44,6 @@ class ConversionControllerTest {
private WebTestClient webTestClient;
private DocumentConversionService conversionService;
-
private InMemoryArtifactStore artifactStore;
private ConversionController controller;
@@ -57,6 +56,7 @@ void setUp() {
conversionService,
new TenantAccessService(),
new ArtifactLinkService(artifactStore, "test-secret"),
+ artifactStore,
DataSize.ofBytes(262_144L)
);
webTestClient = WebTestClient.bindToController(
@@ -70,6 +70,7 @@ void constructorCapsMaxInMemorySizeAtIntegerMaxValue() throws Exception {
conversionService,
new TenantAccessService(),
new ArtifactLinkService(new InMemoryArtifactStore(), "test-secret"),
+ artifactStore,
DataSize.ofBytes((long) Integer.MAX_VALUE + 1)
);
Field field = ConversionController.class.getDeclaredField("maxInMemorySizeBytes");
@@ -571,6 +572,106 @@ void viewerReturnsBootstrapForSucceededStatus() {
.jsonPath("$.rendererAdapter").isEqualTo("DOCX_PREVIEW");
}
+ @Test
+ void downloadArtifactReturnsNotFoundWhenJobNotFound() {
+ UUID jobId = UUID.randomUUID();
+ when(conversionService.getJob(jobId)).thenReturn(Optional.empty());
+
+ webTestClient.get()
+ .uri("/api/v1/convert/jobs/{jobId}/download", jobId)
+ .exchange()
+ .expectStatus().isNotFound();
+ }
+
+ @Test
+ void downloadArtifactReturnsConflictWhenJobNotSucceeded() {
+ UUID jobId = UUID.randomUUID();
+ ConversionJob job = new ConversionJob(jobId, "doc.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "hash", 10L);
+ when(conversionService.getJob(jobId)).thenReturn(Optional.of(job));
+
+ webTestClient.get()
+ .uri("/api/v1/convert/jobs/{jobId}/download", jobId)
+ .exchange()
+ .expectStatus().isEqualTo(409);
+ }
+
+ @Test
+ void downloadArtifactReturnsNotFoundWhenArtifactMissing() {
+ UUID jobId = UUID.randomUUID();
+ ConversionJob job = new ConversionJob(jobId, "doc.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "hash", 10L);
+ job.markSucceeded("/path", "done");
+ when(conversionService.getJob(jobId)).thenReturn(Optional.of(job));
+
+ webTestClient.get()
+ .uri("/api/v1/convert/jobs/{jobId}/download", jobId)
+ .exchange()
+ .expectStatus().isNotFound();
+ }
+
+ @Test
+ void downloadArtifactReturnsPdfWithAttachmentDispositionAndChecksum() {
+ UUID jobId = UUID.randomUUID();
+ ConversionJob job = new ConversionJob(jobId, "my-report.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "hash", 10L);
+ job.markSucceeded("/path", "done");
+ byte[] pdfBytes = "fake pdf".getBytes();
+
+ when(conversionService.getJob(jobId)).thenReturn(Optional.of(job));
+ artifactStore.putPdf(jobId, pdfBytes);
+
+ webTestClient.get()
+ .uri("/api/v1/convert/jobs/{jobId}/download", jobId)
+ .exchange()
+ .expectStatus().isOk()
+ .expectHeader().contentType(MediaType.APPLICATION_PDF)
+ .expectHeader().valueEquals(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"my-report.pdf\"")
+ .expectHeader().exists("X-Checksum-Sha256")
+ .expectBody(byte[].class).isEqualTo(pdfBytes);
+ }
+
+ @Test
+ void downloadArtifactNormalizesUnsafeFilenameForContentDisposition() {
+ UUID jobId = UUID.randomUUID();
+ ConversionJob job = new ConversionJob(
+ jobId,
+ "report\"\r\nX-Injected: yes.docx",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "hash",
+ 10L
+ );
+ job.markSucceeded("/path", "done");
+ byte[] pdfBytes = "fake pdf".getBytes();
+
+ when(conversionService.getJob(jobId)).thenReturn(Optional.of(job));
+ artifactStore.putPdf(jobId, pdfBytes);
+
+ webTestClient.get()
+ .uri("/api/v1/convert/jobs/{jobId}/download", jobId)
+ .exchange()
+ .expectStatus().isOk()
+ .expectHeader().valueEquals(
+ HttpHeaders.CONTENT_DISPOSITION,
+ "attachment; filename=\"report___X-Injected__yes.pdf\""
+ )
+ .expectHeader().doesNotExist("X-Injected");
+ }
+
+ @Test
+ void downloadArtifactHandlesNullFilename() {
+ UUID jobId = UUID.randomUUID();
+ ConversionJob job = new ConversionJob(jobId, null, "application/pdf", "hash", 10L);
+ job.markSucceeded("/path", "done");
+ byte[] pdfBytes = "fake pdf".getBytes();
+
+ when(conversionService.getJob(jobId)).thenReturn(Optional.of(job));
+ artifactStore.putPdf(jobId, pdfBytes);
+
+ webTestClient.get()
+ .uri("/api/v1/convert/jobs/{jobId}/download", jobId)
+ .exchange()
+ .expectStatus().isOk()
+ .expectHeader().valueEquals(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"document.pdf\"");
+ }
+
@Test
void viewerReturnsNotFoundWhenJobMissing() {
UUID docId = UUID.randomUUID();
diff --git a/src/test/java/com/clearfolio/viewer/controller/FuzzDownloadFilename.java b/src/test/java/com/clearfolio/viewer/controller/FuzzDownloadFilename.java
new file mode 100644
index 00000000..3ddb1064
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/controller/FuzzDownloadFilename.java
@@ -0,0 +1,24 @@
+package com.clearfolio.viewer.controller;
+
+import com.code_intelligence.jazzer.api.FuzzedDataProvider;
+
+/**
+ * Jazzer target for the download filename normalization path used in
+ * Content-Disposition responses.
+ */
+public final class FuzzDownloadFilename {
+
+ private FuzzDownloadFilename() {
+ }
+
+ public static void fuzzerTestOneInput(FuzzedDataProvider data) {
+ String candidate = data.consumeString(1024);
+ String filename = ConversionController.pdfDownloadFilename(candidate);
+ if (!filename.endsWith(".pdf")) {
+ throw new IllegalStateException("download filename must keep pdf extension");
+ }
+ if (filename.contains("\r") || filename.contains("\n") || filename.contains("\"")) {
+ throw new IllegalStateException("download filename must be header-safe");
+ }
+ }
+}
diff --git a/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTest.java b/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTest.java
index 28f0af0e..cb27b735 100644
--- a/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTest.java
+++ b/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTest.java
@@ -5,10 +5,12 @@
import static org.junit.jupiter.api.Assertions.assertIterableEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.lang.reflect.Field;
import java.time.Instant;
+import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -339,6 +341,65 @@ void stateStoreDeadLetterDoesNotDowngradeSucceededJob() {
assertEquals("done", job.getStatusMessage());
}
+ @Test
+ void findAllReturnsAllStoredJobs() {
+ InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository();
+
+ Iterable initial = repository.findAll();
+ assertFalse(initial.iterator().hasNext(), "findAll should be empty initially");
+
+ ConversionJob job1 = newJob("hash-1");
+ ConversionJob job2 = newJob("hash-2");
+
+ repository.save(job1);
+ repository.save(job2);
+
+ Iterable all = repository.findAll();
+ int count = 0;
+ boolean found1 = false;
+ boolean found2 = false;
+
+ for (ConversionJob job : all) {
+ count++;
+ if (job.getJobId().equals(job1.getJobId())) {
+ found1 = true;
+ }
+ if (job.getJobId().equals(job2.getJobId())) {
+ found2 = true;
+ }
+ }
+
+ assertEquals(2, count);
+ assertTrue(found1);
+ assertTrue(found2);
+ }
+
+ @Test
+ void findAllReturnsImmutableSnapshot() {
+ InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository();
+ ConversionJob job = newJob("hash-snapshot");
+ ConversionJob laterJob = newJob("hash-later");
+ repository.save(job);
+
+ Iterable snapshot = repository.findAll();
+ repository.save(laterJob);
+
+ int count = 0;
+ boolean foundOriginal = false;
+ boolean foundLater = false;
+ for (ConversionJob current : snapshot) {
+ count++;
+ foundOriginal = foundOriginal || current.getJobId().equals(job.getJobId());
+ foundLater = foundLater || current.getJobId().equals(laterJob.getJobId());
+ }
+
+ assertEquals(1, count);
+ assertTrue(foundOriginal);
+ assertFalse(foundLater);
+ assertThrows(UnsupportedOperationException.class, () -> ((Collection>) snapshot).clear());
+ assertTrue(repository.findById(job.getJobId()).isPresent());
+ }
+
private ConversionJob newJob(String contentHash) {
return new ConversionJob(
UUID.randomUUID(),
diff --git a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentConversionServiceTest.java b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentConversionServiceTest.java
index ca51807a..b5f4a0ab 100644
--- a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentConversionServiceTest.java
+++ b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentConversionServiceTest.java
@@ -45,6 +45,45 @@
class DefaultDocumentConversionServiceTest {
+ @Test
+ void getAllJobsReturnsAllJobsFromRepository() {
+ InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository();
+ ConversionProperties props = new ConversionProperties();
+ DefaultDocumentConversionService service = new DefaultDocumentConversionService(
+ repository,
+ new DocumentValidationService() {
+ @Override
+ public void validateOrThrow(org.springframework.web.multipart.MultipartFile file) {
+ }
+ },
+ id -> {},
+ props
+ );
+
+ ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L);
+ ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L);
+ repository.save(job1);
+ repository.save(job2);
+
+ Iterable allJobs = service.getAllJobs();
+ int count = 0;
+ boolean found1 = false;
+ boolean found2 = false;
+ for (ConversionJob job : allJobs) {
+ count++;
+ if (job.getJobId().equals(job1.getJobId())) {
+ found1 = true;
+ }
+ if (job.getJobId().equals(job2.getJobId())) {
+ found2 = true;
+ }
+ }
+
+ assertEquals(2, count);
+ assertTrue(found1);
+ assertTrue(found2);
+ }
+
@Test
void submitWithOverrideDelegatesPolicyHeadersToValidationService() {
ConversionJobRepository repository = new InMemoryConversionJobRepository();
diff --git a/src/test/java/com/clearfolio/viewer/service/ServiceInterfaceDefaultMethodsTest.java b/src/test/java/com/clearfolio/viewer/service/ServiceInterfaceDefaultMethodsTest.java
index 075df363..07c5969d 100644
--- a/src/test/java/com/clearfolio/viewer/service/ServiceInterfaceDefaultMethodsTest.java
+++ b/src/test/java/com/clearfolio/viewer/service/ServiceInterfaceDefaultMethodsTest.java
@@ -32,6 +32,11 @@ public Optional getJob(UUID jobId) {
public RetryDeadLetterResult retryDeadLettered(UUID jobId, String operatorId) {
return RetryDeadLetterResult.NOT_FOUND;
}
+
+ @Override
+ public Iterable getAllJobs() {
+ return java.util.Collections.emptyList();
+ }
};
UUID actual = service.submit(
@@ -67,6 +72,11 @@ public Optional getJob(UUID jobId) {
public RetryDeadLetterResult retryDeadLettered(UUID jobId, String operatorId) {
return RetryDeadLetterResult.NOT_FOUND;
}
+
+ @Override
+ public Iterable getAllJobs() {
+ return java.util.Collections.emptyList();
+ }
};
PolicyOverrideRequest overrideRequest = PolicyOverrideRequest.of("true", "token-123", "approver-1");