test(stage): пункт 12 — 2FA TOTP 6/6 ✓ (enroll+verify+login flow+disable)
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 <noreply@anthropic.com>
This commit is contained in:
parent
6a5bb52b13
commit
6b6f27d238
|
|
@ -28,7 +28,7 @@
|
|||
- [x] **8. SupplierReturn (Возврат поставщику)** — аналог для Supply. *(stage-supplier-return.yml: 8/8 ✓)*
|
||||
- [x] **9. Demand (Оптовая отгрузка)** — юрлицо, опт-цена, кредит (PaidAmount<Total), Stock минус WholesaleSale; multi-tenant. *(stage-demand.yml: 8/8 ✓)*
|
||||
- [x] **10. Отчёты** — Sales / Stock-на-дату / Profit / ABC; фильтры периода/магазина/группы; экспорт CSV/XLSX; edge. *(stage-reports.yml: 8/8 ✓, 3 фикса: UTC dates, Enter→Cost, ABC Pareto)*
|
||||
- [ ] **11. OrgAuditLog** — UI /audit-log, CRUD продукта → запись с diff; multi-tenant строго.
|
||||
- [x] **11. OrgAuditLog** — UI /audit-log, CRUD продукта → запись с diff; multi-tenant строго. *(stage-audit-log.yml: 7/7 ✓)*
|
||||
- [ ] **12. 2FA TOTP** — enroll (QR), verify, login (двухшаговый), disable; невалидный код → 400.
|
||||
- [ ] **13. OpenAPI/Swagger** — все новые контроллеры в /swagger/v1/swagger.json.
|
||||
- [ ] **14. POS Sync API** — `POST /api/pos/sync` (dev-token), `POST /api/pos/sales` (idempotency-key — повтор не дублирует).
|
||||
|
|
|
|||
75
tests/e2e/reports/stage-2fa-2026-05-29T12-41-15-748Z.md
Normal file
75
tests/e2e/reports/stage-2fa-2026-05-29T12-41-15-748Z.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# E2E report: stage-2fa
|
||||
|
||||
Запущен: 2026-05-29T12:41:12.460Z
|
||||
Длительность: 3.3с
|
||||
|
||||
**Итог:** 6 ✓ / 0 ✗ / 0 ⚠ / 0 ◯ (всего 6)
|
||||
|
||||
## ✓ Step tfa01_setup_and_status: Создать org → status показывает enabled=false
|
||||
|
||||
Длительность: 1760мс
|
||||
|
||||
| Тип | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| api | GET /status → 200 | ✓ 200 |
|
||||
| api | enabled = false для нового user | ✓ enabled=false |
|
||||
|
||||
## ✓ Step tfa02_enroll: POST /api/me/2fa/enroll → sharedKey + otpauth-URI, alreadyEnabled=false
|
||||
|
||||
Длительность: 140мс
|
||||
|
||||
| Тип | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| api | POST /enroll → 200 | ✓ 200 |
|
||||
| api | sharedKey не пустой | ✓ key.len=32 |
|
||||
| api | authenticatorUri начинается с otpauth://totp/ | ✓ otpauth://totp/food-market%3astage-2fa-1780058472460%40food-… |
|
||||
| api | alreadyEnabled = false | ✓ already=false |
|
||||
|
||||
## ✓ Step tfa03_verify_invalid_code: POST /api/me/2fa/verify с кодом 000000 → 400
|
||||
|
||||
Длительность: 99мс
|
||||
|
||||
| Тип | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| api | verify с 000000 → 400 "Неверный код" | ✓ 400 {"error":"Неверный код. Попробуйте ещё раз.","field":"code"} |
|
||||
|
||||
## ✓ Step tfa04_verify_valid_code: POST /api/me/2fa/verify с реальным TOTP кодом → 200, enabled=true
|
||||
|
||||
Длительность: 172мс
|
||||
|
||||
| Тип | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| api | verify с реальным TOTP-кодом (612117) → 200 | ✓ 200 {"enabled":true} |
|
||||
| api | status.enabled = true | ✓ enabled=true |
|
||||
|
||||
## ✓ Step tfa05_login_requires_2fa: Логин без otp_code → 400 invalid_grant 2fa_required; с правильным otp_code → 200
|
||||
|
||||
Длительность: 735мс
|
||||
|
||||
| Тип | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| api | Логин без otp_code → 400 с 2fa_required | ✓ 400 {"error":"invalid_grant","error_description":"2fa_required"} |
|
||||
| api | Логин с правильным otp_code (612117) → 200 | ✓ 200 access=yes |
|
||||
| api | Логин с неверным otp_code → 400 с 2fa_invalid | ✓ 400 {"error":"invalid_grant","error_description":"2fa_invalid"} |
|
||||
|
||||
## ✓ Step tfa06_disable: POST /api/me/2fa/disable с кодом → 200; status показывает enabled=false; повторный enroll выдаёт новый key
|
||||
|
||||
Длительность: 382мс
|
||||
|
||||
| Тип | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| api | disable без кода → 400 | ✓ 400 {"error":"Подтверди отключение текущим кодом из приложения.","field":"code"} |
|
||||
| api | disable с code (612117) → 200, enabled=false | ✓ 200 {"enabled":false} |
|
||||
| api | status.enabled = false | ✓ enabled=false |
|
||||
| api | Повторный enroll → новый sharedKey (отличается от старого) | ✓ same=false |
|
||||
|
||||
## Summary
|
||||
|
||||
- Passed: 6
|
||||
- Failed: 0
|
||||
- Warnings: 0
|
||||
- Skipped: 0
|
||||
|
||||
## Critical bugs
|
||||
|
||||
Нет.
|
||||
224
tests/e2e/scenarios/stage-2fa.steps.ts
Normal file
224
tests/e2e/scenarios/stage-2fa.steps.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
/**
|
||||
* Stage 2FA TOTP: enroll → verify → login с 2FA → disable.
|
||||
* TOTP-коды генерируем на ходу из sharedKey (RFC 6238, Base32).
|
||||
*/
|
||||
import { createHmac } from 'node:crypto'
|
||||
import { login, makeClient, ADMIN_BASE } from '../lib/api.js'
|
||||
import type { CheckResult, Step, Report } from '../lib/report.js'
|
||||
|
||||
type Ctx = {
|
||||
apiOnly: boolean
|
||||
ts: number
|
||||
email?: string
|
||||
password?: string
|
||||
token?: string
|
||||
sharedKey?: string
|
||||
}
|
||||
interface StepCtx { ctx: Ctx; step: Step; report: Report }
|
||||
|
||||
const TS = Date.now()
|
||||
function check(step: Step, c: CheckResult) { step.checks.push(c) }
|
||||
function asString(x: unknown): string {
|
||||
if (x == null) return ''
|
||||
if (typeof x === 'string') return x
|
||||
return JSON.stringify(x)
|
||||
}
|
||||
|
||||
/** Base32 (RFC 4648) decode → Buffer. ASP.NET выдаёт ключ без '=' padding и в upper-case. */
|
||||
function base32Decode(raw: string): Buffer {
|
||||
const s = raw.replace(/=+$/, '').toUpperCase()
|
||||
const alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
|
||||
let bits = 0, value = 0
|
||||
const out: number[] = []
|
||||
for (const ch of s) {
|
||||
const idx = alpha.indexOf(ch)
|
||||
if (idx < 0) continue // пропускаем не-Base32 символы (пробелы)
|
||||
value = (value << 5) | idx
|
||||
bits += 5
|
||||
if (bits >= 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<void> {
|
||||
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}`,
|
||||
})
|
||||
}
|
||||
23
tests/e2e/scenarios/stage-2fa.yml
Normal file
23
tests/e2e/scenarios/stage-2fa.yml
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue