food-market/tests/e2e/scenarios/employees.steps.ts
nns 68ce968021 test(e2e): scenario employees — CRUD, увольнение гасит логин, tenant-изоляция
10 шагов (ТЗ 2.7.1): создание без/с учёткой (temp password), email
обязателен при createAccount, дубль email, логин новым сотрудником,
увольнение гасит логин и refresh (P0-проверка), двухступенчатое удаление
(fired → soft-delete → 409), защита главного администратора/самого себя,
multi-tenant изоляция (чужой сотрудник → 404).

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

207 lines
12 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 для employees.
*
* Центральная проверка безопасности (ТЗ 0.4): увольнение/удаление сотрудника
* обязано гасить логин связанного User. Состояние Employee.IsActive и
* Employee.IsDeleted не должно расходиться с User.IsActive, иначе уволенный
* сотрудник продолжает входить в систему и обновлять токены.
*/
import { login, makeClient } 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 })
const BASE = process.env.E2E_ADMIN_URL ?? 'https://admin.food-market.kz'
interface Ctx {
apiOnly: boolean
superToken?: string
adminToken?: string
orgId?: string
roleId?: string
empId?: string
empEmail?: string
empPwd?: string
empRefresh?: string
bEmpId?: string
}
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, 200) } catch { return String(x) }
}
function q1(sql: string): string { return (psql(sql).trim().split('\n')[0] ?? '').trim() }
async function rawToken(body: Record<string, string>) {
const res = await axios.post(`${BASE}/connect/token`, new URLSearchParams(body),
{ httpsAgent, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, validateStatus: () => true })
return { status: res.status, data: res.data }
}
const loginRaw = (email: string, pwd: string) => rawToken({
grant_type: 'password', username: email, password: pwd,
client_id: 'food-market-web', scope: 'openid profile email roles api offline_access',
})
const refreshRaw = (rt: string) => rawToken({
grant_type: 'refresh_token', refresh_token: rt, client_id: 'food-market-web',
scope: 'openid profile email roles api offline_access',
})
async function ensureSuper(ctx: Ctx): Promise<string> {
if (!ctx.superToken) ctx.superToken = (await login('admin@food-market.local', 'Admin12345!')).accessToken
return ctx.superToken
}
async function createOrg(saToken: string, label: string) {
const r = await makeClient(saToken).post('/api/super-admin/organizations', {
org: { name: `Emp ${label} ${TS}`, countryCode: 'KZ', bin: null, address: null, phone: null, email: null, defaultCurrencyId: null, accountOwnerUserId: null },
adminLastName: label, adminFirstName: 'Admin', adminEmail: `emp-${label.toLowerCase()}-${TS}@example.kz`, adminPosition: null,
})
return r
}
// ---------------------------------------------------------------------------
export async function step01_bootstrap({ ctx, step, report }: StepCtx) {
const sa = await ensureSuper(ctx)
const orgRes = await createOrg(sa, 'A')
if (orgRes.status !== 200) { report.bug({ step: '01', severity: 'critical', title: 'bootstrap орг', detail: asString(orgRes.data) }); return }
ctx.orgId = orgRes.data.organization.id
ctx.adminToken = (await login(orgRes.data.adminEmail, orgRes.data.adminTempPassword)).accessToken
const api = makeClient(ctx.adminToken)
const roles = await api.get('/api/organization/employee-roles')
const list = roles.data?.items ?? roles.data ?? []
const role = list.find((r: { name: string }) => r.name !== 'Администратор') ?? list[0]
ctx.roleId = role?.id
check(step, { kind: 'api', description: 'Орг A + не-админская роль найдены', ok: !!ctx.orgId && !!ctx.roleId, detail: `role=${role?.name}` })
}
export async function step02_create_without_account({ ctx, step }: StepCtx) {
if (!ctx.roleId) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken!)
const r = await api.post('/api/organization/employees', {
lastName: 'Безучётный', firstName: 'Иван', roleId: ctx.roleId, isActive: true, createAccount: false,
})
check(step, { kind: 'api', description: 'Создан без учётки → 200/201', ok: r.status === 200 || r.status === 201, detail: `status=${r.status}` })
check(step, { kind: 'api', description: 'UserId = null', ok: r.data?.employee?.userId == null, detail: `userId=${r.data?.employee?.userId}` })
check(step, { kind: 'api', description: 'generatedPassword отсутствует', ok: !r.data?.generatedPassword })
}
export async function step03_create_with_account({ ctx, step, report }: StepCtx) {
if (!ctx.roleId) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken!)
ctx.empEmail = `worker-${TS}@example.kz`
const r = await api.post('/api/organization/employees', {
lastName: 'Сотрудников', firstName: 'Пётр', roleId: ctx.roleId, isActive: true,
createAccount: true, email: ctx.empEmail,
})
check(step, { kind: 'api', description: 'Создан с учёткой → 200/201', ok: r.status === 200 || r.status === 201, detail: `status=${r.status}` })
ctx.empId = r.data?.employee?.id
ctx.empPwd = r.data?.generatedPassword
check(step, { kind: 'api', description: 'Temp password возвращён один раз', ok: !!ctx.empPwd })
check(step, { kind: 'api', description: 'UserId присвоен', ok: !!r.data?.employee?.userId, detail: `userId=${r.data?.employee?.userId}` })
if (!ctx.empId || !ctx.empPwd) report.bug({ step: '03', severity: 'high', title: 'Учётка сотрудника не создана', detail: asString(r.data) })
}
export async function step04_email_required({ ctx, step }: StepCtx) {
if (!ctx.roleId) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken!)
const r = await api.post('/api/organization/employees', {
lastName: 'Безмыла', firstName: 'Анна', roleId: ctx.roleId, isActive: true, createAccount: true, email: '',
})
check(step, { kind: 'api', description: 'createAccount=true без email → 400', ok: r.status === 400, detail: `status=${r.status}` })
}
export async function step05_duplicate_email({ ctx, step }: StepCtx) {
if (!ctx.roleId || !ctx.empEmail) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken!)
const r = await api.post('/api/organization/employees', {
lastName: 'Дубль', firstName: 'Мария', roleId: ctx.roleId, isActive: true, createAccount: true, email: ctx.empEmail,
})
check(step, { kind: 'api', description: 'Дубль email → 4xx (rejected)', ok: r.status >= 400 && r.status < 500, detail: `status=${r.status}` })
}
export async function step06_account_can_login({ ctx, step, report }: StepCtx) {
if (!ctx.empEmail || !ctx.empPwd) { step.status = 'skip'; return }
const r = await loginRaw(ctx.empEmail, ctx.empPwd)
check(step, { kind: 'api', description: 'Логин новым сотрудником → 200', ok: r.status === 200, detail: `status=${r.status}` })
ctx.empRefresh = r.data?.refresh_token
if (r.status !== 200) report.bug({ step: '06', severity: 'high', title: 'Созданный сотрудник не может войти', detail: asString(r.data) })
}
export async function step07_fire_blocks_login({ ctx, step, report }: StepCtx) {
if (!ctx.empId || !ctx.empEmail || !ctx.empPwd) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken!)
const del = await api.delete(`/api/organization/employees/${ctx.empId}`)
check(step, { kind: 'api', description: 'Увольнение (DELETE) → 204', ok: del.status === 204, detail: `status=${del.status}` })
// Состояние в БД: уволен, не удалён.
const empActive = q1(`SELECT "IsActive" FROM employees WHERE "Id"='${ctx.empId}'`)
check(step, { kind: 'db', description: 'Employee.IsActive=false (уволен)', ok: empActive === 'f' || empActive === 'false', detail: `IsActive=${empActive}` })
const userActive = q1(`SELECT u."IsActive" FROM users u JOIN employees e ON e."UserId"=u."Id" WHERE e."Id"='${ctx.empId}'`)
check(step, { kind: 'db', description: 'User.IsActive=false (логин погашен)', ok: userActive === 'f' || userActive === 'false', detail: `User.IsActive=${userActive}` })
// Главное: уволенный больше НЕ логинится и НЕ обновляет токен.
const reLogin = await loginRaw(ctx.empEmail, ctx.empPwd)
check(step, { kind: 'api', description: 'Повторный login уволенного → 4xx', ok: reLogin.status >= 400 && reLogin.status < 500, detail: `status=${reLogin.status}` })
if (reLogin.status === 200) report.bug({ step: '07', severity: 'critical',
title: 'Уволенный сотрудник продолжает логиниться',
detail: 'DELETE сотрудника не гасит User.IsActive — уволенный сохраняет полный доступ (ТЗ 0.4).' })
if (ctx.empRefresh) {
const ref = await refreshRaw(ctx.empRefresh)
check(step, { kind: 'api', description: 'Refresh уволенного → 4xx', ok: ref.status >= 400 && ref.status < 500, detail: `status=${ref.status}` })
if (ref.status === 200) report.bug({ step: '07', severity: 'critical',
title: 'Старый refresh уволенного всё ещё рабочий', detail: 'Увольнение не отзывает активные сессии.' })
}
}
export async function step08_two_step_delete({ ctx, step }: StepCtx) {
if (!ctx.empId) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken!)
const del2 = await api.delete(`/api/organization/employees/${ctx.empId}`)
check(step, { kind: 'api', description: 'Второй DELETE → 204 (soft-delete)', ok: del2.status === 204, detail: `status=${del2.status}` })
const isDeleted = q1(`SELECT "IsDeleted" FROM employees WHERE "Id"='${ctx.empId}'`)
check(step, { kind: 'db', description: 'Employee.IsDeleted=true', ok: isDeleted === 't' || isDeleted === 'true', detail: `IsDeleted=${isDeleted}` })
const del3 = await api.delete(`/api/organization/employees/${ctx.empId}`)
check(step, { kind: 'api', description: 'Третий DELETE → 409 (уже удалён)', ok: del3.status === 409, detail: `status=${del3.status}` })
}
export async function step09_owner_self_protected({ ctx, step }: StepCtx) {
const api = makeClient(ctx.adminToken!)
const list = await api.get('/api/organization/employees?status=all')
const items = list.data?.items ?? list.data ?? []
const owner = items.find((e: { isOwner: boolean }) => e.isOwner)
check(step, { kind: 'api', description: 'Главный администратор найден в списке', ok: !!owner, detail: `owner=${owner?.id}` })
if (!owner) return
const r = await api.delete(`/api/organization/employees/${owner.id}`)
check(step, { kind: 'api', description: 'DELETE главного администратора (он же self) → 403', ok: r.status === 403, detail: `status=${r.status}` })
}
export async function step10_tenant_isolation({ ctx, step, report }: StepCtx) {
const sa = await ensureSuper(ctx)
const orgB = await createOrg(sa, 'B')
if (orgB.status !== 200) { step.status = 'skip'; return }
const bAdmin = (await login(orgB.data.adminEmail, orgB.data.adminTempPassword)).accessToken
const bApi = makeClient(bAdmin)
const bRoles = await bApi.get('/api/organization/employee-roles')
const bRoleId = (bRoles.data?.items ?? bRoles.data ?? []).find((r: { name: string }) => r.name !== 'Администратор')?.id
const bEmp = await bApi.post('/api/organization/employees', {
lastName: ужойB', firstName: 'Орг', roleId: bRoleId, isActive: true, createAccount: false,
})
ctx.bEmpId = bEmp.data?.employee?.id
check(step, { kind: 'api', description: 'Сотрудник в орг B создан', ok: !!ctx.bEmpId })
if (!ctx.bEmpId) return
const api = makeClient(ctx.adminToken!)
const get = await api.get(`/api/organization/employees/${ctx.bEmpId}`)
check(step, { kind: 'api', description: 'GET чужого сотрудника (орг B) из орг A → 404', ok: get.status === 404, detail: `status=${get.status}` })
const del = await api.delete(`/api/organization/employees/${ctx.bEmpId}`)
check(step, { kind: 'api', description: 'DELETE чужого сотрудника → 404', ok: del.status === 404, detail: `status=${del.status}` })
if (get.status === 200 || del.status === 204) report.bug({ step: '10', severity: 'critical',
title: 'Multi-tenant утечка: доступ к сотрудникам чужой организации', detail: `GET=${get.status} DELETE=${del.status}` })
}