mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Cucumber concurrency validation plus fix (#7379)
# Description of Changes cucumber tests to run multiple threads of commands at same time --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
# Run either directly, e.g. `python -m behave features/enterprise`.
|
||||
exclude_re = features/(enterprise|multinode)
|
||||
tags = ~@manual
|
||||
~@nightly
|
||||
|
||||
[behave.formatters]
|
||||
# Registers the html report formatter (behave-html-formatter) used by run-multinode-regression.sh.
|
||||
|
||||
@@ -15,6 +15,7 @@ Feature: Analysis API Endpoints
|
||||
Given I generate a PDF file as "fileInput"
|
||||
And the pdf contains <pages> pages
|
||||
When I send the API request to the endpoint "/api/v1/analysis/page-count"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -35,6 +36,7 @@ Feature: Analysis API Endpoints
|
||||
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"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -66,6 +68,7 @@ Feature: Analysis API Endpoints
|
||||
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"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -104,6 +107,7 @@ Feature: Analysis API Endpoints
|
||||
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"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -118,6 +122,7 @@ Feature: Analysis API Endpoints
|
||||
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"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -141,6 +146,7 @@ Feature: Analysis API Endpoints
|
||||
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"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -164,6 +170,7 @@ Feature: Analysis API Endpoints
|
||||
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"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -187,6 +194,7 @@ Feature: Analysis API Endpoints
|
||||
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"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
@jobs
|
||||
Feature: Asynchronous job API
|
||||
|
||||
# Any tool endpoint accepts ?async=true and returns a jobId instead of the file.
|
||||
# No parallel step here: every step after the submit depends on that one jobId.
|
||||
|
||||
@positive
|
||||
Scenario: An async job runs to completion and returns its result
|
||||
Given I generate a PDF file as "fileInput"
|
||||
And the pdf contains 3 pages
|
||||
And the request data includes
|
||||
| parameter | value |
|
||||
| angle | 90 |
|
||||
When I send the API request to the endpoint "/api/v1/general/rotate-pdf?async=true"
|
||||
Then the response status code should be 200
|
||||
And the response JSON field "async" should be true
|
||||
|
||||
When I store the job id from the response
|
||||
And I wait for the job to complete
|
||||
Then the job should be reported complete
|
||||
|
||||
When I request the job result
|
||||
Then the response status code should be 200
|
||||
And the response content type should be "application/pdf"
|
||||
And the response PDF should contain 3 pages
|
||||
|
||||
|
||||
@positive
|
||||
Scenario: Async job results are listed and downloadable as individual files
|
||||
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/general/split-pages?async=true"
|
||||
Then the response status code should be 200
|
||||
|
||||
When I store the job id from the response
|
||||
And I wait for the job to complete
|
||||
And I request the job result file list
|
||||
Then the response status code should be 200
|
||||
And the job result file list should contain at least 4 file(s)
|
||||
|
||||
When I request the first job result file metadata
|
||||
Then the response status code should be 200
|
||||
And the response JSON field "fileName" should not be empty
|
||||
|
||||
When I download the first job result file
|
||||
Then the response status code should be 200
|
||||
And the response file should have size greater than 100
|
||||
|
||||
|
||||
@negative
|
||||
Scenario: Cancelling an already-finished job is rejected
|
||||
Given I generate a PDF file as "fileInput"
|
||||
And the pdf contains 2 pages
|
||||
And the request data includes
|
||||
| parameter | value |
|
||||
| angle | 180 |
|
||||
When I send the API request to the endpoint "/api/v1/general/rotate-pdf?async=true"
|
||||
And I store the job id from the response
|
||||
And I wait for the job to complete
|
||||
And I cancel the job
|
||||
Then the response status code should be 400
|
||||
|
||||
|
||||
@negative
|
||||
Scenario: Polling a job id the caller does not own is forbidden
|
||||
When I send a GET request to "/api/v1/general/job/does-not-exist-1234"
|
||||
Then the response status code should be 403
|
||||
|
||||
|
||||
@negative
|
||||
Scenario: Downloading an unknown file id is rejected
|
||||
When I send a GET request to "/api/v1/general/files/does-not-exist-1234"
|
||||
Then the response status code should be 404
|
||||
|
||||
|
||||
# An async submit persists a copy of the upload and its results under the server's
|
||||
# file store. Those outlive the request by design, so the only proof they are not a
|
||||
# leak is that cleanup actually removes them. These scenarios exercise that, and
|
||||
# environment.after_all sweeps the rest so the post-run temp-file diff stays honest.
|
||||
|
||||
@positive @cleanup
|
||||
Scenario: Downloading a result does not consume it
|
||||
Given I generate a PDF file as "fileInput"
|
||||
And the pdf contains 2 pages
|
||||
And the request data includes
|
||||
| parameter | value |
|
||||
| angle | 90 |
|
||||
When I send the API request to the endpoint "/api/v1/general/rotate-pdf?async=true"
|
||||
And I store the job id from the response
|
||||
And I wait for the job to complete
|
||||
And I request the job result file list
|
||||
Then the response status code should be 200
|
||||
|
||||
When I download the first job result file
|
||||
Then the response status code should be 200
|
||||
And the response file should have size greater than 100
|
||||
# A download is a read, not a take: the file survives for a retry or a second client.
|
||||
And the job result file should still be downloadable
|
||||
|
||||
|
||||
@positive @cleanup
|
||||
Scenario: Cleanup releases a finished job and deletes its stored files
|
||||
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/general/split-pages?async=true"
|
||||
And I store the job id from the response
|
||||
And I wait for the job to complete
|
||||
And I request the job result file list
|
||||
Then the response status code should be 200
|
||||
And the job result file list should contain at least 3 file(s)
|
||||
|
||||
When I trigger the async job cleanup
|
||||
Then the response status code should be 200
|
||||
# 3 split results plus the persisted copy of the upload.
|
||||
And the cleanup should report at least 1 job(s) removed
|
||||
And the cleanup should report at least 4 file(s) deleted
|
||||
And the job should no longer exist
|
||||
And the job result file should no longer be downloadable
|
||||
|
||||
|
||||
@positive @cleanup
|
||||
Scenario: A second cleanup finds nothing left behind
|
||||
Given I generate a PDF file as "fileInput"
|
||||
And the pdf contains 2 pages
|
||||
And the request data includes
|
||||
| parameter | value |
|
||||
| angle | 180 |
|
||||
When I send the API request to the endpoint "/api/v1/general/rotate-pdf?async=true"
|
||||
And I store the job id from the response
|
||||
And I wait for the job to complete
|
||||
And I trigger the async job cleanup
|
||||
Then the response status code should be 200
|
||||
And the cleanup should report at least 1 job(s) removed
|
||||
|
||||
When I trigger the async job cleanup
|
||||
Then the response status code should be 200
|
||||
And the cleanup should report nothing left to remove
|
||||
@@ -7,6 +7,7 @@ Feature: Attachments API Validation
|
||||
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"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -30,6 +31,7 @@ Feature: Attachments API Validation
|
||||
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"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -49,6 +51,7 @@ Feature: Attachments API Validation
|
||||
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"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response status code should be 200
|
||||
And the response file should have size greater than 0
|
||||
|
||||
@@ -62,6 +65,7 @@ Feature: Attachments API Validation
|
||||
| attachmentName | original.txt |
|
||||
| newName | renamed.txt |
|
||||
When I send the API request to the endpoint "/api/v1/misc/rename-attachment"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -76,6 +80,7 @@ Feature: Attachments API Validation
|
||||
| parameter | value |
|
||||
| attachmentName | to_delete.txt |
|
||||
When I send the API request to the endpoint "/api/v1/misc/delete-attachment"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
|
||||
@@ -7,6 +7,7 @@ Feature: Bookmarks and Chapter Splitting API Validation
|
||||
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"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -31,6 +32,7 @@ Feature: Bookmarks and Chapter Splitting API Validation
|
||||
| includeMetadata | false |
|
||||
| allowDuplicates | false |
|
||||
When I send the API request to the endpoint "/api/v1/general/split-pdf-by-chapters"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response status code should be 200
|
||||
And the response file should have size greater than 0
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
@nightly @convert
|
||||
Feature: Heavy conversion endpoints
|
||||
|
||||
# Too slow for every PR: these shell out to LibreOffice, Calibre or Ghostscript.
|
||||
# behave.ini excludes @nightly; the nightly job opts back in with --tags=@nightly.
|
||||
|
||||
@pdf-to-xlsx @positive
|
||||
Scenario: Convert a PDF containing tables to XLSX
|
||||
Given I use an example file at "exampleFiles/tables.pdf" as parameter "fileInput"
|
||||
When I send the API request to the endpoint "/api/v1/convert/pdf/xlsx"
|
||||
And this operation is run 3 times in parallel
|
||||
Then the response status code should be 200
|
||||
And the response file should have size greater than 1000
|
||||
And the response file should have extension ".xlsx"
|
||||
|
||||
|
||||
@pdf-to-xlsx @positive
|
||||
Scenario: A PDF with no tables converts without producing a spreadsheet
|
||||
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/convert/pdf/xlsx"
|
||||
Then the response status code should be 204
|
||||
|
||||
|
||||
@text-editor @positive
|
||||
Scenario: text-editor metadata describes the document for the editor
|
||||
Given I use an example file at "exampleFiles/tables.pdf" as parameter "fileInput"
|
||||
When I send the API request to the endpoint "/api/v1/convert/pdf/text-editor/metadata"
|
||||
And this operation is run 3 times in parallel
|
||||
Then the response status code should be 200
|
||||
And the response content type should be "application/json"
|
||||
And the response JSON field "fonts" should not be empty
|
||||
|
||||
|
||||
@vector @negative
|
||||
Scenario: vector conversion rejects an input format Ghostscript cannot read
|
||||
Given I generate an SVG file as "fileInput"
|
||||
When I send the API request to the endpoint "/api/v1/convert/vector/pdf"
|
||||
Then the response status code should be 400
|
||||
And the response JSON error should contain "Unsupported"
|
||||
|
||||
|
||||
@ebook @positive
|
||||
Scenario: An EPUB produced by Stirling converts back to PDF
|
||||
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 |
|
||||
When I send the API request to the endpoint "/api/v1/convert/pdf/epub"
|
||||
And this operation is run 3 times in parallel
|
||||
Then the response status code should be 200
|
||||
And the response file should have size greater than 200
|
||||
@@ -1,8 +1,13 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "steps"))
|
||||
import job_support # noqa: E402
|
||||
import parallel_support # noqa: E402
|
||||
|
||||
_BASE_URL = "http://localhost:8080"
|
||||
_CONTAINER_NAME = os.environ.get("TEST_CONTAINER_NAME", "")
|
||||
_REPORT_DIR = os.environ.get("TEST_REPORT_DIR", "")
|
||||
@@ -142,8 +147,17 @@ def before_all(context):
|
||||
|
||||
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)
|
||||
|
||||
# Concurrency is opted into by a step in the feature, never by configuration.
|
||||
context.parallel_repeat = 1
|
||||
context.parallel_decoy = False
|
||||
context.parallel_validated = False
|
||||
context.parallel_ran_at = 0
|
||||
context.parallel_request = None
|
||||
context.parallel_get = None
|
||||
|
||||
# Skip scenarios that require JWT Bearer auth when it is not functional.
|
||||
if _JWT_DEPENDENT_TAGS & scenario_tags and not context.jwt_available:
|
||||
scenario.skip(
|
||||
"JWT Bearer authentication not available in this environment (V2 disabled). "
|
||||
@@ -217,3 +231,48 @@ def after_scenario(context, scenario):
|
||||
context.jwt_token = None
|
||||
context.original_jwt_token = None
|
||||
context._status_ok = False
|
||||
context.parallel_request = None
|
||||
context.parallel_get = None
|
||||
|
||||
|
||||
def _cleanup_async_job_files():
|
||||
"""Release every async job result the run left on the server.
|
||||
|
||||
An async submit persists a copy of the upload plus its results, and both are held
|
||||
for the job retention window (30 minutes by default) - far longer than a test run.
|
||||
The regression check that diffs the container filesystem before and after this suite
|
||||
would otherwise flag them as leaked temp files. Sweeping them here keeps that check
|
||||
strict: anything it still reports afterwards is a genuine leak.
|
||||
"""
|
||||
try:
|
||||
response = job_support.trigger_cleanup()
|
||||
except Exception as exc:
|
||||
print(f"\n[CLEANUP] Async job cleanup request failed: {exc}")
|
||||
return
|
||||
if response.status_code == 404:
|
||||
print(
|
||||
"\n[CLEANUP] Async job cleanup endpoint not available on this build; "
|
||||
"async job files will age out on their own."
|
||||
)
|
||||
return
|
||||
if response.status_code != 200:
|
||||
print(
|
||||
f"\n[CLEANUP] Async job cleanup returned {response.status_code}: "
|
||||
f"{response.text[:200]}"
|
||||
)
|
||||
return
|
||||
try:
|
||||
summary = response.json()
|
||||
except ValueError:
|
||||
print("\n[CLEANUP] Async job cleanup returned a non-JSON body")
|
||||
return
|
||||
print(
|
||||
f"\n[CLEANUP] Released {summary.get('jobsRemoved', '?')} async job(s) and "
|
||||
f"{summary.get('filesDeleted', '?')} stored file(s); "
|
||||
f"{summary.get('jobsRetained', '?')} retained."
|
||||
)
|
||||
|
||||
|
||||
def after_all(context):
|
||||
_cleanup_async_job_files()
|
||||
parallel_support.print_summary()
|
||||
|
||||
@@ -10,6 +10,7 @@ Feature: API Validation
|
||||
| parameter | value |
|
||||
| password | password123 |
|
||||
When I send the API request to the endpoint "/api/v1/security/remove-password"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response content type should be "application/pdf"
|
||||
And the response file should have size greater than 0
|
||||
And the response PDF is not passworded
|
||||
@@ -31,6 +32,7 @@ Feature: API Validation
|
||||
Scenario: Get info
|
||||
Given I generate a PDF file as "fileInput"
|
||||
When I send the API request to the endpoint "/api/v1/security/get-info-on-pdf"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response content type should be "application/json"
|
||||
And the response file should have size greater than 100
|
||||
And the response status code should be 200
|
||||
@@ -43,6 +45,7 @@ Feature: API Validation
|
||||
| parameter | value |
|
||||
| password | password123 |
|
||||
When I send the API request to the endpoint "/api/v1/security/add-password"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response content type should be "application/pdf"
|
||||
And the response file should have size greater than 100
|
||||
And the response PDF is passworded
|
||||
@@ -81,6 +84,7 @@ Feature: API Validation
|
||||
| alphabet | roman |
|
||||
| customColor | #d3d3d3 |
|
||||
When I send the API request to the endpoint "/api/v1/security/add-watermark"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response content type should be "application/pdf"
|
||||
And the response file should have size greater than 100
|
||||
And the response status code should be 200
|
||||
@@ -94,6 +98,7 @@ Feature: API Validation
|
||||
| threshold | 90 |
|
||||
| whitePercent | 99.9 |
|
||||
When I send the API request to the endpoint "/api/v1/misc/remove-blanks"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response content type should be "application/octet-stream"
|
||||
And the response file should have extension ".zip"
|
||||
And the response ZIP should contain 1 files
|
||||
@@ -106,6 +111,7 @@ Feature: API Validation
|
||||
| parameter | value |
|
||||
| flattenOnlyForms | false |
|
||||
When I send the API request to the endpoint "/api/v1/misc/flatten"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response content type should be "application/pdf"
|
||||
And the response file should have size greater than 0
|
||||
And the response status code should be 200
|
||||
@@ -121,6 +127,7 @@ Feature: API Validation
|
||||
| keywords | sample, test |
|
||||
| producer | Test Producer |
|
||||
When I send the API request to the endpoint "/api/v1/misc/update-metadata"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response content type should be "application/pdf"
|
||||
And the response file should have size greater than 0
|
||||
And the response PDF metadata should include "Author" as "John Doe"
|
||||
|
||||
@@ -174,6 +174,7 @@ Feature: API Validation
|
||||
| dpi | 300 |
|
||||
| imageFormat | <format> |
|
||||
When I send the API request to the endpoint "/api/v1/convert/pdf/img"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response status code should be 200
|
||||
And the response file should have size greater than 100
|
||||
And the response file should have extension ".zip"
|
||||
@@ -231,6 +232,7 @@ Feature: API Validation
|
||||
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/markdown"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response status code should be 200
|
||||
And the response file should have size greater than 100
|
||||
And the response file should have extension ".md"
|
||||
@@ -244,6 +246,7 @@ Feature: API Validation
|
||||
| outputFormat | csv |
|
||||
| pageNumbers | all |
|
||||
When I send the API request to the endpoint "/api/v1/convert/pdf/csv"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response status code should be 200
|
||||
And the response file should have size greater than 200
|
||||
And the response file should have extension ".zip"
|
||||
|
||||
@@ -18,6 +18,7 @@ Feature: Filter API Endpoints
|
||||
| pageCount | <pageCount> |
|
||||
| comparator | <comparator> |
|
||||
When I send the API request to the endpoint "/api/v1/filter/filter-page-count"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -59,6 +60,7 @@ Feature: Filter API Endpoints
|
||||
| fileSize | <fileSize> |
|
||||
| comparator | <comparator> |
|
||||
When I send the API request to the endpoint "/api/v1/filter/filter-file-size"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -93,6 +95,7 @@ Feature: Filter API Endpoints
|
||||
| rotation | 0 |
|
||||
| comparator | Equal |
|
||||
When I send the API request to the endpoint "/api/v1/filter/filter-page-rotation"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -170,6 +173,7 @@ Feature: Filter API Endpoints
|
||||
| standardPageSize | LETTER |
|
||||
| comparator | Equal |
|
||||
When I send the API request to the endpoint "/api/v1/filter/filter-page-size"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -242,6 +246,7 @@ Feature: Filter API Endpoints
|
||||
| text | FINDME |
|
||||
| pageNumbers | all |
|
||||
When I send the API request to the endpoint "/api/v1/filter/filter-contains-text"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -281,6 +286,7 @@ Feature: Filter API Endpoints
|
||||
| parameter | value |
|
||||
| pageNumbers | all |
|
||||
When I send the API request to the endpoint "/api/v1/filter/filter-contains-image"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
|
||||
@@ -8,6 +8,7 @@ Feature: Advanced Forms API Validation (JSON data parts)
|
||||
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"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
|
||||
@@ -6,6 +6,7 @@ Feature: Forms API Validation
|
||||
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"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -24,6 +25,7 @@ Feature: Forms API Validation
|
||||
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"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -67,6 +69,7 @@ Feature: Forms API Validation
|
||||
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"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response status code should be 400
|
||||
|
||||
@modify-fields @negative
|
||||
@@ -81,6 +84,7 @@ Feature: Forms API Validation
|
||||
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"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response status code should be 400
|
||||
|
||||
@delete-fields @negative
|
||||
@@ -89,3 +93,34 @@ Feature: Forms API Validation
|
||||
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
|
||||
|
||||
|
||||
@extract-csv @positive
|
||||
Scenario: extract-csv returns CSV for a form PDF
|
||||
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/extract-csv"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response status code should be 200
|
||||
And the response content type should be "text/csv"
|
||||
|
||||
|
||||
@extract-xlsx @positive
|
||||
Scenario: extract-xlsx returns a spreadsheet for a form PDF
|
||||
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/extract-xlsx"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response status code should be 200
|
||||
And the response file should have size greater than 200
|
||||
|
||||
|
||||
@extract-csv @negative
|
||||
Scenario: extract-csv rejects a upload sent under the wrong part name
|
||||
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/form/extract-csv"
|
||||
Then the response status code should be 400
|
||||
And the response JSON error should contain "file"
|
||||
|
||||
@@ -12,6 +12,7 @@ Feature: API Validation
|
||||
| verticalDivisions | <verticalDivisions> |
|
||||
| merge | true |
|
||||
When I send the API request to the endpoint "/api/v1/general/split-pdf-by-sections"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response content type should be "application/pdf"
|
||||
And the response file should have size greater than 200
|
||||
And the response status code should be 200
|
||||
@@ -34,6 +35,7 @@ Feature: API Validation
|
||||
| fileInput | fileInput |
|
||||
| pageNumbers | <pageNumbers> |
|
||||
When I send the API request to the endpoint "/api/v1/general/split-pages"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response content type should be "application/octet-stream"
|
||||
And the response status code should be 200
|
||||
And the response file should have size greater than 200
|
||||
@@ -57,6 +59,7 @@ Feature: API Validation
|
||||
| splitType | <splitType> |
|
||||
| splitValue | <splitValue> |
|
||||
When I send the API request to the endpoint "/api/v1/general/split-by-size-or-count"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response content type should be "application/octet-stream"
|
||||
And the response status code should be 200
|
||||
And the response file should have size greater than 200
|
||||
|
||||
@@ -10,6 +10,7 @@ Feature: General PDF Operations API Validation
|
||||
| parameter | value |
|
||||
| angle | <angle> |
|
||||
When I send the API request to the endpoint "/api/v1/general/rotate-pdf"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -41,6 +42,7 @@ Feature: General PDF Operations API Validation
|
||||
| parameter | value |
|
||||
| pageNumbers | 3 |
|
||||
When I send the API request to the endpoint "/api/v1/general/remove-pages"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -76,6 +78,7 @@ Feature: General PDF Operations API Validation
|
||||
| parameter | value |
|
||||
| customMode | <customMode> |
|
||||
When I send the API request to the endpoint "/api/v1/general/rearrange-pages"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -103,6 +106,7 @@ Feature: General PDF Operations API Validation
|
||||
| parameter | value |
|
||||
| pageSize | <pageSize> |
|
||||
When I send the API request to the endpoint "/api/v1/general/scale-pages"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -127,6 +131,7 @@ Feature: General PDF Operations API Validation
|
||||
| width | 50 |
|
||||
| height | 50 |
|
||||
When I send the API request to the endpoint "/api/v1/general/crop"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -155,6 +160,7 @@ Feature: General PDF Operations API Validation
|
||||
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"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -199,6 +205,7 @@ Feature: General PDF Operations API Validation
|
||||
| parameter | value |
|
||||
| pagesPerSheet | 9 |
|
||||
When I send the API request to the endpoint "/api/v1/general/multi-page-layout"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -213,6 +220,7 @@ Feature: General PDF Operations API Validation
|
||||
| parameter | value |
|
||||
| pagesPerSheet | 2 |
|
||||
When I send the API request to the endpoint "/api/v1/general/booklet-imposition"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -249,3 +257,55 @@ Feature: General PDF Operations API Validation
|
||||
# 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
|
||||
|
||||
|
||||
@edit-table-of-contents @positive
|
||||
Scenario: edit-table-of-contents rewrites the outline
|
||||
Given I generate a PDF file as "fileInput"
|
||||
And the pdf contains 3 pages
|
||||
And the pdf has bookmarks
|
||||
And the request data includes
|
||||
| parameter | value |
|
||||
| bookmarkData | [{"title":"Intro","pageNumber":1,"children":[]}] |
|
||||
When I send the API request to the endpoint "/api/v1/general/edit-table-of-contents"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response status code should be 200
|
||||
And the response content type should be "application/pdf"
|
||||
And the response PDF should contain 3 pages
|
||||
|
||||
|
||||
@edit-text @positive
|
||||
Scenario: edit-text applies a find and replace across the document
|
||||
Given I generate a PDF file as "fileInput"
|
||||
And the pdf contains 3 pages
|
||||
And the pdf pages all contain the text "Hello world"
|
||||
And the request data includes
|
||||
| parameter | value |
|
||||
| edits | [{"find":"Hello","replace":"Goodbye"}] |
|
||||
When I send the API request to the endpoint "/api/v1/general/edit-text"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response status code should be 200
|
||||
And the response content type should be "application/pdf"
|
||||
|
||||
|
||||
@edit-text @negative
|
||||
Scenario: edit-text without any operations returns 400
|
||||
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/general/edit-text"
|
||||
Then the response status code should be 400
|
||||
And the response JSON error should contain "find/replace"
|
||||
|
||||
|
||||
@split-for-poster-print @positive
|
||||
Scenario: split-for-poster-print tiles each page into an archive
|
||||
Given I generate a PDF file as "fileInput"
|
||||
And the pdf contains 2 pages
|
||||
And the request data includes
|
||||
| parameter | value |
|
||||
| horizontalDivisions | 1 |
|
||||
| verticalDivisions | 1 |
|
||||
When I send the API request to the endpoint "/api/v1/general/split-for-poster-print"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response status code should be 200
|
||||
And the response file should have size greater than 200
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
@info @config
|
||||
Feature: Config, info and UI-data read APIs
|
||||
|
||||
# Cheap read-only JSON endpoints behind the frontend and admin surfaces.
|
||||
|
||||
@app-config @positive
|
||||
Scenario: app-config returns the frontend configuration
|
||||
When I send a GET request to "/api/v1/config/app-config"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response status code should be 200
|
||||
And the response content type should be "application/json"
|
||||
|
||||
|
||||
@endpoints-availability @positive
|
||||
Scenario: endpoints-availability reports per-endpoint availability
|
||||
When I send a GET request to "/api/v1/config/endpoints-availability"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response status code should be 200
|
||||
And the response content type should be "application/json"
|
||||
|
||||
|
||||
@endpoints-enabled @positive
|
||||
Scenario: endpoints-enabled reports the status of a named endpoint
|
||||
When I send a GET request to "/api/v1/config/endpoints-enabled" with parameters
|
||||
| parameter | value |
|
||||
| endpoints | merge-pdfs |
|
||||
Then the response status code should be 200
|
||||
And the response content type should be "application/json"
|
||||
|
||||
|
||||
@endpoints-enabled @negative
|
||||
Scenario: endpoints-enabled without the endpoints parameter returns 400
|
||||
When I send a GET request to "/api/v1/config/endpoints-enabled"
|
||||
Then the response status code should be 400
|
||||
|
||||
|
||||
@login-disclaimer @positive
|
||||
Scenario: login-disclaimer returns its configuration
|
||||
When I send a GET request to "/api/v1/config/login-disclaimer"
|
||||
Then the response status code should be 200
|
||||
And the response JSON field "enabled" should be false
|
||||
|
||||
|
||||
@health @positive
|
||||
Scenario: info health reports the running version
|
||||
When I send a GET request to "/api/v1/info/health"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response status code should be 200
|
||||
And the response JSON field "status" should equal "UP"
|
||||
And the response JSON field "version" should not be empty
|
||||
|
||||
|
||||
@metrics @positive
|
||||
Scenario Outline: metrics endpoints return a numeric aggregate
|
||||
When I send a GET request to "<endpoint>"
|
||||
Then the response status code should be 200
|
||||
And the response content type should be "application/json"
|
||||
And the response should match the regex "^[0-9.]+$"
|
||||
|
||||
Examples:
|
||||
| endpoint |
|
||||
| /api/v1/info/load/unique |
|
||||
| /api/v1/info/requests/unique |
|
||||
|
||||
|
||||
@metrics @positive
|
||||
Scenario Outline: metrics list endpoints return a JSON list
|
||||
When I send a GET request to "<endpoint>"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response status code should be 200
|
||||
And the response JSON should be a list
|
||||
|
||||
Examples:
|
||||
| endpoint |
|
||||
| /api/v1/info/load/all/unique |
|
||||
| /api/v1/info/requests/all/unique |
|
||||
|
||||
|
||||
@metrics @positive
|
||||
Scenario: weekly active users reports tracking metadata
|
||||
When I send a GET request to "/api/v1/info/wau"
|
||||
Then the response status code should be 200
|
||||
And the response JSON field "trackingSince" should not be empty
|
||||
|
||||
|
||||
@settings @positive
|
||||
Scenario: get-endpoints-status returns the endpoint toggle map
|
||||
When I send a GET request to "/api/v1/settings/get-endpoints-status"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response status code should be 200
|
||||
And the response content type should be "application/json"
|
||||
|
||||
|
||||
@ui-data @positive
|
||||
Scenario Outline: UI data endpoints return JSON for the frontend
|
||||
When I send a GET request to "<endpoint>"
|
||||
Then the response status code should be 200
|
||||
And the response content type should be "application/json"
|
||||
|
||||
Examples:
|
||||
| endpoint |
|
||||
| /api/v1/ui-data/footer-info |
|
||||
| /api/v1/ui-data/home |
|
||||
| /api/v1/ui-data/licenses |
|
||||
| /api/v1/ui-data/pipeline |
|
||||
|
||||
|
||||
@ui-data @positive
|
||||
Scenario: OCR UI data lists the installed tesseract languages
|
||||
When I send a GET request to "/api/v1/ui-data/ocr-pdf"
|
||||
Then the response status code should be 200
|
||||
And the response JSON field "languages" should be a list
|
||||
|
||||
|
||||
@ui-data @positive
|
||||
Scenario: Sign UI data lists the available signature fonts
|
||||
When I send a GET request to "/api/v1/ui-data/sign"
|
||||
Then the response status code should be 200
|
||||
And the response JSON field "fonts" should be a list
|
||||
|
||||
|
||||
@hardware-signing @positive
|
||||
Scenario: Hardware signing capabilities are reported for the server build
|
||||
When I send a GET request to "/api/v1/security/cert-sign/hardware/capabilities"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response status code should be 200
|
||||
And the response JSON field "desktop" should be false
|
||||
|
||||
|
||||
@hardware-signing @negative
|
||||
Scenario: Windows certificate store is rejected outside the desktop app
|
||||
When I send a GET request to "/api/v1/security/cert-sign/hardware/windows-certificates"
|
||||
Then the response status code should be 400
|
||||
And the response JSON error should contain "desktop"
|
||||
@@ -7,6 +7,7 @@ Feature: Merge and Overlay PDF API Validation
|
||||
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"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -51,6 +52,7 @@ Feature: Merge and Overlay PDF API Validation
|
||||
| overlayMode | SequentialOverlay |
|
||||
| overlayPosition | 0 |
|
||||
When I send the API request to the endpoint "/api/v1/general/overlay-pdfs"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
|
||||
@@ -48,6 +48,7 @@ Feature: Miscellaneous PDF Operations API Validation
|
||||
| overrideX | -1 |
|
||||
| overrideY | -1 |
|
||||
When I send the API request to the endpoint "/api/v1/misc/add-stamp"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -92,6 +93,7 @@ Feature: Miscellaneous PDF Operations API Validation
|
||||
| position | 2 |
|
||||
| fontSize | 14 |
|
||||
When I send the API request to the endpoint "/api/v1/misc/add-page-numbers"
|
||||
And this operation is run 5 times in parallel against decoy traffic
|
||||
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
|
||||
@@ -103,6 +105,7 @@ Feature: Miscellaneous PDF Operations API Validation
|
||||
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"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -138,6 +141,7 @@ Feature: Miscellaneous PDF Operations API Validation
|
||||
| replaceAndInvertOption | HIGH_CONTRAST_COLOR |
|
||||
| highContrastColorCombination | WHITE_TEXT_ON_BLACK |
|
||||
When I send the API request to the endpoint "/api/v1/misc/replace-invert-pdf"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -163,6 +167,7 @@ Feature: Miscellaneous PDF Operations API Validation
|
||||
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"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -188,6 +193,7 @@ Feature: Miscellaneous PDF Operations API Validation
|
||||
| parameter | value |
|
||||
| useFirstTextAsFallback | true |
|
||||
When I send the API request to the endpoint "/api/v1/misc/auto-rename"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -212,6 +218,7 @@ Feature: Miscellaneous PDF Operations API Validation
|
||||
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"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response status code should be 200
|
||||
And the response file should have size greater than 0
|
||||
|
||||
@@ -222,3 +229,28 @@ Feature: Miscellaneous PDF Operations API Validation
|
||||
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
|
||||
|
||||
|
||||
@auto-rotate-pdf @positive
|
||||
Scenario: auto-rotate-pdf returns a PDF with the page count preserved
|
||||
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/misc/auto-rotate-pdf"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response status code should be 200
|
||||
And the response content type should be "application/pdf"
|
||||
And the response PDF should contain 3 pages
|
||||
|
||||
|
||||
@add-comments @positive
|
||||
Scenario: add-comments annotates a page without changing the page count
|
||||
Given I generate a PDF file as "fileInput"
|
||||
And the pdf contains 3 pages
|
||||
And the request data includes
|
||||
| parameter | value |
|
||||
| comments | [{"pageNumber":1,"x":100,"y":100,"text":"review me","author":"qa"}] |
|
||||
When I send the API request to the endpoint "/api/v1/misc/add-comments"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response status code should be 200
|
||||
And the response content type should be "application/pdf"
|
||||
And the response PDF should contain 3 pages
|
||||
|
||||
@@ -14,6 +14,7 @@ Feature: Security API Validation
|
||||
| removeLinks | true |
|
||||
| removeFonts | false |
|
||||
When I send the API request to the endpoint "/api/v1/security/sanitize-pdf"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -54,6 +55,7 @@ Feature: Security API Validation
|
||||
| wholeWordSearch| true |
|
||||
| convertPDFToImage | false |
|
||||
When I send the API request to the endpoint "/api/v1/security/auto-redact"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -96,6 +98,7 @@ Feature: Security API Validation
|
||||
| pageNumbers | 2,4 |
|
||||
| pageRedactionColor | #000000 |
|
||||
When I send the API request to the endpoint "/api/v1/security/redact"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -130,6 +133,7 @@ Feature: Security API Validation
|
||||
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"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -148,6 +152,7 @@ Feature: Security API Validation
|
||||
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"
|
||||
And this operation is run 5 times in parallel
|
||||
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
|
||||
@@ -161,3 +166,35 @@ Feature: Security API Validation
|
||||
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
|
||||
|
||||
|
||||
@validate-signature @positive
|
||||
Scenario: validate-signature reports no signatures on an unsigned 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/security/validate-signature"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response status code should be 200
|
||||
And the response JSON should be a list
|
||||
|
||||
|
||||
@redact-execute @positive
|
||||
Scenario: redact-execute removes the targeted text
|
||||
Given I generate a PDF file as "fileInput"
|
||||
And the pdf contains 3 pages
|
||||
And the pdf pages all contain the text "Hello world"
|
||||
And the request data includes
|
||||
| parameter | value |
|
||||
| textValues | Hello |
|
||||
When I send the API request to the endpoint "/api/v1/security/redact-execute"
|
||||
And this operation is run 5 times in parallel
|
||||
Then the response status code should be 200
|
||||
And the response content type should be "application/pdf"
|
||||
|
||||
|
||||
@redact-execute @negative
|
||||
Scenario: redact-execute without any targets returns 400
|
||||
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/redact-execute"
|
||||
Then the response status code should be 400
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Steps for the async job API. DELETE is a cancel, so it 400s once the job finishes."""
|
||||
import time
|
||||
|
||||
import requests
|
||||
from behave import then, when
|
||||
from job_support import API_HEADERS, BASE_URL, trigger_cleanup
|
||||
|
||||
POLL_TIMEOUT_SECONDS = 60
|
||||
|
||||
|
||||
@when("I store the job id from the response")
|
||||
def step_store_job_id(context):
|
||||
payload = context.response.json()
|
||||
context.job_id = payload.get("jobId")
|
||||
assert context.job_id, f"No jobId in async submit response: {payload}"
|
||||
|
||||
|
||||
@when("I wait for the job to complete")
|
||||
def step_wait_for_job(context):
|
||||
deadline = time.time() + POLL_TIMEOUT_SECONDS
|
||||
while time.time() < deadline:
|
||||
response = requests.get(
|
||||
f"{BASE_URL}/api/v1/general/job/{context.job_id}",
|
||||
headers=API_HEADERS, timeout=30,
|
||||
)
|
||||
assert response.status_code == 200, (
|
||||
f"Job status returned {response.status_code}: {response.text}"
|
||||
)
|
||||
context.response = response
|
||||
payload = response.json()
|
||||
if payload.get("complete"):
|
||||
context.job_status = payload
|
||||
return
|
||||
time.sleep(0.2)
|
||||
raise AssertionError(
|
||||
f"Job {context.job_id} did not complete within {POLL_TIMEOUT_SECONDS}s"
|
||||
)
|
||||
|
||||
|
||||
@when("I request the job result")
|
||||
def step_request_job_result(context):
|
||||
context.response = requests.get(
|
||||
f"{BASE_URL}/api/v1/general/job/{context.job_id}/result",
|
||||
headers=API_HEADERS, timeout=60,
|
||||
)
|
||||
|
||||
|
||||
@when("I request the job result file list")
|
||||
def step_request_job_result_files(context):
|
||||
context.response = requests.get(
|
||||
f"{BASE_URL}/api/v1/general/job/{context.job_id}/result/files",
|
||||
headers=API_HEADERS, timeout=60,
|
||||
)
|
||||
files = context.response.json().get("files") or []
|
||||
context.job_files = files
|
||||
if files:
|
||||
context.job_file_id = files[0].get("fileId")
|
||||
|
||||
|
||||
@when("I download the first job result file")
|
||||
def step_download_job_file(context):
|
||||
assert getattr(context, "job_file_id", None), "No fileId captured from the result file list"
|
||||
context.response = requests.get(
|
||||
f"{BASE_URL}/api/v1/general/files/{context.job_file_id}",
|
||||
headers=API_HEADERS, timeout=60,
|
||||
)
|
||||
|
||||
|
||||
@when("I request the first job result file metadata")
|
||||
def step_job_file_metadata(context):
|
||||
assert getattr(context, "job_file_id", None), "No fileId captured from the result file list"
|
||||
context.response = requests.get(
|
||||
f"{BASE_URL}/api/v1/general/files/{context.job_file_id}/metadata",
|
||||
headers=API_HEADERS, timeout=60,
|
||||
)
|
||||
|
||||
|
||||
@when("I cancel the job")
|
||||
def step_cancel_job(context):
|
||||
context.response = requests.delete(
|
||||
f"{BASE_URL}/api/v1/general/job/{context.job_id}",
|
||||
headers=API_HEADERS, timeout=30,
|
||||
)
|
||||
|
||||
|
||||
@then("the job result file list should contain at least {count:d} file(s)")
|
||||
def step_check_job_file_count(context, count):
|
||||
files = context.response.json().get("files") or []
|
||||
assert len(files) >= count, f"Expected at least {count} result file(s), got {len(files)}"
|
||||
|
||||
|
||||
@then("the job should be reported complete")
|
||||
def step_check_job_complete(context):
|
||||
payload = context.response.json()
|
||||
assert payload.get("complete") is True, f"Job not complete: {payload}"
|
||||
assert not payload.get("error"), f"Job reported an error: {payload.get('error')}"
|
||||
|
||||
|
||||
# --- Cleanup: the async job files must actually go away, not just age out ---
|
||||
|
||||
|
||||
@when("I trigger the async job cleanup")
|
||||
def step_trigger_cleanup(context):
|
||||
context.response = trigger_cleanup()
|
||||
try:
|
||||
context.cleanup_summary = context.response.json()
|
||||
except ValueError:
|
||||
context.cleanup_summary = {}
|
||||
|
||||
|
||||
@then("the cleanup should report at least {count:d} job(s) removed")
|
||||
def step_check_cleanup_jobs(context, count):
|
||||
removed = context.cleanup_summary.get("jobsRemoved")
|
||||
assert removed is not None, f"No jobsRemoved in cleanup response: {context.cleanup_summary}"
|
||||
assert removed >= count, f"Expected at least {count} job(s) removed, got {removed}"
|
||||
|
||||
|
||||
@then("the cleanup should report at least {count:d} file(s) deleted")
|
||||
def step_check_cleanup_files(context, count):
|
||||
deleted = context.cleanup_summary.get("filesDeleted")
|
||||
assert deleted is not None, f"No filesDeleted in cleanup response: {context.cleanup_summary}"
|
||||
assert deleted >= count, f"Expected at least {count} file(s) deleted, got {deleted}"
|
||||
|
||||
|
||||
@then("the cleanup should report nothing left to remove")
|
||||
def step_check_cleanup_idempotent(context):
|
||||
removed = context.cleanup_summary.get("jobsRemoved")
|
||||
deleted = context.cleanup_summary.get("filesDeleted")
|
||||
assert removed == 0 and deleted == 0, (
|
||||
"A repeat cleanup still found work to do, so the first pass did not fully clean up: "
|
||||
f"{context.cleanup_summary}"
|
||||
)
|
||||
|
||||
|
||||
@then("the job should no longer exist")
|
||||
def step_check_job_gone(context):
|
||||
response = requests.get(
|
||||
f"{BASE_URL}/api/v1/general/job/{context.job_id}",
|
||||
headers=API_HEADERS, timeout=30,
|
||||
)
|
||||
assert response.status_code == 404, (
|
||||
f"Job {context.job_id} still exists after cleanup: "
|
||||
f"{response.status_code} {response.text[:200]}"
|
||||
)
|
||||
|
||||
|
||||
@then("the job result file should no longer be downloadable")
|
||||
def step_check_job_file_gone(context):
|
||||
assert getattr(context, "job_file_id", None), "No fileId captured from the result file list"
|
||||
response = requests.get(
|
||||
f"{BASE_URL}/api/v1/general/files/{context.job_file_id}",
|
||||
headers=API_HEADERS, timeout=30,
|
||||
)
|
||||
assert response.status_code == 404, (
|
||||
f"File {context.job_file_id} still downloadable after cleanup: "
|
||||
f"{response.status_code} {response.text[:200]}"
|
||||
)
|
||||
|
||||
|
||||
@then("the job result file should still be downloadable")
|
||||
def step_check_job_file_still_there(context):
|
||||
assert getattr(context, "job_file_id", None), "No fileId captured from the result file list"
|
||||
response = requests.get(
|
||||
f"{BASE_URL}/api/v1/general/files/{context.job_file_id}",
|
||||
headers=API_HEADERS, timeout=60,
|
||||
)
|
||||
assert response.status_code == 200, (
|
||||
f"File {context.job_file_id} was not retrievable a second time: "
|
||||
f"{response.status_code} {response.text[:200]}"
|
||||
)
|
||||
assert len(response.content) > 0, "Second download returned an empty body"
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Shared helpers for the async job API.
|
||||
|
||||
Support module, not a step module: behave execs everything under features/steps as
|
||||
step definitions, so anything environment.py needs to import has to live apart from
|
||||
the @when/@then decorators or they would register twice.
|
||||
"""
|
||||
import requests
|
||||
|
||||
BASE_URL = "http://localhost:8080"
|
||||
API_HEADERS = {"X-API-KEY": "123456789"}
|
||||
CLEANUP_URL = f"{BASE_URL}/api/v1/general/jobs/cleanup"
|
||||
|
||||
|
||||
def trigger_cleanup():
|
||||
"""Force-expire finished jobs so their stored files are released now.
|
||||
|
||||
An async submit persists a copy of the upload plus its results and holds them for
|
||||
the job retention window (30 minutes by default), which far outlasts a test run.
|
||||
Calling this keeps the post-run temp-file check strict: anything it still reports
|
||||
afterwards is a genuine leak rather than a file that simply had not aged out.
|
||||
"""
|
||||
return requests.post(CLEANUP_URL, headers=API_HEADERS, timeout=60)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Concurrency steps, usable either before the request or after it."""
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
import parallel_support
|
||||
|
||||
|
||||
def _set_repeat(context, count, decoy=False):
|
||||
context.parallel_decoy = decoy
|
||||
if count < 2:
|
||||
context.parallel_repeat = 1
|
||||
return
|
||||
context.parallel_repeat = count
|
||||
|
||||
# An asked-for level higher than one already run wins.
|
||||
if count > getattr(context, "parallel_ran_at", 0):
|
||||
context.parallel_validated = False
|
||||
if getattr(context, "parallel_validated", False):
|
||||
return
|
||||
|
||||
# Placed after the request: replay whichever request was just sent.
|
||||
pending = getattr(context, "parallel_request", None)
|
||||
if pending is not None:
|
||||
url, spec, headers, label = pending
|
||||
parallel_support.validate(context, url, spec, headers, context.response, label)
|
||||
return
|
||||
|
||||
pending_get = getattr(context, "parallel_get", None)
|
||||
if pending_get is not None:
|
||||
url, params, headers, label = pending_get
|
||||
parallel_support.validate_get(context, url, params, headers, context.response, label)
|
||||
|
||||
|
||||
@given("this operation is run {count:d} times in parallel")
|
||||
@when("this operation is run {count:d} times in parallel")
|
||||
@then("this operation is run {count:d} times in parallel")
|
||||
def step_operation_run_in_parallel(context, count):
|
||||
_set_repeat(context, count)
|
||||
|
||||
|
||||
# Decoy traffic is an equal number of concurrent requests carrying different page
|
||||
# text, so a response that came from the wrong request becomes visible.
|
||||
@given("this operation is run {count:d} times in parallel against decoy traffic")
|
||||
@when("this operation is run {count:d} times in parallel against decoy traffic")
|
||||
@then("this operation is run {count:d} times in parallel against decoy traffic")
|
||||
def step_operation_run_in_parallel_with_decoy(context, count):
|
||||
_set_repeat(context, count, decoy=True)
|
||||
@@ -0,0 +1,478 @@
|
||||
"""Re-issues a request concurrently and asserts every response matches the baseline."""
|
||||
|
||||
import io
|
||||
import json as json_module
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from hashlib import sha256
|
||||
|
||||
import requests
|
||||
from pypdf import PdfReader
|
||||
|
||||
_UUID_RE = re.compile(r"[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}")
|
||||
_LONG_NUM_RE = re.compile(r"\d{10,}")
|
||||
_DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?")
|
||||
_VOLATILE_KEY_RE = re.compile(
|
||||
r"^(id|uuid|.*Id|.*_id|.*[Tt]ime|.*[Dd]ate|timestamp|created.*|modified.*|"
|
||||
r"updated.*|expires.*|token|traceId|requestId|duration.*|elapsed.*)$"
|
||||
)
|
||||
|
||||
# Only hash text for reasonably sized documents so the check stays cheap.
|
||||
_MAX_TEXT_PAGES = 25
|
||||
|
||||
# Sequential samples taken to separate inherent nondeterminism from a concurrency bug.
|
||||
NOISE_PROBE_SAMPLES = 3
|
||||
|
||||
# Response size is allowed to drift this far before it counts as a difference.
|
||||
SIZE_TOLERANCE = 0.05
|
||||
|
||||
# Collected per-scenario results, printed as a summary in after_all.
|
||||
VALIDATIONS = []
|
||||
|
||||
|
||||
# A spec replaces open file handles with bytes so it can be replayed from many threads.
|
||||
|
||||
|
||||
def materialize(spec):
|
||||
"""Turn a captured spec into a fresh `files=` list for one request."""
|
||||
parts = []
|
||||
for key, filename, payload, mime in spec:
|
||||
if filename is None:
|
||||
parts.append((key, (None, payload) if mime is None else (None, payload, mime)))
|
||||
else:
|
||||
parts.append((key, (filename, io.BytesIO(payload), mime)))
|
||||
return parts
|
||||
|
||||
|
||||
def send(url, spec, headers, timeout=300):
|
||||
return requests.post(url, files=materialize(spec), headers=headers, timeout=timeout)
|
||||
|
||||
|
||||
def _normalize_name(name):
|
||||
name = _UUID_RE.sub("<uuid>", name)
|
||||
return _LONG_NUM_RE.sub("<num>", name)
|
||||
|
||||
|
||||
def _strip_volatile(value):
|
||||
"""Drop keys whose values legitimately differ between two identical requests."""
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
k: _strip_volatile(v)
|
||||
for k, v in sorted(value.items())
|
||||
if not _VOLATILE_KEY_RE.match(k)
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [_strip_volatile(v) for v in value]
|
||||
if isinstance(value, str):
|
||||
return _normalize_name(value)
|
||||
return value
|
||||
|
||||
|
||||
def _pdf_fingerprint(body, prefix=""):
|
||||
parts = {}
|
||||
try:
|
||||
reader = PdfReader(io.BytesIO(body))
|
||||
except Exception as exc:
|
||||
parts[prefix + "pdf"] = f"unreadable: {type(exc).__name__}"
|
||||
return parts
|
||||
parts[prefix + "encrypted"] = reader.is_encrypted
|
||||
if reader.is_encrypted:
|
||||
return parts
|
||||
try:
|
||||
pages = reader.pages
|
||||
parts[prefix + "pages"] = len(pages)
|
||||
except Exception as exc:
|
||||
parts[prefix + "pdf"] = f"unreadable pages: {type(exc).__name__}"
|
||||
return parts
|
||||
if len(pages) <= _MAX_TEXT_PAGES:
|
||||
try:
|
||||
text = "\n".join((page.extract_text() or "") for page in pages)
|
||||
parts[prefix + "text_sha"] = sha256(text.encode("utf-8")).hexdigest()[:16]
|
||||
except Exception:
|
||||
pass
|
||||
return parts
|
||||
|
||||
|
||||
def _entry_sha(entry):
|
||||
"""Hash an archive entry, normalizing the ids and timestamps EPUB/ODF restamp each run."""
|
||||
try:
|
||||
text = entry.decode("utf-8")
|
||||
if "\x00" not in text:
|
||||
entry = _DATE_RE.sub("<date>", _normalize_name(text)).encode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
return sha256(entry).hexdigest()[:16]
|
||||
|
||||
|
||||
def _zip_fingerprint(body):
|
||||
parts = {}
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(body)) as archive:
|
||||
names = sorted(_normalize_name(n) for n in archive.namelist())
|
||||
parts["zip_entries"] = len(names)
|
||||
parts["zip_names"] = names
|
||||
for index, name in enumerate(sorted(archive.namelist())):
|
||||
entry = archive.read(name)
|
||||
if entry[:5] == b"%PDF-":
|
||||
parts.update(_pdf_fingerprint(entry, prefix=f"zip[{index}]."))
|
||||
else:
|
||||
parts[f"zip[{index}].sha"] = _entry_sha(entry)
|
||||
except Exception as exc:
|
||||
parts["zip"] = f"unreadable: {type(exc).__name__}"
|
||||
return parts
|
||||
|
||||
|
||||
def fingerprint(response):
|
||||
"""Structural signature of a response, ignoring benign per-request variance."""
|
||||
body = response.content
|
||||
content_type = (response.headers.get("Content-Type") or "").split(";")[0].strip()
|
||||
parts = {"status": response.status_code, "content_type": content_type, "size": len(body)}
|
||||
|
||||
|
||||
if "json" in content_type:
|
||||
try:
|
||||
parts["json"] = _strip_volatile(json_module.loads(body.decode("utf-8")))
|
||||
except Exception:
|
||||
parts["body_sha"] = sha256(body).hexdigest()[:16]
|
||||
elif body[:5] == b"%PDF-":
|
||||
parts.update(_pdf_fingerprint(body))
|
||||
elif body[:2] == b"PK":
|
||||
parts.update(_zip_fingerprint(body))
|
||||
elif content_type.startswith("text/") or response.status_code >= 400:
|
||||
try:
|
||||
parts["text"] = _normalize_name(body.decode("utf-8", "replace"))[:2000]
|
||||
except Exception:
|
||||
parts["body_sha"] = sha256(body).hexdigest()[:16]
|
||||
else:
|
||||
parts["body_sha"] = sha256(body).hexdigest()[:16]
|
||||
return parts
|
||||
|
||||
|
||||
def differing_keys(baseline, other):
|
||||
return {
|
||||
key
|
||||
for key in set(baseline) | set(other)
|
||||
if key != "size" and baseline.get(key) != other.get(key)
|
||||
}
|
||||
|
||||
|
||||
def size_differs(baseline, other):
|
||||
base_size, other_size = baseline.get("size", 0), other.get("size", 0)
|
||||
return abs(other_size - base_size) > max(64, base_size * SIZE_TOLERANCE)
|
||||
|
||||
|
||||
def compare(baseline, other, ignore=frozenset(), ignore_size=False):
|
||||
"""Return a list of human-readable differences between two fingerprints."""
|
||||
diffs = []
|
||||
for key in sorted(differing_keys(baseline, other) - set(ignore)):
|
||||
diffs.append(
|
||||
f"{key}: baseline={_short(baseline.get(key))} parallel={_short(other.get(key))}"
|
||||
)
|
||||
if not ignore_size and size_differs(baseline, other):
|
||||
diffs.append(
|
||||
f"size: baseline={baseline.get('size', 0)} parallel={other.get('size', 0)} "
|
||||
f"(differs by more than {SIZE_TOLERANCE:.0%})"
|
||||
)
|
||||
return diffs
|
||||
|
||||
|
||||
def _short(value):
|
||||
text = repr(value)
|
||||
return text if len(text) <= 160 else text[:157] + "..."
|
||||
|
||||
|
||||
def build_decoy_spec(spec):
|
||||
"""Clone a spec with each PDF stamped with unique text, or None if not possible.
|
||||
|
||||
Structure is preserved so parameters stay valid; only the text differs, which
|
||||
is what makes bleed visible.
|
||||
"""
|
||||
try:
|
||||
from pypdf import PdfWriter
|
||||
from reportlab.pdfgen import canvas
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
decoy = []
|
||||
stamped_any = False
|
||||
for key, filename, payload, mime in spec:
|
||||
if filename is None or not isinstance(payload, bytes) or payload[:5] != b"%PDF-":
|
||||
decoy.append((key, filename, payload, mime))
|
||||
continue
|
||||
try:
|
||||
reader = PdfReader(io.BytesIO(payload))
|
||||
if reader.is_encrypted:
|
||||
return None
|
||||
writer = PdfWriter()
|
||||
for index, page in enumerate(reader.pages):
|
||||
box = page.mediabox
|
||||
width, height = float(box.width), float(box.height)
|
||||
overlay_buffer = io.BytesIO()
|
||||
overlay_canvas = canvas.Canvas(overlay_buffer, pagesize=(width, height))
|
||||
overlay_canvas.drawString(
|
||||
20, max(20.0, height - 20), f"DECOY-MARKER-{index}-do-not-mix"
|
||||
)
|
||||
overlay_canvas.showPage()
|
||||
overlay_canvas.save()
|
||||
overlay_buffer.seek(0)
|
||||
page.merge_page(PdfReader(overlay_buffer).pages[0])
|
||||
writer.add_page(page)
|
||||
out = io.BytesIO()
|
||||
writer.write(out)
|
||||
decoy.append((key, filename, out.getvalue(), mime))
|
||||
stamped_any = True
|
||||
except Exception:
|
||||
return None
|
||||
return decoy if stamped_any else None
|
||||
|
||||
|
||||
def _run_concurrently(url, specs, headers, timeout):
|
||||
"""Fire every spec at once and return (response, error) in submission order."""
|
||||
results = [None] * len(specs)
|
||||
|
||||
def _worker(index):
|
||||
try:
|
||||
results[index] = (send(url, specs[index], headers, timeout=timeout), None)
|
||||
except Exception as exc:
|
||||
results[index] = (None, exc)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=len(specs)) as pool:
|
||||
list(pool.map(_worker, range(len(specs))))
|
||||
return results
|
||||
|
||||
|
||||
def validate(context, url, spec, headers, baseline, label, timeout=300):
|
||||
"""Re-issue the request concurrently; no-op unless the repeat count is above 1."""
|
||||
repeat = getattr(context, "parallel_repeat", 1)
|
||||
if repeat < 2 or getattr(context, "parallel_validated", False):
|
||||
return
|
||||
context.parallel_validated = True
|
||||
context.parallel_ran_at = repeat
|
||||
|
||||
decoy_spec = build_decoy_spec(spec) if getattr(context, "parallel_decoy", False) else None
|
||||
specs = [spec] * repeat + ([decoy_spec] * repeat if decoy_spec else [])
|
||||
results = _run_concurrently(url, specs, headers, timeout)
|
||||
|
||||
main_results = results[:repeat]
|
||||
decoy_results = results[repeat:]
|
||||
baseline_fp = fingerprint(baseline)
|
||||
|
||||
noise, noisy_size = frozenset(), False
|
||||
failures = _collect_failures(
|
||||
main_results, decoy_results, baseline_fp, repeat, noise, noisy_size
|
||||
)
|
||||
if failures:
|
||||
# Some endpoints are inherently nondeterministic (embedded ids, timestamps,
|
||||
# deliberate randomness). Re-run sequentially to tell that apart from a real bug.
|
||||
noise, noisy_size = _probe_noise(url, spec, headers, baseline_fp, timeout)
|
||||
failures = _collect_failures(
|
||||
main_results, decoy_results, baseline_fp, repeat, noise, noisy_size
|
||||
)
|
||||
|
||||
VALIDATIONS.append(
|
||||
{
|
||||
"label": label,
|
||||
"repeat": repeat,
|
||||
"decoy": bool(decoy_spec),
|
||||
"failed": bool(failures),
|
||||
"noise": sorted(noise) + (["size"] if noisy_size else []),
|
||||
}
|
||||
)
|
||||
|
||||
if failures:
|
||||
raise AssertionError(
|
||||
f"Parallel consistency failed for {label} at concurrency {repeat}.\n"
|
||||
f"Two sequential runs agreed on these fields, so the differences below are "
|
||||
f"caused by running the same operation concurrently.\n"
|
||||
f"Baseline fingerprint: {_short(baseline_fp)}\n - " + "\n - ".join(failures)
|
||||
)
|
||||
|
||||
|
||||
def _collect_failures(main_results, decoy_results, baseline_fp, repeat, noise, noisy_size):
|
||||
decoy_fp = _decoy_reference(decoy_results, baseline_fp, noise, noisy_size)
|
||||
failures = []
|
||||
|
||||
for index, (response, error) in enumerate(main_results):
|
||||
if error is not None:
|
||||
failures.append(f"copy {index + 1}/{repeat} raised {type(error).__name__}: {error}")
|
||||
continue
|
||||
actual_fp = fingerprint(response)
|
||||
diffs = compare(baseline_fp, actual_fp, noise, noisy_size)
|
||||
if not diffs:
|
||||
continue
|
||||
if decoy_fp is not None and not compare(
|
||||
decoy_fp, actual_fp, noise, noisy_size
|
||||
):
|
||||
failures.append(
|
||||
f"copy {index + 1}/{repeat} returned the CONCURRENT DECOY REQUEST'S response "
|
||||
f"(cross-request bleed)"
|
||||
)
|
||||
else:
|
||||
failures.append(f"copy {index + 1}/{repeat} diverged: " + "; ".join(diffs))
|
||||
|
||||
failures.extend(_check_decoys(decoy_results, repeat, noise, noisy_size))
|
||||
return failures
|
||||
|
||||
|
||||
def _probe_noise(url, spec, headers, baseline_fp, timeout, samples=NOISE_PROBE_SAMPLES):
|
||||
"""Fields that already vary between uncontended runs, so they prove nothing.
|
||||
|
||||
Several samples: high-variance output can look stable across any single pair.
|
||||
"""
|
||||
probes = []
|
||||
for _ in range(samples):
|
||||
try:
|
||||
probes.append(fingerprint(send(url, spec, headers, timeout=timeout)))
|
||||
except Exception:
|
||||
break
|
||||
return _noise_from_samples(baseline_fp, probes)
|
||||
|
||||
|
||||
def _noise_from_samples(baseline_fp, probes):
|
||||
noise = set()
|
||||
noisy_size = False
|
||||
for index, probe in enumerate(probes):
|
||||
for other in [baseline_fp] + probes[:index]:
|
||||
noise |= differing_keys(other, probe)
|
||||
noisy_size = noisy_size or size_differs(other, probe)
|
||||
return frozenset(noise), noisy_size
|
||||
|
||||
|
||||
def validate_get(context, url, params, headers, baseline, label, timeout=60):
|
||||
"""Concurrency check for read-only GET endpoints."""
|
||||
repeat = getattr(context, "parallel_repeat", 1)
|
||||
if repeat < 2 or getattr(context, "parallel_validated", False):
|
||||
return
|
||||
context.parallel_validated = True
|
||||
context.parallel_ran_at = repeat
|
||||
|
||||
results = [None] * repeat
|
||||
|
||||
def _worker(index):
|
||||
try:
|
||||
results[index] = (
|
||||
requests.get(url, params=params, headers=headers, timeout=timeout),
|
||||
None,
|
||||
)
|
||||
except Exception as exc:
|
||||
results[index] = (None, exc)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=repeat) as pool:
|
||||
list(pool.map(_worker, range(repeat)))
|
||||
|
||||
baseline_fp = fingerprint(baseline)
|
||||
|
||||
def _failures(noise, noisy_size):
|
||||
found = []
|
||||
for index, (response, error) in enumerate(results):
|
||||
if error is not None:
|
||||
found.append(f"copy {index + 1}/{repeat} raised {type(error).__name__}: {error}")
|
||||
continue
|
||||
diffs = compare(
|
||||
baseline_fp,
|
||||
fingerprint(response),
|
||||
noise,
|
||||
noisy_size,
|
||||
)
|
||||
if diffs:
|
||||
found.append(f"copy {index + 1}/{repeat} diverged: " + "; ".join(diffs))
|
||||
return found
|
||||
|
||||
noise, noisy_size = frozenset(), False
|
||||
failures = _failures(noise, noisy_size)
|
||||
if failures:
|
||||
probes = []
|
||||
for _ in range(NOISE_PROBE_SAMPLES):
|
||||
try:
|
||||
probes.append(
|
||||
fingerprint(
|
||||
requests.get(url, params=params, headers=headers, timeout=timeout)
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
break
|
||||
noise, noisy_size = _noise_from_samples(baseline_fp, probes)
|
||||
failures = _failures(noise, noisy_size)
|
||||
|
||||
VALIDATIONS.append(
|
||||
{
|
||||
"label": label,
|
||||
"repeat": repeat,
|
||||
"decoy": False,
|
||||
"failed": bool(failures),
|
||||
"noise": sorted(noise) + (["size"] if noisy_size else []),
|
||||
}
|
||||
)
|
||||
if failures:
|
||||
raise AssertionError(
|
||||
f"Parallel consistency failed for GET {label} at concurrency {repeat}.\n - "
|
||||
+ "\n - ".join(failures)
|
||||
)
|
||||
|
||||
|
||||
def _decoy_reference(decoy_results, baseline_fp, noise, noisy_size):
|
||||
"""Fingerprint of the decoy response, or None when it is not distinguishable."""
|
||||
live = [r for r, _e in decoy_results if r is not None]
|
||||
if not live:
|
||||
return None
|
||||
decoy_fp = fingerprint(live[0])
|
||||
# Some endpoints ignore page content, so the decoy cannot prove anything there.
|
||||
if not compare(baseline_fp, decoy_fp, noise, noisy_size):
|
||||
return None
|
||||
return decoy_fp
|
||||
|
||||
|
||||
def _check_decoys(decoy_results, repeat, noise, noisy_size):
|
||||
"""Assert the decoy load stayed self-consistent while contending with the main copies."""
|
||||
if not decoy_results:
|
||||
return []
|
||||
live = [(i, r) for i, (r, _e) in enumerate(decoy_results) if r is not None]
|
||||
if not live:
|
||||
return ["every decoy request failed to complete"]
|
||||
|
||||
failures = []
|
||||
reference_fp = fingerprint(live[0][1])
|
||||
for index, response in live[1:]:
|
||||
diffs = compare(
|
||||
reference_fp,
|
||||
fingerprint(response),
|
||||
noise,
|
||||
noisy_size,
|
||||
)
|
||||
if diffs:
|
||||
failures.append(f"decoy {index + 1}/{repeat} diverged: " + "; ".join(diffs))
|
||||
return failures
|
||||
|
||||
|
||||
def print_summary():
|
||||
if not VALIDATIONS:
|
||||
return
|
||||
total = len(VALIDATIONS)
|
||||
failed = sum(1 for v in VALIDATIONS if v["failed"])
|
||||
max_repeat = max(v["repeat"] for v in VALIDATIONS)
|
||||
requests_sent = sum(v["repeat"] * (2 if v["decoy"] else 1) for v in VALIDATIONS)
|
||||
|
||||
lines = [
|
||||
f"\n[PARALLEL] {total - failed}/{total} operations stayed consistent under "
|
||||
f"concurrency (up to {max_repeat} at once, {requests_sent} concurrent requests sent)."
|
||||
]
|
||||
noisy = {}
|
||||
for entry in VALIDATIONS:
|
||||
if entry["noise"]:
|
||||
noisy.setdefault(entry["label"], set()).update(entry["noise"])
|
||||
if noisy:
|
||||
lines.append(
|
||||
f"[PARALLEL] {len(noisy)} endpoint(s) produce nondeterministic output. Those "
|
||||
f"fields were excluded only after confirming they also vary across "
|
||||
f"{NOISE_PROBE_SAMPLES} sequential runs:"
|
||||
)
|
||||
for label, fields in sorted(noisy.items()):
|
||||
lines.append(f" {label} -> {', '.join(sorted(fields))}")
|
||||
|
||||
# behave's --junit reporter swallows after_all stdout, so bypass any capture.
|
||||
stream = getattr(sys, "__stderr__", None) or sys.stderr
|
||||
stream.write("\n".join(lines) + "\n")
|
||||
stream.flush()
|
||||
@@ -15,6 +15,8 @@ import zipfile
|
||||
import re
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
import parallel_support
|
||||
|
||||
API_HEADERS = {"X-API-KEY": "123456789"}
|
||||
|
||||
#########
|
||||
@@ -585,6 +587,8 @@ def step_send_get_request(context, endpoint):
|
||||
full_url = f"{base_url}{endpoint}"
|
||||
response = requests.get(full_url, headers=API_HEADERS, timeout=60)
|
||||
context.response = response
|
||||
context.parallel_get = (full_url, None, API_HEADERS, endpoint)
|
||||
parallel_support.validate_get(context, full_url, None, API_HEADERS, response, endpoint)
|
||||
|
||||
|
||||
@when('I send a GET request to "{endpoint}" with parameters')
|
||||
@@ -594,17 +598,18 @@ def step_send_get_request_with_params(context, endpoint):
|
||||
full_url = f"{base_url}{endpoint}"
|
||||
response = requests.get(full_url, params=params, headers=API_HEADERS, timeout=60)
|
||||
context.response = response
|
||||
context.parallel_get = (full_url, params, API_HEADERS, endpoint)
|
||||
parallel_support.validate_get(context, full_url, params, API_HEADERS, response, endpoint)
|
||||
|
||||
|
||||
@when('I send the API request to the endpoint "{endpoint}"')
|
||||
def step_send_api_request(context, endpoint):
|
||||
url = f"http://localhost:8080{endpoint}"
|
||||
def _build_request_spec(context):
|
||||
"""Capture the multipart payload as replayable bytes rather than file handles."""
|
||||
files = context.files if hasattr(context, "files") else {}
|
||||
|
||||
if not hasattr(context, "request_data") or context.request_data is None:
|
||||
context.request_data = {}
|
||||
|
||||
form_data = []
|
||||
spec = []
|
||||
for key, value in context.request_data.items():
|
||||
# Handle list parameters (like 'languages') - send multiple form fields
|
||||
# Split comma-separated values or treat single values as single-item lists
|
||||
@@ -612,32 +617,50 @@ def step_send_api_request(context, endpoint):
|
||||
# Split by comma if present, otherwise treat as single value
|
||||
values = [v.strip() for v in value.split(",")] if "," in value else [value]
|
||||
for val in values:
|
||||
form_data.append((key, (None, val)))
|
||||
spec.append((key, None, val, None))
|
||||
else:
|
||||
form_data.append((key, (None, value)))
|
||||
spec.append((key, None, value, None))
|
||||
|
||||
def _read(file):
|
||||
file.seek(0)
|
||||
payload = file.read()
|
||||
file.seek(0)
|
||||
return payload
|
||||
|
||||
for key, file in files.items():
|
||||
mime_type, _ = mimetypes.guess_type(file.name)
|
||||
mime_type = mime_type or "application/octet-stream"
|
||||
print(f"form_data {file.name} with {mime_type}")
|
||||
form_data.append((key, (file.name, file, mime_type)))
|
||||
spec.append((key, file.name, _read(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)))
|
||||
spec.append((key, file.name, _read(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")))
|
||||
spec.append((part_name, None, json_content, "application/json"))
|
||||
|
||||
return spec
|
||||
|
||||
|
||||
@when('I send the API request to the endpoint "{endpoint}"')
|
||||
def step_send_api_request(context, endpoint):
|
||||
url = f"http://localhost:8080{endpoint}"
|
||||
spec = _build_request_spec(context)
|
||||
|
||||
# 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)
|
||||
response = parallel_support.send(url, spec, API_HEADERS, timeout=300)
|
||||
context.response = response
|
||||
|
||||
# Remembered so a later "run N times in parallel" step can replay this request.
|
||||
context.parallel_request = (url, spec, API_HEADERS, endpoint)
|
||||
parallel_support.validate(context, url, spec, API_HEADERS, response, endpoint)
|
||||
|
||||
|
||||
########
|
||||
# THEN #
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/bin/bash
|
||||
# Run the behave suite as N concurrent shards against one server.
|
||||
# Usage: ./run-parallel.sh [SHARDS] [-- behave args] Env: BASE_URL
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SHARDS="${1:-10}"
|
||||
if [[ "$SHARDS" =~ ^[0-9]+$ ]]; then
|
||||
shift
|
||||
else
|
||||
SHARDS=10
|
||||
fi
|
||||
[[ "${1:-}" == "--" ]] && shift
|
||||
|
||||
BASE_URL="${BASE_URL:-http://localhost:8080}"
|
||||
CUCUMBER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_ROOT="$CUCUMBER_DIR/.parallel"
|
||||
REPORT_DIR="${PARALLEL_REPORT_DIR:-$WORK_ROOT/reports}"
|
||||
|
||||
cd "$CUCUMBER_DIR" || exit 1
|
||||
|
||||
if ! curl -sf --retry 30 --retry-delay 2 --retry-connrefused --retry-all-errors \
|
||||
"$BASE_URL/api/v1/info/status" >/dev/null; then
|
||||
echo "ERROR: no server responding at $BASE_URL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mapfile -t ALL_FEATURES < <(
|
||||
find features -name '*.feature' \
|
||||
-not -path 'features/enterprise/*' \
|
||||
-not -path 'features/multinode/*' | sort
|
||||
)
|
||||
|
||||
if [ "${#ALL_FEATURES[@]}" -eq 0 ]; then
|
||||
echo "ERROR: no feature files found under $CUCUMBER_DIR/features"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# user_management.feature changes the admin password mid-scenario, so a concurrent
|
||||
# shard logging in then gets a 401. Keep all admin-auth features in one shard.
|
||||
AUTH_RE='logged in as admin|I login with username|JWT authentication|stored JWT token'
|
||||
declare -a AUTH_FEATURES=() FEATURES=()
|
||||
for feature in "${ALL_FEATURES[@]}"; do
|
||||
if grep -qE "$AUTH_RE" "$feature"; then
|
||||
AUTH_FEATURES+=("$feature")
|
||||
else
|
||||
FEATURES+=("$feature")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "${#AUTH_FEATURES[@]}" -gt 0 ]; then
|
||||
echo "Pinning ${#AUTH_FEATURES[@]} auth-coupled feature(s) to a single shard:"
|
||||
printf ' %s\n' "${AUTH_FEATURES[@]##*/}"
|
||||
SHARDS=$((SHARDS - 1))
|
||||
fi
|
||||
|
||||
# The pin above can take SHARDS to 0 or below, which makes the shard loop run zero
|
||||
# times: every shardable feature is skipped and the run still reports success.
|
||||
[ "$SHARDS" -lt 0 ] && SHARDS=0
|
||||
[ "${#FEATURES[@]}" -gt 0 ] && [ "$SHARDS" -lt 1 ] && SHARDS=1
|
||||
|
||||
if [ "$SHARDS" -gt "${#FEATURES[@]}" ]; then
|
||||
echo "Only ${#FEATURES[@]} shardable feature files, reducing shards from $SHARDS"
|
||||
SHARDS="${#FEATURES[@]}"
|
||||
fi
|
||||
|
||||
rm -rf "$WORK_ROOT"
|
||||
mkdir -p "$WORK_ROOT" "$REPORT_DIR"
|
||||
|
||||
TOTAL_SHARDS=$SHARDS
|
||||
[ "${#AUTH_FEATURES[@]}" -gt 0 ] && TOTAL_SHARDS=$((SHARDS + 1))
|
||||
echo "Running ${#ALL_FEATURES[@]} feature files across $TOTAL_SHARDS concurrent shards against $BASE_URL"
|
||||
|
||||
declare -a PIDS=()
|
||||
|
||||
start_shard() {
|
||||
local shard=$1
|
||||
shift
|
||||
local SHARD_DIR="$WORK_ROOT/shard-$shard"
|
||||
mkdir -p "$SHARD_DIR"
|
||||
# Feature files reference exampleFiles/ and behave.ini relative to the CWD.
|
||||
cp -r "$CUCUMBER_DIR/exampleFiles" "$SHARD_DIR/exampleFiles"
|
||||
cp "$CUCUMBER_DIR/behave.ini" "$SHARD_DIR/behave.ini"
|
||||
|
||||
local assigned=("$@")
|
||||
(
|
||||
cd "$SHARD_DIR" || exit 1
|
||||
uv run --project "$CUCUMBER_DIR/../../engine" --locked --group cucumber \
|
||||
python -m behave "${assigned[@]}" \
|
||||
--junit --junit-directory "$REPORT_DIR/shard-$shard" \
|
||||
--no-capture -f plain "${BEHAVE_EXTRA[@]}" \
|
||||
>"$REPORT_DIR/shard-$shard.log" 2>&1
|
||||
) &
|
||||
PIDS+=($!)
|
||||
}
|
||||
|
||||
declare -a BEHAVE_EXTRA=("$@")
|
||||
|
||||
for ((shard = 0; shard < SHARDS; shard++)); do
|
||||
assigned=()
|
||||
for ((i = shard; i < ${#FEATURES[@]}; i += SHARDS)); do
|
||||
assigned+=("$CUCUMBER_DIR/${FEATURES[$i]}")
|
||||
done
|
||||
start_shard "$shard" "${assigned[@]}"
|
||||
done
|
||||
|
||||
if [ "${#AUTH_FEATURES[@]}" -gt 0 ]; then
|
||||
assigned=()
|
||||
for feature in "${AUTH_FEATURES[@]}"; do
|
||||
assigned+=("$CUCUMBER_DIR/$feature")
|
||||
done
|
||||
start_shard "$SHARDS" "${assigned[@]}"
|
||||
fi
|
||||
|
||||
FAILED=0
|
||||
for ((shard = 0; shard < TOTAL_SHARDS; shard++)); do
|
||||
if wait "${PIDS[$shard]}"; then
|
||||
echo "shard $shard PASSED"
|
||||
else
|
||||
echo "shard $shard FAILED"
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Parallel shard summary ==="
|
||||
grep -h '^[0-9]* scenarios passed' "$REPORT_DIR"/shard-*.log 2>/dev/null || true
|
||||
grep -h '^\[PARALLEL\] [0-9]*/' "$REPORT_DIR"/shard-*.log 2>/dev/null || true
|
||||
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "$FAILED of $TOTAL_SHARDS shards failed. Failing scenarios:"
|
||||
sed -n '/^Failing scenarios:/,/^[0-9]* features/p' "$REPORT_DIR"/shard-*.log 2>/dev/null |
|
||||
grep 'feature:' | sort -u
|
||||
echo ""
|
||||
echo "Parallel consistency failures:"
|
||||
grep -h -A4 'Parallel consistency failed' "$REPORT_DIR"/shard-*.log 2>/dev/null | head -40
|
||||
echo ""
|
||||
echo "Full logs: $REPORT_DIR/shard-*.log"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All $TOTAL_SHARDS shards passed."
|
||||
Reference in New Issue
Block a user