feat/env #1

Merged
daniel.w merged 4 commits from feat/env into main 2026-05-26 17:11:16 +00:00
14 changed files with 690 additions and 43 deletions
Showing only changes of commit 55e0e5c85b - Show all commits

View File

@ -1,4 +1,4 @@
export ZITADEL_SERVICE_TOKEN=4ozUZSXQZ2iaObRwejZT95BtuEPI4j_UZoCqUOM4B26KhYAW4k3uSVokd-sat49HrDMSkxM export ZITADEL_SERVICE_TOKEN=KK234msGgYozMn56fEzHAjsf-lVm5qyoHCGR10ay1nGYUtN06RIxHtSq90Wtle9cuIVhXWg
export BASE_URL=http://localhost:8888 export BASE_URL=http://localhost:8888
export MAILHOG_URL=http://localhost:8025 export MAILHOG_URL=http://localhost:8025
export REDIS_ADDR=localhost:6379 export REDIS_ADDR=localhost:6379

View File

@ -669,9 +669,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -689,9 +686,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -709,9 +703,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -729,9 +720,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -749,9 +737,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -769,9 +754,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2149,9 +2131,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2173,9 +2152,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2197,9 +2173,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2221,9 +2194,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [

View File

@ -1,6 +1,6 @@
# k6 API tests # k6 API tests
完整的 Gateway API smoke + journey 測試套件。**所有 36 個對外端點**都至少在 `smoke/``journeys/` 裡有一發。 完整的 Gateway API smoke + journey 測試套件。**所有對外端點**都至少在 `smoke/``journeys/` 裡有一發(含登入 MFA、忘記/改密碼、註冊 resume
## TL;DR ## TL;DR
@ -33,6 +33,7 @@ k6 run test/k6/journeys/email_register_full.js
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | — | rbac journey seeded admin | | `ADMIN_EMAIL` / `ADMIN_PASSWORD` | — | rbac journey seeded admin |
| `OTP_POLL_INTERVAL_MS` | `300` | OTP poll 頻率 | | `OTP_POLL_INTERVAL_MS` | `300` | OTP poll 頻率 |
| `OTP_POLL_TIMEOUT_MS` | `5000` | 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 │ ├── http.js # get/post/...、checkEnvelope、withBearer
│ ├── otp.js # fetchEmailOTP (MailHog) / fetchSMSOTP (Redis) │ ├── otp.js # fetchEmailOTP (MailHog) / fetchSMSOTP (Redis)
│ ├── totp.js # HMAC-SHA1 TOTPP4 │ ├── totp.js # HMAC-SHA1 TOTPP4
│ ├── auth.js # register / confirm / login / refresh helperP2 │ ├── auth.js # register / confirm / login / MFA / password helper
│ ├── member.js # TOTP enroll / change password helper
│ └── seed.js # tenant + invite + admin role bootstrapP5 │ └── seed.js # tenant + invite + admin role bootstrapP5
├── smoke/ # 每個端點至少一發 ├── smoke/ # 每個端點至少一發
│ ├── health.js # GET /api/v1/health │ ├── health.js # GET /api/v1/health
│ ├── auth_public.js # register / login / refresh / social-start (+negative) │ ├── auth_public.js # register / login / refresh / social-start (+negative)
│ ├── auth_register_resume.js # register/resumehappy + 404/409/429/400
│ ├── auth_password.js # password/forgot + resethappy + 各種 negative
│ ├── auth_login_mfa.js # login/mfaMFA 前置 + negative
│ ├── auth_bearer.js # logout │ ├── 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_read.js # catalog / me
│ └── permission_admin.js # roles CRUD / role-permissions / user-roles / mappings / policy reload │ └── permission_admin.js # roles CRUD / role-permissions / user-roles / mappings / policy reload
└── journeys/ # 完整流程 └── journeys/ # 完整流程
├── email_register_full.js # register → confirm OTP(MailHog) → me → patch → logout ├── 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 ├── login_refresh.js # login → refresh → me → logout
├── email_verify.js # register → confirm → email verify start → confirm ├── email_verify.js # register → confirm → email verify start → confirm
├── phone_verify.js # register → confirm → phone verify (Redis OTP) ├── 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` | journeys/email_register_full + smoke/auth_public |
| Auth 公開 | `POST /register/confirm` | journeys/email_register_full | | Auth 公開 | `POST /register/confirm` | journeys/email_register_full |
| Auth 公開 | `POST /register/resend` | smoke/auth_public | | 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 公開 | `POST /register/social/start` | smoke/auth_public (happy) |
| Auth 公開 | `GET /register/social/callback` | smoke/auth_public (negative — TODO happy) | | Auth 公開 | `GET /register/social/callback` | smoke/auth_public (negative — TODO happy) |
| Auth 公開 | `POST /login` | journeys/login_refresh + smoke/auth_public (negative) | | 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/verify` | smoke/member (negative) + journeys/totp_full (happy) |
| Member | `POST /me/totp/backup-codes` | 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 | `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/catalog` | smoke/permission_read + journeys/rbac_admin |
| Perm 讀 | `GET /permissions/me` | 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 | | Perm 管理 | `GET /roles` | smoke/permission_admin + journeys/rbac_admin |

View File

@ -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,
);
}

View File

@ -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');
}
}

View File

@ -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,
);
}

View File

@ -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}`);
}
}

View File

@ -4,6 +4,7 @@ import { post } from './http.js';
import { checkEnvelope } from './http.js'; import { checkEnvelope } from './http.js';
import { cfg, unique } from './config.js'; import { cfg, unique } from './config.js';
import { fetchEmailOTP } from './otp.js'; import { fetchEmailOTP } from './otp.js';
import { generateTOTP } from './totp.js';
// makeIdentity returns a unique (email, password, display_name) tuple for the // makeIdentity returns a unique (email, password, display_name) tuple for the
// current VU iteration. Use to avoid collisions in concurrent runs. // 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; return checkEnvelope(res, 'POST /auth/register/resend').data;
} }
// login calls POST /api/v1/auth/login. Returns AuthTokenData. // registerResume calls POST /api/v1/auth/register/resume.
export function login({ tenantSlug = cfg.tenantSlug, email, password }) { 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', { const res = post('/api/v1/auth/login', {
tenant_slug: tenantSlug, tenant_slug: tenantSlug,
email, email,
password, password,
}); });
const body = checkEnvelope(res, 'POST /auth/login'); return checkEnvelope(res, 'POST /auth/login').data;
if (!body.data || !body.data.access_token) { }
throw new Error(`login: missing access_token in ${res.body}`);
// 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. // refreshToken calls POST /api/v1/auth/token/refresh.

View File

@ -29,6 +29,8 @@ export const cfg = {
// OTP polling parameters (MailHog / Redis) // OTP polling parameters (MailHog / Redis)
otpPollIntervalMs: parseInt(__ENV.OTP_POLL_INTERVAL_MS || '300', 10), otpPollIntervalMs: parseInt(__ENV.OTP_POLL_INTERVAL_MS || '300', 10),
otpPollTimeoutMs: parseInt(__ENV.OTP_POLL_TIMEOUT_MS || '5000', 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. // Build a unique-ish identity per VU+iteration so concurrent runs do not collide.

28
test/k6/lib/member.js Normal file
View File

@ -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;
}

View File

@ -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 pathlogin → 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 目前回 50028201000
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 slugresolveTenant 失敗)
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');
}
}

View File

@ -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 challenge29505000
// 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 目前回 50029201000非 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}`);
}
}

View File

@ -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 });
}

View File

@ -1,6 +1,6 @@
// smoke: member endpoints (Bearer) // smoke: member endpoints (Bearer)
// //
// Covers (11 endpoints): // Covers (14 endpoints + change password negatives):
// GET /api/v1/members/me // GET /api/v1/members/me
// PATCH /api/v1/members/me // PATCH /api/v1/members/me
// POST /api/v1/members/me/verifications/email/start // 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/enroll-confirm (negative: invalid code)
// POST /api/v1/members/me/totp/verify (negative: not enrolled) // POST /api/v1/members/me/totp/verify (negative: not enrolled)
// POST /api/v1/members/me/totp/backup-codes (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/. // Happy paths for TOTP and verification end-to-end live in journeys/.
import { get, post, patch, del, checkEnvelope, checkError } from '../lib/http.js'; import { get, post, patch, del, checkEnvelope, checkError } from '../lib/http.js';
@ -26,7 +26,7 @@ export const options = {
}; };
export default function () { export default function () {
const { tokens } = registerAndConfirm(); const { identity, tokens } = registerAndConfirm();
const bearer = { Authorization: `Bearer ${tokens.access_token}` }; const bearer = { Authorization: `Bearer ${tokens.access_token}` };
// 1. GET /me // 1. GET /me
@ -106,4 +106,37 @@ export default function () {
if (delRes.status !== 200 && delRes.status !== 404) { if (delRes.status !== 200 && delRes.status !== 404) {
throw new Error(`DELETE /me/totp unexpected status ${delRes.status}: ${delRes.body}`); 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}`);
}
} }