test(e2e): scenarios platform-smtp + auth-password
platform-smtp (ТЗ 2.9, 6 шагов): причина изменения обязательна (≥10), test-send без настроек → 400, пароль шифруется в БД (не плейнтекст) и никогда не возвращается клиентом, сентинел __clear__ очищает пароль. auth-password (ТЗ 2.1.3, 6 шагов): анти-энумерация (forgot всегда 200), reset с битым токеном / коротким паролем → 400, рейт-лимит forgot (>3/час с IP → 429). Оба сценария зелёные, багов в этих областях нет. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
90331ff371
commit
a04b4bf2dd
70
tests/e2e/scenarios/auth-password.steps.ts
Normal file
70
tests/e2e/scenarios/auth-password.steps.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
/**
|
||||||
|
* Step-handlers для auth-password.
|
||||||
|
*
|
||||||
|
* forgot/reset password. Анти-энумерация: forgot всегда отвечает 200,
|
||||||
|
* независимо от существования email (не раскрываем, есть ли такой аккаунт).
|
||||||
|
* reset валидирует токен и длину пароля. Рейт-лимит forgot отбивает перебор.
|
||||||
|
*/
|
||||||
|
import { login, makeClient } from '../lib/api.js'
|
||||||
|
import type { CheckResult, Step, Report } from '../lib/report.js'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { Agent as HttpsAgent } from 'node:https'
|
||||||
|
|
||||||
|
const TS = Date.now()
|
||||||
|
const httpsAgent = new HttpsAgent({ rejectUnauthorized: false })
|
||||||
|
const BASE = process.env.E2E_ADMIN_URL ?? 'https://admin.food-market.kz'
|
||||||
|
|
||||||
|
interface Ctx { apiOnly: boolean; sa?: string; knownEmail?: string }
|
||||||
|
interface StepCtx { ctx: Ctx; step: Step; report: Report }
|
||||||
|
|
||||||
|
function check(step: Step, c: CheckResult) { step.checks.push(c) }
|
||||||
|
async function post(path: string, body: unknown) {
|
||||||
|
const res = await axios.post(`${BASE}${path}`, body, { httpsAgent, validateStatus: () => true })
|
||||||
|
return { status: res.status, data: res.data }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step01_bootstrap({ ctx, step, report }: StepCtx) {
|
||||||
|
ctx.sa = (await login('admin@food-market.local', 'Admin12345!')).accessToken
|
||||||
|
const r = await makeClient(ctx.sa).post('/api/super-admin/organizations', {
|
||||||
|
org: { name: `Pwd ${TS}`, countryCode: 'KZ', bin: null, address: null, phone: null, email: null, defaultCurrencyId: null, accountOwnerUserId: null },
|
||||||
|
adminLastName: 'Pwd', adminFirstName: 'Admin', adminEmail: `pwd-${TS}@example.kz`, adminPosition: null,
|
||||||
|
})
|
||||||
|
ctx.knownEmail = r.data?.adminEmail
|
||||||
|
check(step, { kind: 'api', description: 'Орг + известный email готовы', ok: !!ctx.knownEmail, detail: ctx.knownEmail })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step02_forgot_unknown_200({ step }: StepCtx) {
|
||||||
|
const r = await post('/api/auth/forgot-password', { email: `nobody-${TS}@example.kz` })
|
||||||
|
check(step, { kind: 'api', description: 'forgot несуществующего → 200 (анти-энумерация)', ok: r.status === 200, detail: `status=${r.status}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step03_forgot_known_200({ ctx, step }: StepCtx) {
|
||||||
|
if (!ctx.knownEmail) { step.status = 'skip'; return }
|
||||||
|
const r = await post('/api/auth/forgot-password', { email: ctx.knownEmail })
|
||||||
|
check(step, { kind: 'api', description: 'forgot существующего → 200', ok: r.status === 200, detail: `status=${r.status}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step04_reset_bad_token({ ctx, step }: StepCtx) {
|
||||||
|
if (!ctx.knownEmail) { step.status = 'skip'; return }
|
||||||
|
const r = await post('/api/auth/reset-password', { email: ctx.knownEmail, token: 'totally-invalid-token', newPassword: 'NewPass12345!' })
|
||||||
|
check(step, { kind: 'api', description: 'reset с битым токеном → 400', ok: r.status === 400, detail: `status=${r.status}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step05_reset_short_password({ ctx, step }: StepCtx) {
|
||||||
|
if (!ctx.knownEmail) { step.status = 'skip'; return }
|
||||||
|
const r = await post('/api/auth/reset-password', { email: ctx.knownEmail, token: 'any-token', newPassword: 'short' })
|
||||||
|
check(step, { kind: 'api', description: 'reset со слишком коротким паролем → 400', ok: r.status === 400, detail: `status=${r.status}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step06_forgot_rate_limited({ step, report }: StepCtx) {
|
||||||
|
// Серия forgot с одного IP. Лимит >3/час → ожидаем 429 среди ответов.
|
||||||
|
const statuses: number[] = []
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const r = await post('/api/auth/forgot-password', { email: `flood-${TS}-${i}@example.kz` })
|
||||||
|
statuses.push(r.status)
|
||||||
|
}
|
||||||
|
const got429 = statuses.includes(429)
|
||||||
|
check(step, { kind: 'api', description: 'Серия forgot упирается в 429 (рейт-лимит)', ok: got429, detail: `statuses=${statuses.join(',')}` })
|
||||||
|
if (!got429) report.bug({ step: '06', severity: 'medium',
|
||||||
|
title: 'forgot-password не рейт-лимитится', detail: `Перебор email не отбивается 429 (ТЗ 2.1.3). statuses=${statuses.join(',')}` })
|
||||||
|
}
|
||||||
27
tests/e2e/scenarios/auth-password.yml
Normal file
27
tests/e2e/scenarios/auth-password.yml
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
name: auth-password
|
||||||
|
description: |
|
||||||
|
Восстановление пароля (ТЗ 2.1.3): анти-энумерация (forgot всегда 200),
|
||||||
|
reset с битым токеном → 400, слишком короткий пароль → 400, рейт-лимит
|
||||||
|
forgot (>3 запросов/час с IP → 429).
|
||||||
|
|
||||||
|
Примечание: рейт-лимит хранится в памяти процесса API per-IP на 1 час.
|
||||||
|
Сценарий рассчитан на запуск против свежезапущенного API (повторный прогон
|
||||||
|
без рестарта увидит 429 на первых же forgot-запросах).
|
||||||
|
|
||||||
|
preconditions:
|
||||||
|
reset_db: true
|
||||||
|
smoke_login_super_admin: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: step01_bootstrap
|
||||||
|
title: "Создать орг → известный email активного пользователя"
|
||||||
|
- id: step02_forgot_unknown_200
|
||||||
|
title: "forgot-password несуществующего email → 200 (анти-энумерация)"
|
||||||
|
- id: step03_forgot_known_200
|
||||||
|
title: "forgot-password существующего email → 200"
|
||||||
|
- id: step04_reset_bad_token
|
||||||
|
title: "reset-password с битым токеном → 400"
|
||||||
|
- id: step05_reset_short_password
|
||||||
|
title: "reset-password со слишком коротким паролем → 400"
|
||||||
|
- id: step06_forgot_rate_limited
|
||||||
|
title: "Серия forgot с одного IP → появляется 429 (рейт-лимит)"
|
||||||
88
tests/e2e/scenarios/platform-smtp.steps.ts
Normal file
88
tests/e2e/scenarios/platform-smtp.steps.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
/**
|
||||||
|
* Step-handlers для platform-smtp.
|
||||||
|
*
|
||||||
|
* SMTP-настройки платформы (SuperAdmin). Безопасность: пароль хранится
|
||||||
|
* зашифрованным (DataProtection) и НИКОГДА не отдаётся клиентом — только флаг
|
||||||
|
* hasSmtpPassword. Изменение требует осмысленную причину (≥10) для журнала.
|
||||||
|
* В конце возвращаем настройки в чистое состояние, чтобы не ломать
|
||||||
|
* forgot-password в других сценариях.
|
||||||
|
*/
|
||||||
|
import { login, makeClient } from '../lib/api.js'
|
||||||
|
import { psql } from '../lib/db.js'
|
||||||
|
import type { CheckResult, Step, Report } from '../lib/report.js'
|
||||||
|
|
||||||
|
interface Ctx { apiOnly: boolean; sa?: string }
|
||||||
|
interface StepCtx { ctx: Ctx; step: Step; report: Report }
|
||||||
|
|
||||||
|
function check(step: Step, c: CheckResult) { step.checks.push(c) }
|
||||||
|
function q1(sql: string): string { return (psql(sql).trim().split('\n')[0] ?? '').trim() }
|
||||||
|
const PLAINTEXT = 'SuperSecret123pwd'
|
||||||
|
|
||||||
|
async function ensureSa(ctx: Ctx) {
|
||||||
|
if (!ctx.sa) ctx.sa = (await login('admin@food-market.local', 'Admin12345!')).accessToken
|
||||||
|
return makeClient(ctx.sa)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step01_clean_state({ ctx, step }: StepCtx) {
|
||||||
|
const api = await ensureSa(ctx)
|
||||||
|
const r = await api.put('/api/super-admin/platform-settings', {
|
||||||
|
reason: 'Сброс SMTP для e2e-теста', smtpHost: null, smtpPort: null, smtpUseSsl: false,
|
||||||
|
smtpStartTls: false, smtpUsername: null, newSmtpPassword: '__clear__', fromEmail: null, fromName: null,
|
||||||
|
})
|
||||||
|
check(step, { kind: 'api', description: 'PUT очистки → 204', ok: r.status === 204, detail: `status=${r.status}` })
|
||||||
|
const get = await api.get('/api/super-admin/platform-settings')
|
||||||
|
check(step, { kind: 'api', description: 'hasSmtpPassword=false после очистки', ok: get.data?.hasSmtpPassword === false, detail: `has=${get.data?.hasSmtpPassword}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step02_reason_required({ ctx, step }: StepCtx) {
|
||||||
|
const api = await ensureSa(ctx)
|
||||||
|
const empty = await api.put('/api/super-admin/platform-settings', { reason: '', smtpHost: 'smtp.example.com' })
|
||||||
|
check(step, { kind: 'api', description: 'PUT без причины → 400', ok: empty.status === 400, detail: `status=${empty.status}` })
|
||||||
|
const short = await api.put('/api/super-admin/platform-settings', { reason: 'коротко', smtpHost: 'smtp.example.com' })
|
||||||
|
check(step, { kind: 'api', description: 'PUT причина <10 → 400', ok: short.status === 400, detail: `status=${short.status}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step03_test_send_not_configured({ ctx, step }: StepCtx) {
|
||||||
|
const api = await ensureSa(ctx)
|
||||||
|
const r = await api.post('/api/super-admin/platform-settings/test-send', { toEmail: 'nobody@example.kz' })
|
||||||
|
check(step, { kind: 'api', description: 'test-send без настроенного SMTP → 400', ok: r.status === 400, detail: `status=${r.status}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step04_save_smtp({ ctx, step }: StepCtx) {
|
||||||
|
const api = await ensureSa(ctx)
|
||||||
|
const r = await api.put('/api/super-admin/platform-settings', {
|
||||||
|
reason: 'Настройка SMTP-сервера для рассылок', smtpHost: 'smtp.example.com', smtpPort: 587,
|
||||||
|
smtpUseSsl: false, smtpStartTls: true, smtpUsername: 'mailer@example.com',
|
||||||
|
newSmtpPassword: PLAINTEXT, fromEmail: 'no-reply@example.com', fromName: 'Food Market',
|
||||||
|
})
|
||||||
|
check(step, { kind: 'api', description: 'PUT валидной конфигурации → 204', ok: r.status === 204, detail: `status=${r.status}` })
|
||||||
|
const get = await api.get('/api/super-admin/platform-settings')
|
||||||
|
check(step, { kind: 'api', description: 'hasSmtpPassword=true', ok: get.data?.hasSmtpPassword === true, detail: `has=${get.data?.hasSmtpPassword}` })
|
||||||
|
check(step, { kind: 'api', description: 'smtpHost/username сохранены и возвращаются',
|
||||||
|
ok: get.data?.smtpHost === 'smtp.example.com' && get.data?.smtpUsername === 'mailer@example.com',
|
||||||
|
detail: `host=${get.data?.smtpHost}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step05_password_encrypted({ ctx, step, report }: StepCtx) {
|
||||||
|
const api = await ensureSa(ctx)
|
||||||
|
const get = await api.get('/api/super-admin/platform-settings')
|
||||||
|
const raw = JSON.stringify(get.data ?? {})
|
||||||
|
check(step, { kind: 'api', description: 'Ответ GET не содержит пароль в открытом виде',
|
||||||
|
ok: !raw.includes(PLAINTEXT), detail: raw.includes(PLAINTEXT) ? 'НАЙДЕН ПЛЕЙНТЕКСТ' : 'ok' })
|
||||||
|
if (raw.includes(PLAINTEXT)) report.bug({ step: '05', severity: 'critical', title: 'SMTP-пароль возвращается клиенту в открытом виде', detail: '' })
|
||||||
|
const enc = q1(`SELECT "SmtpPasswordEncrypted" FROM platform_settings LIMIT 1`)
|
||||||
|
check(step, { kind: 'db', description: 'В БД пароль не плейнтекст и не пуст',
|
||||||
|
ok: !!enc && enc !== PLAINTEXT, detail: enc === PLAINTEXT ? 'ПЛЕЙНТЕКСТ В БД' : `len=${enc.length}` })
|
||||||
|
if (enc === PLAINTEXT) report.bug({ step: '05', severity: 'critical', title: 'SMTP-пароль хранится в БД в открытом виде', detail: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step06_clear_password({ ctx, step }: StepCtx) {
|
||||||
|
const api = await ensureSa(ctx)
|
||||||
|
const r = await api.put('/api/super-admin/platform-settings', {
|
||||||
|
reason: 'Очистка пароля и сброс хоста после теста', smtpHost: null, smtpPort: null,
|
||||||
|
smtpUseSsl: false, smtpStartTls: false, smtpUsername: null, newSmtpPassword: '__clear__', fromEmail: null, fromName: null,
|
||||||
|
})
|
||||||
|
check(step, { kind: 'api', description: 'PUT __clear__ → 204', ok: r.status === 204, detail: `status=${r.status}` })
|
||||||
|
const get = await api.get('/api/super-admin/platform-settings')
|
||||||
|
check(step, { kind: 'api', description: 'hasSmtpPassword=false', ok: get.data?.hasSmtpPassword === false, detail: `has=${get.data?.hasSmtpPassword}` })
|
||||||
|
}
|
||||||
23
tests/e2e/scenarios/platform-smtp.yml
Normal file
23
tests/e2e/scenarios/platform-smtp.yml
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
name: platform-smtp
|
||||||
|
description: |
|
||||||
|
SuperAdmin Platform Settings — SMTP (ТЗ 2.9): сохранение настроек, причина
|
||||||
|
изменения обязательна (≥10), пароль шифруется и никогда не возвращается,
|
||||||
|
сентинел "__clear__" очищает пароль, test-send без настроек → 400.
|
||||||
|
|
||||||
|
preconditions:
|
||||||
|
reset_db: true
|
||||||
|
smoke_login_super_admin: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: step01_clean_state
|
||||||
|
title: "Сброс SMTP в чистое состояние (hasSmtpPassword=false, host пуст)"
|
||||||
|
- id: step02_reason_required
|
||||||
|
title: "PUT без причины / причина <10 → 400"
|
||||||
|
- id: step03_test_send_not_configured
|
||||||
|
title: "test-send при ненастроенном SMTP → 400"
|
||||||
|
- id: step04_save_smtp
|
||||||
|
title: "Сохранение SMTP с паролем → 204, GET отдаёт поля кроме пароля"
|
||||||
|
- id: step05_password_encrypted
|
||||||
|
title: "Пароль в БД зашифрован (не плейнтекст) и не возвращается клиенту"
|
||||||
|
- id: step06_clear_password
|
||||||
|
title: "PUT newSmtpPassword=__clear__ → hasSmtpPassword=false"
|
||||||
Loading…
Reference in a new issue