food-market/tests/e2e/scenarios/multi-tenant-isolation.steps.ts
nns ae88a16fd2 test(e2e): scenario multi-tenant-isolation — 12 шагов проверки изоляции
Новый E2E-сценарий покрывает критичную для multi-tenant SaaS поверхность:

1. Создание двух независимых организаций (Alpha и Beta) через SuperAdmin.
2. Логин под admin'ами Alpha и Beta, проверка разных org_id в JWT.
3. Alpha seed'ит counterparty + product.
4. Beta GET по прямым ID Alpha → 404 (не 200, не 403, не 500).
5. Beta GET листинги — Alpha-записей нет.
6. Beta PUT/DELETE по ID Alpha с валидным телом → 404.
7. Beta POST product со ссылкой на supplier Alpha → 4xx.
8. Beta-admin подделывает X-Org-Override:{alphaId} → запрос
   игнорирует заголовок (только SuperAdmin может override).
9. SuperAdmin без override видит обе организации.
10. SuperAdmin + X-Org-Override без reason → read-only (PUT 403).
11. SuperAdmin + X-Org-Override + Reason ≥10 → PUT 200, audit_log растёт.
12. Stock + StockMovements Alpha не видны Beta.

Применение: `bash tests/e2e/run.sh multi-tenant-isolation --api-only`.
Использует ту же runner-инфраструктуру что и full-cycle.yml.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 12:25:05 +05:00

402 lines
21 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 для сценария multi-tenant-isolation.
*
* Каждый step фиксирует API/DB-проверки в step.checks и регистрирует bug,
* если найдена утечка между организациями. Любая утечка = P0/critical.
*/
import { login, makeClient } from '../lib/api.js'
import { countRows } from '../lib/db.js'
import { generateEan13 } from '../lib/barcode.js'
import type { CheckResult, Step, Report } from '../lib/report.js'
const TS = Date.now()
interface Ctx {
apiOnly: boolean
superAdminToken?: string
alpha?: { orgId: string; adminEmail: string; adminPwd: string; adminToken?: string }
beta?: { orgId: string; adminEmail: string; adminPwd: string; adminToken?: string }
alphaCounterpartyId?: string
alphaProductId?: string
alphaProductGroupId?: string
alphaUnitId?: string
alphaCurrencyId?: string
alphaRetailPriceTypeId?: 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, 250) } catch { return String(x) }
}
async function ensureSuperAdmin(ctx: Ctx): Promise<string> {
if (!ctx.superAdminToken) {
const sa = await login('admin@food-market.local', 'Admin12345!')
ctx.superAdminToken = sa.accessToken
}
return ctx.superAdminToken
}
async function createOrg(token: string, label: string, adminEmail: string) {
const api = makeClient(token)
const res = await api.post('/api/super-admin/organizations', {
org: {
name: `${label} ${TS}`,
countryCode: 'KZ',
bin: null, address: null, phone: null, email: null,
defaultCurrencyId: null, accountOwnerUserId: null,
},
adminLastName: label, adminFirstName: 'Admin',
adminEmail, adminPosition: 'Директор',
})
if (res.status !== 200) throw new Error(`createOrg ${label}: ${res.status} ${asString(res.data)}`)
return {
orgId: res.data.organization.id as string,
adminEmail: res.data.adminEmail as string,
adminPwd: res.data.adminTempPassword as string,
}
}
// ---------------------------------------------------------------------------
export async function step01_create_two_orgs({ ctx, step }: StepCtx) {
const token = await ensureSuperAdmin(ctx)
ctx.alpha = await createOrg(token, 'Alpha', `alpha-admin-${TS}@example.kz`)
ctx.beta = await createOrg(token, 'Beta', `beta-admin-${TS}@example.kz`)
check(step, { kind: 'api', description: 'Создана Alpha', ok: !!ctx.alpha.orgId, detail: ctx.alpha.orgId })
check(step, { kind: 'api', description: 'Создана Beta', ok: !!ctx.beta.orgId, detail: ctx.beta.orgId })
check(step, { kind: 'api', description: 'orgId Alpha ≠ orgId Beta', ok: ctx.alpha.orgId !== ctx.beta.orgId })
}
export async function step02_login_both_admins({ ctx, step, report }: StepCtx) {
if (!ctx.alpha || !ctx.beta) { step.status = 'skip'; return }
const sa = await login(ctx.alpha.adminEmail, ctx.alpha.adminPwd)
const sb = await login(ctx.beta.adminEmail, ctx.beta.adminPwd)
ctx.alpha.adminToken = sa.accessToken
ctx.beta.adminToken = sb.accessToken
check(step, { kind: 'api', description: 'Login Alpha admin → 200', ok: !!sa.accessToken })
check(step, { kind: 'api', description: 'Login Beta admin → 200', ok: !!sb.accessToken })
check(step, { kind: 'api', description: 'Alpha orgId == ctx.alpha.orgId',
ok: sa.orgId === ctx.alpha.orgId, detail: `claim=${sa.orgId}` })
check(step, { kind: 'api', description: 'Beta orgId == ctx.beta.orgId',
ok: sb.orgId === ctx.beta.orgId, detail: `claim=${sb.orgId}` })
if (sa.orgId === sb.orgId) {
report.bug({ step: '02', severity: 'critical',
title: 'JWT обеих орг'+'аний содержит одинаковый org_id',
detail: `Alpha=${sa.orgId} Beta=${sb.orgId} — компрометация мультитенантности.` })
}
}
export async function step03_seed_data_in_alpha({ ctx, step, report }: StepCtx) {
if (!ctx.alpha?.adminToken) { step.status = 'skip'; return }
const api = makeClient(ctx.alpha.adminToken)
// 1. Counterparty
const cp = await api.post('/api/catalog/counterparties', {
name: `Поставщик Alpha ${TS}`, type: 1 /* LegalEntity */,
bin: '987654321098', phone: '+77001112233',
})
check(step, { kind: 'api', description: 'Alpha создаёт counterparty', ok: cp.status === 201,
detail: cp.status === 201 ? cp.data.id : asString(cp.data) })
if (cp.status !== 201) { report.bug({ step: '03', severity: 'high',
title: 'Не удалось создать counterparty в Alpha', detail: asString(cp.data) }); return }
ctx.alphaCounterpartyId = cp.data.id
// 2. Group для product
const grpList = await api.get('/api/catalog/product-groups?pageSize=10')
let groupId: string | undefined = grpList.data?.items?.[0]?.id
if (!groupId) {
const grp = await api.post('/api/catalog/product-groups', { name: 'Тестовая', parentId: null })
groupId = grp.data?.id
}
ctx.alphaProductGroupId = groupId
// 3. Подтянем unit/currency/priceType из lookups
const units = await api.get('/api/catalog/units-of-measure')
ctx.alphaUnitId = units.data?.items?.[0]?.id ?? units.data?.[0]?.id
const currencies = await api.get('/api/catalog/currencies?pageSize=200')
ctx.alphaCurrencyId = currencies.data?.items?.find((c: { code: string }) => c.code === 'KZT')?.id
const priceTypes = await api.get('/api/catalog/price-types')
ctx.alphaRetailPriceTypeId = priceTypes.data?.items?.find((p: { isRetail: boolean }) => p.isRetail)?.id
// 4. Product
const prod = await api.post('/api/catalog/products', {
name: `Товар Alpha ${TS}`,
unitOfMeasureId: ctx.alphaUnitId,
productGroupId: groupId,
vat: 12, vatEnabled: true,
barcodes: [{ code: generateEan13(), type: 1 /* Ean13 */, isPrimary: true }],
prices: [{ priceTypeId: ctx.alphaRetailPriceTypeId, currencyId: ctx.alphaCurrencyId, amount: 1500 }],
})
check(step, { kind: 'api', description: 'Alpha создаёт product', ok: prod.status === 201,
detail: prod.status === 201 ? prod.data.id : asString(prod.data) })
if (prod.status !== 201) { report.bug({ step: '03', severity: 'high',
title: 'Не удалось создать product в Alpha', detail: asString(prod.data) }); return }
ctx.alphaProductId = prod.data.id
}
export async function step04_beta_cannot_read_alpha({ ctx, step, report }: StepCtx) {
if (!ctx.beta?.adminToken || !ctx.alphaCounterpartyId || !ctx.alphaProductId) {
step.status = 'skip'; return
}
const api = makeClient(ctx.beta.adminToken)
const cp = await api.get(`/api/catalog/counterparties/${ctx.alphaCounterpartyId}`)
const ok1 = cp.status === 404
check(step, { kind: 'api', description: `Beta GET counterparties/{alphaId} → 404`,
ok: ok1, detail: `actual=${cp.status}` })
if (!ok1) report.bug({ step: '04', severity: 'critical',
title: 'УТЕЧКА: Beta видит counterparty из Alpha',
detail: `status=${cp.status} body=${asString(cp.data)}` })
const pr = await api.get(`/api/catalog/products/${ctx.alphaProductId}`)
const ok2 = pr.status === 404
check(step, { kind: 'api', description: `Beta GET products/{alphaId} → 404`,
ok: ok2, detail: `actual=${pr.status}` })
if (!ok2) report.bug({ step: '04', severity: 'critical',
title: 'УТЕЧКА: Beta видит product из Alpha',
detail: `status=${pr.status} body=${asString(pr.data)}` })
}
export async function step05_beta_cannot_list_alpha_data({ ctx, step, report }: StepCtx) {
if (!ctx.beta?.adminToken) { step.status = 'skip'; return }
const api = makeClient(ctx.beta.adminToken)
const cps = await api.get('/api/catalog/counterparties?pageSize=100')
const cpItems = cps.data?.items ?? []
const cpLeaks = cpItems.filter((x: { id: string }) => x.id === ctx.alphaCounterpartyId)
check(step, { kind: 'api', description: 'Beta GET counterparties не содержит Alpha counterparty',
ok: cpLeaks.length === 0, detail: `всего=${cpItems.length}, утечек=${cpLeaks.length}` })
if (cpLeaks.length > 0) report.bug({ step: '05', severity: 'critical',
title: 'УТЕЧКА: список counterparties Beta содержит Alpha-запись',
detail: asString(cpLeaks) })
const prods = await api.get('/api/catalog/products?pageSize=100')
const prItems = prods.data?.items ?? []
const prLeaks = prItems.filter((x: { id: string }) => x.id === ctx.alphaProductId)
check(step, { kind: 'api', description: 'Beta GET products не содержит Alpha product',
ok: prLeaks.length === 0, detail: `всего=${prItems.length}, утечек=${prLeaks.length}` })
if (prLeaks.length > 0) report.bug({ step: '05', severity: 'critical',
title: 'УТЕЧКА: список products Beta содержит Alpha-запись',
detail: asString(prLeaks) })
}
export async function step06_beta_cannot_modify_alpha({ ctx, step, report }: StepCtx) {
if (!ctx.beta?.adminToken || !ctx.alphaProductId) { step.status = 'skip'; return }
const api = makeClient(ctx.beta.adminToken)
// Beta нужен свой минимальный набор lookup'ов для формирования валидного тела,
// чтобы PUT прошёл валидацию формы и упал именно на «нет такого товара» (404).
// Иначе будет 400 — что НЕ утечка, но и не семантически чистый кейс.
const units = await api.get('/api/catalog/units-of-measure')
const unitB = units.data?.items?.[0]?.id ?? units.data?.[0]?.id
const grps = await api.get('/api/catalog/product-groups?pageSize=10')
let grpB = grps.data?.items?.[0]?.id
if (!grpB) {
const g = await api.post('/api/catalog/product-groups', { name: 'B', parentId: null })
grpB = g.data?.id
}
const cur = await api.get('/api/catalog/currencies?pageSize=200')
const curB = cur.data?.items?.find((c: { code: string }) => c.code === 'KZT')?.id
const pt = await api.get('/api/catalog/price-types')
const retailB = pt.data?.items?.find((p: { isRetail: boolean }) => p.isRetail)?.id
const validBody = {
name: 'ВЗЛОМАНО', unitOfMeasureId: unitB, productGroupId: grpB,
vat: 12, vatEnabled: true,
barcodes: [{ code: generateEan13(), type: 1, isPrimary: true }],
prices: [{ priceTypeId: retailB, currencyId: curB, amount: 999 }],
}
const put = await api.put(`/api/catalog/products/${ctx.alphaProductId}`, validBody)
const ok1 = put.status === 404 || put.status === 403
check(step, { kind: 'api', description: 'Beta PUT products/{alphaId} с валидным телом → 404/403',
ok: ok1, detail: `actual=${put.status} ${asString(put.data).slice(0, 100)}` })
if (!ok1 && put.status >= 200 && put.status < 300) report.bug({ step: '06', severity: 'critical',
title: `УТЕЧКА: Beta может изменить product Alpha (HTTP ${put.status})`,
detail: asString(put.data) })
const del = await api.delete(`/api/catalog/products/${ctx.alphaProductId}`)
const ok2 = del.status === 404 || del.status === 403
check(step, { kind: 'api', description: 'Beta DELETE products/{alphaId} → 404/403',
ok: ok2, detail: `actual=${del.status}` })
if (!ok2 && del.status >= 200 && del.status < 300) report.bug({ step: '06', severity: 'critical',
title: `УТЕЧКА: Beta может удалить product Alpha (HTTP ${del.status})`,
detail: asString(del.data) })
}
export async function step07_beta_cannot_link_to_alpha({ ctx, step, report }: StepCtx) {
if (!ctx.beta?.adminToken || !ctx.alphaCounterpartyId) { step.status = 'skip'; return }
const api = makeClient(ctx.beta.adminToken)
// Beta пробует создать собственный товар с supplierId, принадлежащим Alpha.
const units = await api.get('/api/catalog/units-of-measure')
const unitB = units.data?.items?.[0]?.id ?? units.data?.[0]?.id
const grps = await api.get('/api/catalog/product-groups?pageSize=10')
let grpB = grps.data?.items?.[0]?.id
if (!grpB) {
const g = await api.post('/api/catalog/product-groups', { name: 'B', parentId: null })
grpB = g.data?.id
}
const cr = await api.post('/api/catalog/products', {
name: 'Beta-product-with-alpha-supplier',
unitOfMeasureId: unitB, productGroupId: grpB,
vat: 12, vatEnabled: true,
defaultSupplierId: ctx.alphaCounterpartyId, // ← чужой FK
})
const ok = cr.status >= 400 && cr.status < 500
check(step, { kind: 'api', description: 'Beta POST product с supplierId Alpha → 4xx',
ok, detail: `actual=${cr.status}` })
if (!ok) report.bug({ step: '07', severity: 'critical',
title: `УТЕЧКА: Beta может ссылаться на counterparty Alpha (HTTP ${cr.status})`,
detail: asString(cr.data) })
}
export async function step08_beta_cannot_forge_org_override({ ctx, step, report }: StepCtx) {
if (!ctx.beta?.adminToken || !ctx.alpha?.orgId || !ctx.alphaProductId) { step.status = 'skip'; return }
const api = makeClient(ctx.beta.adminToken)
// Beta-admin пытается подделать X-Org-Override и попасть в Alpha.
// Сервер должен игнорировать заголовок для не-SuperAdmin'ов.
const res = await api.get(`/api/catalog/products/${ctx.alphaProductId}`,
{ headers: { 'X-Org-Override': ctx.alpha.orgId } })
const ok = res.status === 404 || res.status === 403
check(step, { kind: 'api', description: 'Beta admin + X-Org-Override → 404/403',
ok, detail: `actual=${res.status}` })
if (!ok) report.bug({ step: '08', severity: 'critical',
title: 'КРИТИЧНО: обычный Admin может подделать X-Org-Override',
detail: `Beta admin отправил заголовок X-Org-Override=Alpha и получил ${res.status}.
Заголовок должен игнорироваться для всех ролей кроме SuperAdmin.` })
}
export async function step09_superadmin_sees_both({ ctx, step }: StepCtx) {
const token = await ensureSuperAdmin(ctx)
const api = makeClient(token)
const list = await api.get('/api/super-admin/organizations?archived=false&pageSize=200')
const ids = (list.data?.items ?? []).map((o: { id: string }) => o.id)
check(step, { kind: 'api', description: 'SuperAdmin видит Alpha в списке орг',
ok: ids.includes(ctx.alpha?.orgId), detail: `total=${list.data?.total}` })
check(step, { kind: 'api', description: 'SuperAdmin видит Beta в списке орг',
ok: ids.includes(ctx.beta?.orgId) })
}
export async function step10_superadmin_readonly_override({ ctx, step, report }: StepCtx) {
if (!ctx.alpha?.orgId || !ctx.alphaProductId) { step.status = 'skip'; return }
const token = await ensureSuperAdmin(ctx)
const api = makeClient(token)
const override = { 'X-Org-Override': ctx.alpha.orgId }
// GET — должен работать.
const get = await api.get(`/api/catalog/products/${ctx.alphaProductId}`, { headers: override })
check(step, { kind: 'api', description: 'SuperAdmin+override GET → 200', ok: get.status === 200,
detail: `actual=${get.status}` })
// PUT без reason — должно 403 (read-only).
const put = await api.put(`/api/catalog/products/${ctx.alphaProductId}`, {
name: 'override-no-reason', unitOfMeasureId: ctx.alphaUnitId,
productGroupId: ctx.alphaProductGroupId, vat: 12, vatEnabled: true,
}, { headers: override })
const ok = put.status === 403
check(step, { kind: 'api', description: 'SuperAdmin+override PUT без reason → 403',
ok, detail: `actual=${put.status}` })
if (!ok) report.bug({ step: '10', severity: 'high',
title: `READ-ONLY обход: PUT под X-Org-Override без reason прошёл (${put.status})`,
detail: asString(put.data) })
}
export async function step11_superadmin_edit_override_with_reason({ ctx, step, report }: StepCtx) {
if (!ctx.alpha?.orgId || !ctx.alphaCounterpartyId) { step.status = 'skip'; return }
const token = await ensureSuperAdmin(ctx)
const api = makeClient(token)
const reason = 'E2E multi-tenant-isolation: проверяем edit-mode override flow'
const headers = {
'X-Org-Override': ctx.alpha.orgId,
'X-Org-Override-Reason': reason,
}
// Counterparty проще для PUT (нет коллекций merge'а как у Product).
// Сначала фетч текущей записи, чтобы PUT прошёл валидацию.
const cur = await api.get(`/api/catalog/counterparties/${ctx.alphaCounterpartyId}`, { headers })
if (cur.status !== 200) {
check(step, { kind: 'api', description: 'pre-PUT GET counterparty Alpha', ok: false, detail: `actual=${cur.status}` })
return
}
const auditBefore = countRows('super_admin_audit_log')
const put = await api.put(`/api/catalog/counterparties/${ctx.alphaCounterpartyId}`, {
...cur.data,
name: `edited-by-superadmin-${TS}`,
}, { headers })
check(step, { kind: 'api', description: 'SuperAdmin+override+reason PUT counterparty → 200/204',
ok: put.status === 200 || put.status === 204, detail: `actual=${put.status} ${asString(put.data).slice(0, 120)}` })
// Проверка что в БД действительно изменилось — alpha admin (без override) читает.
const get = await makeClient(ctx.alpha.adminToken!).get(`/api/catalog/counterparties/${ctx.alphaCounterpartyId}`)
const actualName = get.data?.name
check(step, { kind: 'db', description: 'Counterparty.Name изменено в БД',
ok: typeof actualName === 'string' && actualName.startsWith('edited-by-superadmin'),
detail: `name=${actualName}` })
// Audit log: должна добавиться хотя бы одна запись после правки.
const auditAfter = countRows('super_admin_audit_log')
const grew = auditAfter > auditBefore
check(step, { kind: 'db', description: 'super_admin_audit_log выросло',
ok: grew, detail: `before=${auditBefore} after=${auditAfter}` })
if (!grew) report.gap('SuperAdmin edit-mode мутация под X-Org-Override-Reason НЕ пишется в super_admin_audit_log — теряется trail-аудит.')
// Заодно зафиксируем gap про ProductsController.Put + merge:
report.gap('ProductsController.Put в режиме X-Org-Override роняет DbUpdateConcurrencyException при пересылке prices/barcodes — merge-логика не учитывает override-режим. Ремонт PUT product через override-консоль невозможен.')
}
export async function step12_stock_isolation({ ctx, step, report }: StepCtx) {
if (!ctx.alpha?.adminToken || !ctx.beta?.adminToken || !ctx.alphaProductId) { step.status = 'skip'; return }
const alphaApi = makeClient(ctx.alpha.adminToken)
const betaApi = makeClient(ctx.beta.adminToken)
// Alpha: создать и провести supply на свой Product.
const stores = await alphaApi.get('/api/catalog/stores?pageSize=10')
const storeA = stores.data?.items?.[0]?.id
// Counterparty id запомнен в ctx ещё на step03; имя могло быть изменено
// edit-mode override в step11, поэтому find-by-name не надёжен.
const supplierA = ctx.alphaCounterpartyId
const supply = await alphaApi.post('/api/purchases/supplies', {
storeId: storeA, supplierId: supplierA,
currencyId: ctx.alphaCurrencyId,
date: new Date().toISOString(),
lines: [{ productId: ctx.alphaProductId, quantity: 7, unitPrice: 100 }],
})
check(step, { kind: 'api', description: 'Alpha создаёт supply', ok: supply.status === 201,
detail: `actual=${supply.status} ${asString(supply.data).slice(0, 120)}` })
if (supply.status !== 201) return
const post = await alphaApi.post(`/api/purchases/supplies/${supply.data.id}/post`)
check(step, { kind: 'api', description: 'Alpha проводит supply',
ok: post.status === 200 || post.status === 204, detail: `actual=${post.status}` })
// Beta: запрашивает свои stock'и — не должна видеть alphaProductId.
const betaStock = await betaApi.get('/api/inventory/stock?pageSize=200&includeZero=true')
const betaItems = betaStock.data?.items ?? []
const leak = betaItems.find((s: { productId: string }) => s.productId === ctx.alphaProductId)
check(step, { kind: 'api', description: 'Beta /inventory/stock не содержит Alpha product',
ok: !leak, detail: `total=${betaItems.length}, утечка=${leak ? 'ЕСТЬ' : 'нет'}` })
if (leak) report.bug({ step: '12', severity: 'critical',
title: 'УТЕЧКА: остатки Alpha видны в /inventory/stock у Beta',
detail: asString(leak) })
// И /movements: тот же тест.
const betaMovements = await betaApi.get('/api/inventory/movements?pageSize=200')
const moveItems = betaMovements.data?.items ?? []
const moveLeak = moveItems.find((m: { productId: string }) => m.productId === ctx.alphaProductId)
check(step, { kind: 'api', description: 'Beta /inventory/movements не содержит Alpha movement',
ok: !moveLeak, detail: `total=${moveItems.length}, утечка=${moveLeak ? 'ЕСТЬ' : 'нет'}` })
if (moveLeak) report.bug({ step: '12', severity: 'critical',
title: 'УТЕЧКА: движения Alpha видны в /inventory/movements у Beta',
detail: asString(moveLeak) })
}