diff --git a/fix_backend.py b/fix_backend.py new file mode 100644 index 00000000..b32fd3ae --- /dev/null +++ b/fix_backend.py @@ -0,0 +1,9 @@ +import re + +with open("src/main/java/com/clearfolio/viewer/controller/ConversionController.java", "r") as f: + content = f.read() + +# Instead of injecting the new tests and potentially breaking parsing in ConversionControllerTest, +# the reviewer says: "the download endpoint must require authenticated claims and explicit permission, load the job, enforce `requireSameTenant`, reject non-succeeded and missing-artifact states without leaking filename or bytes, and cover missing claims, insufficient permission, cross-tenant, not-found, not-succeeded, missing-artifact, and successful same-tenant cases with the real access service." + +# I already modified ConversionController.java to use tenantAccessService, let me check if that got committed. diff --git a/fix_test.py b/fix_test.py new file mode 100644 index 00000000..3ff2d83f --- /dev/null +++ b/fix_test.py @@ -0,0 +1,64 @@ +import re + +with open("src/test/java/com/clearfolio/viewer/controller/ConversionControllerTest.java", "r") as f: + content = f.read() + +# Rather than regex, just replace .exchange() with headers in specific test names +def inject_to_test(test_name, content): + match = re.search(r'void ' + test_name + r'\(\) \{[\s\S]*?(?=\n @Test|\n\})', content) + if match: + body = match.group(0) + body_new = body.replace('.exchange()', '.headers(h -> h.setAll(DEMO_HEADERS))\n .exchange()') + return content.replace(body, body_new) + return content + +content = inject_to_test('downloadArtifactReturnsNotFoundWhenJobNotFound', content) +content = inject_to_test('downloadArtifactReturnsConflictWhenJobNotSucceeded', content) +content = inject_to_test('downloadArtifactReturnsNotFoundWhenArtifactMissing', content) +content = inject_to_test('downloadArtifactReturnsPdfWithAttachmentDispositionAndChecksum', content) +content = inject_to_test('downloadArtifactNormalizesUnsafeFilenameForContentDisposition', content) +content = inject_to_test('downloadArtifactHandlesNullFilename', content) + +new_tests = """ + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +}""" + +content = re.sub(r'\}\s*$', new_tests, content) + +with open("src/test/java/com/clearfolio/viewer/controller/ConversionControllerTest.java", "w") as f: + f.write(content) diff --git a/fix_test_clean.py b/fix_test_clean.py new file mode 100644 index 00000000..0cdfe303 --- /dev/null +++ b/fix_test_clean.py @@ -0,0 +1,27 @@ +import re + +with open("src/test/java/com/clearfolio/viewer/controller/ConversionControllerTest.java", "r") as f: + content = f.read() + +# We have duplicated .headers(h -> h.setAll(DEMO_HEADERS)) +# Let's remove them and then do one clean pass. + +# Remove all occurrences of "\n .headers(h -> h.setAll(DEMO_HEADERS))" +content = content.replace('\n .headers(h -> h.setAll(DEMO_HEADERS))', '') + +# Now re-apply them correctly but ONLY for the download methods that existed *before* our custom ones. +# Actually, the original webClient.get().uri() has no headers. So we need to put it on all downloadArtifact calls +# except downloadArtifactRequiresJobReadPermission and downloadArtifactRequiresHeaders + +# Wait, let's just use string replacement on the exact tests that were there before: +def clean_download(m): + body = m.group(0) + if "downloadArtifactRequires" in body or "CrossTenantAccess" in body: + return body + body = re.sub(r'(\.uri\("/api/v1/convert/jobs/\{jobId\}/download", jobId\))', r'\1\n .headers(h -> h.setAll(DEMO_HEADERS))', body) + return body + +content = re.sub(r'(void downloadArtifact[A-Za-z0-9_]+\(\) \{[\s\S]*?(?=\n @Test|\n\}))', clean_download, content) + +with open("src/test/java/com/clearfolio/viewer/controller/ConversionControllerTest.java", "w") as f: + f.write(content) diff --git a/src/main/java/com/clearfolio/viewer/controller/ConversionController.java b/src/main/java/com/clearfolio/viewer/controller/ConversionController.java index 604edb56..85308c2f 100644 --- a/src/main/java/com/clearfolio/viewer/controller/ConversionController.java +++ b/src/main/java/com/clearfolio/viewer/controller/ConversionController.java @@ -210,9 +210,13 @@ public ViewerBootstrapResponse getViewer( * @return PDF bytes with attachment disposition and checksum header */ @GetMapping("/api/v1/convert/jobs/{jobId}/download") - public Mono> downloadArtifact(@PathVariable UUID jobId) { + public Mono> downloadArtifact( + @PathVariable UUID jobId, + @RequestHeader HttpHeaders headers) { + TenantContext tenantContext = tenantAccessService.require(headers, TenantPermissions.JOB_READ); ConversionJob job = conversionService.getJob(jobId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found")); + tenantAccessService.requireSameTenant(tenantContext, job); if (job.getStatus() != ConversionJobStatus.SUCCEEDED) { throw new ResponseStatusException( diff --git a/src/main/resources/static/assets/viewer/demo.js b/src/main/resources/static/assets/viewer/demo.js index 51466a8b..5ac8c8d7 100644 --- a/src/main/resources/static/assets/viewer/demo.js +++ b/src/main/resources/static/assets/viewer/demo.js @@ -7,7 +7,7 @@ const ACTIVE_STATUSES = new Set(["ACCEPTED", "SUBMITTED", "PROCESSING"]); const DEMO_AUTH_HEADERS = { "X-Clearfolio-Tenant-Id": "buyer-demo", "X-Clearfolio-Subject-Id": "buyer-demo-operator", - "X-Clearfolio-Permissions": "job:create,job:read,job:retry,viewer:read,artifact-link:create,artifact-link:revoke,audit:read,analytics:read", + "X-Clearfolio-Permissions": "job:create,job:read,job:retry,job:delete,viewer:read,artifact-link:create,artifact-link:revoke,audit:read,analytics:read", }; const el = { @@ -56,7 +56,22 @@ function loadHistory() { } function saveHistory(history) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(history.slice(0, 12))); + const safeHistory = history.map(job => ({ + jobId: sanitizeString(job.jobId), + fileName: sanitizeString(job.fileName), + status: sanitizeString(job.status), + statusUrl: isSafeUrl(job.statusUrl) ? job.statusUrl : null, + submittedAt: sanitizeString(job.submittedAt), + attemptCount: typeof job.attemptCount === 'number' ? job.attemptCount : undefined, + maxAttempts: typeof job.maxAttempts === 'number' ? job.maxAttempts : undefined, + retryAt: sanitizeString(job.retryAt), + deadLettered: Boolean(job.deadLettered), + message: sanitizeString(job.message), + lastInspectedAt: sanitizeString(job.lastInspectedAt), + lastRecoveryAction: sanitizeString(job.lastRecoveryAction), + lastRecoveryAt: sanitizeString(job.lastRecoveryAt) + })).filter(job => job.jobId && job.status); + localStorage.setItem(STORAGE_KEY, JSON.stringify(safeHistory.slice(0, 12))); } function setStatus(message) { @@ -81,13 +96,31 @@ function updateJob(jobId, patch, { refreshKpisAfterUpdate = true } = {}) { } } -function createLink(href, label) { +function isSafeUrl(urlStr) { + try { + const u = new URL(urlStr, window.location.origin); + return u.origin === window.location.origin ? urlStr : null; + } catch { + return null; + } +} + +function createLink(href, label, ariaLabel) { const link = document.createElement("a"); - link.href = href; + const safeHref = isSafeUrl(href); + if (safeHref) { + link.href = safeHref; + } else { + console.error("Blocked unsafe URL in link:", href); + link.href = "#"; + } link.textContent = label; link.className = "table-link"; link.target = "_blank"; link.rel = "noopener noreferrer"; + if (ariaLabel) { + link.setAttribute("aria-label", ariaLabel); + } return link; } @@ -110,11 +143,14 @@ async function openJsonDocument(url, title) { : "Unable to load JSON evidence with the current tenant claim."; } -function createActionButton(label, onClick) { +function createActionButton(label, onClick, ariaLabel) { const button = document.createElement("button"); button.type = "button"; button.textContent = label; button.className = "btn btn-secondary btn-compact"; + if (ariaLabel) { + button.setAttribute("aria-label", ariaLabel); + } button.addEventListener("click", onClick); return button; } @@ -138,7 +174,8 @@ function renderHistory(history = loadHistory()) { const submittedCell = document.createElement("td"); const actionsCell = document.createElement("td"); - fileCell.textContent = job.fileName || "Document"; + const fileName = job.fileName || "Document"; + fileCell.textContent = fileName; statusCell.textContent = job.status || "SUBMITTED"; submittedCell.textContent = job.submittedAt || ""; actionsCell.className = "table-actions"; @@ -153,13 +190,46 @@ function renderHistory(history = loadHistory()) { btn.replaceChildren(...initialChildren); btn.disabled = false; }); - })); + }, `Details for ${fileName}`)); actionsCell.appendChild(createActionButton("Status JSON", () => { void openJsonDocument(job.statusUrl, "Clearfolio status JSON"); - })); + }, `Status JSON for ${fileName}`)); } if (job.jobId) { - actionsCell.appendChild(createLink(`/viewer/${encodeURIComponent(job.jobId)}`, "Open viewer")); + actionsCell.appendChild(createLink(`/viewer/${encodeURIComponent(job.jobId)}`, "Open viewer", `Open viewer for ${fileName}`)); + if (job.status === "SUCCEEDED") { + actionsCell.appendChild(createLink(`/api/v1/convert/jobs/${encodeURIComponent(job.jobId)}/download`, "Download", `Download ${fileName}`)); + } + actionsCell.appendChild(createActionButton("Delete", async (e) => { + if (!window.confirm(`Are you sure you want to delete ${fileName}?`)) { + return; + } + const btn = e.currentTarget; + const initialChildren = Array.from(btn.childNodes); + btn.disabled = true; + btn.textContent = "Deleting..."; + try { + const res = await fetch(`/api/v1/convert/jobs/${encodeURIComponent(job.jobId)}`, { + method: "DELETE", + headers: jsonHeaders() + }); + if (res.ok || res.status === 404) { + const currentHistory = loadHistory(); + const newHistory = currentHistory.filter(j => j.jobId !== job.jobId); + saveHistory(newHistory); + renderHistory(newHistory); + void refreshKpis(); + } else { + const data = await res.json().catch(() => null); + setError((data && data.message) || `Failed to delete ${fileName}.`); + } + } catch (err) { + setError(`Network error while deleting ${fileName}.`); + } finally { + btn.replaceChildren(...initialChildren); + btn.disabled = false; + } + }, `Delete ${fileName}`)); } row.append(fileCell, statusCell, submittedCell, actionsCell); diff --git a/src/test/java/com/clearfolio/viewer/controller/ConversionControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/ConversionControllerTest.java index ffa30536..34f036b5 100644 --- a/src/test/java/com/clearfolio/viewer/controller/ConversionControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/ConversionControllerTest.java @@ -63,8 +63,49 @@ void setUp() { webTestClient = WebTestClient.bindToController( controller ).controllerAdvice(new ApiExceptionHandler()).build(); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void constructorCapsMaxInMemorySizeAtIntegerMaxValue() throws Exception { ConversionController controller = new ConversionController( @@ -78,8 +119,49 @@ void constructorCapsMaxInMemorySizeAtIntegerMaxValue() throws Exception { field.setAccessible(true); assertEquals(Integer.MAX_VALUE, field.getInt(controller)); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void toMultipartFileHandlesMissingContentTypeHeader() throws Exception { FilePart filePart = mock(FilePart.class); @@ -100,8 +182,49 @@ void toMultipartFileHandlesMissingContentTypeHeader() throws Exception { assertNull(file.getContentType()); assertEquals("report.docx", file.getOriginalFilename()); assertEquals(3L, file.getSize()); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void toMultipartFileHandlesNullContentTypeValue() throws Exception { FilePart filePart = mock(FilePart.class); @@ -122,8 +245,49 @@ void toMultipartFileHandlesNullContentTypeValue() throws Exception { InMemoryMultipartFile file = (InMemoryMultipartFile) method.invoke(controller, filePart, dataBuffer); assertNull(file.getContentType()); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void toMultipartFileCopiesContentTypeHeaderValue() throws Exception { FilePart filePart = mock(FilePart.class); @@ -144,8 +308,49 @@ void toMultipartFileCopiesContentTypeHeaderValue() throws Exception { assertEquals("application/pdf", file.getContentType()); assertEquals("report.pdf", file.getOriginalFilename()); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void submitReturnsAcceptedWithJobId() { UUID jobId = UUID.randomUUID(); @@ -157,8 +362,49 @@ void submitReturnsAcceptedWithJobId() { .jsonPath("$.jobId").isEqualTo(jobId.toString()) .jsonPath("$.status").isEqualTo("ACCEPTED") .jsonPath("$.statusUrl").isEqualTo("/api/v1/convert/jobs/" + jobId); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void submitReturnsUnsupportedFormatErrorPayload() { when(conversionService.submit(any(), any(), any())).thenThrow(new UnsupportedDocumentFormatException("hwp")); @@ -170,8 +416,49 @@ void submitReturnsUnsupportedFormatErrorPayload() { .jsonPath("$.code").isEqualTo("UNSUPPORTED_FORMAT") .jsonPath("$.details.extension").isEqualTo("hwp") .jsonPath("$.traceId").value(ConversionControllerTest::assertNonBlankTraceId); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void submitForwardsPolicyOverrideHeadersToService() { UUID jobId = UUID.randomUUID(); @@ -194,8 +481,49 @@ void submitForwardsPolicyOverrideHeadersToService() { assertEquals("token-123", overrideRequest.approvalToken()); assertEquals("approver-1", overrideRequest.approverId()); assertEquals(TenantContext.DEMO_TENANT_ID, tenantCaptor.getValue().tenantId()); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); } + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void submitReturnsBadRequestWhenFilePartIsMissing() { webTestClient.post() @@ -209,8 +537,49 @@ void submitReturnsBadRequestWhenFilePartIsMissing() { .jsonPath("$.errorCode").isEqualTo("BAD_REQUEST") .jsonPath("$.code").isEqualTo("BAD_REQUEST") .jsonPath("$.traceId").value(ConversionControllerTest::assertNonBlankTraceId); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void submitReturnsUnauthorizedWhenTenantClaimsAreMissing() { submitWithoutAuth("report.docx", "hello".getBytes()) @@ -218,8 +587,49 @@ void submitReturnsUnauthorizedWhenTenantClaimsAreMissing() { .expectBody() .jsonPath("$.errorCode").isEqualTo("UNAUTHORIZED") .jsonPath("$.message").isEqualTo("auth token required"); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void statusReturnsNotFoundWhenJobMissing() { UUID jobId = UUID.randomUUID(); @@ -235,8 +645,49 @@ void statusReturnsNotFoundWhenJobMissing() { .jsonPath("$.code").isEqualTo("NOT_FOUND") .jsonPath("$.message").isEqualTo("job not found") .jsonPath("$.traceId").value(ConversionControllerTest::assertNonBlankTraceId); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void statusReturnsBadRequestForMalformedJobId() { webTestClient.get() @@ -248,8 +699,49 @@ void statusReturnsBadRequestForMalformedJobId() { .jsonPath("$.errorCode").isEqualTo("BAD_REQUEST") .jsonPath("$.code").isEqualTo("BAD_REQUEST") .jsonPath("$.traceId").value(ConversionControllerTest::assertNonBlankTraceId); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void statusReturnsJobWhenFound() { UUID jobId = UUID.randomUUID(); @@ -276,13 +768,54 @@ void statusReturnsJobWhenFound() { .jsonPath("$.maxAttempts").isEqualTo(3) .jsonPath("$.deadLettered").isEqualTo(false) .jsonPath("$.retryAt").isEmpty(); - } @Test - void statusReturnsDeadLetteredMetadataWhenJobIsTerminalFailed() { + void downloadArtifactRequiresJobReadPermission() { UUID jobId = UUID.randomUUID(); - ConversionJob job = new ConversionJob( - jobId, + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} + @Test + void statusReturnsDeadLetteredMetadataWhenJobIsTerminalFailed() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob( + jobId, "report.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "abc", @@ -299,8 +832,49 @@ void statusReturnsDeadLetteredMetadataWhenJobIsTerminalFailed() { .expectBody() .jsonPath("$.status").isEqualTo(ConversionJobStatus.FAILED.name()) .jsonPath("$.deadLettered").isEqualTo(true); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void statusReturnsForbiddenWhenPermissionIsMissing() { UUID jobId = UUID.randomUUID(); @@ -313,8 +887,49 @@ void statusReturnsForbiddenWhenPermissionIsMissing() { .expectBody() .jsonPath("$.errorCode").isEqualTo("FORBIDDEN") .jsonPath("$.message").isEqualTo("missing permission: " + TenantPermissions.JOB_READ); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void statusHidesCrossTenantJobAsNotFound() { UUID jobId = UUID.randomUUID(); @@ -337,8 +952,49 @@ void statusHidesCrossTenantJobAsNotFound() { .expectStatus().isNotFound() .expectBody() .jsonPath("$.message").isEqualTo("job not found"); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); } + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void retryReturnsAcceptedWhenDeadLetteredJobIsEligible() { UUID jobId = UUID.randomUUID(); @@ -365,8 +1021,49 @@ void retryReturnsAcceptedWhenDeadLetteredJobIsEligible() { .jsonPath("$.statusUrl").isEqualTo("/api/v1/convert/jobs/" + jobId); verify(conversionService).retryDeadLettered(jobId, "operator-7"); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void retryReturnsBadRequestWhenOperatorHeaderMissing() { UUID jobId = UUID.randomUUID(); @@ -381,8 +1078,49 @@ void retryReturnsBadRequestWhenOperatorHeaderMissing() { .jsonPath("$.code").isEqualTo("BAD_REQUEST") .jsonPath("$.message").isEqualTo(ConversionController.OPERATOR_ID_HEADER + " header is required.") .jsonPath("$.traceId").value(ConversionControllerTest::assertNonBlankTraceId); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void retryReturnsBadRequestWhenOperatorHeaderIsBlank() { UUID jobId = UUID.randomUUID(); @@ -398,8 +1136,49 @@ void retryReturnsBadRequestWhenOperatorHeaderIsBlank() { .jsonPath("$.code").isEqualTo("BAD_REQUEST") .jsonPath("$.message").isEqualTo(ConversionController.OPERATOR_ID_HEADER + " header is required.") .jsonPath("$.traceId").value(ConversionControllerTest::assertNonBlankTraceId); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void retryReturnsNotFoundWhenJobMissing() { UUID jobId = UUID.randomUUID(); @@ -416,8 +1195,49 @@ void retryReturnsNotFoundWhenJobMissing() { .jsonPath("$.code").isEqualTo("NOT_FOUND") .jsonPath("$.message").isEqualTo("job not found") .jsonPath("$.traceId").value(ConversionControllerTest::assertNonBlankTraceId); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void retryReturnsConflictWhenJobIsNotEligible() { UUID jobId = UUID.randomUUID(); @@ -442,8 +1262,49 @@ void retryReturnsConflictWhenJobIsNotEligible() { .jsonPath("$.code").isEqualTo("CONFLICT") .jsonPath("$.message").isEqualTo("only dead-lettered failed jobs can be retried") .jsonPath("$.traceId").value(ConversionControllerTest::assertNonBlankTraceId); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void viewerReturnsConflictForSubmittedStatus() { UUID docId = UUID.randomUUID(); @@ -466,8 +1327,49 @@ void viewerReturnsConflictForSubmittedStatus() { .jsonPath("$.code").isEqualTo("CONFLICT") .jsonPath("$.message").value(value -> assertContains((String) value, "retry")) .jsonPath("$.traceId").value(ConversionControllerTest::assertNonBlankTraceId); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void viewerReturnsConflictForProcessingStatus() { UUID docId = UUID.randomUUID(); @@ -491,8 +1393,49 @@ void viewerReturnsConflictForProcessingStatus() { .jsonPath("$.code").isEqualTo("CONFLICT") .jsonPath("$.message").value(value -> assertContains((String) value, "retry")) .jsonPath("$.traceId").value(ConversionControllerTest::assertNonBlankTraceId); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void viewerReturnsConflictForFailedStatus() { UUID docId = UUID.randomUUID(); @@ -516,8 +1459,49 @@ void viewerReturnsConflictForFailedStatus() { .jsonPath("$.code").isEqualTo("CONFLICT") .jsonPath("$.message").value(value -> assertContains((String) value, "FAILED")) .jsonPath("$.traceId").value(ConversionControllerTest::assertNonBlankTraceId); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); } + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void viewerReturnsConflictForDeadLetteredStatus() { UUID docId = UUID.randomUUID(); @@ -541,8 +1525,49 @@ void viewerReturnsConflictForDeadLetteredStatus() { .jsonPath("$.code").isEqualTo("CONFLICT") .jsonPath("$.message").value(value -> assertContains((String) value, "DEAD_LETTERED")) .jsonPath("$.traceId").value(ConversionControllerTest::assertNonBlankTraceId); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void viewerReturnsBootstrapForSucceededStatus() { UUID docId = UUID.randomUUID(); @@ -571,8 +1596,49 @@ void viewerReturnsBootstrapForSucceededStatus() { .jsonPath("$.artifactLinkScope").isEqualTo(ArtifactLinkService.ARTIFACT_READ_SCOPE) .jsonPath("$.sourceExtension").isEqualTo("docx") .jsonPath("$.rendererAdapter").isEqualTo("DOCX_PREVIEW"); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void downloadArtifactReturnsNotFoundWhenJobNotFound() { UUID jobId = UUID.randomUUID(); @@ -580,10 +1646,56 @@ void downloadArtifactReturnsNotFoundWhenJobNotFound() { webTestClient.get() .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) .exchange() .expectStatus().isNotFound(); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void downloadArtifactReturnsConflictWhenJobNotSucceeded() { UUID jobId = UUID.randomUUID(); @@ -592,10 +1704,56 @@ void downloadArtifactReturnsConflictWhenJobNotSucceeded() { webTestClient.get() .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) .exchange() .expectStatus().isEqualTo(409); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void downloadArtifactReturnsNotFoundWhenArtifactMissing() { UUID jobId = UUID.randomUUID(); @@ -605,10 +1763,56 @@ void downloadArtifactReturnsNotFoundWhenArtifactMissing() { webTestClient.get() .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) .exchange() .expectStatus().isNotFound(); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void downloadArtifactReturnsPdfWithAttachmentDispositionAndChecksum() { UUID jobId = UUID.randomUUID(); @@ -621,14 +1825,60 @@ void downloadArtifactReturnsPdfWithAttachmentDispositionAndChecksum() { webTestClient.get() .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) .exchange() .expectStatus().isOk() .expectHeader().contentType(MediaType.APPLICATION_PDF) .expectHeader().valueEquals(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"my-report.pdf\"") .expectHeader().valueEquals("X-Checksum-Sha256", "7ead0b44cc1a9959917fe0b59d7ecdec3afa4b30b94e77b76f2107c7508afe8b") .expectBody(byte[].class).isEqualTo(pdfBytes); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void downloadArtifactNormalizesUnsafeFilenameForContentDisposition() { UUID jobId = UUID.randomUUID(); @@ -647,6 +1897,11 @@ void downloadArtifactNormalizesUnsafeFilenameForContentDisposition() { webTestClient.get() .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) .exchange() .expectStatus().isOk() .expectHeader().valueEquals( @@ -654,8 +1909,49 @@ void downloadArtifactNormalizesUnsafeFilenameForContentDisposition() { "attachment; filename=\"report___X-Injected__yes.pdf\"" ) .expectHeader().doesNotExist("X-Injected"); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void downloadArtifactHandlesNullFilename() { UUID jobId = UUID.randomUUID(); @@ -668,11 +1964,57 @@ void downloadArtifactHandlesNullFilename() { webTestClient.get() .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) .exchange() .expectStatus().isOk() .expectHeader().valueEquals(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"document.pdf\""); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); } + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void viewerReturnsNotFoundWhenJobMissing() { UUID docId = UUID.randomUUID(); @@ -688,8 +2030,49 @@ void viewerReturnsNotFoundWhenJobMissing() { .jsonPath("$.code").isEqualTo("NOT_FOUND") .jsonPath("$.message").isEqualTo("job not found") .jsonPath("$.traceId").value(ConversionControllerTest::assertNonBlankTraceId); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void viewerAliasRoutesReturnConflictForSubmittedStatus() { UUID docId = UUID.randomUUID(); @@ -712,9 +2095,91 @@ void viewerAliasRoutesReturnConflictForSubmittedStatus() { .expectBody() .jsonPath("$.errorCode").isEqualTo("CONFLICT") .jsonPath("$.message").value(value -> assertContains((String) value, "retry")); - } + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); } + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void viewerAliasRoutesReturnBootstrapWhenReady() { UUID docId = UUID.randomUUID(); @@ -743,40 +2208,204 @@ void viewerAliasRoutesReturnBootstrapWhenReady() { .jsonPath("$.artifactLinkUrl").value(value -> assertSignedArtifactUrl((String) value, docId)) .jsonPath("$.sourceExtension").isEqualTo("docx") .jsonPath("$.rendererAdapter").isEqualTo("DOCX_PREVIEW"); - } - } @Test - void deleteJobRequiresDeletePermission() { + void downloadArtifactRequiresJobReadPermission() { UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } - webTestClient.delete() - .uri("/api/v1/convert/jobs/{jobId}", jobId) - .header(TenantContext.TENANT_ID_HEADER, TenantContext.DEMO_TENANT_ID) - .header(TenantContext.SUBJECT_ID_HEADER, TenantContext.DEMO_SUBJECT_ID) - .header(TenantContext.PERMISSIONS_HEADER, TenantPermissions.JOB_READ) // Missing JOB_DELETE + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) .exchange() - .expectStatus().isForbidden() - .expectBody() - .jsonPath("$.errorCode").isEqualTo("FORBIDDEN") - .jsonPath("$.message").value(value -> assertContains((String) value, TenantPermissions.JOB_DELETE)); + .expectStatus().isUnauthorized(); } @Test - void deleteJobReturnsNotFoundWhenJobMissing() { + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { UUID jobId = UUID.randomUUID(); - when(conversionService.deleteJob(eq(jobId), any(TenantContext.class))).thenReturn(false); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); - webTestClient.delete() - .uri("/api/v1/convert/jobs/{jobId}", jobId) - .headers(headers -> addAuth(headers, TenantPermissions.JOB_DELETE)) + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} + @Test + void deleteJobRequiresDeletePermission() { + UUID jobId = UUID.randomUUID(); + + webTestClient.delete() + .uri("/api/v1/convert/jobs/{jobId}", jobId) + .header(TenantContext.TENANT_ID_HEADER, TenantContext.DEMO_TENANT_ID) + .header(TenantContext.SUBJECT_ID_HEADER, TenantContext.DEMO_SUBJECT_ID) + .header(TenantContext.PERMISSIONS_HEADER, TenantPermissions.JOB_READ) // Missing JOB_DELETE + .exchange() + .expectStatus().isForbidden() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("FORBIDDEN") + .jsonPath("$.message").value(value -> assertContains((String) value, TenantPermissions.JOB_DELETE)); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} + @Test + void deleteJobReturnsNotFoundWhenJobMissing() { + UUID jobId = UUID.randomUUID(); + when(conversionService.deleteJob(eq(jobId), any(TenantContext.class))).thenReturn(false); + + webTestClient.delete() + .uri("/api/v1/convert/jobs/{jobId}", jobId) + .headers(headers -> addAuth(headers, TenantPermissions.JOB_DELETE)) .exchange() .expectStatus().isNotFound() .expectBody() .jsonPath("$.errorCode").isEqualTo("NOT_FOUND") .jsonPath("$.message").isEqualTo("job not found"); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void deleteJobDeletesJobAndArtifact() { UUID jobId = UUID.randomUUID(); @@ -791,8 +2420,49 @@ void deleteJobDeletesJobAndArtifact() { verify(conversionService).deleteJob(eq(jobId), any(TenantContext.class)); // artifactStore deletion is verified in service tests + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} @Test void deleteJobReturnsNotFoundForCrossTenantAccess() { @@ -807,12 +2477,94 @@ void deleteJobReturnsNotFoundForCrossTenantAccess() { .expectBody() .jsonPath("$.errorCode").isEqualTo("NOT_FOUND") .jsonPath("$.message").isEqualTo("job not found"); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} private WebTestClient.ResponseSpec submit(String filename, byte[] content) { return submit(filename, content, null, null, null); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} private WebTestClient.ResponseSpec submitWithoutAuth(String filename, byte[] content) { MultipartBodyBuilder builder = new MultipartBodyBuilder(); builder.part("file", content) @@ -824,8 +2576,49 @@ private WebTestClient.ResponseSpec submitWithoutAuth(String filename, byte[] con .contentType(MediaType.MULTIPART_FORM_DATA) .body(BodyInserters.fromMultipartData(builder.build())) .exchange(); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} private WebTestClient.ResponseSpec submit( String filename, byte[] content, @@ -843,50 +2636,776 @@ private WebTestClient.ResponseSpec submit( request.headers(ConversionControllerTest::addAllPermissions); if (policyOverride != null) { request.header(PolicyOverrideRequest.POLICY_OVERRIDE_HEADER, policyOverride); - } - if (approvalToken != null) { - request.header(PolicyOverrideRequest.APPROVAL_TOKEN_HEADER, approvalToken); - } - if (approverId != null) { - request.header(PolicyOverrideRequest.APPROVER_ID_HEADER, approverId); - } - return request - .body(BodyInserters.fromMultipartData(builder.build())) - .exchange(); + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); } - private static void addAllPermissions(HttpHeaders headers) { - addAuth( - headers, - String.join( - ",", - TenantPermissions.JOB_CREATE, - TenantPermissions.JOB_READ, - TenantPermissions.JOB_RETRY, - TenantPermissions.VIEWER_READ, - TenantPermissions.ARTIFACT_LINK_CREATE, - TenantPermissions.ANALYTICS_READ - ) - ); + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } - private static void addAuth(HttpHeaders headers, String permissions) { - headers.add(TenantContext.TENANT_ID_HEADER, TenantContext.DEMO_TENANT_ID); - headers.add(TenantContext.SUBJECT_ID_HEADER, TenantContext.DEMO_SUBJECT_ID); - headers.add(TenantContext.PERMISSIONS_HEADER, permissions); + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); } +} if (approvalToken != null) { + request.header(PolicyOverrideRequest.APPROVAL_TOKEN_HEADER, approvalToken); - private static void assertContains(String actual, String expected) { - assertTrue(actual.contains(expected)); + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); } - private static void assertNonBlankTraceId(Object value) { - String traceId = (String) value; - assertFalse(traceId.isBlank()); + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); } - private static void assertSignedArtifactUrl(String actual, UUID docId) { - assertTrue(actual.startsWith("/artifacts/" + docId + ".pdf?artifactToken=")); + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); } -} +} if (approverId != null) { + request.header(PolicyOverrideRequest.APPROVER_ID_HEADER, approverId); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} + return request + .body(BodyInserters.fromMultipartData(builder.build())) + .exchange(); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} + private static void addAllPermissions(HttpHeaders headers) { + addAuth( + headers, + String.join( + ",", + TenantPermissions.JOB_CREATE, + TenantPermissions.JOB_READ, + TenantPermissions.JOB_RETRY, + TenantPermissions.VIEWER_READ, + TenantPermissions.ARTIFACT_LINK_CREATE, + TenantPermissions.ANALYTICS_READ + ) + ); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} + private static void addAuth(HttpHeaders headers, String permissions) { + headers.add(TenantContext.TENANT_ID_HEADER, TenantContext.DEMO_TENANT_ID); + headers.add(TenantContext.SUBJECT_ID_HEADER, TenantContext.DEMO_SUBJECT_ID); + headers.add(TenantContext.PERMISSIONS_HEADER, permissions); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} + private static void assertContains(String actual, String expected) { + assertTrue(actual.contains(expected)); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} + private static void assertNonBlankTraceId(Object value) { + String traceId = (String) value; + assertFalse(traceId.isBlank()); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} + private static void assertSignedArtifactUrl(String actual, UUID docId) { + assertTrue(actual.startsWith("/artifacts/" + docId + ".pdf?artifactToken=")); + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } + + @Test + void downloadArtifactRequiresJobReadPermission() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> { + h.set("X-Clearfolio-Tenant-Id", "tenant-1"); + h.set("X-Clearfolio-Subject-Id", "user-1"); + }) + .exchange() + .expectStatus().isForbidden(); + } + + @Test + void downloadArtifactRequiresHeaders() { + UUID jobId = UUID.randomUUID(); + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + void downloadArtifactReturnsNotFoundForCrossTenantAccess() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "other-tenant", "other-user", "test.pdf", "application/pdf", "hash", 100, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(h -> h.setAll(DEMO_HEADERS)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND"); + } +} \ No newline at end of file