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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ packages/**/.turbo
CHANGELOG-*.md
*.mdx

# Load test results & secrets
load-tests/results/*.json
load-tests/results/*.csv
load-tests/.env

# Desktop app build outputs
apps/desktop/out/
apps/desktop/release/
Expand Down
6 changes: 5 additions & 1 deletion apps/backend/src/core/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export class Server {
// Add middleware to attach user info to request before all handlers
this.server.addHook('preHandler', addUserToRequestMiddleware);

if (!IN_TEST || env.DISABLE_RATE_LIMITING === true) {
if (!IN_TEST || env.DISABLE_RATE_LIMITING !== true) {
await this.server.register(fastifyRateLimit, {
hook: 'preHandler',
keyGenerator: (request) => {
Expand All @@ -143,6 +143,10 @@ export class Server {
throw new TooManyRequestsError();
},
});
} else {
this.logger.warn(
'Rate limiting is disabled. This should not be used in production environments.',
);
}

this.server.addContentTypeParser('*', function (request, payload, done) {
Expand Down
38 changes: 38 additions & 0 deletions load-tests/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# =============================================================================
# QRcodly Load Tests — Environment Variables
# Copy this file to .env and fill in your values
# =============================================================================

# --- Clerk Authentication ---
# Staging Clerk secret key (from Clerk Dashboard → API Keys)
CLERK_SECRET_KEY=sk_test_...

# --- Staging URLs ---
# Backend API
BASE_URL=

# Frontend (for scan simulation)
FRONTEND_URL=

# --- HTAccess Protection (Frontend) ---
# If the staging frontend is behind htaccess basic auth
HTACCESS_USER=
HTACCESS_PASS=

# --- Test Users ---
# Comma-separated Clerk user IDs for token generation
# These are the 5 staging smoke test users
TEST_USER_IDS=

# --- Short Codes for Scan Simulation ---
# Comma-separated short codes to scan (create some on staging first)
# Example: SHORT_CODES=abc12,def34,ghi56
SHORT_CODES=

# --- Load Test Profile ---
# smoke | light | medium | heavy | spike
PROFILE=smoke

# --- Mode ---
# full (CRUD + scans) | scan-only
MODE=full
142 changes: 142 additions & 0 deletions load-tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# QRcodly Load Tests (k6)

## Setup

```bash
# Install k6
brew install k6

# Configure environment
cp .env.example .env
# → Fill in CLERK_SECRET_KEY, HTACCESS_USER/PASS
```

## Quick Start

```bash
# Smoke test (5 VUs, 30s)
./run.sh

# Medium load (250 VUs)
./run.sh medium

# Heavy load (1000 VUs)
./run.sh heavy

# Spike test (sudden surge to 1000)
./run.sh spike

# Save results as JSON
SAVE_RESULTS=1 ./run.sh heavy
```

The `run.sh` script automatically reads `.env`, generates Clerk tokens, and starts k6.

Test data (QR codes + short URLs) is **created automatically** in the `setup()` phase and cleaned up in `teardown()`. No manual short code entry needed.

## .env Configuration

| Variable | Description |
| ------------------ | ----------------------------------------------------------------------- |
| `CLERK_SECRET_KEY` | Clerk secret key (staging) — for token generation |
| `BASE_URL` | Backend API URL (default: `https://stage-api.qrcodly.de/api/v1`) |
| `FRONTEND_URL` | Frontend URL for scan simulation (default: `https://stage.qrcodly.de`) |
| `HTACCESS_USER` | HTAccess username (if staging frontend is protected) |
| `HTACCESS_PASS` | HTAccess password |
| `TEST_USER_IDS` | Comma-separated Clerk user IDs |
| `SHORT_CODES` | Additional short codes for scanning (optional, auto-created by default) |
| `PROFILE` | Default load profile |
| `MODE` | `full` (CRUD + scans) or `scan-only` |

## Modes

### Full Mode (default)

Requires: `CLERK_SECRET_KEY`

Simulates complete user flows:

- 25% QR code CRUD (create, edit, delete)
- 10% QR code read (dashboard browsing)
- 10% Short URL CRUD
- 5% Template CRUD
- 5% Tag CRUD
- 35% Scans (diverse browsers/devices)
- 10% Burst scans (viral QR code simulation)

### Scan-Only Mode

Requires: `SHORT_CODES` in `.env` (or auto-created with `CLERK_SECRET_KEY`)

100% scan traffic with realistic user agents:

- 70% Mobile (iPhone, Android, Samsung, Huawei)
- 30% Desktop (Chrome, Firefox, Safari, Edge)
- 16 languages (de, en, fr, es, it, nl, pl, ru, ...)
- Various referrers (Google, social media, direct)
- Randomized IPs

```bash
MODE=scan-only ./run.sh heavy
```

## Profiles

| Profile | Max VUs | Duration | Purpose |
| -------- | ------- | -------- | --------------------- |
| `smoke` | 5 | 30s | Verify endpoints work |
| `light` | 50 | 3 min | Normal traffic |
| `medium` | 250 | 5 min | Peak hours |
| `heavy` | 1000 | 9 min | Stress test |
| `spike` | 1000 | 3.5 min | Sudden traffic surge |

## Automatic Test Data

The `setup()` function runs once before all VUs and:

1. Creates 5 dynamic QR codes (with short URLs for scanning)
2. Creates 5 standalone short URLs
3. Passes all short codes to VUs for scan simulation

The `teardown()` function cleans up all created test data after the run.

## Token Refresh

Clerk JWTs expire after ~60 seconds. The system refreshes tokens automatically:

- Each VU monitors token expiry
- 10 seconds before expiry, a new token is fetched via Clerk REST API
- Requires `CLERK_SECRET_KEY` to be passed through to k6

## Interpreting Results

Key metrics in k6 output:

| Metric | Good | Concerning |
| ------------------------------ | --------------- | ------------------- |
| `http_req_duration (p95)` | < 2s | > 5s |
| `http_req_failed` | < 5% | > 10% |
| `http_reqs` | high throughput | dropping under load |
| `http_req_duration{type:scan}` | < 1.5s | > 3s |

## File Structure

```
├── .env # Secrets & config (gitignored)
├── .env.example # Template
├── run.sh # One-click runner
├── generate-tokens.mjs # Clerk token generation (Node.js)
├── main.js # k6 entry point (setup/teardown/default)
├── config.js # Profiles & thresholds
├── auth.js # Token management & auto-refresh
├── helpers.js # HTTP helpers
├── data/
│ ├── payloads.js # Test data generators
│ └── user-agents.js # Browser/device simulation
├── scenarios/
│ ├── qr-codes.js # QR code CRUD + read
│ ├── short-urls.js # Short URL CRUD
│ ├── templates-tags.js # Templates + tags CRUD
│ └── scan-traffic.js # Scan & burst simulation
└── results/ # Output (gitignored)
```
131 changes: 131 additions & 0 deletions load-tests/auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// =============================================================================
// Clerk Auth Token Management for k6
//
// Strategy:
// - setup() creates one Clerk session per user and passes session IDs to VUs
// - VUs reuse these sessions to get fresh JWTs (1 API call, not 2)
// - Random jitter prevents thundering herd on token refresh
// - On 429 (rate limit), VU backs off and keeps using the old token
// =============================================================================

import http from 'k6/http';
import encoding from 'k6/encoding';
import { CLERK_TOKENS, CLERK_SECRET_KEY, CLERK_API, JWT_TEMPLATE } from './config.js';

// Per-VU token state
const vuState = {
token: null,
sessionId: null,
expiresAt: 0,
refreshJitter: Math.random() * 15_000, // 0-15s random offset per VU
backoffUntil: 0,
};

/**
* Decode JWT payload to read expiry
*/
function decodeJwtPayload(jwt) {
try {
const parts = jwt.split('.');
if (parts.length !== 3) return null;

let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
while (payload.length % 4 !== 0) payload += '=';

const decoded = String.fromCharCode(...new Uint8Array(encoding.b64decode(payload, 'std', 's')));
return JSON.parse(decoded);
} catch {
return null;
}
}

/**
* Get token expiry time in ms
*/
function getTokenExpiry(token) {
const payload = decodeJwtPayload(token);
if (payload && payload.exp) {
return payload.exp * 1000;
}
return Date.now() + 60_000;
}

/**
* Get a fresh JWT from an existing Clerk session (single API call)
*/
function refreshTokenFromSession(sessionId) {
if (!CLERK_SECRET_KEY || !sessionId) return null;

const res = http.post(`${CLERK_API}/sessions/${sessionId}/tokens/${JWT_TEMPLATE}`, '{}', {
headers: {
Authorization: `Bearer ${CLERK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
tags: { type: 'auth_refresh' },
});

if (res.status === 429) {
// Rate limited — back off for 30-60s
vuState.backoffUntil = Date.now() + 30_000 + Math.random() * 30_000;
return null;
}

if (res.status !== 200) {
return null;
}

try {
const data = JSON.parse(res.body);
return data.jwt;
} catch {
return null;
}
}

/**
* Initialize auth state from setupData.
* Call once at the start of each VU iteration.
*/
export function initAuth(setupData) {
if (vuState.token) return; // Already initialized

if (CLERK_TOKENS.length === 0) return;

const idx = __VU % CLERK_TOKENS.length;
vuState.token = CLERK_TOKENS[idx];
vuState.expiresAt = getTokenExpiry(vuState.token);

// Get session ID from setupData (created in setup())
if (setupData && setupData.sessionIds && setupData.sessionIds[idx]) {
vuState.sessionId = setupData.sessionIds[idx];
}
}

/**
* Get a valid token for the current VU.
* Automatically refreshes from the existing session when near expiry.
* Requires initAuth(setupData) to be called once before (in main default function).
*/
export function getValidToken() {
if (!vuState.token) return null;

const now = Date.now();

// Still in backoff period — use existing token
if (now < vuState.backoffUntil) {
return vuState.token;
}

// Check if token needs refresh (with per-VU jitter to spread out calls)
const refreshAt = vuState.expiresAt - 5_000 - vuState.refreshJitter;
if (now >= refreshAt && vuState.sessionId) {
const newToken = refreshTokenFromSession(vuState.sessionId);
if (newToken) {
vuState.token = newToken;
vuState.expiresAt = getTokenExpiry(newToken);
}
// If refresh fails, keep using old token
}

return vuState.token;
}
Loading
Loading