/** * HOALedgerIQ – Auth + Dashboard Load Test * Journey: Login → Token Refresh → Dashboard Reports → Profile → Logout * * Covers the highest-frequency production flow: a treasurer or admin * opening the app, loading the dashboard, and reviewing financial reports. */ import http from 'k6/http'; import { check, sleep, group } from 'k6'; import { SharedArray } from 'k6/data'; import { Trend, Rate, Counter } from 'k6/metrics'; // ── Custom metrics ────────────────────────────────────────────────────────── const loginDuration = new Trend('login_duration', true); const dashboardDuration = new Trend('dashboard_duration', true); const refreshDuration = new Trend('refresh_duration', true); const authErrorRate = new Rate('auth_error_rate'); const dashboardErrorRate = new Rate('dashboard_error_rate'); const tokenRefreshCount = new Counter('token_refresh_count'); // ── User pool ──────────────────────────────────────────────────────────────── const users = new SharedArray('users', function () { return open('../config/user-pool.csv') .split('\n') .slice(1) // skip header row .filter(line => line.trim()) .map(line => { const [email, password, orgId, role] = line.split(','); return { email: email.trim(), password: password.trim(), orgId: orgId.trim(), role: role.trim() }; }); }); // ── Environment config ─────────────────────────────────────────────────────── const ENV = __ENV.TARGET_ENV || 'staging'; const envConfig = JSON.parse(open('../config/environments.json'))[ENV]; const BASE_URL = envConfig.baseUrl; // ── Test options ───────────────────────────────────────────────────────────── export const options = { scenarios: { auth_dashboard: { executor: 'ramping-vus', stages: [ { duration: '2m', target: 20 }, // warm up { duration: '5m', target: 100 }, // ramp to target load { duration: '5m', target: 100 }, // sustained load { duration: '3m', target: 200 }, // peak spike { duration: '2m', target: 0 }, // ramp down ], }, }, thresholds: { // Latency targets per environment (overridden by environments.json) 'login_duration': [`p(95)<${envConfig.thresholds.auth_p95}`], 'dashboard_duration': [`p(95)<${envConfig.thresholds.dashboard_p95}`], 'refresh_duration': [`p(95)<${envConfig.thresholds.refresh_p95}`], 'auth_error_rate': [`rate<${envConfig.thresholds.error_rate}`], 'dashboard_error_rate': [`rate<${envConfig.thresholds.error_rate}`], 'http_req_failed': [`rate<${envConfig.thresholds.error_rate}`], 'http_req_duration': [`p(99)<${envConfig.thresholds.global_p99}`], }, }; // ── Helpers ────────────────────────────────────────────────────────────────── function authHeaders(token) { return { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }; } // ── Main scenario ──────────────────────────────────────────────────────────── export default function () { const user = users[__VU % users.length]; let accessToken = null; // ── 1. Login ──────────────────────────────────────────────────────────── group('auth:login', () => { const res = http.post( `${BASE_URL}/api/auth/login`, JSON.stringify({ email: user.email, password: user.password }), { headers: { 'Content-Type': 'application/json' }, tags: { name: 'login' } } ); loginDuration.add(res.timings.duration); const ok = check(res, { 'login 200': r => r.status === 200, 'has access_token': r => r.json('access_token') !== undefined, 'has orgId in body': r => r.json('user.orgId') !== undefined, }); authErrorRate.add(!ok); if (!ok) { sleep(1); return; } accessToken = res.json('access_token'); // httpOnly cookie ledgeriq_rt is set automatically by the browser/k6 jar }); if (!accessToken) return; sleep(1.5); // think time – user lands on dashboard // ── 2. Load dashboard & key reports in parallel ───────────────────────── group('dashboard:load', () => { const requests = { dashboard: ['GET', `${BASE_URL}/api/reports/dashboard`], balance_sheet: ['GET', `${BASE_URL}/api/reports/balance-sheet`], income_statement: ['GET', `${BASE_URL}/api/reports/income-statement`], profile: ['GET', `${BASE_URL}/api/auth/profile`], accounts: ['GET', `${BASE_URL}/api/accounts`], }; const responses = http.batch( Object.entries(requests).map(([name, [method, url]]) => ({ method, url, params: { headers: authHeaders(accessToken), tags: { name } }, })) ); let allOk = true; responses.forEach((res, i) => { const name = Object.keys(requests)[i]; dashboardDuration.add(res.timings.duration, { endpoint: name }); const ok = check(res, { [`${name} 200`]: r => r.status === 200, [`${name} has body`]: r => r.body && r.body.length > 0, }); if (!ok) allOk = false; }); dashboardErrorRate.add(!allOk); }); sleep(2); // user reads the dashboard // ── 3. Simulate token refresh (happens automatically in-app at 55min) ──── // In the load test we trigger it early to validate the refresh path under load group('auth:refresh', () => { const res = http.post( `${BASE_URL}/api/auth/refresh`, null, { headers: authHeaders(accessToken), tags: { name: 'refresh' }, // k6 sends the httpOnly cookie from the jar automatically } ); refreshDuration.add(res.timings.duration); tokenRefreshCount.add(1); const ok = check(res, { 'refresh 200': r => r.status === 200, 'new access_token': r => r.json('access_token') !== undefined, }); authErrorRate.add(!ok); if (ok) accessToken = res.json('access_token'); }); sleep(1); // ── 4. Drill into one report (cash-flow forecast – typically slowest) ──── group('dashboard:drill', () => { const res = http.get( `${BASE_URL}/api/reports/cash-flow-forecast`, { headers: authHeaders(accessToken), tags: { name: 'cash_flow_forecast' } } ); dashboardDuration.add(res.timings.duration, { endpoint: 'cash_flow_forecast' }); dashboardErrorRate.add(res.status !== 200); check(res, { 'forecast 200': r => r.status === 200 }); }); sleep(2); // ── 5. Logout ──────────────────────────────────────────────────────────── group('auth:logout', () => { const res = http.post( `${BASE_URL}/api/auth/logout`, null, { headers: authHeaders(accessToken), tags: { name: 'logout' } } ); check(res, { 'logout 200 or 204': r => r.status === 200 || r.status === 204 }); }); sleep(1); }