Новый 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>
402 lines
21 KiB
TypeScript
402 lines
21 KiB
TypeScript
/**
|
||
* 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) })
|
||
}
|