cucumber for days (#5766)

This commit is contained in:
Anthony Stirling
2026-02-21 23:17:28 +00:00
committed by GitHub
parent 7631b222bd
commit 30c258ce0b
40 changed files with 4079 additions and 49 deletions
+8
View File
@@ -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
Binary file not shown.
@@ -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
+201
View File
@@ -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> 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> 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
@@ -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"
@@ -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
@@ -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
@@ -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"
@@ -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"
@@ -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"
@@ -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 | <format> |
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 "<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"
@@ -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
@@ -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
@@ -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
@@ -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
+102 -3
View File
@@ -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
+271
View File
@@ -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 | <pageCount> |
| comparator | <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 | <pageCount> |
| comparator | <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 | <fileSize> |
| comparator | <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 | <rotation> |
| comparator | <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 | <rotation> |
| comparator | <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 | <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 | <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
@@ -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"
+91
View File
@@ -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
@@ -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 | <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 | <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 <remaining> 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 | <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 <expectedPages> 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 | <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 | <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 <outputPages> 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
+42
View File
@@ -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
@@ -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
+379
View File
@@ -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 "<script>alert(1)</script>" 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"
@@ -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"
+224
View File
@@ -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 | <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 | <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 | <colorspace> |
| quality | <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
@@ -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"
@@ -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
@@ -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.<field> 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.<field> 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.<field> 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
@@ -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}"
)
@@ -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 = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200">\n'
' <rect width="200" height="200" fill="#4a90e2"/>\n'
' <circle cx="100" cy="100" r="60" fill="#f5a623"/>\n'
' <text x="100" y="107" font-size="16" text-anchor="middle" fill="white">Test SVG</text>\n'
"</svg>\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 = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" viewBox="0 0 150 150">\n'
f' <rect width="150" height="150" fill="hsl({count * 60}, 70%, 50%)"/>\n'
' <polygon points="75,20 140,130 10,130" fill="white" opacity="0.6"/>\n'
"</svg>\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: <[email protected]>\r\n"
"From: [email protected]\r\n"
"To: [email protected]\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)
@@ -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"
+2
View File
@@ -1,5 +1,7 @@
behave
behave-html-formatter
requests
pypdf
reportlab
PyCryptodome
qrcode[pil]
+4
View File
@@ -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