- 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>
277 lines
14 KiB
TypeScript
277 lines
14 KiB
TypeScript
/**
|
||
* 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}.` })
|
||
}
|
||
}
|