184 lines
7.7 KiB
JavaScript
184 lines
7.7 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|