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-эндпоинтах: подбор пароля
|
/// <summary>Anti-brute-force на чувствительных auth-эндпоинтах: подбор пароля
|
||||||
/// на <c>/connect/token</c> и спам-регистрация на <c>/api/auth/signup</c>.
|
/// на <c>/connect/token</c> и спам-регистрация на <c>/api/auth/signup</c>.
|
||||||
///
|
///
|
||||||
/// Два sliding-window лимита на IP, оба должны пропустить запрос (chained):
|
/// Стратегия — два независимых партишенера, оба chained-window (1min+1hour):
|
||||||
/// 5/мин (резкий всплеск) и 20/час (медленный перебор). Применяется
|
/// <list type="bullet">
|
||||||
/// глобально, но no-op для всех путей кроме двух auth-эндпоинтов — так
|
/// <item><b>Per-username</b> (только <c>/connect/token</c>) — тугой лимит
|
||||||
/// нет нужды цеплять policy на каждый minimal-API/контроллер по отдельности,
|
/// <c>5/мин</c>, <c>20/час</c> на один account. Защищает от перебора пароля
|
||||||
/// а двойное окно (которое per-endpoint policy выразить не может) работает
|
/// к конкретному логину независимо от IP атакующего.</item>
|
||||||
/// через <see cref="PartitionedRateLimiter.CreateChained{TResource}"/>.</summary>
|
/// <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
|
public static class AuthRateLimiterExtensions
|
||||||
{
|
{
|
||||||
// Дефолты. Переопределяются конфигом RateLimiting:* (см. ниже) — например,
|
// Дефолты per-username (тугой) — переопределяются RateLimiting:PerUserPerMinute/Hour.
|
||||||
// интеграционные тесты с общим loopback-IP поднимают лимит/выключают его,
|
public const int DefaultPerUserPerMinute = 5;
|
||||||
// чтобы повторные логины не упирались в 429.
|
public const int DefaultPerUserPerHour = 20;
|
||||||
public const int DefaultPerMinutePermitLimit = 5;
|
|
||||||
public const int DefaultPerHourPermitLimit = 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";
|
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 section = config.GetSection("RateLimiting");
|
||||||
var enabled = section.GetValue("Enabled", true);
|
var enabled = section.GetValue("Enabled", true);
|
||||||
var perMinute = section.GetValue("PerMinute", DefaultPerMinutePermitLimit);
|
// Back-compat: PerMinute/PerHour действовали раньше как IP-лимит. Если кто-то
|
||||||
var perHour = section.GetValue("PerHour", DefaultPerHourPermitLimit);
|
// их выставил — продолжают работать как 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 =>
|
services.AddRateLimiter(options =>
|
||||||
{
|
{
|
||||||
// По умолчанию RateLimiter отдаёт 503 — нам нужен честный 429.
|
|
||||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
|
||||||
options.GlobalLimiter = enabled
|
options.GlobalLimiter = enabled
|
||||||
? PartitionedRateLimiter.CreateChained(
|
? PartitionedRateLimiter.CreateChained(
|
||||||
BuildWindow(perMinute, TimeSpan.FromMinutes(1)),
|
BuildUserWindow(perUserMinute, TimeSpan.FromMinutes(1)),
|
||||||
BuildWindow(perHour, TimeSpan.FromHours(1)))
|
BuildUserWindow(perUserHour, TimeSpan.FromHours(1)),
|
||||||
|
BuildIpWindow(perIpMinute, TimeSpan.FromMinutes(1)),
|
||||||
|
BuildIpWindow(perIpHour, TimeSpan.FromHours(1)))
|
||||||
: PartitionedRateLimiter.Create<HttpContext, string>(
|
: PartitionedRateLimiter.Create<HttpContext, string>(
|
||||||
_ => RateLimitPartition.GetNoLimiter(NoLimitPartition));
|
_ => RateLimitPartition.GetNoLimiter(NoLimitPartition));
|
||||||
|
|
||||||
|
|
@ -62,19 +77,37 @@ public static IServiceCollection AddAuthRateLimiting(this IServiceCollection ser
|
||||||
return services;
|
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 =>
|
PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
|
||||||
{
|
{
|
||||||
var bucket = ResolveAuthBucket(ctx);
|
var bucket = ResolveAuthBucket(ctx);
|
||||||
if (bucket is null)
|
if (bucket is null) return RateLimitPartition.GetNoLimiter(NoLimitPartition);
|
||||||
{
|
|
||||||
return RateLimitPartition.GetNoLimiter(NoLimitPartition);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Отдельный бакет на каждый эндпоинт: успешная регистрация не должна
|
|
||||||
// съедать лимит логинов (и наоборот). Ключ = "<эндпоинт>:<IP>".
|
|
||||||
return RateLimitPartition.GetSlidingWindowLimiter(
|
return RateLimitPartition.GetSlidingWindowLimiter(
|
||||||
$"{bucket}:{ResolveClientKey(ctx)}",
|
$"ip:{bucket}:{ResolveClientKey(ctx)}",
|
||||||
_ => new SlidingWindowRateLimiterOptions
|
_ => new SlidingWindowRateLimiterOptions
|
||||||
{
|
{
|
||||||
PermitLimit = permitLimit,
|
PermitLimit = permitLimit,
|
||||||
|
|
@ -110,4 +143,49 @@ private static string ResolveClientKey(HttpContext ctx)
|
||||||
}
|
}
|
||||||
return ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
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).
|
// дополнительной кастомизации не нужно, и она запрещена (см. ValidateMappings).
|
||||||
app.UseHttpMetrics();
|
app.UseHttpMetrics();
|
||||||
// До аутентификации: лимитируем перебор пароля ещё на входе, не доводя
|
// До аутентификации: лимитируем перебор пароля ещё на входе, не доводя
|
||||||
// до проверки credential'ов в БД.
|
// до проверки credential'ов в БД. Перед лимитером пик form-body /connect/token,
|
||||||
|
// чтобы per-username бакет имел ключ — без этого мы можем считать только по IP,
|
||||||
|
// и любая NAT/CI-машина роняется первой.
|
||||||
|
app.UseAuthFormUsernameKey();
|
||||||
app.UseRateLimiter();
|
app.UseRateLimiter();
|
||||||
// SignalR-clients не могут слать кастомные хедеры через WebSocket, поэтому
|
// SignalR-clients не могут слать кастомные хедеры через WebSocket, поэтому
|
||||||
// токен приходит как `?access_token=...`. До UseAuthentication перекладываем
|
// токен приходит как `?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