From 90331ff371a900259a0f568e55099900c37adb8b Mon Sep 17 00:00:00 2001 From: nns Date: Tue, 26 May 2026 11:53:32 +0500 Subject: [PATCH] =?UTF-8?q?test(e2e):=20scenario=20superadmin-console=20?= =?UTF-8?q?=E2=80=94=20=D0=B0=D1=80=D1=85=D0=B8=D0=B2/=D0=B2=D0=BE=D1=81?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5/=D0=B2=D0=BB=D0=B0=D0=B4=D0=B5=D0=BB=D0=B5=D1=86/=D1=83?= =?UTF-8?q?=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../e2e/scenarios/superadmin-console.steps.ts | 180 ++++++++++++++++++ tests/e2e/scenarios/superadmin-console.yml | 25 +++ 2 files changed, 205 insertions(+) create mode 100644 tests/e2e/scenarios/superadmin-console.steps.ts create mode 100644 tests/e2e/scenarios/superadmin-console.yml diff --git a/tests/e2e/scenarios/superadmin-console.steps.ts b/tests/e2e/scenarios/superadmin-console.steps.ts new file mode 100644 index 0000000..9b2f550 --- /dev/null +++ b/tests/e2e/scenarios/superadmin-console.steps.ts @@ -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 { + 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 { + 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 ?? '(нет)'}` }) +} diff --git a/tests/e2e/scenarios/superadmin-console.yml b/tests/e2e/scenarios/superadmin-console.yml new file mode 100644 index 0000000..12d5b48 --- /dev/null +++ b/tests/e2e/scenarios/superadmin-console.yml @@ -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"