mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-25 02:52:20 +02:00
Document and respect per-IP rate limits (200/min global, 20/min auth, 60/min webhooks) by holding scenarios well below the ceilings so the histogram captures real handler latency instead of 429s. Total combined ~148 req/min over ~10h. Adds summaryTrendStats so reports include p95/p99/max alongside avg. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
187 lines
6.2 KiB
JavaScript
187 lines
6.2 KiB
JavaScript
import http from 'k6/http';
|
|
|
|
const BASE = __ENV.BASE_URL || 'https://api.sshops.uk';
|
|
const SECRET = __ENV.CLIENT_SECRET || '';
|
|
|
|
// Per-VU JWT cache. JWTs are valid for 30 minutes; we refresh ~5 min early.
|
|
let cachedToken = null;
|
|
let cachedExpiresAt = 0;
|
|
let cachedFingerprint = null;
|
|
|
|
// SSHPublicKey has a uniqueIndex constraint, so each VU needs a distinct
|
|
// (fake but unique) public key string. Suffixing with VU+timestamp keeps
|
|
// the full text unique without affecting the rest of the body.
|
|
let cachedPubkey = null;
|
|
|
|
function getToken() {
|
|
const now = Date.now();
|
|
if (cachedToken && now < cachedExpiresAt) return cachedToken;
|
|
|
|
if (!cachedFingerprint) {
|
|
cachedFingerprint = `SHA256:k6-${__VU}-${now.toString(36)}`;
|
|
cachedPubkey = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILoadTest${__VU}-${now.toString(36)} k6@vu${__VU}`;
|
|
}
|
|
|
|
const res = http.post(
|
|
`${BASE}/api/v1/auth/token`,
|
|
JSON.stringify({
|
|
client_secret: SECRET,
|
|
fingerprint: cachedFingerprint,
|
|
ssh_public_key: cachedPubkey,
|
|
}),
|
|
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'auth_token_refresh' } },
|
|
);
|
|
|
|
if (res.status !== 200) return null;
|
|
const body = JSON.parse(res.body);
|
|
cachedToken = body.access_token;
|
|
cachedExpiresAt = now + (body.expires_in - 300) * 1000;
|
|
return cachedToken;
|
|
}
|
|
|
|
function authHeaders() {
|
|
const tok = getToken();
|
|
return tok ? { Authorization: `Bearer ${tok}`, 'Content-Type': 'application/json' } : null;
|
|
}
|
|
|
|
// Rate limits we are operating under (per-IP, single source IP):
|
|
// global ........... 200 req/min
|
|
// /api/v1/auth/* ... 20 req/min (auth/register, auth/user, auth/token)
|
|
// /api/v1/webhooks/ 60 req/min
|
|
// All rates below are tuned to stay UNDER these ceilings with headroom so
|
|
// nothing gets rate-limited and the histogram captures real handler latency.
|
|
// Total ~148 req/min combined. Wall time ~10h.
|
|
export const options = {
|
|
discardResponseBodies: true,
|
|
summaryTrendStats: ['avg', 'p(95)', 'p(99)', 'max'],
|
|
scenarios: {
|
|
browse: {
|
|
// /products, /products/{id}, /health, /ping. Only the global 200/min
|
|
// limit applies to these. Hold steady at 100/min all night so we never
|
|
// get close to the ceiling no matter what other scenarios do.
|
|
executor: 'constant-arrival-rate',
|
|
rate: 100,
|
|
timeUnit: '1m',
|
|
duration: '10h',
|
|
preAllocatedVUs: 5,
|
|
maxVUs: 20,
|
|
exec: 'browse',
|
|
},
|
|
err_404: {
|
|
// /products/{99xxx} — chi route still matches, returns 404. Counts
|
|
// against the global 200/min only.
|
|
executor: 'constant-arrival-rate',
|
|
rate: 30,
|
|
timeUnit: '1m',
|
|
duration: '10h',
|
|
preAllocatedVUs: 3,
|
|
maxVUs: 10,
|
|
exec: 'notFound',
|
|
},
|
|
auth_lookup: {
|
|
// GET /auth/user — counts against the /auth/* 20/min group ceiling
|
|
// SHARED with auth_warn. We split: 10/min lookup + 8/min warn = 18/min.
|
|
executor: 'constant-arrival-rate',
|
|
rate: 10,
|
|
timeUnit: '1m',
|
|
duration: '10h',
|
|
preAllocatedVUs: 2,
|
|
maxVUs: 5,
|
|
exec: 'authLookup',
|
|
},
|
|
auth_warn: {
|
|
// POST /auth/token with bad creds — same 20/min group ceiling as above.
|
|
executor: 'constant-arrival-rate',
|
|
rate: 8,
|
|
timeUnit: '1m',
|
|
duration: '10h',
|
|
preAllocatedVUs: 2,
|
|
maxVUs: 5,
|
|
exec: 'authBad',
|
|
},
|
|
},
|
|
};
|
|
|
|
export default function () {
|
|
// Smoke-test entry point for `--vus N --duration T` runs.
|
|
browse();
|
|
authBad();
|
|
authLookup();
|
|
notFound();
|
|
if (SECRET) {
|
|
authedBrowse();
|
|
convertFail();
|
|
addressBad();
|
|
}
|
|
}
|
|
|
|
export function browse() {
|
|
const r = Math.random();
|
|
if (r < 0.6) http.get(`${BASE}/api/v1/products`, { tags: { name: 'list_products' } });
|
|
else if (r < 0.8) http.get(`${BASE}/api/v1/products/1`, { tags: { name: 'get_product' } });
|
|
else if (r < 0.95) http.get(`${BASE}/api/v1/health`, { tags: { name: 'health' } });
|
|
else http.get(`${BASE}/api/v1/ping`, { tags: { name: 'ping' } });
|
|
}
|
|
|
|
export function authedBrowse() {
|
|
const h = authHeaders();
|
|
if (!h) return;
|
|
const r = Math.random();
|
|
if (r < 0.4) http.get(`${BASE}/api/v1/cart`, { headers: h, tags: { name: 'authed_get_cart' } });
|
|
else if (r < 0.6) http.get(`${BASE}/api/v1/cards`, { headers: h, tags: { name: 'authed_list_cards' } });
|
|
else if (r < 0.8) http.get(`${BASE}/api/v1/addresses`, { headers: h, tags: { name: 'authed_list_addresses' } });
|
|
else http.get(`${BASE}/api/v1/orders`, { headers: h, tags: { name: 'authed_list_orders' } });
|
|
}
|
|
|
|
export function convertFail() {
|
|
const h = authHeaders();
|
|
if (!h) return;
|
|
// Reset cart, drop one item in, attempt convert. No card/address selected
|
|
// → ConvertCart hits validation_missing_address → cart_conversion_total
|
|
// outcome=validation_missing_address + audit.OrderFailed warn.
|
|
http.del(`${BASE}/api/v1/cart`, null, { headers: h, tags: { name: 'cart_clear' } });
|
|
http.put(
|
|
`${BASE}/api/v1/cart/item`,
|
|
JSON.stringify({ coffee_id: 1, quantity: 1 }),
|
|
{ headers: h, tags: { name: 'cart_add_item' } },
|
|
);
|
|
http.post(`${BASE}/api/v1/cart/convert`, null, { headers: h, tags: { name: 'cart_convert_fail' } });
|
|
}
|
|
|
|
export function addressBad() {
|
|
const h = authHeaders();
|
|
if (!h) return;
|
|
// Junk US zip + street → shippo validation fails → addressLog.Warn.
|
|
http.post(
|
|
`${BASE}/api/v1/addresses`,
|
|
JSON.stringify({
|
|
name: 'Load Test',
|
|
street: 'asdfasdfasdf',
|
|
city: 'Nowhere',
|
|
state: 'XX',
|
|
zip: '00000',
|
|
country: 'US',
|
|
phone: '+15555550000',
|
|
}),
|
|
{ headers: h, tags: { name: 'address_create_bad' } },
|
|
);
|
|
}
|
|
|
|
export function authBad() {
|
|
http.post(
|
|
`${BASE}/api/v1/auth/token`,
|
|
JSON.stringify({ client_secret: 'definitely_wrong', fingerprint: 'SHA256:fake' }),
|
|
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'auth_bad_creds' } },
|
|
);
|
|
}
|
|
|
|
export function authLookup() {
|
|
const fp = `SHA256:${Math.random().toString(36).slice(2, 14)}`;
|
|
http.get(`${BASE}/api/v1/auth/user?fingerprint=${fp}`, { tags: { name: 'auth_user_lookup' } });
|
|
}
|
|
|
|
export function notFound() {
|
|
const id = 99000 + Math.floor(Math.random() * 1000);
|
|
http.get(`${BASE}/api/v1/products/${id}`, { tags: { name: 'product_not_found' } });
|
|
}
|