What it is
A business logic flaw is when the attacker uses a feature exactly as written, in a way the designer never planned for. There is no payload, no special character, nothing to escape — just a sequence of legitimate operations reaching a wrong outcome.
Why you should care
This is the family no tool can find, and the reason is fundamental rather than technical: for a scanner to find it, the scanner would have to know which outcome is correct, and that lives in the product owner's head, not in the code.
Three properties that set it apart from every other topic:
- There is no general fix. SQL injection has one answer (parameterise). Logic flaws have as many fixes as they have variants, and each fix is a business decision.
- It is not a bug in the code. The code does exactly what it was written to do. What is wrong is an assumption nobody wrote down: "quantities are always positive", "nobody changes the price after adding to cart", "step 3 is only reachable from step 2".
- It is the most expensive family in money terms. It lives precisely where transactions are: prices, discounts, refunds, limits, approval workflows.
Which means defence here is not a programming technique but two practices: turning invariants into executable code, and testing by deliberately taking the wrong path. It is also the only topic where core applies to PM and CTO as much as to Dev — because most wrong assumptions are created while writing requirements, not while writing code.
How the attack works
There is no single mechanism. But almost every real logic flaw falls into one of five patterns, and knowing the five is the only way to hunt them systematically.
flowchart TD A["Designed flow:<br/>cart → checkout → pay → ship"] --> P1 P1["① Skip a step<br/>call /ship directly"] --> X["Wrong outcome"] P2["② Trust client data<br/>price=1 in the body"] --> X P3["③ Out-of-domain value<br/>quantity=-5 → refund"] --> X P4["④ Repeat past the limit<br/>apply the discount 10×"] --> X P5["⑤ Unstated assumption<br/>change price AFTER the check"] --> X| Pattern | The question that finds it | Concrete example |
|---|---|---|
| ① Skip a step | Does step N verify step N−1 completed? | POST /orders/7/ship without paying |
| ② Trust client data | Could the server compute this value itself? | {"price": 1} in the cart body |
| ③ Out-of-domain value | What about negative, zero, enormous? | quantity: -5 → negative total → refund |
| ④ Repeat past the limit | Is anybody counting? | Applying the same discount 10× to one order |
| ⑤ Unstated assumption | What is being taken as obvious? | Changing the product price after the check ran |
Pattern ② deserves its own note because it is both the most common and the easiest to spot: any value the server could compute but instead accepts from the client is a waiting hole. Price, tax, shipping, discount amount, userId, role, isAdmin, total. The one-line test question: "if the client sends a different value, would the server notice?"
Pattern ③ has a subtle variant: integer overflow. quantity: 2147483647 plus one wraps negative in an int, and price × quantity overflows into a small total. This is why value domains need an upper bound too, not just a floor.
Diagram description: The diagram shows the designed flow of cart then checkout then pay then ship, and five attack patterns all reaching the same wrong outcome: skipping a step by calling the ship endpoint directly; trusting client data by sending price=1 in the body; sending an out-of-domain value such as quantity=-5 so the total goes negative and becomes a refund; repeating past the limit by applying a discount ten times; and exploiting an unstated assumption by changing the price after the validation step already ran.
Concrete example
One checkout flow, four wrong paths — and every request is a legitimate request.
# ② Trusting client data. The price is in the body, and the server uses it.POST /api/cart/items HTTP/1.1{"productId":"iphone-16-pro","quantity":1,"unitPrice":1} HTTP/1.1 200 OK{"cartTotal":"1","currency":"VND"}# ③ Out-of-domain value. Nobody wrote "quantity must be > 0" as code.POST /api/cart/items HTTP/1.1{"productId":"airpods","quantity":-3} HTTP/1.1 200 OK{"cartTotal":"-8970000"} ← paying a negative total is a refund# ① Skipping a step. This endpoint has [Authorize], and that is all it checks.POST /api/orders/8821/ship HTTP/1.1 HTTP/1.1 200 OK{"status":"Shipped","trackingCode":"VN881…"} ← nobody paid# ⑤ Unstated assumption: the price is validated at checkout, then re-read at capture.POST /api/checkout/8821 → 200, total = 32,000,000PATCH /api/cart/items/1 → {"quantity": 1} (down from 10)POST /api/checkout/8821/capture → 200, charged 3,200,000, shipped 10 unitsNot one of those requests contains anything a WAF could see.
// ❌ Mẫu ② — DTO đầu vào chứa UnitPrice. Server BIẾT giá; nó nằm trong bảng product.// Nhận nó từ client nghĩa là client quyết định giá, và không có phép kiểm nào// ở dưới sửa được điều đó — vì không có gì để so sánh với.public record AddToCartRequest(string ProductId, int Quantity, decimal UnitPrice); [HttpPost("/api/cart/items")]public async Task<IActionResult> AddItem([FromBody] AddToCartRequest req, CancellationToken ct){ var cart = await _carts.GetForUserAsync(_currentUser.UserId, ct); // ❌ Mẫu ③ — không có sàn và không có trần. Quantity = -3 cho tổng âm; // Quantity = 2147483647 tràn int khi nhân và cũng cho tổng âm. // Không ai viết "quantity > 0" ra thành code, vì nó "hiển nhiên". cart.Items.Add(new CartItem(req.ProductId, req.Quantity, req.UnitPrice)); cart.Total = cart.Items.Sum(i => i.Quantity * i.UnitPrice); await _carts.SaveAsync(cart, ct); return Ok(new { cartTotal = cart.Total });} [HttpPost("/api/orders/{id:long}/ship")]public async Task<IActionResult> Ship(long id, CancellationToken ct){ var order = await _orders.GetByIdAsync(id, _currentUser.UserId, ct); if (order is null) return NotFound(); // ❌ Mẫu ① — [Authorize] đã chạy và ownership đã kiểm, nên đoạn này TRÔNG như // đã đủ. Nhưng không có gì kiểm đơn đã được TRẢ TIỀN: thứ tự các bước chỉ // tồn tại trong thứ tự mà UI gọi endpoint. order.Status = OrderStatus.Shipped; order.TrackingCode = await _shipping.CreateLabelAsync(order, ct); await _orders.SaveAsync(order, ct); return Ok(new { status = order.Status, trackingCode = order.TrackingCode });}app.post("/api/checkout", async (req, res) => { const { items, total } = req.body; // ❌ Mẫu ② — total tới từ client. Server tính được nó, nên nhận nó nghĩa là // client quyết định số tiền bị trừ. const charge = await stripe.charges.create({ amount: total, currency: "vnd" }); const order = await db.orders.create({ userId: req.user.id, items, total }); // ❌ Mẫu ① — không có gì kiểm charge đã thành công. charge.status có thể là // "pending" hoặc "failed", và đơn vẫn sang Paid. await db.orders.update(order.id, { status: "paid" }); return res.json({ orderId: order.id });});What happened in the wild
Starbucks, 2015 (Egor Homakov) — transferring a balance between two gift cards concurrently created money from nothing. Pattern ⑤ plus a race condition: the assumption "one transfer debits once" was never written as a constraint.
Negative-price bugs in e-commerce, a recurring pattern. Many public bug bounty reports (HackerOne) describe the same thing: a negative quantity makes the total negative, and the payment gateway treats a negative total as a refund. Memorable because it is pattern ③ at its simplest, and it still appears every year — not because it is hard to fix, but because nobody wrote "quantity > 0" as an invariant.
Uber, 2017 — free rides via a payment account that did not exist. The flow let you pick a payment method then confirm, and a paymentProfileUUID not belonging to the user was accepted. Patterns ② and ① meeting: trusting an id from the client, and a confirm step that never verified the selection step was valid.
How to defend
The server recomputes every value it can compute
mandatoryThis is the fix for pattern ② and it has the best effect-per-effort ratio, because pattern ② is the most common.
The one-line rule: if the server can compute it, the server must not accept it from the client. The client sends productId and quantity; price, tax, shipping, discount amount and total are computed server-side. There is no price field in the input DTO — not "ignored if present", but absent, so a wrong binding cannot happen once.
This is also exactly the mass-assignment fix (see the access-control topic): the input DTO is separate from the entity, and contains only the fields the client genuinely gets to decide.
A quick self-check on any endpoint: read the input DTO and for each field ask "could the server know this value itself?". Every "yes" is a field to delete.
// ── Mẫu ② · DTO đầu vào chỉ có thứ client thật sự được quyết ────────────────//// UnitPrice KHÔNG có ở đây, và điều quan trọng là nó không tồn tại chứ không phải// bị bỏ qua: một trường "bị bỏ qua" chỉ cách một lần refactor với việc được bind lại.public record AddToCartRequest(string ProductId, int Quantity); // ── Mẫu ③ · Miền giá trị là một KIỂU, không phải một câu if ─────────────────/// <summary>/// Một Quantity không hợp lệ KHÔNG TỒN TẠI được trong domain. Đó là khác biệt giữa/// một phép kiểm (có thể quên gọi) và một bất biến (không có đường đi vòng).////// Trần cũng bắt buộc như sàn: 2_147_483_647 * unitPrice tràn int/decimal và cho ra/// một tổng nhỏ hoặc âm — cùng hậu quả với số âm, qua một cửa khác./// </summary>public readonly record struct Quantity{ public const int MaxPerLine = 100; public int Value { get; } private Quantity(int value) => Value = value; public static Quantity Create(int value) => value is < 1 or > MaxPerLine ? throw new ApplicationGeneralException(OrderErrorsList.INVALID_QUANTITY, $"Quantity must be between 1 and {MaxPerLine}") : new Quantity(value);} [HttpPost("/api/cart/items")]public async Task<IActionResult> AddItem([FromBody] AddToCartRequest req, CancellationToken ct){ var quantity = Quantity.Create(req.Quantity); // ném lỗi ngay ở biên // Giá do SERVER tra ra. Không có đường nào để client ảnh hưởng tới con số này. var product = await _products.GetAsync(req.ProductId, ct) ?? throw new NotFoundException(CatalogErrorsList.PRODUCT_NOT_FOUND); var cart = await _carts.GetForUserAsync(_currentUser.UserId, ct); cart.AddItem(product.Id, quantity, product.CurrentPrice); // bất biến trong aggregate await _carts.SaveAsync(cart, ct); return Ok(new { cartTotal = cart.Total });} // ── Mẫu ① · Máy trạng thái tường minh, TRONG aggregate ─────────────────────public sealed class Order : AggregateRoot{ private static readonly Dictionary<OrderStatus, OrderStatus[]> Allowed = new() { [OrderStatus.Draft] = [OrderStatus.AwaitingPayment, OrderStatus.Cancelled], [OrderStatus.AwaitingPayment] = [OrderStatus.Paid, OrderStatus.Cancelled], [OrderStatus.Paid] = [OrderStatus.Shipped, OrderStatus.Refunded], [OrderStatus.Shipped] = [OrderStatus.Delivered, OrderStatus.Returned], [OrderStatus.Delivered] = [OrderStatus.Returned], [OrderStatus.Cancelled] = [], [OrderStatus.Refunded] = [], }; public OrderStatus Status { get; private set; } /// <summary> /// Bất biến ở đây, không ở controller. Controller là MỘT đường vào — còn có job /// nền, admin panel, webhook của cổng thanh toán, và một lệnh CLI dọn dữ liệu. /// Một bất biến chỉ đúng ở một đường vào là một bất biến giả. /// </summary> private void TransitionTo(OrderStatus next, DateTime now) { if (!Allowed[Status].Contains(next)) throw new ApplicationGeneralException(OrderErrorsList.INVALID_TRANSITION, $"Cannot go from {Status} to {next}"); Status = next; UpdatedAt = now; } public void Ship(string trackingCode, DateTime now) { TransitionTo(OrderStatus.Shipped, now); // Draft → Shipped bị từ chối ở đây TrackingCode = trackingCode; } // ── Mẫu ⑤ · Đóng băng giá lúc checkout ───────────────────────────────── /// <summary> /// Sao chép giá và số lượng vào order_line BẤT BIẾN. Sau bước này, sửa giỏ hàng /// không ảnh hưởng gì tới đơn — nên cửa sổ "đổi giá giữa checkout và capture" /// không còn tồn tại. /// /// Và nó trả lời được câu "đơn này lúc mua giá bao nhiêu" sáu tháng sau, thứ mà /// một đơn đọc lại giá từ bảng product không bao giờ trả lời đúng. /// </summary> public void Checkout(Cart cart, DateTime now) { if (cart.Items.Count == 0) throw new ApplicationGeneralException(OrderErrorsList.EMPTY_CART); _lines.Clear(); foreach (var item in cart.Items) _lines.Add(OrderLine.Snapshot(item.ProductId, item.Quantity, item.UnitPriceAtAdd)); Total = _lines.Sum(l => l.Quantity.Value * l.UnitPrice); TransitionTo(OrderStatus.AwaitingPayment, now); }}import { z } from "zod"; // Miền giá trị ở BIÊN. Không có trường total, không có trường price — server tính// cả hai. Client chỉ được quyết productId và quantity.const CheckoutBody = z.object({ items: z.array(z.object({ productId: z.string().min(1).max(64), // Sàn VÀ trần. Thiếu trần thì Number.MAX_SAFE_INTEGER làm tổng mất chính xác. quantity: z.number().int().min(1).max(100), })).min(1).max(50),}); app.post("/api/checkout", async (req, res) => { const parsed = CheckoutBody.safeParse(req.body); if (!parsed.success) return res.status(422).json({ error: "invalid_body" }); // Tính lại tổng từ giá TRONG DB. Đây là dòng đóng mẫu ②. const products = await db.products.findMany({ where: { id: { in: parsed.data.items.map((i) => i.productId) } }, }); const priceOf = new Map(products.map((p) => [p.id, p.currentPrice])); let total = 0n; for (const item of parsed.data.items) { const price = priceOf.get(item.productId); if (price === undefined) return res.status(404).json({ error: "product_not_found" }); // BigInt cho tiền, không float: 0.1 + 0.2 !== 0.3, và sai số tích lũy là một // lỗi logic mà không ai gọi là lỗ hổng cho tới ngày đối soát. total += BigInt(price) * BigInt(item.quantity); } // Đơn được tạo ở trạng thái chờ, KÈM tổng đã đóng băng — không phải trạng thái paid. const order = await db.orders.create({ userId: req.user.id, status: "awaiting_payment", total: total.toString(), lines: parsed.data.items.map((i) => ({ ...i, unitPrice: priceOf.get(i.productId)! })), }); const charge = await stripe.charges.create({ amount: Number(total), currency: "vnd", idempotencyKey: `order-${order.id}`, // retry không tạo lần trừ tiền thứ hai }); // Trạng thái đi theo KẾT QUẢ THẬT, không theo việc lời gọi đã trả về. if (charge.status !== "succeeded") { await db.orders.update(order.id, { status: "payment_failed" }); return res.status(402).json({ error: "payment_failed" }); } await db.orders.update(order.id, { status: "paid" }); return res.json({ orderId: order.id, total: total.toString() });});An explicit state machine — step N verifies step N−1
mandatoryThe fix for pattern ①. The problem is not a missing [Authorize] — the /ship endpoint has [Authorize], and it still ships unpaid orders. The problem is that nothing represents "which step this order is at" except the order in which the UI happens to call the endpoints.
How: a status column with the legal transitions written out as code, and every action asking the aggregate rather than deciding for itself:
public void Ship(DateTime now){ // Not an if in the controller — the invariant lives in the aggregate, so EVERY // entry point goes through it, including a background job written later. if (Status != OrderStatus.Paid) throw new ApplicationGeneralException( OrderErrorsList.INVALID_TRANSITION, $"Cannot ship an order in {Status}"); Status = OrderStatus.Shipped;}The important part: the invariant lives in the aggregate, not the controller. The controller is one of several entry points (there are also background jobs, an admin panel, a payment webhook, a data-cleanup CLI), and an invariant enforced at one entry point is not an invariant.
Value domains with a floor AND a ceiling, enforced by the type not an if
mandatoryThe fix for pattern ③. Two halves, and the second is the one usually skipped:
- The floor:
quantity > 0,amount > 0,discountPercent between 0 and 100. - The ceiling:
quantity <= 100. Without it,quantity = 2147483647overflows aninton multiply, and the total comes out small or negative — the same outcome as a negative number, through a different door.
And where you enforce it decides whether it survives:
- FluentValidation on the input DTO rejects at the boundary and gives a decent error message.
- But the real invariant belongs in a value object or aggregate:
Quantity.Create(n)throws for n ≤ 0, so an invalidQuantitycannot exist in the domain. That is the difference between a check and an invariant. - Plus a DB
CHECKas the net, for the write path nobody considered (imports, migrations, jobs).
For money: decimal, never float. And a Money value object carrying its currency — adding two amounts in different currencies is a logic bug the type system can stop.
Re-read and freeze state at the deciding step
The fix for pattern ⑤. The bug in the block 4 example is: the price is validated at checkout then re-read at capture, and between those two steps the client can edit the cart.
Two approaches, chosen by business need:
- Freeze it: the
checkoutstep copies price and quantity into an immutableorder_line.capturethen reads onlyorder_lineand never re-reads the cart. Editing the cart afterwards changes nothing. This is right for most e-commerce, and it is also the only way to answer "what did this cost at purchase time" six months later. - Re-read and compare:
capturerecomputes the total and compares it to the quoted one. Mismatch aborts. Correct when prices genuinely can change, but it needs a business decision about who absorbs the difference.
And for concurrency: lock the row or use optimistic concurrency (xmin/rowversion) so two flows cannot capture one order — see the race-conditions topic.
Business invariants as DB constraints and reconciliation queries
mandatoryThis layer catches what layer 1 will miss: a new write path, a data import, a background job, or an endpoint written after this page existed.
-- Invariants written as constraints. The database does not forget and grants no exceptions.ALTER TABLE core.order_line ADD CONSTRAINT ck_qty_positive CHECK (quantity BETWEEN 1 AND 100);ALTER TABLE core.order_line ADD CONSTRAINT ck_price_nonneg CHECK (unit_price >= 0);ALTER TABLE core."order" ADD CONSTRAINT ck_total_nonneg CHECK (total >= 0); -- And a nightly reconciliation: every row returned is a broken invariant.SELECT o.id, o.total, sum(l.quantity * l.unit_price) AS computed FROM core."order" o JOIN core.order_line l ON l.order_id = o.id GROUP BY o.id, o.totalHAVING o.total <> sum(l.quantity * l.unit_price);The reconciliation query is the most important detection here, because logic flaws leave no error: no exception, no unusual log line, the data looks normal. The only thing that finds them is two numbers that should agree and do not.
Write the assumptions down at design time — the cheapest and earliest control
Block 2 argues most wrong assumptions are created while writing requirements. So the highest-yield control is not in the code: it is an "invariants" section in every feature document.
Three questions, asked before the first line of code:
- Which values here could the server compute itself? → pattern ②
- Which states are legal, and which step is reachable from which? → pattern ①
- Which values are out of domain — negative, zero, enormous, empty, duplicate? → patterns ③ and ④
Lightweight threat modeling (STRIDE, one page) over the money-touching flows is where those three get asked. See the threat-modeling topic. Layer 3 because it blocks nothing at runtime — but it is the only control acting on the cause, and an assumption written down at design time is roughly two orders of magnitude cheaper than an invariant discovered in production.
Verifying the fix
This is the topic where the verification method is a way of thinking, not a tool. There is no scanner here, and that is the point.
1. For each money-touching flow, write one test per pattern from block 3. Five patterns, five tests. The important part is that they are business negative tests, not validation tests: the assertion is not "returns 400" but "the money did not change" and "the order did not reach Shipped". See the csharp / test tab.
2. Grep for pattern ② in input DTOs — the only feasible automated check on this topic:
# A field the server can compute must not appear in an input DTO.grep -rnE 'record .*(Request|Command|Dto)\(' -A15 --include='*.cs' src/ \ | grep -iE 'decimal +(price|total|amount|discount|tax|fee)|bool +is(Admin|Paid|Verified)|Role +role' \ && { echo "input DTO carries a server-computable field — review it"; exit 1; }exit 03. Test the state machine as a complete table, not a few cases. With N states and M actions, write a [Theory] covering all N×M combinations and assert whether each cell is legal. This is the only way to catch the cell nobody thought about — and the cell nobody thought about is the definition of pattern ①.
4. The layer-2 reconciliation query belongs in CI, run against the data left by the integration tests. If some test leaves total disagreeing with sum(line), that is a logic bug, and this query is what sees it.
5. Check the DB constraints still exist — they live in migrations, so another migration can drop them:
SELECT conname FROM pg_constraint WHERE conrelid = 'core.order_line'::regclass AND contype = 'c';-- must include ck_qty_positive and ck_price_nonneg6. And the check that cannot be automated: a person reads the flow and tries to go wrong. Nothing replaces it. But it has structure: walk the five patterns from block 3, one flow at a time, and write down the answers. Thirty minutes on a checkout flow finds more than every tool on this list.
public class CheckoutLogicTests : IClassFixture<ApiFixture>{ private readonly ApiFixture _fx; public CheckoutLogicTests(ApiFixture fx) => _fx = fx; /// <summary> /// Mẫu ② — tin dữ liệu client. Test gửi unitPrice VÀO BODY dù DTO không có /// trường đó: nếu ai đó thêm nó lại trong một lần refactor, test này đỏ. /// /// Khẳng định là GIÁ THẬT, không phải mã trạng thái: một bản vá trả 400 cũng /// pass "Assert.BadRequest", nhưng chỉ bản vá đúng làm tổng bằng giá catalogue. /// </summary> [Fact] public async Task Client_supplied_price_is_ignored_entirely() { await _fx.SeedProductAsync("iphone-16-pro", price: 32_000_000m); var res = await _fx.ClientAs(_fx.Bob).PostAsJsonAsync("/api/cart/items", new { productId = "iphone-16-pro", quantity = 1, unitPrice = 1, // trường không có trong DTO — cố tình gửi }); res.EnsureSuccessStatusCode(); var cart = await res.Content.ReadFromJsonAsync<CartDto>(); Assert.Equal(32_000_000m, cart!.CartTotal); // giá catalogue, không phải 1 } /// <summary> /// Mẫu ③ — miền giá trị. Cả sàn và TRẦN, và trần là nửa hay bị bỏ: /// int.MaxValue tràn khi nhân và cho ra tổng âm mà không cần số âm nào. /// </summary> [Theory] [InlineData(-3)] [InlineData(0)] [InlineData(101)] [InlineData(int.MaxValue)] public async Task Quantity_outside_its_domain_is_rejected(int quantity) { await _fx.SeedProductAsync("airpods", price: 2_990_000m); var res = await _fx.ClientAs(_fx.Bob).PostAsJsonAsync("/api/cart/items", new { productId = "airpods", quantity, }); Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode); // Và khẳng định thật sự quan trọng: giỏ hàng KHÔNG bị chạm tới. Một bản vá // trả 400 sau khi đã ghi vào DB vẫn pass mọi khẳng định về status code. var cart = await _fx.GetCartAsync(_fx.Bob); Assert.Empty(cart.Items); Assert.Equal(0m, cart.Total); } /// <summary> /// Mẫu ① — bỏ bước. Bảng ĐẦY ĐỦ, không phải vài trường hợp: với N trạng thái và /// M hành động, phủ hết N×M là cách duy nhất bắt được ô mà không ai nghĩ tới — /// và "ô không ai nghĩ tới" chính là định nghĩa của mẫu này. /// </summary> [Theory] [InlineData(OrderStatus.Draft, "ship", false)] [InlineData(OrderStatus.AwaitingPayment, "ship", false)] [InlineData(OrderStatus.Paid, "ship", true)] [InlineData(OrderStatus.Shipped, "ship", false)] // không giao hai lần [InlineData(OrderStatus.Cancelled, "ship", false)] [InlineData(OrderStatus.Refunded, "ship", false)] [InlineData(OrderStatus.Draft, "refund", false)] [InlineData(OrderStatus.Paid, "refund", true)] [InlineData(OrderStatus.Refunded, "refund", false)] // không hoàn hai lần public async Task Only_legal_transitions_are_accepted( OrderStatus from, string action, bool shouldSucceed) { var order = await _fx.SeedOrderInStateAsync(_fx.Bob, from); var res = await _fx.ClientAs(_fx.Bob).PostAsync($"/api/orders/{order.Id}/{action}", null); if (shouldSucceed) res.EnsureSuccessStatusCode(); else { Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode); // Trạng thái KHÔNG đổi — đây là khẳng định về nghiệp vụ, không về HTTP. Assert.Equal(from, (await _fx.GetOrderAsync(order.Id)).Status); } } /// <summary> /// Mẫu ⑤ — giả định ngầm. Đây là test khó nghĩ ra nhất và đáng giá nhất: nó đi /// đúng chuỗi thao tác hợp lệ mà không ai dự tính, và mọi request trong đó đều 200. /// </summary> [Fact] public async Task Editing_the_cart_after_checkout_does_not_change_the_order() { await _fx.SeedProductAsync("iphone-16-pro", price: 32_000_000m); var client = _fx.ClientAs(_fx.Bob); await client.PostAsJsonAsync("/api/cart/items", new { productId = "iphone-16-pro", quantity = 10 }); var order = await (await client.PostAsync("/api/checkout", null)) .Content.ReadFromJsonAsync<OrderDto>(); // Hợp lệ hoàn toàn: người dùng sửa giỏ hàng. Và đó là điểm — nó KHÔNG được // ảnh hưởng tới một đơn đã checkout. await client.PatchAsJsonAsync("/api/cart/items/1", new { quantity = 1 }); var after = await _fx.GetOrderAsync(order!.Id); Assert.Equal(320_000_000m, after.Total); // 10 máy, giá đã đóng băng Assert.Equal(10, after.Lines.Single().Quantity); } /// <summary> /// Mẫu ④ — lặp quá số lần. Xem topic race-conditions cho phiên bản ĐỒNG THỜI; /// đây là phiên bản tuần tự, và nó bắt được lỗi "không ai đếm". /// </summary> [Fact] public async Task Same_coupon_cannot_be_applied_twice_to_one_order() { var order = await _fx.SeedOrderInStateAsync(_fx.Bob, OrderStatus.Draft, total: 10_000_000m); await _fx.SeedCouponAsync("SAVE50", percent: 50, maxUses: 1); var client = _fx.ClientAs(_fx.Bob); var first = await client.PostAsJsonAsync($"/api/orders/{order.Id}/coupon", new { code = "SAVE50" }); var second = await client.PostAsJsonAsync($"/api/orders/{order.Id}/coupon", new { code = "SAVE50" }); first.EnsureSuccessStatusCode(); Assert.Equal(HttpStatusCode.UnprocessableEntity, second.StatusCode); Assert.Equal(5_000_000m, (await _fx.GetOrderAsync(order.Id)).Total); // giảm MỘT lần } /// <summary> /// Bất biến toàn cục, chạy sau mọi test khác: total của đơn luôn khớp tổng các /// dòng. Đây là query đối soát ở lớp 2, đặt vào CI — và nó bắt được lỗi logic mà /// không test nào ở trên nghĩ tới, vì lỗi logic không để lại exception nào. /// </summary> [Fact] public async Task Order_totals_always_match_the_sum_of_their_lines() { var mismatched = await _fx.QueryAsync<(long Id, decimal Total, decimal Computed)>(""" SELECT o.id, o.total, sum(l.quantity * l.unit_price) FROM core."order" o JOIN core.order_line l ON l.order_id = o.id GROUP BY o.id, o.total HAVING o.total <> sum(l.quantity * l.unit_price) """); Assert.Empty(mismatched); }}Common mistakes
| The "fix" | Why it is wrong |
|---|---|
Add quantity > 0 validation on the reported endpoint | Closes one cell. discountPercent, amount, points and the ceiling are all still missing |
| Enforce invariants in the controller | The controller is ONE entry point. Background jobs, the admin panel, the payment webhook and the cleanup CLI do not pass through it |
Ignore the price field if the client sends it | The field is still in the DTO, so one refactor that binds it again is enough. It must NOT EXIST |
| A floor with no ceiling | quantity = 2147483647 overflows an int on multiply and yields a small or negative total — same outcome, different door |
| Validate the price at checkout, re-read it at capture | That is pattern ⑤. The client edits the cart in between |
Use float for money | 0.1 + 0.2 != 0.3. Accumulated rounding error is a logic bug nobody calls a vulnerability until reconciliation day |
| Hide the button in the UI | The UI is not where rules are enforced. The attacker calls the API |
| Rely on a scanner | A scanner would have to know which outcome is CORRECT. It does not know your business |
The biggest mistake on this topic is classification: treating logic flaws as "bugs" rather than "vulnerabilities". They land in the product backlog at medium priority instead of the security incident process — while their financial impact is usually larger than an XSS.
The scoping mistake: fixing the reported variant. If a negative quantity got through, then every numeric field across the whole API needs the same question asked — because the cause was not a missing line, it was that nobody had written the value domain down.
Comments
Commenting needs an account with at least one completed lesson. That condition is what keeps this thread worth reading: every point belongs to someone who can be asked back, and reputation accrues over time.
You can still read every comment below without an account. Signing in brings you back to this exact spot, not to the top of the page.
Loading comments…