Ch. 22 · Software Testing

Security Tests for Rejected Operations

Test what each user must not do: a table-driven access matrix for an invoice API catches a missing ownership check that happy paths miss.

~7 min readadvancedupdated Oct 11, 2026

Most test suites are lists of things users can do. Log in, view an invoice, update a note. Each test sends a valid request and checks a successful response. That’s necessary, but it leaves out the half of the behavior that security depends on: the requests that must fail.

Broken access control has been at the top of the OWASP Top 10 since 2021, and the most common form is embarrassingly simple. An endpoint checks that you’re logged in, then returns whatever record you ask for, without checking that it’s yours. Change /invoices/1001 to /invoices/1002 in the address bar and you’re reading someone else’s invoice. This is called an insecure direct object reference, or IDOR. No amount of testing logged-in users reading their own data will ever find it, because the bug only appears when someone asks for something they shouldn’t have.

Building the denial matrix

Negative security tests are easier to write, and much harder to forget, if you derive them systematically. List the dimensions that decide whether a request should be allowed:

  • Authentication state: no token, a malformed or unknown token, an expired token, a valid one.
  • Role: customer, support agent, admin, and any role that can read but not write.
  • Ownership: your own record, another user’s record, a record in another tenant or organisation, a record that doesn’t exist.
  • Operation: read, update, delete, and any action endpoints (/refund, /export).
  • Input shape: fields that may be edited versus fields that must never be set by the client (owner, status, price, role), and values outside allowed limits.

Then write down the expected outcome for each combination that matters. You don’t need the full cross product. You need every combination where the answer should be “no”, plus a few “yes” cases as controls so you know the tests can tell the difference.

An invoice API with one missing check

Here’s a small API built on node:http. Customers can read and annotate their own invoices; support agents can read any invoice but change nothing. The relevant part of the request handler:

const token = req.headers.authorization?.replace(/^Bearer /, '');
const session = sessions.get(token);
if (!session) return send(res, 401, { error: 'unauthenticated' });

const invoice = invoices.get(match[1]);
const canSee = invoice && (invoice.ownerId === session.userId || session.role === 'support');
if (!canSee) return send(res, 404, { error: 'not_found' }); // same answer as "doesn't exist"

if (req.method === 'GET') return send(res, 200, invoice);

if (req.method === 'PATCH') {
  if (session.role !== 'customer') return send(res, 403, { error: 'forbidden' });
  // ...parse JSON body...
  const allowed = ['note'];
  const unknown = Object.keys(changes).filter((k) => !allowed.includes(k));
  if (unknown.length) return send(res, 400, { error: 'field_not_editable', fields: unknown });
  if (typeof changes.note !== 'string' || changes.note.length > 200) return send(res, 400, { error: 'invalid_note' });
  invoice.note = changes.note;
  return send(res, 200, invoice);
}
JavaScript

To show what the tests catch, the app can also be started with the ownership condition removed, so canSee is simply “the invoice exists”. That’s the IDOR version: authentication still works, roles still work, but any customer can read and edit any invoice.

The tests, written as a table

Each row is one actor making one request, with the status the API must return. The test starts a real server on a random port and talks to it with fetch, so routing, header parsing and JSON handling are all exercised.

const cases = [
  // who      method   invoice  body                        expected  why
  ['ana',    'GET',   '1001',  undefined,                  200, 'owner reads own invoice'],
  ['ana',    'GET',   '1002',  undefined,                  404, "customer reads someone else's invoice"],
  ['ana',    'GET',   '9999',  undefined,                  404, 'missing invoice looks the same'],
  [null,     'GET',   '1001',  undefined,                  401, 'no token'],
  ['forged', 'GET',   '1001',  undefined,                  401, 'unknown token'],
  ['ana',    'PATCH', '1002',  { note: 'mine now' },       404, "customer edits someone else's invoice"],
  ['sam',    'GET',   '1002',  undefined,                  200, 'support reads any invoice'],
  ['sam',    'PATCH', '1002',  { note: 'refunded' },       403, 'support cannot edit'],
  ['ana',    'PATCH', '1001',  { status: 'paid' },         400, 'status is not editable'],
  ['ana',    'PATCH', '1001',  { note: 'x'.repeat(201) },  400, 'note over 200 chars'],
];

for (const [who, method, id, body, expected, why] of cases) {
  test(`${why}: ${method} /invoices/${id} as ${who ?? 'anonymous'} -> ${expected}`, async () => {
    const snapshot = structuredClone([...invoices.values()]);
    const res = await call(method, `/invoices/${id}`, who && `tok-${who}`, body);

    assert.equal(res.status, expected);
    if (expected >= 400) {
      const text = await res.text();
      assert.doesNotMatch(text, /amountCents/, 'error responses must not include invoice data');
      assert.deepEqual([...invoices.values()], snapshot, 'a rejected request must not change anything');
    }
  });
}
JavaScript

Against the IDOR version:

✔ owner reads own invoice: GET /invoices/1001 as ana -> 200 (24.489041ms)
✖ customer reads someone else's invoice: GET /invoices/1002 as ana -> 404 (2.3365ms)
✔ missing invoice looks the same: GET /invoices/9999 as ana -> 404 (3.140708ms)
✔ no token: GET /invoices/1001 as anonymous -> 401 (1.651875ms)
✔ unknown token: GET /invoices/1001 as forged -> 401 (5.897875ms)
✖ customer edits someone else's invoice: PATCH /invoices/1002 as ana -> 404 (5.425667ms)
✔ support reads any invoice: GET /invoices/1002 as sam -> 200 (2.906542ms)
✔ support cannot edit: PATCH /invoices/1002 as sam -> 403 (1.642333ms)
✔ status is not editable: PATCH /invoices/1001 as ana -> 400 (4.51975ms)
✔ note over 200 chars: PATCH /invoices/1001 as ana -> 400 (2.833959ms)

  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:

  200 !== 404
Text

Eight of ten pass. Every happy-path test passes, and so do the authentication and role checks. The only two failures are the rows where a customer reaches for an invoice that isn’t theirs, which is exactly the vulnerability. With the ownership check in place, all ten pass.

That’s the key lesson. The IDOR version would ship with a fully green suite of positive tests, plus green tests for “no token” and “wrong role”, because those parts work. Only the ownership rows see it.

Asserting more than the status code

A status code alone can lie. An endpoint could return 403 and include the invoice in the body, or reject the request after it already saved the change. Each denied case above checks two more things.

The body leaks nothing. The test asserts the error response doesn’t contain invoice fields. In real APIs, the leaks are usually subtler: an error message like invoice 1002 belongs to ben@example.com, a stack trace with a SQL query, or a validation error that echoes the stored record.

Nothing changed. Every rejected request is checked against a snapshot of stored data. A handler that applies the update and then checks permissions (it happens, especially with ORMs that save on assignment) returns the right status and still corrupts data.

The 404-versus-403 choice is deliberate. Answering 403 for ben’s invoice and 404 for a missing one tells an attacker which IDs exist, which helps them enumerate customers or count orders. Returning the same 404 for both, and testing that they really are the same ('missing invoice looks the same' is in the table for that reason), closes that channel. Within the caller’s own scope, 403 is still the right answer: support agent Sam can see invoice 1002, so telling Sam “you can’t edit this” leaks nothing new.

The mass-assignment row ({ status: 'paid' }) covers a related bug. Frameworks that copy request bodies onto models will happily let a client set status, ownerId or role unless the code allow-lists editable fields. The test proves the allow-list exists and that the forbidden field didn’t sneak through.

Where these tests belong

Put access-control tests at the API level, not in the UI. Hiding the “Edit” button from support agents is a convenience; the server’s 403 is the control. A UI test that checks the button is hidden says nothing about what a request crafted with curl can do.

Keep the matrix complete as the API grows. A practical trick is to generate it from the route table: for every route, require at least one “other user’s resource” case and one “no token” case, and fail the build when a new route has none. New endpoints are where IDOR bugs tend to appear, because the developer copied an existing handler without its ownership check.

These tests complement, rather than replace, other security work. Scanners and penetration testers explore inputs you didn’t think of; your tests pin down the rules you know about so they can’t regress. Run them only against test environments with synthetic users, never against production data.

Interview practice

A multi-tenant SaaS application stores each customer company’s projects with a tenant_id. All queries go through a repository that adds WHERE tenant_id = ? from the current session. How would you test that one tenant can never see another tenant’s data, and what kinds of bugs would you specifically go looking for?

Answer

I’d create two tenants in the test setup, each with a user and some projects, and write API-level tests where tenant A’s user requests tenant B’s resources by ID: read, update, delete, and any export or search endpoints. Every one should return the same 404 as a nonexistent ID, leak nothing in the body, and leave B’s data unchanged. As a control, A’s user must still succeed on A’s own resources.

Then I’d look for places that bypass the repository’s automatic filter, since that’s where real tenant leaks come from: raw SQL queries, reporting or analytics endpoints, background jobs that process “all projects”, caches keyed only by project ID (so the second tenant gets the first tenant’s cached result), search indexes that aren’t partitioned by tenant, file downloads served from storage by key, and joins where the filter applies to one table but not the joined one. For search and list endpoints, I’d assert that results contain only A’s items, not merely that a known B item is missing. Finally, I’d add a check that every new route appears in the cross-tenant test matrix, so the coverage doesn’t erode as endpoints are added.

More in Software Testing

read ✓Software Testing · mid

Contract Testing Across Independent Services

Consumer-driven contracts in practice: a shipping service records what it needs from orders, and a renamed field fails before deploy.

~7 min readread →
read ✓Software Testing · hard

Testing Time with an Injected Clock

Pass a clock into time-dependent code, test expiry boundaries to the millisecond with node:test, and see why sleep-based tests flake.

~7 min readread →
read ✓Software Testing · mid

Code Coverage and Its Limits

See a 100% covered function ship a boundary bug, read line and branch numbers from Node's coverage report, and set thresholds that help.

~6 min readread →
esc