Business Logic and File Upload Testing
Business logic testing checks whether an application enforces its own rules when a user does not follow the intended path: skipping a step, changing a value the browser was supposed to set, or using something more often than allowed. The code can be free of injection and XSS and still let a customer buy a 500 SAR phone for 1 SAR, because the server trusted a price field sent by the browser.
File upload testing is the same idea applied to one risky input. A file has a name, a declared type, a size and real content, and each of them can lie. The lesson covers both because they share a method: read the rule, then try every way a dishonest or careless user could break it, with harmless test data on a system you are authorised to test.
Why it matters
Vulnerability scanners rarely find these defects, because nothing is technically malformed: the request is valid JSON, the file is a valid upload. Only a tester who understands what the business intends can see that discount: 100 or a second confirm should have been refused. OWASP lists this class twice: Insecure Design (A06:2025) in the Top 10, and Unrestricted Access to Sensitive Business Flows (API6:2023) in the API Security Top 10, where automated abuse of a purchase, booking or sign-up flow is the stated risk.
The cost is direct money and trust: free orders, drained coupons, stock bought out by bots, and an uploaded file that executes on the server or runs script in another user's browser. For the tester, this is the part of security testing that needs the least tooling and the most thinking, and it is where functional testing skills transfer directly.
Workflow abuse: skipped steps and tampered fields
Every multi-step flow is a state machine: cart, coupon applied, awaiting payment, paid, confirmed. The browser shows the steps in order, but the server receives separate requests and must check, on every one, that the order is in the right state. Testing starts by listing the states and the requests that move between them, as in the lesson on state transition testing, then trying the moves the design does not allow: calling the confirmation request before payment, reordering steps (choosing shipping after paying), or repeating a step that should happen once.
The second group of tests changes values the server should own. Price, discount, total, currency, role and is_admin fields have no business arriving from the client; if they do, change them and see whether the server recomputes or believes them. Add the negative and extreme values you know from boundary value analysis: quantity -1 (a refund disguised as a purchase), 0, 999999, a discount of 101 percent, a price of 0.001.
- Skip: send the last step first. Expected: a
4xxerror and no change of state. - Reorder and repeat: pay twice, confirm twice, apply a coupon to an empty cart. Expected: the second call is refused or is a harmless no-op.
- Tamper: change
price,total,discountorrolein the request, as in the payment request below for an order of 1,899.00. Expected:400or422, or the field is ignored; either way the server uses its own values and the response shows them. A200that charges 1.00 is the defect. - Limits and reuse: one coupon per customer, three attempts per hour, five items per order. Expected: the server counts each use, so the limit holds across sessions, devices and page reloads, and a code meant to be used once in total also fails from a second account.
POST /orders/8841/pay HTTP/1.1
Host: shop.example
Content-Type: application/json
Cookie: session=...
{ "payment_token": "tok_test_123", "total": 1.00, "currency": "SAR" }Try it: State Transition Generator Try it: API Request Builder
Race conditions: the same request twice at once
A race condition is a defect that appears only when two or more requests touch the same record at nearly the same time. The classic case is double spend: a wallet holds 100 SAR, two withdrawals of 100 SAR arrive together, both read the balance before either writes it, and both succeed. The same shape produces two orders from one double-click on "Place order", a single-use coupon redeemed twice, or a limit of five attempts that lets six through.
At this level the concept matters more than the tooling. The expected behaviour is that the server makes the check and the change in one indivisible (atomic) step, usually with a row lock inside a database transaction, an update that succeeds only if the balance is still enough, or a unique constraint, so that exactly one request wins. To test, submit the same request two or more times with as little delay as possible (two tabs, a script that fires in parallel, or the repeat function of an intercepting proxy) and compare the final state with what one request should produce. Idempotency keys on payment requests, which you may see as an Idempotency-Key header, are the design answer for retries and worth asking the developer about.
- Double submit: the order button pressed twice. Expected: one order, one charge, one e-mail.
- Double spend: two withdrawals or redemptions at once. Expected: exactly one succeeds and the balance never goes below zero.
- Counter limits: many parallel attempts against a limit. Expected: the limit holds exactly; the attempt after the limit is refused however the requests are timed.
File uploads: name, type, size, content and where it lands
An upload gives the application three things that can disagree: a file name with its extension, a declared media (MIME) type in the Content-Type of the multipart part, and the bytes themselves. The client chooses the first two freely. A file called report.pdf with Content-Type application/pdf can contain an HTML page. Secure handling, as the OWASP File Upload Cheat Sheet describes it, is an allow list of extensions, a check of the actual content (its file signature, the first bytes that identify the format, and for images rewriting them), a size limit, a server-generated file name, and storage on a separate host or outside the web root (the folder the web server serves files from), where the server will not run the file as code.
The second half of the test is what happens when the file is served back. A stored HTML or SVG file opened at a URL on the application's own origin runs script with the user's session: a stored XSS delivered by upload. Safe responses send the exact Content-Type the server decided on, X-Content-Type-Options: nosniff, and Content-Disposition: attachment (the browser downloads the file instead of displaying it) for anything other than a raster image such as PNG or JPEG whose content the server has verified, or serve user files from a separate domain. An SVG is an image too, but it can carry script, so it gets the same treatment as HTML.
- Type: a harmless text file renamed
.jpg; an image sent with Content-Typetext/html; a double extensionphoto.jpg.html; an uppercase.PHP. Expected: the final extension, compared without regard to case, is on the allow list and the content matches it; the declared type alone never decides. - Size: exactly the limit, one byte over, an empty file. Expected: accepted; rejected with a clear message; rejected or handled as empty.
- Name:
../../qa-traversal-test.png, a path traversal attempt: if the server builds the storage path from the original name, each../climbs one folder up, out of the upload folder (the name is unique, so a defect creates a new file instead of overwriting one); a 300-character name, spaces, Arabic letters,%00(an encoded null byte), a name that is only an extension. Expected: stored under a server-generated name; the original name is only displayed, encoded. - Served back: open the returned URL. Expected: the correct
Content-Type,nosniff,attachmentor a separate origin; nothing runs. - Storage and execution: ask where files land and whether that path is executable; if a text file with a
.phpor.aspxname is accepted at all, it must download as text, never execute.
Try it: MIME Type Lookup Try it: Content-Type Validator Try it: Special Character Dataset Generator
Worked example: a checkout with a single-use coupon
Requirement: "A customer adds items to the cart, may apply one coupon (single-use per customer, minimum order 200 SAR), pays through the payment provider and receives a confirmation. Stock is reduced when the order is confirmed." The requests are POST /cart/items, POST /cart/coupon, POST /orders (creates the order and returns the total), POST /orders/{id}/pay and POST /orders/{id}/confirm.
Model it as states first, Cart, Coupon applied, Awaiting payment, Paid and Confirmed, then derive one test per forbidden move, one per field the server must own, and the boundary values of each rule (199.99 and 200.00 for the minimum order, checked again when items are removed after the coupon). Each test has a clear oracle: the order's state and total after the call, read back with GET /orders/{id}, and the stock count.
| # | From | To | Request / expected server behaviour |
|---|---|---|---|
| 1 | CartStart | Coupon applied | POST /cart/coupon / the server checks the code: valid, unused by this customer, order at least 200.00 |
| 2 | Coupon applied | Awaiting payment | POST /orders / the server computes the total from its own prices and the discount |
| 3 | Awaiting payment | Paid | POST /orders/{id}/pay / the payment provider confirms the server's total |
| 4 | Paid | ConfirmedEnd | POST /orders/{id}/confirm / order confirmed, stock reduced once, one e-mail |
| 5 | Awaiting payment | ConfirmedEnd | Abuse: confirm sent while the order still awaits payment, skipping /pay / expected 4xx, the order stays Awaiting paymentalternative path |
| 6 | Awaiting payment | Paid | Abuse: "total": 1.00 in the payment body / expected 4xx or the field ignored; the server's own total is chargedalternative path |
| 7 | Coupon applied | Coupon applied | Abuse: the same code applied again, or after it was spent / expected 4xx, the discount counted oncealternative path |
| 8 | ConfirmedEnd | ConfirmedEnd | Abuse: confirm replayed, or two sent in parallel / expected one confirmation, stock reduced oncealternative path |
A simplified model: the coupon is optional in reality, and a real checkout adds address, shipping, refund and cancellation states.
# Test Expected
1 POST /orders/{id}/confirm before /pay 4xx; stays Awaiting payment
2 /pay with "total": 1.00 in the body 4xx or ignored; server total kept
3 /cart/coupon twice with the same code second call 4xx; discount once
4 coupon on a 199.99 cart (minimum 200.00) 4xx; no discount
5 coupon on a 200.00 cart accepted; discount applied
6 coupon at 200.00, then an item removed discount dropped, or /orders 4xx
7 /cart/items with "quantity": -1 4xx; cart unchanged
8 /confirm sent twice in parallel one confirmation; stock reduced once
9 /pay on another customer's order 403 or 404 (access control)
10 coupon already used on an earlier order 4xx; no discountTry it: State Transition Generator Try it: API Request Builder
Common mistakes
- Testing the happy path through the UI only, where the browser hides the steps and fields that an attacker edits directly.
- Reporting a changed
pricefield as a defect without checking whether the server actually used it; the total the server stored and charged, read back after the call, is the evidence. - Checking the file extension and declaring the upload safe, when the content and the response headers were never examined.
- Using real malware, exploit files or a colleague's personal data as test uploads. A text file with a wrong name is enough to prove the check.
- Running race-condition or limit tests against production or a shared environment, where double orders and drained coupons affect real data.
- Forgetting the second half of the test: where the file is stored, how it is served back and whether it can ever execute.
The QA perspective
On a team, business logic testing starts in refinement: when a story mentions a limit, a discount, a step order or an upload, the tester asks what the server does when the rule is broken and writes the answer as acceptance criteria. The tests are then derived like any other state transition or boundary tests and kept in the regression suite, because a refactor of the checkout can silently reintroduce trust in a client field.
You test only systems you own or have written authorisation to test, with test accounts, test payment tokens and harmless files, and you tell the team before firing parallel requests at a shared environment. A finding records the request, the single change you made, the response and the resulting state, with severity set by money and reach: a free order or an upload that executes is critical; a limit exceeded by one is usually minor.
Practice exercise
Plan the tests for a support-ticket attachment
A helpdesk lets a signed-in customer attach files to a ticket with POST /tickets/{id}/attachments: PDF, PNG and JPEG only, at most 10 MB, up to 3 files per ticket. Support agents open attachments from the ticket page, where the file is served from /attachments/{uuid}.
Prepare a test plan of harmless files and requests, with the expected result for each, that covers type, size, name, the count limit and how the file is served back. You need no real system: name the files you would create and what each one proves.
- List the test files: valid ones, disguised ones and oversized ones, and how you would create each without malware.
- List the requests that break a rule: the limit, the state, another customer's ticket.
- Write what you would check in the response and in the served file for each.
Show the model answer
Files: valid.pdf (a real one-page PDF), valid.png and valid.jpg (real images), all accepted; a valid PDF of exactly 10 MB, accepted, and the same file one byte larger, rejected with a size message (ask first whether 10 MB means 10,000,000 or 10,485,760 bytes); an empty file; page.html renamed page.png (HTML content under an image name); note.txt renamed note.pdf.exe; script.svg (an SVG with a <script> element that only shows an alert); a real PNG sent with Content-Type text/html; and a real PNG named ../../qa-traversal-test.png, a unique name that cannot overwrite anything. Expected: the empty, disguised, SVG and .exe uploads are refused with a clear message and nothing is stored; the PNG with the wrong Content-Type is refused, or stored and served as image/png; the traversal name is refused or stored under a server-generated name, and no file appears outside the upload folder.
Requests: a fourth attachment on a ticket that already has three (expected 4xx); two uploads sent in parallel to a ticket with two files, when only one slot remains (expected exactly one accepted, never four stored); an upload to another customer's ticket id (expected 403 or 404, as in the access control lesson); an upload to a closed ticket if the rule says closed tickets are read-only (expected 4xx, nothing stored).
Served back: open /attachments/{uuid} for the PNG and the PDF. Expected: a Content-Type matching the stored type, X-Content-Type-Options: nosniff, Content-Disposition: attachment for the PDF, the original file name only in the ticket page text, encoded, a URL that cannot be guessed from another ticket, and 403 or 404 when another customer's session requests it.
Evidence to record: each request, the response status and body, the ticket's attachment list after the call, and the response headers of the served file.
Frequently asked questions
Is business logic testing a security activity or a functional one?
Both. The tests are derived like functional tests, from the rules and the states, but the oracle is what a dishonest user must not be able to do. OWASP covers it under Insecure Design and, for APIs, under Unrestricted Access to Sensitive Business Flows.
Can I test race conditions by hand?
Partly. Two browser tabs submitting at once, or a double-click on a button, find the common double-submit defect. Reliable results need requests fired in parallel from a script or a proxy, and a test environment where double orders do no harm.
Which test files are safe to use for uploads?
Plain text, a real small image or PDF, and the same files renamed or sent with a wrong Content-Type. An SVG with a harmless <script> that shows an alert proves the served-back risk. Never use real malware or someone else's personal documents.
Does checking the extension make an upload safe?
No. The extension is a name the client chooses. Safe handling checks the content, enforces the size, generates its own file name, stores the file where nothing executes it, and serves it with a fixed Content-Type and nosniff.
References
- OWASP Web Security Testing Guide (WSTG)The reference methodology for testing web applications and APIs for security weaknesses, test by test.
- OWASP Cheat Sheet Series: File UploadValidating uploaded files: extension and content-type checks, size limits, storage outside the web root, scanning.
- OWASP API Security Top 10 (2023)The ten most common API weaknesses: broken object-level authorization, broken authentication, unrestricted resource consumption and more.
- OWASP Top 10:2025The ten most critical web application security risks, with how to test for and prevent each.
Practise with Mutqan tools
Define states and events, then generate a transition table, valid/invalid transition tests and 0-switch/1-switch coverage.
Generate edge cases for strings, numbers, dates, files, lists and concurrency from a field definition.
Compose and send HTTP requests (method, URL, headers, auth, body) and inspect the response — a lightweight Postman in your browser.
Find the MIME type for a file extension, or the extensions for a MIME type.
Validate Content-Type headers and check that a response body actually matches its declared media type.
Generate inputs full of special characters: quotes, SQL/HTML/JSON metacharacters, control chars, emoji and path tricks.
Go deeper
Your progress on this topic
Not started
Mark the topic complete when you have studied it and done the exercise.
Sign in to track your progress
Mark topics complete, continue where you left off and unlock specialisation exams.