food-market/tests/e2e/scenarios/auth-edge.steps.ts
nns 9f0f071193 test(e2e): scenarios auth-edge, catalog-edge, stock-invariant-deep
- auth-edge (10 шагов): refresh rotation/redemption, подделка JWT,
  деактивированный user, архивная орг, повторный/orphan signup.
- catalog-edge (12 шагов): валидация товара, дубль артикула, удаление
  групп/единиц/системных типов цен с зависимостями, FK-guard контрагента.
- stock-invariant-deep (10 шагов): инвариант Stock == SUM(StockMovement)
  через post/unpost/repost и конкурентные продажи.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:03:47 +05:00

277 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Step-handlers для auth-edge.
*
* Проверяем чувствительный для безопасности слой:
* - refresh-token rotation (старый refresh инвалидируется после использования),
* - подделка JWT (любая модификация → 401),
* - деактивированный user не получает новых токенов,
* - архивная организация блокирует логин,
* - повторный signup на занятый email отвергается, на orphan — реактивирует.
*/
import { login, makeClient, ADMIN_BASE } from '../lib/api.js'
import { psql } from '../lib/db.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 })
interface TokenPair { access: string; refresh?: string }
interface Ctx {
apiOnly: boolean
superAdminToken?: string
orgId?: string
adminEmail?: string
adminPwd?: string
adminTokens?: TokenPair
rotated?: TokenPair
}
interface StepCtx { ctx: Ctx; step: Step; report: Report }
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
try { return JSON.stringify(x).slice(0, 220) } catch { return String(x) }
}
async function ensureSuperAdmin(ctx: Ctx): Promise<string> {
if (!ctx.superAdminToken) {
const sa = await login('admin@food-market.local', 'Admin12345!')
ctx.superAdminToken = sa.accessToken
}
return ctx.superAdminToken
}
async function rawToken(body: Record<string, string>): Promise<{ status: number; data: any }> {
const res = await axios.post(`${ADMIN_BASE}/connect/token`,
new URLSearchParams(body),
{ httpsAgent, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, validateStatus: () => true })
return { status: res.status, data: res.data }
}
async function loginRaw(email: string, password: string): Promise<{ status: number; data: any }> {
return rawToken({
grant_type: 'password', username: email, password,
client_id: 'food-market-web',
scope: 'openid profile email roles api offline_access',
})
}
async function refreshRaw(refreshToken: string): Promise<{ status: number; data: any }> {
return rawToken({
grant_type: 'refresh_token', refresh_token: refreshToken,
client_id: 'food-market-web',
scope: 'openid profile email roles api offline_access',
})
}
// ---------------------------------------------------------------------------
export async function step01_bootstrap_admin({ ctx, step, report }: StepCtx) {
const sa = await ensureSuperAdmin(ctx)
const api = makeClient(sa)
const orgRes = await api.post('/api/super-admin/organizations', {
org: {
name: `Auth Edge ${TS}`, countryCode: 'KZ',
bin: null, address: null, phone: null, email: null,
defaultCurrencyId: null, accountOwnerUserId: null,
},
adminLastName: 'Edge', adminFirstName: 'Auth',
adminEmail: `auth-edge-${TS}@example.kz`, adminPosition: null,
})
if (orgRes.status !== 200) {
report.bug({ step: '01', severity: 'critical',
title: 'Не удалось создать орг', detail: asString(orgRes.data) }); return
}
ctx.orgId = orgRes.data.organization.id
ctx.adminEmail = orgRes.data.adminEmail
ctx.adminPwd = orgRes.data.adminTempPassword
const tok = await loginRaw(ctx.adminEmail!, ctx.adminPwd!)
check(step, { kind: 'api', description: 'Initial login → 200',
ok: tok.status === 200, detail: `status=${tok.status}` })
if (tok.status === 200) {
ctx.adminTokens = { access: tok.data.access_token, refresh: tok.data.refresh_token }
check(step, { kind: 'api', description: 'Получили access + refresh',
ok: !!ctx.adminTokens.access && !!ctx.adminTokens.refresh })
}
}
export async function step02_refresh_token_works({ ctx, step, report }: StepCtx) {
if (!ctx.adminTokens?.refresh) { step.status = 'skip'; return }
const r = await refreshRaw(ctx.adminTokens.refresh)
const ok = r.status === 200
check(step, { kind: 'api', description: 'POST /connect/token (refresh) → 200',
ok, detail: `status=${r.status}` })
if (!ok) {
report.bug({ step: '02', severity: 'critical',
title: 'Refresh-token flow не работает', detail: asString(r.data) }); return
}
ctx.rotated = { access: r.data.access_token, refresh: r.data.refresh_token }
check(step, { kind: 'api', description: 'Новый access ≠ старый',
ok: ctx.rotated.access !== ctx.adminTokens.access })
check(step, { kind: 'api', description: 'Новый refresh ≠ старый (rotation)',
ok: !!ctx.rotated.refresh && ctx.rotated.refresh !== ctx.adminTokens.refresh })
// Новый access работает на защищённом endpoint'e.
const me = await makeClient(ctx.rotated.access).get('/api/me')
check(step, { kind: 'api', description: 'Новый access → /api/me 200',
ok: me.status === 200, detail: `status=${me.status}` })
}
export async function step03_refresh_token_rotates({ ctx, step, report }: StepCtx) {
if (!ctx.adminTokens?.refresh || !ctx.rotated) { step.status = 'skip'; return }
// Пытаемся повторно использовать СТАРЫЙ refresh после ротации.
const r = await refreshRaw(ctx.adminTokens.refresh)
const ok = r.status >= 400 && r.status < 500
check(step, { kind: 'api', description: 'Повторное использование старого refresh → 4xx',
ok, detail: `status=${r.status} error=${r.data?.error}` })
if (!ok) report.bug({ step: '03', severity: 'high',
title: 'Старый refresh-token остаётся валидным после rotation',
detail: `status=${r.status}. Это позволяет одной утечкой получить вечный доступ.` })
}
export async function step04_invalid_refresh_rejected({ step }: StepCtx) {
const r = await refreshRaw('not-a-token-just-random-text-aaaa')
const ok = r.status >= 400 && r.status < 500
check(step, { kind: 'api', description: 'Случайный refresh → 4xx',
ok, detail: `status=${r.status} error=${r.data?.error}` })
}
export async function step05_tampered_jwt_rejected({ ctx, step, report }: StepCtx) {
if (!ctx.rotated?.access) { step.status = 'skip'; return }
// Берём валидный access, меняем один символ в payload — подпись инвалидна.
const parts = ctx.rotated.access.split('.')
if (parts.length !== 3) { step.notes.push('access не JWT-формата'); return }
// Точечная порча payload: меняем последний символ перед сигнатурой.
const payload = parts[1]
const tampered = payload.slice(0, -1) + (payload.slice(-1) === 'a' ? 'b' : 'a')
const fake = `${parts[0]}.${tampered}.${parts[2]}`
const me = await axios.get(`${ADMIN_BASE}/api/me`, {
httpsAgent, headers: { Authorization: `Bearer ${fake}` }, validateStatus: () => true,
})
const ok = me.status === 401
check(step, { kind: 'api', description: 'Изменённый payload (подпись не сходится) → 401',
ok, detail: `status=${me.status}` })
if (!ok && me.status === 200) report.bug({ step: '05', severity: 'critical',
title: 'JWT без валидной подписи принят — катастрофическая дыра',
detail: `payload изменён, status=${me.status}` })
}
export async function step06_random_jwt_rejected({ step }: StepCtx) {
// Полностью левый «JWT» (валидная base64 в трёх сегментах, но левая подпись).
const fake = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJoYWNrZXIiLCJyb2xlcyI6WyJTdXBlckFkbWluIl19.invalid-sig'
const me = await axios.get(`${ADMIN_BASE}/api/me`, {
httpsAgent, headers: { Authorization: `Bearer ${fake}` }, validateStatus: () => true,
})
check(step, { kind: 'api', description: 'Случайный HS256 «JWT» → 401',
ok: me.status === 401, detail: `status=${me.status}` })
}
export async function step07_deactivated_user_blocked({ ctx, step, report }: StepCtx) {
if (!ctx.adminEmail || !ctx.adminPwd || !ctx.rotated?.refresh) { step.status = 'skip'; return }
// Деактивируем юзера прямо в БД (через UserManager поднять/собрать сложно).
psql(`UPDATE users SET "IsActive" = false WHERE LOWER("Email") = LOWER('${ctx.adminEmail}');`)
// Попытка login с верным паролем.
const tok = await loginRaw(ctx.adminEmail, ctx.adminPwd)
check(step, { kind: 'api', description: 'Login деактивированного → 4xx',
ok: tok.status >= 400 && tok.status < 500, detail: `status=${tok.status}` })
// Refresh старого refresh (rotated) — должен быть инвалид.
const r = await refreshRaw(ctx.rotated.refresh)
const ok = r.status >= 400 && r.status < 500
check(step, { kind: 'api', description: 'Refresh деактивированного → 4xx',
ok, detail: `status=${r.status} error=${r.data?.error}` })
if (!ok) report.bug({ step: '07', severity: 'critical',
title: 'Refresh-token деактивированного юзера остаётся валидным',
detail: `IsActive=false, но refresh вернул ${r.status}.` })
// Возвращаем User обратно — для последующих шагов.
psql(`UPDATE users SET "IsActive" = true WHERE LOWER("Email") = LOWER('${ctx.adminEmail}');`)
}
export async function step08_archived_org_blocks_login({ ctx, step, report }: StepCtx) {
if (!ctx.orgId || !ctx.adminEmail || !ctx.adminPwd) { step.status = 'skip'; return }
const sa = await ensureSuperAdmin(ctx)
const api = makeClient(sa)
// Чтобы archive прошёл, нужен confirmationName == текущее имя орги.
const orgRow = await api.get(`/api/super-admin/organizations/${ctx.orgId}`)
const orgName = orgRow.data?.name ?? ''
const arch = await api.post(`/api/super-admin/organizations/${ctx.orgId}/archive`,
{ confirmationName: orgName, reason: 'E2E auth-edge archive test (>=10 chars)' })
check(step, { kind: 'api', description: 'Архивация орг → 200/204',
ok: arch.status === 200 || arch.status === 204, detail: `status=${arch.status} ${asString(arch.data).slice(0, 100)}` })
if (arch.status >= 400) return
const tok = await loginRaw(ctx.adminEmail, ctx.adminPwd)
const ok = tok.status >= 400 && tok.status < 500
check(step, { kind: 'api', description: 'Login юзера архивной орги → 4xx',
ok, detail: `status=${tok.status} error=${tok.data?.error_description ?? tok.data?.error}` })
if (!ok) report.bug({ step: '08', severity: 'critical',
title: 'Login юзера архивной орги разрешён',
detail: `status=${tok.status}. Архивные орги должны блокировать вход полностью.` })
// Восстанавливаем.
await api.post(`/api/super-admin/organizations/${ctx.orgId}/restore`,
{ reason: 'E2E auth-edge restore back (>=10 chars)' })
}
export async function step09_duplicate_signup_blocked({ ctx, step, report }: StepCtx) {
if (!ctx.adminEmail) { step.status = 'skip'; return }
// Прямой POST /api/auth/signup с тем же email (живая орга после restore).
const r = await axios.post(`${ADMIN_BASE}/api/auth/signup`, {
email: ctx.adminEmail, password: 'NewPass12345!', organizationName: 'Dup Shop',
phone: '+77001234567', plan: 'start',
}, { httpsAgent, validateStatus: () => true })
const ok = r.status >= 400 && r.status < 500
check(step, { kind: 'api', description: 'Повторный signup на занятый email → 4xx',
ok, detail: `status=${r.status} ${asString(r.data).slice(0, 120)}` })
if (!ok) report.bug({ step: '09', severity: 'high',
title: 'Повторный signup на занятый email прошёл — конфликт идентификации',
detail: asString(r.data) })
}
export async function step10_orphan_signup_reactivates({ ctx, step, report }: StepCtx) {
// Воспроизводим orphan: создаём AppUser без живой organization и тестим signup.
// Способ: создаём новую орг, signup'имся, потом DELETE org → user без org. Затем signup тем же email.
const orphanEmail = `orphan-${TS}@example.kz`
const signup1 = await axios.post(`${ADMIN_BASE}/api/auth/signup`, {
email: orphanEmail, password: 'OrphanPwd12!', organizationName: 'Orphan Shop',
phone: '+77007779988', plan: 'start',
}, { httpsAgent, validateStatus: () => true })
check(step, { kind: 'api', description: 'First signup → 200/201',
ok: signup1.status === 200 || signup1.status === 201, detail: `status=${signup1.status} ${asString(signup1.data).slice(0, 120)}` })
if (signup1.status >= 400) return
// Удаляем org этого юзера и оставляем user как orphan (через прямой SQL).
psql(`
UPDATE users SET "OrganizationId" = NULL WHERE LOWER("Email") = LOWER('${orphanEmail}');
DELETE FROM organizations WHERE LOWER("Email") = LOWER('${orphanEmail}');
`)
// Второй signup тем же email — по комменту в AuthSignupController это реактивирует.
const signup2 = await axios.post(`${ADMIN_BASE}/api/auth/signup`, {
email: orphanEmail, password: 'ReactivatedPwd1!', organizationName: 'Reactivated Shop',
phone: '+77007779988', plan: 'start',
}, { httpsAgent, validateStatus: () => true })
const reactivated = signup2.status === 200 || signup2.status === 201
check(step, { kind: 'api', description: 'Re-signup orphan email → 200/201 (реактивация)',
ok: reactivated, detail: `status=${signup2.status} ${asString(signup2.data).slice(0, 150)}` })
// Логин с новым паролем должен работать.
if (reactivated) {
const tok = await loginRaw(orphanEmail, 'ReactivatedPwd1!')
check(step, { kind: 'api', description: 'Login после реактивации → 200',
ok: tok.status === 200, detail: `status=${tok.status}` })
} else {
report.bug({ step: '10', severity: 'medium',
title: 'Orphan-юзер не может реактивироваться через signup',
detail: `Спецификация в AuthSignupController описывает реактивацию. Got ${signup2.status}.` })
}
}