diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 32a6c6d3..8c5ef638 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,6 +19,11 @@ jobs: build-n-test: runs-on: ubuntu-latest + permissions: + contents: read + checks: write + pull-requests: write + steps: - uses: actions/checkout@v4 @@ -49,7 +54,7 @@ jobs: env: ACCESS_KEY: ${{ secrets.DEV_CA_PUBLIC_USE_INTEGRATION_TEST_ACCESS_KEY }} SERVICE_PRINCIPAL_KEY: ${{ secrets.DEV_CA_PUBLIC_USE_TESTOAUTHSERVICEPRINCIPAL_SERVICE_PRINCIPAL_KEY }} - REPOSITORY_ID: ${{ secrets.DEV_CA_PUBLIC_USE_REPOSITORY_ID_2 }} + REPOSITORY_ID: ${{ secrets.DEV_CA_PUBLIC_USE_REPOSITORY_ID_1 }} AUTHORIZATION_TYPE: ${{ secrets.AUTHORIZATION_TYPE }} TEST_HEADER: ${{ secrets.TEST_HEADER }} READONLY_TEST_FOLDER_ID: ${{ secrets.DEV_CA_READONLY_TEST_FOLDER_ID }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c6bf7da..6be08078 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.0.4 + +### Chore & Maintenance + +- Update `jackson-databind` dependency due to vulnerability + ## 1.0.3 ### Chore & Maintenance diff --git a/pom.xml b/pom.xml index 87300363..9553d53f 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ lf-repository-api-client-v2 jar Laserfiche Repository API Client V2 - 1.0.0 + 1.0.4 https://github.com/Laserfiche/lf-repository-api-client-java The Java Laserfiche Repository API Client library for accessing the v2 Laserfiche Repository APIs. @@ -309,7 +309,7 @@ ${java.version} 1.8.0 2.0.0 - 2.18.2 + 2.18.8 2.9.0 1.3.5 1.0.2 diff --git a/src/main/java/com/laserfiche/repository/api/clients/impl/ApiClientUtils.java b/src/main/java/com/laserfiche/repository/api/clients/impl/ApiClientUtils.java index f47547eb..11744a5d 100644 --- a/src/main/java/com/laserfiche/repository/api/clients/impl/ApiClientUtils.java +++ b/src/main/java/com/laserfiche/repository/api/clients/impl/ApiClientUtils.java @@ -174,6 +174,9 @@ public static TResponse sendRequestWithRetry( HttpResponse httpResponse = null; while (retryCount <= maxRetries && shouldRetry) { try { + if (retryCount > 0) { + System.out.println("[RETRY-DEBUG] attempt " + (retryCount + 1) + " starting for " + requestMethod + " " + url); + } String requestUrl = ApiClientUtils.beforeSend(url, headerParametersWithStringTypeValue, httpRequestHandler); final HttpRequestWithBody httpRequestWithBody = httpClient.request(requestMethod, requestUrl); @@ -212,10 +215,17 @@ public static TResponse sendRequestWithRetry( int statusCode = httpResponse.getStatus(); shouldRetry = httpRequestHandler.afterSend(new ResponseImpl((short) statusCode)) || ApiClientUtils.isRetryableStatusCode(statusCode, httpMethod); + if (statusCode == 401 || retryCount > 0 || shouldRetry) { + System.out.println("[RETRY-DEBUG] attempt " + (retryCount + 1) + " " + requestMethod + " " + url + + " -> status=" + statusCode + " shouldRetry=" + shouldRetry); + } if (!shouldRetry) { return parseResponse.apply(httpResponse); } } catch (Exception err) { + if (retryCount > 0 || err instanceof ApiException) { + System.out.println("[RETRY-DEBUG] attempt " + (retryCount + 1) + " threw " + err.getClass().getName() + ": " + err.getMessage()); + } if (err instanceof ApiException || retryCount >= maxRetries) { throw err; } diff --git a/src/test/java/com/laserfiche/repository/api/integration/BaseTest.java b/src/test/java/com/laserfiche/repository/api/integration/BaseTest.java index 280a568a..092625be 100644 --- a/src/test/java/com/laserfiche/repository/api/integration/BaseTest.java +++ b/src/test/java/com/laserfiche/repository/api/integration/BaseTest.java @@ -3,11 +3,13 @@ package com.laserfiche.repository.api.integration; import com.laserfiche.api.client.model.AccessKey; +import com.laserfiche.api.client.model.ApiException; import com.laserfiche.api.client.model.ProblemDetails; import com.laserfiche.repository.api.RepositoryApiClient; import com.laserfiche.repository.api.RepositoryApiClientImpl; import com.laserfiche.repository.api.clients.impl.model.*; import com.laserfiche.repository.api.clients.params.ParametersForCreateEntry; +import com.laserfiche.repository.api.clients.params.ParametersForListAuditReasons; import com.laserfiche.repository.api.clients.params.ParametersForListTasks; import com.laserfiche.repository.api.clients.params.ParametersForStartDeleteEntry; import io.github.cdimascio.dotenv.Dotenv; @@ -24,6 +26,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; enum AuthorizationType { CLOUD_ACCESS_KEY, @@ -156,6 +159,11 @@ private static void waitUntilTaskEnds(String taskId, Duration interval) { .setTaskIds(taskId)); TaskProgress progress = collectionResponse.getValue().get(0); if (progress.getStatus() != TaskStatus.IN_PROGRESS) { + if (progress.getStatus() != TaskStatus.COMPLETED) { + throw new RuntimeException(String.format( + "Task %s ended with status %s instead of COMPLETED. Errors: %s", + taskId, progress.getStatus(), formatErrors(progress.getErrors()))); + } return; } try { @@ -168,6 +176,60 @@ private static void waitUntilTaskEnds(String taskId, Duration interval) { throw new RuntimeException("WaitUntilTaskEnds timeout"); } + // ProblemDetails doesn't override toString(), so printing the list directly just + // shows "ProblemDetails@" with none of the actual server-provided error info. + private static String formatErrors(List errors) { + if (errors == null || errors.isEmpty()) { + return "none"; + } + return errors.stream() + .map(e -> String.format( + "[title=%s, detail=%s, status=%s, errorCode=%s, errorSource=%s]", + e.getTitle(), e.getDetail(), e.getStatus(), e.getErrorCode(), e.getErrorSource())) + .collect(Collectors.joining(", ")); + } + + private static Integer deleteAuditReasonId; + private static boolean deleteAuditReasonResolved = false; + + // This repository requires an audit reason for DeleteEntry (errorCode 216: "Need to + // provide correct audit reason for DeleteEntry"), unlike the old dedicated test repo. + // Resolved once and reused, mirroring ExportDocumentApiTest.findAuditReasonForExport. + protected static Integer getDeleteAuditReasonId() { + if (!deleteAuditReasonResolved) { + AuditReasonCollectionResponse auditReasons; + try { + auditReasons = repositoryApiClient + .getAuditReasonsClient() + .listAuditReasons(new ParametersForListAuditReasons().setRepositoryId(repositoryId)); + } catch (ApiException e) { + // Don't cache "resolved" on failure — a transient hiccup here would otherwise + // permanently strand every later delete in the run with no audit reason, silently + // re-triggering "Need to provide correct audit reason for DeleteEntry" for the rest + // of the suite instead of just this one call. + throw new RuntimeException(String.format( + "listAuditReasons failed while resolving delete audit reason: statusCode=%d, headers=%s, problemDetails=%s", + e.getStatusCode(), e.getHeaders(), e.getProblemDetails()), e); + } + deleteAuditReasonId = auditReasons.getValue().stream() + .filter(reason -> reason.getAuditEventType() == AuditEventType.DELETE_ENTRY) + .map(AuditReason::getId) + .findFirst() + .orElse(null); + deleteAuditReasonResolved = true; + } + return deleteAuditReasonId; + } + + protected static StartDeleteEntryRequest newDeleteEntryRequest() { + StartDeleteEntryRequest request = new StartDeleteEntryRequest(); + Integer auditReasonId = getDeleteAuditReasonId(); + if (auditReasonId != null) { + request.setAuditReasonId(auditReasonId); + } + return request; + } + public static void deleteEntry(int entryId) { if (entryId != 0) { StartTaskResponse startTaskResponse = repositoryApiClient @@ -175,7 +237,7 @@ public static void deleteEntry(int entryId) { .startDeleteEntry(new ParametersForStartDeleteEntry() .setRepositoryId(repositoryId) .setEntryId(entryId) - .setRequestBody(new StartDeleteEntryRequest())); + .setRequestBody(newDeleteEntryRequest())); waitUntilTaskEnds(startTaskResponse.getTaskId()); } } diff --git a/src/test/java/com/laserfiche/repository/api/integration/EntriesClientTest.java b/src/test/java/com/laserfiche/repository/api/integration/EntriesClientTest.java index fbcefa9d..6c6c80f7 100644 --- a/src/test/java/com/laserfiche/repository/api/integration/EntriesClientTest.java +++ b/src/test/java/com/laserfiche/repository/api/integration/EntriesClientTest.java @@ -420,7 +420,7 @@ void startDeleteEntryCanDeleteFolder() { StartTaskResponse deleteEntryResponse = client.startDeleteEntry(new ParametersForStartDeleteEntry() .setRepositoryId(repositoryId) .setEntryId(entryToDelete.getId()) - .setRequestBody(new StartDeleteEntryRequest())); + .setRequestBody(newDeleteEntryRequest())); String taskId = deleteEntryResponse.getTaskId(); assertNotNull(taskId); diff --git a/src/test/java/com/laserfiche/repository/api/integration/ExportDocumentApiTest.java b/src/test/java/com/laserfiche/repository/api/integration/ExportDocumentApiTest.java index b24e6674..35f0b669 100644 --- a/src/test/java/com/laserfiche/repository/api/integration/ExportDocumentApiTest.java +++ b/src/test/java/com/laserfiche/repository/api/integration/ExportDocumentApiTest.java @@ -107,6 +107,19 @@ static void classCleanUp() { deleteEntry(testEntryId); } + // Wraps exportEntry so a failure's ApiException status code/headers/problem details + // (not just its message) show up in the surefire report, since we can't get the raw + // job log from this environment to see the actual HTTP response otherwise. + private ExportEntryResponse exportEntry(ParametersForExportEntry parameters) { + try { + return client.exportEntry(parameters); + } catch (ApiException e) { + throw new AssertionError(String.format( + "exportEntry failed: statusCode=%d, headers=%s, problemDetails=%s", + e.getStatusCode(), e.getHeaders(), e.getProblemDetails()), e); + } + } + @Test void exportDocumentCanExportEDocPart() { final String FILE_NAME = "exportDocument_temp_file.pdf"; @@ -116,7 +129,7 @@ void exportDocumentCanExportEDocPart() { request.setAuditReasonId(auditReasonId); request.setAuditReasonComment(auditReasonComment); } - ExportEntryResponse response = client.exportEntry(new ParametersForExportEntry() + ExportEntryResponse response = exportEntry(new ParametersForExportEntry() .setRepositoryId(repositoryId) .setEntryId(testEntryId) .setRequestBody(request)); @@ -137,7 +150,7 @@ void exportDocumentCanExportTextPart() { request.setAuditReasonId(auditReasonId); request.setAuditReasonComment(auditReasonComment); } - ExportEntryResponse response = client.exportEntry(new ParametersForExportEntry() + ExportEntryResponse response = exportEntry(new ParametersForExportEntry() .setRepositoryId(repositoryId) .setEntryId(testEntryId) .setRequestBody(request)); @@ -188,7 +201,7 @@ void exportDocumentCanExportPagesAsSinglePageTIFF() { request.setAuditReasonId(auditReasonId); request.setAuditReasonComment(auditReasonComment); } - ExportEntryResponse response = client.exportEntry(new ParametersForExportEntry() + ExportEntryResponse response = exportEntry(new ParametersForExportEntry() .setRepositoryId(repositoryId) .setEntryId(testEntryId) .setRequestBody(request)); @@ -217,7 +230,7 @@ void exportDocumentCanExportPagesAsMultiPageTIFF() { request.setAuditReasonId(auditReasonId); request.setAuditReasonComment(auditReasonComment); } - ExportEntryResponse response = client.exportEntry(new ParametersForExportEntry() + ExportEntryResponse response = exportEntry(new ParametersForExportEntry() .setRepositoryId(repositoryId) .setEntryId(testEntryId) .setRequestBody(request)); @@ -244,7 +257,7 @@ void exportDocumentCanExportPagesAsJPEG() { request.setAuditReasonId(auditReasonId); request.setAuditReasonComment(auditReasonComment); } - ExportEntryResponse response = client.exportEntry(new ParametersForExportEntry() + ExportEntryResponse response = exportEntry(new ParametersForExportEntry() .setRepositoryId(repositoryId) .setEntryId(testEntryId) .setRequestBody(request)); @@ -271,7 +284,7 @@ void exportDocumentCanExportPagesAsJPEGWithPageRange() { request.setAuditReasonId(auditReasonId); request.setAuditReasonComment(auditReasonComment); } - ExportEntryResponse response = client.exportEntry(new ParametersForExportEntry() + ExportEntryResponse response = exportEntry(new ParametersForExportEntry() .setRepositoryId(repositoryId) .setEntryId(testEntryId) .setPageRange("1") diff --git a/src/test/java/com/laserfiche/repository/api/integration/ImportUploadedPartsApiTest.java b/src/test/java/com/laserfiche/repository/api/integration/ImportUploadedPartsApiTest.java index c5156600..46d86c2f 100644 --- a/src/test/java/com/laserfiche/repository/api/integration/ImportUploadedPartsApiTest.java +++ b/src/test/java/com/laserfiche/repository/api/integration/ImportUploadedPartsApiTest.java @@ -2,6 +2,7 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. package com.laserfiche.repository.api.integration; +import com.laserfiche.api.client.model.ApiException; import com.laserfiche.repository.api.clients.EntriesClient; import com.laserfiche.repository.api.clients.TasksClient; import com.laserfiche.repository.api.clients.impl.model.*; @@ -59,6 +60,19 @@ static void classCleanUp() { deleteEntry(testClassParentFolder.getId()); } + // Surfaces the real HTTP status code/headers on failure instead of just the generic + // message, so an "Invalid or expired access token" error tells us whether it was + // actually a 401 (and thus in principle retryable) or something else entirely. + private static T call(String callName, java.util.function.Supplier apiCall) { + try { + return apiCall.get(); + } catch (ApiException e) { + throw new AssertionError(String.format( + "%s failed: statusCode=%d, headers=%s, problemDetails=%s", + callName, e.getStatusCode(), e.getHeaders(), e.getProblemDetails()), e); + } + } + @Test void createMultipartUploadUrlsCanBeCalledForSecondBatchOfURLs() { String fileName = "Sample.pdf"; @@ -108,8 +122,8 @@ void startImportUploadedPartsCanImportLargeFileAndGeneratePages() { requestBody.setMimeType(mimeType); requestBody.setNumberOfParts(parts); - CreateMultipartUploadUrlsResponse response = client.createMultipartUploadUrls(new ParametersForCreateMultipartUploadUrls() - .setRepositoryId(repositoryId).setRequestBody(requestBody)); + CreateMultipartUploadUrlsResponse response = call("createMultipartUploadUrls", () -> client.createMultipartUploadUrls(new ParametersForCreateMultipartUploadUrls() + .setRepositoryId(repositoryId).setRequestBody(requestBody))); assertNotNull(response); String uploadId = response.getUploadId(); @@ -130,17 +144,17 @@ void startImportUploadedPartsCanImportLargeFileAndGeneratePages() { pdfOptions.setGeneratePages(true); pdfOptions.setKeepPdfAfterImport(true); requestBody2.setPdfOptions(pdfOptions); - StartTaskResponse response2 = client.startImportUploadedParts(new ParametersForStartImportUploadedParts() + StartTaskResponse response2 = call("startImportUploadedParts", () -> client.startImportUploadedParts(new ParametersForStartImportUploadedParts() .setRepositoryId(repositoryId) .setEntryId(testClassParentFolder.getId()) - .setRequestBody(requestBody2)); + .setRequestBody(requestBody2))); assertNotNull(response2); String taskId = response2.getTaskId(); assertNotNull(taskId); waitUntilTaskEnds(taskId); - TaskCollectionResponse tasks = tasksClient.listTasks(new ParametersForListTasks().setRepositoryId(repositoryId).setTaskIds(taskId)); + TaskCollectionResponse tasks = call("listTasks(import)", () -> tasksClient.listTasks(new ParametersForListTasks().setRepositoryId(repositoryId).setTaskIds(taskId))); assertNotNull(tasks); assertEquals(1, tasks.getValue().size()); TaskProgress taskProgress = tasks.getValue().get(0); @@ -161,16 +175,16 @@ void startImportUploadedPartsCanImportLargeFileAndGeneratePages() { exportRequestBody.setAuditReasonId(auditReasonId); exportRequestBody.setAuditReasonComment(auditReasonComment); } - StartTaskResponse exportResponse = client.startExportEntry(new ParametersForStartExportEntry() + StartTaskResponse exportResponse = call("startExportEntry", () -> client.startExportEntry(new ParametersForStartExportEntry() .setRepositoryId(repositoryId) .setEntryId(createdEntryId) - .setRequestBody(exportRequestBody)); + .setRequestBody(exportRequestBody))); assertNotNull(exportResponse); - taskId = exportResponse.getTaskId(); - assertNotNull(taskId); + String exportTaskId = exportResponse.getTaskId(); + assertNotNull(exportTaskId); - tasks = tasksClient.listTasks(new ParametersForListTasks().setRepositoryId(repositoryId).setTaskIds(taskId)); + tasks = call("listTasks(export)", () -> tasksClient.listTasks(new ParametersForListTasks().setRepositoryId(repositoryId).setTaskIds(exportTaskId))); assertNotNull(tasks); assertEquals(1, tasks.getValue().size()); taskProgress = tasks.getValue().get(0); diff --git a/src/test/java/com/laserfiche/repository/api/integration/TasksClientTest.java b/src/test/java/com/laserfiche/repository/api/integration/TasksClientTest.java index 597bfad0..24f047e7 100644 --- a/src/test/java/com/laserfiche/repository/api/integration/TasksClientTest.java +++ b/src/test/java/com/laserfiche/repository/api/integration/TasksClientTest.java @@ -30,7 +30,7 @@ void cancelTasksDoesNotReturnErrorWhenCancellingACompletedTask() { .startDeleteEntry(new ParametersForStartDeleteEntry() .setRepositoryId(repositoryId) .setEntryId(deleteEntry.getId()) - .setRequestBody(new StartDeleteEntryRequest())); + .setRequestBody(newDeleteEntryRequest())); String taskId = result.getTaskId(); assertNotNull(taskId); @@ -58,7 +58,7 @@ void listTasksWorksAndAcceptsMultipleTaskIdsAndCanBeCalledWithNoTaskIdsAndIgnore .startDeleteEntry(new ParametersForStartDeleteEntry() .setRepositoryId(repositoryId) .setEntryId(entry.getId()) - .setRequestBody(new StartDeleteEntryRequest())); + .setRequestBody(newDeleteEntryRequest())); assertNotNull(startTaskResponse); taskIds[i] = startTaskResponse.getTaskId(); }