Files
Agent-System-Dev/tests/load/collaboration/k6/collab_api.js
T
weichenandCursor 479b7afa33 Walk the live password-change gate in k6 load setup
Why: Admin-created load users still have must_change; business APIs return 403 text and k6 json() crashes.
Evidence: brew k6 v2.3.0 smoke --seed tiny (653/653 checks, fail=0); --instances 2 (765/765, cross-read 30, dup_seq=0).
Impact: Load suite matches the real first-login set-password flow; no SQL backdoor for k6 users.
Refs: server/auth.go enforcePasswordGate; U7 run_load.sh
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 06:11:13 +00:00

234 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 协作存储 U7 压测(方案 §3 负载合同 / §5 U7):k6 覆盖非模型普通 API
// 的吞吐与延迟阶梯;SSE 可见延迟 P95 由 Go 探针(../sse_probe)承担——
// k6 核心不支持 SSE 流式读取,两者分工记录在同一份结果目录。
//
// 目标实例:run_load.sh 起的隔离栈(mock 模式 backend + 隔离 PG),绝不打
// 生产服务。预期限流/冲突(409/429)单列计数,不计入失败。
// C2 双实例:BASE_URLS=a,b 时 VU 粘滞到实例;热点会话跨实例写触发 409;
// 另有跨实例读抽样(A 拓扑在 B 上 GET)。
//
// 阶梯与环境变量(由 run_load.sh 注入):
// BASE_URL / BASE_URLS 单址或逗号分隔双址
// ADMIN_* 引导管理员(run_load.sh 预建;setup 里登录后建压测用户)
// USERS 压测用户数(默认 30;每 VU 绑定一个用户)
// PROFILE smoke | ladder | sustained60m | sustained2h(默认 smoke)
//
// 阈值(负载合同,待实测后校准):
// 读路径 p(95)<300ms、全量 p(99)<1s、非预期失败率<0.1%。
import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { Counter, Trend } from 'k6/metrics';
import exec from 'k6/execution';
const BASE_SINGLE = __ENV.BASE_URL || 'http://127.0.0.1:18980';
const BASE_URLS = (__ENV.BASE_URLS || BASE_SINGLE)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const ADMIN_OWNER = __ENV.ADMIN_OWNER || 'load_admin';
const ADMIN_PASS = __ENV.ADMIN_PASS || 'load-admin-pass';
const USERS = parseInt(__ENV.USERS || '30', 10);
const PROFILE = __ENV.PROFILE || 'smoke';
// 预期拥塞(负载合同要求单列,不算失败):409 会话忙 / 429 队列满。
const expectedContention = new Counter('expected_contention');
const msgLatency = new Trend('message_roundtrip', true);
const crossReadOk = new Counter('cross_instance_read_ok');
const stages = {
smoke: [{ duration: '30s', target: 10 }],
ladder: [
{ duration: '2m', target: 10 },
{ duration: '3m', target: 30 },
{ duration: '5m', target: 100 },
{ duration: '2m', target: 0 },
],
sustained60m: [
{ duration: '2m', target: 30 },
{ duration: '56m', target: 30 },
{ duration: '2m', target: 0 },
],
sustained2h: [
{ duration: '3m', target: 30 },
{ duration: '114m', target: 30 },
{ duration: '3m', target: 0 },
],
};
export const options = {
stages: stages[PROFILE] || stages.smoke,
thresholds: {
// 非预期失败:2xx 之外;409/429 经 expectedStatuses 排除后进 expected_contention。
http_req_failed: ['rate<0.001'],
'http_req_duration{group:::read}': ['p(95)<300', 'p(99)<1000'],
http_req_duration: ['p(99)<1000'],
},
summaryTrendStats: ['avg', 'p(50)', 'p(95)', 'p(99)', 'max'],
};
// 会话忙 / 队列满为负载合同预期拥塞,不计入 http_req_failed。
http.setResponseCallback(http.expectedStatuses(200, 201, 202, 409, 429));
function baseForVU() {
// VU 粘滞:偶数 → 0,奇数 → 1(单实例时恒为 0)。
const idx = exec.vu.idInTest % BASE_URLS.length;
return BASE_URLS[idx];
}
function otherBase(mine) {
if (BASE_URLS.length < 2) return mine;
return BASE_URLS[0] === mine ? BASE_URLS[1] : BASE_URLS[0];
}
const jsonHeaders = { headers: { 'Content-Type': 'application/json' } };
function parseJSON(res) {
try {
return res.json();
} catch (e) {
return null;
}
}
// 与现网一致:管理员建号 → 首登 must_change → POST /api/auth/password → 再登录。
// 压测口令保持不变(next=同一密码),只走完设密门,不另造后门清标志。
function loginReady(base, owner, password) {
const login = http.post(`${base}/api/auth/login`, JSON.stringify({ owner, password }), jsonHeaders);
if (login.status !== 200) return login;
const body = parseJSON(login);
if (body && body.mustChangePassword) {
const ch = http.post(`${base}/api/auth/password`, JSON.stringify({ next: password }), jsonHeaders);
if (ch.status !== 200) return ch;
return http.post(`${base}/api/auth/login`, JSON.stringify({ owner, password }), jsonHeaders);
}
return login;
}
// ---- setup:管理员建压测用户;双实例时建共享热点会话 ----
export function setup() {
const adminBase = BASE_URLS[0];
const adminLogin = loginReady(adminBase, ADMIN_OWNER, ADMIN_PASS);
if (adminLogin.status !== 200) throw new Error(`admin login: ${adminLogin.status} ${adminLogin.body}`);
const users = [];
for (let i = 0; i < USERS; i++) {
const owner = `load_u${i}`;
const password = `load-user-pass-${i}-Aa9`;
const created = http.post(`${adminBase}/api/admin/users`, JSON.stringify({ owner, name: `压测用户${i}`, password, isAdmin: false }), jsonHeaders);
if (created.status !== 200 && created.status !== 409) throw new Error(`create user ${owner}: ${created.status} ${created.body}`);
users.push({ owner, password });
}
// 建号必须保持管理员会话;设密会换 cookie,只能在全员建完后逐个走门。
for (const u of users) {
const gated = loginReady(adminBase, u.owner, u.password);
if (gated.status !== 200) throw new Error(`activate ${u.owner}: ${gated.status} ${gated.body}`);
}
// 热点会话:多 VU 跨实例争抢同一会话(C2「互相不拖累」的争用源)。
// 建用户时 cookie 落在最后一位 load_u* 上,热点资源须用管理员会话。
const adminAgain = loginReady(adminBase, ADMIN_OWNER, ADMIN_PASS);
if (adminAgain.status !== 200) throw new Error(`admin re-login: ${adminAgain.status} ${adminAgain.body}`);
let hotspot = null;
if (BASE_URLS.length >= 2) {
const proj = http.post(`${adminBase}/api/projects`, JSON.stringify({ name: 'hotspot-shared' }), jsonHeaders);
if (proj.status !== 200) throw new Error(`hotspot project: ${proj.status} ${proj.body}`);
const projBody = parseJSON(proj);
const conv = http.post(`${adminBase}/api/projects/${projBody.id}/conversations`, JSON.stringify({ title: '热点会话' }), jsonHeaders);
if (conv.status !== 200) throw new Error(`hotspot conv: ${conv.status} ${conv.body}`);
hotspot = { projectID: projBody.id, convID: parseJSON(conv).id, owner: ADMIN_OWNER, password: ADMIN_PASS };
}
return { users, hotspot, bases: BASE_URLS };
}
// ---- default:每 VU 一个用户身份,混合读写 ----
export default function (data) {
const base = baseForVU();
const u = data.users[exec.scenario.iterationInTest % data.users.length];
const login = loginReady(base, u.owner, u.password);
if (login.status !== 200) {
check(null, { 'user login': () => false });
return;
}
if (!u.__ctx) {
const proj = http.post(`${base}/api/projects`, JSON.stringify({ name: `压测-${u.owner}` }), jsonHeaders);
const projBody = parseJSON(proj);
if (proj.status !== 200 || !projBody || !projBody.id) {
check(proj, { 'create project 2xx': () => false });
return;
}
const conv = http.post(`${base}/api/projects/${projBody.id}/conversations`, JSON.stringify({ title: '压测会话' }), jsonHeaders);
const convBody = parseJSON(conv);
if (conv.status !== 200 || !convBody || !convBody.id) {
check(conv, { 'create conversation 2xx': () => false });
return;
}
u.__ctx = { projectID: projBody.id, convID: convBody.id };
}
const { projectID, convID } = u.__ctx;
group('read', () => {
const projs = http.get(`${base}/api/projects`);
check(projs, { 'list projects 2xx': (r) => r.status === 200 });
const detail = http.get(`${base}/api/conversations/${convID}`);
check(detail, { 'conversation detail 2xx': (r) => r.status === 200 });
const cards = http.get(`${base}/api/conversations/${convID}/cards`);
check(cards, { 'cards 2xx': (r) => r.status === 200 });
const settings = http.get(`${base}/api/settings`);
check(settings, { 'settings 2xx': (r) => r.status === 200 });
});
// 跨实例读:本 VU 会话在另一实例上应立即可见(共享权威)。
if (BASE_URLS.length >= 2 && exec.scenario.iterationInTest % 5 === 0) {
const other = otherBase(base);
// 另一实例需同用户登录(cookie jar 按 VU;换 host 需重新登录)。
const loginB = loginReady(other, u.owner, u.password);
if (loginB.status === 200) {
const cross = http.get(`${other}/api/conversations/${convID}`);
if (cross.status === 200) crossReadOk.add(1);
check(cross, { 'cross-instance conversation 2xx': (r) => r.status === 200 });
}
}
group('write', () => {
// ~20% 迭代打热点会话(双实例时经本 VU 粘滞实例提交 → 预期 409 上升)。
let targetConv = convID;
let writeBase = base;
if (data.hotspot && exec.scenario.iterationInTest % 5 === 0) {
targetConv = data.hotspot.convID;
// 热点会话属 admin:先以 admin 登录再写。
const hl = loginReady(writeBase, data.hotspot.owner, data.hotspot.password);
if (hl.status !== 200) {
check(null, { 'hotspot admin login': () => false });
return;
}
}
const requestId = `req-${u.owner}-${exec.scenario.iterationInTest}-${exec.vu.idInTest}`;
const body = JSON.stringify({ text: `压测消息 ${Date.now()}`, requestId });
const t0 = Date.now();
const sent = http.post(`${writeBase}/api/conversations/${targetConv}/messages`, body, { headers: { 'Content-Type': 'application/json' } });
if (sent.status === 200) {
msgLatency.add(Date.now() - t0);
const id = sent.json().message && sent.json().message.id;
check(sent, { 'message has id': () => !!id });
if (exec.scenario.iterationInTest % 10 === 0 && targetConv === convID) {
const replay = http.post(`${writeBase}/api/conversations/${targetConv}/messages`, body, { headers: { 'Content-Type': 'application/json' } });
check(replay, {
'requestId replay same message': (r) => r.status === 200 && r.json().message && r.json().message.id === id,
});
}
} else if (sent.status === 409 || sent.status === 429) {
expectedContention.add(1);
} else {
check(sent, { 'message send 2xx': () => false });
}
});
sleep(1);
}