openclaw/load-tests/gateway-auth.ts
ronitchidara 1546fb5c04 enterprise: add Phase 5 observability and enterprise features
Observability:
- HTTP health endpoints (/health, /ready, /health/deep)
- Prometheus metrics endpoint (/metrics) with prom-client
- W3C traceparent request tracing with AsyncLocalStorage
- Trace ID propagation to logs and diagnostic events

Enterprise:
- JSONL audit logging with daily rotation and 7-day retention
- RBAC permission engine with flat roles (admin, operator, user, viewer)
- Tool and agent access restrictions per role
- Audit integration with pairing, auth, and exec approval flows

Documentation:
- Enterprise deployment guide (single/multi-tenant, K8s, Docker)
- Security hardening guide (TLS, RBAC, rate limiting)
- Observability guide (Prometheus, Grafana, alerting)
- Self-healing behaviors documentation

Load Testing:
- Connection stress test (WebSocket saturation)
- Chat throughput test (sustained message load)
- Auth stress test (rate limit verification)
- Configurable scenarios with p50/p95/p99 latency metrics

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 17:01:41 +05:30

334 lines
8.5 KiB
TypeScript

/**
* Gateway Authentication Stress Test
*
* Tests the gateway's rate limiting and auth backoff mechanisms.
* Verifies that the gateway correctly limits failed auth attempts
* and applies exponential backoff.
*/
import { randomUUID } from "node:crypto";
import { WebSocket } from "ws";
import type { LoadTestConfig, LoadTestMetrics } from "./config.js";
import { aggregateErrors, mean, percentile } from "./config.js";
type AuthAttempt = {
startMs: number;
latencyMs: number;
success: boolean;
error?: string;
rateLimited: boolean;
};
/**
* Run the authentication stress test.
*/
export async function runAuthStressTest(config: LoadTestConfig): Promise<LoadTestMetrics> {
const startTime = Date.now();
const attempts: AuthAttempt[] = [];
const errors: Array<{ message: string; timestamp: number }> = [];
let connectionsAttempted = 0;
let connectionsSucceeded = 0;
let connectionsFailed = 0;
let rateLimitHits = 0;
let authFailures = 0;
const log = config.verbose
? (msg: string) => console.log(`[${new Date().toISOString()}] ${msg}`)
: () => {};
log(`Starting auth stress test: ${config.concurrency} concurrent attempts`);
// Phase 1: Rapid auth attempts with invalid tokens
log("Phase 1: Testing invalid token rejection...");
const invalidTokenAttempts = await runAuthAttempts({
config,
count: 20,
concurrency: 5,
token: "invalid-token-" + randomUUID(),
log,
});
for (const attempt of invalidTokenAttempts) {
attempts.push(attempt);
connectionsAttempted++;
if (attempt.success) {
connectionsSucceeded++;
} else {
connectionsFailed++;
authFailures++;
if (attempt.error) {
errors.push({ message: attempt.error, timestamp: attempt.startMs });
}
}
if (attempt.rateLimited) {
rateLimitHits++;
}
}
log(`Phase 1 complete: ${authFailures} auth failures, ${rateLimitHits} rate limits`);
// Phase 2: Verify backoff is applied
log("Phase 2: Verifying rate limit backoff...");
await sleep(1000);
const backoffAttempts = await runAuthAttempts({
config,
count: 10,
concurrency: 1,
token: "invalid-token-" + randomUUID(),
log,
});
for (const attempt of backoffAttempts) {
attempts.push(attempt);
connectionsAttempted++;
if (attempt.success) {
connectionsSucceeded++;
} else {
connectionsFailed++;
authFailures++;
if (attempt.error) {
errors.push({ message: attempt.error, timestamp: attempt.startMs });
}
}
if (attempt.rateLimited) {
rateLimitHits++;
}
}
log(`Phase 2 complete: ${rateLimitHits} total rate limits observed`);
// Phase 3: Valid token should still work (if provided)
if (config.token) {
log("Phase 3: Verifying valid token still works...");
await sleep(2000);
const validTokenAttempts = await runAuthAttempts({
config,
count: 5,
concurrency: 1,
token: config.token,
log,
});
for (const attempt of validTokenAttempts) {
attempts.push(attempt);
connectionsAttempted++;
if (attempt.success) {
connectionsSucceeded++;
log("Valid token accepted");
} else {
connectionsFailed++;
if (attempt.error) {
errors.push({ message: attempt.error, timestamp: attempt.startMs });
}
if (attempt.rateLimited) {
rateLimitHits++;
log("Valid token rate limited (expected during cooldown)");
} else {
authFailures++;
}
}
}
}
const endTime = Date.now();
// Calculate latency metrics
const latencies = attempts.map((a) => a.latencyMs).sort((a, b) => a - b);
const metrics: LoadTestMetrics = {
scenario: "auth-stress",
startTime,
endTime,
durationMs: endTime - startTime,
connectionsAttempted,
connectionsSucceeded,
connectionsFailed,
connectionsPeak: config.concurrency,
requestsTotal: attempts.length,
requestsSucceeded: attempts.filter((a) => a.success).length,
requestsFailed: attempts.filter((a) => !a.success).length,
requestsTimedOut: attempts.filter((a) => a.error?.includes("timed out")).length,
latencyP50: percentile(latencies, 50),
latencyP95: percentile(latencies, 95),
latencyP99: percentile(latencies, 99),
latencyMin: latencies[0] ?? 0,
latencyMax: latencies[latencies.length - 1] ?? 0,
latencyMean: mean(latencies),
rateLimitHits,
authFailures,
errors: aggregateErrors(errors),
};
return metrics;
}
async function runAuthAttempts(params: {
config: LoadTestConfig;
count: number;
concurrency: number;
token: string;
log: (msg: string) => void;
}): Promise<AuthAttempt[]> {
const { config, count, concurrency, token, log } = params;
const attempts: AuthAttempt[] = [];
const pending: Promise<AuthAttempt>[] = [];
for (let i = 0; i < count; i++) {
const attemptPromise = runSingleAuthAttempt(config, token, log);
pending.push(attemptPromise);
if (pending.length >= concurrency) {
const completed = await Promise.race(pending.map((p, idx) => p.then((r) => ({ idx, result: r }))));
void pending.splice(completed.idx, 1);
attempts.push(completed.result);
}
}
// Wait for remaining
const remaining = await Promise.all(pending);
attempts.push(...remaining);
return attempts;
}
async function runSingleAuthAttempt(
config: LoadTestConfig,
token: string,
_log: (msg: string) => void,
): Promise<AuthAttempt> {
const startMs = Date.now();
let ws: WebSocket | null = null;
try {
ws = new WebSocket(config.gatewayUrl, {
maxPayload: 10 * 1024 * 1024,
});
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Connection timeout"));
}, 10000);
ws?.once("open", () => {
clearTimeout(timeout);
resolve();
});
ws?.once("error", (err) => {
clearTimeout(timeout);
reject(err);
});
});
// Send connect request
const result = await sendAuthRequest(ws, token);
const latencyMs = Date.now() - startMs;
if (result.success) {
return { startMs, latencyMs, success: true, rateLimited: false };
} else {
const isRateLimited =
result.error?.includes("rate") ||
result.error?.includes("429") ||
result.error?.includes("backoff") ||
result.error?.includes("too many");
return {
startMs,
latencyMs,
success: false,
error: result.error,
rateLimited: isRateLimited,
};
}
} catch (err) {
const latencyMs = Date.now() - startMs;
const errMsg = err instanceof Error ? err.message : String(err);
const isRateLimited =
errMsg.includes("rate") ||
errMsg.includes("429") ||
errMsg.includes("backoff") ||
errMsg.includes("too many");
return {
startMs,
latencyMs,
success: false,
error: errMsg,
rateLimited: isRateLimited,
};
} finally {
ws?.close();
}
}
async function sendAuthRequest(
ws: WebSocket,
token: string,
): Promise<{ success: boolean; error?: string }> {
return new Promise((resolve) => {
if (ws.readyState !== WebSocket.OPEN) {
resolve({ success: false, error: "WebSocket not open" });
return;
}
const requestId = randomUUID();
const timeout = setTimeout(() => {
ws.off("message", handler);
resolve({ success: false, error: "Auth request timed out" });
}, 10000);
const handler = (data: Buffer) => {
try {
const msg = JSON.parse(data.toString());
if (msg.type === "res" && msg.id === requestId) {
clearTimeout(timeout);
ws.off("message", handler);
if (msg.ok) {
resolve({ success: true });
} else {
resolve({ success: false, error: msg.error?.message ?? "Auth failed" });
}
}
} catch {
// Ignore parse errors
}
};
ws.on("message", handler);
ws.send(
JSON.stringify({
type: "req",
id: requestId,
method: "connect",
params: {
minProtocol: 1,
maxProtocol: 1,
client: {
id: "load-test-auth",
version: "1.0.0",
platform: "test",
mode: "test",
instanceId: randomUUID(),
},
caps: [],
role: "operator",
auth: { token },
},
}),
);
});
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}