diff --git a/deploy/zitadel/machinekey/k6.env b/deploy/zitadel/machinekey/k6.env index 6755819..43fdc61 100644 --- a/deploy/zitadel/machinekey/k6.env +++ b/deploy/zitadel/machinekey/k6.env @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8c92fc9..26641d2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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": [ diff --git a/test/k6/README.md b/test/k6/README.md index 167beb2..9444995 100644 --- a/test/k6/README.md +++ b/test/k6/README.md @@ -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 | diff --git a/test/k6/journeys/change_password_full.js b/test/k6/journeys/change_password_full.js new file mode 100644 index 0000000..21da827 --- /dev/null +++ b/test/k6/journeys/change_password_full.js @@ -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, + ); +} diff --git a/test/k6/journeys/login_mfa_full.js b/test/k6/journeys/login_mfa_full.js new file mode 100644 index 0000000..6daf640 --- /dev/null +++ b/test/k6/journeys/login_mfa_full.js @@ -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'); + } +} diff --git a/test/k6/journeys/password_forgot_reset_full.js b/test/k6/journeys/password_forgot_reset_full.js new file mode 100644 index 0000000..e7ebcff --- /dev/null +++ b/test/k6/journeys/password_forgot_reset_full.js @@ -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, + ); +} diff --git a/test/k6/journeys/register_resume_full.js b/test/k6/journeys/register_resume_full.js new file mode 100644 index 0000000..e883ac0 --- /dev/null +++ b/test/k6/journeys/register_resume_full.js @@ -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}`); + } +} diff --git a/test/k6/lib/auth.js b/test/k6/lib/auth.js index 55fab2d..8a4343a 100644 --- a/test/k6/lib/auth.js +++ b/test/k6/lib/auth.js @@ -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; +} + +// 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)}`); } - return body.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. diff --git a/test/k6/lib/config.js b/test/k6/lib/config.js index 391d1d6..d9ac81d 100644 --- a/test/k6/lib/config.js +++ b/test/k6/lib/config.js @@ -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. diff --git a/test/k6/lib/member.js b/test/k6/lib/member.js new file mode 100644 index 0000000..37f3895 --- /dev/null +++ b/test/k6/lib/member.js @@ -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; +} diff --git a/test/k6/smoke/auth_login_mfa.js b/test/k6/smoke/auth_login_mfa.js new file mode 100644 index 0000000..0d68ba4 --- /dev/null +++ b/test/k6/smoke/auth_login_mfa.js @@ -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'); + } +} diff --git a/test/k6/smoke/auth_password.js b/test/k6/smoke/auth_password.js new file mode 100644 index 0000000..1ac8306 --- /dev/null +++ b/test/k6/smoke/auth_password.js @@ -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}`); + } +} diff --git a/test/k6/smoke/auth_register_resume.js b/test/k6/smoke/auth_register_resume.js new file mode 100644 index 0000000..033acf9 --- /dev/null +++ b/test/k6/smoke/auth_register_resume.js @@ -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 }); +} diff --git a/test/k6/smoke/member.js b/test/k6/smoke/member.js index 0ef88e6..1a50da1 100644 --- a/test/k6/smoke/member.js +++ b/test/k6/smoke/member.js @@ -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}`); + } }