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:
parent
01568baf4f
commit
90331ff371
180
tests/e2e/scenarios/superadmin-console.steps.ts
Normal file
180
tests/e2e/scenarios/superadmin-console.steps.ts
Normal 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 ?? '(нет)'}` })
|
||||
}
|
||||
25
tests/e2e/scenarios/superadmin-console.yml
Normal file
25
tests/e2e/scenarios/superadmin-console.yml
Normal 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"
|
||||
Loading…
Reference in a new issue