Sprint 23 (adversarial): атаковали систему как недоброжелатель. Найдено 4 бага, все починены. Bug #001 (Medium): NULL-byte в Product.Name вызывал 500 без тела. Postgres TEXT не принимает \x00. Добавил NoControlChars() в ProductInputValidator + CounterpartyInputValidator. Bug #002 (Low): ProductInputValidator MaximumLength(200) конфликтовал со StringLength(500) в DTO и schema HasMaxLength(500). Сделал 500 везде. Counterparty: 200 → 255 (matches HasMaxLength). Bug #003 (CRITICAL): параллельные posting'и под Serializable выбрасывали PostgresException 40001 → middleware → 500 empty body. Добавил SerializationConflictMiddleware который мапит 40001 → 409 Conflict с {error, retryable: true}. Также SerializableRetry helper для явного retry внутри endpoint'ов с exp backoff. Применил retry-wrap к RetailSalesController.Post (PostCoreAsync extracted). Bug #004 (Low): цена 0.0000001 округлялась до 0 уже после прохождения required-price check (check был ДО RoundIfNeeded). FindMissing- RequiredPriceAsync теперь округляет перед сравнением — required цена реально > 0 после rounding. Bug reports: tests/e2e/reports/bugs/bug-00[1-4]-*.md (github-issue format). Multi-tenant attacks (cat 3): clean — все cross-org GET/PUT/DELETE дают 404, bulk-update affected=0, lists не утекают. Auth-edge (cat 2): clean — JWT tampering 401, garbage 401, CORS evil.com не получает allow-origin, fake refresh 400 invalid_grant. DOS (cat 7): clean — 50MB body 413, 200 headers 431, long URL 200. Hangfire safety (cat 8): clean — regular Admin → /hangfire 403, seed-demo использует tenant context, body org-id игнорируется. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2.5 KiB
Bug #004 — Очень малая цена (0.0000001) тихо округляется в 0
Severity: Low (бизнес-логика, не security)
Component: ProductsController.Create/Update/UpdatePrice/BulkUpdate.price-adjust
Found: Sprint 23, 2026-06-08
Воспроизведение
POST /api/catalog/products { ..., "prices": [{"priceTypeId":"…", "amount": 0.0000001, "currencyId": "…"}] }
Ответ: 201 Created. Перечитываем — price.amount = 0.0.
Контроллер делает RoundIfNeeded(0.0000001, allowFractional=true) =
Math.Round(0.0000001, 2) = 0.00. БД-колонка decimal(18,4) сохраняет 0.
В FindMissingRequiredPriceAsync проверка price.Amount <= 0m происходит
ДО округления — 0.0000001 > 0, проходит. После округления — 0, но
required-price-check уже не повторяется.
Ожидание
Один из вариантов:
- Validator отвергает: «цена меньше 0.01 — введите как минимум 0.01».
- Округлять ДО проверки required.
- Документировать: «цены округляются до настроек организации; 0.0000001 → 0».
Фикс
Выбираем (2) — повторно проверить после RoundIfNeeded, чтобы required
price check учитывал то что реально окажется в БД.
foreach (var pr in input.Prices ?? []) {
var rounded = RoundIfNeeded(pr.Amount, allowFractional);
if (rounded < 0m) return BadRequest(...);
// Если PriceType.IsRequired — округлённое значение должно быть > 0.
}
// Перевалидируем required AFTER rounding:
if (await FindMissingRequiredPriceAsync(/* rounded */, ct) is { } missing) return BadRequest(...);
Проще: добавить в FindMissingRequiredPriceAsync округление перед сравнением
(или дополнить проверку в начале Create/Update после Apply).
Severity rationale
Бизнес-проблема (товар с нулевой ценой не попадёт на чек правильно — а "required" type'ы существуют именно чтобы гарантировать НЕ-нулевую цену), но эксплоит ограничен авторизованным admin'ом. Не критично, но фиксим.