From 98b01bf48f8a6bf3f2b9d5a6f3790d789095eb20 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sat, 9 May 2026 08:08:10 -0400 Subject: [PATCH] Add k6 load test scripts for Darkwatch authentication endpoints (FRE-4928) - darkwatch-auth.js: k6 script testing POST /auth/login, /auth/logout, /auth/refresh - P99 thresholds: login <200ms, logout <100ms, refresh <150ms - Config: 500 req/s sustained for 5 minutes - Mixed workload scenario + individual endpoint scenarios - .env.example and run.sh for execution Co-Authored-By: Paperclip --- load-tests/darkwatch-auth/.env.example | 20 ++ load-tests/darkwatch-auth/darkwatch-auth.js | 293 ++++++++++++++++++++ load-tests/darkwatch-auth/run.sh | 69 +++++ 3 files changed, 382 insertions(+) create mode 100644 load-tests/darkwatch-auth/.env.example create mode 100644 load-tests/darkwatch-auth/darkwatch-auth.js create mode 100755 load-tests/darkwatch-auth/run.sh diff --git a/load-tests/darkwatch-auth/.env.example b/load-tests/darkwatch-auth/.env.example new file mode 100644 index 0000000..7f30ac3 --- /dev/null +++ b/load-tests/darkwatch-auth/.env.example @@ -0,0 +1,20 @@ +# Darkwatch Auth Load Test Configuration +# Copy to .env and adjust values + +# Base URL of the Darkwatch API +DARKWATCH_BASE_URL=http://localhost:3000 + +# Test credentials for load testing +TEST_EMAIL=loadtest@darkwatch.shieldai +TEST_PASSWORD=LoadTest2026! + +# Test duration (default: 300s = 5 minutes) +DURATION=300s + +# Target requests per second (default: 500) +TARGET_RPS=500 + +# P99 latency thresholds in milliseconds +LOGIN_P99_MS=200 +LOGOUT_P99_MS=100 +REFRESH_P99_MS=150 diff --git a/load-tests/darkwatch-auth/darkwatch-auth.js b/load-tests/darkwatch-auth/darkwatch-auth.js new file mode 100644 index 0000000..dd7a34a --- /dev/null +++ b/load-tests/darkwatch-auth/darkwatch-auth.js @@ -0,0 +1,293 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate, Trend } from 'k6/metrics'; + +// ── Configuration ──────────────────────────────────────────────────────────── +const BASE_URL = __ENV.DARKWATCH_BASE_URL || 'http://localhost:3000'; +const TEST_EMAIL = __ENV.TEST_EMAIL || 'loadtest@darkwatch.shieldai'; +const TEST_PASSWORD = __ENV.TEST_PASSWORD || 'LoadTest2026!'; +const DURATION = __ENV.DURATION || '300s'; // 5 minutes +const TARGET_RPS = parseInt(__ENV.TARGET_RPS || '500', 10); + +// P99 latency thresholds (ms) +const THRESHOLDS = { + login: parseInt(__ENV.LOGIN_P99_MS || '200', 10), + logout: parseInt(__ENV.LOGOUT_P99_MS || '100', 10), + refresh: parseInt(__ENV.REFRESH_P99_MS || '150', 10), +}; + +// ── Custom Metrics ─────────────────────────────────────────────────────────── +const loginLatency = new Trend('login_p99'); +const logoutLatency = new Trend('logout_p99'); +const refreshLatency = new Trend('refresh_p99'); + +const loginSuccess = new Rate('login_success'); +const logoutSuccess = new Rate('logout_success'); +const refreshSuccess = new Rate('refresh_success'); + +// ── Helpers ────────────────────────────────────────────────────────────────── +function uuidv4() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +const authHeaders = { + 'Content-Type': 'application/json', +}; + +// ── Scenario: Login (POST /auth/login) ────────────────────────────────────── +function testLogin() { + const uniqueEmail = `${TEST_EMAIL.replace('@', `_${uuidv4().slice(0, 8)}@`)}`; + + const payload = JSON.stringify({ + email: uniqueEmail, + password: TEST_PASSWORD, + }); + + const res = http.post(`${BASE_URL}/auth/login`, payload, { headers: authHeaders }); + const duration = res.timings.duration; + loginLatency.add(duration); + + const success = res.status === 200 || res.status === 201; + loginSuccess.add(success); + + check(res, { + 'login: status 200 or 201': (r) => r.status === 200 || r.status === 201, + 'login: has access_token': (r) => { + try { + const json = JSON.parse(r.body); + return !!json.access_token || !!json.token || !!json.data?.access_token; + } catch { + return false; + } + }, + `login: P99 < ${THRESHOLDS.login}ms`: (r) => duration < THRESHOLDS.login, + }); + + try { + const json = JSON.parse(res.body); + return { + accessToken: json.access_token || json.token || json.data?.access_token || uuidv4(), + refreshToken: json.refresh_token || json.data?.refresh_token || uuidv4(), + userId: json.user?.id || json.data?.user?.id || uuidv4(), + }; + } catch { + return { + accessToken: uuidv4(), + refreshToken: uuidv4(), + userId: uuidv4(), + }; + } +} + +// ── Scenario: Refresh (POST /auth/refresh) ────────────────────────────────── +function testRefresh(refreshToken) { + const token = refreshToken || uuidv4(); + + const payload = JSON.stringify({ + refresh_token: token, + }); + + const res = http.post(`${BASE_URL}/auth/refresh`, payload, { headers: authHeaders }); + const duration = res.timings.duration; + refreshLatency.add(duration); + + const success = res.status === 200; + refreshSuccess.add(success); + + check(res, { + 'refresh: status 200': (r) => r.status === 200, + 'refresh: has new access_token': (r) => { + try { + const json = JSON.parse(r.body); + return !!json.access_token || !!json.token || !!json.data?.access_token; + } catch { + return false; + } + }, + `refresh: P99 < ${THRESHOLDS.refresh}ms`: (r) => duration < THRESHOLDS.refresh, + }); + + try { + const json = JSON.parse(res.body); + return { + accessToken: json.access_token || json.token || json.data?.access_token || uuidv4(), + refreshToken: json.refresh_token || json.data?.refresh_token || token, + }; + } catch { + return { + accessToken: uuidv4(), + refreshToken: token, + }; + } +} + +// ── Scenario: Logout (POST /auth/logout) ──────────────────────────────────── +function testLogout(accessToken) { + const token = accessToken || uuidv4(); + + const payload = JSON.stringify({ + access_token: token, + }); + + const res = http.post(`${BASE_URL}/auth/logout`, payload, { + headers: { + ...authHeaders, + Authorization: `Bearer ${token}`, + }, + }); + const duration = res.timings.duration; + logoutLatency.add(duration); + + const success = res.status === 200 || res.status === 204; + logoutSuccess.add(success); + + check(res, { + 'logout: status 200 or 204': (r) => r.status === 200 || r.status === 204, + `logout: P99 < ${THRESHOLDS.logout}ms`: (r) => duration < THRESHOLDS.logout, + }); +} + +// ── Default Scenario: Weighted mixed workload ──────────────────────────────── +export const options = { + scenarios: { + sustained_load: { + executor: 'constant-arrival-rate', + duration: DURATION, + rate: TARGET_RPS, + preAllocatedVUs: 20, + maxVUs: 100, + startTime: '0s', + exec: 'mixedWorkload', + tags: { scenario: 'sustained_load' }, + }, + }, + thresholds: { + `login_p99`: [`p(99)<${THRESHOLDS.login}`], + `logout_p99`: [`p(99)<${THRESHOLDS.logout}`], + `refresh_p99`: [`p(99)<${THRESHOLDS.refresh}`], + `login_success`: ['rate>0.95'], + `logout_success`: ['rate>0.95'], + `refresh_success`: ['rate>0.95'], + http_req_duration: [`p(95)<300`, `p(99)<400`], + http_req_failed: ['rate<0.05'], + }, +}; + +// Mixed workload: 40% login, 35% refresh, 25% logout +export function mixedWorkload() { + const rand = Math.random(); + + if (rand < 0.4) { + const tokens = testLogin(); + sleep(0.05); + testRefresh(tokens.refreshToken); + sleep(0.02); + testLogout(tokens.accessToken); + } else if (rand < 0.75) { + const tokens = testLogin(); + sleep(0.05); + testRefresh(tokens.refreshToken); + } else { + const tokens = testLogin(); + sleep(0.05); + testLogout(tokens.accessToken); + } + + sleep(0.05); +} + +// ── Individual endpoint scenarios for targeted testing ─────────────────────── +export const endpointScenarios = { + login_only: { + executor: 'constant-arrival-rate', + duration: DURATION, + rate: TARGET_RPS, + preAllocatedVUs: 20, + maxVUs: 100, + exec: 'loginOnly', + startTime: '0s', + tags: { scenario: 'login_only' }, + }, + logout_only: { + executor: 'constant-arrival-rate', + duration: DURATION, + rate: TARGET_RPS, + preAllocatedVUs: 20, + maxVUs: 100, + exec: 'logoutOnly', + startTime: '0s', + tags: { scenario: 'logout_only' }, + }, + refresh_only: { + executor: 'constant-arrival-rate', + duration: DURATION, + rate: TARGET_RPS, + preAllocatedVUs: 20, + maxVUs: 100, + exec: 'refreshOnly', + startTime: '0s', + tags: { scenario: 'refresh_only' }, + }, +}; + +export function loginOnly() { + testLogin(); + sleep(0.1); +} + +export function logoutOnly() { + const tokens = testLogin(); + sleep(0.05); + testLogout(tokens.accessToken); + sleep(0.05); +} + +export function refreshOnly() { + const tokens = testLogin(); + sleep(0.05); + testRefresh(tokens.refreshToken); + sleep(0.05); +} + +// ── Summary Hook ───────────────────────────────────────────────────────────── +export function handleSummary(data) { + return { + 'stdout': `\n=== Darkwatch Auth Load Test Results ===\n`, + 'summary.json': JSON.stringify({ + timestamp: new Date().toISOString(), + duration: DURATION, + targetRPS: TARGET_RPS, + thresholds: THRESHOLDS, + metrics: { + login: { + p99: data.metrics.login_p99?.values['p(99)']?.toFixed(2) || 'N/A', + p95: data.metrics.login_p99?.values['p(95)']?.toFixed(2) || 'N/A', + avg: data.metrics.login_p99?.values.avg?.toFixed(2) || 'N/A', + count: data.metrics.login_p99?.values.count || 0, + successRate: (data.metrics.login_success?.values.rate || 0) * 100 + '%', + }, + logout: { + p99: data.metrics.logout_p99?.values['p(99)']?.toFixed(2) || 'N/A', + p95: data.metrics.logout_p99?.values['p(95)']?.toFixed(2) || 'N/A', + avg: data.metrics.logout_p99?.values.avg?.toFixed(2) || 'N/A', + count: data.metrics.logout_p99?.values.count || 0, + successRate: (data.metrics.logout_success?.values.rate || 0) * 100 + '%', + }, + refresh: { + p99: data.metrics.refresh_p99?.values['p(99)']?.toFixed(2) || 'N/A', + p95: data.metrics.refresh_p99?.values['p(95)']?.toFixed(2) || 'N/A', + avg: data.metrics.refresh_p99?.values.avg?.toFixed(2) || 'N/A', + count: data.metrics.refresh_p99?.values.count || 0, + successRate: (data.metrics.refresh_success?.values.rate || 0) * 100 + '%', + }, + }, + passed: Object.entries(data.metrics).every( + ([_, metric]) => metric?.thresholds?.every?.((t) => t.pass) + ), + }, null, 2), + }; +} diff --git a/load-tests/darkwatch-auth/run.sh b/load-tests/darkwatch-auth/run.sh new file mode 100755 index 0000000..40028b4 --- /dev/null +++ b/load-tests/darkwatch-auth/run.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Run k6 load tests for Darkwatch authentication endpoints +# Usage: ./run.sh [scenario] +# scenario: mixed (default), login, logout, refresh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Load environment variables from .env if present +if [[ -f .env ]]; then + set -a + source .env + set +a +fi + +SCENARIO="${1:-mixed}" +OUTPUT_DIR="${SCRIPT_DIR}/results" +TIMESTAMP="$(date +%Y%m%d-%H%M%S)" + +mkdir -p "$OUTPUT_DIR" + +echo "=== Darkwatch Auth Load Test ===" +echo "Scenario: $SCENARIO" +echo "Target RPS: ${TARGET_RPS:-500}" +echo "Duration: ${DURATION:-300s}" +echo "Base URL: ${DARKWATCH_BASE_URL:-http://localhost:3000}" +echo "" + +case "$SCENARIO" in + mixed) + k6 run darkwatch-auth.js \ + --out json="$OUTPUT_DIR/results-${TIMESTAMP}.json" \ + <