fix(rate-limit): per-username 5/мин + per-IP 30/мин — brute-force на конкретный аккаунт ловится, CI/NAT не страдают
Some checks are pending
Some checks are pending
Регрессия после ba54155: rate-limit 5/мин per-IP сваливал stage e2e
(75 тестов с одного IP, каждый по /connect/token при apiSignup → 22+
из них падали на 429 после 5-й попытки). Per-IP лимит был
неправильной осью защиты.
Новая стратегия в AuthRateLimiterExtensions:
- Per-username (только /connect/token): 5/мин, 20/час. Защищает от
перебора пароля к конкретному account независимо от IP атакующего.
Username вытаскивается form-body peek-middleware'ом перед UseRateLimiter
(EnableBuffering + ручной парс x-www-form-urlencoded, тело ≤4KB).
- Per-IP (token+signup): 30/мин, 200/час. Защищает от спам-регистрации
и от 1-IP-перебирает-тысячи-аккаунтов сценария.
- Back-compat: legacy RateLimiting:PerMinute/PerHour мапятся в IP-лимит.
Проверено через https://test.admin.food-market.kz:
- 6 неверных попыток на ОДНУ учётку → 6-я → 429 ✓
- 8 неверных попыток на РАЗНЫЕ учётки с того же IP → все 400 (IP-лимит 30/мин не достигнут) ✓
Также добавлены verify-spec'и stage-ui-verify-pos-sync (п.14) и
stage-ui-verify-stock-race (п.15).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
ba54155225
commit
9d48ca6483
|
|
@ -6,19 +6,27 @@ namespace foodmarket.Api.Infrastructure.RateLimiting;
|
|||
/// <summary>Anti-brute-force на чувствительных auth-эндпоинтах: подбор пароля
|
||||
/// на <c>/connect/token</c> и спам-регистрация на <c>/api/auth/signup</c>.
|
||||
///
|
||||
/// Два sliding-window лимита на IP, оба должны пропустить запрос (chained):
|
||||
/// 5/мин (резкий всплеск) и 20/час (медленный перебор). Применяется
|
||||
/// глобально, но no-op для всех путей кроме двух auth-эндпоинтов — так
|
||||
/// нет нужды цеплять policy на каждый minimal-API/контроллер по отдельности,
|
||||
/// а двойное окно (которое per-endpoint policy выразить не может) работает
|
||||
/// через <see cref="PartitionedRateLimiter.CreateChained{TResource}"/>.</summary>
|
||||
/// Стратегия — два независимых партишенера, оба chained-window (1min+1hour):
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Per-username</b> (только <c>/connect/token</c>) — тугой лимит
|
||||
/// <c>5/мин</c>, <c>20/час</c> на один account. Защищает от перебора пароля
|
||||
/// к конкретному логину независимо от IP атакующего.</item>
|
||||
/// <item><b>Per-IP</b> — широкий лимит <c>30/мин</c>, <c>200/час</c> на все
|
||||
/// auth-эндпоинты с одного IP. Защищает от спам-регистрации и от того,
|
||||
/// что один IP откуда-то перебирает тысячи аккаунтов.</item>
|
||||
/// </list>
|
||||
/// e2e/CI поднимают свежие org'и сериально с одного IP → IP-лимит широкий,
|
||||
/// чтобы их не уронить; username-лимит работает per-account и не съедается
|
||||
/// чужими логинами.</summary>
|
||||
public static class AuthRateLimiterExtensions
|
||||
{
|
||||
// Дефолты. Переопределяются конфигом RateLimiting:* (см. ниже) — например,
|
||||
// интеграционные тесты с общим loopback-IP поднимают лимит/выключают его,
|
||||
// чтобы повторные логины не упирались в 429.
|
||||
public const int DefaultPerMinutePermitLimit = 5;
|
||||
public const int DefaultPerHourPermitLimit = 20;
|
||||
// Дефолты per-username (тугой) — переопределяются RateLimiting:PerUserPerMinute/Hour.
|
||||
public const int DefaultPerUserPerMinute = 5;
|
||||
public const int DefaultPerUserPerHour = 20;
|
||||
|
||||
// Дефолты per-IP (широкий, для CI/NAT) — переопределяются RateLimiting:PerIpPerMinute/Hour.
|
||||
public const int DefaultPerIpPerMinute = 30;
|
||||
public const int DefaultPerIpPerHour = 200;
|
||||
|
||||
private const string NoLimitPartition = "__not-an-auth-endpoint";
|
||||
|
||||
|
|
@ -26,18 +34,25 @@ public static IServiceCollection AddAuthRateLimiting(this IServiceCollection ser
|
|||
{
|
||||
var section = config.GetSection("RateLimiting");
|
||||
var enabled = section.GetValue("Enabled", true);
|
||||
var perMinute = section.GetValue("PerMinute", DefaultPerMinutePermitLimit);
|
||||
var perHour = section.GetValue("PerHour", DefaultPerHourPermitLimit);
|
||||
// Back-compat: PerMinute/PerHour действовали раньше как IP-лимит. Если кто-то
|
||||
// их выставил — продолжают работать как IP-лимит, чтобы не сломать прод-конфиг.
|
||||
var legacyPerMinute = section.GetValue<int?>("PerMinute");
|
||||
var legacyPerHour = section.GetValue<int?>("PerHour");
|
||||
var perUserMinute = section.GetValue("PerUserPerMinute", DefaultPerUserPerMinute);
|
||||
var perUserHour = section.GetValue("PerUserPerHour", DefaultPerUserPerHour);
|
||||
var perIpMinute = legacyPerMinute ?? section.GetValue("PerIpPerMinute", DefaultPerIpPerMinute);
|
||||
var perIpHour = legacyPerHour ?? section.GetValue("PerIpPerHour", DefaultPerIpPerHour);
|
||||
|
||||
services.AddRateLimiter(options =>
|
||||
{
|
||||
// По умолчанию RateLimiter отдаёт 503 — нам нужен честный 429.
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
|
||||
options.GlobalLimiter = enabled
|
||||
? PartitionedRateLimiter.CreateChained(
|
||||
BuildWindow(perMinute, TimeSpan.FromMinutes(1)),
|
||||
BuildWindow(perHour, TimeSpan.FromHours(1)))
|
||||
BuildUserWindow(perUserMinute, TimeSpan.FromMinutes(1)),
|
||||
BuildUserWindow(perUserHour, TimeSpan.FromHours(1)),
|
||||
BuildIpWindow(perIpMinute, TimeSpan.FromMinutes(1)),
|
||||
BuildIpWindow(perIpHour, TimeSpan.FromHours(1)))
|
||||
: PartitionedRateLimiter.Create<HttpContext, string>(
|
||||
_ => RateLimitPartition.GetNoLimiter(NoLimitPartition));
|
||||
|
||||
|
|
@ -62,19 +77,37 @@ public static IServiceCollection AddAuthRateLimiting(this IServiceCollection ser
|
|||
return services;
|
||||
}
|
||||
|
||||
private static PartitionedRateLimiter<HttpContext> BuildWindow(int permitLimit, TimeSpan window) =>
|
||||
/// <summary>Per-username бакет, только для <c>/connect/token</c>. Username
|
||||
/// предварительно сложен в HttpContext.Items pre-buffer middleware'ом
|
||||
/// (<see cref="UseAuthFormUsernameKey"/>). Если ключа нет (signup или иной
|
||||
/// путь) — no-op limiter.</summary>
|
||||
private static PartitionedRateLimiter<HttpContext> BuildUserWindow(int permitLimit, TimeSpan window) =>
|
||||
PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
|
||||
{
|
||||
if (ResolveAuthBucket(ctx) != "token") return RateLimitPartition.GetNoLimiter(NoLimitPartition);
|
||||
var user = ctx.Items[FormUsernameKeyItem] as string;
|
||||
if (string.IsNullOrEmpty(user)) return RateLimitPartition.GetNoLimiter(NoLimitPartition);
|
||||
return RateLimitPartition.GetSlidingWindowLimiter(
|
||||
$"user:{user.ToLowerInvariant()}",
|
||||
_ => new SlidingWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = permitLimit,
|
||||
Window = window,
|
||||
SegmentsPerWindow = 6,
|
||||
QueueLimit = 0,
|
||||
AutoReplenishment = true,
|
||||
});
|
||||
});
|
||||
|
||||
/// <summary>Per-IP бакет для всех auth-эндпоинтов. Защита от DDoS и
|
||||
/// от того, что один IP перебирает тысячи аккаунтов.</summary>
|
||||
private static PartitionedRateLimiter<HttpContext> BuildIpWindow(int permitLimit, TimeSpan window) =>
|
||||
PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
|
||||
{
|
||||
var bucket = ResolveAuthBucket(ctx);
|
||||
if (bucket is null)
|
||||
{
|
||||
return RateLimitPartition.GetNoLimiter(NoLimitPartition);
|
||||
}
|
||||
|
||||
// Отдельный бакет на каждый эндпоинт: успешная регистрация не должна
|
||||
// съедать лимит логинов (и наоборот). Ключ = "<эндпоинт>:<IP>".
|
||||
if (bucket is null) return RateLimitPartition.GetNoLimiter(NoLimitPartition);
|
||||
return RateLimitPartition.GetSlidingWindowLimiter(
|
||||
$"{bucket}:{ResolveClientKey(ctx)}",
|
||||
$"ip:{bucket}:{ResolveClientKey(ctx)}",
|
||||
_ => new SlidingWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = permitLimit,
|
||||
|
|
@ -110,4 +143,49 @@ private static string ResolveClientKey(HttpContext ctx)
|
|||
}
|
||||
return ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
}
|
||||
|
||||
internal const string FormUsernameKeyItem = "auth.rate.username";
|
||||
private const int MaxBodyPeekBytes = 4096;
|
||||
|
||||
/// <summary>Перед UseRateLimiter() пик POST /connect/token form-body, чтобы
|
||||
/// per-user бакет знал кому считать. Body буферизуется через
|
||||
/// <see cref="HttpRequest.EnableBuffering()"/>, читается, перематывается
|
||||
/// — последующие middleware получают целое тело без перетруска.
|
||||
///
|
||||
/// Размер тела ограничен 4 КБ — параметров OAuth password grant
|
||||
/// (grant_type/username/password/client_id/scope) с запасом хватает.</summary>
|
||||
public static IApplicationBuilder UseAuthFormUsernameKey(this IApplicationBuilder app)
|
||||
{
|
||||
return app.Use(async (ctx, next) =>
|
||||
{
|
||||
if (HttpMethods.IsPost(ctx.Request.Method)
|
||||
&& ctx.Request.Path.StartsWithSegments("/connect/token", StringComparison.OrdinalIgnoreCase)
|
||||
&& ctx.Request.ContentType is { } ct
|
||||
&& ct.StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase)
|
||||
&& (ctx.Request.ContentLength ?? 0) > 0
|
||||
&& (ctx.Request.ContentLength ?? 0) <= MaxBodyPeekBytes)
|
||||
{
|
||||
ctx.Request.EnableBuffering();
|
||||
var pos = ctx.Request.Body.Position;
|
||||
using var reader = new StreamReader(ctx.Request.Body, leaveOpen: true);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
ctx.Request.Body.Position = pos;
|
||||
|
||||
// Ручной парсер чтобы не дёргать FormReader (он триггерит form-read,
|
||||
// который OpenIddict ниже по конвейеру тоже захочет сделать — оставляем
|
||||
// ему чистую копию). URL-encoded формат: key=value&key=value.
|
||||
foreach (var pair in body.Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var eq = pair.IndexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
var key = pair[..eq];
|
||||
if (!string.Equals(key, "username", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
var raw = pair[(eq + 1)..];
|
||||
ctx.Items[FormUsernameKeyItem] = Uri.UnescapeDataString(raw);
|
||||
break;
|
||||
}
|
||||
}
|
||||
await next();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -346,7 +346,10 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
|||
// дополнительной кастомизации не нужно, и она запрещена (см. ValidateMappings).
|
||||
app.UseHttpMetrics();
|
||||
// До аутентификации: лимитируем перебор пароля ещё на входе, не доводя
|
||||
// до проверки credential'ов в БД.
|
||||
// до проверки credential'ов в БД. Перед лимитером пик form-body /connect/token,
|
||||
// чтобы per-username бакет имел ключ — без этого мы можем считать только по IP,
|
||||
// и любая NAT/CI-машина роняется первой.
|
||||
app.UseAuthFormUsernameKey();
|
||||
app.UseRateLimiter();
|
||||
// SignalR-clients не могут слать кастомные хедеры через WebSocket, поэтому
|
||||
// токен приходит как `?access_token=...`. До UseAuthentication перекладываем
|
||||
|
|
|
|||
97
tests/e2e/scenarios/stage-ui-verify-pos-sync.spec.ts
Normal file
97
tests/e2e/scenarios/stage-ui-verify-pos-sync.spec.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* Verify-Sprint п.14: POS Sync API с idempotency-key через домен.
|
||||
* Сценарий:
|
||||
* - signup новой org → bootstrap (склад, товар, остаток через Enter).
|
||||
* - POST /api/pos/v1/sales с idempotencyKey K1 → 200, ServerSaleId != Empty.
|
||||
* - повтор того же body+K1 → 200, replayedFromCache=true, ServerSaleId === тот же.
|
||||
* - psql проверяет, что в retail_sales ровно 1 строка с notes начинающимся на 'pos:'.
|
||||
*/
|
||||
import { test, expect, request as apiRequest } from '@playwright/test'
|
||||
import { apiSignup } from '../lib/ui.js'
|
||||
import { generateEan13 } from '../lib/barcode.js'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
const BASE = process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz'
|
||||
|
||||
test.describe('Verify POS Sync idempotency', () => {
|
||||
test('V-14 POST /api/pos/v1/sales дважды с одним IdempotencyKey → один RetailSale, replayedFromCache=true', async () => {
|
||||
test.setTimeout(120_000)
|
||||
const sess = await apiSignup('v14')
|
||||
const ctx = await apiRequest.newContext({
|
||||
baseURL: BASE, ignoreHTTPSErrors: true,
|
||||
extraHTTPHeaders: { Authorization: `Bearer ${sess.accessToken}` },
|
||||
})
|
||||
|
||||
// Bootstrap: склад, товар, цена, остаток через Enter.
|
||||
const stores = await ctx.get('/api/inventory/stores')
|
||||
expect(stores.status()).toBe(200)
|
||||
const storeId = (await stores.json()).items[0]?.id
|
||||
?? (await (await ctx.post('/api/inventory/stores', { data: { name: 'Главный', address: 'Алматы' } })).json()).id
|
||||
const units = await ctx.get('/api/catalog/units-of-measure')
|
||||
const unitId = (await units.json()).items[0].id
|
||||
const cur = (await (await ctx.get('/api/catalog/currencies?pageSize=200')).json()).items.find((c: { code: string }) => c.code === 'KZT').id
|
||||
const ptList = (await (await ctx.get('/api/catalog/price-types')).json()).items
|
||||
const pt = (ptList.find((p: { isRetail: boolean }) => p.isRetail) ?? ptList[0]).id
|
||||
const groupsRes = (await (await ctx.get('/api/catalog/product-groups?pageSize=10')).json())
|
||||
const groupId = groupsRes.items[0]?.id
|
||||
?? (await (await ctx.post('/api/catalog/product-groups', { data: { name: 'G', parentId: null } })).json()).id
|
||||
const prod = await ctx.post('/api/catalog/products', {
|
||||
data: {
|
||||
name: `POSverify ${Date.now()}`, unitOfMeasureId: unitId, productGroupId: groupId,
|
||||
vat: 12, vatEnabled: true,
|
||||
barcodes: [{ code: generateEan13(7), type: 1, isPrimary: true }],
|
||||
prices: [{ priceTypeId: pt, currencyId: cur, amount: 500 }],
|
||||
},
|
||||
})
|
||||
expect(prod.status()).toBe(201)
|
||||
const productId = (await prod.json()).id
|
||||
|
||||
// Enter (приход без поставщика) на 10 штук, чтобы был остаток для продажи.
|
||||
const ent = await ctx.post('/api/inventory/enter', {
|
||||
data: { storeId, date: new Date().toISOString(), lines: [{ productId, qty: 10, costPrice: 200 }] },
|
||||
})
|
||||
expect(ent.status()).toBe(201)
|
||||
const entId = (await ent.json()).id
|
||||
expect((await ctx.post(`/api/inventory/enter/${entId}/post`)).status()).toBe(200)
|
||||
|
||||
// POS batch — 1 продажа.
|
||||
const idempKey = randomUUID()
|
||||
const clientSaleId = randomUUID()
|
||||
const body = {
|
||||
idempotencyKey: idempKey,
|
||||
sales: [{
|
||||
clientSaleId, storeId,
|
||||
soldAt: new Date().toISOString(),
|
||||
paymentMethod: 1, // Cash
|
||||
lines: [{ productId, qty: 1, unitPrice: 500, vat: 12 }],
|
||||
paidCash: 500, paidCard: 0, change: 0,
|
||||
}],
|
||||
}
|
||||
|
||||
const r1 = await ctx.post(`/api/pos/v1/sales?storeId=${storeId}`, { data: body })
|
||||
expect(r1.status(), 'first POS batch').toBe(200)
|
||||
const b1 = await r1.json() as { accepted: Array<{ serverSaleId: string }>, replayedFromCache: boolean }
|
||||
expect(b1.replayedFromCache).toBe(false)
|
||||
expect(b1.accepted.length).toBe(1)
|
||||
const serverSaleId = b1.accepted[0].serverSaleId
|
||||
expect(serverSaleId).toMatch(/^[0-9a-f-]{36}$/i)
|
||||
|
||||
// Повтор того же body+idempotencyKey.
|
||||
const r2 = await ctx.post(`/api/pos/v1/sales?storeId=${storeId}`, { data: body })
|
||||
expect(r2.status(), 'second POS batch (replay)').toBe(200)
|
||||
const b2 = await r2.json() as { accepted: Array<{ serverSaleId: string }>, replayedFromCache: boolean }
|
||||
expect(b2.replayedFromCache, 'replayed flag').toBe(true)
|
||||
expect(b2.accepted[0].serverSaleId, 'same serverSaleId').toBe(serverSaleId)
|
||||
|
||||
// Проверяем через API: GET /api/sales/retail должен показать ровно 1 чек
|
||||
// в этой org (помимо seed-данных, у свежей org 0 sales до нашего батча).
|
||||
const list = await ctx.get('/api/sales/retail?pageSize=200')
|
||||
expect(list.status()).toBe(200)
|
||||
const items = (await list.json() as { items: Array<{ id: string, notes: string | null }> }).items
|
||||
const posOnes = items.filter(s => s.notes != null && s.notes.startsWith(`pos:${clientSaleId.replace(/-/g, '')}`))
|
||||
expect(posOnes.length, 'ровно 1 retail_sale с маркером pos:<clientSaleId>').toBe(1)
|
||||
expect(posOnes[0].id).toBe(serverSaleId)
|
||||
|
||||
await ctx.dispose()
|
||||
})
|
||||
})
|
||||
95
tests/e2e/scenarios/stage-ui-verify-stock-race.spec.ts
Normal file
95
tests/e2e/scenarios/stage-ui-verify-stock-race.spec.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* Verify-Sprint п.15: stock-инвариант под конкуренцией.
|
||||
*
|
||||
* 5 параллельных POST /api/sales/retail/{id}/post по 1 единице товара,
|
||||
* остаток=3. Ожидание: ровно 3 → 200, 2 → 4xx (409 conflict либо 400 oversell).
|
||||
* После dust settles: Stock = 0, движения = -3.
|
||||
*
|
||||
* Использует Serializable + xmin row-version (UseXminAsConcurrencyToken) — если
|
||||
* один из параллельных post проходит, остальные должны получить DbUpdateConcurrencyException
|
||||
* → API возвращает 409 либо retry'ит и натыкается на oversell.
|
||||
*/
|
||||
import { test, expect, request as apiRequest } from '@playwright/test'
|
||||
import { apiSignup } from '../lib/ui.js'
|
||||
import { generateEan13 } from '../lib/barcode.js'
|
||||
|
||||
const BASE = process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz'
|
||||
|
||||
test.describe('Verify stock race', () => {
|
||||
test('V-15 5 параллельных Post(1шт) на остаток=3 → 3×200, 2×4xx, Stock=0', async () => {
|
||||
test.setTimeout(180_000)
|
||||
const sess = await apiSignup('v15')
|
||||
const ctx = await apiRequest.newContext({
|
||||
baseURL: BASE, ignoreHTTPSErrors: true,
|
||||
extraHTTPHeaders: { Authorization: `Bearer ${sess.accessToken}` },
|
||||
})
|
||||
|
||||
// Bootstrap.
|
||||
const stores = await ctx.get('/api/inventory/stores')
|
||||
const storeId = (await stores.json()).items[0]?.id
|
||||
?? (await (await ctx.post('/api/inventory/stores', { data: { name: 'Главный', address: 'Алматы' } })).json()).id
|
||||
const units = await ctx.get('/api/catalog/units-of-measure')
|
||||
const unitId = (await units.json()).items[0].id
|
||||
const cur = (await (await ctx.get('/api/catalog/currencies?pageSize=200')).json()).items.find((c: { code: string }) => c.code === 'KZT').id
|
||||
const ptList = (await (await ctx.get('/api/catalog/price-types')).json()).items
|
||||
const pt = (ptList.find((p: { isRetail: boolean }) => p.isRetail) ?? ptList[0]).id
|
||||
const groupsRes = (await (await ctx.get('/api/catalog/product-groups?pageSize=10')).json())
|
||||
const groupId = groupsRes.items[0]?.id
|
||||
?? (await (await ctx.post('/api/catalog/product-groups', { data: { name: 'G', parentId: null } })).json()).id
|
||||
const prod = await ctx.post('/api/catalog/products', {
|
||||
data: {
|
||||
name: `RaceProd ${Date.now()}`, unitOfMeasureId: unitId, productGroupId: groupId,
|
||||
vat: 12, vatEnabled: true,
|
||||
barcodes: [{ code: generateEan13(7), type: 1, isPrimary: true }],
|
||||
prices: [{ priceTypeId: pt, currencyId: cur, amount: 500 }],
|
||||
},
|
||||
})
|
||||
expect(prod.status()).toBe(201)
|
||||
const productId = (await prod.json()).id
|
||||
|
||||
// Enter: 3 штуки в остаток.
|
||||
const ent = await ctx.post('/api/inventory/enter', {
|
||||
data: { storeId, date: new Date().toISOString(), lines: [{ productId, qty: 3, costPrice: 200 }] },
|
||||
})
|
||||
const entId = (await ent.json()).id
|
||||
expect((await ctx.post(`/api/inventory/enter/${entId}/post`)).status()).toBe(200)
|
||||
|
||||
// Создаём 5 RetailSale-черновиков с qty=1 каждый.
|
||||
const draftIds: string[] = []
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const r = await ctx.post('/api/sales/retail', {
|
||||
data: {
|
||||
storeId, date: new Date().toISOString(),
|
||||
lines: [{ productId, qty: 1, unitPrice: 500, vat: 12 }],
|
||||
paidCash: 500, paidCard: 0, change: 0,
|
||||
},
|
||||
})
|
||||
expect(r.status(), `draft ${i}`).toBe(201)
|
||||
draftIds.push((await r.json()).id)
|
||||
}
|
||||
|
||||
// Параллельный Post всех 5.
|
||||
const postResults = await Promise.all(draftIds.map(id =>
|
||||
ctx.post(`/api/sales/retail/${id}/post`).then(r => ({ id, status: r.status() }))
|
||||
))
|
||||
const ok = postResults.filter(r => r.status === 200)
|
||||
const failed = postResults.filter(r => r.status >= 400)
|
||||
test.info().annotations.push({ type: 'post-results', description: JSON.stringify(postResults) })
|
||||
|
||||
expect(ok.length, 'ровно 3 успешных Post').toBe(3)
|
||||
expect(failed.length, 'ровно 2 неуспешных Post').toBe(2)
|
||||
for (const f of failed) {
|
||||
expect(f.status, `failed Post должен быть 4xx (409 conflict / 400 oversell)`).toBeGreaterThanOrEqual(400)
|
||||
expect(f.status, `failed Post должен быть < 500`).toBeLessThan(500)
|
||||
}
|
||||
|
||||
// Stock в этой org для productId = 0.
|
||||
const stocks = await ctx.get(`/api/inventory/stock?productId=${productId}&storeId=${storeId}`)
|
||||
expect(stocks.status()).toBe(200)
|
||||
const stockRows = (await stocks.json() as { items: Array<{ quantity: number }> }).items
|
||||
const qty = stockRows.length ? stockRows[0].quantity : 0
|
||||
expect(qty, 'остаток = 0 после трёх успешных продаж').toBe(0)
|
||||
|
||||
await ctx.dispose()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue