food-market/tests/e2e/scenarios/superadmin-console.steps.ts
nns 90331ff371 test(e2e): scenario superadmin-console — архив/восстановление/владелец/удаление
6 шагов (ТЗ 2.8): создание орг + аудит CreateOrg; архив с подтверждением
имени (неверное → 400); восстановление; смена владельца (без reason / reason<10
→ 400, валидно → 204 + реальная передача владения); hard-delete с
retention-гейтом (не-архив → 409, до retention → 409, retention=0 + верное
имя → 204, орг удалена, юзеры отвязаны); фильтры журнала аудита по org и
actionType (DeleteOrg переживает удаление орг — FK отсутствует).

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

181 lines
13 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 для superadmin-console.
*
* Проверяем платформенные операции SuperAdmin над организациями и журнал
* аудита. Ключевые инварианты: архив/удаление требуют точного имени для
* подтверждения; hard-delete доступен только после retention-периода и
* деактивирует юзеров; смена владельца требует осмысленный reason (≥10);
* каждая мутация оставляет строку в super_admin_audit_log.
*/
import { login, makeClient } from '../lib/api.js'
import { psql } from '../lib/db.js'
import type { CheckResult, Step, Report } from '../lib/report.js'
import type { AxiosInstance } from 'axios'
const TS = Date.now()
interface Ctx {
apiOnly: boolean
sa?: string
org1Id?: string
org1Name?: string
org1AdminEmail?: string
org1AdminPwd?: string
org2Id?: string
org2Name?: string
newOwnerUserId?: 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 ensureSa(ctx: Ctx): Promise<string> {
if (!ctx.sa) ctx.sa = (await login('admin@food-market.local', 'Admin12345!')).accessToken
return ctx.sa
}
async function createOrg(api: AxiosInstance, name: string) {
return api.post('/api/super-admin/organizations', {
org: { name, countryCode: 'KZ', bin: null, address: null, phone: null, email: null, defaultCurrencyId: null, accountOwnerUserId: null },
adminLastName: 'Owner', adminFirstName: 'Admin', adminEmail: `sac-${name.replace(/\s+/g, '').toLowerCase()}@example.kz`, adminPosition: null,
})
}
// Какие ActionType есть в audit-log для организации.
async function auditActions(api: AxiosInstance, orgId: string): Promise<string[]> {
const r = await api.get(`/api/super-admin/audit-log?organizationId=${orgId}&pageSize=100`)
return (r.data?.items ?? []).map((x: { actionType: string }) => x.actionType)
}
// ---------------------------------------------------------------------------
export async function step01_create_org_audited({ ctx, step, report }: StepCtx) {
const sa = await ensureSa(ctx)
const api = makeClient(sa)
ctx.org1Name = `Console Org1 ${TS}`
const r = await createOrg(api, ctx.org1Name)
check(step, { kind: 'api', description: 'Создание орг1 → 200', ok: r.status === 200, detail: `status=${r.status}` })
ctx.org1Id = r.data?.organization?.id
ctx.org1AdminEmail = r.data?.adminEmail
ctx.org1AdminPwd = r.data?.adminTempPassword
if (!ctx.org1Id) { report.bug({ step: '01', severity: 'critical', title: 'Орг не создана', detail: asString(r.data) }); return }
const actions = await auditActions(api, ctx.org1Id)
check(step, { kind: 'api', description: 'audit-log содержит CreateOrg', ok: actions.includes('CreateOrg'), detail: `actions=${actions.join(',')}` })
}
export async function step02_archive({ ctx, step }: StepCtx) {
if (!ctx.org1Id) { step.status = 'skip'; return }
const api = makeClient(ctx.sa!)
const wrong = await api.post(`/api/super-admin/organizations/${ctx.org1Id}/archive`, { confirmationName: 'неверное имя' })
check(step, { kind: 'api', description: 'Архив с неверным именем → 400', ok: wrong.status === 400, detail: `status=${wrong.status}` })
const ok = await api.post(`/api/super-admin/organizations/${ctx.org1Id}/archive`, { confirmationName: ctx.org1Name })
check(step, { kind: 'api', description: 'Архив с верным именем → 204', ok: ok.status === 204, detail: `status=${ok.status}` })
const arch = q1(`SELECT "IsArchived" FROM organizations WHERE "Id"='${ctx.org1Id}'`)
check(step, { kind: 'db', description: 'IsArchived=true, ArchivedAt задан',
ok: (arch === 't' || arch === 'true'), detail: `IsArchived=${arch}` })
check(step, { kind: 'api', description: 'audit-log содержит ArchiveOrg', ok: (await auditActions(api, ctx.org1Id)).includes('ArchiveOrg') })
}
export async function step03_restore({ ctx, step }: StepCtx) {
if (!ctx.org1Id) { step.status = 'skip'; return }
const api = makeClient(ctx.sa!)
const r = await api.post(`/api/super-admin/organizations/${ctx.org1Id}/restore`, {})
check(step, { kind: 'api', description: 'Восстановление → 204', ok: r.status === 204, detail: `status=${r.status}` })
const arch = q1(`SELECT "IsArchived" FROM organizations WHERE "Id"='${ctx.org1Id}'`)
check(step, { kind: 'db', description: 'IsArchived=false', ok: arch === 'f' || arch === 'false', detail: `IsArchived=${arch}` })
check(step, { kind: 'api', description: 'audit-log содержит RestoreOrg', ok: (await auditActions(api, ctx.org1Id)).includes('RestoreOrg') })
}
export async function step04_change_owner({ ctx, step, report }: StepCtx) {
if (!ctx.org1Id || !ctx.org1AdminEmail || !ctx.org1AdminPwd) { step.status = 'skip'; return }
const sa = makeClient(ctx.sa!)
// Заводим второго сотрудника-с-учёткой в орг1, чтобы передать владение РЕАЛЬНО
// другому пользователю (логинимся админом орг1 — его creds сохранены в step01).
const orgAdmin = makeClient((await login(ctx.org1AdminEmail, ctx.org1AdminPwd)).accessToken)
const roleId = (await orgAdmin.get('/api/organization/employee-roles')).data?.items?.find((r: { name: string }) => r.name !== 'Администратор')?.id
const emp = await orgAdmin.post('/api/organization/employees', {
lastName: 'Преемник', firstName: 'Влад', roleId, isActive: true, createAccount: true, email: `heir-${TS}@example.kz`,
})
ctx.newOwnerUserId = emp.data?.employee?.userId
check(step, { kind: 'api', description: 'Второй пользователь орг1 создан (кандидат во владельцы)', ok: !!ctx.newOwnerUserId })
if (!ctx.newOwnerUserId) return
const noReason = await sa.post(`/api/super-admin/organizations/${ctx.org1Id}/change-owner`, { newOwnerUserId: ctx.newOwnerUserId })
check(step, { kind: 'api', description: 'change-owner без reason → 400', ok: noReason.status === 400, detail: `status=${noReason.status}` })
const shortReason = await sa.post(`/api/super-admin/organizations/${ctx.org1Id}/change-owner`, { newOwnerUserId: ctx.newOwnerUserId, reason: 'ok' })
check(step, { kind: 'api', description: 'change-owner reason<10 → 400', ok: shortReason.status === 400, detail: `status=${shortReason.status}` })
if (shortReason.status !== 400) report.bug({ step: '04', severity: 'medium',
title: 'change-owner принимает слишком короткий reason', detail: `reason="ok" → ${shortReason.status}, ожидалось 400 (ТЗ 2.8)` })
const okReason = await sa.post(`/api/super-admin/organizations/${ctx.org1Id}/change-owner`,
{ newOwnerUserId: ctx.newOwnerUserId, reason: 'Передача владения по тикету поддержки #4242' })
check(step, { kind: 'api', description: 'change-owner валидный reason → 204', ok: okReason.status === 204, detail: `status=${okReason.status}` })
const dbOwner = q1(`SELECT "AccountOwnerUserId" FROM organizations WHERE "Id"='${ctx.org1Id}'`)
check(step, { kind: 'db', description: 'AccountOwnerUserId обновлён на нового владельца', ok: dbOwner === ctx.newOwnerUserId, detail: `owner=${dbOwner}` })
const actions = await auditActions(sa, ctx.org1Id)
check(step, { kind: 'api', description: 'audit-log содержит ChangeOwner', ok: actions.includes('ChangeOwner'), detail: `actions=${actions.join(',')}` })
}
export async function step05_hard_delete({ ctx, step, report }: StepCtx) {
const sa = makeClient(ctx.sa!)
ctx.org2Name = `Console Org2 ${TS}`
const created = await createOrg(sa, ctx.org2Name)
ctx.org2Id = created.data?.organization?.id
check(step, { kind: 'api', description: 'Орг2 создана', ok: !!ctx.org2Id })
if (!ctx.org2Id) return
// Удаление неархивной → 409.
const delLive = await sa.delete(`/api/super-admin/organizations/${ctx.org2Id}`, { data: { confirmationName: ctx.org2Name } })
check(step, { kind: 'api', description: 'Удаление неархивной орг → 409', ok: delLive.status === 409, detail: `status=${delLive.status}` })
await sa.post(`/api/super-admin/organizations/${ctx.org2Id}/archive`, { confirmationName: ctx.org2Name })
// До истечения retention (дефолт 30 дней, ArchivedAt=сейчас) → 409.
const delEarly = await sa.delete(`/api/super-admin/organizations/${ctx.org2Id}`, { data: { confirmationName: ctx.org2Name } })
check(step, { kind: 'api', description: 'Удаление до retention → 409', ok: delEarly.status === 409, detail: `status=${delEarly.status} ${asString(delEarly.data)}` })
// retention=0 → удаление сразу доступно.
await sa.put('/api/super-admin/settings', { archiveRetentionDays: 0 })
const delWrong = await sa.delete(`/api/super-admin/organizations/${ctx.org2Id}`, { data: { confirmationName: 'не то имя' } })
check(step, { kind: 'api', description: 'Удаление с неверным именем → 400', ok: delWrong.status === 400, detail: `status=${delWrong.status}` })
// userId'ы орг2 до удаления — проверим, что после удаления они деактивированы.
const usersBefore = q1(`SELECT count(*) FROM users WHERE "OrganizationId"='${ctx.org2Id}'`)
const delOk = await sa.delete(`/api/super-admin/organizations/${ctx.org2Id}`, { data: { confirmationName: ctx.org2Name } })
check(step, { kind: 'api', description: 'Удаление архивной (retention=0, верное имя) → 204', ok: delOk.status === 204, detail: `status=${delOk.status}` })
const orgGone = q1(`SELECT count(*) FROM organizations WHERE "Id"='${ctx.org2Id}'`)
check(step, { kind: 'db', description: 'Организация физически удалена', ok: orgGone === '0', detail: `rows=${orgGone}` })
const stillLinked = q1(`SELECT count(*) FROM users WHERE "OrganizationId"='${ctx.org2Id}'`)
check(step, { kind: 'db', description: 'Юзеры отвязаны/деактивированы (нет привязки к удалённой орг)',
ok: stillLinked === '0', detail: `before=${usersBefore} stillLinked=${stillLinked}` })
if (orgGone !== '0') report.bug({ step: '05', severity: 'high', title: 'Hard-delete не удалил организацию', detail: `rows=${orgGone}` })
// Возвращаем retention в дефолт, чтобы не влиять на другие прогоны.
await sa.put('/api/super-admin/settings', { archiveRetentionDays: 30 })
}
export async function step06_audit_log_filters({ ctx, step, report }: StepCtx) {
const sa = makeClient(ctx.sa!)
if (ctx.org1Id) {
const a1 = await auditActions(sa, ctx.org1Id)
const need = ['CreateOrg', 'ArchiveOrg', 'RestoreOrg', 'ChangeOwner']
const missing = need.filter((n) => !a1.includes(n))
check(step, { kind: 'api', description: `audit орг1 содержит ${need.join('/')}`, ok: missing.length === 0, detail: missing.length ? `missing=${missing.join(',')}` : `actions=${a1.join(',')}` })
}
if (ctx.org2Id) {
// Журнал не привязан FK к орг — запись DeleteOrg переживает удаление.
const r = await sa.get(`/api/super-admin/audit-log?organizationId=${ctx.org2Id}&actionType=DeleteOrg`)
const items = r.data?.items ?? []
check(step, { kind: 'api', description: 'Фильтр actionType=DeleteOrg для орг2 возвращает запись', ok: items.length >= 1, detail: `count=${items.length}` })
check(step, { kind: 'api', description: 'Фильтр actionType отсекает прочее (все строки DeleteOrg)',
ok: items.every((x: { actionType: string }) => x.actionType === 'DeleteOrg'), detail: `types=${items.map((x: any) => x.actionType).join(',')}` })
}
// ChangeOwner хранит reason.
const ch = await sa.get('/api/super-admin/audit-log?actionType=ChangeOwner&pageSize=20')
const rows = ch.data?.items ?? []
const withReason = rows.find((x: { reason?: string }) => !!x.reason)
check(step, { kind: 'api', description: 'ChangeOwner в журнале хранит reason', ok: !!withReason, detail: `reason=${withReason?.reason ?? '(нет)'}` })
}