Testing API Routes: Making Sure Your Backend Works
Production Ready — Part 10 of 30
The McDonald's Breach That Changed How I Talk About Testing
In early 2025, security researchers discovered that McDonald's hiring platform, McHire — built on a third-party service called Paradox.ai — had exposed the personal data of 64 million job applicants. Names, email addresses, phone numbers, chat histories, even personality test results.
Here's the part that should keep you up at night: the attack wasn't sophisticated. The admin account had default credentials (literally 123456 / 123456). And once inside, the attacker found that every API endpoint for fetching applicant data accepted a plain sequential integer as an ID — and never checked whether the authenticated user was authorized to see that particular record. You could cycle through IDs like turning pages in a book.
That's called Broken Object Level Authorization (BOLA), and according to APISecurity.io's 2025 year-end review, it was the second most frequent API vulnerability category of the entire year. Meanwhile, missing authentication entirely — endpoints that simply never check who you are — rose to the #1 spot in 2025, accounting for 17% of all reported API incidents.
Neither of these is a mysterious zero-day exploit. Both are things a basic test suite would catch in 30 seconds.
So let's build that test suite.
Why Vibe Coders Skip This (And Why They Regret It)
I've coached hundreds of developers over 25 years. The pattern I see with vibe coders — people who build fast and iterate even faster — is that API testing feels abstract and annoying until the moment it becomes catastrophically urgent. The AI wrote the route, it returned the right data in Postman, you shipped it. Done.
But "returned the right data in Postman" answers exactly one question: does it work when I use it correctly? Production doesn't work correctly. Production sends malformed inputs, expired tokens, someone else's user ID, and 500 concurrent requests at 2am.
Your routes need to handle all of that gracefully. Tests are how you prove they do, before users prove they don't.
The Four Things Every Route Needs to Prove
When I review a new backend, I apply a simple four-part checklist to every route:
- The happy path works — valid input, authenticated user, correct response
- Authentication is enforced — no token = 401, not 200 or 500
- Authorization is enforced — your token can't access someone else's data
- Bad input is handled gracefully — garbage in, clean error out (not a stack trace)
Everything else — performance, edge cases, contract validation — layers on top of these four. Get these right first.
Setting Up: A Simple Express API to Test
Let's use a minimal Node.js/Express API as our example. Imagine this route:
// routes/orders.js
router.get('/orders/:id', authenticate, async (req, res) => {
const order = await db.orders.findById(req.params.id);
if (!order) return res.status(404).json({ error: 'Order not found' });
res.json(order);
});
Notice anything? The route checks if the order exists, but it doesn't check if the order belongs to the requesting user. This is textbook BOLA — exactly what brought down McDonald's hiring platform.
The fix is one line:
router.get('/orders/:id', authenticate, async (req, res) => {
const order = await db.orders.findById(req.params.id);
if (!order) return res.status(404).json({ error: 'Order not found' });
// THE CRITICAL CHECK — don't skip this
if (order.userId !== req.user.id) {
return res.status(403).json({ error: 'Forbidden' });
}
res.json(order);
});
Now let's write tests that force this behavior to be verified on every deploy.
Writing Real Route Tests with Jest + Supertest
Supertest is the standard tool for testing Express routes without spinning up a live server. Install it:
npm install --save-dev jest supertest
Here's a complete test file that covers all four of our required checks:
// tests/orders.test.js
const request = require('supertest');
const app = require('../app');
const { createTestUser, generateToken, seedOrder } = require('./helpers');
describe('GET /orders/:id', () => {
let userA, userB, tokenA, tokenB, orderA;
beforeEach(async () => {
userA = await createTestUser({ email: 'alice@test.com' });
userB = await createTestUser({ email: 'bob@test.com' });
tokenA = generateToken(userA);
tokenB = generateToken(userB);
orderA = await seedOrder({ userId: userA.id, total: 59.99 });
});
// 1. Happy path
it('returns the order for the owner', async () => {
const res = await request(app)
.get(`/orders/${orderA.id}`)
.set('Authorization', `Bearer ${tokenA}`);
expect(res.status).toBe(200);
expect(res.body.id).toBe(orderA.id);
expect(res.body.total).toBe(59.99);
});
// 2. Authentication enforced
it('returns 401 when no token is provided', async () => {
const res = await request(app).get(`/orders/${orderA.id}`);
expect(res.status).toBe(401);
});
// 3. Authorization enforced (BOLA check)
it('returns 403 when user B tries to access user A order', async () => {
const res = await request(app)
.get(`/orders/${orderA.id}`)
.set('Authorization', `Bearer ${tokenB}`);
expect(res.status).toBe(403);
// Critical: the body should NOT contain any order data
expect(res.body.total).toBeUndefined();
});
// 4. Bad input handled gracefully
it('returns 404 for a non-existent order ID', async () => {
const res = await request(app)
.get('/orders/nonexistent-id-99999')
.set('Authorization', `Bearer ${tokenA}`);
expect(res.status).toBe(404);
// Make sure we're not leaking a stack trace
expect(res.body.stack).toBeUndefined();
});
});
That third test — userB trying to access userA's order — is the one that would have caught the McDonald's vulnerability during development. It takes 10 seconds to write. It could have protected 64 million people.
The Python Equivalent (FastAPI + pytest)
If you're on a Python stack, the same philosophy applies with FastAPI and pytest:
# tests/test_orders.py
import pytest
from httpx import AsyncClient
from app.main import app
from tests.factories import create_user, create_order, make_token
@pytest.mark.asyncio
async def test_owner_can_access_order():
user = await create_user()
order = await create_order(user_id=user.id)
token = make_token(user)
async with AsyncClient(app=app, base_url="http://test") as client:
response = await client.get(
f"/orders/{order.id}",
headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == 200
assert response.json()["id"] == str(order.id)
@pytest.mark.asyncio
async def test_other_user_cannot_access_order():
owner = await create_user()
attacker = await create_user()
order = await create_order(user_id=owner.id)
attacker_token = make_token(attacker)
async with AsyncClient(app=app, base_url="http://test") as client:
response = await client.get(
f"/orders/{order.id}",
headers={"Authorization": f"Bearer {attacker_token}"}
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_unauthenticated_request_is_rejected():
async with AsyncClient(app=app, base_url="http://test") as client:
response = await client.get("/orders/any-id")
assert response.status_code == 401
Running Tests in Your CI Pipeline
Tests that only run locally are decorative. The whole point is to block bad code from reaching production automatically.
Here's a minimal GitHub Actions workflow that runs your test suite on every push:
# .github/workflows/test.yml
name: API Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run API tests
run: npm test
env:
DATABASE_URL: postgres://postgres:testpass@localhost:5432/testdb
JWT_SECRET: test-secret-not-for-production
Now any pull request that breaks authentication or authorization gets blocked before it merges. The Volkswagen breach of 2025 — where their connected car app's OTP endpoint had no rate limiting, letting attackers brute-force login in seconds — would have been blocked by a test that simply verifies the 429 response kicks in after N failed attempts. Security researchers documented the full attack chain: a multithreaded Python script cycled through all 10,000 four-digit combos in under a minute. One rate-limit test. One blocked PR. Millions of vehicle owners protected.
What to Test Beyond the Happy Path
Once the four core checks pass, here's the priority order for expanding coverage:
| Priority | Test Type | What It Catches |
|---|---|---|
| 🔴 Critical | Auth/authz on every protected route | BOLA, missing auth (top 2 vulnerabilities in 2025) |
| 🔴 Critical | Input validation on write routes | Injection attacks, data corruption |
| 🟡 High | Error response format | Stack traces leaking internals |
| 🟡 High | Pagination / limit enforcement | Unbounded data dumps |
| 🟢 Medium | Rate limiting behavior | Brute force, DDoS amplification |
| 🟢 Medium | Response schema validation | Schema drift breaking consumers |
| 🔵 Nice-to-have | Performance under load | Latency regressions |
Start with the red row. Get 100% coverage there before writing a single performance test. Organizations test only 38% of their APIs for vulnerabilities on average — being in the tested 100% puts you in rare company.
A Quick Note on Test Data
The biggest friction point I see in new test suites is test data setup. Two rules that will save you weeks of pain:
Rule 1: Always use a separate test database. Set NODE_ENV=test and point it at a different DB than development. Never run tests against real data.
Rule 2: Reset between tests. Each test should start clean. Use beforeEach to seed only what that test needs. Shared state between tests is how you get tests that pass in isolation and fail in CI.
// In your test setup file
beforeEach(async () => {
await db.orders.deleteMany({});
await db.users.deleteMany({});
});
afterAll(async () => {
await db.$disconnect();
});
Your Action Checklist
- Install
supertest+jest(Node) orhttpx+pytest(Python) in your project - Write a test for every protected route that checks: happy path, 401 without token, 403 for wrong user, 404 for missing resource
- Add the authorization check (
resource.userId !== req.user.id) to every route that returns user-specific data — then write the test that would fail without it - Add your test command to a GitHub Actions workflow that runs on every PR to
main - Audit your existing routes: open each one and ask "can a valid user access another user's data through this endpoint?"
- Set up a separate test database and confirm
NODE_ENV=testroutes to it - Add at minimum one negative test (bad input, expired token) for every route you add going forward
Ask The Guild
Community prompt: What's the most embarrassing API bug you've shipped to production — and what test would have caught it? Drop it in the thread below. Bonus points if you include the one-line test that would have blocked it. We learn faster from war stories than from documentation, and I promise: whatever you share, someone in this guild has done something worse. 🙂