Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions load-tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# BetterAuth IAM Load Tests

k6-based load tests for the BetterAuth IAM module of `@lenne.tech/nest-server`.

## Prerequisites

```bash
# Install k6
brew install k6

# Build the server
npm run build

# Ensure MongoDB is running on localhost:27017
```

## Quick Start

```bash
# Start server in one terminal
npm start

# Run all load tests in another terminal
./load-tests/run.sh

# Or start server automatically
./load-tests/run.sh --with-server
```

## Available Tests

| Test | File | VUs | Duration | What it measures |
|------|------|-----|----------|------------------|
| **Sign-In** | `iam-sign-in.k6.js` | 50 | 80s | Sign-in endpoint latency |
| **GraphQL JWT** | `iam-graphql-jwt.k6.js` | 100 | 140s | JWT auth middleware overhead |
| **Session** | `iam-session.k6.js` | 50 | 80s | Session cookie / DB lookup |
| **Memory Soak** | `iam-memory-soak.k6.js` | 20 | 10 min | Memory leak detection |

## Running Individual Tests

```bash
# Run a single test directly
k6 run load-tests/iam-sign-in.k6.js

# Via runner (partial name match)
./load-tests/run.sh sign-in
./load-tests/run.sh graphql
./load-tests/run.sh session
./load-tests/run.sh memory-soak

# Custom base URL
BASE_URL=http://staging.example.com:3000 k6 run load-tests/iam-sign-in.k6.js
```

## Memory Soak Test

The memory soak test is excluded from the default run because it takes 10 minutes.

```bash
# Run soak test
./load-tests/run.sh memory-soak

# Monitor memory in a separate terminal
./load-tests/monitor-memory.sh

# Or with specific PID and interval
./load-tests/monitor-memory.sh <pid> 5
```

The memory monitor outputs a CSV file in `load-tests/results/` for later analysis.

## Results

Test results are saved as JSON in `load-tests/results/`:

```
load-tests/results/
iam-sign-in-20250206-143022.json
iam-graphql-jwt-20250206-143022.json
memory-20250206-143022.csv
```

## Thresholds

Each test defines pass/fail thresholds:

| Test | Metric | p95 Target | p99 Target | Success Rate |
|------|--------|------------|------------|-------------|
| Sign-In | `iam_sign_in_duration` | < 2000ms | < 5000ms | > 95% |
| GraphQL JWT | `gql_jwt_duration` | < 1000ms | < 3000ms | > 95% |
| Session | `iam_session_duration` | < 1500ms | < 4000ms | > 95% |

## Suspected Bottlenecks

These tests target the following suspected performance issues:

| Test | Bottleneck | Code Location |
|------|-----------|---------------|
| Sign-In | DB queries per request | `core-better-auth.service.ts` |
| GraphQL JWT | JWKS key import per request | `core-better-auth-token.service.ts` |
| GraphQL JWT | HS256 key re-derivation | `core-better-auth-token.service.ts` |
| Session | MongoDB aggregation pipeline | `core-better-auth.service.ts` |
| Memory Soak | Unbounded rate limiter Map | `core-better-auth-rate-limiter.service.ts` |
| Memory Soak | Unbounded email verification Map | `core-better-auth-email-verification.service.ts` |
196 changes: 196 additions & 0 deletions load-tests/helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import http from 'k6/http';
import { check } from 'k6';

// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------

export const BASE_URL = __ENV.BASE_URL || 'http://127.0.0.1:3000';
export const IAM_URL = `${BASE_URL}/iam`;
export const GQL_URL = `${BASE_URL}/graphql`;

export const JSON_HEADERS = {
'Content-Type': 'application/json',
};

// ---------------------------------------------------------------------------
// IAM helpers
// ---------------------------------------------------------------------------

/**
* Sign up a new user via BetterAuth IAM.
* Returns the parsed JSON body (contains user + session).
*/
export function iamSignUp(email, password, name) {
const res = http.post(
`${IAM_URL}/sign-up/email`,
JSON.stringify({ email, password, name, termsAndPrivacyAccepted: true }),
{ headers: JSON_HEADERS, tags: { endpoint: 'sign-up' } },
);

check(res, {
'sign-up status ok': (r) => r.status === 200 || r.status === 201,
});

return { body: safeJson(res), res };
}

/**
* Sign in an existing user via BetterAuth IAM.
* Returns the parsed JSON body (contains user + session + token).
*/
export function iamSignIn(email, password) {
const res = http.post(
`${IAM_URL}/sign-in/email`,
JSON.stringify({ email, password }),
{ headers: JSON_HEADERS, tags: { endpoint: 'sign-in' } },
);

check(res, {
'sign-in status 200': (r) => r.status === 200,
});

return { body: safeJson(res), res };
}

/**
* Get current session via GET /iam/session.
* Accepts either a JWT bearer token or a session cookie string.
*/
export function iamGetSession({ token, cookie }) {
const headers = { ...JSON_HEADERS };
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}

const params = { headers, tags: { endpoint: 'session' } };

if (cookie) {
// k6 jar-style cookies
params.cookies = { 'iam.session_token': cookie };
}

const res = http.get(`${IAM_URL}/session`, params);

check(res, {
'session status 200': (r) => r.status === 200,
});

return { body: safeJson(res), res };
}

// ---------------------------------------------------------------------------
// GraphQL helpers
// ---------------------------------------------------------------------------

/**
* Execute an authenticated GraphQL query.
*/
export function graphqlQuery(query, variables, token) {
const headers = { ...JSON_HEADERS };
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}

const res = http.post(
GQL_URL,
JSON.stringify({ query, variables }),
{ headers, tags: { endpoint: 'graphql' } },
);

check(res, {
'graphql status 200': (r) => r.status === 200,
});

return { body: safeJson(res), res };
}

// ---------------------------------------------------------------------------
// Token extraction
// ---------------------------------------------------------------------------

/**
* Extract JWT from sign-in response.
* BetterAuth returns the token in the session object or as a set-cookie header.
*/
export function extractJwt(signInResult) {
// 1. Try response body → session.token or token
const body = signInResult.body;
if (body) {
if (body.token) return body.token;
if (body.session?.token) return body.session.token;
}

// 2. Try set-cookie header (lt-jwt-token)
const setCookie = signInResult.res.headers['Set-Cookie'] || signInResult.res.headers['set-cookie'];
if (setCookie) {
const cookies = Array.isArray(setCookie) ? setCookie : [setCookie];
for (const c of cookies) {
// BetterAuth JWT cookie
const jwtMatch = c.match(/lt-jwt-token=([^;]+)/);
if (jwtMatch) return jwtMatch[1];
// Generic bearer token cookie
const tokenMatch = c.match(/token=([^;]+)/);
if (tokenMatch) return tokenMatch[1];
}
}

return null;
}

/**
* Extract session cookie value from sign-in response.
*/
export function extractSessionCookie(signInResult) {
const setCookie = signInResult.res.headers['Set-Cookie'] || signInResult.res.headers['set-cookie'];
if (!setCookie) return null;

const cookies = Array.isArray(setCookie) ? setCookie : [setCookie];
for (const c of cookies) {
const match = c.match(/iam\.session_token=([^;]+)/);
if (match) return match[1];
}
return null;
}

// ---------------------------------------------------------------------------
// Utility
// ---------------------------------------------------------------------------

function safeJson(res) {
try {
return res.json();
} catch {
return null;
}
}

/**
* Generate a unique test email address.
*/
export function uniqueEmail(prefix) {
const ts = Date.now();
const rnd = Math.random().toString(36).substring(2, 8);
return `${prefix || 'k6'}-${ts}-${rnd}@loadtest.local`;
}

/**
* Wait for server health-check to pass.
* k6 setup functions can call this once.
*/
export function waitForServer(maxRetries = 30, delayMs = 1000) {
for (let i = 0; i < maxRetries; i++) {
try {
const res = http.get(`${BASE_URL}/health`, { timeout: '5s' });
if (res.status === 200) return true;
} catch {
// ignore
}
// k6 doesn't have a blocking sleep in setup – use a busy-wait
const end = Date.now() + delayMs;
while (Date.now() < end) {
// spin
}
}
return false;
}
Loading