File Upload Security

File Upload Security Test Cases: Verify Every Boundary

Verify file upload security across authorization, type validation, filenames, scanning, storage isolation, delivery policy, archives, quotas, audit trails and cleanup.

WrightTest TeamApproximately 20 min read
Secure file upload testing pipeline from authorization and validation through scanning, storage, delivery, access control, audit and cleanup.
A secure upload pipeline should keep untrusted bytes away from publication and delivery until every required control has passed.

A file upload can reject .exe files and still be insecure.

The server may trust a forged MIME type, preserve a user-controlled path, expose temporary objects before scanning completes, attach a file to the wrong tenant, or serve untrusted content from the application’s own origin.

In practice, the dangerous gap is often not the allowlist itself. It is the interval between accepting the bytes and deciding that the file may be processed, published, or retrieved.

A secure upload therefore needs more than one validation rule:

User authorised
→ request limits checked
→ source content validated
→ safe storage identity assigned
→ file isolated
→ security checks completed
→ ownership confirmed
→ controlled delivery enabled
→ retention policy enforced

The test should follow that complete path.

This guide covers the security controls that QA engineers can verify safely with controlled fixtures, browser automation, API tests, and authorised test environments. It does not provide weaponised payloads or instructions for exploiting parsers, web servers, antivirus products, or storage infrastructure.

For general behaviour such as progress, retry, cancellation, multiple files, and business-level processing, see File Upload Test Cases.

For image decoding, EXIF orientation, cropping, responsive variants, transparency, and metadata, see Image Upload Test Cases.

Start with the Security Contract

A secure upload policy must describe more than the permitted extensions.

Before building the test matrix, document why the product accepts each format, which components process it, where the file waits before approval, and who can retrieve it after publication.

Security areaQuestions to answer
Upload permissionWhich users, roles, tenants, and account states may upload?
Business purposeWhy does the application need each accepted format?
Accepted contentWhich extensions and detected formats are allowed?
Type detectionDoes the server inspect extension, MIME type, signature, full decoding, or parser output?
Filename handlingIs the original name used only for display, or does it influence a storage path?
Storage identityDoes the application generate a separate object key or identifier?
Temporary storageWhere does the file exist before security checks finish?
ProcessingWhich decoders, parsers, converters, preview generators, and extractors consume it?
ScanningMust antivirus, content analysis, moderation, or content disarm and reconstruction succeed?
PublicationCan the file be retrieved before every mandatory check passes?
DeliveryIs it rendered inline, downloaded, proxied, signed, or served through a separate origin?
OwnershipWhich user, tenant, project, order, message, or case owns the file?
LimitsWhat are the per-file, batch, account, tenant, and rate limits?
ArchivesAre compressed files accepted, inspected, or extracted?
RetentionWhat happens to rejected, quarantined, replaced, expired, and deleted objects?
AuditWhich decisions are logged, and which events should trigger an alert?

A practical security completion rule is:

A file becomes publishable only after the uploader is authorised, the detected content matches an allowed business format, every mandatory security check has passed, the application has assigned a safe storage identity, and each retrieval path enforces the intended access and delivery policy.

Do not represent all intermediate states as Uploaded.

Use explicit states such as:

Rejected
Pending validation
Quarantined
Pending scan
Safe
Ready for processing
Published
Expired
Deleted

The distinction matters. A file may exist in temporary storage without being safe, publishable, or retrievable.

What QA Can Test Safely

A QA suite should prove that the documented controls work. It should not quietly turn a shared CI runner into an exploit laboratory.

Routine functional and integration tests can safely verify:

  • server-side type validation;
  • extension, MIME, and content mismatches;
  • generated storage identities;
  • filename normalisation;
  • authentication and authorisation;
  • tenant isolation;
  • quarantine states;
  • scanner success, rejection, timeout, and unavailability;
  • blocked retrieval before approval;
  • response content types and download headers;
  • signed URL expiration;
  • file, batch, quota, and archive limits;
  • direct API calls that bypass the browser;
  • retries, replacement, deletion, and cleanup;
  • audit events.

A dedicated security assessment is more appropriate for:

  • real malware;
  • executable server-side payloads;
  • weaponised office documents;
  • parser exploitation;
  • antivirus evasion;
  • malicious polyglot research;
  • decompression bombs;
  • sandbox escape attempts;
  • infrastructure-level bucket or container misconfiguration;
  • race-condition exploitation against production-like infrastructure.

Not every case belongs in CI.

Filename, content-validation, ownership, quarantine, and delivery-policy checks should run regularly. Destructive archive and parser scenarios belong in an isolated environment owned jointly with the security team.

For routine automation, use inert fixtures that reproduce the required property without executing code.

Model the Secure Upload Pipeline

A secure upload is a gated state machine, not a direct path from the browser to public storage.

Unauthenticated
  └── Reject

Authenticated
  ↓
Authorised for the target record
  ├── Reject role, ownership, or tenant
  ↓
Request and quota limits checked
  ├── Reject size, count, rate, or storage limit
  ↓
Temporary isolated storage
  ↓
Filename normalised
  ↓
Safe storage identity assigned
  ↓
Extension policy checked
  ↓
Declared MIME evaluated
  ↓
Signature and content detected
  ↓
Full parser or decoder validation
  ├── Reject unexpected or corrupted content
  ↓
Quarantine
  ↓
Scanner or content analysis
  ├── Reject or hold suspicious content
  ├── Remain pending if the scanner is unavailable
  ↓
Ownership confirmed again
  ↓
Permanent storage or controlled processing
  ↓
Authorised delivery
  ↓
Expired, replaced, or deleted

The order is part of the security contract.

A scanner integration can be technically healthy and still provide little protection if a temporary URL is already public. Likewise, a safe generated object key does not help if the original filename later reappears inside an unvalidated download path.

The release-gating test should ask a narrow question:

Can untrusted bytes cross any boundary before the product has made and recorded its security decision?

Core File Upload Security Test Cases

Validate the Source and Assign a Safe Identity

Extension, browser-declared MIME type, file signature, and actual content are separate signals.

The browser controls the multipart filename and request content type. A signature may identify the beginning of a format, but it does not prove that the complete file is valid. Some formats require full decoding or specialised parsing before the application can trust them.

The original filename is also untrusted. Keep it for display when needed, but do not use it as the physical object key.

IDTest caseExpected security result
SU-001Upload every documented allowed formatOnly formats required by the business contract are accepted
SU-002Upload a clearly unsupported formatThe server rejects it before publication or trusted processing
SU-003Rename unsupported content with an allowed extensionDetected content prevents acceptance
SU-004Send an allowed file with a forged request MIME typeThe client header does not determine trust
SU-005Send invalid content with an allowed extension and MIME typeFull parsing or decoding rejects it
SU-006Upload a valid allowed file with the wrong extensionThe documented detected-content policy is applied consistently
SU-007Vary extension case, including uppercase and mixed caseCase changes do not bypass the allowlist
SU-008Use double extensions, trailing dots, or trailing spacesNormalisation occurs before the final security decision
SU-009Upload a valid file without an extensionThe server follows its documented detected-content rule
SU-010Upload truncated content with a valid signature prefixFull parsing fails, and the file never becomes safe
SU-011Upload a corrupted allowed fileThe application returns a controlled failure instead of leaving it pending
SU-012Remove a format from the server allowlist while the UI still permits itThe server remains authoritative
SU-013Call the endpoint directly without using the browser pickerThe same validation rules apply
SU-014Upload a mixed batch containing one prohibited typeEach item receives an explicit security result
SU-015Upload two different files with the same display nameNeither object silently overwrites the other
SU-016Reuse the same display name across two users or tenantsStorage identity and ownership remain separate
SU-017Include path separators or relative path segments in the filenameDirectory components are removed or rejected
SU-018Use an excessively long, Unicode, hidden-style, or reserved filenameThe application normalises or rejects it without creating collisions
SU-019Include control characters or line breaks in the filenameHeaders, logs, and interface output remain structurally valid
SU-020Replace an existing file while keeping its display nameThe application creates a controlled version or independent object identity

A safe model separates three values:

Display name:
customer-contract.pdf

Logical file ID:
file_7f23d91c

Storage object key:
tenant-a/2026/07/2de91d1e-6a7b-4bfd-a85e

This allows the product to preserve a friendly name without trusting it as a path.

For boundary variants such as extension case, double extensions, missing extensions, and trailing characters, a data-driven test is better than several nearly identical browser flows.

Input variationExpected decision
Uppercase extensionSame allowlist result
Double extensionEvaluated using the documented final-name policy
Trailing dots or spacesNormalised before validation
Missing extensionDecided through detected content
Similar Unicode charactersNo storage collision or ownership confusion

Authorise Every Upload and State Transition

Upload permission and file retrieval are separate authorisation decisions.

A user may be able to upload to one project but not another. A support agent may view an attachment without permission to replace it. A tenant administrator should not gain access to objects owned by another tenant, even if the identifier is predictable.

The browser UI is not the security boundary. Every relevant check must also run when the endpoint is called directly.

IDTest caseExpected security result
SU-021Upload without authenticationThe request is rejected
SU-022Upload with an expired or revoked sessionThe request is rejected without creating a publishable object
SU-023Upload as a role without file permissionAccess is denied
SU-024Upload to another user’s recordOwnership validation rejects the request
SU-025Upload to another tenant’s recordTenant isolation is enforced
SU-026Modify a valid business-record identifierThe server checks current ownership instead of trusting the page route
SU-027Replace a file owned by another userThe operation is rejected
SU-028Delete a file owned by another userThe operation is rejected
SU-029Retrieve a private file without authenticationAccess is denied
SU-030Retrieve another user’s private fileAccess is denied even when the identifier is known
SU-031Retrieve another tenant’s fileTenant isolation is enforced at delivery
SU-032Reuse an upload token issued to another accountThe token is rejected or remains bound to its original subject
SU-033Reuse an upload token for another business recordToken scope prevents reassignment
SU-034Complete a long-running upload after the user loses permissionThe final attachment step checks current authorisation
SU-035Submit a browser-session upload without the required CSRF protectionThe request is rejected according to the application’s CSRF policy
SU-036Modify a client-generated safe, scanned, or validated flagThe server ignores the untrusted verdict
SU-037Attach an upload ID owned by another userOwnership validation rejects the transition
SU-038Reorder create, upload, scan, complete, and attach requestsThe state machine rejects invalid transitions

Direct-to-storage designs need special attention.

The storage provider may accept the bytes, while the application separately controls object creation, scan status, ownership, and final attachment. A test that proves only that the signed upload succeeded does not prove that the application accepted the file securely.

Validate again when the client reports completion:

Signed upload issued
→ object uploaded
→ object metadata inspected
→ size and ownership confirmed
→ scan requested
→ safe verdict recorded
→ attachment allowed

A pre-signed instruction should bind at least the intended object key, expiration, uploader context, and relevant size or policy constraints.

Quarantine and Release

The security property is not “the scanner was called.”

It is:

Untrusted object
→ isolated
→ analysed
→ decision recorded
→ only an approved result released

The file should remain inaccessible while any mandatory scanner or content-analysis result is pending.

IDTest caseExpected security result
SU-039Upload a normal clean fixtureThe object moves from quarantine to the approved state
SU-040Upload a security-team-approved scanner test fixtureThe scanner returns the expected non-production verdict
SU-041Request the file while scanning is pendingThe file cannot be retrieved or processed as trusted
SU-042Simulate a scanner rejectionThe object stays quarantined or is removed
SU-043Make the scanner unavailableThe system fails closed according to policy
SU-044Let the scanner time outThe file remains pending or rejected instead of becoming safe by default
SU-045Retry scanning after a temporary failureOne controlled transition occurs without duplicate publication
SU-046Return conflicting results from mandatory scannersThe documented conservative decision is applied
SU-047Replace a previously safe fileThe replacement returns to quarantine
SU-048Change file bytes in an authorised integration harness after approvalThe previous verdict no longer applies
SU-049Generate a preview while the verdict is pendingUntrusted content is not exposed through the preview path
SU-050Attach a pending file to a business recordThe record distinguishes pending from ready and blocks use where required
SU-051Inspect scan metadata through an authorised endpointVerdict, timestamp, scanner version, and object identity match
SU-052Replay a clean verdict for another objectThe system rejects a verdict that is not bound to the same content identity

A scanner result should be bound to immutable evidence such as the object ID, version, checksum, or another content identity used by the processing pipeline.

Otherwise, replacement and retry flows can accidentally inherit an old verdict.

The same rule applies to generated previews and derivatives. If the product creates a thumbnail before final approval, that thumbnail must remain isolated too.

Control Storage and Delivery

An allowed file can become dangerous when served incorrectly.

Risk changes depending on whether the browser renders the response inline, whether the object comes from the application origin, whether the content type is accurate, and whether caches can store a private response.

IDTest caseExpected security result
SU-053Retrieve an approved fileThe response uses the correct detected content type
SU-054Retrieve a file intended only for downloadContent-Disposition prevents unintended inline rendering
SU-055Retrieve a browser-active formatDelivery follows the explicit isolation, sanitisation, or rejection policy
SU-056Inspect X-Content-Type-OptionsThe response uses nosniff where required
SU-057Upload markup disguised as plain dataThe browser does not reinterpret it as active content
SU-058Access a quarantined storage URL directlyThe object remains inaccessible
SU-059Guess another logical file identifierPredictability does not bypass authorisation
SU-060Attempt to list a storage prefix or directoryOrdinary users cannot enumerate stored objects
SU-061Request uploaded content through the application originThe response cannot become unintended same-origin active content
SU-062Request the same content through a dedicated file originThe intended isolation and headers remain intact
SU-063Reuse an expired signed URLAccess is denied
SU-064Modify a signed URL path, object key, or relevant parameterSignature scope prevents retrieval of another object
SU-065Share a private URL with an unauthorised sessionAccess behaves according to the documented signed or server-side policy
SU-066Replace a file while retaining its display nameDelivery returns the new controlled version rather than stale bytes
SU-067Delete a file and request its former URLThe object becomes unavailable after the documented retention period
SU-068Inspect cache directives on private contentShared caches cannot expose private responses
SU-069Request a rejected object through a known temporary pathTemporary storage is not web-accessible
SU-070Download the same private file as its owner and another userOnly the authorised context succeeds
SU-071Retrieve a transformed preview or derivativeThe derivative enforces the same ownership and verdict policy as the source

X-Content-Type-Options: nosniff is useful, but it is only one delivery control.

It does not replace:

  • correct type detection;
  • rejection of dangerous formats;
  • storage isolation;
  • authorisation;
  • suitable Content-Disposition;
  • safe preview generation;
  • a separate delivery origin where needed.

Private responses also need an explicit cache policy. Hiding a URL in the interface does not make the object private.

Limit Files, Batches, Archives, and Processing

File size is only one resource boundary.

A small compressed image may expand into a large bitmap. An archive may contain thousands of entries. A valid document can trigger expensive preview generation. Many individually acceptable files can exceed a tenant quota when submitted together.

IDTest caseExpected security result
SU-072Upload below, at, and above the maximum byte sizeThe documented boundary is enforced against actual bytes received
SU-073Omit or forge the request content lengthStreaming limits still enforce the real upload size
SU-074Submit many small files in one requestCount and aggregate-size limits apply
SU-075Exceed the permitted batch item countThe batch or excess items are rejected according to policy
SU-076Exhaust the account or tenant storage quotaFurther uploads are blocked without affecting other tenants
SU-077Repeat rejected oversized uploadsRate and resource controls prevent repeated expensive work
SU-078Upload a small image with excessive pixel dimensionsDecoder limits prevent uncontrolled expansion
SU-079Upload a file that triggers many generated derivativesProcessing and storage limits remain controlled
SU-080Upload an allowed archive containing a prohibited typeEvery extracted entry is validated independently
SU-081Include nested directories in an archiveExtraction stays inside the controlled destination
SU-082Include duplicate archive entry namesExtraction cannot overwrite another entry unexpectedly
SU-083Use a controlled high-compression test archive in an isolated environmentExpansion limits stop processing safely
SU-084Exceed the permitted archive nesting depthProcessing stops at the documented limit
SU-085Exceed the permitted archive entry countThe archive is rejected before uncontrolled extraction
SU-086Submit a batch where one file exceeds security limitsPer-item and batch states follow the documented atomicity rule
SU-087Cancel processing after temporary extraction beginsTemporary extracted objects are removed
SU-088Download large files repeatedlyDelivery rate or usage limits protect availability where required

Use a compact boundary matrix for size and count rules:

ConstraintBelow limitExact limitAbove limit
File bytesAccept if otherwise validFollow documented inclusivityReject before expensive processing
Batch countAcceptAcceptReject excess or whole batch
Aggregate batch bytesAcceptAcceptReject according to batch policy
Tenant quotaAcceptAccept or reserve final bytesBlock without cross-tenant impact
Archive entriesAcceptAcceptStop before extraction exceeds the limit
Archive depthAcceptAcceptReject or stop extraction

Destructive archive testing should not run on a shared CI worker.

Use low-impact synthetic fixtures, service doubles, or a security-owned environment where CPU, memory, storage, and cleanup are controlled.

Retention, Audit, and Cleanup

A rejected file is still a stored object until the system removes it.

Security defects often appear after the visible workflow has ended: an orphan survives a failed attachment, a deleted object remains reachable through a CDN, or a late scanner event republishes a file whose business record no longer exists.

IDTest caseExpected security result
SU-089Reject a file during validationTemporary bytes are removed within the documented period
SU-090Quarantine a file for manual reviewRetention and review rules are applied
SU-091Replace an approved fileThe replacement passes every mandatory security control again
SU-092Delete an approved fileBusiness record, retrieval, and storage states become consistent
SU-093Delete a file with previews or derivativesEvery dependent artifact follows the deletion policy
SU-094Expire a temporary uploadIt cannot later be attached or retrieved
SU-095Delete the business record while scanning is pendingA late event cannot reattach or publish the file
SU-096Skip the final completion step after direct storage uploadThe orphan remains unavailable and is eventually cleaned
SU-097Call the completion endpoint without a corresponding objectThe application does not create a ready record
SU-098Inspect audit events for accepted and rejected uploadsEvents include actor, object, decision, reason, and correlation ID
SU-099Submit unusual filenames and metadataLogging remains safe and structurally valid
SU-100Trigger repeated rejected uploadsMonitoring records the pattern without retaining prohibited content indefinitely
SU-101Change a file’s security verdictThe transition is attributable and auditable
SU-102Retry cleanup after a storage failureCleanup remains idempotent and targets only the intended object

Logs should identify the decision without becoming another uncontrolled copy of user data.

Avoid logging:

  • complete file contents;
  • credentials;
  • active signed URLs;
  • unnecessary personal metadata;
  • raw secrets included in document content.

A useful audit event may include:

{
  "event": "file_upload_rejected",
  "fileId": "file_7f23d91c",
  "actorId": "user_1042",
  "tenantId": "tenant_a",
  "decision": "reject",
  "reason": "content_type_mismatch",
  "detectedType": "text/plain",
  "declaredType": "application/pdf",
  "correlationId": "req_92f814",
  "occurredAt": "2026-07-29T15:12:33Z"
}

Evidence That the Controls Worked

A red validation message is not enough.

The test should prove that the rejected or pending file did not cross another boundary behind the interface.

LayerSecurity evidence
BrowserCorrect state appears, and prohibited content is not rendered
Upload APIAuthentication, authorisation, size, type, and state decisions
Temporary storageObject is isolated and inaccessible
Type detectorExtension, declared MIME, signature, and detected format
Parser or decoderFull content matches the allowed contract
ScannerVerdict is bound to the correct object version or checksum
State machineOnly valid transitions can reach Safe or Published
Permanent storageGenerated identity, correct ownership, least-privilege access
Business recordFile attaches only to the authorised entity
DeliveryCorrect content type, disposition, origin, caching, and authorisation
Audit trailActor, decision, reason, object, and correlation identity
CleanupRejected, expired, replaced, and deleted objects follow retention rules
File upload security evidence boundaries across browser, API, temporary storage, detector, parser, scanner, state machine, permanent storage, delivery, audit and cleanup
Security evidence needs to cross every boundary where untrusted bytes move, not only the browser upload control.

A strong rejection test may look like this:

Upload inert mismatched content
→ server rejects the mismatch
→ no safe object is created
→ no business attachment exists
→ direct retrieval fails
→ the rejection is logged
→ temporary bytes are removed

A strong successful test proves the opposite path:

Upload controlled clean fixture
→ isolate it
→ validate the source
→ record a successful scan
→ confirm ownership
→ publish one object
→ permit the owner
→ reject another user
→ delete and revoke access

Security Failures That Look Safe

Failure that appears protectedWhat actually happenedEvidence required
Browser rejected the fileDirect API request still accepts itSend the same inert fixture through multipart API
Extension was blockedForged MIME type bypasses the backend ruleVary extension, MIME, and content independently
Scanner rejected the fileTemporary URL was already publicAttempt retrieval during every pending state
Filename was sanitisedOriginal name still became the storage keyInspect generated object identity
File is private in the UIDirect URL works without authorisationCross-user and unauthenticated retrieval
Download button worksResponse renders active content inlineInspect content type, disposition, origin, and nosniff
Replacement inherited SafeNew bytes reused the old verdictBind verdict to immutable content identity
Database row was deletedOld object or CDN response remains accessibleRetrieve after deletion and retention expiry
Each file meets the limitBatch exhausts memory, processing, or quotaTest aggregate count, bytes, and concurrency
Signed URL expiredModified path still accesses another objectChange object-related signed parameters
Quarantine existsPreview generator exposes unapproved contentRetrieve every derivative before approval
Audit event existsIt cannot identify the actor or file versionVerify object, actor, reason, and correlation fields
File upload security failures that appear protected but still expose unsafe content, direct URLs, stale verdicts or missing cleanup
Many upload controls look safe in the UI while direct API, storage or delivery paths still bypass the intended protection.

These patterns are valuable because they target false confidence.

The visible control may work exactly as designed while another storage, processing, or delivery path remains open.

Automate the Boundaries with Playwright

Playwright can test both sides of the feature:

  • locator.setInputFiles() covers the visible upload journey;
  • APIRequestContext calls the server directly and bypasses client-side validation.

Use controlled inert content. The goal is to verify the boundary, not execute a payload.

Reject Mismatched Content at the API Boundary

The following fixture claims to be a PDF through its name and MIME type but contains plain text.

import { test, expect } from '@playwright/test';

test('rejects content that does not match the declared PDF type', async ({
  page,
}) => {
  await page.goto('/documents');

  await page.getByLabel('Upload document').setInputFiles({
    name: 'quarterly-report.pdf',
    mimeType: 'application/pdf',
    buffer: Buffer.from(
      'Controlled inert fixture. This is not a valid PDF document.',
    ),
  });

  const [response] = await Promise.all([
    page.waitForResponse(
      candidate =>
        candidate.url().includes('/api/uploads') &&
        candidate.request().method() === 'POST',
    ),
    page.getByRole('button', { name: 'Upload' }).click(),
  ]);

  expect([400, 415, 422]).toContain(response.status());

  await expect(page.getByRole('alert')).toContainText(
    /invalid|unsupported|content|format/i,
  );

  await expect(page.getByTestId('uploaded-file')).toHaveCount(0);
});

Now bypass the browser:

import { test, expect } from '@playwright/test';

test('enforces type validation on direct multipart requests', async ({
  request,
}) => {
  const response = await request.post('/api/uploads', {
    multipart: {
      file: {
        name: 'quarterly-report.pdf',
        mimeType: 'application/pdf',
        buffer: Buffer.from(
          'Controlled inert fixture. This is not a PDF.',
        ),
      },
      businessRecordId: 'case-123',
    },
  });

  expect([400, 415, 422]).toContain(response.status());

  const body = await response.json();

  expect(body.code).toMatch(
    /INVALID_FILE|UNSUPPORTED_TYPE|CONTENT_MISMATCH/,
  );
});

The exact status and error code are application-specific.

The stable assertion is that browser metadata cannot make invalid content trusted.

Safe storage identity can be verified in the same API journey:

expect(upload.originalName).toBe('customer-contract.pdf');
expect(upload.storageKey).not.toContain('..');
expect(upload.storageKey).not.toContain('customer-contract.pdf');
expect(upload.storageKey).toMatch(/^[a-zA-Z0-9/_-]+$/);

Keep Pending Files Inaccessible

A clean response from the upload endpoint may create only a pending object.

import { test, expect } from '@playwright/test';

test('does not expose a file while security checks are pending', async ({
  request,
}) => {
  const uploadResponse = await request.post('/api/uploads', {
    multipart: {
      file: {
        name: 'document.pdf',
        mimeType: 'application/pdf',
        buffer: Buffer.from(
          '%PDF-1.4\n% controlled non-sensitive fixture\n',
        ),
      },
      businessRecordId: 'case-123',
    },
  });

  expect(uploadResponse.ok()).toBeTruthy();

  const upload = await uploadResponse.json();

  expect(upload.status).toMatch(
    /pending_validation|pending_scan|quarantined/,
  );

  const contentResponse = await request.get(
    `/api/files/${upload.id}/content`,
  );

  expect([403, 404, 409, 423]).toContain(contentResponse.status());
});

The endpoint may return 403, 404, 409, or 423. The precise status is less important than the security property: pending bytes cannot be retrieved as approved content.

For the successful path, poll the trusted source of truth rather than sleeping for a fixed duration:

await expect
  .poll(
    async () => {
      const response = await request.get(`/api/files/${upload.id}`);

      if (!response.ok()) {
        return `http-${response.status()}`;
      }

      return (await response.json()).status;
    },
    {
      message: 'file should reach the approved state',
      timeout: 30_000,
    },
  )
  .toBe('ready');

Scanner timeout and unavailability should be tested through a service stub, controlled test mode, or isolated integration environment. Do not attempt to break a shared scanner in CI.

Verify Ownership and Delivery Policy

Use separate authenticated API contexts to prove that ownership is enforced at retrieval.

import { test, expect, request as apiRequest } from '@playwright/test';

test('permits the owner and rejects another user', async () => {
  const ownerApi = await apiRequest.newContext({
    baseURL: 'https://test.example.com',
    extraHTTPHeaders: {
      Authorization: `Bearer ${process.env.OWNER_TEST_TOKEN}`,
    },
  });

  const otherUserApi = await apiRequest.newContext({
    baseURL: 'https://test.example.com',
    extraHTTPHeaders: {
      Authorization: `Bearer ${process.env.OTHER_USER_TEST_TOKEN}`,
    },
  });

  try {
    const uploadResponse = await ownerApi.post('/api/uploads', {
      multipart: {
        file: {
          name: 'private-note.txt',
          mimeType: 'text/plain',
          buffer: Buffer.from('Controlled private test content'),
        },
        businessRecordId: 'case-123',
      },
    });

    expect(uploadResponse.ok()).toBeTruthy();

    const upload = await uploadResponse.json();

    await expect
      .poll(async () => {
        const response = await ownerApi.get(`/api/files/${upload.id}`);

        if (!response.ok()) {
          return `http-${response.status()}`;
        }

        return (await response.json()).status;
      })
      .toBe('ready');

    const ownerDownload = await ownerApi.get(
      `/api/files/${upload.id}/content`,
    );

    expect(ownerDownload.ok()).toBeTruthy();

    const headers = ownerDownload.headers();

    expect(headers['content-type']).toBe('text/plain');
    expect(headers['content-disposition']).toMatch(
      /^attachment;\s*filename=/i,
    );
    expect(headers['x-content-type-options']).toBe('nosniff');

    const otherUserDownload = await otherUserApi.get(
      `/api/files/${upload.id}/content`,
    );

    expect([403, 404]).toContain(otherUserDownload.status());
  } finally {
    await ownerApi.dispose();
    await otherUserApi.dispose();
  }
});

Only require attachment when the product is supposed to download the file. Images, videos, and PDFs may intentionally render inline, but they still need the correct content type, ownership policy, cache policy, and origin strategy.

Use dedicated test accounts. Keep tokens in the CI secret store, and never hard-code real user credentials.

Build a Controlled Security Fixture Set

Random files from tester laptops do not create a reliable security suite.

Each fixture should represent one explicit property and have a documented expected decision.

tests/
└── fixtures/
    └── upload-security/
        ├── allowed/
        │   ├── valid-small.pdf
        │   ├── valid-text.txt
        │   └── valid-image.png
        ├── mismatches/
        │   ├── text-named-as-pdf.pdf
        │   ├── png-with-text-mime.png
        │   └── truncated-pdf.pdf
        ├── filenames/
        │   ├── duplicate-name.txt
        │   ├── unicode-name.txt
        │   ├── very-long-name.txt
        │   └── reserved-name.txt
        ├── boundaries/
        │   ├── exact-max.bin
        │   ├── max-plus-one.bin
        │   └── many-small-files/
        ├── archives/
        │   ├── allowed-small.zip
        │   ├── nested-directories.zip
        │   └── mixed-allowed-and-rejected.zip
        └── scanner/
            ├── approved-clean-fixture.bin
            └── approved-scanner-test-fixture.bin

Keep expectations in a manifest:

{
  "text-named-as-pdf.pdf": {
    "declaredMimeType": "application/pdf",
    "detectedType": "text/plain",
    "expectedDecision": "reject",
    "reason": "content_type_mismatch",
    "safeForSharedCi": true
  },
  "approved-scanner-test-fixture.bin": {
    "expectedDecision": "quarantine",
    "environment": "isolated-security-test",
    "owner": "security-team",
    "safeForSharedCi": false
  }
}

Fixture rules:

  • use inert content in routine CI;
  • version fixtures with the policy they test;
  • never use customer uploads;
  • keep security-team fixtures in controlled storage;
  • document whether a fixture is safe for shared runners;
  • remove temporary artifacts after execution;
  • use separate owner, other-user, and other-tenant accounts;
  • record the expected detected type, verdict, and terminal state;
  • generate large boundary buffers when committing binaries is unnecessary.

For example:

const maximumBytes = 5 * 1024 * 1024;

const exactMaximum = Buffer.alloc(maximumBytes, 0x61);
const oneByteOver = Buffer.alloc(maximumBytes + 1, 0x61);

Avoid generating many large buffers concurrently across test workers.

The Release-Gating Security Journey

Do not begin by automating all 102 checks as separate browser scripts.

Start with one clean fixture and one controlled mismatch. Prove the critical boundaries first.

The release-gating path should demonstrate that:

  1. an authorised user can upload a permitted clean fixture;
  2. an unauthorised user cannot upload to the same business record;
  3. an extension or MIME mismatch does not bypass server validation;
  4. the application generates an independent storage identity;
  5. the file remains inaccessible while validation or scanning is pending;
  6. only an approved verdict permits publication;
  7. the owner can retrieve the ready file;
  8. another user and another tenant cannot retrieve it;
  9. delivery headers match the intended inline or attachment policy;
  10. retry creates one logical file rather than a duplicate;
  11. replacement returns to quarantine;
  12. deletion removes retrieval access;
  13. rejected and expired temporary objects are cleaned;
  14. the audit trail records the security decision.

Once that path is reliable, vary the data:

  • role;
  • tenant;
  • extension;
  • declared MIME type;
  • detected content;
  • filename;
  • file size;
  • batch count;
  • quota state;
  • scan result;
  • scanner availability;
  • object version;
  • delivery origin;
  • signed URL lifetime;
  • retention state.

With WrightTest, the visible upload flow can remain stable while the account, fixture, request metadata, security result, and expected terminal state change.

The goal is not to build an automated exploit library. It is to prove that each documented boundary continues to reject, quarantine, isolate, or authorise a file exactly where the product says it should.

Sources