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>
This commit is contained in:
nns 2026-05-26 11:53:32 +05:00
parent 01568baf4f
commit 90331ff371
2 changed files with 205 additions and 0 deletions

View file

@ -0,0 +1,180 @@
/**
* 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 ?? '(нет)'}` })
}

View file

@ -0,0 +1,25 @@
name: superadmin-console
description: |
SuperAdmin Console (ТЗ 2.8): создание организации с аудитом, архивирование с
подтверждением имени, восстановление, смена владельца (reason обязателен и
≥10 символов), hard-delete с retention-периодом и деактивацией юзеров, и
фильтрация журнала аудита. Все мутации обязаны писать строку в
super_admin_audit_log.
preconditions:
reset_db: true
smoke_login_super_admin: true
steps:
- id: step01_create_org_audited
title: "Создание орг1 → 200 + запись CreateOrg в audit-log"
- id: step02_archive
title: "Архив: неверное имя → 400, верное → 204, IsArchived=true + ArchiveOrg"
- id: step03_restore
title: "Восстановление → 204, IsArchived=false + RestoreOrg"
- id: step04_change_owner
title: "Смена владельца: без reason → 400, reason<10 → 400, валидно → 204 + ChangeOwner"
- id: step05_hard_delete
title: "Hard-delete: не-архив → 409, до retention → 409, retention=0 + верное имя → 204, юзеры деактивированы"
- id: step06_audit_log_filters
title: "audit-log: фильтр по org и actionType, ChangeOwner хранит reason"