diff --git a/.gitignore b/.gitignore index 134ff6fb..733e990c 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,13 @@ dist/ build/ *.tsbuildinfo +# k6 test results (but track .gitkeep to preserve directory) +k6/results/* +!k6/results/.gitkeep + +# Generated HTML reports (including k6 output) +*.html + # Editors .vscode/ .idea/ diff --git a/README.md b/README.md index 74b7d805..97e6c36f 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Express 5 REST API for EquipChain — a decentralized utility meter monitoring a - [Architecture Overview](#architecture-overview) - [Configuration Reference](#configuration-reference) - [Development Guide](#development-guide) +- [Load Testing with k6](#load-testing-with-k6) - [Deployment Guide](#deployment-guide) - [Contributing](#contributing) - [Related](#related) @@ -251,7 +252,7 @@ Same parameters as daily-summary, returns monthly rollups. Connect to `ws://localhost:3000/ws`. -### Example Response +### Example Responses ```json GET / @@ -353,7 +354,7 @@ EquipChain-backend/ Request │ ▼ -[Logger] → HTTP request logging +[Logger] → HTTP request logging (Pino + Correlation ID) [Rate Limiter] → Rate limiting per IP/user [CORS] → Cross-origin resource sharing [Auth] → JWT verification for protected routes @@ -452,6 +453,99 @@ docker run -p 3000:3000 --env-file .env equipchain-backend --- +## Load Testing with k6 + +Performance testing is implemented using [Grafana k6](https://k6.io), an open-source load testing tool to identify performance bottlenecks under stress. + +### Installation + +Install k6 by following the [official installation guide](https://k6.io/docs/get-started/installation/): + +```bash +# macOS +brew install k6 + +# Ubuntu/Debian +sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69 +echo "deb https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list +sudo apt-get update && sudo apt-get install k6 + +# Windows (winget) +winget install k6 +``` + +Verify installation: + +```bash +k6 version +``` + +### Test Scenarios + +All test scripts are located in the `k6/` directory and are parameterizable via environment variables. + +| Test | File | Description | Command | +|------|------|-------------|---------| +| **Smoke** | `k6/smoke.js` | 1 VU performing all API operations for 30s. Verifies basic functionality under no load. | `npm run k6:smoke` | +| **Load** | `k6/load.js` | Ramp up to 50 VUs over 1 min, sustain for 3 min, ramp down over 1 min. 80% reads, 20% writes. | `npm run k6:load` | +| **Stress** | `k6/stress.js` | Gradual increase from 10 → 50 → 100 → 200 → 500 VUs to identify the breaking point. | `npm run k6:stress` | +| **Spike** | `k6/spike.js` | Sudden jump from 0 to 200 VUs in 10s, sustain for 1 min, then cool down. | `npm run k6:spike` | +| **Soak** | `k6/soak.js` | 50 VUs sustained for 30+ minutes to detect memory leaks and performance degradation. | `npm run k6:soak` | +| **Quick** | (all except soak) | Runs smoke, load, stress, and spike tests sequentially. | `npm run k6:quick` | + +### Configuration + +Override the base URL and other parameters via environment variables: + +```bash +# Point to a different environment +k6 run k6/smoke.js -e BASE_URL=https://staging.example.com + +# Override soak test duration and concurrency +k6 run k6/soak.js -e DURATION=60m -e VUS=100 +``` + +### Metrics Collected + +Each test measures and reports: + +| Metric | Description | +|--------|-------------| +| **Request Rate (RPS)** | Number of requests per second | +| **Response Time Percentiles** | p50, p75, p90, p95, p99 — median and tail latency | +| **Error Rate** | Percentage of failed/non-2xx requests | +| **Checks** | Application-level assertions (e.g., status is 200, body has required fields) | + +### Generating HTML Reports + +Generate visual HTML reports for detailed analysis: + +```bash +k6 run --out html=k6/results/load-report.html k6/load.js +``` + +### CI Integration + +The standard CI workflow (`.github/workflows/ci.yml`) runs unit tests only (`npm test`). For load testing in CI, add a separate workflow step that installs k6 and runs the smoke test as a quick health check: + +```yaml +- name: Install k6 + run: | + curl -fsSL https://github.com/grafana/k6/releases/download/v0.54.0/k6-v0.54.0-linux-amd64.tar.gz | tar -xz + sudo cp k6-v0.54.0-linux-amd64/k6 /usr/local/bin/ + +- name: Run k6 smoke test + run: k6 run k6/smoke.js + env: + BASE_URL: ${{ secrets.BASE_URL }} +``` + +### Results + +Test results (HTML reports, JSON summaries) are stored in `k6/results/`. This directory is gitignored and will not be committed to the repository. + +--- + ## Deployment Guide ### Docker (Recommended) diff --git a/k6/load.js b/k6/load.js new file mode 100644 index 00000000..c7c3cd64 --- /dev/null +++ b/k6/load.js @@ -0,0 +1,57 @@ +/** + * Load Test + * + * Purpose: Determine how the API performs under expected normal traffic. + * Scenarios: Ramp up to 50 VUs over 1 min, stay at 50 for 3 min, ramp down to 0 over 1 min. + * Mix: 80 % read operations (GET), 20 % write operations (POST). + * Thresholds: p95 < 2000 ms, error rate < 1 %. + * + * Run: k6 run k6/load.js + * k6 run k6/load.js -e BASE_URL=https://staging.example.com + */ + +import { check, sleep } from 'k6'; +import http from 'k6/http'; +import { BASE_URL, DEFAULT_HEADERS, randomWallet } from './shared.js'; + +export const options = { + stages: [ + { target: 50, duration: '1m' }, // Ramp up to 50 VUs + { target: 50, duration: '3m' }, // Stay at 50 VUs + { target: 0, duration: '1m' }, // Ramp down to 0 + ], + thresholds: { + http_req_duration: ['p(95)<2000'], + http_req_failed: ['rate<0.01'], + checks: ['rate>0.99'], + }, + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(50)', 'p(75)', 'p(90)', 'p(95)', 'p(99)'], +}; + +export default function () { + // 80 % read operations + if (Math.random() < 0.8) { + // Read: GET / + const resp = http.get(`${BASE_URL}/`, { + headers: DEFAULT_HEADERS, + }); + check(resp, { + 'load-read: status is 200': (r) => r.status === 200, + 'load-read: has project': (r) => r.json('project') !== undefined, + }); + sleep(1); + } else { + // Write: POST /api/auth/challenge + const wallet = randomWallet(); + const resp = http.post( + `${BASE_URL}/api/auth/challenge`, + JSON.stringify({ wallet }), + { headers: DEFAULT_HEADERS }, + ); + check(resp, { + 'load-write: status is 200': (r) => r.status === 200, + 'load-write: token returned': (r) => r.json('token') !== undefined, + }); + sleep(1); + } +} diff --git a/k6/results/.gitkeep b/k6/results/.gitkeep new file mode 100644 index 00000000..69337f50 --- /dev/null +++ b/k6/results/.gitkeep @@ -0,0 +1,8 @@ +# k6 Test Results Directory +# +# This directory stores HTML reports and JSON summaries generated by k6 load tests. +# The directory is gitignored via .gitignore, but this .gitkeep ensures the +# directory exists when the repository is first cloned. +# +# Generate a report: +# k6 run --out html=k6/results/report.html k6/smoke.js diff --git a/k6/shared.js b/k6/shared.js new file mode 100644 index 00000000..6fe2da7b --- /dev/null +++ b/k6/shared.js @@ -0,0 +1,65 @@ +/** + * Shared configuration, thresholds, and helper functions for k6 load tests. + * + * All test scenarios import from this module to maintain consistent defaults. + * Environment variables override any value at runtime. + * + * Usage: + * import { BASE_URL, thresholds, randomWallet } from './shared.js'; + */ + +// --------------------------------------------------------------------------- +// Base URL & Default Headers +// --------------------------------------------------------------------------- +export const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000'; + +export const DEFAULT_HEADERS = { + 'Content-Type': 'application/json', + Accept: 'application/json', +}; + +// --------------------------------------------------------------------------- +// Thresholds (applied across all tests; individual tests may override) +// --------------------------------------------------------------------------- +export const THRESHOLDS = { + /** 95 % of requests should complete under 2 seconds */ + http_req_duration: ['p(95)<2000'], + /** Less than 1 % of requests may return errors */ + http_req_failed: ['rate<0.01'], + /** All checks must pass (100 % success rate) */ + checks: ['rate===1'], +}; + +// --------------------------------------------------------------------------- +// Helper Functions +// --------------------------------------------------------------------------- + +/** + * Generate a random wallet address for auth challenge tests. + * @returns {string} Random Stellar-like wallet address + */ +export function randomWallet() { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + let wallet = 'G'; + for (let i = 0; i < 55; i++) { + wallet += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return wallet; +} + +/** + * Return a random integer between min and max (inclusive). + */ +export function randomInt(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +/** + * Simulated think / sleep time to mimic real user behaviour. + * @param {number} t - base time in seconds + * @param {number} [jitter=0.5] - random jitter fraction (0..1) + */ +export function thinkTime(t, jitter = 0.5) { + const sleep = t * (1 + Math.random() * jitter); + return sleep; +} diff --git a/k6/smoke.js b/k6/smoke.js new file mode 100644 index 00000000..4a0099fc --- /dev/null +++ b/k6/smoke.js @@ -0,0 +1,87 @@ +/** + * Smoke Test + * + * Purpose: Verify the API responds correctly under minimal load. + * Scenarios: 1 virtual user performing all primary operations. + * Duration: 30 seconds. + * Thresholds: All requests succeed, p95 < 1000 ms. + * + * Run: k6 run k6/smoke.js + * k6 run k6/smoke.js -e BASE_URL=https://staging.example.com + */ + +import { check } from 'k6'; +import http from 'k6/http'; +import { BASE_URL, DEFAULT_HEADERS, randomWallet } from './shared.js'; + +// Allow both success (2xx) and expected auth failures (401) — 401 is intentional behaviour +http.setResponseCallback(http.expectedStatuses(200, 401)); + +export const options = { + vus: 1, + duration: '30s', + thresholds: { + http_req_duration: ['p(95)<1000'], + http_req_failed: ['rate<0.01'], + checks: ['rate===1'], + }, + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(50)', 'p(75)', 'p(90)', 'p(95)', 'p(99)'], +}; + +export default function () { + // 1. GET / – Project metadata + const homeResp = http.get(`${BASE_URL}/`, { + headers: DEFAULT_HEADERS, + }); + check(homeResp, { + 'home: status is 200': (r) => r.status === 200, + 'home: body has project field': (r) => r.json('project') !== undefined, + 'home: body has contract field': (r) => r.json('contract') !== undefined, + }); + + // 2. GET /api/health – Health check + const healthResp = http.get(`${BASE_URL}/api/health`, { + headers: DEFAULT_HEADERS, + }); + check(healthResp, { + 'health: status is 200': (r) => r.status === 200, + 'health: body is healthy': (r) => r.json('status') === 'healthy', + 'health: has uptime field': (r) => r.json('uptime') !== undefined, + }); + + // 3. POST /api/auth/challenge – Auth challenge + const wallet = randomWallet(); + const authResp = http.post( + `${BASE_URL}/api/auth/challenge`, + JSON.stringify({ wallet }), + { headers: DEFAULT_HEADERS }, + ); + const token = authResp.json('token'); + check(authResp, { + 'auth: status is 200': (r) => r.status === 200, + 'auth: token is returned': (r) => token !== undefined, + 'auth: token is non-empty': (r) => typeof token === 'string' && token.length > 0, + }); + + // 4. GET /api/protected – Protected route (using token from auth challenge) + const protectedHeaders = { + ...DEFAULT_HEADERS, + Authorization: `Bearer ${token}`, + }; + const protectedResp = http.get(`${BASE_URL}/api/protected`, { + headers: protectedHeaders, + }); + check(protectedResp, { + 'protected: status is 200': (r) => r.status === 200, + 'protected: returns data': (r) => r.json('data') === 'Sensitive meter data', + }); + + // 5. GET /api/protected – Without token (expected 401) + const unauthorizedResp = http.get(`${BASE_URL}/api/protected`, { + headers: DEFAULT_HEADERS, + }); + check(unauthorizedResp, { + 'unauthorized: status is 401': (r) => r.status === 401, + 'unauthorized: returns error': (r) => r.json('error') !== undefined, + }); +} diff --git a/k6/soak.js b/k6/soak.js new file mode 100644 index 00000000..a6a164b3 --- /dev/null +++ b/k6/soak.js @@ -0,0 +1,88 @@ +/** + * Soak / Endurance Test + * + * Purpose: Detect memory leaks and performance degradation over extended periods. + * Scenarios: 50 VUs sustained for 30+ minutes. + * Thresholds: p95 < 2000 ms, error rate < 1 %. + * + * Run: k6 run k6/soak.js + * k6 run k6/soak.js -e BASE_URL=https://staging.example.com + * k6 run k6/soak.js -e DURATION=60m -e VUS=100 + */ + +import { check, sleep } from 'k6'; +import http from 'k6/http'; +import { BASE_URL, DEFAULT_HEADERS, randomWallet, randomInt } from './shared.js'; + +// Allow duration and VUs to be overridden via environment variables +const DURATION = __ENV.DURATION || '30m'; +const VUS = parseInt(__ENV.VUS, 10) || 50; + +export const options = { + vus: VUS, + duration: DURATION, + thresholds: { + http_req_duration: ['p(95)<2000'], + http_req_failed: ['rate<0.01'], + checks: ['rate>0.99'], + }, + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(50)', 'p(75)', 'p(90)', 'p(95)', 'p(99)'], +}; + +export default function () { + // Realistic user behaviour: mix of operations with think time + const choice = Math.random(); + + if (choice < 0.35) { + // GET / + const resp = http.get(`${BASE_URL}/`, { headers: DEFAULT_HEADERS }); + check(resp, { + 'soak-home: status is 200': (r) => r.status === 200, + }); + } else if (choice < 0.55) { + // GET /api/health + const resp = http.get(`${BASE_URL}/api/health`, { headers: DEFAULT_HEADERS }); + check(resp, { + 'soak-health: status is 200': (r) => r.status === 200, + }); + } else if (choice < 0.75) { + // POST /api/auth/challenge + const wallet = randomWallet(); + const resp = http.post( + `${BASE_URL}/api/auth/challenge`, + JSON.stringify({ wallet }), + { headers: DEFAULT_HEADERS }, + ); + check(resp, { + 'soak-auth: status is 200': (r) => r.status === 200, + }); + } else if (choice < 0.9) { + // GET /api/protected (with token) + const wallet = randomWallet(); + const authResp = http.post( + `${BASE_URL}/api/auth/challenge`, + JSON.stringify({ wallet }), + { headers: DEFAULT_HEADERS }, + ); + const token = authResp.json('token'); + if (token) { + const resp = http.get(`${BASE_URL}/api/protected`, { + headers: { ...DEFAULT_HEADERS, Authorization: `Bearer ${token}` }, + }); + check(resp, { + 'soak-protected: status is 200': (r) => r.status === 200, + }); + } + } else { + // Unauthorized access (401 expected) + const resp = http.get(`${BASE_URL}/api/protected`, { + headers: DEFAULT_HEADERS, + }); + check(resp, { + 'soak-unauthorized: status is 401': (r) => r.status === 401, + }); + } + + // Think time: simulate real user pause between actions + sleep(randomInt(1, 3)); +} diff --git a/k6/spike.js b/k6/spike.js new file mode 100644 index 00000000..140d480c --- /dev/null +++ b/k6/spike.js @@ -0,0 +1,65 @@ +/** + * Spike Test + * + * Purpose: Verify the API can handle sudden traffic surges. + * Scenarios: Sudden jump from 0 → 200 VUs, sustain for 1 min, then immediate drop. + * Thresholds: p99 < 3000 ms, error rate < 2 %. + * + * Run: k6 run k6/spike.js + * k6 run k6/spike.js -e BASE_URL=https://staging.example.com + */ + +import { check, sleep } from 'k6'; +import http from 'k6/http'; +import { BASE_URL, DEFAULT_HEADERS, randomWallet } from './shared.js'; + +export const options = { + stages: [ + { target: 200, duration: '10s' }, // Instant spike to 200 VUs + { target: 200, duration: '1m' }, // Sustain spike + { target: 0, duration: '10s' }, // Cool down + ], + thresholds: { + http_req_duration: ['p(99)<3000'], + http_req_failed: ['rate<0.02'], + checks: ['rate>0.98'], + }, + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(50)', 'p(75)', 'p(90)', 'p(95)', 'p(99)'], +}; + +export default function () { + // Primarily read operations (GET) during spike + const resp = http.get(`${BASE_URL}/`, { + headers: DEFAULT_HEADERS, + tags: { operation: 'spike-read' }, + }); + check(resp, { + 'spike: status is 200': (r) => r.status === 200, + 'spike: has project': (r) => r.json('project') !== undefined, + }); + + // Every 5th request also hits the health endpoint + if (__ITER % 5 === 0) { + const healthResp = http.get(`${BASE_URL}/api/health`, { + headers: DEFAULT_HEADERS, + }); + check(healthResp, { + 'spike-health: status is 200': (r) => r.status === 200, + }); + } + + // Every 10th request also hits the auth endpoint + if (__ITER % 10 === 0) { + const wallet = randomWallet(); + const authResp = http.post( + `${BASE_URL}/api/auth/challenge`, + JSON.stringify({ wallet }), + { headers: DEFAULT_HEADERS }, + ); + check(authResp, { + 'spike-auth: status is 200': (r) => r.status === 200, + }); + } + + sleep(0.3); +} diff --git a/k6/stress.js b/k6/stress.js new file mode 100644 index 00000000..286584ff --- /dev/null +++ b/k6/stress.js @@ -0,0 +1,80 @@ +/** + * Stress Test + * + * Purpose: Identify the breaking point of the API by gradually increasing load. + * Scenarios: Ramp up from 10 → 50 → 100 → 200 → 500 VUs in stages. + * Thresholds: p95 < 5000 ms, error rate < 5 % (relaxed for high load). + * + * Run: k6 run k6/stress.js + * k6 run k6/stress.js -e BASE_URL=https://staging.example.com + */ + +import { check, sleep } from 'k6'; +import http from 'k6/http'; +import { BASE_URL, DEFAULT_HEADERS, randomWallet } from './shared.js'; + +export const options = { + stages: [ + { target: 10, duration: '30s' }, // Warm-up + { target: 50, duration: '1m' }, // Moderate load + { target: 100, duration: '1m' }, // High load + { target: 200, duration: '1m' }, // Very high load + { target: 500, duration: '2m' }, // Stress peak + { target: 0, duration: '30s' }, // Cool down + ], + thresholds: { + http_req_duration: ['p(95)<5000'], + http_req_failed: ['rate<0.05'], + checks: ['rate>0.95'], + }, + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(50)', 'p(75)', 'p(90)', 'p(95)', 'p(99)'], +}; + +export default function () { + // Mix of endpoints to simulate realistic traffic + const choice = Math.random(); + + if (choice < 0.4) { + // GET / + const resp = http.get(`${BASE_URL}/`, { headers: DEFAULT_HEADERS }); + check(resp, { + 'stress-home: status is 200': (r) => r.status === 200, + }); + } else if (choice < 0.7) { + // GET /api/health + const resp = http.get(`${BASE_URL}/api/health`, { headers: DEFAULT_HEADERS }); + check(resp, { + 'stress-health: status is 200': (r) => r.status === 200, + }); + } else if (choice < 0.9) { + // POST /api/auth/challenge + const wallet = randomWallet(); + const resp = http.post( + `${BASE_URL}/api/auth/challenge`, + JSON.stringify({ wallet }), + { headers: DEFAULT_HEADERS }, + ); + check(resp, { + 'stress-auth: status is 200': (r) => r.status === 200, + }); + } else { + // GET /api/protected (with token) + const wallet = randomWallet(); + const authResp = http.post( + `${BASE_URL}/api/auth/challenge`, + JSON.stringify({ wallet }), + { headers: DEFAULT_HEADERS }, + ); + const token = authResp.json('token'); + if (token) { + const resp = http.get(`${BASE_URL}/api/protected`, { + headers: { ...DEFAULT_HEADERS, Authorization: `Bearer ${token}` }, + }); + check(resp, { + 'stress-protected: status is 200': (r) => r.status === 200, + }); + } + } + + sleep(0.5); +} diff --git a/package.json b/package.json index 7f15e568..b863c8d3 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,13 @@ "main": "index.js", "scripts": { "test": "NODE_ENV=test node --test", - "start": "node index.js" + "start": "node index.js", + "k6:smoke": "k6 run k6/smoke.js", + "k6:load": "k6 run k6/load.js", + "k6:stress": "k6 run k6/stress.js", + "k6:spike": "k6 run k6/spike.js", + "k6:soak": "k6 run k6/soak.js", + "k6:quick": "k6 run k6/smoke.js && k6 run k6/load.js && k6 run k6/stress.js && k6 run k6/spike.js" }, "dependencies": { "@opentelemetry/api": "^1.9.1", diff --git a/test/server.test.js b/test/server.test.js index 6712fb29..61ba433f 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -6,7 +6,7 @@ const server = app.listen(0); after(() => server.close()); -it('responds with project info', async () => { +it('GET / responds with project info', async () => { const res = await fetch(`http://localhost:${server.address().port}/`); assert.strictEqual(res.status, 200); @@ -15,3 +15,86 @@ it('responds with project info', async () => { assert.strictEqual(data.status, 'Monitoring Meters'); assert.ok(data.contract); }); + +it('GET /api/health returns healthy status', async () => { + const res = await fetch(`http://localhost:${server.address().port}/api/health`); + assert.strictEqual(res.status, 200); + + const data = await res.json(); + assert.strictEqual(data.status, 'healthy'); + assert.ok(typeof data.uptime === 'number'); + assert.ok(typeof data.timestamp === 'number'); +}); + +it('POST /api/auth/challenge returns a token', async () => { + const res = await fetch( + `http://localhost:${server.address().port}/api/auth/challenge`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ wallet: 'GANONEXISTENT123' }), + }, + ); + assert.strictEqual(res.status, 200); + + const data = await res.json(); + assert.ok(data.token); + assert.strictEqual(data.expiresIn, 3600); +}); + +it('POST /api/auth/challenge works without wallet', async () => { + const res = await fetch( + `http://localhost:${server.address().port}/api/auth/challenge`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }, + ); + assert.strictEqual(res.status, 200); + + const data = await res.json(); + assert.ok(data.token); +}); + +it('GET /api/protected returns data with valid token', async () => { + // First get a token + const authRes = await fetch( + `http://localhost:${server.address().port}/api/auth/challenge`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ wallet: 'GTESTWALLET123' }), + }, + ); + const { token } = await authRes.json(); + + // Use token to access protected route + const res = await fetch( + `http://localhost:${server.address().port}/api/protected`, + { headers: { Authorization: `Bearer ${token}` } }, + ); + assert.strictEqual(res.status, 200); + + const data = await res.json(); + assert.strictEqual(data.data, 'Sensitive meter data'); + assert.ok(data.contract); +}); + +it('GET /api/protected returns 401 without Authorization header', async () => { + const res = await fetch( + `http://localhost:${server.address().port}/api/protected`, + ); + assert.strictEqual(res.status, 401); + + const data = await res.json(); + assert.strictEqual(data.error, 'Unauthorized'); +}); + +it('GET /api/protected returns 401 without Bearer prefix', async () => { + const res = await fetch( + `http://localhost:${server.address().port}/api/protected`, + { headers: { Authorization: 'Token some-value' } }, + ); + assert.strictEqual(res.status, 401); +});