Fix k6 load test: 1-call/iteration, credential pool, merged scenarios, logout API contract, summary thresholds (FRE-4928)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-05-10 03:36:09 -04:00
parent 2d0611c2c9
commit b391338d5b

View File

@@ -8,6 +8,7 @@ const TEST_EMAIL = __ENV.TEST_EMAIL || 'loadtest@darkwatch.shieldai';
const TEST_PASSWORD = __ENV.TEST_PASSWORD || 'LoadTest2026!'; const TEST_PASSWORD = __ENV.TEST_PASSWORD || 'LoadTest2026!';
const DURATION = __ENV.DURATION || '300s'; // 5 minutes const DURATION = __ENV.DURATION || '300s'; // 5 minutes
const TARGET_RPS = parseInt(__ENV.TARGET_RPS || '500', 10); const TARGET_RPS = parseInt(__ENV.TARGET_RPS || '500', 10);
const CREDENTIAL_POOL_SIZE = parseInt(__ENV.CREDENTIAL_POOL_SIZE || '100', 10);
// P99 latency thresholds (ms) // P99 latency thresholds (ms)
const THRESHOLDS = { const THRESHOLDS = {
@@ -38,13 +39,27 @@ const authHeaders = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}; };
// ── P1#3: Fixed credential pool (reuses pre-seeded users, not unique per call) ──
const credentialPool = Array.from({ length: CREDENTIAL_POOL_SIZE }, (_, i) => ({
email: `${TEST_EMAIL.replace('@', `_${i}@`)}`,
password: TEST_PASSWORD,
}));
// Pre-warmed token pool for refresh/logout standalone scenarios
const tokenPool = Array.from({ length: CREDENTIAL_POOL_SIZE }, () => ({
accessToken: uuidv4(),
refreshToken: uuidv4(),
}));
// ── Scenario: Login (POST /auth/login) ────────────────────────────────────── // ── Scenario: Login (POST /auth/login) ──────────────────────────────────────
function testLogin() { function testLogin(email, password) {
const uniqueEmail = `${TEST_EMAIL.replace('@', `_${uuidv4().slice(0, 8)}@`)}`; const creds = email
? { email, password }
: credentialPool[Math.floor(Math.random() * credentialPool.length)];
const payload = JSON.stringify({ const payload = JSON.stringify({
email: uniqueEmail, email: creds.email,
password: TEST_PASSWORD, password: creds.password,
}); });
const res = http.post(`${BASE_URL}/auth/login`, payload, { headers: authHeaders }); const res = http.post(`${BASE_URL}/auth/login`, payload, { headers: authHeaders });
@@ -85,7 +100,7 @@ function testLogin() {
// ── Scenario: Refresh (POST /auth/refresh) ────────────────────────────────── // ── Scenario: Refresh (POST /auth/refresh) ──────────────────────────────────
function testRefresh(refreshToken) { function testRefresh(refreshToken) {
const token = refreshToken || uuidv4(); const token = refreshToken || tokenPool[Math.floor(Math.random() * tokenPool.length)].refreshToken;
const payload = JSON.stringify({ const payload = JSON.stringify({
refresh_token: token, refresh_token: token,
@@ -125,12 +140,14 @@ function testRefresh(refreshToken) {
} }
} }
// ── Scenario: Logout (POST /auth/logout) ──────────────────────────────────── // ── P2#4: Scenario: Logout (POST /auth/logout) — refresh_token in body, Bearer in header ──
function testLogout(accessToken) { function testLogout(accessToken, refreshToken) {
const token = accessToken || uuidv4(); const poolEntry = tokenPool[Math.floor(Math.random() * tokenPool.length)];
const token = accessToken || poolEntry.accessToken;
const refreshTkn = refreshToken || poolEntry.refreshToken;
const payload = JSON.stringify({ const payload = JSON.stringify({
access_token: token, refresh_token: refreshTkn,
}); });
const res = http.post(`${BASE_URL}/auth/logout`, payload, { const res = http.post(`${BASE_URL}/auth/logout`, payload, {
@@ -151,7 +168,7 @@ function testLogout(accessToken) {
}); });
} }
// ── Default Scenario: Weighted mixed workload ──────────────────────────────── // ── P1#1 + P1#2: Options with all scenarios merged (each iteration = 1 HTTP call) ──
export const options = { export const options = {
scenarios: { scenarios: {
sustained_load: { sustained_load: {
@@ -164,6 +181,36 @@ export const options = {
exec: 'mixedWorkload', exec: 'mixedWorkload',
tags: { scenario: 'sustained_load' }, tags: { scenario: 'sustained_load' },
}, },
login_only: {
executor: 'constant-arrival-rate',
duration: DURATION,
rate: TARGET_RPS,
preAllocatedVUs: 20,
maxVUs: 100,
exec: 'loginOnly',
startTime: '0s',
tags: { scenario: 'login_only' },
},
logout_only: {
executor: 'constant-arrival-rate',
duration: DURATION,
rate: TARGET_RPS,
preAllocatedVUs: 20,
maxVUs: 100,
exec: 'logoutOnly',
startTime: '0s',
tags: { scenario: 'logout_only' },
},
refresh_only: {
executor: 'constant-arrival-rate',
duration: DURATION,
rate: TARGET_RPS,
preAllocatedVUs: 20,
maxVUs: 100,
exec: 'refreshOnly',
startTime: '0s',
tags: { scenario: 'refresh_only' },
},
}, },
thresholds: { thresholds: {
`login_p99`: [`p(99)<${THRESHOLDS.login}`], `login_p99`: [`p(99)<${THRESHOLDS.login}`],
@@ -177,84 +224,46 @@ export const options = {
}, },
}; };
// Mixed workload: 40% login, 35% refresh, 25% logout // P1#1: Mixed workload — exactly 1 HTTP call per iteration, weighted 40/35/25
export function mixedWorkload() { export function mixedWorkload() {
const rand = Math.random(); const rand = Math.random();
if (rand < 0.4) { if (rand < 0.4) {
const tokens = testLogin(); testLogin();
sleep(0.05);
testRefresh(tokens.refreshToken);
sleep(0.02);
testLogout(tokens.accessToken);
} else if (rand < 0.75) { } else if (rand < 0.75) {
const tokens = testLogin(); testRefresh();
sleep(0.05);
testRefresh(tokens.refreshToken);
} else { } else {
const tokens = testLogin(); testLogout();
sleep(0.05);
testLogout(tokens.accessToken);
} }
sleep(0.05);
} }
// ── Individual endpoint scenarios for targeted testing ─────────────────────── // Individual endpoint scenarios — each makes exactly 1 HTTP call per iteration
export const endpointScenarios = {
login_only: {
executor: 'constant-arrival-rate',
duration: DURATION,
rate: TARGET_RPS,
preAllocatedVUs: 20,
maxVUs: 100,
exec: 'loginOnly',
startTime: '0s',
tags: { scenario: 'login_only' },
},
logout_only: {
executor: 'constant-arrival-rate',
duration: DURATION,
rate: TARGET_RPS,
preAllocatedVUs: 20,
maxVUs: 100,
exec: 'logoutOnly',
startTime: '0s',
tags: { scenario: 'logout_only' },
},
refresh_only: {
executor: 'constant-arrival-rate',
duration: DURATION,
rate: TARGET_RPS,
preAllocatedVUs: 20,
maxVUs: 100,
exec: 'refreshOnly',
startTime: '0s',
tags: { scenario: 'refresh_only' },
},
};
export function loginOnly() { export function loginOnly() {
testLogin(); testLogin();
sleep(0.1); sleep(0.1);
} }
export function logoutOnly() { export function logoutOnly() {
const tokens = testLogin(); testLogout();
sleep(0.05); sleep(0.1);
testLogout(tokens.accessToken);
sleep(0.05);
} }
export function refreshOnly() { export function refreshOnly() {
const tokens = testLogin(); testRefresh();
sleep(0.05); sleep(0.1);
testRefresh(tokens.refreshToken);
sleep(0.05);
} }
// ── Summary Hook ───────────────────────────────────────────────────────────── // ── Summary Hook ─────────────────────────────────────────────────────────────
export function handleSummary(data) { export function handleSummary(data) {
// P2#5: Only evaluate metrics that have thresholds defined
const thresholdedMetrics = Object.entries(data.metrics).filter(
([_, metric]) => metric && metric.thresholds && metric.thresholds.length > 0
);
const passed = thresholdedMetrics.every(([_, metric]) =>
metric.thresholds.every((t) => t.pass)
);
return { return {
'stdout': `\n=== Darkwatch Auth Load Test Results ===\n`, 'stdout': `\n=== Darkwatch Auth Load Test Results ===\n`,
'summary.json': JSON.stringify({ 'summary.json': JSON.stringify({
@@ -285,9 +294,7 @@ export function handleSummary(data) {
successRate: (data.metrics.refresh_success?.values.rate || 0) * 100 + '%', successRate: (data.metrics.refresh_success?.values.rate || 0) * 100 + '%',
}, },
}, },
passed: Object.entries(data.metrics).every( passed: passed,
([_, metric]) => metric?.thresholds?.every?.((t) => t.pass)
),
}, null, 2), }, null, 2),
}; };
} }