by thetestingacademy
API gateway testing skill covering rate limiting validation, request routing, authentication proxy testing, load balancing verification, circuit breaker testing, and gateway configuration validation for Kong, Envoy, and AWS API Gateway.
npx @qaskills/cli add api-gateway-testingAuto-detects your AI agent and installs the skill. Works with Claude Code, Cursor, Copilot, and more.
You are an expert QA automation engineer specializing in API gateway testing. When the user asks you to write, review, or debug tests for API gateways including rate limiting, routing, authentication proxying, circuit breakers, and gateway configuration validation, follow these detailed instructions.
Always organize API gateway testing projects with this structure:
tests/
gateway/
routing/
path-routing.test.ts
header-routing.test.ts
method-routing.test.ts
rate-limiting/
fixed-window.test.ts
sliding-window.test.ts
per-client.test.ts
auth/
jwt-validation.test.ts
api-key.test.ts
oauth-proxy.test.ts
circuit-breaker/
trip-threshold.test.ts
recovery.test.ts
transformation/
request-transform.test.ts
response-transform.test.ts
cors/
cors-policy.test.ts
load-balancing/
round-robin.test.ts
weighted.test.ts
fixtures/
mock-backend/
server.ts
routes.ts
gateway-config/
kong.yaml
envoy.yaml
utils/
gateway-client.ts
rate-limit-helpers.ts
jwt-helpers.ts
mock-server.ts
config/
test-config.ts
jest.config.ts
// utils/gateway-client.ts
import axios, { AxiosInstance, AxiosResponse } from 'axios';
interface GatewayClientConfig {
baseURL: string;
apiKey?: string;
jwtToken?: string;
timeout?: number;
}
export class GatewayClient {
private client: AxiosInstance;
constructor(config: GatewayClientConfig) {
const headers: Record<string, string> = {};
if (config.apiKey) headers['X-API-Key'] = config.apiKey;
if (config.jwtToken) headers['Authorization'] = `Bearer ${config.jwtToken}`;
this.client = axios.create({
baseURL: config.baseURL,
timeout: config.timeout || 10000,
headers,
validateStatus: () => true, // Never throw on HTTP status
});
}
async get(path: string, headers?: Record<string, string>): Promise<AxiosResponse> {
return this.client.get(path, { headers });
}
async post(path: string, data?: unknown, headers?: Record<string, string>): Promise<AxiosResponse> {
return this.client.post(path, data, { headers });
}
async put(path: string, data?: unknown, headers?: Record<string, string>): Promise<AxiosResponse> {
return this.client.put(path, data, { headers });
}
async delete(path: string, headers?: Record<string, string>): Promise<AxiosResponse> {
return this.client.delete(path, { headers });
}
async sendConcurrent(
method: 'GET' | 'POST',
path: string,
count: number,
headers?: Record<string, string>
): Promise<AxiosResponse[]> {
const requests = Array.from({ length: count }, () =>
method === 'GET' ? this.get(path, headers) : this.post(path, {}, headers)
);
return Promise.all(requests);
}
}
import { describe, it, expect, beforeAll, afterAll } from '@jest/globals';
import { GatewayClient } from '../utils/gateway-client';
describe('Fixed Window Rate Limiting', () => {
const gateway = new GatewayClient({
baseURL: process.env.GATEWAY_URL || 'http://localhost:8000',
apiKey: 'test-api-key-rate-limit',
});
it('should allow requests within the rate limit', async () => {
const RATE_LIMIT = 100; // 100 requests per minute
// Send requests within limit
const responses = await gateway.sendConcurrent('GET', '/api/v1/products', RATE_LIMIT - 10);
const successResponses = responses.filter((r) => r.status === 200);
expect(successResponses.length).toBe(RATE_LIMIT - 10);
// Verify rate limit headers
const lastResponse = responses[responses.length - 1];
expect(lastResponse.headers['x-ratelimit-limit']).toBe('100');
expect(parseInt(lastResponse.headers['x-ratelimit-remaining'])).toBeGreaterThan(0);
});
it('should reject requests exceeding the rate limit with 429', async () => {
const RATE_LIMIT = 100;
// Exhaust the rate limit
const responses = await gateway.sendConcurrent('GET', '/api/v1/products', RATE_LIMIT + 20);
const rejectedResponses = responses.filter((r) => r.status === 429);
expect(rejectedResponses.length).toBeGreaterThan(0);
// Verify 429 response body
const rejected = rejectedResponses[0];
expect(rejected.data).toHaveProperty('message');
expect(rejected.data.message).toContain('rate limit');
// Verify Retry-After header
expect(rejected.headers['retry-after']).toBeDefined();
const retryAfter = parseInt(rejected.headers['retry-after']);
expect(retryAfter).toBeGreaterThan(0);
expect(retryAfter).toBeLessThanOrEqual(60);
});
it('should apply rate limits per client independently', async () => {
const clientA = new GatewayClient({
baseURL: process.env.GATEWAY_URL || 'http://localhost:8000',
apiKey: 'client-a-key',
});
const clientB = new GatewayClient({
baseURL: process.env.GATEWAY_URL || 'http://localhost:8000',
apiKey: 'client-b-key',
});
// Client A exhausts their limit
await clientA.sendConcurrent('GET', '/api/v1/products', 110);
// Client B should still be able to make requests
const responseB = await clientB.get('/api/v1/products');
expect(responseB.status).toBe(200);
});
it('should reset rate limit after the window expires', async () => {
const RATE_LIMIT = 100;
// Exhaust the rate limit
await gateway.sendConcurrent('GET', '/api/v1/products', RATE_LIMIT + 10);
// Verify we are rate limited
const blockedResponse = await gateway.get('/api/v1/products');
expect(blockedResponse.status).toBe(429);
// Wait for window reset (check Retry-After header)
const retryAfter = parseInt(blockedResponse.headers['retry-after']) || 60;
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
// Should be allowed again
const recoveredResponse = await gateway.get('/api/v1/products');
expect(recoveredResponse.status).toBe(200);
}, 120000);
});
describe('Sliding Window Rate Limiting', () => {
const gateway = new GatewayClient({
baseURL: process.env.GATEWAY_URL || 'http://localhost:8000',
apiKey: 'sliding-window-key',
});
it('should enforce sliding window limits across time boundaries', async () => {
// Send 50 requests in first half of the window
const firstBatch = await gateway.sendConcurrent('GET', '/api/v1/data', 50);
expect(firstBatch.filter((r) => r.status === 200).length).toBe(50);
// Wait half the window
await new Promise((resolve) => setTimeout(resolve, 30000));
// Send another 60 requests -- should hit limit since window slides
const secondBatch = await gateway.sendConcurrent('GET', '/api/v1/data', 60);
const rejected = secondBatch.filter((r) => r.status === 429);
expect(rejected.length).toBeGreaterThan(0);
}, 120000);
});
import { describe, it, expect } from '@jest/globals';
import { GatewayClient } from '../utils/gateway-client';
describe('Request Routing', () => {
const gateway = new GatewayClient({
baseURL: process.env.GATEWAY_URL || 'http://localhost:8000',
});
it('should route requests based on path prefix', async () => {
const usersResponse = await gateway.get('/api/v1/users');
expect(usersResponse.status).toBe(200);
expect(usersResponse.headers['x-upstream']).toBe('users-service');
const productsResponse = await gateway.get('/api/v1/products');
expect(productsResponse.status).toBe(200);
expect(productsResponse.headers['x-upstream']).toBe('products-service');
const ordersResponse = await gateway.get('/api/v1/orders');
expect(ordersResponse.status).toBe(200);
expect(ordersResponse.headers['x-upstream']).toBe('orders-service');
});
it('should route based on request headers', async () => {
const v1Response = await gateway.get('/api/products', {
'Accept-Version': 'v1',
});
expect(v1Response.headers['x-api-version']).toBe('v1');
const v2Response = await gateway.get('/api/products', {
'Accept-Version': 'v2',
});
expect(v2Response.headers['x-api-version']).toBe('v2');
});
it('should route based on HTTP method', async () => {
const getResponse = await gateway.get('/api/v1/resources');
expect(getResponse.status).toBe(200);
const postResponse = await gateway.post('/api/v1/resources', { name: 'test' });
expect(postResponse.status).toBe(201);
const deleteResponse = await gateway.delete('/api/v1/resources/123');
expect(deleteResponse.status).toBe(204);
});
it('should return 404 for unmatched routes', async () => {
const response = await gateway.get('/api/v1/nonexistent-endpoint');
expect(response.status).toBe(404);
expect(response.data).toHaveProperty('error');
});
it('should handle path parameter extraction correctly', async () => {
const response = await gateway.get('/api/v1/users/12345/orders');
expect(response.status).toBe(200);
// Backend should receive the extracted path parameters
expect(response.data.userId).toBe('12345');
});
it('should preserve query parameters through routing', async () => {
const response = await gateway.get('/api/v1/products?page=2&limit=10&sort=price');
expect(response.status).toBe(200);
expect(response.data.pagination).toEqual(
expect.objectContaining({
page: 2,
limit: 10,
})
);
});
});
import { describe, it, expect } from '@jest/globals';
import { GatewayClient } from '../utils/gateway-client';
import { generateJWT, generateExpiredJWT } from '../utils/jwt-helpers';
describe('Authentication Proxy', () => {
it('should reject requests without authentication', async () => {
const gateway = new GatewayClient({
baseURL: process.env.GATEWAY_URL || 'http://localhost:8000',
});
const response = await gateway.get('/api/v1/protected/resource');
expect(response.status).toBe(401);
expect(response.data.error).toContain('authentication required');
});
it('should accept valid JWT tokens', async () => {
const token = generateJWT({ sub: 'user-123', role: 'admin' });
const gateway = new GatewayClient({
baseURL: process.env.GATEWAY_URL || 'http://localhost:8000',
jwtToken: token,
});
const response = await gateway.get('/api/v1/protected/resource');
expect(response.status).toBe(200);
// Gateway should forward user identity to upstream
expect(response.headers['x-user-id']).toBe('user-123');
});
it('should reject expired JWT tokens', async () => {
const expiredToken = generateExpiredJWT({ sub: 'user-123' });
const gateway = new GatewayClient({
baseURL: process.env.GATEWAY_URL || 'http://localhost:8000',
jwtToken: expiredToken,
});
const response = await gateway.get('/api/v1/protected/resource');
expect(response.status).toBe(401);
expect(response.data.error).toContain('token expired');
});
it('should reject JWT with invalid signature', async () => {
const tampered = generateJWT({ sub: 'user-123' }, 'wrong-secret-key');
const gateway = new GatewayClient({
baseURL: process.env.GATEWAY_URL || 'http://localhost:8000',
jwtToken: tampered,
});
const response = await gateway.get('/api/v1/protected/resource');
expect(response.status).toBe(401);
});
it('should validate API key authentication', async () => {
const validKeyGateway = new GatewayClient({
baseURL: process.env.GATEWAY_URL || 'http://localhost:8000',
apiKey: 'valid-api-key-12345',
});
const response = await validKeyGateway.get('/api/v1/data');
expect(response.status).toBe(200);
const invalidKeyGateway = new GatewayClient({
baseURL: process.env.GATEWAY_URL || 'http://localhost:8000',
apiKey: 'invalid-key',
});
const rejectedResponse = await invalidKeyGateway.get('/api/v1/data');
expect(rejectedResponse.status).toBe(403);
});
it('should enforce role-based access control', async () => {
const adminToken = generateJWT({ sub: 'admin-1', role: 'admin' });
const viewerToken = generateJWT({ sub: 'viewer-1', role: 'viewer' });
const adminGateway = new GatewayClient({
baseURL: process.env.GATEWAY_URL || 'http://localhost:8000',
jwtToken: adminToken,
});
const viewerGateway = new GatewayClient({
baseURL: process.env.GATEWAY_URL || 'http://localhost:8000',
jwtToken: viewerToken,
});
// Admin can delete
const adminDelete = await adminGateway.delete('/api/v1/resources/123');
expect(adminDelete.status).toBe(204);
// Viewer cannot delete
const viewerDelete = await viewerGateway.delete('/api/v1/resources/456');
expect(viewerDelete.status).toBe(403);
});
});
import { describe, it, expect, beforeEach } from '@jest/globals';
import { GatewayClient } from '../utils/gateway-client';
import { MockServer } from '../utils/mock-server';
describe('Circuit Breaker', () => {
const gateway = new GatewayClient({
baseURL: process.env.GATEWAY_URL || 'http://localhost:8000',
apiKey: 'test-key',
});
let mockBackend: MockServer;
beforeEach(async () => {
mockBackend = new MockServer(3001);
await mockBackend.start();
});
afterEach(async () => {
await mockBackend.stop();
});
it('should trip circuit breaker after consecutive failures', async () => {
// Configure mock to return 500 errors
mockBackend.setResponseCode(500);
const FAILURE_THRESHOLD = 5;
// Send enough requests to trip the circuit breaker
for (let i = 0; i < FAILURE_THRESHOLD + 2; i++) {
await gateway.get('/api/v1/unstable-service');
}
// Circuit should now be open -- gateway returns 503
const response = await gateway.get('/api/v1/unstable-service');
expect(response.status).toBe(503);
expect(response.data.error).toContain('circuit breaker');
});
it('should recover after the circuit breaker timeout', async () => {
// Trip the circuit
mockBackend.setResponseCode(500);
for (let i = 0; i < 10; i++) {
await gateway.get('/api/v1/unstable-service');
}
// Verify circuit is open
const openResponse = await gateway.get('/api/v1/unstable-service');
expect(openResponse.status).toBe(503);
// Fix the backend
mockBackend.setResponseCode(200);
// Wait for circuit breaker timeout (half-open state)
await new Promise((resolve) => setTimeout(resolve, 30000));
// Next request should be allowed through (half-open test)
const recoveryResponse = await gateway.get('/api/v1/unstable-service');
expect(recoveryResponse.status).toBe(200);
}, 60000);
it('should not trip circuit breaker for client errors (4xx)', async () => {
mockBackend.setResponseCode(400);
// Send many 400 errors
for (let i = 0; i < 20; i++) {
await gateway.get('/api/v1/unstable-service');
}
// Circuit should remain closed -- 400s are client errors, not backend failures
mockBackend.setResponseCode(200);
const response = await gateway.get('/api/v1/unstable-service');
expect(response.status).toBe(200);
});
});
describe('Request/Response Transformation', () => {
const gateway = new GatewayClient({
baseURL: process.env.GATEWAY_URL || 'http://localhost:8000',
apiKey: 'test-key',
});
it('should add required headers to upstream requests', async () => {
const response = await gateway.get('/api/v1/echo-headers');
// Gateway should add correlation ID
expect(response.data.receivedHeaders['x-correlation-id']).toBeDefined();
expect(response.data.receivedHeaders['x-correlation-id']).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
);
// Gateway should add request timestamp
expect(response.data.receivedHeaders['x-request-timestamp']).toBeDefined();
});
it('should strip sensitive headers from responses', async () => {
const response = await gateway.get('/api/v1/products');
// Internal headers should be stripped
expect(response.headers['x-internal-service-id']).toBeUndefined();
expect(response.headers['x-debug-trace']).toBeUndefined();
expect(response.headers['server']).toBeUndefined();
});
it('should transform response body format based on Accept header', async () => {
const jsonResponse = await gateway.get('/api/v1/products', {
Accept: 'application/json',
});
expect(jsonResponse.headers['content-type']).toContain('application/json');
const xmlResponse = await gateway.get('/api/v1/products', {
Accept: 'application/xml',
});
expect(xmlResponse.headers['content-type']).toContain('application/xml');
});
});
describe('CORS Policy Enforcement', () => {
const gateway = new GatewayClient({
baseURL: process.env.GATEWAY_URL || 'http://localhost:8000',
});
it('should allow requests from whitelisted origins', async () => {
const response = await gateway.get('/api/v1/products', {
Origin: 'https://app.example.com',
});
expect(response.headers['access-control-allow-origin']).toBe('https://app.example.com');
expect(response.status).toBe(200);
});
it('should reject requests from non-whitelisted origins', async () => {
const response = await gateway.get('/api/v1/products', {
Origin: 'https://malicious-site.com',
});
expect(response.headers['access-control-allow-origin']).toBeUndefined();
});
it('should handle preflight OPTIONS requests correctly', async () => {
const response = await gateway.client.options('/api/v1/products', {
headers: {
Origin: 'https://app.example.com',
'Access-Control-Request-Method': 'POST',
'Access-Control-Request-Headers': 'Content-Type, Authorization',
},
});
expect(response.status).toBe(204);
expect(response.headers['access-control-allow-methods']).toContain('POST');
expect(response.headers['access-control-allow-headers']).toContain('Authorization');
expect(response.headers['access-control-max-age']).toBeDefined();
});
it('should not allow disallowed HTTP methods', async () => {
const response = await gateway.client.options('/api/v1/products', {
headers: {
Origin: 'https://app.example.com',
'Access-Control-Request-Method': 'PATCH',
},
});
const allowedMethods = response.headers['access-control-allow-methods'] || '';
expect(allowedMethods).not.toContain('PATCH');
});
});
# test_gateway.py
import pytest
import requests
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
GATEWAY_URL = "http://localhost:8000"
class TestRateLimiting:
"""Test rate limiting at the API gateway."""
def test_allows_requests_within_limit(self):
"""Should allow requests within the configured rate limit."""
headers = {"X-API-Key": "test-key"}
responses = []
for _ in range(50):
resp = requests.get(f"{GATEWAY_URL}/api/v1/products", headers=headers)
responses.append(resp)
success_count = sum(1 for r in responses if r.status_code == 200)
assert success_count == 50
def test_rejects_requests_exceeding_limit(self):
"""Should return 429 when rate limit is exceeded."""
headers = {"X-API-Key": "burst-test-key"}
def make_request():
return requests.get(f"{GATEWAY_URL}/api/v1/products", headers=headers)
with ThreadPoolExecutor(max_workers=20) as executor:
futures = [executor.submit(make_request) for _ in range(150)]
responses = [f.result() for f in as_completed(futures)]
status_codes = [r.status_code for r in responses]
assert 429 in status_codes, "Expected at least one 429 response"
def test_rate_limit_headers_present(self):
"""Should include rate limit headers in responses."""
headers = {"X-API-Key": "header-test-key"}
resp = requests.get(f"{GATEWAY_URL}/api/v1/products", headers=headers)
assert "X-RateLimit-Limit" in resp.headers
assert "X-RateLimit-Remaining" in resp.headers
assert "X-RateLimit-Reset" in resp.headers
class TestCircuitBreaker:
"""Test circuit breaker behavior at the gateway."""
def test_circuit_opens_after_failures(self):
"""Should open circuit after consecutive backend failures."""
headers = {"X-API-Key": "circuit-test-key"}
# Trigger backend failures
for _ in range(10):
requests.get(f"{GATEWAY_URL}/api/v1/failing-service", headers=headers)
# Circuit should be open
resp = requests.get(f"{GATEWAY_URL}/api/v1/failing-service", headers=headers)
assert resp.status_code == 503
assert "circuit" in resp.json().get("error", "").lower()
def test_circuit_recovers(self):
"""Should allow requests after circuit breaker timeout."""
headers = {"X-API-Key": "recovery-test-key"}
# Trip the circuit
for _ in range(10):
requests.get(f"{GATEWAY_URL}/api/v1/flaky-service", headers=headers)
# Wait for recovery window
time.sleep(30)
resp = requests.get(f"{GATEWAY_URL}/api/v1/flaky-service", headers=headers)
assert resp.status_code in [200, 503] # Half-open state may succeed or fail
class TestHealthChecks:
"""Test gateway health check endpoints."""
def test_gateway_health_endpoint(self):
resp = requests.get(f"{GATEWAY_URL}/health")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "healthy"
def test_gateway_readiness_check(self):
resp = requests.get(f"{GATEWAY_URL}/ready")
assert resp.status_code == 200
data = resp.json()
assert "upstreams" in data
for upstream in data["upstreams"]:
assert upstream["status"] in ["healthy", "degraded"]
def test_gateway_liveness_check(self):
resp = requests.get(f"{GATEWAY_URL}/live")
assert resp.status_code == 200
describe('Gateway Failover', () => {
const gateway = new GatewayClient({
baseURL: process.env.GATEWAY_URL || 'http://localhost:8000',
apiKey: 'failover-key',
});
it('should failover to secondary upstream when primary is down', async () => {
// Simulate primary being down (mock server stopped)
const response = await gateway.get('/api/v1/critical-service');
// Gateway should route to secondary upstream
expect(response.status).toBe(200);
expect(response.headers['x-served-by']).toMatch(/secondary|backup/);
});
it('should return cached response when all upstreams are unavailable', async () => {
// First request populates cache
const initialResponse = await gateway.get('/api/v1/cacheable-data');
expect(initialResponse.status).toBe(200);
// All upstreams down -- should serve stale cache
const cachedResponse = await gateway.get('/api/v1/cacheable-data');
expect(cachedResponse.status).toBe(200);
expect(cachedResponse.headers['x-cache-status']).toBe('STALE');
});
it('should respect timeout configuration for slow upstreams', async () => {
const startTime = Date.now();
const response = await gateway.get('/api/v1/slow-service');
const duration = Date.now() - startTime;
// Gateway should timeout before the slow service responds
expect(response.status).toBe(504);
expect(duration).toBeLessThan(10000); // Should timeout within configured limit
});
});
X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers must be present and accurate.npx jest tests/gateway/ --runInBandnpx jest tests/gateway/rate-limiting/npx jest tests/gateway/auth/npx jest tests/gateway/ --verbosepytest tests/test_gateway.py -vpytest tests/test_gateway.py -n 4npx tsx tests/fixtures/mock-backend/server.tsnpx jest tests/gateway/circuit-breaker/trip-threshold.test.ts- name: Install QA Skills
run: npx @qaskills/cli add api-gateway-testing12 of 29 agents supported