diff --git a/src/food-market.api/Infrastructure/RateLimiting/AuthRateLimiterExtensions.cs b/src/food-market.api/Infrastructure/RateLimiting/AuthRateLimiterExtensions.cs
index c82791a..9f605cd 100644
--- a/src/food-market.api/Infrastructure/RateLimiting/AuthRateLimiterExtensions.cs
+++ b/src/food-market.api/Infrastructure/RateLimiting/AuthRateLimiterExtensions.cs
@@ -6,19 +6,27 @@ namespace foodmarket.Api.Infrastructure.RateLimiting;
/// Anti-brute-force на чувствительных auth-эндпоинтах: подбор пароля
/// на /connect/token и спам-регистрация на /api/auth/signup.
///
-/// Два sliding-window лимита на IP, оба должны пропустить запрос (chained):
-/// 5/мин (резкий всплеск) и 20/час (медленный перебор). Применяется
-/// глобально, но no-op для всех путей кроме двух auth-эндпоинтов — так
-/// нет нужды цеплять policy на каждый minimal-API/контроллер по отдельности,
-/// а двойное окно (которое per-endpoint policy выразить не может) работает
-/// через .
+/// Стратегия — два независимых партишенера, оба chained-window (1min+1hour):
+///
+/// - Per-username (только /connect/token) — тугой лимит
+/// 5/мин, 20/час на один account. Защищает от перебора пароля
+/// к конкретному логину независимо от IP атакующего.
+/// - Per-IP — широкий лимит 30/мин, 200/час на все
+/// auth-эндпоинты с одного IP. Защищает от спам-регистрации и от того,
+/// что один IP откуда-то перебирает тысячи аккаунтов.
+///
+/// e2e/CI поднимают свежие org'и сериально с одного IP → IP-лимит широкий,
+/// чтобы их не уронить; username-лимит работает per-account и не съедается
+/// чужими логинами.
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("PerMinute");
+ var legacyPerHour = section.GetValue("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(
_ => RateLimitPartition.GetNoLimiter(NoLimitPartition));
@@ -62,19 +77,37 @@ public static IServiceCollection AddAuthRateLimiting(this IServiceCollection ser
return services;
}
- private static PartitionedRateLimiter BuildWindow(int permitLimit, TimeSpan window) =>
+ /// Per-username бакет, только для /connect/token. Username
+ /// предварительно сложен в HttpContext.Items pre-buffer middleware'ом
+ /// (). Если ключа нет (signup или иной
+ /// путь) — no-op limiter.
+ private static PartitionedRateLimiter BuildUserWindow(int permitLimit, TimeSpan window) =>
+ PartitionedRateLimiter.Create(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,
+ });
+ });
+
+ /// Per-IP бакет для всех auth-эндпоинтов. Защита от DDoS и
+ /// от того, что один IP перебирает тысячи аккаунтов.
+ private static PartitionedRateLimiter BuildIpWindow(int permitLimit, TimeSpan window) =>
PartitionedRateLimiter.Create(ctx =>
{
var bucket = ResolveAuthBucket(ctx);
- if (bucket is null)
- {
- return RateLimitPartition.GetNoLimiter(NoLimitPartition);
- }
-
- // Отдельный бакет на каждый эндпоинт: успешная регистрация не должна
- // съедать лимит логинов (и наоборот). Ключ = "<эндпоинт>:".
+ 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;
+
+ /// Перед UseRateLimiter() пик POST /connect/token form-body, чтобы
+ /// per-user бакет знал кому считать. Body буферизуется через
+ /// , читается, перематывается
+ /// — последующие middleware получают целое тело без перетруска.
+ ///
+ /// Размер тела ограничен 4 КБ — параметров OAuth password grant
+ /// (grant_type/username/password/client_id/scope) с запасом хватает.
+ 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();
+ });
+ }
}
diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs
index 4f2d5ce..908ed9c 100644
--- a/src/food-market.api/Program.cs
+++ b/src/food-market.api/Program.cs
@@ -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 перекладываем
diff --git a/tests/e2e/scenarios/stage-ui-verify-pos-sync.spec.ts b/tests/e2e/scenarios/stage-ui-verify-pos-sync.spec.ts
new file mode 100644
index 0000000..1a5bf21
--- /dev/null
+++ b/tests/e2e/scenarios/stage-ui-verify-pos-sync.spec.ts
@@ -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:').toBe(1)
+ expect(posOnes[0].id).toBe(serverSaleId)
+
+ await ctx.dispose()
+ })
+})
diff --git a/tests/e2e/scenarios/stage-ui-verify-stock-race.spec.ts b/tests/e2e/scenarios/stage-ui-verify-stock-race.spec.ts
new file mode 100644
index 0000000..e7ad5fc
--- /dev/null
+++ b/tests/e2e/scenarios/stage-ui-verify-stock-race.spec.ts
@@ -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()
+ })
+})