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>
This commit is contained in:
parent
5091d43f5d
commit
68ce968021
206
tests/e2e/scenarios/employees.steps.ts
Normal file
206
tests/e2e/scenarios/employees.steps.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
/**
|
||||||
|
* 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}` })
|
||||||
|
}
|
||||||
33
tests/e2e/scenarios/employees.yml
Normal file
33
tests/e2e/scenarios/employees.yml
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
name: employees
|
||||||
|
description: |
|
||||||
|
Сотрудники (ТЗ 2.7.1): CRUD, создание учётки с временным паролем,
|
||||||
|
двухступенчатое удаление (увольнение → soft-delete), защита главного
|
||||||
|
администратора и самого себя, tenant-изоляция. Критично (ТЗ 0.4):
|
||||||
|
уволенный/удалённый сотрудник НЕ должен иметь возможности залогиниться —
|
||||||
|
состояние Employee.IsActive обязано гасить логин связанного User.
|
||||||
|
|
||||||
|
preconditions:
|
||||||
|
reset_db: true
|
||||||
|
smoke_login_super_admin: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: step01_bootstrap
|
||||||
|
title: "Орг A + логин админа + выбор не-админской роли"
|
||||||
|
- id: step02_create_without_account
|
||||||
|
title: "Создание сотрудника без учётки (createAccount=false) → UserId=null"
|
||||||
|
- id: step03_create_with_account
|
||||||
|
title: "Создание сотрудника с учёткой → temp password в ответе, User создан"
|
||||||
|
- id: step04_email_required
|
||||||
|
title: "createAccount=true без email → 400"
|
||||||
|
- id: step05_duplicate_email
|
||||||
|
title: "Дубль email при createAccount → 4xx"
|
||||||
|
- id: step06_account_can_login
|
||||||
|
title: "Новый сотрудник логинится временным паролем → 200"
|
||||||
|
- id: step07_fire_blocks_login
|
||||||
|
title: "Увольнение (DELETE) гасит логин: повторный login и refresh → 4xx"
|
||||||
|
- id: step08_two_step_delete
|
||||||
|
title: "Второй DELETE → soft-delete (IsDeleted), третий → 409"
|
||||||
|
- id: step09_owner_self_protected
|
||||||
|
title: "DELETE главного администратора (он же self) → 403"
|
||||||
|
- id: step10_tenant_isolation
|
||||||
|
title: "Админ орг A не видит и не удаляет сотрудника орг B → 404"
|
||||||
Loading…
Reference in a new issue