From 6b6f27d2384730ab254d1fde55bf202520e4941d Mon Sep 17 00:00:00 2001 From: nns Date: Fri, 29 May 2026 17:41:22 +0500 Subject: [PATCH] =?UTF-8?q?test(stage):=20=D0=BF=D1=83=D0=BD=D0=BA=D1=82?= =?UTF-8?q?=2012=20=E2=80=94=202FA=20TOTP=206/6=20=E2=9C=93=20(enroll+veri?= =?UTF-8?q?fy+login=20flow+disable)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TOTP-коды генерируются локально (RFC 6238 / Base32 + HMAC-SHA1) из sharedKey. End-to-end: signup → enroll → verify (invalid+valid) → login с otp_code (required+invalid+success) → disable → reset key. Co-Authored-By: Claude Opus 4.7 --- docs/stage-testing-progress.md | 2 +- .../stage-2fa-2026-05-29T12-41-15-748Z.md | 75 ++++++ tests/e2e/scenarios/stage-2fa.steps.ts | 224 ++++++++++++++++++ tests/e2e/scenarios/stage-2fa.yml | 23 ++ 4 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/reports/stage-2fa-2026-05-29T12-41-15-748Z.md create mode 100644 tests/e2e/scenarios/stage-2fa.steps.ts create mode 100644 tests/e2e/scenarios/stage-2fa.yml diff --git a/docs/stage-testing-progress.md b/docs/stage-testing-progress.md index fa39bcf..9b4fbd7 100644 --- a/docs/stage-testing-progress.md +++ b/docs/stage-testing-progress.md @@ -28,7 +28,7 @@ - [x] **8. SupplierReturn (Возврат поставщику)** — аналог для Supply. *(stage-supplier-return.yml: 8/8 ✓)* - [x] **9. Demand (Оптовая отгрузка)** — юрлицо, опт-цена, кредит (PaidAmount= 8) { + bits -= 8 + out.push((value >> bits) & 0xff) + } + } + return Buffer.from(out) +} + +/** TOTP code по RFC 6238 (period=30s, digits=6, SHA-1). */ +function totp(secretBase32: string, atSeconds = Math.floor(Date.now() / 1000)): string { + const key = base32Decode(secretBase32) + const counter = Math.floor(atSeconds / 30) + const cb = Buffer.alloc(8) + cb.writeBigUInt64BE(BigInt(counter)) + const hmac = createHmac('sha1', key).update(cb).digest() + const offset = hmac[hmac.length - 1] & 0x0f + const bin = + ((hmac[offset] & 0x7f) << 24) | + ((hmac[offset + 1] & 0xff) << 16) | + ((hmac[offset + 2] & 0xff) << 8) | + (hmac[offset + 3] & 0xff) + return String(bin % 1_000_000).padStart(6, '0') +} + +// --------------------------------------------------------------------------- + +async function bootstrap(ctx: Ctx): Promise { + if (ctx.token) return + const api = makeClient() + ctx.email = `stage-2fa-${TS}@food-market.local` + ctx.password = 'Stage2fa12345!' + let r = await api.post('/api/auth/signup', { + email: ctx.email, password: ctx.password, + organizationName: `TwoFA ${TS}`, phone: '+77011112000', plan: 'start', + }) + for (let i = 0; i < 5 && r.status === 429; i++) { + await new Promise(res => setTimeout(res, 15000)) + r = await api.post('/api/auth/signup', { + email: ctx.email, password: ctx.password, + organizationName: `TwoFA ${TS}`, phone: '+77011112000', plan: 'start', + }) + } + if (r.status !== 200) throw new Error(`signup: ${r.status} ${JSON.stringify(r.data)}`) + const sess = await login(ctx.email, ctx.password) + ctx.token = sess.accessToken +} + +// --------------------------------------------------------------------------- + +export async function tfa01_setup_and_status({ ctx, step, report }: StepCtx) { + ctx.ts = TS + await bootstrap(ctx) + const api = makeClient(ctx.token) + const status = await api.get('/api/me/2fa/status') + check(step, { kind: 'api', description: 'GET /status → 200', ok: status.status === 200, detail: `${status.status}` }) + check(step, { kind: 'api', description: 'enabled = false для нового user', ok: status.data?.enabled === false, detail: `enabled=${status.data?.enabled}` }) +} + +// --------------------------------------------------------------------------- + +export async function tfa02_enroll({ ctx, step, report }: StepCtx) { + if (!ctx.token) { step.status = 'skip'; return } + const api = makeClient(ctx.token) + const r = await api.post('/api/me/2fa/enroll', {}) + check(step, { kind: 'api', description: 'POST /enroll → 200', ok: r.status === 200, detail: `${r.status}` }) + check(step, { kind: 'api', description: 'sharedKey не пустой', ok: !!r.data?.sharedKey && r.data.sharedKey.length >= 16, detail: `key.len=${r.data?.sharedKey?.length}` }) + check(step, { kind: 'api', description: 'authenticatorUri начинается с otpauth://totp/', ok: String(r.data?.authenticatorUri ?? '').startsWith('otpauth://totp/'), detail: `${String(r.data?.authenticatorUri ?? '').slice(0, 60)}…` }) + check(step, { kind: 'api', description: 'alreadyEnabled = false', ok: r.data?.alreadyEnabled === false, detail: `already=${r.data?.alreadyEnabled}` }) + ctx.sharedKey = r.data?.sharedKey +} + +// --------------------------------------------------------------------------- + +export async function tfa03_verify_invalid_code({ ctx, step, report }: StepCtx) { + if (!ctx.token) { step.status = 'skip'; return } + const api = makeClient(ctx.token) + const r = await api.post('/api/me/2fa/verify', { code: '000000' }) + check(step, { + kind: 'api', description: 'verify с 000000 → 400 "Неверный код"', + ok: r.status === 400 && /неверн/i.test(asString(r.data)), + detail: `${r.status} ${asString(r.data).slice(0, 200)}`, + }) +} + +// --------------------------------------------------------------------------- + +export async function tfa04_verify_valid_code({ ctx, step, report }: StepCtx) { + if (!ctx.token || !ctx.sharedKey) { step.status = 'skip'; return } + const api = makeClient(ctx.token) + const code = totp(ctx.sharedKey) + const r = await api.post('/api/me/2fa/verify', { code }) + check(step, { + kind: 'api', description: `verify с реальным TOTP-кодом (${code}) → 200`, + ok: r.status === 200 && r.data?.enabled === true, + detail: `${r.status} ${asString(r.data).slice(0, 200)}`, + }) + const status = await api.get('/api/me/2fa/status') + check(step, { kind: 'api', description: 'status.enabled = true', ok: status.data?.enabled === true, detail: `enabled=${status.data?.enabled}` }) +} + +// --------------------------------------------------------------------------- + +export async function tfa05_login_requires_2fa({ ctx, step, report }: StepCtx) { + if (!ctx.email || !ctx.password || !ctx.sharedKey) { step.status = 'skip'; return } + const api = makeClient() + + // Без otp_code → 400 invalid_grant 2fa_required + const noOtp = await api.post('/connect/token', new URLSearchParams({ + grant_type: 'password', + username: ctx.email, password: ctx.password, + client_id: 'food-market-web', + scope: 'openid profile email roles api offline_access', + }), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }) + check(step, { + kind: 'api', description: 'Логин без otp_code → 400 с 2fa_required', + ok: noOtp.status === 400 && /2fa_required/.test(asString(noOtp.data)), + detail: `${noOtp.status} ${asString(noOtp.data).slice(0, 250)}`, + }) + + // С правильным otp_code → 200 + const code = totp(ctx.sharedKey) + const ok = await api.post('/connect/token', new URLSearchParams({ + grant_type: 'password', + username: ctx.email, password: ctx.password, + client_id: 'food-market-web', + scope: 'openid profile email roles api offline_access', + otp_code: code, + }), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }) + check(step, { + kind: 'api', description: `Логин с правильным otp_code (${code}) → 200`, + ok: ok.status === 200 && !!ok.data?.access_token, + detail: `${ok.status} access=${ok.data?.access_token ? 'yes' : 'no'}`, + }) + + // С неверным otp_code → 400 2fa_invalid + const bad = await api.post('/connect/token', new URLSearchParams({ + grant_type: 'password', + username: ctx.email, password: ctx.password, + client_id: 'food-market-web', + scope: 'openid profile email roles api offline_access', + otp_code: '000000', + }), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }) + check(step, { + kind: 'api', description: 'Логин с неверным otp_code → 400 с 2fa_invalid', + ok: bad.status === 400 && /2fa_invalid/.test(asString(bad.data)), + detail: `${bad.status} ${asString(bad.data).slice(0, 250)}`, + }) + + // Обновим ctx.token валидным (для disable шага) + if (ok.status === 200) ctx.token = ok.data.access_token +} + +// --------------------------------------------------------------------------- + +export async function tfa06_disable({ ctx, step, report }: StepCtx) { + if (!ctx.token || !ctx.sharedKey) { step.status = 'skip'; return } + const api = makeClient(ctx.token) + + // Без code → 400 + const noCode = await api.post('/api/me/2fa/disable', { code: '' }) + check(step, { + kind: 'api', description: 'disable без кода → 400', + ok: noCode.status === 400, + detail: `${noCode.status} ${asString(noCode.data).slice(0, 200)}`, + }) + + // С кодом → 200 + const code = totp(ctx.sharedKey) + const ok = await api.post('/api/me/2fa/disable', { code }) + check(step, { + kind: 'api', description: `disable с code (${code}) → 200, enabled=false`, + ok: ok.status === 200 && ok.data?.enabled === false, + detail: `${ok.status} ${asString(ok.data).slice(0, 200)}`, + }) + + // status + const status = await api.get('/api/me/2fa/status') + check(step, { kind: 'api', description: 'status.enabled = false', ok: status.data?.enabled === false, detail: `enabled=${status.data?.enabled}` }) + + // Re-enroll выдаёт НОВЫЙ key (т.к. был сброшен) + const re = await api.post('/api/me/2fa/enroll', {}) + check(step, { + kind: 'api', description: 'Повторный enroll → новый sharedKey (отличается от старого)', + ok: !!re.data?.sharedKey && re.data.sharedKey !== ctx.sharedKey, + detail: `same=${re.data?.sharedKey === ctx.sharedKey}`, + }) +} diff --git a/tests/e2e/scenarios/stage-2fa.yml b/tests/e2e/scenarios/stage-2fa.yml new file mode 100644 index 0000000..08a6f5a --- /dev/null +++ b/tests/e2e/scenarios/stage-2fa.yml @@ -0,0 +1,23 @@ +name: stage-2fa +description: | + 2FA TOTP на test.admin.food-market.kz. Enroll (получение sharedKey), + verify валидным/невалидным кодом, login требует otp_code, disable. + TOTP-коды генерируем локально из sharedKey через crypto. + +preconditions: + reset_db: false + smoke_login_super_admin: false + +steps: + - id: tfa01_setup_and_status + title: Создать org → status показывает enabled=false + - id: tfa02_enroll + title: POST /api/me/2fa/enroll → sharedKey + otpauth-URI, alreadyEnabled=false + - id: tfa03_verify_invalid_code + title: POST /api/me/2fa/verify с кодом 000000 → 400 + - id: tfa04_verify_valid_code + title: POST /api/me/2fa/verify с реальным TOTP кодом → 200, enabled=true + - id: tfa05_login_requires_2fa + title: Логин без otp_code → 400 invalid_grant 2fa_required; с правильным otp_code → 200 + - id: tfa06_disable + title: POST /api/me/2fa/disable с кодом → 200; status показывает enabled=false; повторный enroll выдаёт новый key