Cucumber concurrency validation plus fix (#7379)

# Description of Changes

cucumber tests to run multiple threads of commands at same time 

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
This commit is contained in:
Anthony Stirling
2026-08-14 14:01:45 +01:00
committed by GitHub
parent 55087313b7
commit 0be10b2dff
46 changed files with 2422 additions and 58 deletions
+79
View File
@@ -4,6 +4,11 @@ on:
schedule:
- cron: "0 2 * * *" # 2 AM UTC every night
workflow_dispatch:
pull_request:
paths:
- .github/workflows/nightly.yml
- testing/cucumber/**
- docker/embedded/compose/test_cicd.yml
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -99,8 +104,13 @@ jobs:
# Builds all desktop platforms on a schedule so the Rust dependency cache is
# written on main, where PR and merge-queue tauri builds can restore it.
#
# The only job here still pinned to schedule/main: it primes a cache rather than
# testing anything, and Actions scopes a cache written on a PR branch to that PR
# alone, so a PR run costs three platform builds and produces nothing reusable.
warm-tauri-cache:
name: Warm Tauri Rust cache
if: github.event_name == 'schedule' || github.ref == 'refs/heads/main'
permissions:
contents: read
pull-requests: write
@@ -109,3 +119,72 @@ jobs:
platform: all
sign: false
secrets: inherit
# Runs the @nightly tag (conversion scenarios) plus a 10-shard concurrency run
# of every other feature.
cucumber-nightly:
name: Cucumber (nightly scenarios + full concurrency)
runs-on: ubuntu-latest
# Fork pull requests get no MAVEN_* secrets, so the image build cannot work.
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Start the fat image with login and storage enabled
run: docker compose -f docker/embedded/compose/test_cicd.yml up -d --build
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
- name: Wait for the server
# Throwaway key from test_cicd.yml; out of the header literal for gitleaks.
env:
TEST_API_KEY: "123456789"
run: |
curl --retry 90 --retry-delay 3 --retry-connrefused --retry-all-errors \
-sf -H "X-API-KEY: $TEST_API_KEY" http://localhost:8080/api/v1/info/status
# Heavy LibreOffice/Calibre/Ghostscript conversions, excluded from the PR run.
# Both tasks install the behave deps themselves, so there is no separate uv sync step.
- name: Run @nightly scenarios
run: task cucumber:nightly
# Genuinely different payloads contending on one backend.
- name: Sharded concurrency validation
run: task cucumber:parallel SHARDS=10
- name: Container logs on failure
if: failure()
run: docker compose -f docker/embedded/compose/test_cicd.yml logs --tail 400
- name: Tear down
if: always()
run: docker compose -f docker/embedded/compose/test_cicd.yml down -v
+1
View File
@@ -38,6 +38,7 @@ exampleYmlFiles/stirling/
/testing/file_snapshots
/testing/cucumber/junit/
/testing/cucumber/report.html
/testing/cucumber/.parallel/
/testing/.failed_tests
/.test-state/
SwaggerDoc.json
+44
View File
@@ -0,0 +1,44 @@
version: '3'
tasks:
install:
desc: "Sync the Python environment with the cucumber test dependencies"
run: once
# Deliberately no sources/status fingerprint: the engine venv is shared, so it can
# already exist while synced to a different dependency group. uv no-ops when correct.
cmds:
- uv sync --project ../../engine --locked --group cucumber
run:
desc: "Run the cucumber suite against a running server (BASE_URL, default localhost:8080)"
deps: [install]
cmds:
- uv run --project ../../engine --locked --group cucumber python -m behave --no-capture -f plain {{.CLI_ARGS}}
nightly:
desc: "Run the @nightly cucumber scenarios, excluded from the default run"
summary: |
Heavy LibreOffice/Calibre/Ghostscript conversions. behave.ini excludes @nightly,
so this opts back in explicitly.
Pass extra behave flags via -- :
task cucumber:nightly -- --tags=@convert
deps: [install]
cmds:
- uv run --project ../../engine --locked --group cucumber python -m behave --tags=@nightly --no-capture -f plain {{.CLI_ARGS}}
parallel:
desc: "Run the cucumber suite as concurrent shards against one server (SHARDS, default 10)"
summary: |
Splits the feature files across SHARDS concurrent behave processes hitting a single
backend, to shake out cross-request interference. Auth-coupled features are pinned
to one shard because they change the admin password mid-scenario.
task cucumber:parallel
task cucumber:parallel SHARDS=4
BASE_URL=http://localhost:8081 task cucumber:parallel
deps: [install]
vars:
SHARDS: '{{.SHARDS | default "10"}}'
cmds:
- bash run-parallel.sh {{.SHARDS}} {{if .CLI_ARGS}}-- {{.CLI_ARGS}}{{end}}
+3
View File
@@ -25,6 +25,9 @@ includes:
e2e:
taskfile: .taskfiles/e2e.yml
dir: .
cucumber:
taskfile: .taskfiles/cucumber.yml
dir: testing/cucumber
pre-commit:
taskfile: .taskfiles/pre-commit.yml
dir: .
@@ -2,7 +2,9 @@ package stirling.software.common.aop;
import java.io.IOException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;
@@ -273,6 +275,7 @@ public class AutoJobAspect {
// Store the fileId for later reference
pdfFile.setFileId(fileId);
recordPendingInputFile(fileId);
// Replace the original MultipartFile with our persistent copy
MultipartFile persistentFile = fileStorage.retrieveFile(fileId);
@@ -290,6 +293,29 @@ public class AutoJobAspect {
return originalArgs;
}
/**
* Queue an input copy for attribution to the job. The job id does not exist yet at this point,
* so {@link JobExecutorService} drains this list once it mints one.
*/
@SuppressWarnings("unchecked")
private void recordPendingInputFile(String fileId) {
try {
Object existing = request.getAttribute(JobExecutorService.PENDING_INPUT_FILE_IDS_ATTR);
List<String> ids;
if (existing instanceof List<?> list) {
ids = (List<String>) list;
} else {
ids = new ArrayList<>();
request.setAttribute(JobExecutorService.PENDING_INPUT_FILE_IDS_ATTR, ids);
}
ids.add(fileId);
} catch (RuntimeException ex) {
// Without a bound request the copy cannot be attributed; the periodic sweep is the
// only backstop, so make the miss visible rather than silently leaking the file.
log.warn("Could not record input copy {} for cleanup: {}", fileId, ex.getMessage());
}
}
private String getJobIdFromContext() {
try {
return (String) request.getAttribute("jobId");
@@ -52,6 +52,13 @@ public class JobResult {
/** Key/value metadata that survives the write-through into the shared job store. */
private final Map<String, String> metadata = new ConcurrentHashMap<>();
/**
* File ids of the persistent input copies made for this job. An async submit copies the upload
* into FileStorage so the job can still read it after the request returns; without tracking
* them here nothing would ever delete those copies.
*/
@JsonIgnore private final List<String> inputFileIds = new CopyOnWriteArrayList<>();
/**
* Create a new JobResult with the given job ID
*
@@ -167,6 +174,22 @@ public class JobResult {
return Collections.unmodifiableList(notes);
}
/** Record a persistent input copy so job cleanup deletes it alongside the results. */
public void addInputFileId(String fileId) {
if (fileId != null && !fileId.isBlank() && !inputFileIds.contains(fileId)) {
this.inputFileIds.add(fileId);
}
}
/**
* File ids of this job's persistent input copies.
*
* @return An unmodifiable view of the input file ids
*/
public List<String> getInputFileIds() {
return Collections.unmodifiableList(inputFileIds);
}
/** Attach a metadata value, e.g. a policy id so cluster peers can identify a policy run. */
public void putMetadata(String key, String value) {
if (key != null && value != null) {
@@ -179,6 +179,21 @@ public class FileStorage {
return fileStore.delete(fileId);
}
/**
* Delete a stored file without the per-file ownership check.
*
* <p>Job cleanup authorises at the job level and then deletes that job's own files, so the
* deleter is legitimately not their owner - an admin sweeping every user's jobs, or the
* unauthenticated scheduled task. Routing those through {@link #deleteFile(String)} makes the
* ownership check throw and silently orphans the files on disk.
*
* <p>Only ever pass file ids read back off a job that the caller has already been authorised
* for; never a caller-supplied id.
*/
public boolean deleteFileAsSystem(String fileId) {
return fileStore.delete(fileId);
}
public boolean fileExists(String fileId) {
enforceOwnership(fileId);
return fileStore.exists(fileId);
@@ -1,6 +1,7 @@
package stirling.software.common.service;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@@ -33,6 +34,14 @@ import stirling.software.common.util.RegexPatternUtils;
@Slf4j
public class JobExecutorService {
/**
* Request attribute holding the FileStorage ids of persistent input copies made for the job
* about to be created. Populated before the job id exists (the aspect copies the upload while
* processing arguments), drained onto the JobResult as soon as the task is created so cleanup
* can delete them.
*/
public static final String PENDING_INPUT_FILE_IDS_ATTR = "autoJobPendingInputFileIds";
private final TaskManager taskManager;
private final FileStorage fileStorage;
private final HttpServletRequest request;
@@ -133,6 +142,7 @@ public class JobExecutorService {
resourceWeight);
taskManager.createTask(jobId);
registerPendingInputFiles(jobId);
final String capturedJobIdForQueue = jobId;
Supplier<Object> wrappedWork =
@@ -163,6 +173,7 @@ public class JobExecutorService {
return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null));
} else if (async) {
taskManager.createTask(jobId);
registerPendingInputFiles(jobId);
final String capturedJobId = jobId;
@@ -484,4 +495,30 @@ public class JobExecutorService {
}
return baseJobId;
}
/**
* Hand the input copies made while processing arguments to the freshly created job, so job
* cleanup deletes them. Drains the attribute so a retry cannot attribute the same ids twice.
*/
@SuppressWarnings("unchecked")
private void registerPendingInputFiles(String jobId) {
if (request == null) {
return;
}
Object pending;
try {
pending = request.getAttribute(PENDING_INPUT_FILE_IDS_ATTR);
request.removeAttribute(PENDING_INPUT_FILE_IDS_ATTR);
} catch (RuntimeException ex) {
// No request bound to this thread (e.g. an internally dispatched job).
log.debug("Could not read pending input file ids: {}", ex.getMessage());
return;
}
if (!(pending instanceof List<?> ids)) {
return;
}
for (String fileId : (List<String>) ids) {
taskManager.registerInputFile(jobId, fileId);
}
}
}
@@ -17,6 +17,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Predicate;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
@@ -234,6 +235,24 @@ public class TaskManager {
return false;
}
/**
* Record a persistent input copy against a job so cleanup deletes it with the results.
*
* @param jobId The job ID
* @param fileId The FileStorage id of the input copy
* @return true if the job exists and the id was recorded
*/
public boolean registerInputFile(String jobId, String fileId) {
JobResult jobResult = jobResults.get(jobId);
if (jobResult == null) {
log.warn("Attempted to register an input file against non-existent job ID: {}", jobId);
return false;
}
jobResult.addInputFileId(fileId);
log.debug("Registered input file {} for job {}", fileId, jobId);
return true;
}
/** Attach metadata to a job and write it through to the shared store for cluster peers. */
public boolean putMetadata(String jobId, String key, String value) {
JobResult jobResult = jobResults.get(jobId);
@@ -329,25 +348,59 @@ public class TaskManager {
return jobResults.computeIfAbsent(jobId, JobResult::createNew);
}
/**
* What a cleanup pass removed. Returned by the on-demand cleanup so callers can assert on it.
*/
public record CleanupSummary(int jobsRemoved, int filesDeleted, int jobsRetained) {}
/** Clean up old completed job results. No-op in cluster mode; the backplane TTL owns expiry. */
public void cleanupOldJobs() {
public CleanupSummary cleanupOldJobs() {
if (clusterBackplane != null && !clusterBackplane.shouldRunLocalCleanup()) {
return;
return new CleanupSummary(0, 0, jobResults.size());
}
return cleanupJobs(false, jobId -> true);
}
/**
* Force-expire this node's finished jobs now, ignoring the age threshold. Jobs still running
* are left alone - deleting their files mid-flight would break them - and are reported as
* retained.
*
* <p>Unlike {@link #cleanupOldJobs()} this always runs locally: it is an explicit request to
* release this node's storage, not the scheduled sweep the backplane TTL owns.
*
* @param jobIdFilter Only jobs whose id passes this predicate are considered, so a caller can
* restrict the sweep to jobs the requester is allowed to touch
* @return What was removed
*/
public CleanupSummary cleanupFinishedJobsNow(Predicate<String> jobIdFilter) {
return cleanupJobs(true, jobIdFilter);
}
private CleanupSummary cleanupJobs(boolean force, Predicate<String> filter) {
LocalDateTime expiryThreshold =
LocalDateTime.now().minus(jobResultExpiryMinutes, ChronoUnit.MINUTES);
LocalDateTime pendingExpiryThreshold =
LocalDateTime.now().minus(pendingJobExpiryMinutes, ChronoUnit.MINUTES);
int removedCount = 0;
int filesDeleted = 0;
int retainedCount = 0;
try {
for (Map.Entry<String, JobResult> entry : jobResults.entrySet()) {
JobResult result = entry.getValue();
if (!filter.test(entry.getKey())) {
retainedCount++;
continue;
}
boolean expiredCompletedJob =
result.isComplete()
&& result.getCompletedAt() != null
&& result.getCompletedAt().isBefore(expiryThreshold);
&& (force
|| (result.getCompletedAt() != null
&& result.getCompletedAt()
.isBefore(expiryThreshold)));
boolean abandonedPendingJob =
!result.isComplete()
&& result.getCreatedAt() != null
@@ -360,7 +413,7 @@ public class TaskManager {
// Clean up file results
if (expiredCompletedJob) {
cleanupJobFiles(result, entry.getKey());
filesDeleted += cleanupJobFiles(result, entry.getKey());
}
// Remove the job result
@@ -369,15 +422,22 @@ public class TaskManager {
jobStore.delete(entry.getKey());
}
removedCount++;
} else {
retainedCount++;
}
}
if (removedCount > 0) {
log.info("Cleaned up {} expired job results", removedCount);
log.info(
"Cleaned up {} {} job results ({} files deleted)",
removedCount,
force ? "finished" : "expired",
filesDeleted);
}
} catch (Exception e) {
log.error("Error during job cleanup: {}", e.getMessage(), e);
}
return new CleanupSummary(removedCount, filesDeleted, retainedCount);
}
/** Mirror the in-memory {@code JobResult} into the cluster-visible {@link JobStore}. */
@@ -525,22 +585,43 @@ public class TaskManager {
}
}
/** Clean up files associated with a job result */
private void cleanupJobFiles(JobResult result, String jobId) {
/**
* Clean up files associated with a job result: both the results and the persistent input copy
* an async submit made of the upload.
*
* @return The number of files actually deleted
*/
private int cleanupJobFiles(JobResult result, String jobId) {
int deleted = 0;
// Clean up all result files
if (result.hasFiles()) {
for (ResultFile resultFile : result.getAllResultFiles()) {
try {
fileStorage.deleteFile(resultFile.getFileId());
} catch (Exception e) {
log.warn(
"Failed to delete file {} for job {}: {}",
resultFile.getFileId(),
jobId,
e.getMessage());
if (deleteJobFile(resultFile.getFileId(), jobId)) {
deleted++;
}
}
}
for (String inputFileId : result.getInputFileIds()) {
if (deleteJobFile(inputFileId, jobId)) {
deleted++;
}
}
return deleted;
}
/**
* Deletes as the system, not as the caller: an admin sweeping another user's jobs, or the
* scheduled task running with no security context, is not the file's owner, and the
* ownership-checked delete would throw and leave the file orphaned on disk. The job itself is
* already authorised by the time we get here, and these ids come off that job, not the request.
*/
private boolean deleteJobFile(String fileId, String jobId) {
try {
return fileStorage.deleteFileAsSystem(fileId);
} catch (Exception e) {
log.warn("Failed to delete file {} for job {}: {}", fileId, jobId, e.getMessage());
return false;
}
}
/** Find the ResultFile metadata for a given file ID by searching through all job results */
@@ -12,6 +12,7 @@ import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.MessageDigest;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -54,6 +55,10 @@ public class GeneralUtils {
private final String DEFAULT_WEBUI_CONFIGS_DIR = "defaultWebUIConfigs";
private final String PYTHON_SCRIPTS_DIR = "python";
// Extracted once per run. Rewriting a script while another request is exec-ing it
// races wherever rename is not atomic, such as 9p or NFS bind mounts.
private final Map<String, Path> EXTRACTED_SCRIPTS = new ConcurrentHashMap<>();
private final RegexPatternUtils patternCache = RegexPatternUtils.getInstance();
// Valid size units used for convertSizeToBytes validation and parsing
private final Set<String> VALID_SIZE_UNITS = Set.of("B", "KB", "MB", "GB", "TB");
@@ -1025,17 +1030,30 @@ public class GeneralUtils {
}
Path scriptsDir = Path.of(InstallationPathConfig.getScriptsPath(), PYTHON_SCRIPTS_DIR);
Files.createDirectories(scriptsDir);
Path target = scriptsDir.resolve(scriptName);
ClassPathResource res =
new ClassPathResource("static/" + PYTHON_SCRIPTS_DIR + "/" + scriptName);
if (!res.exists()) {
log.error("Resource not found: {}", res.getPath());
throw new IOException("Resource not found: " + res.getPath());
Path cached = EXTRACTED_SCRIPTS.get(scriptName);
if (cached != null && Files.isRegularFile(cached)) {
return cached;
}
synchronized (EXTRACTED_SCRIPTS) {
cached = EXTRACTED_SCRIPTS.get(scriptName);
if (cached != null && Files.isRegularFile(cached)) {
return cached;
}
Files.createDirectories(scriptsDir);
ClassPathResource res =
new ClassPathResource("static/" + PYTHON_SCRIPTS_DIR + "/" + scriptName);
if (!res.exists()) {
log.error("Resource not found: {}", res.getPath());
throw new IOException("Resource not found: " + res.getPath());
}
copyResourceToFile(res, target);
EXTRACTED_SCRIPTS.put(scriptName, target);
return target;
}
copyResourceToFile(res, target);
return target;
}
/*
@@ -2,6 +2,7 @@ package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -65,6 +66,24 @@ class FileStorageOwnershipTest {
assertThrows(SecurityException.class, () -> fs.deleteFile(id));
}
@Test
void systemDeleteOfAnotherUsersFile_allowed_soJobCleanupDoesNotOrphanIt(@TempDir Path tempDir)
throws IOException {
// An admin sweeping every user's finished jobs is not the owner of their files. The
// ownership-checked delete throws there, which used to drop the job record and leave the
// files stranded on disk with nothing left able to reference them.
AtomicReference<String> user = new AtomicReference<>("alice");
FileStorage fs = newStorageWithCurrentUser(tempDir, user);
String id = fs.storeBytes("alice's file".getBytes(), "x.bin");
user.set("admin");
assertThrows(SecurityException.class, () -> fs.deleteFile(id));
assertTrue(fs.deleteFileAsSystem(id), "System delete must not be blocked by ownership");
user.set("alice");
assertThrows(IOException.class, () -> fs.retrieveBytes(id), "File should really be gone");
}
@Test
void anonymousRetrieveOfOwnedFile_allowed_noCurrentUserMeansNoCompare(@TempDir Path tempDir)
throws IOException {
@@ -0,0 +1,263 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.http.MediaType;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.cluster.ClusterBackplane;
import stirling.software.common.cluster.JobStore;
import stirling.software.common.model.job.JobResult;
import stirling.software.common.model.job.ResultFile;
/**
* Covers the on-demand cleanup path and the input-copy tracking that makes it complete. An async
* submit persists a copy of the upload as well as its results; before both were tracked, only the
* results were ever deleted and the input copy stayed on disk indefinitely.
*/
class TaskManagerCleanupTest {
@Mock private FileStorage fileStorage;
@Mock private JobStore jobStore;
@Mock private ClusterBackplane clusterBackplane;
@InjectMocks private TaskManager taskManager;
private AutoCloseable closeable;
@BeforeEach
void setUp() {
closeable = MockitoAnnotations.openMocks(this);
lenient().when(clusterBackplane.localNodeId()).thenReturn("test-node");
lenient().when(clusterBackplane.shouldRunLocalCleanup()).thenReturn(true);
lenient().when(fileStorage.deleteFileAsSystem(anyString())).thenReturn(true);
ReflectionTestUtils.setField(taskManager, "jobResultExpiryMinutes", 30);
ReflectionTestUtils.setField(taskManager, "pendingJobExpiryMinutes", 1440);
}
@AfterEach
void tearDown() throws Exception {
closeable.close();
}
@SuppressWarnings("unchecked")
private Map<String, JobResult> jobResults() {
return (Map<String, JobResult>) ReflectionTestUtils.getField(taskManager, "jobResults");
}
/** Complete a job with a single result file, as an async file-producing job would. */
private void completeWithFile(String jobId, String fileId) {
taskManager.setFileResult(jobId, fileId, "out.pdf", MediaType.APPLICATION_PDF_VALUE);
taskManager.setComplete(jobId);
}
@Test
void forcedCleanupRemovesFinishedJobsRegardlessOfAge() {
String jobId = "fresh-job";
taskManager.createTask(jobId);
completeWithFile(jobId, "result-file");
// The scheduled sweep leaves it alone: it completed well inside the retention window.
taskManager.cleanupOldJobs();
assertTrue(jobResults().containsKey(jobId), "Scheduled cleanup should respect the expiry");
TaskManager.CleanupSummary summary = taskManager.cleanupFinishedJobsNow(id -> true);
assertEquals(1, summary.jobsRemoved());
assertEquals(1, summary.filesDeleted());
assertEquals(0, summary.jobsRetained());
assertFalse(jobResults().containsKey(jobId));
verify(fileStorage).deleteFileAsSystem("result-file");
verify(jobStore).delete(jobId);
}
@Test
void forcedCleanupDeletesThePersistedInputCopy() {
String jobId = "job-with-input";
taskManager.createTask(jobId);
assertTrue(taskManager.registerInputFile(jobId, "input-file"));
completeWithFile(jobId, "result-file");
TaskManager.CleanupSummary summary = taskManager.cleanupFinishedJobsNow(id -> true);
assertEquals(1, summary.jobsRemoved());
assertEquals(2, summary.filesDeleted(), "Both the result and the input copy must go");
verify(fileStorage).deleteFileAsSystem("result-file");
verify(fileStorage).deleteFileAsSystem("input-file");
}
@Test
void scheduledCleanupAlsoDeletesThePersistedInputCopy() {
String jobId = "expired-job";
taskManager.createTask(jobId);
taskManager.registerInputFile(jobId, "input-file");
completeWithFile(jobId, "result-file");
JobResult result = taskManager.getJobResult(jobId);
ReflectionTestUtils.setField(result, "completedAt", LocalDateTime.now().minusHours(1));
taskManager.cleanupOldJobs();
assertFalse(jobResults().containsKey(jobId));
verify(fileStorage).deleteFileAsSystem("result-file");
verify(fileStorage).deleteFileAsSystem("input-file");
}
@Test
void forcedCleanupLeavesRunningJobsAlone() {
String running = "running-job";
taskManager.createTask(running);
taskManager.registerInputFile(running, "in-flight-input");
TaskManager.CleanupSummary summary = taskManager.cleanupFinishedJobsNow(id -> true);
assertEquals(0, summary.jobsRemoved());
assertEquals(0, summary.filesDeleted());
assertEquals(1, summary.jobsRetained());
assertTrue(jobResults().containsKey(running));
// Deleting a running job's input mid-flight would break it.
verify(fileStorage, never()).deleteFileAsSystem(anyString());
}
@Test
void forcedCleanupSkipsJobsTheFilterRejects() {
taskManager.createTask("alice:job");
completeWithFile("alice:job", "alice-file");
taskManager.createTask("bob:job");
completeWithFile("bob:job", "bob-file");
TaskManager.CleanupSummary summary =
taskManager.cleanupFinishedJobsNow(id -> id.startsWith("alice:"));
assertEquals(1, summary.jobsRemoved());
assertEquals(1, summary.jobsRetained());
assertFalse(jobResults().containsKey("alice:job"));
assertTrue(jobResults().containsKey("bob:job"), "Another user's job must survive");
verify(fileStorage).deleteFileAsSystem("alice-file");
verify(fileStorage, never()).deleteFileAsSystem("bob-file");
}
@Test
void forcedCleanupIsIdempotent() {
String jobId = "job-to-clean";
taskManager.createTask(jobId);
taskManager.registerInputFile(jobId, "input-file");
completeWithFile(jobId, "result-file");
taskManager.cleanupFinishedJobsNow(id -> true);
TaskManager.CleanupSummary second = taskManager.cleanupFinishedJobsNow(id -> true);
assertEquals(0, second.jobsRemoved());
assertEquals(0, second.filesDeleted());
}
@Test
void forcedCleanupRunsEvenWhenTheBackplaneOwnsScheduledExpiry() {
// The scheduled sweep defers to the backplane TTL in cluster mode, but an explicit
// request to release this node's storage still has to do something.
when(clusterBackplane.shouldRunLocalCleanup()).thenReturn(false);
String jobId = "clustered-job";
taskManager.createTask(jobId);
completeWithFile(jobId, "result-file");
taskManager.cleanupOldJobs();
assertTrue(jobResults().containsKey(jobId));
TaskManager.CleanupSummary summary = taskManager.cleanupFinishedJobsNow(id -> true);
assertEquals(1, summary.jobsRemoved());
assertFalse(jobResults().containsKey(jobId));
}
@Test
void cleanupCountsOnlyFilesThatWereActuallyDeleted() {
// A file already gone (a retry deleted it, say) must not be counted as freed.
String jobId = "partially-cleaned";
taskManager.createTask(jobId);
taskManager.registerInputFile(jobId, "already-gone");
completeWithFile(jobId, "result-file");
when(fileStorage.deleteFileAsSystem("already-gone")).thenReturn(false);
TaskManager.CleanupSummary summary = taskManager.cleanupFinishedJobsNow(id -> true);
assertEquals(1, summary.filesDeleted());
}
@Test
void cleanupSurvivesAFileStorageFailure() {
String jobId = "job-with-unhappy-storage";
taskManager.createTask(jobId);
taskManager.registerInputFile(jobId, "input-file");
completeWithFile(jobId, "result-file");
when(fileStorage.deleteFileAsSystem("result-file"))
.thenThrow(new RuntimeException("disk on fire"));
TaskManager.CleanupSummary summary = taskManager.cleanupFinishedJobsNow(id -> true);
// The job is still released and the remaining file still deleted.
assertEquals(1, summary.jobsRemoved());
assertEquals(1, summary.filesDeleted());
assertFalse(jobResults().containsKey(jobId));
verify(fileStorage).deleteFileAsSystem("input-file");
}
@Test
void registerInputFileRejectsAnUnknownJob() {
assertFalse(taskManager.registerInputFile("no-such-job", "input-file"));
}
@Test
void registerInputFileIgnoresDuplicatesAndBlanks() {
String jobId = "dedupe-job";
taskManager.createTask(jobId);
taskManager.registerInputFile(jobId, "input-file");
taskManager.registerInputFile(jobId, "input-file");
taskManager.registerInputFile(jobId, " ");
taskManager.registerInputFile(jobId, null);
List<String> inputFileIds = taskManager.getJobResult(jobId).getInputFileIds();
assertEquals(List.of("input-file"), inputFileIds);
}
@Test
void multiFileResultsAndTheInputCopyAreAllDeleted() {
String jobId = "split-job";
taskManager.createTask(jobId);
taskManager.registerInputFile(jobId, "input-file");
JobResult result = taskManager.getJobResult(jobId);
result.completeWithFiles(
List.of(
ResultFile.builder()
.fileId("page-1")
.fileName("1.pdf")
.contentType(MediaType.APPLICATION_PDF_VALUE)
.fileSize(10L)
.build(),
ResultFile.builder()
.fileId("page-2")
.fileName("2.pdf")
.contentType(MediaType.APPLICATION_PDF_VALUE)
.fileSize(10L)
.build()));
TaskManager.CleanupSummary summary = taskManager.cleanupFinishedJobsNow(id -> true);
assertEquals(3, summary.filesDeleted());
verify(fileStorage).deleteFileAsSystem("page-1");
verify(fileStorage).deleteFileAsSystem("page-2");
verify(fileStorage).deleteFileAsSystem("input-file");
}
}
@@ -290,7 +290,8 @@ class TaskManagerMoreTest {
ReflectionTestUtils.setField(job, "complete", true);
ReflectionTestUtils.setField(job, "completedAt", LocalDateTime.now().minusHours(2));
when(fileStorage.deleteFile("doomed")).thenThrow(new RuntimeException("locked"));
when(fileStorage.deleteFileAsSystem("doomed"))
.thenThrow(new RuntimeException("locked"));
// Must not propagate; the job is still removed afterwards.
taskManager.cleanupOldJobs();
@@ -258,7 +258,7 @@ class TaskManagerTest {
.build();
ReflectionTestUtils.setField(oldJob, "resultFiles", java.util.List.of(resultFile));
when(fileStorage.deleteFile("file-id")).thenReturn(true);
when(fileStorage.deleteFileAsSystem("file-id")).thenReturn(true);
// Obtain access to the private jobResults map
Map<String, JobResult> jobResultsMap =
@@ -281,7 +281,7 @@ class TaskManagerTest {
assertFalse(jobResultsMap.containsKey(oldJobId));
assertTrue(jobResultsMap.containsKey(recentJobId));
assertTrue(jobResultsMap.containsKey(activeJobId));
verify(fileStorage).deleteFile("file-id");
verify(fileStorage).deleteFileAsSystem("file-id");
}
@Test
@@ -308,7 +308,7 @@ class TaskManagerTest {
// Assert: nothing was removed locally, and no jobStore.delete was issued.
assertTrue(jobResultsMap.containsKey(oldJobId));
verify(jobStore, never()).delete(anyString());
verify(fileStorage, never()).deleteFile(anyString());
verify(fileStorage, never()).deleteFileAsSystem(anyString());
}
@Test
@@ -12,6 +12,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -213,6 +214,35 @@ public class JobController {
}
}
/**
* Self-service counterpart to the admin-only {@code POST /api/v1/admin/job/cleanup}: that one
* sweeps every user's jobs and needs ROLE_ADMIN, this one releases only the caller's own and so
* is safe for any authenticated user. Both run the same sweep inside {@link TaskManager}.
*/
@PostMapping("/jobs/cleanup")
@Operation(
summary = "Release finished jobs and their stored files now",
description =
"Force-expires this node's finished jobs instead of waiting out the retention"
+ " window, deleting their result files and the persistent copies made of"
+ " their inputs. Only jobs the caller may access are touched, and jobs"
+ " still running are left alone. Admins can sweep every user's jobs"
+ " with POST /api/v1/admin/job/cleanup?force=true.")
public ResponseEntity<?> cleanupFinishedJobs() {
TaskManager.CleanupSummary summary =
taskManager.cleanupFinishedJobsNow(this::validateJobAccess);
log.info(
"On-demand job cleanup removed {} job(s) and {} file(s), retained {} job(s)",
summary.jobsRemoved(),
summary.filesDeleted(),
summary.jobsRetained());
return ResponseEntity.ok(
Map.of(
"jobsRemoved", summary.jobsRemoved(),
"filesDeleted", summary.filesDeleted(),
"jobsRetained", summary.jobsRetained()));
}
@GetMapping("/job/{jobId}/result/files")
@Operation(summary = "Get job result files")
public ResponseEntity<?> getJobFiles(@PathVariable("jobId") String jobId) {
@@ -61,7 +61,10 @@ class ToolIODeclarationCoverageTest {
// signing tool itself is /api/v1/security/cert-sign, which is declared.
"/api/v1/security/cert-sign/sessions",
"/api/v1/security/cert-sign/validate-certificate",
"/api/v1/security/cert-sign/hardware");
"/api/v1/security/cert-sign/hardware",
// Releases finished jobs and their stored files; server maintenance, takes and
// returns no document.
"/api/v1/general/jobs/cleanup");
private record Scan(Set<String> required, Map<String, ToolIOSpec> declared) {}
@@ -0,0 +1,78 @@
package stirling.software.SPDF.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import stirling.software.common.util.GeneralUtils;
/**
* Lives in core because the python resources it extracts ship in this module.
*
* <p>Callers hand the returned path straight to python3, so the file has to stay readable while
* other requests are extracting it. Re-writing it per call used to break that wherever rename is
* not atomic, such as a 9p or NFS bind mount.
*/
class ExtractScriptConcurrencyTest {
@Test
@DisplayName("concurrent callers always get a readable script")
void concurrentCallersSeeAReadableScript() throws Exception {
int threads = 16;
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(threads);
AtomicInteger unreadable = new AtomicInteger();
AtomicInteger errors = new AtomicInteger();
for (int i = 0; i < threads; i++) {
Thread.ofVirtual()
.start(
() -> {
try {
start.await();
for (int n = 0; n < 25; n++) {
Path script = GeneralUtils.extractScript("png_to_webp.py");
if (!Files.isReadable(script)) {
unreadable.incrementAndGet();
}
}
} catch (Exception e) {
errors.incrementAndGet();
} finally {
done.countDown();
}
});
}
start.countDown();
assertTrue(done.await(60, TimeUnit.SECONDS), "extractScript threads did not finish");
assertEquals(0, errors.get(), "extractScript threw under concurrency");
assertEquals(0, unreadable.get(), "script was missing while another caller held it");
}
@Test
@DisplayName("repeated calls return the same path without rewriting the file")
void repeatedCallsAreStable() throws Exception {
Path first = GeneralUtils.extractScript("png_to_webp.py");
long modified = Files.getLastModifiedTime(first).toMillis();
long size = Files.size(first);
// Any rewrite after this pause lands on a later timestamp, so mtime is a
// reliable signal rather than a same-millisecond coin flip.
Thread.sleep(50);
for (int i = 0; i < 5; i++) {
assertEquals(first, GeneralUtils.extractScript("png_to_webp.py"));
}
assertEquals(modified, Files.getLastModifiedTime(first).toMillis(), "script was rewritten");
assertEquals(size, Files.size(first));
}
}
@@ -5,9 +5,11 @@ import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
import java.util.Map;
import java.util.function.Predicate;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
@@ -442,4 +444,52 @@ class JobControllerTest {
assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
verify(fileStorage, never()).getFileSize(eq(fileId));
}
@Test
void testCleanupFinishedJobs_ReportsWhatWasReleased() {
when(taskManager.cleanupFinishedJobsNow(any()))
.thenReturn(new TaskManager.CleanupSummary(2, 5, 1));
ResponseEntity<?> response = controller.cleanupFinishedJobs();
assertEquals(HttpStatus.OK, response.getStatusCode());
@SuppressWarnings("unchecked")
Map<String, Object> body = (Map<String, Object>) response.getBody();
assertEquals(2, body.get("jobsRemoved"));
assertEquals(5, body.get("filesDeleted"));
assertEquals(1, body.get("jobsRetained"));
}
@Test
void testCleanupFinishedJobs_OnlySweepsJobsTheCallerOwns() {
ReflectionTestUtils.setField(controller, "jobOwnershipService", jobOwnershipService);
when(jobOwnershipService.validateJobAccess("me:job")).thenReturn(true);
when(jobOwnershipService.validateJobAccess("someone-else:job"))
.thenThrow(new SecurityException("not yours"));
when(taskManager.cleanupFinishedJobsNow(any()))
.thenReturn(new TaskManager.CleanupSummary(1, 1, 1));
controller.cleanupFinishedJobs();
@SuppressWarnings("unchecked")
ArgumentCaptor<Predicate<String>> filter = ArgumentCaptor.forClass(Predicate.class);
verify(taskManager).cleanupFinishedJobsNow(filter.capture());
assertTrue(filter.getValue().test("me:job"));
assertFalse(
filter.getValue().test("someone-else:job"),
"A job the caller cannot access must be left in place");
}
@Test
void testCleanupFinishedJobs_SweepsEverythingWhenSecurityIsDisabled() {
when(taskManager.cleanupFinishedJobsNow(any()))
.thenReturn(new TaskManager.CleanupSummary(3, 3, 0));
controller.cleanupFinishedJobs();
@SuppressWarnings("unchecked")
ArgumentCaptor<Predicate<String>> filter = ArgumentCaptor.forClass(Predicate.class);
verify(taskManager).cleanupFinishedJobsNow(filter.capture());
assertTrue(filter.getValue().test("any-job-id"));
}
}
@@ -7,6 +7,7 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
@@ -66,28 +67,40 @@ public class AdminJobController {
}
/**
* Manually trigger cleanup of old jobs (admin only)
* Manually trigger cleanup of old jobs (admin only). Covers every user's jobs, unlike the
* self-service {@code POST /api/v1/general/jobs/cleanup}, which only releases the caller's own.
*
* @return A response indicating how many jobs were cleaned up
* @param force Ignore the retention window and release every finished job now, rather than only
* those already past it
* @return A response indicating how many jobs and files were cleaned up
*/
@PostMapping("/job/cleanup")
@Operation(summary = "Cleanup old jobs")
@Operation(
summary = "Cleanup old jobs",
description =
"Runs the job retention sweep now across all users. With force=true the"
+ " retention window is ignored and every finished job is released"
+ " immediately.")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> cleanupOldJobs() {
int beforeCount = taskManager.getJobStats().getTotalJobs();
taskManager.cleanupOldJobs();
int afterCount = taskManager.getJobStats().getTotalJobs();
int removedCount = beforeCount - afterCount;
public ResponseEntity<?> cleanupOldJobs(
@RequestParam(name = "force", defaultValue = "false") boolean force) {
TaskManager.CleanupSummary summary =
force
? taskManager.cleanupFinishedJobsNow(jobId -> true)
: taskManager.cleanupOldJobs();
log.info(
"Admin triggered job cleanup: removed {} jobs, {} remaining",
removedCount,
afterCount);
"Admin triggered job cleanup (force={}): removed {} jobs and {} files, {} remaining",
force,
summary.jobsRemoved(),
summary.filesDeleted(),
summary.jobsRetained());
return ResponseEntity.ok(
Map.of(
"message", "Cleanup complete",
"removedJobs", removedCount,
"remainingJobs", afterCount));
"removedJobs", summary.jobsRemoved(),
"filesDeleted", summary.filesDeleted(),
"remainingJobs", summary.jobsRetained()));
}
}
@@ -0,0 +1,89 @@
package stirling.software.proprietary.controller.api;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import stirling.software.common.service.JobQueue;
import stirling.software.common.service.TaskManager;
/**
* The admin sweep covers every user's jobs, so it must stay distinct from the self-service endpoint
* on JobController - and both must run the same TaskManager sweep rather than each growing their
* own cleanup logic.
*/
class AdminJobControllerTest {
@Mock private TaskManager taskManager;
@Mock private JobQueue jobQueue;
@InjectMocks private AdminJobController controller;
private AutoCloseable closeable;
@BeforeEach
void setUp() {
closeable = MockitoAnnotations.openMocks(this);
}
@Test
void cleanupWithoutForceRunsTheRetentionSweep() throws Exception {
when(taskManager.cleanupOldJobs()).thenReturn(new TaskManager.CleanupSummary(2, 4, 3));
ResponseEntity<?> response = controller.cleanupOldJobs(false);
assertEquals(HttpStatus.OK, response.getStatusCode());
@SuppressWarnings("unchecked")
Map<String, Object> body = (Map<String, Object>) response.getBody();
assertEquals(2, body.get("removedJobs"));
assertEquals(4, body.get("filesDeleted"));
assertEquals(3, body.get("remainingJobs"));
verify(taskManager).cleanupOldJobs();
verify(taskManager, never()).cleanupFinishedJobsNow(any());
closeable.close();
}
@Test
void cleanupWithForceIgnoresTheRetentionWindow() throws Exception {
when(taskManager.cleanupFinishedJobsNow(any()))
.thenReturn(new TaskManager.CleanupSummary(5, 9, 0));
ResponseEntity<?> response = controller.cleanupOldJobs(true);
assertEquals(HttpStatus.OK, response.getStatusCode());
@SuppressWarnings("unchecked")
Map<String, Object> body = (Map<String, Object>) response.getBody();
assertEquals(5, body.get("removedJobs"));
assertEquals(9, body.get("filesDeleted"));
verify(taskManager).cleanupFinishedJobsNow(any());
verify(taskManager, never()).cleanupOldJobs();
closeable.close();
}
@Test
void forcedAdminCleanupSweepsEveryUsersJobs() throws Exception {
when(taskManager.cleanupFinishedJobsNow(any()))
.thenReturn(new TaskManager.CleanupSummary(1, 1, 0));
controller.cleanupOldJobs(true);
@SuppressWarnings("unchecked")
org.mockito.ArgumentCaptor<java.util.function.Predicate<String>> filter =
org.mockito.ArgumentCaptor.forClass(java.util.function.Predicate.class);
verify(taskManager).cleanupFinishedJobsNow(filter.capture());
// Unlike the self-service endpoint, the admin sweep is not scoped to one caller.
assertTrue(filter.getValue().test("alice:job"));
assertTrue(filter.getValue().test("bob:job"));
closeable.close();
}
}
+3
View File
@@ -149,6 +149,9 @@ class ToolDiscovery:
"/api/v1/misc/add-image",
"/api/v1/misc/add-attachments",
"/api/v1/general/overlay-pdfs",
# 5. Server maintenance, not a document operation: releases finished jobs and
# their stored files. Nothing an edit agent should ever call on its own.
"/api/v1/general/jobs/cleanup",
)
def _is_excluded(self, path: str) -> bool:
+1
View File
@@ -3,6 +3,7 @@
# Run either directly, e.g. `python -m behave features/enterprise`.
exclude_re = features/(enterprise|multinode)
tags = ~@manual
~@nightly
[behave.formatters]
# Registers the html report formatter (behave-html-formatter) used by run-multinode-regression.sh.
@@ -15,6 +15,7 @@ Feature: Analysis API Endpoints
Given I generate a PDF file as "fileInput"
And the pdf contains <pages> pages
When I send the API request to the endpoint "/api/v1/analysis/page-count"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/json"
And the response file should have size greater than 0
@@ -35,6 +36,7 @@ Feature: Analysis API Endpoints
Given I generate a PDF file as "fileInput"
And the pdf contains 4 pages
When I send the API request to the endpoint "/api/v1/analysis/basic-info"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/json"
And the response file should have size greater than 0
@@ -66,6 +68,7 @@ Feature: Analysis API Endpoints
Given I generate a PDF file as "fileInput"
And the pdf contains 2 pages
When I send the API request to the endpoint "/api/v1/analysis/document-properties"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/json"
And the response file should have size greater than 0
@@ -104,6 +107,7 @@ Feature: Analysis API Endpoints
Given I generate a PDF file as "fileInput"
And the pdf contains 2 pages with random text
When I send the API request to the endpoint "/api/v1/analysis/page-dimensions"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/json"
And the response file should have size greater than 0
@@ -118,6 +122,7 @@ Feature: Analysis API Endpoints
Given I generate a PDF file as "fileInput"
And the pdf contains 2 pages
When I send the API request to the endpoint "/api/v1/analysis/form-fields"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/json"
And the response file should have size greater than 0
@@ -141,6 +146,7 @@ Feature: Analysis API Endpoints
Given I generate a PDF file as "fileInput"
And the pdf contains 2 pages
When I send the API request to the endpoint "/api/v1/analysis/annotation-info"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/json"
And the response file should have size greater than 0
@@ -164,6 +170,7 @@ Feature: Analysis API Endpoints
Given I generate a PDF file as "fileInput"
And the pdf contains 2 pages
When I send the API request to the endpoint "/api/v1/analysis/font-info"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/json"
And the response file should have size greater than 0
@@ -187,6 +194,7 @@ Feature: Analysis API Endpoints
Given I generate a PDF file as "fileInput"
And the pdf contains 2 pages
When I send the API request to the endpoint "/api/v1/analysis/security-info"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/json"
And the response file should have size greater than 0
@@ -0,0 +1,143 @@
@jobs
Feature: Asynchronous job API
# Any tool endpoint accepts ?async=true and returns a jobId instead of the file.
# No parallel step here: every step after the submit depends on that one jobId.
@positive
Scenario: An async job runs to completion and returns its result
Given I generate a PDF file as "fileInput"
And the pdf contains 3 pages
And the request data includes
| parameter | value |
| angle | 90 |
When I send the API request to the endpoint "/api/v1/general/rotate-pdf?async=true"
Then the response status code should be 200
And the response JSON field "async" should be true
When I store the job id from the response
And I wait for the job to complete
Then the job should be reported complete
When I request the job result
Then the response status code should be 200
And the response content type should be "application/pdf"
And the response PDF should contain 3 pages
@positive
Scenario: Async job results are listed and downloadable as individual files
Given I generate a PDF file as "fileInput"
And the pdf contains 4 pages
And the request data includes
| parameter | value |
| pageNumbers | all |
When I send the API request to the endpoint "/api/v1/general/split-pages?async=true"
Then the response status code should be 200
When I store the job id from the response
And I wait for the job to complete
And I request the job result file list
Then the response status code should be 200
And the job result file list should contain at least 4 file(s)
When I request the first job result file metadata
Then the response status code should be 200
And the response JSON field "fileName" should not be empty
When I download the first job result file
Then the response status code should be 200
And the response file should have size greater than 100
@negative
Scenario: Cancelling an already-finished job is rejected
Given I generate a PDF file as "fileInput"
And the pdf contains 2 pages
And the request data includes
| parameter | value |
| angle | 180 |
When I send the API request to the endpoint "/api/v1/general/rotate-pdf?async=true"
And I store the job id from the response
And I wait for the job to complete
And I cancel the job
Then the response status code should be 400
@negative
Scenario: Polling a job id the caller does not own is forbidden
When I send a GET request to "/api/v1/general/job/does-not-exist-1234"
Then the response status code should be 403
@negative
Scenario: Downloading an unknown file id is rejected
When I send a GET request to "/api/v1/general/files/does-not-exist-1234"
Then the response status code should be 404
# An async submit persists a copy of the upload and its results under the server's
# file store. Those outlive the request by design, so the only proof they are not a
# leak is that cleanup actually removes them. These scenarios exercise that, and
# environment.after_all sweeps the rest so the post-run temp-file diff stays honest.
@positive @cleanup
Scenario: Downloading a result does not consume it
Given I generate a PDF file as "fileInput"
And the pdf contains 2 pages
And the request data includes
| parameter | value |
| angle | 90 |
When I send the API request to the endpoint "/api/v1/general/rotate-pdf?async=true"
And I store the job id from the response
And I wait for the job to complete
And I request the job result file list
Then the response status code should be 200
When I download the first job result file
Then the response status code should be 200
And the response file should have size greater than 100
# A download is a read, not a take: the file survives for a retry or a second client.
And the job result file should still be downloadable
@positive @cleanup
Scenario: Cleanup releases a finished job and deletes its stored files
Given I generate a PDF file as "fileInput"
And the pdf contains 3 pages
And the request data includes
| parameter | value |
| pageNumbers | all |
When I send the API request to the endpoint "/api/v1/general/split-pages?async=true"
And I store the job id from the response
And I wait for the job to complete
And I request the job result file list
Then the response status code should be 200
And the job result file list should contain at least 3 file(s)
When I trigger the async job cleanup
Then the response status code should be 200
# 3 split results plus the persisted copy of the upload.
And the cleanup should report at least 1 job(s) removed
And the cleanup should report at least 4 file(s) deleted
And the job should no longer exist
And the job result file should no longer be downloadable
@positive @cleanup
Scenario: A second cleanup finds nothing left behind
Given I generate a PDF file as "fileInput"
And the pdf contains 2 pages
And the request data includes
| parameter | value |
| angle | 180 |
When I send the API request to the endpoint "/api/v1/general/rotate-pdf?async=true"
And I store the job id from the response
And I wait for the job to complete
And I trigger the async job cleanup
Then the response status code should be 200
And the cleanup should report at least 1 job(s) removed
When I trigger the async job cleanup
Then the response status code should be 200
And the cleanup should report nothing left to remove
@@ -7,6 +7,7 @@ Feature: Attachments API Validation
And the pdf contains 2 pages with random text
And I also generate a PDF file as "attachments"
When I send the API request to the endpoint "/api/v1/misc/add-attachments"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/pdf"
And the response file should have size greater than 0
@@ -30,6 +31,7 @@ Feature: Attachments API Validation
And the pdf contains 2 pages
And the pdf has an attachment named "test_doc.txt"
When I send the API request to the endpoint "/api/v1/misc/list-attachments"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/json"
And the response file should have size greater than 0
@@ -49,6 +51,7 @@ Feature: Attachments API Validation
And the pdf contains 2 pages
And the pdf has an attachment named "report.txt"
When I send the API request to the endpoint "/api/v1/misc/extract-attachments"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response file should have size greater than 0
@@ -62,6 +65,7 @@ Feature: Attachments API Validation
| attachmentName | original.txt |
| newName | renamed.txt |
When I send the API request to the endpoint "/api/v1/misc/rename-attachment"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/pdf"
And the response file should have size greater than 0
@@ -76,6 +80,7 @@ Feature: Attachments API Validation
| parameter | value |
| attachmentName | to_delete.txt |
When I send the API request to the endpoint "/api/v1/misc/delete-attachment"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/pdf"
And the response file should have size greater than 0
@@ -7,6 +7,7 @@ Feature: Bookmarks and Chapter Splitting API Validation
And the pdf contains 3 pages with random text
And the pdf has bookmarks
When I send the API request to the endpoint "/api/v1/general/extract-bookmarks"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/json"
And the response file should have size greater than 0
@@ -31,6 +32,7 @@ Feature: Bookmarks and Chapter Splitting API Validation
| includeMetadata | false |
| allowDuplicates | false |
When I send the API request to the endpoint "/api/v1/general/split-pdf-by-chapters"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response file should have size greater than 0
@@ -0,0 +1,53 @@
@nightly @convert
Feature: Heavy conversion endpoints
# Too slow for every PR: these shell out to LibreOffice, Calibre or Ghostscript.
# behave.ini excludes @nightly; the nightly job opts back in with --tags=@nightly.
@pdf-to-xlsx @positive
Scenario: Convert a PDF containing tables to XLSX
Given I use an example file at "exampleFiles/tables.pdf" as parameter "fileInput"
When I send the API request to the endpoint "/api/v1/convert/pdf/xlsx"
And this operation is run 3 times in parallel
Then the response status code should be 200
And the response file should have size greater than 1000
And the response file should have extension ".xlsx"
@pdf-to-xlsx @positive
Scenario: A PDF with no tables converts without producing a spreadsheet
Given I generate a PDF file as "fileInput"
And the pdf contains 2 pages with random text
When I send the API request to the endpoint "/api/v1/convert/pdf/xlsx"
Then the response status code should be 204
@text-editor @positive
Scenario: text-editor metadata describes the document for the editor
Given I use an example file at "exampleFiles/tables.pdf" as parameter "fileInput"
When I send the API request to the endpoint "/api/v1/convert/pdf/text-editor/metadata"
And this operation is run 3 times in parallel
Then the response status code should be 200
And the response content type should be "application/json"
And the response JSON field "fonts" should not be empty
@vector @negative
Scenario: vector conversion rejects an input format Ghostscript cannot read
Given I generate an SVG file as "fileInput"
When I send the API request to the endpoint "/api/v1/convert/vector/pdf"
Then the response status code should be 400
And the response JSON error should contain "Unsupported"
@ebook @positive
Scenario: An EPUB produced by Stirling converts back to PDF
Given I generate a PDF file as "fileInput"
And the pdf contains 3 pages with random text
And the request data includes
| parameter | value |
| outputFormat | EPUB |
When I send the API request to the endpoint "/api/v1/convert/pdf/epub"
And this operation is run 3 times in parallel
Then the response status code should be 200
And the response file should have size greater than 200
+60 -1
View File
@@ -1,8 +1,13 @@
import os
import subprocess
import sys
import requests
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "steps"))
import job_support # noqa: E402
import parallel_support # noqa: E402
_BASE_URL = "http://localhost:8080"
_CONTAINER_NAME = os.environ.get("TEST_CONTAINER_NAME", "")
_REPORT_DIR = os.environ.get("TEST_REPORT_DIR", "")
@@ -142,8 +147,17 @@ def before_all(context):
def before_scenario(context, scenario):
"""Reset all per-scenario state before each scenario runs."""
# Skip scenarios that require JWT Bearer auth when it is not functional.
scenario_tags = set(scenario.effective_tags)
# Concurrency is opted into by a step in the feature, never by configuration.
context.parallel_repeat = 1
context.parallel_decoy = False
context.parallel_validated = False
context.parallel_ran_at = 0
context.parallel_request = None
context.parallel_get = None
# Skip scenarios that require JWT Bearer auth when it is not functional.
if _JWT_DEPENDENT_TAGS & scenario_tags and not context.jwt_available:
scenario.skip(
"JWT Bearer authentication not available in this environment (V2 disabled). "
@@ -217,3 +231,48 @@ def after_scenario(context, scenario):
context.jwt_token = None
context.original_jwt_token = None
context._status_ok = False
context.parallel_request = None
context.parallel_get = None
def _cleanup_async_job_files():
"""Release every async job result the run left on the server.
An async submit persists a copy of the upload plus its results, and both are held
for the job retention window (30 minutes by default) - far longer than a test run.
The regression check that diffs the container filesystem before and after this suite
would otherwise flag them as leaked temp files. Sweeping them here keeps that check
strict: anything it still reports afterwards is a genuine leak.
"""
try:
response = job_support.trigger_cleanup()
except Exception as exc:
print(f"\n[CLEANUP] Async job cleanup request failed: {exc}")
return
if response.status_code == 404:
print(
"\n[CLEANUP] Async job cleanup endpoint not available on this build; "
"async job files will age out on their own."
)
return
if response.status_code != 200:
print(
f"\n[CLEANUP] Async job cleanup returned {response.status_code}: "
f"{response.text[:200]}"
)
return
try:
summary = response.json()
except ValueError:
print("\n[CLEANUP] Async job cleanup returned a non-JSON body")
return
print(
f"\n[CLEANUP] Released {summary.get('jobsRemoved', '?')} async job(s) and "
f"{summary.get('filesDeleted', '?')} stored file(s); "
f"{summary.get('jobsRetained', '?')} retained."
)
def after_all(context):
_cleanup_async_job_files()
parallel_support.print_summary()
@@ -10,6 +10,7 @@ Feature: API Validation
| parameter | value |
| password | password123 |
When I send the API request to the endpoint "/api/v1/security/remove-password"
And this operation is run 5 times in parallel
Then the response content type should be "application/pdf"
And the response file should have size greater than 0
And the response PDF is not passworded
@@ -31,6 +32,7 @@ Feature: API Validation
Scenario: Get info
Given I generate a PDF file as "fileInput"
When I send the API request to the endpoint "/api/v1/security/get-info-on-pdf"
And this operation is run 5 times in parallel
Then the response content type should be "application/json"
And the response file should have size greater than 100
And the response status code should be 200
@@ -43,6 +45,7 @@ Feature: API Validation
| parameter | value |
| password | password123 |
When I send the API request to the endpoint "/api/v1/security/add-password"
And this operation is run 5 times in parallel
Then the response content type should be "application/pdf"
And the response file should have size greater than 100
And the response PDF is passworded
@@ -81,6 +84,7 @@ Feature: API Validation
| alphabet | roman |
| customColor | #d3d3d3 |
When I send the API request to the endpoint "/api/v1/security/add-watermark"
And this operation is run 5 times in parallel
Then the response content type should be "application/pdf"
And the response file should have size greater than 100
And the response status code should be 200
@@ -94,6 +98,7 @@ Feature: API Validation
| threshold | 90 |
| whitePercent | 99.9 |
When I send the API request to the endpoint "/api/v1/misc/remove-blanks"
And this operation is run 5 times in parallel
Then the response content type should be "application/octet-stream"
And the response file should have extension ".zip"
And the response ZIP should contain 1 files
@@ -106,6 +111,7 @@ Feature: API Validation
| parameter | value |
| flattenOnlyForms | false |
When I send the API request to the endpoint "/api/v1/misc/flatten"
And this operation is run 5 times in parallel
Then the response content type should be "application/pdf"
And the response file should have size greater than 0
And the response status code should be 200
@@ -121,6 +127,7 @@ Feature: API Validation
| keywords | sample, test |
| producer | Test Producer |
When I send the API request to the endpoint "/api/v1/misc/update-metadata"
And this operation is run 5 times in parallel
Then the response content type should be "application/pdf"
And the response file should have size greater than 0
And the response PDF metadata should include "Author" as "John Doe"
@@ -174,6 +174,7 @@ Feature: API Validation
| dpi | 300 |
| imageFormat | <format> |
When I send the API request to the endpoint "/api/v1/convert/pdf/img"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response file should have size greater than 100
And the response file should have extension ".zip"
@@ -231,6 +232,7 @@ Feature: API Validation
Given I generate a PDF file as "fileInput"
And the pdf contains 3 pages with random text
When I send the API request to the endpoint "/api/v1/convert/pdf/markdown"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response file should have size greater than 100
And the response file should have extension ".md"
@@ -244,6 +246,7 @@ Feature: API Validation
| outputFormat | csv |
| pageNumbers | all |
When I send the API request to the endpoint "/api/v1/convert/pdf/csv"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response file should have size greater than 200
And the response file should have extension ".zip"
+6
View File
@@ -18,6 +18,7 @@ Feature: Filter API Endpoints
| pageCount | <pageCount> |
| comparator | <comparator> |
When I send the API request to the endpoint "/api/v1/filter/filter-page-count"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/pdf"
And the response file should have size greater than 0
@@ -59,6 +60,7 @@ Feature: Filter API Endpoints
| fileSize | <fileSize> |
| comparator | <comparator> |
When I send the API request to the endpoint "/api/v1/filter/filter-file-size"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/pdf"
And the response file should have size greater than 0
@@ -93,6 +95,7 @@ Feature: Filter API Endpoints
| rotation | 0 |
| comparator | Equal |
When I send the API request to the endpoint "/api/v1/filter/filter-page-rotation"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/pdf"
And the response file should have size greater than 0
@@ -170,6 +173,7 @@ Feature: Filter API Endpoints
| standardPageSize | LETTER |
| comparator | Equal |
When I send the API request to the endpoint "/api/v1/filter/filter-page-size"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/pdf"
And the response file should have size greater than 0
@@ -242,6 +246,7 @@ Feature: Filter API Endpoints
| text | FINDME |
| pageNumbers | all |
When I send the API request to the endpoint "/api/v1/filter/filter-contains-text"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/pdf"
And the response file should have size greater than 0
@@ -281,6 +286,7 @@ Feature: Filter API Endpoints
| parameter | value |
| pageNumbers | all |
When I send the API request to the endpoint "/api/v1/filter/filter-contains-image"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/pdf"
And the response file should have size greater than 0
@@ -8,6 +8,7 @@ Feature: Advanced Forms API Validation (JSON data parts)
And the pdf has form fields
And the request includes a JSON part "data" with content "{}"
When I send the API request to the endpoint "/api/v1/form/fill"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/pdf"
And the response file should have size greater than 0
+35
View File
@@ -6,6 +6,7 @@ Feature: Forms API Validation
Given I generate a PDF file as "file"
And the pdf contains 2 pages
When I send the API request to the endpoint "/api/v1/form/fields"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/json"
And the response file should have size greater than 0
@@ -24,6 +25,7 @@ Feature: Forms API Validation
Given I generate a PDF file as "file"
And the pdf contains 2 pages
When I send the API request to the endpoint "/api/v1/form/fields-with-coordinates"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/json"
And the response file should have size greater than 0
@@ -67,6 +69,7 @@ Feature: Forms API Validation
Given I generate a PDF file as "file"
And the pdf contains 2 pages
When I send the API request to the endpoint "/api/v1/form/modify-fields"
And this operation is run 5 times in parallel
Then the response status code should be 400
@modify-fields @negative
@@ -81,6 +84,7 @@ Feature: Forms API Validation
Given I generate a PDF file as "file"
And the pdf contains 2 pages
When I send the API request to the endpoint "/api/v1/form/delete-fields"
And this operation is run 5 times in parallel
Then the response status code should be 400
@delete-fields @negative
@@ -89,3 +93,34 @@ Feature: Forms API Validation
And the pdf contains 4 pages
When I send the API request to the endpoint "/api/v1/form/delete-fields"
Then the response status code should be 400
@extract-csv @positive
Scenario: extract-csv returns CSV for a form PDF
Given I generate a PDF file as "file"
And the pdf contains 2 pages
And the pdf has form fields
When I send the API request to the endpoint "/api/v1/form/extract-csv"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "text/csv"
@extract-xlsx @positive
Scenario: extract-xlsx returns a spreadsheet for a form PDF
Given I generate a PDF file as "file"
And the pdf contains 2 pages
And the pdf has form fields
When I send the API request to the endpoint "/api/v1/form/extract-xlsx"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response file should have size greater than 200
@extract-csv @negative
Scenario: extract-csv rejects a upload sent under the wrong part name
Given I generate a PDF file as "fileInput"
And the pdf contains 2 pages
When I send the API request to the endpoint "/api/v1/form/extract-csv"
Then the response status code should be 400
And the response JSON error should contain "file"
@@ -12,6 +12,7 @@ Feature: API Validation
| verticalDivisions | <verticalDivisions> |
| merge | true |
When I send the API request to the endpoint "/api/v1/general/split-pdf-by-sections"
And this operation is run 5 times in parallel
Then the response content type should be "application/pdf"
And the response file should have size greater than 200
And the response status code should be 200
@@ -34,6 +35,7 @@ Feature: API Validation
| fileInput | fileInput |
| pageNumbers | <pageNumbers> |
When I send the API request to the endpoint "/api/v1/general/split-pages"
And this operation is run 5 times in parallel
Then the response content type should be "application/octet-stream"
And the response status code should be 200
And the response file should have size greater than 200
@@ -57,6 +59,7 @@ Feature: API Validation
| splitType | <splitType> |
| splitValue | <splitValue> |
When I send the API request to the endpoint "/api/v1/general/split-by-size-or-count"
And this operation is run 5 times in parallel
Then the response content type should be "application/octet-stream"
And the response status code should be 200
And the response file should have size greater than 200
@@ -10,6 +10,7 @@ Feature: General PDF Operations API Validation
| parameter | value |
| angle | <angle> |
When I send the API request to the endpoint "/api/v1/general/rotate-pdf"
And this operation is run 5 times in parallel
Then the response content type should be "application/pdf"
And the response status code should be 200
And the response file should have size greater than 200
@@ -41,6 +42,7 @@ Feature: General PDF Operations API Validation
| parameter | value |
| pageNumbers | 3 |
When I send the API request to the endpoint "/api/v1/general/remove-pages"
And this operation is run 5 times in parallel
Then the response content type should be "application/pdf"
And the response status code should be 200
And the response file should have size greater than 200
@@ -76,6 +78,7 @@ Feature: General PDF Operations API Validation
| parameter | value |
| customMode | <customMode> |
When I send the API request to the endpoint "/api/v1/general/rearrange-pages"
And this operation is run 5 times in parallel
Then the response content type should be "application/pdf"
And the response status code should be 200
And the response file should have size greater than 200
@@ -103,6 +106,7 @@ Feature: General PDF Operations API Validation
| parameter | value |
| pageSize | <pageSize> |
When I send the API request to the endpoint "/api/v1/general/scale-pages"
And this operation is run 5 times in parallel
Then the response content type should be "application/pdf"
And the response status code should be 200
And the response file should have size greater than 200
@@ -127,6 +131,7 @@ Feature: General PDF Operations API Validation
| width | 50 |
| height | 50 |
When I send the API request to the endpoint "/api/v1/general/crop"
And this operation is run 5 times in parallel
Then the response content type should be "application/pdf"
And the response status code should be 200
And the response file should have size greater than 200
@@ -155,6 +160,7 @@ Feature: General PDF Operations API Validation
Given I generate a PDF file as "fileInput"
And the pdf contains 5 pages
When I send the API request to the endpoint "/api/v1/general/pdf-to-single-page"
And this operation is run 5 times in parallel
Then the response content type should be "application/pdf"
And the response status code should be 200
And the response file should have size greater than 200
@@ -199,6 +205,7 @@ Feature: General PDF Operations API Validation
| parameter | value |
| pagesPerSheet | 9 |
When I send the API request to the endpoint "/api/v1/general/multi-page-layout"
And this operation is run 5 times in parallel
Then the response content type should be "application/pdf"
And the response status code should be 200
And the response file should have size greater than 200
@@ -213,6 +220,7 @@ Feature: General PDF Operations API Validation
| parameter | value |
| pagesPerSheet | 2 |
When I send the API request to the endpoint "/api/v1/general/booklet-imposition"
And this operation is run 5 times in parallel
Then the response content type should be "application/pdf"
And the response status code should be 200
And the response file should have size greater than 200
@@ -249,3 +257,55 @@ Feature: General PDF Operations API Validation
# Then the response content type should be "application/pdf"
# And the response status code should be 200
# And the response file should have size greater than 0
@edit-table-of-contents @positive
Scenario: edit-table-of-contents rewrites the outline
Given I generate a PDF file as "fileInput"
And the pdf contains 3 pages
And the pdf has bookmarks
And the request data includes
| parameter | value |
| bookmarkData | [{"title":"Intro","pageNumber":1,"children":[]}] |
When I send the API request to the endpoint "/api/v1/general/edit-table-of-contents"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/pdf"
And the response PDF should contain 3 pages
@edit-text @positive
Scenario: edit-text applies a find and replace across the document
Given I generate a PDF file as "fileInput"
And the pdf contains 3 pages
And the pdf pages all contain the text "Hello world"
And the request data includes
| parameter | value |
| edits | [{"find":"Hello","replace":"Goodbye"}] |
When I send the API request to the endpoint "/api/v1/general/edit-text"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/pdf"
@edit-text @negative
Scenario: edit-text without any operations returns 400
Given I generate a PDF file as "fileInput"
And the pdf contains 2 pages
When I send the API request to the endpoint "/api/v1/general/edit-text"
Then the response status code should be 400
And the response JSON error should contain "find/replace"
@split-for-poster-print @positive
Scenario: split-for-poster-print tiles each page into an archive
Given I generate a PDF file as "fileInput"
And the pdf contains 2 pages
And the request data includes
| parameter | value |
| horizontalDivisions | 1 |
| verticalDivisions | 1 |
When I send the API request to the endpoint "/api/v1/general/split-for-poster-print"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response file should have size greater than 200
@@ -0,0 +1,134 @@
@info @config
Feature: Config, info and UI-data read APIs
# Cheap read-only JSON endpoints behind the frontend and admin surfaces.
@app-config @positive
Scenario: app-config returns the frontend configuration
When I send a GET request to "/api/v1/config/app-config"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/json"
@endpoints-availability @positive
Scenario: endpoints-availability reports per-endpoint availability
When I send a GET request to "/api/v1/config/endpoints-availability"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/json"
@endpoints-enabled @positive
Scenario: endpoints-enabled reports the status of a named endpoint
When I send a GET request to "/api/v1/config/endpoints-enabled" with parameters
| parameter | value |
| endpoints | merge-pdfs |
Then the response status code should be 200
And the response content type should be "application/json"
@endpoints-enabled @negative
Scenario: endpoints-enabled without the endpoints parameter returns 400
When I send a GET request to "/api/v1/config/endpoints-enabled"
Then the response status code should be 400
@login-disclaimer @positive
Scenario: login-disclaimer returns its configuration
When I send a GET request to "/api/v1/config/login-disclaimer"
Then the response status code should be 200
And the response JSON field "enabled" should be false
@health @positive
Scenario: info health reports the running version
When I send a GET request to "/api/v1/info/health"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response JSON field "status" should equal "UP"
And the response JSON field "version" should not be empty
@metrics @positive
Scenario Outline: metrics endpoints return a numeric aggregate
When I send a GET request to "<endpoint>"
Then the response status code should be 200
And the response content type should be "application/json"
And the response should match the regex "^[0-9.]+$"
Examples:
| endpoint |
| /api/v1/info/load/unique |
| /api/v1/info/requests/unique |
@metrics @positive
Scenario Outline: metrics list endpoints return a JSON list
When I send a GET request to "<endpoint>"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response JSON should be a list
Examples:
| endpoint |
| /api/v1/info/load/all/unique |
| /api/v1/info/requests/all/unique |
@metrics @positive
Scenario: weekly active users reports tracking metadata
When I send a GET request to "/api/v1/info/wau"
Then the response status code should be 200
And the response JSON field "trackingSince" should not be empty
@settings @positive
Scenario: get-endpoints-status returns the endpoint toggle map
When I send a GET request to "/api/v1/settings/get-endpoints-status"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/json"
@ui-data @positive
Scenario Outline: UI data endpoints return JSON for the frontend
When I send a GET request to "<endpoint>"
Then the response status code should be 200
And the response content type should be "application/json"
Examples:
| endpoint |
| /api/v1/ui-data/footer-info |
| /api/v1/ui-data/home |
| /api/v1/ui-data/licenses |
| /api/v1/ui-data/pipeline |
@ui-data @positive
Scenario: OCR UI data lists the installed tesseract languages
When I send a GET request to "/api/v1/ui-data/ocr-pdf"
Then the response status code should be 200
And the response JSON field "languages" should be a list
@ui-data @positive
Scenario: Sign UI data lists the available signature fonts
When I send a GET request to "/api/v1/ui-data/sign"
Then the response status code should be 200
And the response JSON field "fonts" should be a list
@hardware-signing @positive
Scenario: Hardware signing capabilities are reported for the server build
When I send a GET request to "/api/v1/security/cert-sign/hardware/capabilities"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response JSON field "desktop" should be false
@hardware-signing @negative
Scenario: Windows certificate store is rejected outside the desktop app
When I send a GET request to "/api/v1/security/cert-sign/hardware/windows-certificates"
Then the response status code should be 400
And the response JSON error should contain "desktop"
@@ -7,6 +7,7 @@ Feature: Merge and Overlay PDF API Validation
And the pdf contains 2 pages with random text
And I also generate a PDF file as "fileInput"
When I send the API request to the endpoint "/api/v1/general/merge-pdfs"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/pdf"
And the response file should have size greater than 0
@@ -51,6 +52,7 @@ Feature: Merge and Overlay PDF API Validation
| overlayMode | SequentialOverlay |
| overlayPosition | 0 |
When I send the API request to the endpoint "/api/v1/general/overlay-pdfs"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/pdf"
And the response file should have size greater than 0
@@ -48,6 +48,7 @@ Feature: Miscellaneous PDF Operations API Validation
| overrideX | -1 |
| overrideY | -1 |
When I send the API request to the endpoint "/api/v1/misc/add-stamp"
And this operation is run 5 times in parallel
Then the response content type should be "application/pdf"
And the response status code should be 200
And the response file should have size greater than 200
@@ -92,6 +93,7 @@ Feature: Miscellaneous PDF Operations API Validation
| position | 2 |
| fontSize | 14 |
When I send the API request to the endpoint "/api/v1/misc/add-page-numbers"
And this operation is run 5 times in parallel against decoy traffic
Then the response content type should be "application/pdf"
And the response status code should be 200
And the response file should have size greater than 200
@@ -103,6 +105,7 @@ Feature: Miscellaneous PDF Operations API Validation
Given I generate a PDF file as "fileInput"
And the pdf contains 2 pages
When I send the API request to the endpoint "/api/v1/misc/unlock-pdf-forms"
And this operation is run 5 times in parallel
Then the response content type should be "application/pdf"
And the response status code should be 200
And the response file should have size greater than 0
@@ -138,6 +141,7 @@ Feature: Miscellaneous PDF Operations API Validation
| replaceAndInvertOption | HIGH_CONTRAST_COLOR |
| highContrastColorCombination | WHITE_TEXT_ON_BLACK |
When I send the API request to the endpoint "/api/v1/misc/replace-invert-pdf"
And this operation is run 5 times in parallel
Then the response content type should be "application/pdf"
And the response status code should be 200
And the response file should have size greater than 0
@@ -163,6 +167,7 @@ Feature: Miscellaneous PDF Operations API Validation
Given I generate a PDF file as "fileInput"
And the pdf contains 3 pages
When I send the API request to the endpoint "/api/v1/misc/decompress-pdf"
And this operation is run 5 times in parallel
Then the response content type should be "application/pdf"
And the response status code should be 200
And the response file should have size greater than 0
@@ -188,6 +193,7 @@ Feature: Miscellaneous PDF Operations API Validation
| parameter | value |
| useFirstTextAsFallback | true |
When I send the API request to the endpoint "/api/v1/misc/auto-rename"
And this operation is run 5 times in parallel
Then the response content type should be "application/pdf"
And the response status code should be 200
And the response file should have size greater than 0
@@ -212,6 +218,7 @@ Feature: Miscellaneous PDF Operations API Validation
Given I generate a PDF file as "fileInput"
And the pdf contains 2 pages
When I send the API request to the endpoint "/api/v1/misc/show-javascript"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response file should have size greater than 0
@@ -222,3 +229,28 @@ Feature: Miscellaneous PDF Operations API Validation
And the pdf contains 5 pages with random text
When I send the API request to the endpoint "/api/v1/misc/show-javascript"
Then the response status code should be 200
@auto-rotate-pdf @positive
Scenario: auto-rotate-pdf returns a PDF with the page count preserved
Given I generate a PDF file as "fileInput"
And the pdf contains 3 pages with random text
When I send the API request to the endpoint "/api/v1/misc/auto-rotate-pdf"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/pdf"
And the response PDF should contain 3 pages
@add-comments @positive
Scenario: add-comments annotates a page without changing the page count
Given I generate a PDF file as "fileInput"
And the pdf contains 3 pages
And the request data includes
| parameter | value |
| comments | [{"pageNumber":1,"x":100,"y":100,"text":"review me","author":"qa"}] |
When I send the API request to the endpoint "/api/v1/misc/add-comments"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/pdf"
And the response PDF should contain 3 pages
@@ -14,6 +14,7 @@ Feature: Security API Validation
| removeLinks | true |
| removeFonts | false |
When I send the API request to the endpoint "/api/v1/security/sanitize-pdf"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/pdf"
And the response file should have size greater than 0
@@ -54,6 +55,7 @@ Feature: Security API Validation
| wholeWordSearch| true |
| convertPDFToImage | false |
When I send the API request to the endpoint "/api/v1/security/auto-redact"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/pdf"
And the response file should have size greater than 0
@@ -96,6 +98,7 @@ Feature: Security API Validation
| pageNumbers | 2,4 |
| pageRedactionColor | #000000 |
When I send the API request to the endpoint "/api/v1/security/redact"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/pdf"
And the response file should have size greater than 0
@@ -130,6 +133,7 @@ Feature: Security API Validation
Scenario: Verify PDF-A compliance
Given I use an example file at "exampleFiles/pdfa1.pdf" as parameter "fileInput"
When I send the API request to the endpoint "/api/v1/security/verify-pdf"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/json"
And the response file should have size greater than 2
@@ -148,6 +152,7 @@ Feature: Security API Validation
Given I generate a PDF file as "fileInput"
And the pdf contains 2 pages
When I send the API request to the endpoint "/api/v1/security/remove-cert-sign"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/pdf"
And the response file should have size greater than 0
@@ -161,3 +166,35 @@ Feature: Security API Validation
Then the response status code should be 200
And the response content type should be "application/pdf"
And the response file should have size greater than 0
@validate-signature @positive
Scenario: validate-signature reports no signatures on an unsigned PDF
Given I generate a PDF file as "fileInput"
And the pdf contains 3 pages
When I send the API request to the endpoint "/api/v1/security/validate-signature"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response JSON should be a list
@redact-execute @positive
Scenario: redact-execute removes the targeted text
Given I generate a PDF file as "fileInput"
And the pdf contains 3 pages
And the pdf pages all contain the text "Hello world"
And the request data includes
| parameter | value |
| textValues | Hello |
When I send the API request to the endpoint "/api/v1/security/redact-execute"
And this operation is run 5 times in parallel
Then the response status code should be 200
And the response content type should be "application/pdf"
@redact-execute @negative
Scenario: redact-execute without any targets returns 400
Given I generate a PDF file as "fileInput"
And the pdf contains 2 pages
When I send the API request to the endpoint "/api/v1/security/redact-execute"
Then the response status code should be 400
@@ -0,0 +1,171 @@
"""Steps for the async job API. DELETE is a cancel, so it 400s once the job finishes."""
import time
import requests
from behave import then, when
from job_support import API_HEADERS, BASE_URL, trigger_cleanup
POLL_TIMEOUT_SECONDS = 60
@when("I store the job id from the response")
def step_store_job_id(context):
payload = context.response.json()
context.job_id = payload.get("jobId")
assert context.job_id, f"No jobId in async submit response: {payload}"
@when("I wait for the job to complete")
def step_wait_for_job(context):
deadline = time.time() + POLL_TIMEOUT_SECONDS
while time.time() < deadline:
response = requests.get(
f"{BASE_URL}/api/v1/general/job/{context.job_id}",
headers=API_HEADERS, timeout=30,
)
assert response.status_code == 200, (
f"Job status returned {response.status_code}: {response.text}"
)
context.response = response
payload = response.json()
if payload.get("complete"):
context.job_status = payload
return
time.sleep(0.2)
raise AssertionError(
f"Job {context.job_id} did not complete within {POLL_TIMEOUT_SECONDS}s"
)
@when("I request the job result")
def step_request_job_result(context):
context.response = requests.get(
f"{BASE_URL}/api/v1/general/job/{context.job_id}/result",
headers=API_HEADERS, timeout=60,
)
@when("I request the job result file list")
def step_request_job_result_files(context):
context.response = requests.get(
f"{BASE_URL}/api/v1/general/job/{context.job_id}/result/files",
headers=API_HEADERS, timeout=60,
)
files = context.response.json().get("files") or []
context.job_files = files
if files:
context.job_file_id = files[0].get("fileId")
@when("I download the first job result file")
def step_download_job_file(context):
assert getattr(context, "job_file_id", None), "No fileId captured from the result file list"
context.response = requests.get(
f"{BASE_URL}/api/v1/general/files/{context.job_file_id}",
headers=API_HEADERS, timeout=60,
)
@when("I request the first job result file metadata")
def step_job_file_metadata(context):
assert getattr(context, "job_file_id", None), "No fileId captured from the result file list"
context.response = requests.get(
f"{BASE_URL}/api/v1/general/files/{context.job_file_id}/metadata",
headers=API_HEADERS, timeout=60,
)
@when("I cancel the job")
def step_cancel_job(context):
context.response = requests.delete(
f"{BASE_URL}/api/v1/general/job/{context.job_id}",
headers=API_HEADERS, timeout=30,
)
@then("the job result file list should contain at least {count:d} file(s)")
def step_check_job_file_count(context, count):
files = context.response.json().get("files") or []
assert len(files) >= count, f"Expected at least {count} result file(s), got {len(files)}"
@then("the job should be reported complete")
def step_check_job_complete(context):
payload = context.response.json()
assert payload.get("complete") is True, f"Job not complete: {payload}"
assert not payload.get("error"), f"Job reported an error: {payload.get('error')}"
# --- Cleanup: the async job files must actually go away, not just age out ---
@when("I trigger the async job cleanup")
def step_trigger_cleanup(context):
context.response = trigger_cleanup()
try:
context.cleanup_summary = context.response.json()
except ValueError:
context.cleanup_summary = {}
@then("the cleanup should report at least {count:d} job(s) removed")
def step_check_cleanup_jobs(context, count):
removed = context.cleanup_summary.get("jobsRemoved")
assert removed is not None, f"No jobsRemoved in cleanup response: {context.cleanup_summary}"
assert removed >= count, f"Expected at least {count} job(s) removed, got {removed}"
@then("the cleanup should report at least {count:d} file(s) deleted")
def step_check_cleanup_files(context, count):
deleted = context.cleanup_summary.get("filesDeleted")
assert deleted is not None, f"No filesDeleted in cleanup response: {context.cleanup_summary}"
assert deleted >= count, f"Expected at least {count} file(s) deleted, got {deleted}"
@then("the cleanup should report nothing left to remove")
def step_check_cleanup_idempotent(context):
removed = context.cleanup_summary.get("jobsRemoved")
deleted = context.cleanup_summary.get("filesDeleted")
assert removed == 0 and deleted == 0, (
"A repeat cleanup still found work to do, so the first pass did not fully clean up: "
f"{context.cleanup_summary}"
)
@then("the job should no longer exist")
def step_check_job_gone(context):
response = requests.get(
f"{BASE_URL}/api/v1/general/job/{context.job_id}",
headers=API_HEADERS, timeout=30,
)
assert response.status_code == 404, (
f"Job {context.job_id} still exists after cleanup: "
f"{response.status_code} {response.text[:200]}"
)
@then("the job result file should no longer be downloadable")
def step_check_job_file_gone(context):
assert getattr(context, "job_file_id", None), "No fileId captured from the result file list"
response = requests.get(
f"{BASE_URL}/api/v1/general/files/{context.job_file_id}",
headers=API_HEADERS, timeout=30,
)
assert response.status_code == 404, (
f"File {context.job_file_id} still downloadable after cleanup: "
f"{response.status_code} {response.text[:200]}"
)
@then("the job result file should still be downloadable")
def step_check_job_file_still_there(context):
assert getattr(context, "job_file_id", None), "No fileId captured from the result file list"
response = requests.get(
f"{BASE_URL}/api/v1/general/files/{context.job_file_id}",
headers=API_HEADERS, timeout=60,
)
assert response.status_code == 200, (
f"File {context.job_file_id} was not retrievable a second time: "
f"{response.status_code} {response.text[:200]}"
)
assert len(response.content) > 0, "Second download returned an empty body"
@@ -0,0 +1,22 @@
"""Shared helpers for the async job API.
Support module, not a step module: behave execs everything under features/steps as
step definitions, so anything environment.py needs to import has to live apart from
the @when/@then decorators or they would register twice.
"""
import requests
BASE_URL = "http://localhost:8080"
API_HEADERS = {"X-API-KEY": "123456789"}
CLEANUP_URL = f"{BASE_URL}/api/v1/general/jobs/cleanup"
def trigger_cleanup():
"""Force-expire finished jobs so their stored files are released now.
An async submit persists a copy of the upload plus its results and holds them for
the job retention window (30 minutes by default), which far outlasts a test run.
Calling this keeps the post-run temp-file check strict: anything it still reports
afterwards is a genuine leak rather than a file that simply had not aged out.
"""
return requests.post(CLEANUP_URL, headers=API_HEADERS, timeout=60)
@@ -0,0 +1,47 @@
"""Concurrency steps, usable either before the request or after it."""
from behave import given, then, when
import parallel_support
def _set_repeat(context, count, decoy=False):
context.parallel_decoy = decoy
if count < 2:
context.parallel_repeat = 1
return
context.parallel_repeat = count
# An asked-for level higher than one already run wins.
if count > getattr(context, "parallel_ran_at", 0):
context.parallel_validated = False
if getattr(context, "parallel_validated", False):
return
# Placed after the request: replay whichever request was just sent.
pending = getattr(context, "parallel_request", None)
if pending is not None:
url, spec, headers, label = pending
parallel_support.validate(context, url, spec, headers, context.response, label)
return
pending_get = getattr(context, "parallel_get", None)
if pending_get is not None:
url, params, headers, label = pending_get
parallel_support.validate_get(context, url, params, headers, context.response, label)
@given("this operation is run {count:d} times in parallel")
@when("this operation is run {count:d} times in parallel")
@then("this operation is run {count:d} times in parallel")
def step_operation_run_in_parallel(context, count):
_set_repeat(context, count)
# Decoy traffic is an equal number of concurrent requests carrying different page
# text, so a response that came from the wrong request becomes visible.
@given("this operation is run {count:d} times in parallel against decoy traffic")
@when("this operation is run {count:d} times in parallel against decoy traffic")
@then("this operation is run {count:d} times in parallel against decoy traffic")
def step_operation_run_in_parallel_with_decoy(context, count):
_set_repeat(context, count, decoy=True)
@@ -0,0 +1,478 @@
"""Re-issues a request concurrently and asserts every response matches the baseline."""
import io
import json as json_module
import os
import re
import sys
import zipfile
from concurrent.futures import ThreadPoolExecutor
from hashlib import sha256
import requests
from pypdf import PdfReader
_UUID_RE = re.compile(r"[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}")
_LONG_NUM_RE = re.compile(r"\d{10,}")
_DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?")
_VOLATILE_KEY_RE = re.compile(
r"^(id|uuid|.*Id|.*_id|.*[Tt]ime|.*[Dd]ate|timestamp|created.*|modified.*|"
r"updated.*|expires.*|token|traceId|requestId|duration.*|elapsed.*)$"
)
# Only hash text for reasonably sized documents so the check stays cheap.
_MAX_TEXT_PAGES = 25
# Sequential samples taken to separate inherent nondeterminism from a concurrency bug.
NOISE_PROBE_SAMPLES = 3
# Response size is allowed to drift this far before it counts as a difference.
SIZE_TOLERANCE = 0.05
# Collected per-scenario results, printed as a summary in after_all.
VALIDATIONS = []
# A spec replaces open file handles with bytes so it can be replayed from many threads.
def materialize(spec):
"""Turn a captured spec into a fresh `files=` list for one request."""
parts = []
for key, filename, payload, mime in spec:
if filename is None:
parts.append((key, (None, payload) if mime is None else (None, payload, mime)))
else:
parts.append((key, (filename, io.BytesIO(payload), mime)))
return parts
def send(url, spec, headers, timeout=300):
return requests.post(url, files=materialize(spec), headers=headers, timeout=timeout)
def _normalize_name(name):
name = _UUID_RE.sub("<uuid>", name)
return _LONG_NUM_RE.sub("<num>", name)
def _strip_volatile(value):
"""Drop keys whose values legitimately differ between two identical requests."""
if isinstance(value, dict):
return {
k: _strip_volatile(v)
for k, v in sorted(value.items())
if not _VOLATILE_KEY_RE.match(k)
}
if isinstance(value, list):
return [_strip_volatile(v) for v in value]
if isinstance(value, str):
return _normalize_name(value)
return value
def _pdf_fingerprint(body, prefix=""):
parts = {}
try:
reader = PdfReader(io.BytesIO(body))
except Exception as exc:
parts[prefix + "pdf"] = f"unreadable: {type(exc).__name__}"
return parts
parts[prefix + "encrypted"] = reader.is_encrypted
if reader.is_encrypted:
return parts
try:
pages = reader.pages
parts[prefix + "pages"] = len(pages)
except Exception as exc:
parts[prefix + "pdf"] = f"unreadable pages: {type(exc).__name__}"
return parts
if len(pages) <= _MAX_TEXT_PAGES:
try:
text = "\n".join((page.extract_text() or "") for page in pages)
parts[prefix + "text_sha"] = sha256(text.encode("utf-8")).hexdigest()[:16]
except Exception:
pass
return parts
def _entry_sha(entry):
"""Hash an archive entry, normalizing the ids and timestamps EPUB/ODF restamp each run."""
try:
text = entry.decode("utf-8")
if "\x00" not in text:
entry = _DATE_RE.sub("<date>", _normalize_name(text)).encode("utf-8")
except UnicodeDecodeError:
pass
return sha256(entry).hexdigest()[:16]
def _zip_fingerprint(body):
parts = {}
try:
with zipfile.ZipFile(io.BytesIO(body)) as archive:
names = sorted(_normalize_name(n) for n in archive.namelist())
parts["zip_entries"] = len(names)
parts["zip_names"] = names
for index, name in enumerate(sorted(archive.namelist())):
entry = archive.read(name)
if entry[:5] == b"%PDF-":
parts.update(_pdf_fingerprint(entry, prefix=f"zip[{index}]."))
else:
parts[f"zip[{index}].sha"] = _entry_sha(entry)
except Exception as exc:
parts["zip"] = f"unreadable: {type(exc).__name__}"
return parts
def fingerprint(response):
"""Structural signature of a response, ignoring benign per-request variance."""
body = response.content
content_type = (response.headers.get("Content-Type") or "").split(";")[0].strip()
parts = {"status": response.status_code, "content_type": content_type, "size": len(body)}
if "json" in content_type:
try:
parts["json"] = _strip_volatile(json_module.loads(body.decode("utf-8")))
except Exception:
parts["body_sha"] = sha256(body).hexdigest()[:16]
elif body[:5] == b"%PDF-":
parts.update(_pdf_fingerprint(body))
elif body[:2] == b"PK":
parts.update(_zip_fingerprint(body))
elif content_type.startswith("text/") or response.status_code >= 400:
try:
parts["text"] = _normalize_name(body.decode("utf-8", "replace"))[:2000]
except Exception:
parts["body_sha"] = sha256(body).hexdigest()[:16]
else:
parts["body_sha"] = sha256(body).hexdigest()[:16]
return parts
def differing_keys(baseline, other):
return {
key
for key in set(baseline) | set(other)
if key != "size" and baseline.get(key) != other.get(key)
}
def size_differs(baseline, other):
base_size, other_size = baseline.get("size", 0), other.get("size", 0)
return abs(other_size - base_size) > max(64, base_size * SIZE_TOLERANCE)
def compare(baseline, other, ignore=frozenset(), ignore_size=False):
"""Return a list of human-readable differences between two fingerprints."""
diffs = []
for key in sorted(differing_keys(baseline, other) - set(ignore)):
diffs.append(
f"{key}: baseline={_short(baseline.get(key))} parallel={_short(other.get(key))}"
)
if not ignore_size and size_differs(baseline, other):
diffs.append(
f"size: baseline={baseline.get('size', 0)} parallel={other.get('size', 0)} "
f"(differs by more than {SIZE_TOLERANCE:.0%})"
)
return diffs
def _short(value):
text = repr(value)
return text if len(text) <= 160 else text[:157] + "..."
def build_decoy_spec(spec):
"""Clone a spec with each PDF stamped with unique text, or None if not possible.
Structure is preserved so parameters stay valid; only the text differs, which
is what makes bleed visible.
"""
try:
from pypdf import PdfWriter
from reportlab.pdfgen import canvas
except ImportError:
return None
decoy = []
stamped_any = False
for key, filename, payload, mime in spec:
if filename is None or not isinstance(payload, bytes) or payload[:5] != b"%PDF-":
decoy.append((key, filename, payload, mime))
continue
try:
reader = PdfReader(io.BytesIO(payload))
if reader.is_encrypted:
return None
writer = PdfWriter()
for index, page in enumerate(reader.pages):
box = page.mediabox
width, height = float(box.width), float(box.height)
overlay_buffer = io.BytesIO()
overlay_canvas = canvas.Canvas(overlay_buffer, pagesize=(width, height))
overlay_canvas.drawString(
20, max(20.0, height - 20), f"DECOY-MARKER-{index}-do-not-mix"
)
overlay_canvas.showPage()
overlay_canvas.save()
overlay_buffer.seek(0)
page.merge_page(PdfReader(overlay_buffer).pages[0])
writer.add_page(page)
out = io.BytesIO()
writer.write(out)
decoy.append((key, filename, out.getvalue(), mime))
stamped_any = True
except Exception:
return None
return decoy if stamped_any else None
def _run_concurrently(url, specs, headers, timeout):
"""Fire every spec at once and return (response, error) in submission order."""
results = [None] * len(specs)
def _worker(index):
try:
results[index] = (send(url, specs[index], headers, timeout=timeout), None)
except Exception as exc:
results[index] = (None, exc)
with ThreadPoolExecutor(max_workers=len(specs)) as pool:
list(pool.map(_worker, range(len(specs))))
return results
def validate(context, url, spec, headers, baseline, label, timeout=300):
"""Re-issue the request concurrently; no-op unless the repeat count is above 1."""
repeat = getattr(context, "parallel_repeat", 1)
if repeat < 2 or getattr(context, "parallel_validated", False):
return
context.parallel_validated = True
context.parallel_ran_at = repeat
decoy_spec = build_decoy_spec(spec) if getattr(context, "parallel_decoy", False) else None
specs = [spec] * repeat + ([decoy_spec] * repeat if decoy_spec else [])
results = _run_concurrently(url, specs, headers, timeout)
main_results = results[:repeat]
decoy_results = results[repeat:]
baseline_fp = fingerprint(baseline)
noise, noisy_size = frozenset(), False
failures = _collect_failures(
main_results, decoy_results, baseline_fp, repeat, noise, noisy_size
)
if failures:
# Some endpoints are inherently nondeterministic (embedded ids, timestamps,
# deliberate randomness). Re-run sequentially to tell that apart from a real bug.
noise, noisy_size = _probe_noise(url, spec, headers, baseline_fp, timeout)
failures = _collect_failures(
main_results, decoy_results, baseline_fp, repeat, noise, noisy_size
)
VALIDATIONS.append(
{
"label": label,
"repeat": repeat,
"decoy": bool(decoy_spec),
"failed": bool(failures),
"noise": sorted(noise) + (["size"] if noisy_size else []),
}
)
if failures:
raise AssertionError(
f"Parallel consistency failed for {label} at concurrency {repeat}.\n"
f"Two sequential runs agreed on these fields, so the differences below are "
f"caused by running the same operation concurrently.\n"
f"Baseline fingerprint: {_short(baseline_fp)}\n - " + "\n - ".join(failures)
)
def _collect_failures(main_results, decoy_results, baseline_fp, repeat, noise, noisy_size):
decoy_fp = _decoy_reference(decoy_results, baseline_fp, noise, noisy_size)
failures = []
for index, (response, error) in enumerate(main_results):
if error is not None:
failures.append(f"copy {index + 1}/{repeat} raised {type(error).__name__}: {error}")
continue
actual_fp = fingerprint(response)
diffs = compare(baseline_fp, actual_fp, noise, noisy_size)
if not diffs:
continue
if decoy_fp is not None and not compare(
decoy_fp, actual_fp, noise, noisy_size
):
failures.append(
f"copy {index + 1}/{repeat} returned the CONCURRENT DECOY REQUEST'S response "
f"(cross-request bleed)"
)
else:
failures.append(f"copy {index + 1}/{repeat} diverged: " + "; ".join(diffs))
failures.extend(_check_decoys(decoy_results, repeat, noise, noisy_size))
return failures
def _probe_noise(url, spec, headers, baseline_fp, timeout, samples=NOISE_PROBE_SAMPLES):
"""Fields that already vary between uncontended runs, so they prove nothing.
Several samples: high-variance output can look stable across any single pair.
"""
probes = []
for _ in range(samples):
try:
probes.append(fingerprint(send(url, spec, headers, timeout=timeout)))
except Exception:
break
return _noise_from_samples(baseline_fp, probes)
def _noise_from_samples(baseline_fp, probes):
noise = set()
noisy_size = False
for index, probe in enumerate(probes):
for other in [baseline_fp] + probes[:index]:
noise |= differing_keys(other, probe)
noisy_size = noisy_size or size_differs(other, probe)
return frozenset(noise), noisy_size
def validate_get(context, url, params, headers, baseline, label, timeout=60):
"""Concurrency check for read-only GET endpoints."""
repeat = getattr(context, "parallel_repeat", 1)
if repeat < 2 or getattr(context, "parallel_validated", False):
return
context.parallel_validated = True
context.parallel_ran_at = repeat
results = [None] * repeat
def _worker(index):
try:
results[index] = (
requests.get(url, params=params, headers=headers, timeout=timeout),
None,
)
except Exception as exc:
results[index] = (None, exc)
with ThreadPoolExecutor(max_workers=repeat) as pool:
list(pool.map(_worker, range(repeat)))
baseline_fp = fingerprint(baseline)
def _failures(noise, noisy_size):
found = []
for index, (response, error) in enumerate(results):
if error is not None:
found.append(f"copy {index + 1}/{repeat} raised {type(error).__name__}: {error}")
continue
diffs = compare(
baseline_fp,
fingerprint(response),
noise,
noisy_size,
)
if diffs:
found.append(f"copy {index + 1}/{repeat} diverged: " + "; ".join(diffs))
return found
noise, noisy_size = frozenset(), False
failures = _failures(noise, noisy_size)
if failures:
probes = []
for _ in range(NOISE_PROBE_SAMPLES):
try:
probes.append(
fingerprint(
requests.get(url, params=params, headers=headers, timeout=timeout)
)
)
except Exception:
break
noise, noisy_size = _noise_from_samples(baseline_fp, probes)
failures = _failures(noise, noisy_size)
VALIDATIONS.append(
{
"label": label,
"repeat": repeat,
"decoy": False,
"failed": bool(failures),
"noise": sorted(noise) + (["size"] if noisy_size else []),
}
)
if failures:
raise AssertionError(
f"Parallel consistency failed for GET {label} at concurrency {repeat}.\n - "
+ "\n - ".join(failures)
)
def _decoy_reference(decoy_results, baseline_fp, noise, noisy_size):
"""Fingerprint of the decoy response, or None when it is not distinguishable."""
live = [r for r, _e in decoy_results if r is not None]
if not live:
return None
decoy_fp = fingerprint(live[0])
# Some endpoints ignore page content, so the decoy cannot prove anything there.
if not compare(baseline_fp, decoy_fp, noise, noisy_size):
return None
return decoy_fp
def _check_decoys(decoy_results, repeat, noise, noisy_size):
"""Assert the decoy load stayed self-consistent while contending with the main copies."""
if not decoy_results:
return []
live = [(i, r) for i, (r, _e) in enumerate(decoy_results) if r is not None]
if not live:
return ["every decoy request failed to complete"]
failures = []
reference_fp = fingerprint(live[0][1])
for index, response in live[1:]:
diffs = compare(
reference_fp,
fingerprint(response),
noise,
noisy_size,
)
if diffs:
failures.append(f"decoy {index + 1}/{repeat} diverged: " + "; ".join(diffs))
return failures
def print_summary():
if not VALIDATIONS:
return
total = len(VALIDATIONS)
failed = sum(1 for v in VALIDATIONS if v["failed"])
max_repeat = max(v["repeat"] for v in VALIDATIONS)
requests_sent = sum(v["repeat"] * (2 if v["decoy"] else 1) for v in VALIDATIONS)
lines = [
f"\n[PARALLEL] {total - failed}/{total} operations stayed consistent under "
f"concurrency (up to {max_repeat} at once, {requests_sent} concurrent requests sent)."
]
noisy = {}
for entry in VALIDATIONS:
if entry["noise"]:
noisy.setdefault(entry["label"], set()).update(entry["noise"])
if noisy:
lines.append(
f"[PARALLEL] {len(noisy)} endpoint(s) produce nondeterministic output. Those "
f"fields were excluded only after confirming they also vary across "
f"{NOISE_PROBE_SAMPLES} sequential runs:"
)
for label, fields in sorted(noisy.items()):
lines.append(f" {label} -> {', '.join(sorted(fields))}")
# behave's --junit reporter swallows after_all stdout, so bypass any capture.
stream = getattr(sys, "__stderr__", None) or sys.stderr
stream.write("\n".join(lines) + "\n")
stream.flush()
@@ -15,6 +15,8 @@ import zipfile
import re
from PIL import Image, ImageDraw
import parallel_support
API_HEADERS = {"X-API-KEY": "123456789"}
#########
@@ -585,6 +587,8 @@ def step_send_get_request(context, endpoint):
full_url = f"{base_url}{endpoint}"
response = requests.get(full_url, headers=API_HEADERS, timeout=60)
context.response = response
context.parallel_get = (full_url, None, API_HEADERS, endpoint)
parallel_support.validate_get(context, full_url, None, API_HEADERS, response, endpoint)
@when('I send a GET request to "{endpoint}" with parameters')
@@ -594,17 +598,18 @@ def step_send_get_request_with_params(context, endpoint):
full_url = f"{base_url}{endpoint}"
response = requests.get(full_url, params=params, headers=API_HEADERS, timeout=60)
context.response = response
context.parallel_get = (full_url, params, API_HEADERS, endpoint)
parallel_support.validate_get(context, full_url, params, API_HEADERS, response, endpoint)
@when('I send the API request to the endpoint "{endpoint}"')
def step_send_api_request(context, endpoint):
url = f"http://localhost:8080{endpoint}"
def _build_request_spec(context):
"""Capture the multipart payload as replayable bytes rather than file handles."""
files = context.files if hasattr(context, "files") else {}
if not hasattr(context, "request_data") or context.request_data is None:
context.request_data = {}
form_data = []
spec = []
for key, value in context.request_data.items():
# Handle list parameters (like 'languages') - send multiple form fields
# Split comma-separated values or treat single values as single-item lists
@@ -612,32 +617,50 @@ def step_send_api_request(context, endpoint):
# Split by comma if present, otherwise treat as single value
values = [v.strip() for v in value.split(",")] if "," in value else [value]
for val in values:
form_data.append((key, (None, val)))
spec.append((key, None, val, None))
else:
form_data.append((key, (None, value)))
spec.append((key, None, value, None))
def _read(file):
file.seek(0)
payload = file.read()
file.seek(0)
return payload
for key, file in files.items():
mime_type, _ = mimetypes.guess_type(file.name)
mime_type = mime_type or "application/octet-stream"
print(f"form_data {file.name} with {mime_type}")
form_data.append((key, (file.name, file, mime_type)))
spec.append((key, file.name, _read(file), mime_type))
# Multi-file entries (duplicate keys for MultipartFile[] endpoints, e.g. merge-pdfs)
for key, file in getattr(context, "multi_files", []):
mime_type, _ = mimetypes.guess_type(file.name)
mime_type = mime_type or "application/octet-stream"
print(f"form_data (multi) {file.name} with {mime_type}")
form_data.append((key, (file.name, file, mime_type)))
spec.append((key, file.name, _read(file), mime_type))
# JSON multipart parts for @RequestPart endpoints (e.g. /form/fill)
for part_name, json_content in getattr(context, "json_parts", {}).items():
form_data.append((part_name, (None, json_content, "application/json")))
spec.append((part_name, None, json_content, "application/json"))
return spec
@when('I send the API request to the endpoint "{endpoint}"')
def step_send_api_request(context, endpoint):
url = f"http://localhost:8080{endpoint}"
spec = _build_request_spec(context)
# Set timeout to 300 seconds (5 minutes) to prevent infinite hangs
print(f"Sending POST request to {endpoint} with timeout=300s")
response = requests.post(url, files=form_data, headers=API_HEADERS, timeout=300)
response = parallel_support.send(url, spec, API_HEADERS, timeout=300)
context.response = response
# Remembered so a later "run N times in parallel" step can replay this request.
context.parallel_request = (url, spec, API_HEADERS, endpoint)
parallel_support.validate(context, url, spec, API_HEADERS, response, endpoint)
########
# THEN #
+143
View File
@@ -0,0 +1,143 @@
#!/bin/bash
# Run the behave suite as N concurrent shards against one server.
# Usage: ./run-parallel.sh [SHARDS] [-- behave args] Env: BASE_URL
set -uo pipefail
SHARDS="${1:-10}"
if [[ "$SHARDS" =~ ^[0-9]+$ ]]; then
shift
else
SHARDS=10
fi
[[ "${1:-}" == "--" ]] && shift
BASE_URL="${BASE_URL:-http://localhost:8080}"
CUCUMBER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_ROOT="$CUCUMBER_DIR/.parallel"
REPORT_DIR="${PARALLEL_REPORT_DIR:-$WORK_ROOT/reports}"
cd "$CUCUMBER_DIR" || exit 1
if ! curl -sf --retry 30 --retry-delay 2 --retry-connrefused --retry-all-errors \
"$BASE_URL/api/v1/info/status" >/dev/null; then
echo "ERROR: no server responding at $BASE_URL"
exit 1
fi
mapfile -t ALL_FEATURES < <(
find features -name '*.feature' \
-not -path 'features/enterprise/*' \
-not -path 'features/multinode/*' | sort
)
if [ "${#ALL_FEATURES[@]}" -eq 0 ]; then
echo "ERROR: no feature files found under $CUCUMBER_DIR/features"
exit 1
fi
# user_management.feature changes the admin password mid-scenario, so a concurrent
# shard logging in then gets a 401. Keep all admin-auth features in one shard.
AUTH_RE='logged in as admin|I login with username|JWT authentication|stored JWT token'
declare -a AUTH_FEATURES=() FEATURES=()
for feature in "${ALL_FEATURES[@]}"; do
if grep -qE "$AUTH_RE" "$feature"; then
AUTH_FEATURES+=("$feature")
else
FEATURES+=("$feature")
fi
done
if [ "${#AUTH_FEATURES[@]}" -gt 0 ]; then
echo "Pinning ${#AUTH_FEATURES[@]} auth-coupled feature(s) to a single shard:"
printf ' %s\n' "${AUTH_FEATURES[@]##*/}"
SHARDS=$((SHARDS - 1))
fi
# The pin above can take SHARDS to 0 or below, which makes the shard loop run zero
# times: every shardable feature is skipped and the run still reports success.
[ "$SHARDS" -lt 0 ] && SHARDS=0
[ "${#FEATURES[@]}" -gt 0 ] && [ "$SHARDS" -lt 1 ] && SHARDS=1
if [ "$SHARDS" -gt "${#FEATURES[@]}" ]; then
echo "Only ${#FEATURES[@]} shardable feature files, reducing shards from $SHARDS"
SHARDS="${#FEATURES[@]}"
fi
rm -rf "$WORK_ROOT"
mkdir -p "$WORK_ROOT" "$REPORT_DIR"
TOTAL_SHARDS=$SHARDS
[ "${#AUTH_FEATURES[@]}" -gt 0 ] && TOTAL_SHARDS=$((SHARDS + 1))
echo "Running ${#ALL_FEATURES[@]} feature files across $TOTAL_SHARDS concurrent shards against $BASE_URL"
declare -a PIDS=()
start_shard() {
local shard=$1
shift
local SHARD_DIR="$WORK_ROOT/shard-$shard"
mkdir -p "$SHARD_DIR"
# Feature files reference exampleFiles/ and behave.ini relative to the CWD.
cp -r "$CUCUMBER_DIR/exampleFiles" "$SHARD_DIR/exampleFiles"
cp "$CUCUMBER_DIR/behave.ini" "$SHARD_DIR/behave.ini"
local assigned=("$@")
(
cd "$SHARD_DIR" || exit 1
uv run --project "$CUCUMBER_DIR/../../engine" --locked --group cucumber \
python -m behave "${assigned[@]}" \
--junit --junit-directory "$REPORT_DIR/shard-$shard" \
--no-capture -f plain "${BEHAVE_EXTRA[@]}" \
>"$REPORT_DIR/shard-$shard.log" 2>&1
) &
PIDS+=($!)
}
declare -a BEHAVE_EXTRA=("$@")
for ((shard = 0; shard < SHARDS; shard++)); do
assigned=()
for ((i = shard; i < ${#FEATURES[@]}; i += SHARDS)); do
assigned+=("$CUCUMBER_DIR/${FEATURES[$i]}")
done
start_shard "$shard" "${assigned[@]}"
done
if [ "${#AUTH_FEATURES[@]}" -gt 0 ]; then
assigned=()
for feature in "${AUTH_FEATURES[@]}"; do
assigned+=("$CUCUMBER_DIR/$feature")
done
start_shard "$SHARDS" "${assigned[@]}"
fi
FAILED=0
for ((shard = 0; shard < TOTAL_SHARDS; shard++)); do
if wait "${PIDS[$shard]}"; then
echo "shard $shard PASSED"
else
echo "shard $shard FAILED"
FAILED=$((FAILED + 1))
fi
done
echo ""
echo "=== Parallel shard summary ==="
grep -h '^[0-9]* scenarios passed' "$REPORT_DIR"/shard-*.log 2>/dev/null || true
grep -h '^\[PARALLEL\] [0-9]*/' "$REPORT_DIR"/shard-*.log 2>/dev/null || true
if [ "$FAILED" -gt 0 ]; then
echo ""
echo "$FAILED of $TOTAL_SHARDS shards failed. Failing scenarios:"
sed -n '/^Failing scenarios:/,/^[0-9]* features/p' "$REPORT_DIR"/shard-*.log 2>/dev/null |
grep 'feature:' | sort -u
echo ""
echo "Parallel consistency failures:"
grep -h -A4 'Parallel consistency failed' "$REPORT_DIR"/shard-*.log 2>/dev/null | head -40
echo ""
echo "Full logs: $REPORT_DIR/shard-*.log"
exit 1
fi
echo "All $TOTAL_SHARDS shards passed."
+17 -4
View File
@@ -391,6 +391,17 @@ capture_file_list() {
gha_endgroup
}
# Tail of container logs shown when a leak is detected. The full log is captured as a
# job artifact anyway, and dumping all of it here buried the actual finding.
TEMP_FILE_LOG_TAIL=${TEMP_FILE_LOG_TAIL:-200}
# Print the log context for a detected temp-file leak, bounded so the finding stays readable.
print_temp_file_leak_logs() {
local container_name=$1
echo "Last $TEMP_FILE_LOG_TAIL lines of container logs (full log is in the uploaded artifacts):"
docker logs --tail "$TEMP_FILE_LOG_TAIL" "$container_name" 2>&1 || true
}
# Function to compare before and after file lists
compare_file_lists() {
local before_file=$1
@@ -417,8 +428,7 @@ compare_file_lists() {
if [ -s "${diff_file}.tmp" ]; then
echo "WARNING: Temporary files found:"
cat "${diff_file}.tmp"
echo "Printing docker logs due to temporary file detection:"
docker logs "$container_name" # Print logs when temp files are found
print_temp_file_leak_logs "$container_name"
gha_endgroup
return 1
else
@@ -450,8 +460,11 @@ compare_file_lists() {
if [ -s "${diff_file}.tmp" ]; then
echo "WARNING: Temporary files detected:"
cat "${diff_file}.tmp"
echo "Printing docker logs due to temporary file detection:"
docker logs "$container_name" # Print logs when temp files are found
echo "These files were still present after the suite finished. Async job results"
echo "and their input copies live under the server's file store and are released"
echo "by the cleanup that features/environment.py runs in after_all - if they show"
echo "up here, that cleanup did not cover them."
print_temp_file_leak_logs "$container_name"
return 1
fi
fi