diff --git a/tests/e2e/scenarios/multi-tenant-isolation.steps.ts b/tests/e2e/scenarios/multi-tenant-isolation.steps.ts new file mode 100644 index 0000000..70be430 --- /dev/null +++ b/tests/e2e/scenarios/multi-tenant-isolation.steps.ts @@ -0,0 +1,401 @@ +/** + * 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 { + 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) }) +} diff --git a/tests/e2e/scenarios/multi-tenant-isolation.yml b/tests/e2e/scenarios/multi-tenant-isolation.yml new file mode 100644 index 0000000..54779d4 --- /dev/null +++ b/tests/e2e/scenarios/multi-tenant-isolation.yml @@ -0,0 +1,36 @@ +name: multi-tenant-isolation +description: | + Multi-tenant изоляция. Создаём две организации, проверяем что данные + одной невидимы и неизменяемы из другой. Проверяем SuperAdmin override + (read-only без reason, edit-mode с reason). Это критическая проверка + безопасности — любая утечка между орг'ами это P0 баг. + +preconditions: + reset_db: true + smoke_login_super_admin: true + +steps: + - id: step01_create_two_orgs + title: SuperAdmin создаёт две независимые орги Alpha и Beta (каждая со своим админом) + - id: step02_login_both_admins + title: Логин под admin Alpha и admin Beta — получаем два разных org_id в JWT + - id: step03_seed_data_in_alpha + title: Admin Alpha создаёт counterparty + product → запоминаем их ID + - id: step04_beta_cannot_read_alpha + title: Admin Beta GET /api/catalog/counterparties/{alphaId} и /products/{alphaId} → 404 + - id: step05_beta_cannot_list_alpha_data + title: Admin Beta GET /api/catalog/counterparties|/products → пустые списки (нет данных Alpha) + - id: step06_beta_cannot_modify_alpha + title: Admin Beta PUT/DELETE /api/catalog/products/{alphaId} → 404 (не 200, не 403) + - id: step07_beta_cannot_link_to_alpha + title: Admin Beta POST product с DefaultSupplierId=alphaCounterpartyId → 400 (FK через query filter) + - id: step08_beta_cannot_forge_org_override + title: Admin Beta с заголовком X-Org-Override:{alphaId} → запрос всё равно идёт от Beta (не SuperAdmin) + - id: step09_superadmin_sees_both + title: SuperAdmin без override GET /api/super-admin/organizations → видит и Alpha и Beta + - id: step10_superadmin_readonly_override + title: SuperAdmin с X-Org-Override:{alphaId} → GET товаров Alpha (200), PUT/POST без reason → 403 + - id: step11_superadmin_edit_override_with_reason + title: SuperAdmin с X-Org-Override + X-Org-Override-Reason → PUT 200 + запись в audit_log + - id: step12_stock_isolation + title: Остатки Alpha и Beta не смешиваются — Supply в Alpha не появляется в /inventory/stock у Beta