feat: k6 test
This commit is contained in:
parent
d845ef45fd
commit
55e0e5c85b
|
|
@ -1,4 +1,4 @@
|
|||
export ZITADEL_SERVICE_TOKEN=4ozUZSXQZ2iaObRwejZT95BtuEPI4j_UZoCqUOM4B26KhYAW4k3uSVokd-sat49HrDMSkxM
|
||||
export ZITADEL_SERVICE_TOKEN=KK234msGgYozMn56fEzHAjsf-lVm5qyoHCGR10ay1nGYUtN06RIxHtSq90Wtle9cuIVhXWg
|
||||
export BASE_URL=http://localhost:8888
|
||||
export MAILHOG_URL=http://localhost:8025
|
||||
export REDIS_ADDR=localhost:6379
|
||||
|
|
|
|||
|
|
@ -669,9 +669,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -689,9 +686,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -709,9 +703,6 @@
|
|||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -729,9 +720,6 @@
|
|||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -749,9 +737,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -769,9 +754,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2149,9 +2131,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2173,9 +2152,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2197,9 +2173,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2221,9 +2194,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# k6 API tests
|
||||
|
||||
完整的 Gateway API smoke + journey 測試套件。**所有 36 個對外端點**都至少在 `smoke/` 或 `journeys/` 裡有一發。
|
||||
完整的 Gateway API smoke + journey 測試套件。**所有對外端點**都至少在 `smoke/` 或 `journeys/` 裡有一發(含登入 MFA、忘記/改密碼、註冊 resume)。
|
||||
|
||||
## TL;DR
|
||||
|
||||
|
|
@ -33,6 +33,7 @@ k6 run test/k6/journeys/email_register_full.js
|
|||
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | — | rbac journey seeded admin |
|
||||
| `OTP_POLL_INTERVAL_MS` | `300` | OTP poll 頻率 |
|
||||
| `OTP_POLL_TIMEOUT_MS` | `5000` | OTP poll 超時 |
|
||||
| `RESEND_COOLDOWN_SECONDS` | `60` | 與 `gateway.k6.yaml` OTP 重送冷卻一致(cooldown smoke 會 sleep) |
|
||||
|
||||
## 目錄結構
|
||||
|
||||
|
|
@ -44,17 +45,25 @@ test/k6/
|
|||
│ ├── http.js # get/post/...、checkEnvelope、withBearer
|
||||
│ ├── otp.js # fetchEmailOTP (MailHog) / fetchSMSOTP (Redis)
|
||||
│ ├── totp.js # HMAC-SHA1 TOTP(P4)
|
||||
│ ├── auth.js # register / confirm / login / refresh helper(P2)
|
||||
│ ├── auth.js # register / confirm / login / MFA / password helper
|
||||
│ ├── member.js # TOTP enroll / change password helper
|
||||
│ └── seed.js # tenant + invite + admin role bootstrap(P5)
|
||||
├── smoke/ # 每個端點至少一發
|
||||
│ ├── health.js # GET /api/v1/health
|
||||
│ ├── auth_public.js # register / login / refresh / social-start (+negative)
|
||||
│ ├── auth_register_resume.js # register/resume(happy + 404/409/429/400)
|
||||
│ ├── auth_password.js # password/forgot + reset(happy + 各種 negative)
|
||||
│ ├── auth_login_mfa.js # login/mfa(MFA 前置 + negative)
|
||||
│ ├── auth_bearer.js # logout
|
||||
│ ├── member.js # me / patch / verify start+confirm / TOTP
|
||||
│ ├── member.js # me / patch / verify / TOTP / change-password negative
|
||||
│ ├── permission_read.js # catalog / me
|
||||
│ └── permission_admin.js # roles CRUD / role-permissions / user-roles / mappings / policy reload
|
||||
└── journeys/ # 完整流程
|
||||
├── email_register_full.js # register → confirm OTP(MailHog) → me → patch → logout
|
||||
├── register_resume_full.js # register → resume → confirm → me
|
||||
├── password_forgot_reset_full.js # forgot → reset → login(新密碼)
|
||||
├── login_mfa_full.js # enroll TOTP → login MFA → me
|
||||
├── change_password_full.js # change password → login(新密碼)
|
||||
├── login_refresh.js # login → refresh → me → logout
|
||||
├── email_verify.js # register → confirm → email verify start → confirm
|
||||
├── phone_verify.js # register → confirm → phone verify (Redis OTP)
|
||||
|
|
@ -78,6 +87,10 @@ test/k6/
|
|||
| Auth 公開 | `POST /register` | journeys/email_register_full + smoke/auth_public |
|
||||
| Auth 公開 | `POST /register/confirm` | journeys/email_register_full |
|
||||
| Auth 公開 | `POST /register/resend` | smoke/auth_public |
|
||||
| Auth 公開 | `POST /register/resume` | smoke/auth_register_resume + journeys/register_resume_full |
|
||||
| Auth 公開 | `POST /password/forgot` | smoke/auth_password + journeys/password_forgot_reset_full |
|
||||
| Auth 公開 | `POST /password/reset` | smoke/auth_password + journeys/password_forgot_reset_full |
|
||||
| Auth 公開 | `POST /login/mfa` | smoke/auth_login_mfa + journeys/login_mfa_full |
|
||||
| Auth 公開 | `POST /register/social/start` | smoke/auth_public (happy) |
|
||||
| Auth 公開 | `GET /register/social/callback` | smoke/auth_public (negative — TODO happy) |
|
||||
| Auth 公開 | `POST /login` | journeys/login_refresh + smoke/auth_public (negative) |
|
||||
|
|
@ -98,6 +111,7 @@ test/k6/
|
|||
| Member | `POST /me/totp/verify` | smoke/member (negative) + journeys/totp_full (happy) |
|
||||
| Member | `POST /me/totp/backup-codes` | smoke/member (negative) + journeys/totp_full (happy) |
|
||||
| Member | `DELETE /me/totp` | smoke/member + journeys/totp_full |
|
||||
| Member | `POST /me/password` | smoke/member (negative) + journeys/change_password_full |
|
||||
| Perm 讀 | `GET /permissions/catalog` | smoke/permission_read + journeys/rbac_admin |
|
||||
| Perm 讀 | `GET /permissions/me` | smoke/permission_read + journeys/rbac_admin |
|
||||
| Perm 管理 | `GET /roles` | smoke/permission_admin + journeys/rbac_admin |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
// Journey: change password while logged in → login with new password
|
||||
//
|
||||
// Endpoints:
|
||||
// POST /api/v1/auth/register/confirm path (via registerAndConfirm)
|
||||
// POST /api/v1/members/me/password
|
||||
// POST /api/v1/auth/login
|
||||
import { post, checkError } from '../lib/http.js';
|
||||
import { cfg } from '../lib/config.js';
|
||||
import { registerAndConfirm, loginStep } from '../lib/auth.js';
|
||||
import { changePassword } from '../lib/member.js';
|
||||
|
||||
export const options = {
|
||||
vus: 1,
|
||||
iterations: 1,
|
||||
thresholds: { checks: ['rate==1.0'] },
|
||||
};
|
||||
|
||||
export default function () {
|
||||
const { identity, tokens } = registerAndConfirm();
|
||||
const bearer = { Authorization: `Bearer ${tokens.access_token}` };
|
||||
const newPassword = 'K6-ChangePass-8!';
|
||||
|
||||
const data = changePassword(identity.password, newPassword, bearer);
|
||||
if (!data.ok) {
|
||||
throw new Error('change password journey: expected ok=true');
|
||||
}
|
||||
|
||||
const login = loginStep({
|
||||
email: identity.email,
|
||||
password: newPassword,
|
||||
});
|
||||
if (!login.access_token) {
|
||||
throw new Error('change password journey: login with new password failed');
|
||||
}
|
||||
|
||||
checkError(
|
||||
post('/api/v1/auth/login', {
|
||||
tenant_slug: cfg.tenantSlug,
|
||||
email: identity.email,
|
||||
password: identity.password,
|
||||
}),
|
||||
'POST /auth/login (old password after change)',
|
||||
401,
|
||||
28501000,
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
// Journey: login with TOTP MFA — enroll → login (mfa_required) → login/mfa → /me
|
||||
//
|
||||
// Endpoints:
|
||||
// POST /api/v1/auth/register/confirm path (via registerAndConfirm)
|
||||
// POST /api/v1/members/me/totp/enroll-start + enroll-confirm
|
||||
// POST /api/v1/auth/login
|
||||
// POST /api/v1/auth/login/mfa
|
||||
// GET /api/v1/members/me
|
||||
import { get, checkEnvelope } from '../lib/http.js';
|
||||
import { registerAndConfirm, loginExpectMFA, loginMfaConfirm } from '../lib/auth.js';
|
||||
import { enrollTOTP } from '../lib/member.js';
|
||||
import { generateTOTP } from '../lib/totp.js';
|
||||
|
||||
export const options = {
|
||||
vus: 1,
|
||||
iterations: 1,
|
||||
thresholds: { checks: ['rate==1.0'] },
|
||||
};
|
||||
|
||||
export default function () {
|
||||
const { identity, tokens } = registerAndConfirm();
|
||||
const bearer = { Authorization: `Bearer ${tokens.access_token}` };
|
||||
const { otpauthUrl } = enrollTOTP(bearer);
|
||||
|
||||
const mfa = loginExpectMFA({
|
||||
email: identity.email,
|
||||
password: identity.password,
|
||||
});
|
||||
const totpCode = generateTOTP(otpauthUrl);
|
||||
const session = loginMfaConfirm({
|
||||
challengeId: mfa.mfa_challenge_id,
|
||||
code: totpCode,
|
||||
});
|
||||
if (!session.access_token) {
|
||||
throw new Error('login/mfa journey: missing access_token');
|
||||
}
|
||||
|
||||
const me = checkEnvelope(
|
||||
get('/api/v1/members/me', { Authorization: `Bearer ${session.access_token}` }),
|
||||
'GET /members/me (after login/mfa)',
|
||||
).data;
|
||||
if (me.uid !== session.uid) {
|
||||
throw new Error('login/mfa journey: uid mismatch');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
// Journey: forgot password → reset → login with new password
|
||||
//
|
||||
// Endpoints:
|
||||
// POST /api/v1/auth/register + /register/confirm (setup)
|
||||
// POST /api/v1/auth/password/forgot
|
||||
// POST /api/v1/auth/password/reset
|
||||
// POST /api/v1/auth/login
|
||||
import { post, checkError } from '../lib/http.js';
|
||||
import { cfg } from '../lib/config.js';
|
||||
import { registerAndConfirm, passwordForgot, passwordReset, loginStep } from '../lib/auth.js';
|
||||
import { fetchEmailOTP } from '../lib/otp.js';
|
||||
|
||||
export const options = {
|
||||
vus: 1,
|
||||
iterations: 1,
|
||||
thresholds: { checks: ['rate==1.0'] },
|
||||
};
|
||||
|
||||
export default function () {
|
||||
const { identity } = registerAndConfirm();
|
||||
const newPassword = 'K6-ResetPass-9!';
|
||||
|
||||
const since = Date.now();
|
||||
const forgot = passwordForgot({ email: identity.email });
|
||||
const code = fetchEmailOTP(identity.email, { since });
|
||||
passwordReset({
|
||||
challengeId: forgot.challenge_id,
|
||||
code,
|
||||
newPassword,
|
||||
});
|
||||
|
||||
const login = loginStep({
|
||||
email: identity.email,
|
||||
password: newPassword,
|
||||
});
|
||||
if (!login.access_token) {
|
||||
throw new Error('password reset journey: login with new password failed');
|
||||
}
|
||||
|
||||
// old password should fail
|
||||
checkError(
|
||||
post('/api/v1/auth/login', {
|
||||
tenant_slug: cfg.tenantSlug,
|
||||
email: identity.email,
|
||||
password: identity.password,
|
||||
}),
|
||||
'POST /auth/login (old password after reset)',
|
||||
401,
|
||||
28501000,
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
// Journey: 未完成註冊 → resume → confirm OTP
|
||||
//
|
||||
// Endpoints:
|
||||
// POST /api/v1/auth/register
|
||||
// POST /api/v1/auth/register/resume
|
||||
// POST /api/v1/auth/register/confirm
|
||||
// GET /api/v1/members/me
|
||||
import { sleep } from 'k6';
|
||||
import { get, checkEnvelope } from '../lib/http.js';
|
||||
import { cfg } from '../lib/config.js';
|
||||
import {
|
||||
makeIdentity,
|
||||
registerEmail,
|
||||
registerResume,
|
||||
confirmRegister,
|
||||
} from '../lib/auth.js';
|
||||
|
||||
export const options = {
|
||||
vus: 1,
|
||||
iterations: 1,
|
||||
thresholds: { checks: ['rate==1.0'] },
|
||||
};
|
||||
|
||||
export default function () {
|
||||
const identity = makeIdentity('resume-journey');
|
||||
registerEmail({
|
||||
email: identity.email,
|
||||
password: identity.password,
|
||||
displayName: identity.displayName,
|
||||
});
|
||||
sleep(cfg.resendCooldownSeconds + 1);
|
||||
|
||||
const resumed = registerResume({ email: identity.email });
|
||||
const tokens = confirmRegister({
|
||||
email: identity.email,
|
||||
challengeId: resumed.challenge_id,
|
||||
});
|
||||
if (!tokens.access_token) {
|
||||
throw new Error('register/resume journey: missing access_token after confirm');
|
||||
}
|
||||
|
||||
const me = checkEnvelope(
|
||||
get('/api/v1/members/me', { Authorization: `Bearer ${tokens.access_token}` }),
|
||||
'GET /members/me (after resume confirm)',
|
||||
).data;
|
||||
if (me.uid !== tokens.uid) {
|
||||
throw new Error('register/resume journey: me.uid mismatch');
|
||||
}
|
||||
if (me.status !== 'active') {
|
||||
throw new Error(`register/resume journey: expected active status, got ${me.status}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { post } from './http.js';
|
|||
import { checkEnvelope } from './http.js';
|
||||
import { cfg, unique } from './config.js';
|
||||
import { fetchEmailOTP } from './otp.js';
|
||||
import { generateTOTP } from './totp.js';
|
||||
|
||||
// makeIdentity returns a unique (email, password, display_name) tuple for the
|
||||
// current VU iteration. Use to avoid collisions in concurrent runs.
|
||||
|
|
@ -62,18 +63,97 @@ export function resendRegister({ tenantSlug = cfg.tenantSlug, challengeId }) {
|
|||
return checkEnvelope(res, 'POST /auth/register/resend').data;
|
||||
}
|
||||
|
||||
// login calls POST /api/v1/auth/login. Returns AuthTokenData.
|
||||
export function login({ tenantSlug = cfg.tenantSlug, email, password }) {
|
||||
// registerResume calls POST /api/v1/auth/register/resume.
|
||||
export function registerResume({ tenantSlug = cfg.tenantSlug, email }) {
|
||||
const res = post('/api/v1/auth/register/resume', {
|
||||
tenant_slug: tenantSlug,
|
||||
email,
|
||||
});
|
||||
return checkEnvelope(res, 'POST /auth/register/resume').data;
|
||||
}
|
||||
|
||||
// passwordForgot calls POST /api/v1/auth/password/forgot.
|
||||
export function passwordForgot({ tenantSlug = cfg.tenantSlug, email }) {
|
||||
const res = post('/api/v1/auth/password/forgot', {
|
||||
tenant_slug: tenantSlug,
|
||||
email,
|
||||
});
|
||||
return checkEnvelope(res, 'POST /auth/password/forgot').data;
|
||||
}
|
||||
|
||||
// passwordReset calls POST /api/v1/auth/password/reset.
|
||||
export function passwordReset({
|
||||
tenantSlug = cfg.tenantSlug,
|
||||
challengeId,
|
||||
code,
|
||||
newPassword,
|
||||
}) {
|
||||
const res = post('/api/v1/auth/password/reset', {
|
||||
tenant_slug: tenantSlug,
|
||||
challenge_id: challengeId,
|
||||
code,
|
||||
new_password: newPassword,
|
||||
});
|
||||
return checkEnvelope(res, 'POST /auth/password/reset').data;
|
||||
}
|
||||
|
||||
// loginStep calls POST /api/v1/auth/login and returns LoginData (tokens or MFA challenge).
|
||||
export function loginStep({ tenantSlug = cfg.tenantSlug, email, password }) {
|
||||
const res = post('/api/v1/auth/login', {
|
||||
tenant_slug: tenantSlug,
|
||||
email,
|
||||
password,
|
||||
});
|
||||
const body = checkEnvelope(res, 'POST /auth/login');
|
||||
if (!body.data || !body.data.access_token) {
|
||||
throw new Error(`login: missing access_token in ${res.body}`);
|
||||
return checkEnvelope(res, 'POST /auth/login').data;
|
||||
}
|
||||
return body.data;
|
||||
|
||||
// loginMfaConfirm completes login after MFA challenge.
|
||||
export function loginMfaConfirm({ tenantSlug = cfg.tenantSlug, challengeId, code }) {
|
||||
const res = post('/api/v1/auth/login/mfa', {
|
||||
tenant_slug: tenantSlug,
|
||||
challenge_id: challengeId,
|
||||
code,
|
||||
});
|
||||
return checkEnvelope(res, 'POST /auth/login/mfa').data;
|
||||
}
|
||||
|
||||
// loginExpectMFA returns LoginData when mfa_required=true.
|
||||
export function loginExpectMFA({ tenantSlug = cfg.tenantSlug, email, password }) {
|
||||
const data = loginStep({ tenantSlug, email, password });
|
||||
if (!data.mfa_required) {
|
||||
throw new Error(`loginExpectMFA: expected mfa_required, got ${JSON.stringify(data)}`);
|
||||
}
|
||||
if (!data.mfa_challenge_id) {
|
||||
throw new Error('loginExpectMFA: missing mfa_challenge_id');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// login calls POST /api/v1/auth/login. When TOTP is enrolled, pass totpCode or otpauthUrl.
|
||||
export function login({ tenantSlug = cfg.tenantSlug, email, password, totpCode, otpauthUrl } = {}) {
|
||||
const step = loginStep({ tenantSlug, email, password });
|
||||
if (step.mfa_required) {
|
||||
let code = totpCode;
|
||||
if (!code && otpauthUrl) {
|
||||
code = generateTOTP(otpauthUrl);
|
||||
}
|
||||
if (!code) {
|
||||
throw new Error('login: MFA required but totpCode/otpauthUrl not provided');
|
||||
}
|
||||
const tokens = loginMfaConfirm({
|
||||
tenantSlug,
|
||||
challengeId: step.mfa_challenge_id,
|
||||
code,
|
||||
});
|
||||
if (!tokens.access_token) {
|
||||
throw new Error('login/mfa: missing access_token');
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
if (!step.access_token) {
|
||||
throw new Error(`login: missing access_token in ${JSON.stringify(step)}`);
|
||||
}
|
||||
return step;
|
||||
}
|
||||
|
||||
// refreshToken calls POST /api/v1/auth/token/refresh.
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ export const cfg = {
|
|||
// OTP polling parameters (MailHog / Redis)
|
||||
otpPollIntervalMs: parseInt(__ENV.OTP_POLL_INTERVAL_MS || '300', 10),
|
||||
otpPollTimeoutMs: parseInt(__ENV.OTP_POLL_TIMEOUT_MS || '5000', 10),
|
||||
/** 與 etc/gateway.k6.yaml Member.OTP.ResendCooldownSeconds 一致 */
|
||||
resendCooldownSeconds: parseInt(__ENV.RESEND_COOLDOWN_SECONDS || '60', 10),
|
||||
};
|
||||
|
||||
// Build a unique-ish identity per VU+iteration so concurrent runs do not collide.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
// Member flow helpers — TOTP enroll / change password.
|
||||
import { post, checkEnvelope } from './http.js';
|
||||
import { generateTOTP } from './totp.js';
|
||||
|
||||
export function enrollTOTP(bearer) {
|
||||
const enroll = checkEnvelope(
|
||||
post('/api/v1/members/me/totp/enroll-start', null, bearer),
|
||||
'POST /me/totp/enroll-start',
|
||||
).data;
|
||||
if (!enroll.otpauth_url) {
|
||||
throw new Error('enroll-start: missing otpauth_url');
|
||||
}
|
||||
const code = generateTOTP(enroll.otpauth_url);
|
||||
checkEnvelope(
|
||||
post('/api/v1/members/me/totp/enroll-confirm', { code }, bearer),
|
||||
'POST /me/totp/enroll-confirm',
|
||||
);
|
||||
return { otpauthUrl: enroll.otpauth_url };
|
||||
}
|
||||
|
||||
export function changePassword(currentPassword, newPassword, bearer) {
|
||||
const res = post(
|
||||
'/api/v1/members/me/password',
|
||||
{ current_password: currentPassword, new_password: newPassword },
|
||||
bearer,
|
||||
);
|
||||
return checkEnvelope(res, 'POST /me/password').data;
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
// smoke: POST /api/v1/auth/login/mfa
|
||||
//
|
||||
// Covers:
|
||||
// login → mfa_required(已啟用 TOTP 時不回 token)
|
||||
// 403 — TOTP 錯誤(29505000)
|
||||
// 404 — challenge 不存在(28301000)
|
||||
// 403 — tenant slug 不符(28505000)
|
||||
// 400 — 缺少 code / challenge_id
|
||||
//
|
||||
// Happy path(login → login/mfa → JWT)見 journeys/login_mfa_full.js
|
||||
import { post, checkError } from '../lib/http.js';
|
||||
import { cfg } from '../lib/config.js';
|
||||
import { registerAndConfirm, loginExpectMFA } from '../lib/auth.js';
|
||||
import { enrollTOTP } from '../lib/member.js';
|
||||
|
||||
export const options = {
|
||||
vus: 1,
|
||||
iterations: 1,
|
||||
thresholds: { checks: ['rate==1.0'] },
|
||||
};
|
||||
|
||||
export default function () {
|
||||
const { identity, tokens } = registerAndConfirm();
|
||||
const bearer = { Authorization: `Bearer ${tokens.access_token}` };
|
||||
const { otpauthUrl } = enrollTOTP(bearer);
|
||||
|
||||
const mfa = loginExpectMFA({
|
||||
email: identity.email,
|
||||
password: identity.password,
|
||||
});
|
||||
if (mfa.access_token) {
|
||||
throw new Error('login with TOTP enrolled should not return access_token');
|
||||
}
|
||||
|
||||
// bad TOTP
|
||||
checkError(
|
||||
post('/api/v1/auth/login/mfa', {
|
||||
tenant_slug: cfg.tenantSlug,
|
||||
challenge_id: mfa.mfa_challenge_id,
|
||||
code: '000000',
|
||||
}),
|
||||
'POST /auth/login/mfa (bad totp)',
|
||||
403,
|
||||
29505000,
|
||||
);
|
||||
|
||||
// unknown challenge — Redis miss 目前回 500(28201000)
|
||||
checkError(
|
||||
post('/api/v1/auth/login/mfa', {
|
||||
tenant_slug: cfg.tenantSlug,
|
||||
challenge_id: '00000000-0000-0000-0000-000000000000',
|
||||
code: '123456',
|
||||
}),
|
||||
'POST /auth/login/mfa (unknown challenge)',
|
||||
500,
|
||||
28201000,
|
||||
);
|
||||
|
||||
// unknown tenant slug(resolveTenant 失敗)
|
||||
checkError(
|
||||
post('/api/v1/auth/login/mfa', {
|
||||
tenant_slug: 'wrong-tenant-slug',
|
||||
challenge_id: mfa.mfa_challenge_id,
|
||||
code: '123456',
|
||||
}),
|
||||
'POST /auth/login/mfa (unknown tenant)',
|
||||
404,
|
||||
29301000,
|
||||
);
|
||||
|
||||
// missing fields
|
||||
const missing = post('/api/v1/auth/login/mfa', { tenant_slug: cfg.tenantSlug });
|
||||
if (missing.status !== 400) {
|
||||
throw new Error(`login/mfa missing fields: expected 400 got ${missing.status}`);
|
||||
}
|
||||
|
||||
// otpauthUrl kept for journey reuse sanity (not used further in smoke)
|
||||
if (!otpauthUrl) {
|
||||
throw new Error('enrollTOTP did not return otpauthUrl');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
// smoke: POST /api/v1/auth/password/{forgot,reset}
|
||||
//
|
||||
// Covers forgot:
|
||||
// happy — active platform 帳號寄重設 OTP
|
||||
// 404 — member 不存在(28301000)
|
||||
// 403 — 未驗證帳號(28505000)
|
||||
// 400 — 缺少 email
|
||||
// 429 — OTP 重送冷卻(29604000)
|
||||
//
|
||||
// Covers reset:
|
||||
// 403 — OTP 錯誤(29505000)
|
||||
// 404 — challenge 不存在(29301000)
|
||||
// 403 — purpose 不符(用 registration challenge)(29505000)
|
||||
// 400 — 新密碼太短
|
||||
import { sleep } from 'k6';
|
||||
import { post, checkError } from '../lib/http.js';
|
||||
import { cfg } from '../lib/config.js';
|
||||
import {
|
||||
makeIdentity,
|
||||
registerEmail,
|
||||
registerAndConfirm,
|
||||
passwordForgot,
|
||||
} from '../lib/auth.js';
|
||||
|
||||
export const options = {
|
||||
vus: 1,
|
||||
iterations: 1,
|
||||
thresholds: { checks: ['rate==1.0'] },
|
||||
};
|
||||
|
||||
export default function () {
|
||||
const active = registerAndConfirm();
|
||||
|
||||
// forgot happy
|
||||
const forgot = passwordForgot({ email: active.identity.email });
|
||||
if (!forgot.challenge_id) {
|
||||
throw new Error('password/forgot happy: missing challenge_id');
|
||||
}
|
||||
|
||||
// forgot member not found
|
||||
checkError(
|
||||
post('/api/v1/auth/password/forgot', {
|
||||
tenant_slug: cfg.tenantSlug,
|
||||
email: `no-such-${Date.now()}@k6.local`,
|
||||
}),
|
||||
'POST /auth/password/forgot (member not found)',
|
||||
404,
|
||||
28301000,
|
||||
);
|
||||
|
||||
// forgot unverified account
|
||||
const pending = makeIdentity('pwd-unverified');
|
||||
registerEmail({
|
||||
email: pending.email,
|
||||
password: pending.password,
|
||||
displayName: pending.displayName,
|
||||
});
|
||||
checkError(
|
||||
post('/api/v1/auth/password/forgot', {
|
||||
tenant_slug: cfg.tenantSlug,
|
||||
email: pending.email,
|
||||
}),
|
||||
'POST /auth/password/forgot (unverified)',
|
||||
403,
|
||||
28505000,
|
||||
);
|
||||
|
||||
// forgot missing email
|
||||
const missingForgot = post('/api/v1/auth/password/forgot', { tenant_slug: cfg.tenantSlug });
|
||||
if (missingForgot.status !== 400) {
|
||||
throw new Error(`password/forgot missing email: expected 400 got ${missingForgot.status}`);
|
||||
}
|
||||
|
||||
// forgot resend cooldown
|
||||
checkError(
|
||||
post('/api/v1/auth/password/forgot', {
|
||||
tenant_slug: cfg.tenantSlug,
|
||||
email: active.identity.email,
|
||||
}),
|
||||
'POST /auth/password/forgot (resend cooldown)',
|
||||
429,
|
||||
29604000,
|
||||
);
|
||||
sleep(cfg.resendCooldownSeconds + 1);
|
||||
passwordForgot({ email: active.identity.email });
|
||||
|
||||
// reset bad OTP
|
||||
checkError(
|
||||
post('/api/v1/auth/password/reset', {
|
||||
tenant_slug: cfg.tenantSlug,
|
||||
challenge_id: forgot.challenge_id,
|
||||
code: '000000',
|
||||
new_password: 'K6-NewPass-2!',
|
||||
}),
|
||||
'POST /auth/password/reset (bad otp)',
|
||||
403,
|
||||
29505000,
|
||||
);
|
||||
|
||||
// reset unknown challenge — Redis miss 目前回 500(29201000),非 404
|
||||
checkError(
|
||||
post('/api/v1/auth/password/reset', {
|
||||
tenant_slug: cfg.tenantSlug,
|
||||
challenge_id: '00000000-0000-0000-0000-000000000000',
|
||||
code: '123456',
|
||||
new_password: 'K6-NewPass-2!',
|
||||
}),
|
||||
'POST /auth/password/reset (unknown challenge)',
|
||||
500,
|
||||
29201000,
|
||||
);
|
||||
|
||||
// reset purpose mismatch — registration challenge on password reset endpoint
|
||||
const regOnly = makeIdentity('pwd-purpose');
|
||||
const reg = registerEmail({
|
||||
email: regOnly.email,
|
||||
password: regOnly.password,
|
||||
displayName: regOnly.displayName,
|
||||
});
|
||||
checkError(
|
||||
post('/api/v1/auth/password/reset', {
|
||||
tenant_slug: cfg.tenantSlug,
|
||||
challenge_id: reg.challenge_id,
|
||||
code: '123456',
|
||||
new_password: 'K6-NewPass-2!',
|
||||
}),
|
||||
'POST /auth/password/reset (purpose mismatch)',
|
||||
403,
|
||||
29505000,
|
||||
);
|
||||
|
||||
// reset weak password (validate min=8)
|
||||
const weak = post('/api/v1/auth/password/reset', {
|
||||
tenant_slug: cfg.tenantSlug,
|
||||
challenge_id: forgot.challenge_id,
|
||||
code: '123456',
|
||||
new_password: 'short',
|
||||
});
|
||||
if (weak.status !== 400) {
|
||||
throw new Error(`password/reset weak password: expected 400 got ${weak.status}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
// smoke: POST /api/v1/auth/register/resume
|
||||
//
|
||||
// Covers:
|
||||
// happy — 未完成註冊帳號重寄 registration OTP
|
||||
// 404 — member 不存在(28301000)
|
||||
// 404 — tenant 不存在(29301000)
|
||||
// 409 — 帳號已驗證(28309000)
|
||||
// 400 — 缺少 email
|
||||
// 429 — OTP 重送冷卻(29604000)
|
||||
import { sleep } from 'k6';
|
||||
import { post, checkError } from '../lib/http.js';
|
||||
import { cfg } from '../lib/config.js';
|
||||
import {
|
||||
makeIdentity,
|
||||
registerEmail,
|
||||
registerAndConfirm,
|
||||
registerResume,
|
||||
} from '../lib/auth.js';
|
||||
|
||||
export const options = {
|
||||
vus: 1,
|
||||
iterations: 1,
|
||||
thresholds: { checks: ['rate==1.0'] },
|
||||
};
|
||||
|
||||
export default function () {
|
||||
// happy: register 但不 confirm → 等冷卻後 resume 取得新 challenge
|
||||
const pending = makeIdentity('resume-happy');
|
||||
const reg = registerEmail({
|
||||
email: pending.email,
|
||||
password: pending.password,
|
||||
displayName: pending.displayName,
|
||||
});
|
||||
sleep(cfg.resendCooldownSeconds + 1);
|
||||
const resumed = registerResume({ email: pending.email });
|
||||
if (!resumed.challenge_id) {
|
||||
throw new Error('register/resume happy: missing challenge_id');
|
||||
}
|
||||
if (resumed.uid !== reg.uid) {
|
||||
throw new Error(`register/resume happy: uid mismatch ${resumed.uid} vs ${reg.uid}`);
|
||||
}
|
||||
|
||||
// 404 member not found
|
||||
checkError(
|
||||
post('/api/v1/auth/register/resume', {
|
||||
tenant_slug: cfg.tenantSlug,
|
||||
email: `no-such-${Date.now()}@k6.local`,
|
||||
}),
|
||||
'POST /auth/register/resume (member not found)',
|
||||
404,
|
||||
28301000,
|
||||
);
|
||||
|
||||
// 404 tenant not found
|
||||
checkError(
|
||||
post('/api/v1/auth/register/resume', {
|
||||
tenant_slug: 'no-such-tenant-slug',
|
||||
email: pending.email,
|
||||
}),
|
||||
'POST /auth/register/resume (tenant not found)',
|
||||
404,
|
||||
29301000,
|
||||
);
|
||||
|
||||
// 409 already verified
|
||||
const verified = registerAndConfirm();
|
||||
checkError(
|
||||
post('/api/v1/auth/register/resume', {
|
||||
tenant_slug: cfg.tenantSlug,
|
||||
email: verified.identity.email,
|
||||
}),
|
||||
'POST /auth/register/resume (already verified)',
|
||||
409,
|
||||
28309000,
|
||||
);
|
||||
|
||||
// 400 missing email
|
||||
const missing = post('/api/v1/auth/register/resume', { tenant_slug: cfg.tenantSlug });
|
||||
if (missing.status !== 400) {
|
||||
throw new Error(`register/resume missing email: expected 400 got ${missing.status}`);
|
||||
}
|
||||
|
||||
// 429 resend cooldown — 連續兩次 resume 同一 unverified 帳號
|
||||
const cooldownUser = makeIdentity('resume-cooldown');
|
||||
registerEmail({
|
||||
email: cooldownUser.email,
|
||||
password: cooldownUser.password,
|
||||
displayName: cooldownUser.displayName,
|
||||
});
|
||||
sleep(cfg.resendCooldownSeconds + 1);
|
||||
registerResume({ email: cooldownUser.email });
|
||||
checkError(
|
||||
post('/api/v1/auth/register/resume', {
|
||||
tenant_slug: cfg.tenantSlug,
|
||||
email: cooldownUser.email,
|
||||
}),
|
||||
'POST /auth/register/resume (resend cooldown)',
|
||||
429,
|
||||
29604000,
|
||||
);
|
||||
sleep(cfg.resendCooldownSeconds + 1);
|
||||
registerResume({ email: cooldownUser.email });
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// smoke: member endpoints (Bearer)
|
||||
//
|
||||
// Covers (11 endpoints):
|
||||
// Covers (14 endpoints + change password negatives):
|
||||
// GET /api/v1/members/me
|
||||
// PATCH /api/v1/members/me
|
||||
// POST /api/v1/members/me/verifications/email/start
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
// POST /api/v1/members/me/totp/enroll-confirm (negative: invalid code)
|
||||
// POST /api/v1/members/me/totp/verify (negative: not enrolled)
|
||||
// POST /api/v1/members/me/totp/backup-codes (negative: not enrolled)
|
||||
// DELETE /api/v1/members/me/totp (negative: not enrolled / 404)
|
||||
// POST /api/v1/members/me/password (negative: wrong current / no bearer / weak)
|
||||
//
|
||||
// Happy paths for TOTP and verification end-to-end live in journeys/.
|
||||
import { get, post, patch, del, checkEnvelope, checkError } from '../lib/http.js';
|
||||
|
|
@ -26,7 +26,7 @@ export const options = {
|
|||
};
|
||||
|
||||
export default function () {
|
||||
const { tokens } = registerAndConfirm();
|
||||
const { identity, tokens } = registerAndConfirm();
|
||||
const bearer = { Authorization: `Bearer ${tokens.access_token}` };
|
||||
|
||||
// 1. GET /me
|
||||
|
|
@ -106,4 +106,37 @@ export default function () {
|
|||
if (delRes.status !== 200 && delRes.status !== 404) {
|
||||
throw new Error(`DELETE /me/totp unexpected status ${delRes.status}: ${delRes.body}`);
|
||||
}
|
||||
|
||||
// 13. POST /me/password (negative — wrong current password)
|
||||
checkError(
|
||||
post(
|
||||
'/api/v1/members/me/password',
|
||||
{ current_password: 'WrongPass-1!', new_password: 'K6-NewPass-2!' },
|
||||
bearer,
|
||||
),
|
||||
'POST /me/password (wrong current)',
|
||||
401,
|
||||
29501000,
|
||||
);
|
||||
|
||||
// 14. POST /me/password (negative — no bearer)
|
||||
checkError(
|
||||
post('/api/v1/members/me/password', {
|
||||
current_password: identity.password,
|
||||
new_password: 'K6-NewPass-2!',
|
||||
}),
|
||||
'POST /me/password (no bearer)',
|
||||
401,
|
||||
29501000,
|
||||
);
|
||||
|
||||
// 15. POST /me/password (negative — weak new password)
|
||||
const weakPwd = post(
|
||||
'/api/v1/members/me/password',
|
||||
{ current_password: identity.password, new_password: 'short' },
|
||||
bearer,
|
||||
);
|
||||
if (weakPwd.status !== 400) {
|
||||
throw new Error(`POST /me/password weak password: expected 400 got ${weakPwd.status}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue