/** * 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 ?? '(нет)'}` }) }