diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d5f10bbd5..38e610ed2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -253,6 +253,8 @@ jobs: # ) runs-on: ubuntu-latest + permissions: + checks: write steps: - name: Harden Runner @@ -292,6 +294,7 @@ jobs: - name: Pip requirements run: | pip install --require-hashes -r ./testing/cucumber/requirements.txt + pip install behave-html-formatter - name: Run Docker Compose Tests run: | @@ -304,6 +307,24 @@ jobs: MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} + - name: Upload Cucumber Report + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: cucumber-report + path: testing/cucumber/report.html + retention-days: 7 + if-no-files-found: warn + + - name: Cucumber Test Report + if: always() + uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0 + with: + name: Cucumber Tests + path: testing/cucumber/junit/*.xml + reporter: java-junit + fail-on-error: false + test-build-docker-images: if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true' needs: [files-changed, build, check-generateOpenApiDocs, check-licence] diff --git a/app/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java b/app/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java index e7473140e..eff801858 100644 --- a/app/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java +++ b/app/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java @@ -158,13 +158,23 @@ public class CustomPDFDocumentFactory { // Since we don't know the size upfront, buffer to a temp file Path tempFile = createTempFile("pdf-stream-"); - - Files.copy(input, tempFile, StandardCopyOption.REPLACE_EXISTING); - PDDocument doc = loadAdaptively(tempFile.toFile(), Files.size(tempFile)); - if (!readOnly) { - postProcessDocument(doc); + boolean success = false; + try { + Files.copy(input, tempFile, StandardCopyOption.REPLACE_EXISTING); + PDDocument doc = loadAdaptively(tempFile.toFile(), Files.size(tempFile)); + if (!readOnly) { + postProcessDocument(doc); + } + success = true; + return doc; + } finally { + // On success: small files are deleted inside loadAdaptively; large files are deleted + // by DeletingRandomAccessFile when the returned PDDocument is closed. + // On failure: clean up the temp file ourselves since no one else will. + if (!success) { + Files.deleteIfExists(tempFile); + } } - return doc; } /** Load with password from InputStream */ @@ -181,14 +191,21 @@ public class CustomPDFDocumentFactory { // Since we don't know the size upfront, buffer to a temp file Path tempFile = createTempFile("pdf-stream-"); - - Files.copy(input, tempFile, StandardCopyOption.REPLACE_EXISTING); - PDDocument doc = - loadAdaptivelyWithPassword(tempFile.toFile(), Files.size(tempFile), password); - if (!readOnly) { - postProcessDocument(doc); + boolean success = false; + try { + Files.copy(input, tempFile, StandardCopyOption.REPLACE_EXISTING); + PDDocument doc = + loadAdaptivelyWithPassword(tempFile.toFile(), Files.size(tempFile), password); + if (!readOnly) { + postProcessDocument(doc); + } + success = true; + return doc; + } finally { + if (!success) { + Files.deleteIfExists(tempFile); + } } - return doc; } /** Load from a file path string */ @@ -358,9 +375,24 @@ public class CustomPDFDocumentFactory { if (size >= SMALL_FILE_THRESHOLD) { log.debug("Writing large byte array to temp file for password-protected PDF"); Path tempFile = createTempFile("pdf-bytes-"); - - Files.write(tempFile, bytes); - return Loader.loadPDF(tempFile.toFile(), password, null, null, cache); + boolean success = false; + try { + Files.write(tempFile, bytes); + // Use DeletingRandomAccessFile so the temp file is removed when the document closes + PDDocument doc = + Loader.loadPDF( + new DeletingRandomAccessFile(tempFile.toFile()), + password, + null, + null, + cache); + success = true; + return doc; + } finally { + if (!success) { + Files.deleteIfExists(tempFile); + } + } } return Loader.loadPDF(bytes, password, null, null, cache); } @@ -426,9 +458,12 @@ public class CustomPDFDocumentFactory { } } else { Path tempFile = createTempFile("pdf-save-"); - - document.save(tempFile.toFile()); - return Files.readAllBytes(tempFile); + try { + document.save(tempFile.toFile()); + return Files.readAllBytes(tempFile); + } finally { + Files.deleteIfExists(tempFile); + } } } diff --git a/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java b/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java index 3dc434423..53aa9b044 100644 --- a/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java +++ b/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java @@ -254,10 +254,11 @@ public class JobExecutorService { return ResponseEntity.internalServerError() .body(Map.of("error", "Job timed out after " + timeoutToUse + " ms")); } catch (RuntimeException e) { - // Check if this is a wrapped typed exception that should be handled by - // GlobalExceptionHandler + // Check if this is a typed exception that should be handled by + // GlobalExceptionHandler (either directly or wrapped) Throwable cause = e.getCause(); - if (cause instanceof stirling.software.common.util.ExceptionUtils.BaseAppException + if (e instanceof IllegalArgumentException + || cause instanceof stirling.software.common.util.ExceptionUtils.BaseAppException || cause instanceof stirling.software.common.util.ExceptionUtils diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/AnalysisController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/AnalysisController.java index 13f830b30..d84aceb1f 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/AnalysisController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/AnalysisController.java @@ -8,10 +8,12 @@ import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocumentInformation; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPageTree; +import org.apache.pdfbox.pdmodel.PDResources; import org.apache.pdfbox.pdmodel.encryption.PDEncryption; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation; import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import io.swagger.v3.oas.annotations.Operation; @@ -35,9 +37,9 @@ public class AnalysisController { @Operation( summary = "Get PDF page count", description = "Returns total number of pages in PDF. Input:PDF Output:JSON Type:SISO") - public Map getPageCount(@ModelAttribute PDFFile file) throws IOException { + public ResponseEntity getPageCount(@ModelAttribute PDFFile file) throws IOException { try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) { - return Map.of("pageCount", document.getNumberOfPages()); + return ResponseEntity.ok(Map.of("pageCount", document.getNumberOfPages())); } } @@ -46,13 +48,13 @@ public class AnalysisController { @Operation( summary = "Get basic PDF information", description = "Returns page count, version, file size. Input:PDF Output:JSON Type:SISO") - public Map getBasicInfo(@ModelAttribute PDFFile file) throws IOException { + public ResponseEntity getBasicInfo(@ModelAttribute PDFFile file) throws IOException { try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) { Map info = new HashMap<>(); info.put("pageCount", document.getNumberOfPages()); info.put("pdfVersion", document.getVersion()); info.put("fileSize", file.getFileInput().getSize()); - return info; + return ResponseEntity.ok(info); } } @@ -63,7 +65,7 @@ public class AnalysisController { @Operation( summary = "Get PDF document properties", description = "Returns title, author, subject, etc. Input:PDF Output:JSON Type:SISO") - public Map getDocumentProperties(@ModelAttribute PDFFile file) + public ResponseEntity getDocumentProperties(@ModelAttribute PDFFile file) throws IOException { // Load the document in read-only mode to prevent modifications and ensure the integrity of // the original file. @@ -76,9 +78,15 @@ public class AnalysisController { properties.put("keywords", info.getKeywords()); properties.put("creator", info.getCreator()); properties.put("producer", info.getProducer()); - properties.put("creationDate", info.getCreationDate().toString()); - properties.put("modificationDate", info.getModificationDate().toString()); - return properties; + properties.put( + "creationDate", + info.getCreationDate() != null ? info.getCreationDate().toString() : null); + properties.put( + "modificationDate", + info.getModificationDate() != null + ? info.getModificationDate().toString() + : null); + return ResponseEntity.ok(properties); } } @@ -87,8 +95,7 @@ public class AnalysisController { @Operation( summary = "Get page dimensions for all pages", description = "Returns width and height of each page. Input:PDF Output:JSON Type:SISO") - public List> getPageDimensions(@ModelAttribute PDFFile file) - throws IOException { + public ResponseEntity getPageDimensions(@ModelAttribute PDFFile file) throws IOException { try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) { List> dimensions = new ArrayList<>(); PDPageTree pages = document.getPages(); @@ -99,7 +106,7 @@ public class AnalysisController { pageDim.put("height", page.getBBox().getHeight()); dimensions.add(pageDim); } - return dimensions; + return ResponseEntity.ok(dimensions); } } @@ -109,7 +116,7 @@ public class AnalysisController { summary = "Get form field information", description = "Returns count and details of form fields. Input:PDF Output:JSON Type:SISO") - public Map getFormFields(@ModelAttribute PDFFile file) throws IOException { + public ResponseEntity getFormFields(@ModelAttribute PDFFile file) throws IOException { try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) { Map formInfo = new HashMap<>(); PDAcroForm form = document.getDocumentCatalog().getAcroForm(); @@ -123,7 +130,7 @@ public class AnalysisController { formInfo.put("hasXFA", false); formInfo.put("isSignaturesExist", false); } - return formInfo; + return ResponseEntity.ok(formInfo); } } @@ -132,7 +139,7 @@ public class AnalysisController { @Operation( summary = "Get annotation information", description = "Returns count and types of annotations. Input:PDF Output:JSON Type:SISO") - public Map getAnnotationInfo(@ModelAttribute PDFFile file) throws IOException { + public ResponseEntity getAnnotationInfo(@ModelAttribute PDFFile file) throws IOException { try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) { Map annotInfo = new HashMap<>(); int totalAnnotations = 0; @@ -148,7 +155,7 @@ public class AnalysisController { annotInfo.put("totalCount", totalAnnotations); annotInfo.put("typeBreakdown", annotationTypes); - return annotInfo; + return ResponseEntity.ok(annotInfo); } } @@ -158,20 +165,23 @@ public class AnalysisController { summary = "Get font information", description = "Returns list of fonts used in the document. Input:PDF Output:JSON Type:SISO") - public Map getFontInfo(@ModelAttribute PDFFile file) throws IOException { + public ResponseEntity getFontInfo(@ModelAttribute PDFFile file) throws IOException { try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) { Map fontInfo = new HashMap<>(); Set fontNames = new HashSet<>(); for (PDPage page : document.getPages()) { - for (COSName font : page.getResources().getFontNames()) { - fontNames.add(font.getName()); + PDResources resources = page.getResources(); + if (resources != null) { + for (COSName font : resources.getFontNames()) { + fontNames.add(font.getName()); + } } } fontInfo.put("fontCount", fontNames.size()); fontInfo.put("fonts", fontNames); - return fontInfo; + return ResponseEntity.ok(fontInfo); } } @@ -181,7 +191,7 @@ public class AnalysisController { summary = "Get security information", description = "Returns encryption and permission details. Input:PDF Output:JSON Type:SISO") - public Map getSecurityInfo(@ModelAttribute PDFFile file) throws IOException { + public ResponseEntity getSecurityInfo(@ModelAttribute PDFFile file) throws IOException { try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) { Map securityInfo = new HashMap<>(); PDEncryption encryption = document.getEncryption(); @@ -208,7 +218,7 @@ public class AnalysisController { securityInfo.put("isEncrypted", false); } - return securityInfo; + return ResponseEntity.ok(securityInfo); } } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java index be5411bc1..7a4f12d69 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java @@ -399,7 +399,12 @@ public class MergeController { String mergedFileName = GeneralUtils.generateFilename(firstFilename, "_merged_unsigned.pdf"); - byte[] pdfBytes = Files.readAllBytes(outputTempFile.getPath()); + byte[] pdfBytes; + try { + pdfBytes = Files.readAllBytes(outputTempFile.getPath()); + } finally { + outputTempFile.close(); + } return WebResponseUtils.bytesToWebResponse(pdfBytes, mergedFileName); } } diff --git a/docker/embedded/compose/test_cicd.yml b/docker/embedded/compose/test_cicd.yml index 08ae0330a..dd692dba2 100644 --- a/docker/embedded/compose/test_cicd.yml +++ b/docker/embedded/compose/test_cicd.yml @@ -20,7 +20,6 @@ services: environment: DISABLE_ADDITIONAL_FEATURES: "false" SECURITY_ENABLELOGIN: "true" - V2: "false" PUID: 1002 PGID: 1002 UMASK: "022" @@ -31,5 +30,6 @@ services: SYSTEM_MAXFILESIZE: "100" METRICS_ENABLED: "true" SYSTEM_GOOGLEVISIBILITY: "true" + SYSTEM_ENABLEMOBILESCANNER: "true" SECURITY_CUSTOMGLOBALAPIKEY: "123456789" restart: on-failure:5 diff --git a/testing/cucumber/behave.ini b/testing/cucumber/behave.ini new file mode 100644 index 000000000..202b26576 --- /dev/null +++ b/testing/cucumber/behave.ini @@ -0,0 +1,8 @@ +[behave] +# Enterprise and premium-licensed features live in features/enterprise/. +# They are excluded from the default CI run because the test environment +# does not have a commercial licence. To run them explicitly: +# +# python -m behave features/enterprise +# +exclude_re = features/enterprise diff --git a/testing/cucumber/exampleFiles/example.msg b/testing/cucumber/exampleFiles/example.msg new file mode 100644 index 000000000..ffb7f0398 Binary files /dev/null and b/testing/cucumber/exampleFiles/example.msg differ diff --git a/testing/cucumber/features/admin_settings.feature b/testing/cucumber/features/admin_settings.feature new file mode 100644 index 000000000..689fb48e8 --- /dev/null +++ b/testing/cucumber/features/admin_settings.feature @@ -0,0 +1,77 @@ +@jwt @auth @admin_settings +Feature: Admin Settings API + + Tests for the admin settings REST API endpoints, which expose application + configuration values to authenticated admins. + + All endpoints require ROLE_ADMIN. Non-admin / unauthenticated requests must + receive 401 or 403. + + Admin credentials: username=admin, password=stirling + + # ========================================================================= + # GET ALL SETTINGS + # ========================================================================= + + @positive + Scenario: Admin can retrieve all application settings + Given I am logged in as admin + When I send a GET request to "/api/v1/admin/settings" with JWT authentication + Then the response status code should be 200 + And the response body should not be empty + + @negative + Scenario: Unauthenticated request to settings returns 401 + When I send a GET request to "/api/v1/admin/settings" with no authentication + Then the response status code should be 401 + + # ========================================================================= + # GET SETTINGS DELTA + # ========================================================================= + + @positive + Scenario: Admin can retrieve the settings delta (changed values) + Given I am logged in as admin + When I send a GET request to "/api/v1/admin/settings/delta" with JWT authentication + Then the response status code should be 200 + + @negative + Scenario: Unauthenticated request to settings delta returns 401 + When I send a GET request to "/api/v1/admin/settings/delta" with no authentication + Then the response status code should be 401 + + # ========================================================================= + # GET SETTINGS BY SECTION + # ========================================================================= + + @positive + Scenario: Admin can retrieve settings for the system section + Given I am logged in as admin + When I send a GET request to "/api/v1/admin/settings/section/system" with JWT authentication + Then the response status code should be one of "200, 404" + + @positive + Scenario: Admin can retrieve settings for the security section + Given I am logged in as admin + When I send a GET request to "/api/v1/admin/settings/section/security" with JWT authentication + Then the response status code should be one of "200, 404" + + @negative + Scenario: Unauthenticated request to settings section returns 401 + When I send a GET request to "/api/v1/admin/settings/section/system" with no authentication + Then the response status code should be 401 + + # ========================================================================= + # GET SINGLE SETTING BY KEY + # ========================================================================= + + @positive + Scenario: Admin can retrieve a single setting by key + Given I am logged in as admin + When I send a GET request to "/api/v1/admin/settings/key/system.defaultLocale" with JWT authentication + Then the response status code should be one of "200, 404" + + @negative + Scenario: Unauthenticated request to settings key returns 401 + When I send a GET request to "/api/v1/admin/settings/key/system.defaultLocale" with no authentication + Then the response status code should be 401 diff --git a/testing/cucumber/features/analysis.feature b/testing/cucumber/features/analysis.feature new file mode 100644 index 000000000..0f7ed17b3 --- /dev/null +++ b/testing/cucumber/features/analysis.feature @@ -0,0 +1,201 @@ +@analysis +Feature: Analysis API Endpoints + + Analysis endpoints inspect a PDF and return structured JSON (HTTP 200). + No binary output is produced; all responses are application/json with + a non-empty body. + + + # --------------------------------------------------------------------------- + # /api/v1/analysis/page-count + # --------------------------------------------------------------------------- + + @page-count @positive + Scenario Outline: page-count returns JSON with pageCount for different page numbers + Given I generate a PDF file as "fileInput" + And the pdf contains pages + When I send the API request to the endpoint "/api/v1/analysis/page-count" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 0 + + Examples: + | pages | + | 1 | + | 5 | + | 20 | + + + # --------------------------------------------------------------------------- + # /api/v1/analysis/basic-info + # --------------------------------------------------------------------------- + + @basic-info @positive + Scenario: basic-info returns JSON for a standard PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 4 pages + When I send the API request to the endpoint "/api/v1/analysis/basic-info" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 0 + + @basic-info @positive + Scenario: basic-info returns JSON for a single-page PDF + Given I generate a PDF file as "fileInput" + When I send the API request to the endpoint "/api/v1/analysis/basic-info" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 0 + + @basic-info @positive + Scenario: basic-info returns JSON for a PDF with text content + Given I generate a PDF file as "fileInput" + And the pdf contains 3 pages with random text + When I send the API request to the endpoint "/api/v1/analysis/basic-info" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 0 + + + # --------------------------------------------------------------------------- + # /api/v1/analysis/document-properties + # --------------------------------------------------------------------------- + + @document-properties @positive + Scenario: document-properties returns JSON for a plain PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + When I send the API request to the endpoint "/api/v1/analysis/document-properties" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 0 + + @document-properties @positive + Scenario: document-properties returns JSON for a multi-page PDF with text + Given I generate a PDF file as "fileInput" + And the pdf contains 5 pages with random text + When I send the API request to the endpoint "/api/v1/analysis/document-properties" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 0 + + + # --------------------------------------------------------------------------- + # /api/v1/analysis/page-dimensions + # --------------------------------------------------------------------------- + + @page-dimensions @positive + Scenario Outline: page-dimensions returns JSON for PDFs of different sizes + Given I generate a PDF file as "fileInput" + And the pdf contains pages + When I send the API request to the endpoint "/api/v1/analysis/page-dimensions" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 0 + + Examples: + | pages | + | 1 | + | 3 | + | 10 | + + @page-dimensions @positive + Scenario: page-dimensions returns JSON for a LETTER-sized PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages with random text + When I send the API request to the endpoint "/api/v1/analysis/page-dimensions" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 0 + + + # --------------------------------------------------------------------------- + # /api/v1/analysis/form-fields + # --------------------------------------------------------------------------- + + @form-fields @positive + Scenario: form-fields returns JSON for a PDF without any form fields + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + When I send the API request to the endpoint "/api/v1/analysis/form-fields" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 0 + + @form-fields @positive + Scenario: form-fields returns JSON for a multi-page PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 10 pages + When I send the API request to the endpoint "/api/v1/analysis/form-fields" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 0 + + + # --------------------------------------------------------------------------- + # /api/v1/analysis/annotation-info + # --------------------------------------------------------------------------- + + @annotation-info @positive + Scenario: annotation-info returns JSON for a PDF with no annotations + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + When I send the API request to the endpoint "/api/v1/analysis/annotation-info" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 0 + + @annotation-info @positive + Scenario: annotation-info returns JSON for a text-content PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 4 pages with random text + When I send the API request to the endpoint "/api/v1/analysis/annotation-info" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 0 + + + # --------------------------------------------------------------------------- + # /api/v1/analysis/font-info + # --------------------------------------------------------------------------- + + @font-info @positive + Scenario: font-info returns JSON for a blank PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + When I send the API request to the endpoint "/api/v1/analysis/font-info" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 0 + + @font-info @positive + Scenario: font-info returns JSON for a PDF containing text + Given I generate a PDF file as "fileInput" + And the pdf contains 3 pages with random text + When I send the API request to the endpoint "/api/v1/analysis/font-info" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 0 + + + # --------------------------------------------------------------------------- + # /api/v1/analysis/security-info + # --------------------------------------------------------------------------- + + @security-info @positive + Scenario: security-info returns JSON for an unencrypted PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + When I send the API request to the endpoint "/api/v1/analysis/security-info" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 0 + + @security-info @positive + Scenario: security-info returns JSON for a multi-page PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 5 pages with random text + When I send the API request to the endpoint "/api/v1/analysis/security-info" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 0 diff --git a/testing/cucumber/features/attachments.feature b/testing/cucumber/features/attachments.feature new file mode 100644 index 000000000..6bb9cfd6e --- /dev/null +++ b/testing/cucumber/features/attachments.feature @@ -0,0 +1,82 @@ +@misc +Feature: Attachments API Validation + + @add-attachments @positive + Scenario: Add a single attachment to PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages with random text + And I also generate a PDF file as "attachments" + When I send the API request to the endpoint "/api/v1/misc/add-attachments" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + And the response file should have extension ".pdf" + + @add-attachments @positive + Scenario: Add multiple attachments to PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages with random text + And I also generate a PDF file as "attachments" + And I also generate a PDF file as "attachments" + When I send the API request to the endpoint "/api/v1/misc/add-attachments" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + And the response file should have extension ".pdf" + + @list-attachments @positive + Scenario: List attachments in PDF with embedded attachment + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + And the pdf has an attachment named "test_doc.txt" + When I send the API request to the endpoint "/api/v1/misc/list-attachments" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 0 + + @list-attachments @positive + Scenario: List attachments in plain PDF returns empty list + Given I generate a PDF file as "fileInput" + And the pdf contains 1 pages + When I send the API request to the endpoint "/api/v1/misc/list-attachments" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 0 + + @extract-attachments @positive + Scenario: Extract attachments from PDF with embedded attachment + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + And the pdf has an attachment named "report.txt" + When I send the API request to the endpoint "/api/v1/misc/extract-attachments" + Then the response status code should be 200 + And the response file should have size greater than 0 + + @rename-attachment @positive + Scenario: Rename attachment in PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + And the pdf has an attachment named "original.txt" + And the request data includes + | parameter | value | + | attachmentName | original.txt | + | newName | renamed.txt | + When I send the API request to the endpoint "/api/v1/misc/rename-attachment" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + And the response file should have extension ".pdf" + + @delete-attachment @positive + Scenario: Delete attachment from PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + And the pdf has an attachment named "to_delete.txt" + And the request data includes + | parameter | value | + | attachmentName | to_delete.txt | + When I send the API request to the endpoint "/api/v1/misc/delete-attachment" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + And the response file should have extension ".pdf" diff --git a/testing/cucumber/features/auto_split.feature b/testing/cucumber/features/auto_split.feature new file mode 100644 index 000000000..c8297aa03 --- /dev/null +++ b/testing/cucumber/features/auto_split.feature @@ -0,0 +1,41 @@ +@misc +Feature: Auto-Split PDF API Validation + + @auto-split @positive + Scenario: Auto-split PDF with QR code marker on first page + Given I generate a PDF file as "fileInput" + And the pdf contains 4 pages with random text + And the pdf has a Stirling-PDF QR code split marker on page 1 + When I send the API request to the endpoint "/api/v1/misc/auto-split-pdf" + Then the response status code should be 200 + And the response file should have size greater than 0 + + @auto-split @positive + Scenario: Auto-split PDF with QR code marker on middle page + Given I generate a PDF file as "fileInput" + And the pdf contains 5 pages with random text + And the pdf has a Stirling-PDF QR code split marker on page 3 + When I send the API request to the endpoint "/api/v1/misc/auto-split-pdf" + Then the response status code should be 200 + And the response file should have size greater than 0 + + @auto-split @positive + Scenario: Auto-split PDF with duplex mode enabled + Given I generate a PDF file as "fileInput" + And the pdf contains 4 pages with random text + And the pdf has a Stirling-PDF QR code split marker on page 1 + And the request data includes + | parameter | value | + | duplexMode | true | + When I send the API request to the endpoint "/api/v1/misc/auto-split-pdf" + Then the response status code should be 200 + And the response file should have size greater than 0 + + @auto-split @positive + Scenario: Auto-split single-page PDF with QR marker + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages with random text + And the pdf has a Stirling-PDF QR code split marker on page 1 + When I send the API request to the endpoint "/api/v1/misc/auto-split-pdf" + Then the response status code should be 200 + And the response file should have size greater than 0 diff --git a/testing/cucumber/features/bookmarks_chapters.feature b/testing/cucumber/features/bookmarks_chapters.feature new file mode 100644 index 000000000..8bda99172 --- /dev/null +++ b/testing/cucumber/features/bookmarks_chapters.feature @@ -0,0 +1,63 @@ +@general +Feature: Bookmarks and Chapter Splitting API Validation + + @extract-bookmarks @positive + Scenario: Extract bookmarks from PDF with bookmarks + Given I generate a PDF file as "file" + And the pdf contains 3 pages with random text + And the pdf has bookmarks + When I send the API request to the endpoint "/api/v1/general/extract-bookmarks" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 0 + + @extract-bookmarks @positive + Scenario: Extract bookmarks from plain PDF returns empty list + Given I generate a PDF file as "file" + And the pdf contains 2 pages + When I send the API request to the endpoint "/api/v1/general/extract-bookmarks" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 0 + + @split-pdf-by-chapters @positive + Scenario: Split PDF by chapters with top-level bookmarks + Given I generate a PDF file as "fileInput" + And the pdf contains 4 pages with random text + And the pdf has bookmarks + And the request data includes + | parameter | value | + | bookmarkLevel | 0 | + | includeMetadata | false | + | allowDuplicates | false | + When I send the API request to the endpoint "/api/v1/general/split-pdf-by-chapters" + Then the response status code should be 200 + And the response file should have size greater than 0 + + @split-pdf-by-chapters @positive + Scenario: Split PDF by chapters with metadata included + Given I generate a PDF file as "fileInput" + And the pdf contains 3 pages with random text + And the pdf has bookmarks + And the request data includes + | parameter | value | + | bookmarkLevel | 0 | + | includeMetadata | true | + | allowDuplicates | false | + When I send the API request to the endpoint "/api/v1/general/split-pdf-by-chapters" + Then the response status code should be 200 + And the response file should have size greater than 0 + + @split-pdf-by-chapters @positive + Scenario: Split PDF by chapters allowing duplicate pages + Given I generate a PDF file as "fileInput" + And the pdf contains 3 pages with random text + And the pdf has bookmarks + And the request data includes + | parameter | value | + | bookmarkLevel | 0 | + | includeMetadata | false | + | allowDuplicates | true | + When I send the API request to the endpoint "/api/v1/general/split-pdf-by-chapters" + Then the response status code should be 200 + And the response file should have size greater than 0 diff --git a/testing/cucumber/features/convert_comic.feature b/testing/cucumber/features/convert_comic.feature new file mode 100644 index 000000000..be1db0432 --- /dev/null +++ b/testing/cucumber/features/convert_comic.feature @@ -0,0 +1,23 @@ +@convert @image +Feature: Comic Archive Conversion API Validation + + @cbz-to-pdf @positive + Scenario: Convert CBZ comic archive to PDF + Given I generate a CBZ comic archive file as "fileInput" + When I send the API request to the endpoint "/api/v1/convert/cbz/pdf" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + And the response file should have extension ".pdf" + + @cbz-to-pdf @positive + Scenario: Convert CBZ comic archive to PDF without ebook optimisation + Given I generate a CBZ comic archive file as "fileInput" + And the request data includes + | parameter | value | + | optimizeForEbook | false | + When I send the API request to the endpoint "/api/v1/convert/cbz/pdf" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + And the response file should have extension ".pdf" diff --git a/testing/cucumber/features/convert_eml.feature b/testing/cucumber/features/convert_eml.feature new file mode 100644 index 000000000..916ca1585 --- /dev/null +++ b/testing/cucumber/features/convert_eml.feature @@ -0,0 +1,25 @@ +@convert +Feature: EML to PDF Conversion API Validation + + @eml-to-pdf @positive + Scenario: Convert EML email to PDF + Given I generate an EML email file as "fileInput" + When I send the API request to the endpoint "/api/v1/convert/eml/pdf" + Then the response status code should be 200 + And the response file should have size greater than 0 + + @eml-to-pdf @positive + Scenario: Convert EML with subject and body to PDF + Given I generate an EML email file as "fileInput" + When I send the API request to the endpoint "/api/v1/convert/eml/pdf" + Then the response status code should be 200 + And the response file should have size greater than 0 + And the response file should have extension ".pdf" + + @eml-to-pdf @positive + Scenario: Convert MSG (Outlook) file to PDF + Given I use an example file at "exampleFiles/example.msg" as parameter "fileInput" + When I send the API request to the endpoint "/api/v1/convert/eml/pdf" + Then the response status code should be 200 + And the response file should have size greater than 0 + And the response file should have extension ".pdf" diff --git a/testing/cucumber/features/convert_images.feature b/testing/cucumber/features/convert_images.feature new file mode 100644 index 000000000..47db7b4a9 --- /dev/null +++ b/testing/cucumber/features/convert_images.feature @@ -0,0 +1,99 @@ +@convert +Feature: Image Conversion API Validation + + @img-to-pdf @positive + Scenario: Convert single PNG image to PDF + Given I generate a PNG image file as "fileInput" + When I send the API request to the endpoint "/api/v1/convert/img/pdf" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + And the response file should have extension ".pdf" + + @img-to-pdf @positive + Scenario: Convert PNG image to PDF with fillPage fit option + Given I generate a PNG image file as "fileInput" + And the request data includes + | parameter | value | + | fitOption | fillPage | + | colorType | color | + | autoRotate | false | + When I send the API request to the endpoint "/api/v1/convert/img/pdf" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + And the response file should have extension ".pdf" + + @img-to-pdf @positive + Scenario: Convert multiple PNG images to PDF + Given I generate a PNG image file as "fileInput" + And I also generate a PNG image file as "fileInput" + And I also generate a PNG image file as "fileInput" + When I send the API request to the endpoint "/api/v1/convert/img/pdf" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + And the response file should have extension ".pdf" + + @svg-to-pdf @positive + Scenario: Convert single SVG file to PDF + Given I generate an SVG file as "fileInput" + When I send the API request to the endpoint "/api/v1/convert/svg/pdf" + Then the response status code should be 200 + And the response file should have size greater than 0 + + @svg-to-pdf @positive + Scenario: Convert multiple SVG files to PDF + Given I generate an SVG file as "fileInput" + And I also generate an SVG file as "fileInput" + When I send the API request to the endpoint "/api/v1/convert/svg/pdf" + Then the response status code should be 200 + And the response file should have size greater than 0 + + @add-image @positive + Scenario: Overlay PNG image onto PDF at default position + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages with random text + And I generate a PNG image file as "imageFile" + And the request data includes + | parameter | value | + | x | 0 | + | y | 0 | + | everyPage | false | + When I send the API request to the endpoint "/api/v1/misc/add-image" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + And the response file should have extension ".pdf" + + @add-image @positive + Scenario: Overlay PNG image onto every page of PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 3 pages with random text + And I generate a PNG image file as "imageFile" + And the request data includes + | parameter | value | + | x | 10 | + | y | 10 | + | everyPage | true | + When I send the API request to the endpoint "/api/v1/misc/add-image" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + And the response file should have extension ".pdf" + + @add-image @positive + Scenario: Overlay SVG image onto PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages with random text + And I generate an SVG file as "imageFile" + And the request data includes + | parameter | value | + | x | 50 | + | y | 50 | + | everyPage | true | + When I send the API request to the endpoint "/api/v1/misc/add-image" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + And the response file should have extension ".pdf" diff --git a/testing/cucumber/features/convert_new.feature b/testing/cucumber/features/convert_new.feature new file mode 100644 index 000000000..595f6df5c --- /dev/null +++ b/testing/cucumber/features/convert_new.feature @@ -0,0 +1,97 @@ +@convert +Feature: Convert API Validation (additional endpoints) + + @ghostscript @positive + Scenario Outline: Convert PDF to vector format + Given I generate a PDF file as "fileInput" + And the pdf contains 1 pages with random text + And the request data includes + | parameter | value | + | outputFormat | | + When I send the API request to the endpoint "/api/v1/convert/pdf/vector" + Then the response status code should be 200 + And the response file should have size greater than 0 + And the response file should have extension "" + + @pdf-to-eps + Examples: + | format | extension | + | eps | .eps | + + Examples: + | format | extension | + | ps | .ps | + | pcl | .pcl | + | xps | .xps | + + @image @positive @pdf-to-cbz + Scenario: Convert PDF to CBZ with default DPI + Given I generate a PDF file as "fileInput" + And the pdf contains 3 pages with random text + When I send the API request to the endpoint "/api/v1/convert/pdf/cbz" + Then the response status code should be 200 + And the response file should have size greater than 0 + And the response file should have extension ".cbz" + + @image @positive @pdf-to-cbz + Scenario: Convert PDF to CBZ with low DPI + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages with random text + And the request data includes + | parameter | value | + | dpi | 72 | + When I send the API request to the endpoint "/api/v1/convert/pdf/cbz" + Then the response status code should be 200 + And the response file should have size greater than 0 + And the response file should have extension ".cbz" + + @image @positive @pdf-to-cbz + Scenario: Convert single-page PDF to CBZ + Given I generate a PDF file as "fileInput" + And the pdf contains 1 pages + And the request data includes + | parameter | value | + | dpi | 72 | + When I send the API request to the endpoint "/api/v1/convert/pdf/cbz" + Then the response status code should be 200 + And the response file should have size greater than 0 + And the response file should have extension ".cbz" + + @calibre @positive @pdf-to-epub + Scenario: Convert PDF to EPUB format + Given I generate a PDF file as "fileInput" + And the pdf contains 3 pages with random text + And the request data includes + | parameter | value | + | outputFormat | EPUB | + | detectChapters | false | + When I send the API request to the endpoint "/api/v1/convert/pdf/epub" + Then the response status code should be 200 + And the response file should have size greater than 0 + And the response file should have extension ".epub" + + @calibre @positive @pdf-to-epub + Scenario: Convert PDF to AZW3 format + Given I generate a PDF file as "fileInput" + And the pdf contains 3 pages with random text + And the request data includes + | parameter | value | + | outputFormat | AZW3 | + | detectChapters | false | + When I send the API request to the endpoint "/api/v1/convert/pdf/epub" + Then the response status code should be 200 + And the response file should have size greater than 0 + And the response file should have extension ".azw3" + + @calibre @positive @pdf-to-epub + Scenario: Convert PDF to EPUB with chapter detection + Given I generate a PDF file as "fileInput" + And the pdf contains 5 pages with random text + And the request data includes + | parameter | value | + | outputFormat | EPUB | + | detectChapters | true | + When I send the API request to the endpoint "/api/v1/convert/pdf/epub" + Then the response status code should be 200 + And the response file should have size greater than 0 + And the response file should have extension ".epub" diff --git a/testing/cucumber/features/enterprise/audit.feature b/testing/cucumber/features/enterprise/audit.feature new file mode 100644 index 000000000..bf21caede --- /dev/null +++ b/testing/cucumber/features/enterprise/audit.feature @@ -0,0 +1,138 @@ +@jwt @auth @audit +Feature: Audit Dashboard API + + Tests for the audit dashboard REST API endpoints, which provide + audit log data, statistics, and export capabilities. + + All endpoints: + - Require ROLE_ADMIN (JWT authentication) + - Are gated by @EnterpriseEndpoint (may return 403 on non-enterprise builds) + + Responses are therefore expected to be one of: 200 (enterprise enabled) + or 403 (enterprise feature not available in this build). + + Admin credentials: username=admin, password=stirling + + # ========================================================================= + # AUDIT DATA + # ========================================================================= + + @positive + Scenario: Admin can retrieve audit log data + Given I am logged in as admin + When I send a GET request to "/api/v1/audit/data" with JWT authentication + Then the response status code should be one of "200, 403" + + @negative + Scenario: Unauthenticated request to audit data returns 401 + When I send a GET request to "/api/v1/audit/data" with no authentication + Then the response status code should be 401 + + # ========================================================================= + # AUDIT STATS + # ========================================================================= + + @positive + Scenario: Admin can retrieve audit statistics + Given I am logged in as admin + When I send a GET request to "/api/v1/audit/stats" with JWT authentication + Then the response status code should be one of "200, 403" + + @negative + Scenario: Unauthenticated request to audit stats returns 401 + When I send a GET request to "/api/v1/audit/stats" with no authentication + Then the response status code should be 401 + + # ========================================================================= + # AUDIT TYPES + # ========================================================================= + + @positive + Scenario: Admin can retrieve audit event types + Given I am logged in as admin + When I send a GET request to "/api/v1/audit/types" with JWT authentication + Then the response status code should be one of "200, 403" + + @negative + Scenario: Unauthenticated request to audit types returns 401 + When I send a GET request to "/api/v1/audit/types" with no authentication + Then the response status code should be 401 + + # ========================================================================= + # AUDIT EXPORT (CSV) + # ========================================================================= + + @positive + Scenario: Admin can export audit log as CSV + Given I am logged in as admin + When I send a GET request to "/api/v1/audit/export/csv" with JWT authentication + Then the response status code should be one of "200, 403" + + @negative + Scenario: Unauthenticated request to audit CSV export returns 401 + When I send a GET request to "/api/v1/audit/export/csv" with no authentication + Then the response status code should be 401 + + # ========================================================================= + # AUDIT EXPORT (JSON) + # ========================================================================= + + @positive + Scenario: Admin can export audit log as JSON + Given I am logged in as admin + When I send a GET request to "/api/v1/audit/export/json" with JWT authentication + Then the response status code should be one of "200, 403" + + @negative + Scenario: Unauthenticated request to audit JSON export returns 401 + When I send a GET request to "/api/v1/audit/export/json" with no authentication + Then the response status code should be 401 + + # ========================================================================= + # AUDIT CLEANUP + # ========================================================================= + + @positive + Scenario: Admin can trigger cleanup of old audit records + Given I am logged in as admin + When I send a DELETE request to "/api/v1/audit/cleanup/before" with JWT authentication and params "date=2020-01-01" + Then the response status code should be one of "200, 403" + + @negative + Scenario: Unauthenticated request to audit cleanup returns 401 + When I send a DELETE request to "/api/v1/audit/cleanup/before" with no authentication and params "date=2020-01-01" + Then the response status code should be 401 + + # ========================================================================= + # PROPRIETARY UI DATA – AUDIT EVENTS (AuditRestController) + # Endpoint base: /api/v1/proprietary/ui-data + # ========================================================================= + + @positive + Scenario: Admin can retrieve paginated audit events from UI data API + Given I am logged in as admin + When I send a GET request to "/api/v1/proprietary/ui-data/audit-events" with JWT authentication + Then the response status code should be one of "200, 403" + + @positive + Scenario: Admin can retrieve audit chart data + Given I am logged in as admin + When I send a GET request to "/api/v1/proprietary/ui-data/audit-charts" with JWT authentication + Then the response status code should be one of "200, 403" + + @positive + Scenario: Admin can retrieve list of audit event types from UI data API + Given I am logged in as admin + When I send a GET request to "/api/v1/proprietary/ui-data/audit-event-types" with JWT authentication + Then the response status code should be one of "200, 403" + + @positive + Scenario: Admin can retrieve list of audited users + Given I am logged in as admin + When I send a GET request to "/api/v1/proprietary/ui-data/audit-users" with JWT authentication + Then the response status code should be one of "200, 403" + + @negative + Scenario: Unauthenticated request to proprietary audit events returns 401 + When I send a GET request to "/api/v1/proprietary/ui-data/audit-events" with no authentication + Then the response status code should be 401 diff --git a/testing/cucumber/features/enterprise/signatures.feature b/testing/cucumber/features/enterprise/signatures.feature new file mode 100644 index 000000000..c999da7af --- /dev/null +++ b/testing/cucumber/features/enterprise/signatures.feature @@ -0,0 +1,45 @@ +@jwt @auth @signature +Feature: Signatures API + + Tests for the saved signatures REST API, which allows authenticated + users to store and retrieve their signature images. + + Endpoints: + - GET /api/v1/proprietary/signatures (authenticated) + - POST /api/v1/proprietary/signatures (authenticated, multipart) + - DELETE /api/v1/proprietary/signatures/{id} (authenticated) + + POST is omitted here because it requires a multipart image upload; the + format of SavedSignatureRequest is tested via integration rather than BDD. + + Admin credentials: username=admin, password=stirling + + # ========================================================================= + # LIST SIGNATURES + # ========================================================================= + + @positive + Scenario: Authenticated user can retrieve their signatures list + Given I am logged in as admin + When I send a GET request to "/api/v1/proprietary/signatures" with JWT authentication + Then the response status code should be one of "200, 403" + + @negative + Scenario: Unauthenticated request to signatures list returns 401 + When I send a GET request to "/api/v1/proprietary/signatures" with no authentication + Then the response status code should be 401 + + # ========================================================================= + # DELETE SIGNATURE + # ========================================================================= + + @negative + Scenario: Delete a non-existent signature returns 404 or 403 + Given I am logged in as admin + When I send a DELETE request to "/api/v1/proprietary/signatures/nonexistent-sig-id-xyz" with JWT authentication + Then the response status code should be one of "403, 404" + + @negative + Scenario: Unauthenticated request to delete signature returns 401 + When I send a DELETE request to "/api/v1/proprietary/signatures/some-id" with no authentication + Then the response status code should be 401 diff --git a/testing/cucumber/features/enterprise/steps/steps.py b/testing/cucumber/features/enterprise/steps/steps.py new file mode 100644 index 000000000..fb347015a --- /dev/null +++ b/testing/cucumber/features/enterprise/steps/steps.py @@ -0,0 +1,21 @@ +""" +Re-export all step definitions so that `behave features/enterprise/` works +as a standalone run without needing to reference the parent steps directory. + +When running the default suite (`behave` from testing/cucumber/), these +enterprise features are excluded via behave.ini. When running enterprise +tests explicitly (`python -m behave features/enterprise`), Behave loads +steps only from this directory, so we import the parent implementations here. +""" + +import os +import sys + +# Make the parent steps/ directory importable +_parent_steps = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../steps")) +if _parent_steps not in sys.path: + sys.path.insert(0, _parent_steps) + +from step_definitions import * # noqa: F401, F403 +from auth_step_definitions import * # noqa: F401, F403 +from enterprise_step_definitions import * # noqa: F401, F403 diff --git a/testing/cucumber/features/enterprise/teams.feature b/testing/cucumber/features/enterprise/teams.feature new file mode 100644 index 000000000..9697b1c89 --- /dev/null +++ b/testing/cucumber/features/enterprise/teams.feature @@ -0,0 +1,80 @@ +@jwt @auth @team +Feature: Teams API + + Tests for the teams REST API, which provides multi-user grouping + functionality (a @PremiumEndpoint feature). + + Endpoints: + - POST /api/v1/teams/create (admin only, query param: name) + - POST /api/v1/teams/rename (admin only, query params: teamId, name) + - POST /api/v1/teams/delete (admin only, query param: teamId) + - POST /api/v1/teams/addUser (admin only, query params: teamId, username) + + Because this is a @PremiumEndpoint, responses may be 200 (premium enabled) + or 403 (premium not available in this build). + + There is no GET /teams endpoint, so full CRUD lifecycle cannot be verified + via ID-based lookup. Tests are limited to exercising each endpoint and + checking the response is not a security bypass. + + Admin credentials: username=admin, password=stirling + + # ========================================================================= + # CREATE TEAM + # ========================================================================= + + @positive + Scenario: Admin can attempt to create a new team + Given I am logged in as admin + When I send a POST request to "/api/v1/teams/create" with JWT authentication and params "name=bdd_test_team" + Then the response status code should be one of "200, 201, 403" + + @negative + Scenario: Unauthenticated request to create team returns 401 + When I send a POST request to "/api/v1/teams/create" with no authentication and params "name=evil_team" + Then the response status code should be 401 + + # ========================================================================= + # RENAME TEAM + # ========================================================================= + + @positive + Scenario: Admin can attempt to rename a team + Given I am logged in as admin + When I send a POST request to "/api/v1/teams/rename" with JWT authentication and params "teamId=1&newName=bdd_renamed_team" + Then the response status code should be one of "200, 400, 403, 404" + + @negative + Scenario: Unauthenticated request to rename team returns 401 + When I send a POST request to "/api/v1/teams/rename" with no authentication and params "teamId=1&newName=evil_renamed" + Then the response status code should be 401 + + # ========================================================================= + # ADD USER TO TEAM + # ========================================================================= + + @positive + Scenario: Admin can attempt to add a user to a team + Given I am logged in as admin + When I send a POST request to "/api/v1/teams/addUser" with JWT authentication and params "teamId=1&userId=1" + Then the response status code should be one of "200, 400, 403, 404" + + @negative + Scenario: Unauthenticated request to add user to team returns 401 + When I send a POST request to "/api/v1/teams/addUser" with no authentication and params "teamId=1&userId=1" + Then the response status code should be 401 + + # ========================================================================= + # DELETE TEAM + # ========================================================================= + + @positive + Scenario: Admin can attempt to delete a team + Given I am logged in as admin + When I send a POST request to "/api/v1/teams/delete" with JWT authentication and params "teamId=999" + Then the response status code should be one of "200, 400, 403, 404" + + @negative + Scenario: Unauthenticated request to delete team returns 401 + When I send a POST request to "/api/v1/teams/delete" with no authentication and params "teamId=1" + Then the response status code should be 401 diff --git a/testing/cucumber/features/environment.py b/testing/cucumber/features/environment.py index ac6676f86..63cbc920e 100644 --- a/testing/cucumber/features/environment.py +++ b/testing/cucumber/features/environment.py @@ -1,25 +1,124 @@ import os +import requests + +_BASE_URL = "http://localhost:8080" + +# Tags that indicate a scenario requires JWT Bearer auth to be functional. +# These scenarios are skipped when the server has JWT disabled (V2=false). +# @login and @register scenarios work in both modes. +# The "jwt" tag itself is included so that feature-level @jwt tagging is sufficient +# to mark an entire feature as JWT-dependent. +_JWT_DEPENDENT_TAGS = frozenset({ + # jwt_auth.feature scenario tags + "me", "refresh", "logout", "role", "token", "mfa", "apikey", + # proprietary/enterprise feature tags (all scenarios in these features need JWT) + "jwt", "user_mgmt", "admin_settings", "audit", "signature", "team", +}) + + +def _check_jwt_available(): + """Probe the server to determine whether JWT Bearer auth is functional. + + Logs in as admin, then attempts to use the returned token on /me. + Returns True only when the full JWT round-trip succeeds (V2 enabled). + """ + try: + login = requests.post( + f"{_BASE_URL}/api/v1/auth/login", + json={"username": "admin", "password": "stirling"}, + timeout=10, + ) + if login.status_code != 200: + return False + token = login.json().get("session", {}).get("access_token") + if not token: + return False + me = requests.get( + f"{_BASE_URL}/api/v1/auth/me", + headers={"Authorization": f"Bearer {token}"}, + timeout=10, + ) + return me.status_code == 200 + except Exception: + return False + def before_all(context): context.endpoint = None context.request_data = None context.files = {} context.response = None + context.jwt_available = _check_jwt_available() + if not context.jwt_available: + print( + "\n[JWT] JWT Bearer authentication is not available in this environment " + "(server likely running with V2=false). " + "Scenarios tagged with JWT-dependent tags will be skipped." + ) + + +def before_scenario(context, scenario): + """Reset all per-scenario state before each scenario runs.""" + # Skip scenarios that require JWT Bearer auth when it is not functional. + scenario_tags = set(scenario.effective_tags) + if _JWT_DEPENDENT_TAGS & scenario_tags and not context.jwt_available: + scenario.skip( + "JWT Bearer authentication not available in this environment (V2 disabled). " + "Run against a server with V2=true to execute these scenarios." + ) + return + + context.files = {} + context.multi_files = [] + context.json_parts = {} + context.request_data = None + # JWT auth state + context.jwt_token = None + context.original_jwt_token = None + # OR-status helper used by auth step definitions + context._status_ok = False + # Stored value used by enterprise step definitions for dynamic URL composition + context.stored_value = None def after_scenario(context, scenario): if hasattr(context, "files"): for file in context.files.values(): + try: + file.close() + except Exception: + pass + + # Close any multi-file handles + for _key, file in getattr(context, "multi_files", []): + try: file.close() + except Exception: + pass + if os.path.exists("response_file"): os.remove("response_file") - if hasattr(context, "file_name") and os.path.exists(context.file_name): + # Guard against context.file_name being None (e.g. reset from a previous scenario) + if hasattr(context, "file_name") and context.file_name and os.path.exists(context.file_name): os.remove(context.file_name) - # Remove any temporary files + # Remove any temporary files generated during the scenario for temp_file in os.listdir("."): if temp_file.startswith("genericNonCustomisableName") or temp_file.startswith( "temp_image_" ): - os.remove(temp_file) + try: + os.remove(temp_file) + except Exception: + pass + + # Reset all per-scenario state so stale handles don't bleed into the next scenario + context.files = {} + context.multi_files = [] + context.json_parts = {} + context.request_data = None + # JWT auth state + context.jwt_token = None + context.original_jwt_token = None + context._status_ok = False diff --git a/testing/cucumber/features/filter.feature b/testing/cucumber/features/filter.feature new file mode 100644 index 000000000..75e02e419 --- /dev/null +++ b/testing/cucumber/features/filter.feature @@ -0,0 +1,271 @@ +@filter +Feature: Filter API Endpoints + + Filter endpoints return 200 with the original PDF when the filter condition + is satisfied, or 204 (No Content) when the condition is not satisfied. + + + # --------------------------------------------------------------------------- + # filter-page-count + # --------------------------------------------------------------------------- + + @filter-page-count @positive + Scenario Outline: filter-page-count returns 200 when condition is met + Given I generate a PDF file as "fileInput" + And the pdf contains 5 pages + And the request data includes + | parameter | value | + | pageCount | | + | comparator | | + When I send the API request to the endpoint "/api/v1/filter/filter-page-count" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + + Examples: + | pageCount | comparator | + | 3 | Greater | + | 5 | Equal | + | 7 | Less | + + @filter-page-count @negative + Scenario Outline: filter-page-count returns 204 when condition is not met + Given I generate a PDF file as "fileInput" + And the pdf contains 5 pages + And the request data includes + | parameter | value | + | pageCount | | + | comparator | | + When I send the API request to the endpoint "/api/v1/filter/filter-page-count" + Then the response status code should be 204 + + Examples: + | pageCount | comparator | + | 5 | Greater | + | 4 | Equal | + | 5 | Less | + + + # --------------------------------------------------------------------------- + # filter-file-size + # --------------------------------------------------------------------------- + + @filter-file-size @positive + Scenario Outline: filter-file-size returns 200 when condition is met + Given I generate a PDF file as "fileInput" + And the pdf contains 3 pages + And the request data includes + | parameter | value | + | fileSize | | + | comparator | | + When I send the API request to the endpoint "/api/v1/filter/filter-file-size" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + + Examples: + | fileSize | comparator | + | 100 | Greater | + | 9999999 | Less | + + @filter-file-size @negative + Scenario: filter-file-size returns 204 when file is not large enough + Given I generate a PDF file as "fileInput" + And the pdf contains 1 pages + And the request data includes + | parameter | value | + | fileSize | 99999999 | + | comparator | Greater | + When I send the API request to the endpoint "/api/v1/filter/filter-file-size" + Then the response status code should be 204 + + + # --------------------------------------------------------------------------- + # filter-page-rotation + # --------------------------------------------------------------------------- + + @filter-page-rotation @positive + Scenario: filter-page-rotation returns 200 for Equal comparator on 0-degree pages + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + And the request data includes + | parameter | value | + | rotation | 0 | + | comparator | Equal | + When I send the API request to the endpoint "/api/v1/filter/filter-page-rotation" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + + @filter-page-rotation @positive + Scenario Outline: filter-page-rotation returns 200 for Greater comparator on 0-degree pages + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + And the request data includes + | parameter | value | + | rotation | | + | comparator | | + When I send the API request to the endpoint "/api/v1/filter/filter-page-rotation" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + + Examples: + | rotation | comparator | + | 90 | Less | + | 180 | Less | + | 270 | Less | + + @filter-page-rotation @negative + Scenario Outline: filter-page-rotation returns 204 when condition is not met + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + And the request data includes + | parameter | value | + | rotation | | + | comparator | | + When I send the API request to the endpoint "/api/v1/filter/filter-page-rotation" + Then the response status code should be 204 + + Examples: + | rotation | comparator | + | 0 | Greater | + | 90 | Equal | + | 180 | Equal | + + + # --------------------------------------------------------------------------- + # filter-page-size + # Blank pages are 72x72 points (smaller than any standard page size). + # Pages with random text use LETTER (612x792 points). + # --------------------------------------------------------------------------- + + @filter-page-size @positive + Scenario Outline: filter-page-size returns 200 when blank PDF is smaller than standard size + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + And the request data includes + | parameter | value | + | standardPageSize | | + | comparator | Less | + When I send the API request to the endpoint "/api/v1/filter/filter-page-size" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + + Examples: + | standardPageSize | + | A0 | + | A4 | + | A6 | + | LETTER | + | LEGAL | + + @filter-page-size @positive + Scenario: filter-page-size returns 200 when text PDF equals LETTER size + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages with random text + And the request data includes + | parameter | value | + | standardPageSize | LETTER | + | comparator | Equal | + When I send the API request to the endpoint "/api/v1/filter/filter-page-size" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + + @filter-page-size @negative + Scenario Outline: filter-page-size returns 204 when blank PDF does not match standard size as Equal + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + And the request data includes + | parameter | value | + | standardPageSize | | + | comparator | Equal | + When I send the API request to the endpoint "/api/v1/filter/filter-page-size" + Then the response status code should be 204 + + Examples: + | standardPageSize | + | A4 | + | LETTER | + | LEGAL | + + @filter-page-size @negative + Scenario: filter-page-size returns 204 when blank PDF is not Greater than any standard size + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + And the request data includes + | parameter | value | + | standardPageSize | A6 | + | comparator | Greater | + When I send the API request to the endpoint "/api/v1/filter/filter-page-size" + Then the response status code should be 204 + + + # --------------------------------------------------------------------------- + # filter-contains-text + # --------------------------------------------------------------------------- + + @filter-contains-text @positive + Scenario: filter-contains-text returns 200 when text is found in PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + And the pdf pages all contain the text "FINDME" + And the request data includes + | parameter | value | + | text | FINDME | + | pageNumbers | all | + When I send the API request to the endpoint "/api/v1/filter/filter-contains-text" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + + @filter-contains-text @negative + Scenario: filter-contains-text returns 204 when text is not found in blank PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + And the request data includes + | parameter | value | + | text | NOTPRESENT | + | pageNumbers | all | + When I send the API request to the endpoint "/api/v1/filter/filter-contains-text" + Then the response status code should be 204 + + @filter-contains-text @negative + Scenario: filter-contains-text returns 204 when searched text differs from PDF content + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + And the pdf pages all contain the text "HELLO" + And the request data includes + | parameter | value | + | text | GOODBYE | + | pageNumbers | all | + When I send the API request to the endpoint "/api/v1/filter/filter-contains-text" + Then the response status code should be 204 + + + # --------------------------------------------------------------------------- + # filter-contains-image + # --------------------------------------------------------------------------- + + @filter-contains-image @positive + Scenario: filter-contains-image returns 200 when PDF contains images + Given the pdf contains 2 images of size 100x100 on 1 pages + And the request data includes + | parameter | value | + | pageNumbers | all | + When I send the API request to the endpoint "/api/v1/filter/filter-contains-image" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + + @filter-contains-image @negative + Scenario: filter-contains-image returns 204 when PDF has no images + Given I generate a PDF file as "fileInput" + And the pdf contains 3 pages + And the request data includes + | parameter | value | + | pageNumbers | all | + When I send the API request to the endpoint "/api/v1/filter/filter-contains-image" + Then the response status code should be 204 diff --git a/testing/cucumber/features/form_advanced.feature b/testing/cucumber/features/form_advanced.feature new file mode 100644 index 000000000..bb02a453b --- /dev/null +++ b/testing/cucumber/features/form_advanced.feature @@ -0,0 +1,29 @@ +@proprietary @forms +Feature: Advanced Forms API Validation (JSON data parts) + + @fill @positive + Scenario: Fill PDF form with empty JSON data part + Given I generate a PDF file as "file" + And the pdf contains 2 pages + And the pdf has form fields + And the request includes a JSON part "data" with content "{}" + When I send the API request to the endpoint "/api/v1/form/fill" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + And the response file should have extension ".pdf" + + @fill @positive + Scenario: Fill and flatten PDF form with empty JSON data part + Given I generate a PDF file as "file" + And the pdf contains 2 pages + And the pdf has form fields + And the request data includes + | parameter | value | + | flatten | true | + And the request includes a JSON part "data" with content "{}" + When I send the API request to the endpoint "/api/v1/form/fill" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + And the response file should have extension ".pdf" diff --git a/testing/cucumber/features/forms.feature b/testing/cucumber/features/forms.feature new file mode 100644 index 000000000..c0c1b0f7f --- /dev/null +++ b/testing/cucumber/features/forms.feature @@ -0,0 +1,91 @@ +@proprietary @forms +Feature: Forms API Validation + + @fields @positive + Scenario: Get form fields from plain PDF + Given I generate a PDF file as "file" + And the pdf contains 2 pages + When I send the API request to the endpoint "/api/v1/form/fields" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 0 + + @fields @positive + Scenario: Get form fields from multi-page PDF + Given I generate a PDF file as "file" + And the pdf contains 5 pages + When I send the API request to the endpoint "/api/v1/form/fields" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 0 + + @fields-with-coordinates @positive + Scenario: Get form fields with coordinates from PDF + Given I generate a PDF file as "file" + And the pdf contains 2 pages + When I send the API request to the endpoint "/api/v1/form/fields-with-coordinates" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 0 + + @fields-with-coordinates @positive + Scenario: Get form fields with coordinates from multi-page PDF + Given I generate a PDF file as "file" + And the pdf contains 4 pages + When I send the API request to the endpoint "/api/v1/form/fields-with-coordinates" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 0 + + @fill @positive + Scenario: Fill PDF form with default options + Given I generate a PDF file as "file" + And the pdf contains 2 pages + And the pdf has form fields + When I send the API request to the endpoint "/api/v1/form/fill" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + And the response file should have extension ".pdf" + + @fill @positive + Scenario: Fill and flatten PDF form + Given I generate a PDF file as "file" + And the pdf contains 3 pages + And the pdf has form fields + And the request data includes + | parameter | value | + | flatten | true | + When I send the API request to the endpoint "/api/v1/form/fill" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + And the response file should have extension ".pdf" + + @modify-fields @negative + Scenario: Modify form fields with no updates payload returns 400 + Given I generate a PDF file as "file" + And the pdf contains 2 pages + When I send the API request to the endpoint "/api/v1/form/modify-fields" + Then the response status code should be 400 + + @modify-fields @negative + Scenario: Modify form fields in multi-page PDF with no updates payload returns 400 + Given I generate a PDF file as "file" + And the pdf contains 5 pages + When I send the API request to the endpoint "/api/v1/form/modify-fields" + Then the response status code should be 400 + + @delete-fields @negative + Scenario: Delete form fields with no names payload returns 400 + Given I generate a PDF file as "file" + And the pdf contains 2 pages + When I send the API request to the endpoint "/api/v1/form/delete-fields" + Then the response status code should be 400 + + @delete-fields @negative + Scenario: Delete form fields from multi-page PDF with no names payload returns 400 + Given I generate a PDF file as "file" + And the pdf contains 4 pages + When I send the API request to the endpoint "/api/v1/form/delete-fields" + Then the response status code should be 400 diff --git a/testing/cucumber/features/general_new.feature b/testing/cucumber/features/general_new.feature new file mode 100644 index 000000000..ea999c043 --- /dev/null +++ b/testing/cucumber/features/general_new.feature @@ -0,0 +1,251 @@ +@general-new +Feature: General PDF Operations API Validation + + + @rotate-pdf @positive + Scenario Outline: rotate-pdf with valid rotation angles + Given I generate a PDF file as "fileInput" + And the pdf contains 3 pages + And the request data includes + | parameter | value | + | angle | | + When I send the API request to the endpoint "/api/v1/general/rotate-pdf" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response file should have size greater than 200 + And the response PDF should contain 3 pages + + Examples: + | angle | + | 90 | + | 180 | + | 270 | + + + @rotate-pdf @negative @rotate-pdf-negative + Scenario: rotate-pdf with invalid angle returns error + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + And the request data includes + | parameter | value | + | angle | 45 | + When I send the API request to the endpoint "/api/v1/general/rotate-pdf" + Then the response status code should be 400 + + + @remove-pages @positive + Scenario: remove-pages removes the specified page from a multi-page PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 5 pages + And the request data includes + | parameter | value | + | pageNumbers | 3 | + When I send the API request to the endpoint "/api/v1/general/remove-pages" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response file should have size greater than 200 + And the response PDF should contain 4 pages + + + @remove-pages @positive + Scenario Outline: remove-pages with various page selections + Given I generate a PDF file as "fileInput" + And the pdf contains 6 pages + And the request data includes + | parameter | value | + | pageNumbers | | + When I send the API request to the endpoint "/api/v1/general/remove-pages" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response file should have size greater than 200 + And the response PDF should contain pages + + Examples: + | pageNumbers | remaining | + | 1 | 5 | + | 6 | 5 | + | 2,4 | 4 | + | 1,2,3 | 3 | + + + @rearrange-pages @positive + Scenario Outline: rearrange-pages with different custom modes + Given I generate a PDF file as "fileInput" + And the pdf contains 4 pages + And the request data includes + | parameter | value | + | customMode | | + When I send the API request to the endpoint "/api/v1/general/rearrange-pages" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response file should have size greater than 200 + And the response PDF should contain pages + + Examples: + | customMode | expectedPages | + | REVERSE_ORDER | 4 | + + @rearrange-duplicate + Examples: + | customMode | expectedPages | + | DUPLICATE | 8 | + + Examples: + | customMode | expectedPages | + | ODD_EVEN_SPLIT | 4 | + + + @scale-pages @positive + Scenario Outline: scale-pages to various standard page sizes + Given I generate a PDF file as "fileInput" + And the pdf contains 3 pages + And the request data includes + | parameter | value | + | pageSize | | + When I send the API request to the endpoint "/api/v1/general/scale-pages" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response file should have size greater than 200 + And the response PDF should contain 3 pages + + Examples: + | pageSize | + | A4 | + | LETTER | + | A3 | + | LEGAL | + + + @crop @positive + Scenario: crop PDF pages to a specific region + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + And the request data includes + | parameter | value | + | x | 0 | + | y | 0 | + | width | 50 | + | height | 50 | + When I send the API request to the endpoint "/api/v1/general/crop" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response file should have size greater than 200 + And the response PDF should contain 2 pages + + + @crop @positive + Scenario: crop single-page PDF preserves page count + Given I generate a PDF file as "fileInput" + And the pdf contains 1 pages + And the request data includes + | parameter | value | + | x | 0 | + | y | 0 | + | width | 50 | + | height | 50 | + When I send the API request to the endpoint "/api/v1/general/crop" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response file should have size greater than 0 + And the response PDF should contain 1 pages + + + @pdf-to-single-page @positive + Scenario: pdf-to-single-page combines all pages into one long page + Given I generate a PDF file as "fileInput" + And the pdf contains 5 pages + When I send the API request to the endpoint "/api/v1/general/pdf-to-single-page" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response file should have size greater than 200 + And the response PDF should contain 1 pages + + + @pdf-to-single-page @positive + Scenario: pdf-to-single-page with a single-page input returns one page + Given I generate a PDF file as "fileInput" + And the pdf contains 1 pages + When I send the API request to the endpoint "/api/v1/general/pdf-to-single-page" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response file should have size greater than 0 + And the response PDF should contain 1 pages + + + @multi-page-layout @positive + Scenario Outline: multi-page-layout combines input pages onto fewer sheets + Given I generate a PDF file as "fileInput" + And the pdf contains 4 pages + And the request data includes + | parameter | value | + | pagesPerSheet | | + When I send the API request to the endpoint "/api/v1/general/multi-page-layout" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response file should have size greater than 200 + And the response PDF should contain pages + + Examples: + | pagesPerSheet | outputPages | + | 2 | 2 | + | 4 | 1 | + + + @multi-page-layout @positive + Scenario: multi-page-layout with 9 pages per sheet on 9 input pages + Given I generate a PDF file as "fileInput" + And the pdf contains 9 pages + And the request data includes + | parameter | value | + | pagesPerSheet | 9 | + When I send the API request to the endpoint "/api/v1/general/multi-page-layout" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response file should have size greater than 200 + And the response PDF should contain 1 pages + + + @booklet-imposition @positive + Scenario: booklet-imposition returns a valid PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 4 pages + And the request data includes + | parameter | value | + | pagesPerSheet | 2 | + When I send the API request to the endpoint "/api/v1/general/booklet-imposition" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response file should have size greater than 200 + + + @booklet-imposition @positive + Scenario: booklet-imposition with 8-page input returns valid PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 8 pages + And the request data includes + | parameter | value | + | pagesPerSheet | 2 | + When I send the API request to the endpoint "/api/v1/general/booklet-imposition" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response file should have size greater than 200 + + + @remove-image-pdf @positive + Scenario: remove-image-pdf strips images from a PDF containing images + Given I generate a PDF file as "fileInput" + And the pdf contains 3 images of size 100x100 on 2 pages + When I send the API request to the endpoint "/api/v1/general/remove-image-pdf" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response file should have size greater than 0 + + + @remove-image-pdf @positive + Scenario: remove-image-pdf on a plain text PDF returns a PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 3 pages with random text + When I send the API request to the endpoint "/api/v1/general/remove-image-pdf" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response file should have size greater than 0 diff --git a/testing/cucumber/features/info.feature b/testing/cucumber/features/info.feature new file mode 100644 index 000000000..fb9d28a48 --- /dev/null +++ b/testing/cucumber/features/info.feature @@ -0,0 +1,42 @@ +@info +Feature: Info API Validation + + @status @positive + Scenario: Get application status + When I send a GET request to "/api/v1/info/status" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 0 + And the JSON value of "status" should be "UP" + + @uptime @positive + Scenario: Get application uptime + When I send a GET request to "/api/v1/info/uptime" + Then the response status code should be 200 + And the response file should have size greater than 0 + + @requests @positive + Scenario: Get total request count + When I send a GET request to "/api/v1/info/requests" + Then the response status code should be 200 + And the response file should have size greater than 0 + + @requests @positive + Scenario: Get per-endpoint request counts + When I send a GET request to "/api/v1/info/requests/all" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 0 + + @load @positive + Scenario: Get current system load + When I send a GET request to "/api/v1/info/load" + Then the response status code should be 200 + And the response file should have size greater than 0 + + @load @positive + Scenario: Get per-endpoint load statistics + When I send a GET request to "/api/v1/info/load/all" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 0 diff --git a/testing/cucumber/features/invite_links.feature b/testing/cucumber/features/invite_links.feature new file mode 100644 index 000000000..0e6d1a869 --- /dev/null +++ b/testing/cucumber/features/invite_links.feature @@ -0,0 +1,77 @@ +@jwt @auth @user_mgmt +Feature: Invite Link API + + Tests for the invite link REST API, which allows admins to manage + registration invite links. + + Endpoints (base: /api/v1/invite — from @InviteApi annotation): + - POST /api/v1/invite/generate (admin only, requires MAIL_ENABLEINVITES) + - GET /api/v1/invite/list (admin only) + - GET /api/v1/invite/validate/{token} (public) + - DELETE /api/v1/invite/revoke/{inviteId} (admin only) + - POST /api/v1/invite/cleanup (admin only) + + NOTE: The /generate endpoint requires MAIL_ENABLEINVITES=true AND an SMTP + server. Since the CI environment has no SMTP, generate-dependent scenarios + (generate, full lifecycle, revoke-by-id) are omitted. Auth guard tests for + those endpoints are still covered. + + Admin credentials: username=admin, password=stirling + + # ========================================================================= + # GENERATE INVITE LINK – auth guard only (no SMTP in CI) + # ========================================================================= + + @negative + Scenario: Unauthenticated request to generate invite link returns 401 + When I send a POST request to "/api/v1/invite/generate" with no authentication and params "role=ROLE_USER&expiryHours=24&sendEmail=false" + Then the response status code should be 401 + + # ========================================================================= + # LIST INVITE LINKS + # ========================================================================= + + @positive + Scenario: Admin can list all active invite links + Given I am logged in as admin + When I send a GET request to "/api/v1/invite/list" with JWT authentication + Then the response status code should be 200 + And the response JSON field "invites" should be a list + + @negative + Scenario: Unauthenticated request to list invite links returns 401 + When I send a GET request to "/api/v1/invite/list" with no authentication + Then the response status code should be 401 + + # ========================================================================= + # VALIDATE INVITE TOKEN (public endpoint) + # ========================================================================= + + @negative + Scenario: Validating a non-existent invite token returns 404 or 400 + When I send a GET request to "/api/v1/invite/validate/completely-invalid-token-xyz-999" with no authentication + Then the response status code should be one of "400, 404" + + # ========================================================================= + # REVOKE INVITE LINK – auth guard only + # ========================================================================= + + @negative + Scenario: Unauthenticated request to revoke invite link returns 401 + When I send a DELETE request to "/api/v1/invite/revoke/some-id-xyz" with no authentication + Then the response status code should be 401 + + # ========================================================================= + # CLEANUP EXPIRED INVITE LINKS + # ========================================================================= + + @positive + Scenario: Admin can trigger cleanup of expired invite links + Given I am logged in as admin + When I send a POST request to "/api/v1/invite/cleanup" with JWT authentication + Then the response status code should be 200 + + @negative + Scenario: Unauthenticated request to cleanup invite links returns 401 + When I send a POST request to "/api/v1/invite/cleanup" with no authentication + Then the response status code should be 401 diff --git a/testing/cucumber/features/jwt_auth.feature b/testing/cucumber/features/jwt_auth.feature new file mode 100644 index 000000000..9a05b254d --- /dev/null +++ b/testing/cucumber/features/jwt_auth.feature @@ -0,0 +1,379 @@ +@jwt @auth +Feature: JWT Authentication End-to-End + + Comprehensive end-to-end tests for JWT-based authentication covering login, + token validation, token refresh, logout, role-based access control, and + API key authentication. + + Admin credentials: username=admin, password=stirling (see docker-compose-security-with-login.yml) + Global API key: 123456789 + + # ========================================================================= + # LOGIN SCENARIOS + # ========================================================================= + + @login @positive + Scenario: Successful admin login returns JWT token + When I login with username "admin" and password "stirling" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response should contain a JWT access token + And the response JSON should have field "user" + And the response JSON should have a user with username "admin" + And the response JSON should have a user with role "ROLE_ADMIN" + + @login @positive + Scenario: Login response includes token expiry in seconds + When I login with username "admin" and password "stirling" + Then the response status code should be 200 + And the response JSON session field "expires_in" should be positive + + @login @positive + Scenario: JWT access token has valid three-part structure + When I login with username "admin" and password "stirling" + Then the response status code should be 200 + And the JWT access token should have three dot-separated parts + + @login @positive + Scenario: Login response user object contains required fields + When I login with username "admin" and password "stirling" + Then the response status code should be 200 + And the response JSON user field "email" should not be empty + And the response JSON user field "username" should not be empty + And the response JSON user field "role" should not be empty + And the response JSON user field "enabled" should not be empty + + @login @positive + Scenario: Login response user authentication type is WEB + When I login with username "admin" and password "stirling" + Then the response status code should be 200 + And the response JSON user field "authenticationType" should equal "web" + + @login @negative + Scenario: Login with wrong password returns 401 + When I login with username "admin" and password "completely_wrong_password_xyz" + Then the response status code should be 401 + And the response JSON error should contain "Invalid" + + @login @negative + Scenario: Login with SQL injection in username is safely rejected + When I login with username "admin' OR '1'='1" and password "anypass" + Then the response status code should be 401 + + @login @negative + Scenario: Login with script injection in username is safely rejected + When I login with username "" and password "anypass" + Then the response status code should be 401 + + @login @negative + Scenario: Login with non-existent user returns 401 + When I login with username "no_such_user_abc999xyz" and password "anypassword123" + Then the response status code should be 401 + + @login @negative + Scenario: Login with empty username returns 400 + When I login with an empty username and password "stirling" + Then the response status code should be 400 + + @login @negative + Scenario: Login with empty password returns 400 + When I login with username "admin" and an empty password + Then the response status code should be 400 + + @login @negative + Scenario: Login with null-equivalent username returns 400 + When I login with only password "stirling" + Then the response status code should be 400 + + @login @negative + Scenario: Login with null-equivalent password returns 400 + When I login with only username "admin" + Then the response status code should be 400 + + @login @negative + Scenario: Multiple sequential failed login attempts are all rejected + When I login with username "admin" and password "badpass1" + Then the response status code should be 401 + When I login with username "admin" and password "badpass2" + Then the response status code should be 401 + When I login with username "admin" and password "badpass3" + Then the response status code should be 401 + + @login @negative + Scenario: Successful login clears lockout after failed attempts + When I login with username "admin" and password "wrongpass" + Then the response status code should be 401 + When I login with username "admin" and password "stirling" + Then the response status code should be 200 + And the response should contain a JWT access token + + # ========================================================================= + # JWT /me ENDPOINT SCENARIOS + # ========================================================================= + + @me @positive + Scenario: Get current user with valid admin JWT token + Given I am logged in as admin + When I send a GET request to "/api/v1/auth/me" with JWT authentication + Then the response status code should be 200 + And the response content type should be "application/json" + And the response JSON should have field "user" + And the response JSON should have a user with username "admin" + And the response JSON should have a user with role "ROLE_ADMIN" + + @me @positive + Scenario: Get current user with JWT shows correct user data + Given I am logged in as admin + When I send a GET request to "/api/v1/auth/me" with JWT authentication + Then the response status code should be 200 + And the response JSON user field "email" should not be empty + And the response JSON user field "enabled" should not be empty + + @me @negative + Scenario: Get current user without any authentication returns 401 + When I send a GET request to "/api/v1/auth/me" with no authentication + Then the response status code should be 401 + + @me @negative + Scenario: Get current user with completely invalid JWT token returns 401 + When I send a GET request to "/api/v1/auth/me" with an invalid JWT token "not.a.jwt" + Then the response status code should be 401 + + @me @negative + Scenario: Get current user with random garbage token returns 401 + When I send a GET request to "/api/v1/auth/me" with an invalid JWT token "eyJhbGciOiJSUzI1NiJ9.ZmFrZXBheWxvYWQ.ZmFrZXNpZ25hdHVyZQ" + Then the response status code should be 401 + + @me @negative + Scenario: Get current user with malformed authorization header returns 401 + When I send a GET request to "/api/v1/auth/me" with a malformed authorization header + Then the response status code should be 401 + + # ========================================================================= + # TOKEN REFRESH SCENARIOS + # ========================================================================= + + @refresh @positive + Scenario: Refresh a valid JWT token returns a new access token + Given I am logged in as admin + When I refresh the JWT token + Then the response status code should be 200 + And the response content type should be "application/json" + And the response should contain a JWT access token + And the response JSON should have field "user" + And the response JSON should have a user with username "admin" + + @refresh @positive + Scenario: Refreshed token has valid three-part JWT structure + Given I am logged in as admin + When I refresh the JWT token + Then the response status code should be 200 + And the JWT access token should have three dot-separated parts + + @refresh @positive + Scenario: Refreshed JWT token can be used to authenticate subsequent requests + Given I am logged in as admin + When I refresh the JWT token + Then the response status code should be 200 + And I update the stored JWT token from the response + When I send a GET request to "/api/v1/auth/me" with JWT authentication + Then the response status code should be 200 + And the response JSON should have a user with username "admin" + + @refresh @positive + Scenario: Refreshed token includes positive expiry time + Given I am logged in as admin + When I refresh the JWT token + Then the response status code should be 200 + And the response JSON session field "expires_in" should be positive + + @refresh @negative + Scenario: Refresh without any token returns 401 + When I send a POST request to "/api/v1/auth/refresh" with no authentication + Then the response status code should be 401 + + @refresh @negative + Scenario: Refresh with invalid token returns 401 + When I send a POST request to "/api/v1/auth/refresh" with an invalid JWT token "bad.token.value" + Then the response status code should be 401 + + # ========================================================================= + # LOGOUT SCENARIOS + # ========================================================================= + + @logout @positive + Scenario: Logout with valid admin JWT token succeeds + Given I am logged in as admin + When I logout with JWT authentication + Then the response status code should be 200 + And the response JSON field "message" should equal "Logged out successfully" + + @logout @token @positive + Scenario: JWT token remains usable after logout (stateless – no server-side revocation) + # JWT is stateless: logout only clears the server SecurityContext for that request. + # The signed token itself is not blacklisted, so it stays valid until its expiry. + # This is expected behaviour; add a token blacklist if revocation is required. + Given I am logged in as admin + When I logout with JWT authentication + Then the response status code should be 200 + When I send a GET request to "/api/v1/auth/me" with JWT authentication + Then the response status code should be 200 + And the response JSON should have a user with username "admin" + + # ========================================================================= + # ROLE-BASED ACCESS CONTROL SCENARIOS + # ========================================================================= + + @role @admin @positive + Scenario: Admin JWT allows access to admin-only MFA management endpoint + Given I am logged in as admin + When I send a POST request to "/api/v1/auth/mfa/disable/admin/admin" with JWT authentication + Then the response status code should be 200 + + @role @admin @positive + Scenario: Admin JWT correctly identifies ROLE_ADMIN in /me response + Given I am logged in as admin + When I send a GET request to "/api/v1/auth/me" with JWT authentication + Then the response status code should be 200 + And the response JSON should have a user with role "ROLE_ADMIN" + + @role @negative + Scenario: Request to admin-only endpoint without JWT returns 401 + When I send a POST request to "/api/v1/auth/mfa/disable/admin/admin" with no authentication + Then the response status code should be 401 + + @role @negative + Scenario: Request to admin-only endpoint with invalid JWT returns 401 + When I send a POST request to "/api/v1/auth/mfa/disable/admin/admin" with an invalid JWT token "bad.jwt.token" + Then the response status code should be 401 + + # ========================================================================= + # API KEY AUTHENTICATION SCENARIOS + # ========================================================================= + + @apikey @positive + Scenario: Valid API key allows access to /me endpoint + When I send a GET request to "/api/v1/auth/me" with API key "123456789" + Then the response status code should be 200 + And the response JSON should have field "user" + + @apikey @positive + Scenario: Valid API key /me response contains user information + When I send a GET request to "/api/v1/auth/me" with API key "123456789" + Then the response status code should be 200 + And the response JSON user field "username" should not be empty + And the response JSON user field "role" should not be empty + + @apikey @negative + Scenario: Invalid API key returns 401 + When I send a GET request to "/api/v1/auth/me" with API key "invalid_key_xyz_999" + Then the response status code should be 401 + + @apikey @negative + Scenario: Absent API key with no other auth returns 401 + When I send a GET request to "/api/v1/auth/me" with no authentication + Then the response status code should be 401 + + # ========================================================================= + # MFA SETUP SCENARIOS (requires JWT authentication) + # ========================================================================= + + @mfa @positive + Scenario: Authenticated admin can initiate or is already past MFA setup + Given I am logged in as admin + When I send a GET request to "/api/v1/auth/mfa/setup" with JWT authentication + Then the response status code should be one of "200, 409" + + @mfa @negative + Scenario: MFA setup endpoint requires authentication + When I send a GET request to "/api/v1/auth/mfa/setup" with no authentication + Then the response status code should be 401 + + @mfa @negative + Scenario: MFA enable with a random invalid TOTP code returns an error + Given I am logged in as admin + When I send a JSON POST request to "/api/v1/auth/mfa/enable" with JWT authentication and body '{"code": "000000"}' + Then the response status code should be one of "400, 401, 409" + + @mfa @admin @positive + Scenario: Admin can disable MFA for any user via admin endpoint + Given I am logged in as admin + When I send a POST request to "/api/v1/auth/mfa/disable/admin/admin" with JWT authentication + Then the response status code should be 200 + And the response JSON field "enabled" should equal "false" + + # ========================================================================= + # USER REGISTRATION SCENARIOS + # ========================================================================= + + @register @positive + Scenario: Register a new unique user account succeeds or reports license limit + When I send a JSON POST request to "/api/v1/user/register" with API key "123456789" and body '{"username": "test_register_user_bdd", "password": "SecurePass123!"}' + Then the response status code should be one of "200, 201, 400" + + @register @negative + Scenario: Register with duplicate username returns error + When I send a JSON POST request to "/api/v1/user/register" with API key "123456789" and body '{"username": "admin", "password": "SecurePass123!"}' + Then the response status code should be 400 + And the response JSON error should contain "already exists" + + @register @negative + Scenario: Register with empty password returns error + When I send a JSON POST request to "/api/v1/user/register" with API key "123456789" and body '{"username": "new_user_empty_pass", "password": ""}' + Then the response status code should be 400 + + @register @negative + Scenario: Newly registered user cannot login before an admin enables the account + # Registration creates accounts with enabled=false; immediate login must be rejected. + # Username is intentionally unique to avoid conflicts with other test runs. + When I send a JSON POST request to "/api/v1/user/register" with API key "123456789" and body '{"username": "bdd_disabled_user_99x", "password": "SecurePass123!"}' + Then the response status code should be one of "201, 400" + When I login with username "bdd_disabled_user_99x" and password "SecurePass123!" + Then the response status code should be 401 + + # ========================================================================= + # TOKEN VALIDATION EDGE CASES + # ========================================================================= + + @token @negative + Scenario: Empty Authorization header value returns 401 + When I send a GET request to "/api/v1/auth/me" with an empty Authorization header + Then the response status code should be 401 + + @token @negative + Scenario: Authorization header without Bearer prefix returns 401 + When I send a GET request to "/api/v1/auth/me" with Authorization header value "Basic dXNlcjpwYXNz" + Then the response status code should be 401 + + @token @negative + Scenario: Authorization header with only the Bearer keyword and no token returns 401 + When I send a GET request to "/api/v1/auth/me" with Authorization header value "Bearer" + Then the response status code should be 401 + + @token @negative + Scenario: Authorization header with Bearer prefix but only whitespace returns 401 + When I send a GET request to "/api/v1/auth/me" with Authorization header value "Bearer " + Then the response status code should be 401 + + @token @positive + Scenario: Login then immediately use the token verifies token is active + When I login with username "admin" and password "stirling" + Then the response status code should be 200 + And I store the JWT token from the login response + When I send a GET request to "/api/v1/auth/me" with the stored JWT token + Then the response status code should be 200 + And the response JSON should have a user with username "admin" + + @token @positive + Scenario: Full login, use, refresh, and re-use flow + When I login with username "admin" and password "stirling" + Then the response status code should be 200 + And I store the JWT token from the login response + When I send a GET request to "/api/v1/auth/me" with the stored JWT token + Then the response status code should be 200 + When I refresh the stored JWT token + Then the response status code should be 200 + And I update the stored JWT token from the response + When I send a GET request to "/api/v1/auth/me" with the stored JWT token + Then the response status code should be 200 + And the response JSON should have a user with username "admin" diff --git a/testing/cucumber/features/merge_overlay.feature b/testing/cucumber/features/merge_overlay.feature new file mode 100644 index 000000000..9e62d1ae9 --- /dev/null +++ b/testing/cucumber/features/merge_overlay.feature @@ -0,0 +1,72 @@ +@general +Feature: Merge and Overlay PDF API Validation + + @merge @positive + Scenario: Merge two PDFs with default options + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages with random text + And I also generate a PDF file as "fileInput" + When I send the API request to the endpoint "/api/v1/general/merge-pdfs" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + And the response file should have extension ".pdf" + + @merge @positive + Scenario: Merge three PDFs with byFileName sort + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages with random text + And I also generate a PDF file as "fileInput" + And I also generate a PDF file as "fileInput" + And the request data includes + | parameter | value | + | sortType | byFileName | + When I send the API request to the endpoint "/api/v1/general/merge-pdfs" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + And the response file should have extension ".pdf" + + @merge @positive + Scenario: Merge PDFs with table of contents + Given I generate a PDF file as "fileInput" + And the pdf contains 3 pages with random text + And I also generate a PDF file as "fileInput" + And the request data includes + | parameter | value | + | generateToc | true | + When I send the API request to the endpoint "/api/v1/general/merge-pdfs" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + And the response file should have extension ".pdf" + + @overlay @positive + Scenario: Overlay PDF in sequential mode foreground + Given I generate a PDF file as "fileInput" + And the pdf contains 3 pages with random text + And I also generate a PDF file as "overlayFiles" + And the request data includes + | parameter | value | + | overlayMode | SequentialOverlay | + | overlayPosition | 0 | + When I send the API request to the endpoint "/api/v1/general/overlay-pdfs" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + And the response file should have extension ".pdf" + + @overlay @positive + Scenario: Overlay PDF in interleaved mode background + Given I generate a PDF file as "fileInput" + And the pdf contains 3 pages with random text + And I also generate a PDF file as "overlayFiles" + And the request data includes + | parameter | value | + | overlayMode | InterleavedOverlay | + | overlayPosition | 1 | + When I send the API request to the endpoint "/api/v1/general/overlay-pdfs" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + And the response file should have extension ".pdf" diff --git a/testing/cucumber/features/misc_new.feature b/testing/cucumber/features/misc_new.feature new file mode 100644 index 000000000..c10f0601d --- /dev/null +++ b/testing/cucumber/features/misc_new.feature @@ -0,0 +1,224 @@ +@misc-new +Feature: Miscellaneous PDF Operations API Validation + + + @add-stamp @positive + Scenario Outline: add-stamp applies a text stamp at various positions + Given I generate a PDF file as "fileInput" + And the pdf contains 3 pages + And the request data includes + | parameter | value | + | stampType | text | + | stampText | Test Stamp | + | position | | + | fontSize | 20 | + | rotation | 0 | + | opacity | 0.5 | + | customColor | #d3d3d3 | + | customMargin | medium | + | overrideX | -1 | + | overrideY | -1 | + When I send the API request to the endpoint "/api/v1/misc/add-stamp" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response file should have size greater than 200 + And the response PDF should contain 3 pages + + Examples: + | position | + | 1 | + | 5 | + | 9 | + + + @add-stamp @positive + Scenario: add-stamp with rotation and full opacity + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + And the request data includes + | parameter | value | + | stampType | text | + | stampText | CONFIDENTIAL | + | position | 5 | + | fontSize | 30 | + | rotation | 45 | + | opacity | 1.0 | + | customColor | #d3d3d3 | + | customMargin | medium | + | overrideX | -1 | + | overrideY | -1 | + When I send the API request to the endpoint "/api/v1/misc/add-stamp" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response file should have size greater than 200 + And the response PDF should contain 2 pages + + + @add-page-numbers @positive + Scenario Outline: add-page-numbers inserts numbers at various positions + Given I generate a PDF file as "fileInput" + And the pdf contains 4 pages + And the request data includes + | parameter | value | + | startingNumber | 1 | + | position | | + | fontSize | 12 | + When I send the API request to the endpoint "/api/v1/misc/add-page-numbers" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response file should have size greater than 200 + And the response PDF should contain 4 pages + + Examples: + | position | + | 1 | + | 2 | + | 3 | + | 4 | + | 5 | + | 6 | + | 7 | + | 8 | + | 9 | + + + @add-page-numbers @positive + Scenario: add-page-numbers starting from a custom number + Given I generate a PDF file as "fileInput" + And the pdf contains 5 pages + And the request data includes + | parameter | value | + | startingNumber | 10 | + | position | 2 | + | fontSize | 14 | + When I send the API request to the endpoint "/api/v1/misc/add-page-numbers" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response file should have size greater than 200 + And the response PDF should contain 5 pages + + + @unlock-pdf-forms @positive + Scenario: unlock-pdf-forms returns a valid unlocked PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + When I send the API request to the endpoint "/api/v1/misc/unlock-pdf-forms" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response file should have size greater than 0 + + + @scanner-effect @positive + Scenario Outline: scanner-effect applies a scan simulation to a PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 1 pages + And the request data includes + | parameter | value | + | colorspace | | + | quality | | + When I send the API request to the endpoint "/api/v1/misc/scanner-effect" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response file should have size greater than 0 + And the response PDF should contain 1 pages + + Examples: + | colorspace | quality | + | grayscale | low | + | grayscale | medium | + | grayscale | high | + + + @replace-invert-pdf @positive + Scenario: replace-invert-pdf returns a valid PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + And the request data includes + | parameter | value | + | replaceAndInvertOption | HIGH_CONTRAST_COLOR | + | highContrastColorCombination | WHITE_TEXT_ON_BLACK | + When I send the API request to the endpoint "/api/v1/misc/replace-invert-pdf" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response file should have size greater than 0 + And the response PDF should contain 2 pages + + + @replace-invert-pdf @positive + Scenario: replace-invert-pdf on a PDF with images returns a valid PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 2 images of size 100x100 on 1 pages + And the request data includes + | parameter | value | + | replaceAndInvertOption | FULL_INVERSION | + | highContrastColorCombination | WHITE_TEXT_ON_BLACK | + When I send the API request to the endpoint "/api/v1/misc/replace-invert-pdf" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response file should have size greater than 0 + + + @decompress-pdf @positive + Scenario: decompress-pdf returns a decompressed PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 3 pages + When I send the API request to the endpoint "/api/v1/misc/decompress-pdf" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response file should have size greater than 0 + And the response PDF should contain 3 pages + + + @decompress-pdf @positive + Scenario: decompress-pdf on a single-page PDF returns valid output + Given I generate a PDF file as "fileInput" + And the pdf contains 1 pages + When I send the API request to the endpoint "/api/v1/misc/decompress-pdf" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response file should have size greater than 0 + And the response PDF should contain 1 pages + + + @auto-rename @positive + Scenario: auto-rename renames PDF using first text content as filename + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages with random text + And the request data includes + | parameter | value | + | useFirstTextAsFallback | true | + When I send the API request to the endpoint "/api/v1/misc/auto-rename" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response file should have size greater than 0 + + + @auto-rename @positive + Scenario: auto-rename on a plain text PDF returns a PDF with a derived name + Given I generate a PDF file as "fileInput" + And the pdf contains 1 pages with random text + And the request data includes + | parameter | value | + | useFirstTextAsFallback | true | + When I send the API request to the endpoint "/api/v1/misc/auto-rename" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response file should have size greater than 0 + And the response file should have extension ".pdf" + + + @show-javascript @positive + Scenario: show-javascript returns a response for a PDF without JavaScript + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + When I send the API request to the endpoint "/api/v1/misc/show-javascript" + Then the response status code should be 200 + And the response file should have size greater than 0 + + + @show-javascript @positive + Scenario: show-javascript on a multi-page PDF returns status 200 + Given I generate a PDF file as "fileInput" + And the pdf contains 5 pages with random text + When I send the API request to the endpoint "/api/v1/misc/show-javascript" + Then the response status code should be 200 diff --git a/testing/cucumber/features/mobile_scanner.feature b/testing/cucumber/features/mobile_scanner.feature new file mode 100644 index 000000000..96cfde56f --- /dev/null +++ b/testing/cucumber/features/mobile_scanner.feature @@ -0,0 +1,81 @@ +Feature: Mobile Scanner Session API + + Tests for the mobile scanner REST API, which manages short-lived upload + sessions used by mobile scanning clients. + + All endpoints are PUBLIC (no authentication required). Sessions are + identified by a caller-supplied session ID string. + + Session IDs must match [a-zA-Z0-9-]+ (alphanumeric and hyphens only – + underscores are rejected with 400). + + # ========================================================================= + # SESSION LIFECYCLE + # ========================================================================= + + @positive + Scenario: Create a new mobile scanner session + When I send a POST request to "/api/v1/mobile-scanner/create-session/bdd-test-session-001" with no authentication + Then the response status code should be 200 + And the response JSON field "success" should be true + And the response JSON field "sessionId" should not be empty + + @positive + Scenario: Validate an existing mobile scanner session + When I send a POST request to "/api/v1/mobile-scanner/create-session/bdd-test-session-002" with no authentication + Then the response status code should be 200 + When I send a GET request to "/api/v1/mobile-scanner/validate-session/bdd-test-session-002" with no authentication + Then the response status code should be 200 + And the response JSON field "valid" should be true + + @positive + Scenario: List files in an existing session returns empty list initially + When I send a POST request to "/api/v1/mobile-scanner/create-session/bdd-test-session-003" with no authentication + Then the response status code should be 200 + When I send a GET request to "/api/v1/mobile-scanner/files/bdd-test-session-003" with no authentication + Then the response status code should be 200 + + @positive + Scenario: Delete an existing mobile scanner session + When I send a POST request to "/api/v1/mobile-scanner/create-session/bdd-test-session-004" with no authentication + Then the response status code should be 200 + When I send a DELETE request to "/api/v1/mobile-scanner/session/bdd-test-session-004" with no authentication + Then the response status code should be 200 + And the response JSON field "success" should be true + + @positive + Scenario: Full session lifecycle – create, validate, list files, delete + When I send a POST request to "/api/v1/mobile-scanner/create-session/bdd-test-session-full" with no authentication + Then the response status code should be 200 + And the response JSON field "sessionId" should not be empty + When I send a GET request to "/api/v1/mobile-scanner/validate-session/bdd-test-session-full" with no authentication + Then the response status code should be 200 + And the response JSON field "valid" should be true + When I send a GET request to "/api/v1/mobile-scanner/files/bdd-test-session-full" with no authentication + Then the response status code should be 200 + When I send a DELETE request to "/api/v1/mobile-scanner/session/bdd-test-session-full" with no authentication + Then the response status code should be 200 + + # ========================================================================= + # EDGE CASES + # ========================================================================= + + @negative + Scenario: Session ID with underscores is rejected as invalid format + When I send a POST request to "/api/v1/mobile-scanner/create-session/invalid_underscore_id" with no authentication + Then the response status code should be 400 + + @negative + Scenario: Validate a non-existent session returns not-found response + When I send a GET request to "/api/v1/mobile-scanner/validate-session/nonexistent-session-xyz" with no authentication + Then the response status code should be one of "200, 404" + + @negative + Scenario: List files for a non-existent session returns 404 or empty + When I send a GET request to "/api/v1/mobile-scanner/files/nonexistent-session-abc" with no authentication + Then the response status code should be one of "200, 404" + + @negative + Scenario: Delete a non-existent session returns 404 or success + When I send a DELETE request to "/api/v1/mobile-scanner/session/nonexistent-session-xyz" with no authentication + Then the response status code should be one of "200, 404" diff --git a/testing/cucumber/features/security_new.feature b/testing/cucumber/features/security_new.feature new file mode 100644 index 000000000..7058cf2fd --- /dev/null +++ b/testing/cucumber/features/security_new.feature @@ -0,0 +1,163 @@ +@security +Feature: Security API Validation + + @sanitize @positive + Scenario: Sanitize PDF with all options enabled + Given I generate a PDF file as "fileInput" + And the pdf contains 3 pages + And the request data includes + | parameter | value | + | removeJavaScript | true | + | removeEmbeddedFiles | true | + | removeXMPMetadata | true | + | removeMetadata | true | + | removeLinks | true | + | removeFonts | false | + When I send the API request to the endpoint "/api/v1/security/sanitize-pdf" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + And the response file should have extension ".pdf" + + @sanitize @positive + Scenario: Sanitize PDF with default options + Given I generate a PDF file as "fileInput" + And the pdf contains 1 pages + When I send the API request to the endpoint "/api/v1/security/sanitize-pdf" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + And the response file should have extension ".pdf" + + @sanitize @positive + Scenario: Sanitize PDF removing only metadata + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + And the request data includes + | parameter | value | + | removeMetadata | true | + | removeXMPMetadata | true | + When I send the API request to the endpoint "/api/v1/security/sanitize-pdf" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + + @auto-redact @positive + Scenario: Auto-redact searchable text in PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 3 pages + And the pdf pages all contain the text "CONFIDENTIAL" + And the request data includes + | parameter | value | + | listOfText | CONFIDENTIAL | + | useRegex | false | + | wholeWordSearch| true | + | convertPDFToImage | false | + When I send the API request to the endpoint "/api/v1/security/auto-redact" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + And the response file should have extension ".pdf" + + @auto-redact @positive + Scenario: Auto-redact with regex pattern + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + And the pdf pages all contain the text "SECRET-1234" + And the request data includes + | parameter | value | + | listOfText | SECRET-\d+ | + | useRegex | true | + When I send the API request to the endpoint "/api/v1/security/auto-redact" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + And the response file should have extension ".pdf" + + @auto-redact @positive + Scenario: Auto-redact and convert to image + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + And the pdf pages all contain the text "PRIVATE" + And the request data includes + | parameter | value | + | listOfText | PRIVATE | + | convertPDFToImage | true | + When I send the API request to the endpoint "/api/v1/security/auto-redact" + Then the response status code should be 200 + And the response file should have size greater than 0 + + @redact @positive + Scenario: Redact specific pages fully + Given I generate a PDF file as "fileInput" + And the pdf contains 5 pages + And the request data includes + | parameter | value | + | pageNumbers | 2,4 | + | pageRedactionColor | #000000 | + When I send the API request to the endpoint "/api/v1/security/redact" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + And the response file should have extension ".pdf" + + @redact @positive + Scenario: Redact single page with custom color + Given I generate a PDF file as "fileInput" + And the pdf contains 3 pages + And the request data includes + | parameter | value | + | pageNumbers | 1 | + | pageRedactionColor | #ff0000 | + When I send the API request to the endpoint "/api/v1/security/redact" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + + @redact @positive + Scenario: Redact all pages + Given I generate a PDF file as "fileInput" + And the pdf contains 4 pages + And the request data includes + | parameter | value | + | pageNumbers | all | + When I send the API request to the endpoint "/api/v1/security/redact" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + + @verify @positive + Scenario: Verify PDF-A compliance + Given I use an example file at "exampleFiles/pdfa1.pdf" as parameter "fileInput" + When I send the API request to the endpoint "/api/v1/security/verify-pdf" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 2 + + @verify @positive + Scenario: Verify standard PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + When I send the API request to the endpoint "/api/v1/security/verify-pdf" + Then the response status code should be 200 + And the response content type should be "application/json" + And the response file should have size greater than 2 + + @remove-cert-sign @positive + Scenario: Remove cert signature from unsigned PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 2 pages + When I send the API request to the endpoint "/api/v1/security/remove-cert-sign" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 + And the response file should have extension ".pdf" + + @remove-cert-sign @positive + Scenario: Remove cert signature from multi-page unsigned PDF + Given I generate a PDF file as "fileInput" + And the pdf contains 5 pages + When I send the API request to the endpoint "/api/v1/security/remove-cert-sign" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response file should have size greater than 0 diff --git a/testing/cucumber/features/steps/auth_step_definitions.py b/testing/cucumber/features/steps/auth_step_definitions.py new file mode 100644 index 000000000..5206c09b8 --- /dev/null +++ b/testing/cucumber/features/steps/auth_step_definitions.py @@ -0,0 +1,499 @@ +""" +Step definitions for JWT authentication end-to-end tests. + +Covers: + - Login / logout (POST /api/v1/auth/login, /logout) + - Token refresh (POST /api/v1/auth/refresh) + - Current user (GET /api/v1/auth/me) + - Role-based access (admin-only endpoints) + - API key authentication (X-API-KEY header) + - MFA endpoints + - User registration +""" + +import json as json_module + +import requests +from behave import given, then, when + +BASE_URL = "http://localhost:8080" + +# Default test credentials (set in docker-compose-security-with-login.yml) +ADMIN_USERNAME = "admin" +ADMIN_PASSWORD = "stirling" +GLOBAL_API_KEY = "123456789" + + +# --------------------------------------------------------------------------- +# Helper utilities +# --------------------------------------------------------------------------- + + +def _jwt_headers(context): + """Return Authorization: Bearer headers using the stored JWT token.""" + token = getattr(context, "jwt_token", None) + assert token, "No JWT token stored in context – did you use 'Given I am logged in as admin'?" + return {"Authorization": f"Bearer {token}"} + + +def _api_key_headers(api_key): + """Return X-API-KEY headers for a given key.""" + return {"X-API-KEY": api_key} + + +def _do_login(username, password): + """Perform a login POST and return the raw response.""" + payload = {"username": username, "password": password} + return requests.post( + f"{BASE_URL}/api/v1/auth/login", + json=payload, + timeout=30, + ) + + +# --------------------------------------------------------------------------- +# GIVEN – session setup +# --------------------------------------------------------------------------- + + +@given("I am logged in as admin") +def step_logged_in_as_admin(context): + """Login as the default admin user and store the JWT token in context.""" + response = _do_login(ADMIN_USERNAME, ADMIN_PASSWORD) + assert response.status_code == 200, ( + f"Admin login failed (status {response.status_code}): {response.text}" + ) + data = response.json() + context.jwt_token = data["session"]["access_token"] + + +@given("I store the JWT token") +def step_store_current_jwt(context): + """Store the currently held jwt_token into context for later comparison.""" + assert hasattr(context, "jwt_token") and context.jwt_token, ( + "No JWT token available – did you log in first?" + ) + context.original_jwt_token = context.jwt_token + + +# --------------------------------------------------------------------------- +# WHEN – login +# --------------------------------------------------------------------------- + + +@when('I login with username "{username}" and password "{password}"') +def step_login(context, username, password): + """Send a login request with the given username and password.""" + payload = {"username": username, "password": password} + context.response = requests.post( + f"{BASE_URL}/api/v1/auth/login", + json=payload, + timeout=30, + ) + + +@when('I login with only username "{username}"') +def step_login_only_username(context, username): + """Send a login request with only a username field (no password key).""" + context.response = requests.post( + f"{BASE_URL}/api/v1/auth/login", + json={"username": username}, + timeout=30, + ) + + +@when('I login with only password "{password}"') +def step_login_only_password(context, password): + """Send a login request with only a password field (no username key).""" + context.response = requests.post( + f"{BASE_URL}/api/v1/auth/login", + json={"password": password}, + timeout=30, + ) + + +@when('I login with an empty username and password "{password}"') +def step_login_empty_username(context, password): + """Send a login request with an explicit empty string as the username.""" + context.response = requests.post( + f"{BASE_URL}/api/v1/auth/login", + json={"username": "", "password": password}, + timeout=30, + ) + + +@when('I login with username "{username}" and an empty password') +def step_login_empty_password(context, username): + """Send a login request with an explicit empty string as the password.""" + context.response = requests.post( + f"{BASE_URL}/api/v1/auth/login", + json={"username": username, "password": ""}, + timeout=30, + ) + + +# --------------------------------------------------------------------------- +# WHEN – GET with various authentication methods +# --------------------------------------------------------------------------- + + +@when('I send a GET request to "{endpoint}" with JWT authentication') +def step_get_with_jwt(context, endpoint): + """Send GET request using the stored JWT token in the Authorization header.""" + context.response = requests.get( + f"{BASE_URL}{endpoint}", + headers=_jwt_headers(context), + timeout=60, + ) + + +@when('I send a GET request to "{endpoint}" with no authentication') +def step_get_no_auth(context, endpoint): + """Send GET request with no authentication headers whatsoever.""" + context.response = requests.get( + f"{BASE_URL}{endpoint}", + timeout=60, + ) + + +@when('I send a GET request to "{endpoint}" with an invalid JWT token "{token_value}"') +def step_get_with_invalid_jwt(context, endpoint, token_value): + """Send GET request with a specific invalid JWT string.""" + context.response = requests.get( + f"{BASE_URL}{endpoint}", + headers={"Authorization": f"Bearer {token_value}"}, + timeout=60, + ) + + +@when('I send a GET request to "{endpoint}" with a malformed authorization header') +def step_get_with_malformed_auth(context, endpoint): + """Send GET request with a non-Bearer authorization header.""" + context.response = requests.get( + f"{BASE_URL}{endpoint}", + headers={"Authorization": "Basic dXNlcjpwYXNz"}, + timeout=60, + ) + + +@when('I send a GET request to "{endpoint}" with API key "{api_key}"') +def step_get_with_api_key(context, endpoint, api_key): + """Send GET request using an X-API-KEY header.""" + context.response = requests.get( + f"{BASE_URL}{endpoint}", + headers=_api_key_headers(api_key), + timeout=60, + ) + + +@when('I send a GET request to "{endpoint}" with Authorization header value "{header_value}"') +def step_get_with_auth_header_value(context, endpoint, header_value): + """Send GET request with an arbitrary Authorization header value.""" + context.response = requests.get( + f"{BASE_URL}{endpoint}", + headers={"Authorization": header_value}, + timeout=60, + ) + + +@when('I send a GET request to "{endpoint}" with an empty Authorization header') +def step_get_with_empty_auth_header(context, endpoint): + """Send GET request with an explicitly empty Authorization header.""" + context.response = requests.get( + f"{BASE_URL}{endpoint}", + headers={"Authorization": ""}, + timeout=60, + ) + + + + +@when('I send a GET request to "{endpoint}" with the stored JWT token') +def step_get_with_stored_jwt(context, endpoint): + """Send GET request using the JWT token currently stored in context.""" + context.response = requests.get( + f"{BASE_URL}{endpoint}", + headers=_jwt_headers(context), + timeout=60, + ) + + +# --------------------------------------------------------------------------- +# WHEN – POST with various authentication methods +# --------------------------------------------------------------------------- + + +@when('I send a POST request to "{endpoint}" with JWT authentication') +def step_post_with_jwt(context, endpoint): + """Send POST request (no body) using the stored JWT token.""" + context.response = requests.post( + f"{BASE_URL}{endpoint}", + headers=_jwt_headers(context), + timeout=60, + ) + + +@when('I send a POST request to "{endpoint}" with no authentication') +def step_post_no_auth(context, endpoint): + """Send POST request with no authentication headers.""" + context.response = requests.post( + f"{BASE_URL}{endpoint}", + timeout=60, + ) + + +@when('I send a POST request to "{endpoint}" with an invalid JWT token "{token_value}"') +def step_post_with_invalid_jwt(context, endpoint, token_value): + """Send POST request with a specific invalid JWT string.""" + context.response = requests.post( + f"{BASE_URL}{endpoint}", + headers={"Authorization": f"Bearer {token_value}"}, + timeout=60, + ) + + +@when( + 'I send a JSON POST request to "{endpoint}" with JWT authentication and body \'{json_body}\'' +) +def step_json_post_with_jwt(context, endpoint, json_body): + """Send JSON POST request using the stored JWT token and a JSON body.""" + headers = { + "Authorization": f"Bearer {context.jwt_token}", + "Content-Type": "application/json", + } + context.response = requests.post( + f"{BASE_URL}{endpoint}", + headers=headers, + data=json_body, + timeout=60, + ) + + +@when( + 'I send a JSON POST request to "{endpoint}" with API key "{api_key}" and body \'{json_body}\'' +) +def step_json_post_with_api_key(context, endpoint, api_key, json_body): + """Send JSON POST request using X-API-KEY header and a JSON body.""" + headers = { + "X-API-KEY": api_key, + "Content-Type": "application/json", + } + context.response = requests.post( + f"{BASE_URL}{endpoint}", + headers=headers, + data=json_body, + timeout=60, + ) + + +# --------------------------------------------------------------------------- +# WHEN – token refresh +# --------------------------------------------------------------------------- + + +@when("I refresh the JWT token") +def step_refresh_jwt(context): + """Send POST /api/v1/auth/refresh with the stored JWT token.""" + context.response = requests.post( + f"{BASE_URL}/api/v1/auth/refresh", + headers=_jwt_headers(context), + timeout=60, + ) + + +@when("I refresh the stored JWT token") +def step_refresh_stored_jwt(context): + """Send POST /api/v1/auth/refresh with the stored JWT token (alias).""" + step_refresh_jwt(context) + + +# --------------------------------------------------------------------------- +# WHEN – logout +# --------------------------------------------------------------------------- + + +@when("I logout with JWT authentication") +def step_logout_with_jwt(context): + """Send POST /api/v1/auth/logout using the stored JWT token.""" + context.response = requests.post( + f"{BASE_URL}/api/v1/auth/logout", + headers=_jwt_headers(context), + timeout=30, + ) + + +# --------------------------------------------------------------------------- +# THEN – status code variants +# --------------------------------------------------------------------------- + + +@then('the response status code should be one of "{codes}"') +def step_status_code_one_of(context, codes): + """Assert that the response status code matches any one of a comma-separated list.""" + allowed = [int(c.strip()) for c in codes.split(",")] + actual = context.response.status_code + assert actual in allowed, ( + f"Expected status code to be one of {allowed} but got {actual}. " + f"Body: {context.response.text[:500]}" + ) + + +# --------------------------------------------------------------------------- +# THEN – JWT structure assertions +# --------------------------------------------------------------------------- + + +@then("the response should contain a JWT access token") +def step_response_contains_jwt(context): + """Assert the response has a session.access_token that looks like a JWT.""" + data = context.response.json() + assert "session" in data, f"No 'session' key in response: {data}" + assert "access_token" in data["session"], ( + f"No 'access_token' in session: {data['session']}" + ) + token = data["session"]["access_token"] + assert token, "access_token is empty" + parts = token.split(".") + assert len(parts) == 3, ( + f"JWT should have 3 dot-separated parts but got {len(parts)}: {token[:60]}..." + ) + + +@then("the JWT access token should have three dot-separated parts") +def step_jwt_three_parts(context): + """Assert the access_token in the response is a three-part JWT.""" + data = context.response.json() + token = data.get("session", {}).get("access_token", "") + assert token, "No access_token found in response" + parts = token.split(".") + assert len(parts) == 3, ( + f"JWT must have 3 parts (header.payload.signature) but got {len(parts)}: {token[:60]}" + ) + + +# --------------------------------------------------------------------------- +# THEN – JSON field assertions +# --------------------------------------------------------------------------- + + +@then("the response JSON should have field \"{field}\"") +def step_json_has_field(context, field): + """Assert the top-level response JSON contains the specified field.""" + data = context.response.json() + assert field in data, ( + f"Expected field '{field}' in response JSON but only found: {list(data.keys())}" + ) + + +@then('the response JSON should have a user with username "{username}"') +def step_json_user_username(context, username): + """Assert response.user.username equals the expected value.""" + data = context.response.json() + assert "user" in data, f"No 'user' in response: {list(data.keys())}" + actual = data["user"].get("username") or data["user"].get("email", "") + assert actual == username, f"Expected username '{username}' but got '{actual}'" + + +@then('the response JSON should have a user with role "{role}"') +def step_json_user_role(context, role): + """Assert response.user.role equals the expected role string.""" + data = context.response.json() + assert "user" in data, f"No 'user' in response: {list(data.keys())}" + actual = data["user"].get("role", "") + assert actual == role, f"Expected role '{role}' but got '{actual}'" + + +@then('the response JSON user field "{field}" should not be empty') +def step_json_user_field_not_empty(context, field): + """Assert response.user. exists and is non-empty.""" + data = context.response.json() + assert "user" in data, f"No 'user' in response: {list(data.keys())}" + value = data["user"].get(field) + assert value is not None and str(value) != "", ( + f"Expected user field '{field}' to be non-empty, got: {value!r}" + ) + + +@then('the response JSON user field "{field}" should equal "{expected}"') +def step_json_user_field_equals(context, field, expected): + """Assert response.user. equals the expected string value. + + JSON booleans are compared as lowercase strings ("true"/"false"). + """ + data = context.response.json() + assert "user" in data, f"No 'user' in response: {list(data.keys())}" + value = data["user"].get(field, "") + actual = str(value).lower() if isinstance(value, bool) else str(value) + assert actual == expected, ( + f"Expected user field '{field}' == '{expected}' but got '{actual}'" + ) + + +@then('the response JSON field "{field}" should equal "{expected}"') +def step_json_top_field_equals(context, field, expected): + """Assert a top-level JSON field equals the expected string value. + + JSON booleans (true/false) are compared as lowercase strings to match + JSON serialisation ("true"/"false"), not Python's "True"/"False". + """ + data = context.response.json() + value = data.get(field, "") + actual = str(value).lower() if isinstance(value, bool) else str(value) + assert actual == expected, ( + f"Expected JSON field '{field}' == '{expected}' but got '{actual}'. " + f"Full response: {data}" + ) + + +@then('the response JSON session field "{field}" should be positive') +def step_json_session_field_positive(context, field): + """Assert response.session. is a number greater than zero.""" + data = context.response.json() + assert "session" in data, f"No 'session' in response: {list(data.keys())}" + value = data["session"].get(field) + assert value is not None, f"Field '{field}' not found in session: {data['session']}" + assert int(value) > 0, f"Expected session field '{field}' > 0 but got {value}" + + +@then('the response JSON error should contain "{error_text}"') +def step_json_error_contains(context, error_text): + """Assert the error/message/detail field contains the expected substring (case-insensitive).""" + data = context.response.json() + error = ( + data.get("error") + or data.get("message") + or data.get("detail") + or "" + ) + assert error_text.lower() in str(error).lower(), ( + f"Expected '{error_text}' (case-insensitive) in error response but got: '{error}'. " + f"Full response: {data}" + ) + + +# --------------------------------------------------------------------------- +# THEN – token storage and chaining +# --------------------------------------------------------------------------- + + +@then("I store the JWT token from the login response") +def step_store_jwt_from_login(context): + """Extract and store access_token from the login response.""" + data = context.response.json() + assert "session" in data and "access_token" in data["session"], ( + f"No access_token in login response: {data}" + ) + context.jwt_token = data["session"]["access_token"] + assert context.jwt_token, "Stored JWT token is empty" + + +@then("I update the stored JWT token from the response") +def step_update_stored_jwt(context): + """Replace the stored JWT token with the new one from the current response.""" + data = context.response.json() + assert "session" in data and "access_token" in data["session"], ( + f"No access_token in response: {data}" + ) + new_token = data["session"]["access_token"] + assert new_token, "New JWT token from response is empty" + context.jwt_token = new_token diff --git a/testing/cucumber/features/steps/enterprise_step_definitions.py b/testing/cucumber/features/steps/enterprise_step_definitions.py new file mode 100644 index 000000000..ae3c7a57d --- /dev/null +++ b/testing/cucumber/features/steps/enterprise_step_definitions.py @@ -0,0 +1,263 @@ +""" +Step definitions for enterprise and proprietary API endpoints. + +Covers: + - User management (password change, API keys, admin CRUD) + - Admin settings + - Audit log + - Invite links + - Mobile scanner sessions + - Signatures + - Teams +""" + +import requests +from behave import given, then, when + +BASE_URL = "http://localhost:8080" + + +def _jwt_headers(context): + """Return Authorization: Bearer headers using the stored JWT token.""" + token = getattr(context, "jwt_token", None) + assert token, "No JWT token stored in context – did you use 'Given I am logged in as admin'?" + return {"Authorization": f"Bearer {token}"} + + +def _parse_params(params_str): + """Parse 'key1=val1&key2=val2' into a dict, supporting values that contain '='.""" + result = {} + for part in params_str.split("&"): + if "=" in part: + k, v = part.split("=", 1) + result[k.strip()] = v.strip() + return result + + +def _expand_stored(template, context): + """Replace '{stored}' in an endpoint template with context.stored_value.""" + return template.replace("{stored}", getattr(context, "stored_value", "")) + + +# --------------------------------------------------------------------------- +# WHEN – POST with query params +# --------------------------------------------------------------------------- + + +@when('I send a POST request to "{endpoint}" with JWT authentication and params "{params}"') +def step_post_with_jwt_and_params(context, endpoint, params): + """Send POST request with query parameters using the stored JWT token.""" + context.response = requests.post( + f"{BASE_URL}{endpoint}", + headers=_jwt_headers(context), + params=_parse_params(params), + timeout=60, + ) + + +@when('I send a POST request to "{endpoint}" with no authentication and params "{params}"') +def step_post_no_auth_and_params(context, endpoint, params): + """Send POST request with query parameters and no authentication.""" + context.response = requests.post( + f"{BASE_URL}{endpoint}", + params=_parse_params(params), + timeout=60, + ) + + +# --------------------------------------------------------------------------- +# WHEN – GET with query params +# --------------------------------------------------------------------------- + + +@when('I send a GET request to "{endpoint}" with JWT authentication and params "{params}"') +def step_get_with_jwt_and_params(context, endpoint, params): + """Send GET request with query parameters using the stored JWT token.""" + context.response = requests.get( + f"{BASE_URL}{endpoint}", + headers=_jwt_headers(context), + params=_parse_params(params), + timeout=60, + ) + + +# --------------------------------------------------------------------------- +# WHEN – DELETE +# --------------------------------------------------------------------------- + + +@when('I send a DELETE request to "{endpoint}" with JWT authentication') +def step_delete_with_jwt(context, endpoint): + """Send DELETE request using the stored JWT token.""" + context.response = requests.delete( + f"{BASE_URL}{endpoint}", + headers=_jwt_headers(context), + timeout=60, + ) + + +@when('I send a DELETE request to "{endpoint}" with no authentication') +def step_delete_no_auth(context, endpoint): + """Send DELETE request with no authentication headers.""" + context.response = requests.delete( + f"{BASE_URL}{endpoint}", + timeout=60, + ) + + +@when('I send a DELETE request to "{endpoint}" with JWT authentication and params "{params}"') +def step_delete_with_jwt_and_params(context, endpoint, params): + """Send DELETE request with query parameters using the stored JWT token.""" + context.response = requests.delete( + f"{BASE_URL}{endpoint}", + headers=_jwt_headers(context), + params=_parse_params(params), + timeout=60, + ) + + +@when('I send a DELETE request to "{endpoint}" with no authentication and params "{params}"') +def step_delete_no_auth_and_params(context, endpoint, params): + """Send DELETE request with query parameters and no authentication.""" + context.response = requests.delete( + f"{BASE_URL}{endpoint}", + params=_parse_params(params), + timeout=60, + ) + + +# --------------------------------------------------------------------------- +# WHEN – steps that use a previously stored value in the URL path +# --------------------------------------------------------------------------- + + +@when( + 'I use the stored value to send a GET request to "{endpoint_template}" with JWT authentication' +) +def step_get_stored_jwt(context, endpoint_template): + """Send GET request substituting {stored} in the path with context.stored_value.""" + endpoint = _expand_stored(endpoint_template, context) + context.response = requests.get( + f"{BASE_URL}{endpoint}", + headers=_jwt_headers(context), + timeout=60, + ) + + +@when( + 'I use the stored value to send a GET request to "{endpoint_template}" with no authentication' +) +def step_get_stored_no_auth(context, endpoint_template): + """Send GET request substituting {stored} in the path with no authentication.""" + endpoint = _expand_stored(endpoint_template, context) + context.response = requests.get( + f"{BASE_URL}{endpoint}", + timeout=60, + ) + + +@when( + 'I use the stored value to send a DELETE request to "{endpoint_template}" with JWT authentication' +) +def step_delete_stored_jwt(context, endpoint_template): + """Send DELETE request substituting {stored} in the path with context.stored_value.""" + endpoint = _expand_stored(endpoint_template, context) + context.response = requests.delete( + f"{BASE_URL}{endpoint}", + headers=_jwt_headers(context), + timeout=60, + ) + + +@when( + 'I use the stored value to send a POST request to "{endpoint_template}" with JWT authentication' +) +def step_post_stored_jwt(context, endpoint_template): + """Send POST request substituting {stored} in the path with context.stored_value.""" + endpoint = _expand_stored(endpoint_template, context) + context.response = requests.post( + f"{BASE_URL}{endpoint}", + headers=_jwt_headers(context), + timeout=60, + ) + + +# --------------------------------------------------------------------------- +# THEN – store response value for later steps +# --------------------------------------------------------------------------- + + +@then('I store the response JSON field "{field}"') +def step_store_response_field(context, field): + """Store a top-level JSON field from the current response into context.stored_value.""" + data = context.response.json() + value = data.get(field) + assert value is not None, f"Field '{field}' not found in response: {data}" + context.stored_value = str(value) + + +# --------------------------------------------------------------------------- +# THEN – response body / field assertions +# --------------------------------------------------------------------------- + + +@then('the response JSON field "{field}" should not be empty') +def step_json_top_field_not_empty(context, field): + """Assert a top-level JSON field is present and non-empty.""" + data = context.response.json() + value = data.get(field) + assert value is not None and str(value) != "", ( + f"Expected field '{field}' to be non-empty, got: {value!r}. " + f"Full response: {data}" + ) + + +@then("the response body should not be empty") +def step_response_body_not_empty(context): + """Assert that the raw response body contains at least one byte.""" + assert len(context.response.content) > 0, "Response body is empty" + + +@then("the response JSON should be a list") +def step_response_is_list(context): + """Assert that the top-level response JSON value is a list.""" + data = context.response.json() + assert isinstance(data, list), ( + f"Expected response to be a JSON list but got: {type(data).__name__}. " + f"Content: {str(data)[:200]}" + ) + + +@then('the response JSON field "{field}" should be a list') +def step_json_field_is_list(context, field): + """Assert a top-level JSON field is a list.""" + data = context.response.json() + value = data.get(field) + assert isinstance(value, list), ( + f"Expected field '{field}' to be a list but got: {type(value).__name__}. " + f"Full response: {data}" + ) + + +@then('the response JSON field "{field}" should be true') +def step_json_field_is_true(context, field): + """Assert a top-level JSON boolean field is true.""" + data = context.response.json() + value = data.get(field) + actual = str(value).lower() if isinstance(value, bool) else str(value).lower() + assert actual == "true", ( + f"Expected field '{field}' to be true but got: {value!r}. " + f"Full response: {data}" + ) + + +@then('the response JSON field "{field}" should be false') +def step_json_field_is_false(context, field): + """Assert a top-level JSON boolean field is false.""" + data = context.response.json() + value = data.get(field) + actual = str(value).lower() if isinstance(value, bool) else str(value).lower() + assert actual == "false", ( + f"Expected field '{field}' to be false but got: {value!r}. " + f"Full response: {data}" + ) diff --git a/testing/cucumber/features/steps/step_definitions.py b/testing/cucumber/features/steps/step_definitions.py index 8f99ab89c..8bdaa473e 100644 --- a/testing/cucumber/features/steps/step_definitions.py +++ b/testing/cucumber/features/steps/step_definitions.py @@ -1,3 +1,4 @@ +import json as json_module import os import requests from behave import given, when, then @@ -289,6 +290,273 @@ def save_generated_pdf(context, filename): print(f"Saved generated PDF content to {filename}") +# --------------------------------------------------------------------------- +# Multi-file accumulation steps (same parameter key sent multiple times) +# --------------------------------------------------------------------------- + + +@given('I also generate a PDF file as "{param}"') +def step_also_generate_pdf(context, param): + """Add an additional generated PDF under the given parameter name (supports duplicate keys).""" + count = sum(1 for k, _ in getattr(context, "multi_files", []) if k == param) + file_name = f"genericNonCustomisableName_extra_{param}_{count}.pdf" + writer = PdfWriter() + writer.add_blank_page(width=72, height=72) + with open(file_name, "wb") as f: + writer.write(f) + if not hasattr(context, "multi_files"): + context.multi_files = [] + context.multi_files.append((param, open(file_name, "rb"))) + + +@given('I also use an example file at "{filePath}" as parameter "{param}"') +def step_also_use_example_file(context, filePath, param): + """Add an additional file from exampleFiles under the given parameter name.""" + if not hasattr(context, "multi_files"): + context.multi_files = [] + try: + context.multi_files.append((param, open(filePath, "rb"))) + except FileNotFoundError: + raise FileNotFoundError(f"The example file '{filePath}' does not exist.") + + +# --------------------------------------------------------------------------- +# Non-PDF file generation steps +# --------------------------------------------------------------------------- + + +@given('I generate a PNG image file as "{param}"') +def step_generate_png(context, param): + """Generate a simple coloured PNG and register it under the given parameter name.""" + file_name = f"genericNonCustomisableName_{param}.png" + img = Image.new("RGB", (200, 200), color=(73, 109, 137)) + draw = ImageDraw.Draw(img) + draw.rectangle([50, 50, 150, 150], fill=(255, 165, 0)) + draw.ellipse([75, 75, 125, 125], fill=(255, 255, 255)) + img.save(file_name, format="PNG") + if not hasattr(context, "files"): + context.files = {} + context.files[param] = open(file_name, "rb") + context.param_name = param + context.file_name = file_name + + +@given('I also generate a PNG image file as "{param}"') +def step_also_generate_png(context, param): + """Add an additional PNG image under the given parameter name (supports duplicate keys).""" + count = sum(1 for k, _ in getattr(context, "multi_files", []) if k == param) + file_name = f"genericNonCustomisableName_extra_{param}_{count}.png" + img = Image.new("RGB", (200, 200), color=(count * 60 + 40, 100, 180)) + draw = ImageDraw.Draw(img) + draw.rectangle([30, 30, 170, 170], fill=(200 - count * 30, 150, 50)) + img.save(file_name, format="PNG") + if not hasattr(context, "multi_files"): + context.multi_files = [] + context.multi_files.append((param, open(file_name, "rb"))) + + +@given('I generate an SVG file as "{param}"') +def step_generate_svg(context, param): + """Generate a minimal SVG file and register it under the given parameter name.""" + file_name = f"genericNonCustomisableName_{param}.svg" + svg_content = ( + '\n' + '\n' + ' \n' + ' \n' + ' Test SVG\n' + "\n" + ) + with open(file_name, "w", encoding="utf-8") as f: + f.write(svg_content) + if not hasattr(context, "files"): + context.files = {} + context.files[param] = open(file_name, "rb") + context.param_name = param + context.file_name = file_name + + +@given('I also generate an SVG file as "{param}"') +def step_also_generate_svg(context, param): + """Add an additional SVG under the given parameter name (supports duplicate keys).""" + count = sum(1 for k, _ in getattr(context, "multi_files", []) if k == param) + file_name = f"genericNonCustomisableName_extra_{param}_{count}.svg" + svg_content = ( + '\n' + '\n' + f' \n' + ' \n' + "\n" + ) + with open(file_name, "w", encoding="utf-8") as f: + f.write(svg_content) + if not hasattr(context, "multi_files"): + context.multi_files = [] + context.multi_files.append((param, open(file_name, "rb"))) + + +@given('I generate an EML email file as "{param}"') +def step_generate_eml(context, param): + """Generate a minimal RFC-2822 EML file and register it under the given parameter name.""" + file_name = f"genericNonCustomisableName_{param}.eml" + eml_content = ( + "MIME-Version: 1.0\r\n" + "Date: Thu, 19 Feb 2026 10:00:00 +0000\r\n" + "Message-ID: \r\n" + "From: sender@example.com\r\n" + "To: recipient@example.com\r\n" + "Subject: Test Email for PDF Conversion\r\n" + "Content-Type: text/plain; charset=UTF-8\r\n" + "\r\n" + "This is a test email body.\r\n" + "It contains multiple lines of text.\r\n" + "Used for EML to PDF conversion testing.\r\n" + ) + with open(file_name, "w", encoding="utf-8") as f: + f.write(eml_content) + if not hasattr(context, "files"): + context.files = {} + context.files[param] = open(file_name, "rb") + context.param_name = param + context.file_name = file_name + + +@given('I generate a CBZ comic archive file as "{param}"') +def step_generate_cbz(context, param): + """Generate a CBZ file (ZIP of PNG images) and register it under the given parameter name.""" + file_name = f"genericNonCustomisableName_{param}.cbz" + with zipfile.ZipFile(file_name, "w") as cbz: + for i in range(3): + img = Image.new("RGB", (200, 300), color=(i * 60 + 40, 120, 200 - i * 50)) + draw = ImageDraw.Draw(img) + draw.rectangle([20, 20, 180, 280], outline=(0, 0, 0), width=3) + draw.rectangle([40, 40, 160, 100], fill=(200, 200, 255)) + img_bytes = io.BytesIO() + img.save(img_bytes, format="PNG") + cbz.writestr(f"page_{i + 1:03d}.png", img_bytes.getvalue()) + if not hasattr(context, "files"): + context.files = {} + context.files[param] = open(file_name, "rb") + context.param_name = param + context.file_name = file_name + + +# --------------------------------------------------------------------------- +# PDF modification steps +# --------------------------------------------------------------------------- + + +@given("the pdf has form fields") +def step_pdf_has_form_fields(context): + """Create a PDF with a basic AcroForm text field on each page.""" + reader = PdfReader(context.file_name) + page_count = len(reader.pages) + buffer = io.BytesIO() + c = canvas.Canvas(buffer, pagesize=letter) + w, h = letter + for i in range(page_count): + c.acroForm.textfield( + name=f"field_{i + 1}", + tooltip=f"Field {i + 1}", + x=72, + y=h - 72, + width=200, + height=20, + forceBorder=True, + ) + c.showPage() + c.save() + with open(context.file_name, "wb") as f: + f.write(buffer.getvalue()) + context.files[context.param_name].close() + context.files[context.param_name] = open(context.file_name, "rb") + + +@given('the pdf has an attachment named "{attachment_name}"') +def step_pdf_has_attachment(context, attachment_name): + """Embed a small text attachment into the current PDF.""" + reader = PdfReader(context.file_name) + writer = PdfWriter() + for page in reader.pages: + writer.add_page(page) + attachment_bytes = ( + f"Attachment: {attachment_name}\nThis is test attachment content.".encode("utf-8") + ) + writer.add_attachment(attachment_name, attachment_bytes) + with open(context.file_name, "wb") as f: + writer.write(f) + context.files[context.param_name].close() + context.files[context.param_name] = open(context.file_name, "rb") + + +@given("the pdf has bookmarks") +def step_pdf_has_bookmarks(context): + """Add one top-level outline/bookmark entry per page to the current PDF.""" + reader = PdfReader(context.file_name) + writer = PdfWriter() + for page in reader.pages: + writer.add_page(page) + for i in range(len(reader.pages)): + writer.add_outline_item(f"Chapter {i + 1}", i) + with open(context.file_name, "wb") as f: + writer.write(f) + context.files[context.param_name].close() + context.files[context.param_name] = open(context.file_name, "rb") + + +@given("the pdf has a Stirling-PDF QR code split marker on page {page_num:d}") +def step_pdf_has_qr_split_marker(context, page_num): + """Replace page page_num (1-indexed) with a page containing a Stirling-PDF QR code.""" + try: + import qrcode as _qrcode + except ImportError: + raise ImportError( + "qrcode package is required for this step. " + "Install with: pip install 'qrcode[pil]'" + ) + reader = PdfReader(context.file_name) + qr = _qrcode.QRCode(box_size=4, border=2) + qr.add_data("https://github.com/Stirling-Tools/Stirling-PDF") + qr.make(fit=True) + qr_img = qr.make_image(fill_color="black", back_color="white") + qr_bytes = io.BytesIO() + qr_img.save(qr_bytes, format="PNG") + qr_bytes.seek(0) + writer = PdfWriter() + for i, page in enumerate(reader.pages): + if i + 1 == page_num: + packet = io.BytesIO() + can = canvas.Canvas(packet, pagesize=letter) + w, h = letter + can.drawImage( + ImageReader(qr_bytes), (w - 100) / 2, (h - 100) / 2, width=100, height=100 + ) + can.showPage() + can.save() + packet.seek(0) + qr_pdf = PdfReader(packet) + writer.add_page(qr_pdf.pages[0]) + else: + writer.add_page(page) + with open(context.file_name, "wb") as f: + writer.write(f) + context.files[context.param_name].close() + context.files[context.param_name] = open(context.file_name, "rb") + + +# --------------------------------------------------------------------------- +# JSON multipart part steps (@RequestPart endpoints like /form/fill) +# --------------------------------------------------------------------------- + + +@given('the request includes a JSON part "{part_name}" with content "{json_content}"') +def step_request_json_part(context, part_name, json_content): + """Register a JSON multipart part (sent with Content-Type: application/json).""" + if not hasattr(context, "json_parts"): + context.json_parts = {} + context.json_parts[part_name] = json_content + + ######## # WHEN # ######## @@ -337,6 +605,17 @@ def step_send_api_request(context, endpoint): print(f"form_data {file.name} with {mime_type}") form_data.append((key, (file.name, file, mime_type))) + # Multi-file entries (duplicate keys for MultipartFile[] endpoints, e.g. merge-pdfs) + for key, file in getattr(context, "multi_files", []): + mime_type, _ = mimetypes.guess_type(file.name) + mime_type = mime_type or "application/octet-stream" + print(f"form_data (multi) {file.name} with {mime_type}") + form_data.append((key, (file.name, file, mime_type))) + + # JSON multipart parts for @RequestPart endpoints (e.g. /form/fill) + for part_name, json_content in getattr(context, "json_parts", {}).items(): + form_data.append((part_name, (None, json_content, "application/json"))) + # Set timeout to 300 seconds (5 minutes) to prevent infinite hangs print(f"Sending POST request to {endpoint} with timeout=300s") response = requests.post(url, files=form_data, headers=API_HEADERS, timeout=300) diff --git a/testing/cucumber/features/user_management.feature b/testing/cucumber/features/user_management.feature new file mode 100644 index 000000000..ccb9366cf --- /dev/null +++ b/testing/cucumber/features/user_management.feature @@ -0,0 +1,124 @@ +@jwt @auth @user_mgmt +Feature: User Management API + + Tests for the user management REST API, covering API key operations, + password changes, and admin-level user CRUD (create, role change, + enable/disable, delete, force password change). + + Admin credentials: username=admin, password=stirling + Global API key: 123456789 + + Each admin CRUD scenario creates and then deletes the test user within + the same scenario to avoid hitting the licence user limit. + + # ========================================================================= + # API KEY OPERATIONS + # ========================================================================= + + @positive + Scenario: Authenticated user can retrieve their current API key + Given I am logged in as admin + When I send a POST request to "/api/v1/user/get-api-key" with JWT authentication + Then the response status code should be 200 + And the response body should not be empty + + @positive + Scenario: Authenticated user can update their API key + Given I am logged in as admin + When I send a POST request to "/api/v1/user/update-api-key" with JWT authentication + Then the response status code should be 200 + And the response body should not be empty + + @negative + Scenario: Get API key without authentication returns 401 + When I send a POST request to "/api/v1/user/get-api-key" with no authentication + Then the response status code should be 401 + + @negative + Scenario: Update API key without authentication returns 401 + When I send a POST request to "/api/v1/user/update-api-key" with no authentication + Then the response status code should be 401 + + # ========================================================================= + # PASSWORD CHANGE + # ========================================================================= + + @positive + Scenario: Admin can change their own password and revert it + Given I am logged in as admin + When I send a POST request to "/api/v1/user/change-password" with JWT authentication and params "currentPassword=stirling&newPassword=stirling_temp_bdd" + Then the response status code should be 200 + # Revert to original password so other tests are not broken + When I send a POST request to "/api/v1/user/change-password" with JWT authentication and params "currentPassword=stirling_temp_bdd&newPassword=stirling" + Then the response status code should be 200 + + @negative + Scenario: Change password with wrong current password returns 401 + Given I am logged in as admin + When I send a POST request to "/api/v1/user/change-password" with JWT authentication and params "currentPassword=completely_wrong_pass_xyz&newPassword=stirling2" + Then the response status code should be one of "400, 401" + + @negative + Scenario: Change password without authentication returns 401 + When I send a POST request to "/api/v1/user/change-password" with no authentication and params "currentPassword=stirling&newPassword=stirling2" + Then the response status code should be 401 + + # ========================================================================= + # ADMIN USER CRUD + # Each scenario is self-contained: creates the test user, runs the + # operation under test, then deletes the user to free the licence slot. + # ========================================================================= + + @admin @positive + Scenario: Admin can create and delete a user account + Given I am logged in as admin + When I send a POST request to "/api/v1/user/admin/saveUser" with JWT authentication and params "username=bdd_mgmt_test_user&password=TestPass123!&role=ROLE_USER&authType=web&forceChange=false" + Then the response status code should be one of "200, 201" + # Clean up immediately so the licence slot is freed for other scenarios + When I send a POST request to "/api/v1/user/admin/deleteUser/bdd_mgmt_test_user" with JWT authentication + Then the response status code should be 200 + + @admin @positive + Scenario: Admin can enable and disable a user account + Given I am logged in as admin + When I send a POST request to "/api/v1/user/admin/saveUser" with JWT authentication and params "username=bdd_mgmt_test_user&password=TestPass123!&role=ROLE_USER&authType=web&forceChange=false" + Then the response status code should be one of "200, 201" + When I send a POST request to "/api/v1/user/admin/changeUserEnabled/bdd_mgmt_test_user" with JWT authentication and params "enabled=true" + Then the response status code should be 200 + When I send a POST request to "/api/v1/user/admin/changeUserEnabled/bdd_mgmt_test_user" with JWT authentication and params "enabled=false" + Then the response status code should be 200 + # Clean up + When I send a POST request to "/api/v1/user/admin/deleteUser/bdd_mgmt_test_user" with JWT authentication + Then the response status code should be 200 + + @admin @positive + Scenario: Admin can change a user's role + Given I am logged in as admin + When I send a POST request to "/api/v1/user/admin/saveUser" with JWT authentication and params "username=bdd_mgmt_test_user&password=TestPass123!&role=ROLE_USER&authType=web&forceChange=false" + Then the response status code should be one of "200, 201" + When I send a POST request to "/api/v1/user/admin/changeRole" with JWT authentication and params "username=bdd_mgmt_test_user&role=ROLE_USER" + Then the response status code should be 200 + # Clean up + When I send a POST request to "/api/v1/user/admin/deleteUser/bdd_mgmt_test_user" with JWT authentication + Then the response status code should be 200 + + @admin @positive + Scenario: Admin can force-change a user's password + Given I am logged in as admin + When I send a POST request to "/api/v1/user/admin/saveUser" with JWT authentication and params "username=bdd_mgmt_test_user&password=TestPass123!&role=ROLE_USER&authType=web&forceChange=false" + Then the response status code should be one of "200, 201" + When I send a POST request to "/api/v1/user/admin/changePasswordForUser" with JWT authentication and params "username=bdd_mgmt_test_user&newPassword=NewTestPass456!" + Then the response status code should be 200 + # Clean up + When I send a POST request to "/api/v1/user/admin/deleteUser/bdd_mgmt_test_user" with JWT authentication + Then the response status code should be 200 + + @admin @negative + Scenario: Non-admin cannot save a user via admin endpoint (returns 401 or 403) + When I send a POST request to "/api/v1/user/admin/saveUser" with no authentication and params "username=evil_user&password=pass&role=ROLE_ADMIN&authType=web&forceChange=false" + Then the response status code should be one of "401, 403" + + @admin @negative + Scenario: Non-admin cannot delete a user via admin endpoint (returns 401 or 403) + When I send a POST request to "/api/v1/user/admin/deleteUser/admin" with no authentication + Then the response status code should be one of "401, 403" diff --git a/testing/cucumber/requirements.in b/testing/cucumber/requirements.in index add7c499f..8b1f01d1f 100644 --- a/testing/cucumber/requirements.in +++ b/testing/cucumber/requirements.in @@ -1,5 +1,7 @@ behave +behave-html-formatter requests pypdf reportlab PyCryptodome +qrcode[pil] diff --git a/testing/cucumber/requirements.txt b/testing/cucumber/requirements.txt index 1f952a665..319e75c28 100644 --- a/testing/cucumber/requirements.txt +++ b/testing/cucumber/requirements.txt @@ -295,6 +295,10 @@ pypdf==6.6.2 \ --hash=sha256:0a3ea3b3303982333404e22d8f75d7b3144f9cf4b2970b96856391a516f9f016 \ --hash=sha256:44c0c9811cfb3b83b28f1c3d054531d5b8b81abaedee0d8cb403650d023832ba # via -r testing/cucumber/requirements.in +qrcode==8.0 \ + --hash=sha256:9fc05f03305ad27a709eb742cf3097fa19e6f6f93bb9e2f039c0979190f6f1b1 \ + --hash=sha256:025ce2b150f7fe4296d116ee9bad455a6643ab4f6e7dce541613a4758cbce347 + # via -r testing/cucumber/requirements.in reportlab==4.4.9 \ --hash=sha256:68e2d103ae8041a37714e8896ec9b79a1c1e911d68c3bd2ea17546568cf17bfd \ --hash=sha256:7cf487764294ee791a4781f5a157bebce262a666ae4bbb87786760a9676c9378 diff --git a/testing/test.sh b/testing/test.sh index 788799deb..ea787eae4 100644 --- a/testing/test.sh +++ b/testing/test.sh @@ -108,6 +108,7 @@ capture_file_list() { -not -path '*/tmp/stirling-pdf/jetty-*/*' \ -not -path '*/tmp/stirling-pdf/lu*' \ -not -path '*/tmp/stirling-pdf/tmp*' \ + -not -path '*/tmp/stirling-pdf/stirling-pdf-*.pdf' \ 2>/dev/null | xargs -I{} sh -c 'stat -c \"%n %s %Y\" \"{}\" 2>/dev/null || true' | sort" > "$output_file" # Check if the output file has content @@ -131,6 +132,7 @@ capture_file_list() { -not -path '*/tmp/stirling-pdf/jetty-*/*' \ -not -path '*/tmp/lu*' \ -not -path '*/tmp/tmp*' \ + -not -path '*/tmp/stirling-pdf/stirling-pdf-*.pdf' \ 2>/dev/null | sort" > "$output_file" if [ ! -s "$output_file" ]; then @@ -539,8 +541,14 @@ main() { capture_file_list "$CONTAINER_NAME" "$BEFORE_FILE" + CUCUMBER_REPORT="$PROJECT_ROOT/testing/cucumber/report.html" + CUCUMBER_JUNIT_DIR="$PROJECT_ROOT/testing/cucumber/junit" + mkdir -p "$CUCUMBER_JUNIT_DIR" cd "testing/cucumber" - if python -m behave; then + if python -m behave \ + -f behave_html_formatter:HTMLFormatter -o "$CUCUMBER_REPORT" \ + -f pretty \ + --junit --junit-directory "$CUCUMBER_JUNIT_DIR"; then echo "Waiting 5 seconds for any file operations to complete..." sleep 5