Là gì
Lỗ hổng logic nghiệp vụ là khi kẻ tấn công dùng tính năng đúng như nó được viết, theo một cách mà người thiết kế không dự tính. Không có payload, không có ký tự đặc biệt, không có gì để escape — chỉ là một chuỗi thao tác hợp lệ dẫn tới một kết quả sai.
Vì sao bạn quan tâm
Đây là họ lỗ hổng mà không công cụ nào tìm được, và lý do là căn bản chứ không phải kỹ thuật: một scanner muốn tìm nó thì phải biết kết quả nào là đúng, và điều đó nằm trong đầu người làm sản phẩm, không nằm trong code.
Ba đặc điểm khiến nó khác mọi topic khác:
- Không có bản vá chung. SQL injection có một câu trả lời (tham số hoá). Lỗi logic có bao nhiêu biến thể thì có bấy nhiêu bản vá, và mỗi bản vá là một quyết định nghiệp vụ.
- Nó không phải bug trong code. Code chạy đúng những gì nó được viết. Chỗ sai nằm ở giả định chưa ai viết ra: "số lượng luôn dương", "người ta không đổi giá sau khi thêm vào giỏ", "bước 3 chỉ tới được từ bước 2".
- Nó là họ lỗi đắt nhất về tiền. Nó nằm đúng chỗ có giao dịch: giá, giảm giá, hoàn tiền, hạn mức, quy trình duyệt.
Nghĩa là phòng chống ở đây không phải một kỹ thuật lập trình mà là hai việc: viết bất biến ra thành code chạy được, và test bằng cách cố tình đi sai đường. Đây cũng là topic duy nhất mà core cho vai PM và CTO ngang với vai Dev — vì phần lớn giả định sai được tạo ra lúc viết yêu cầu, không lúc viết code.
Cơ chế hoạt động
Không có một cơ chế duy nhất. Nhưng gần như mọi lỗi logic thật đều rơi vào một trong năm mẫu, và biết năm mẫu này là cách duy nhất để đi tìm chúng có hệ thống.
flowchart TD A["Luồng thiết kế:<br/>giỏ hàng → thanh toán → trả tiền → giao"] --> P1 P1["① Bỏ bước<br/>gọi thẳng /ship"] --> X["Kết quả sai"] P2["② Tin dữ liệu client<br/>price=1 trong body"] --> X P3["③ Giá trị ngoài miền<br/>quantity=-5 → hoàn tiền"] --> X P4["④ Lặp quá số lần<br/>áp mã giảm giá 10 lần"] --> X P5["⑤ Giả định ngầm<br/>đổi giá SAU khi đã kiểm"] --> X| Mẫu | Câu hỏi đi tìm nó | Ví dụ cụ thể |
|---|---|---|
| ① Bỏ bước | Bước N có kiểm bước N−1 đã xong chưa? | POST /orders/7/ship không qua thanh toán |
| ② Tin dữ liệu client | Giá trị này server tính được không? | {"price": 1} trong body giỏ hàng |
| ③ Giá trị ngoài miền | Số âm, số 0, số rất lớn thì sao? | quantity: -5 → tổng tiền âm → hoàn tiền |
| ④ Lặp quá số lần | Có ai đếm số lần không? | Áp cùng mã giảm giá 10 lần cho một đơn |
| ⑤ Giả định ngầm | Điều gì được coi là hiển nhiên? | Đổi giá sản phẩm sau khi đã qua bước kiểm |
Mẫu ② đáng nói riêng vì nó là mẫu phổ biến nhất và dễ nhận ra nhất: bất kỳ giá trị nào server tính được mà lại nhận từ client là một lỗ hổng đang chờ. Giá, thuế, phí vận chuyển, mức giảm giá, userId, role, isAdmin, total. Câu hỏi kiểm tra một dòng: "nếu client gửi giá trị khác thì server có phát hiện được không?"
Mẫu ③ có một biến thể tinh vi: tràn số. quantity: 2147483647 cộng thêm 1 thành số âm trong int, và price × quantity tràn thành một tổng nhỏ. Đây là lý do miền giá trị phải có cả trần, không chỉ có sàn.
Mô tả sơ đồ: Sơ đồ cho thấy luồng thiết kế giỏ hàng rồi thanh toán rồi trả tiền rồi giao hàng, và năm mẫu tấn công đều dẫn tới cùng một kết quả sai: bỏ bước bằng cách gọi thẳng endpoint giao hàng; tin dữ liệu client bằng cách gửi price=1 trong body; gửi giá trị ngoài miền như quantity=-5 để tổng tiền âm và thành hoàn tiền; lặp quá số lần bằng cách áp mã giảm giá mười lần; và lợi dụng giả định ngầm bằng cách đổi giá sau khi bước kiểm đã chạy.
Ví dụ cụ thể
Một luồng thanh toán, bốn cách đi sai — và mọi request đều là request hợp lệ.
# ② Tin dữ liệu client. Giá nằm trong body, và server dùng nó.POST /api/cart/items HTTP/1.1{"productId":"iphone-16-pro","quantity":1,"unitPrice":1} HTTP/1.1 200 OK{"cartTotal":"1","currency":"VND"}# ③ Giá trị ngoài miền. Không ai viết "quantity phải > 0" ra thành code.POST /api/cart/items HTTP/1.1{"productId":"airpods","quantity":-3} HTTP/1.1 200 OK{"cartTotal":"-8970000"} ← thanh toán một tổng âm = hoàn tiền# ① Bỏ bước. Endpoint này có [Authorize], và đó là tất cả những gì nó kiểm.POST /api/orders/8821/ship HTTP/1.1 HTTP/1.1 200 OK{"status":"Shipped","trackingCode":"VN881…"} ← chưa ai trả tiền# ⑤ Giả định ngầm: giá được kiểm ở bước checkout, rồi đọc LẠI lúc capture.POST /api/checkout/8821 → 200, total = 32.000.000PATCH /api/cart/items/1 → {"quantity": 1} (từ 10 xuống 1)POST /api/checkout/8821/capture → 200, đã trừ 3.200.000, giao 10 máyKhông một request nào trong số đó có gì để một WAF nhìn thấy.
// ❌ 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 });});Chuyện đã xảy ra
Starbucks, 2015 (Egor Homakov) — chuyển số dư giữa hai thẻ quà tặng đồng thời tạo ra tiền từ không khí. Mẫu ⑤ cộng race condition: giả định "một lần chuyển thì trừ một lần" không được viết ra thành ràng buộc nào.
Lỗi giá âm trong e-commerce, mẫu lặp lại. Nhiều báo cáo bug bounty công khai (HackerOne) mô tả cùng một thứ: quantity âm làm tổng tiền âm, và cổng thanh toán xử lý tổng âm như một khoản hoàn tiền. Đáng nhớ vì nó là mẫu ③ ở dạng đơn giản nhất, và nó vẫn xuất hiện hằng năm — không phải vì khó vá, mà vì không ai viết "quantity > 0" ra thành một bất biến.
Uber, 2017 — đi xe miễn phí qua một tài khoản thanh toán không tồn tại. Luồng cho phép chọn phương thức thanh toán rồi mới xác nhận, và một paymentProfileUUID không thuộc về người dùng vẫn được nhận. Đây là mẫu ② và mẫu ① gặp nhau: tin một id từ client, và bước xác nhận không kiểm bước chọn đã hợp lệ chưa.
Cách phòng chống
Server tính lại mọi giá trị nó tính được
bắt buộcĐây là bản vá cho mẫu ② và nó là bản vá có tỉ lệ hiệu quả cao nhất, vì mẫu ② là mẫu phổ biến nhất.
Luật một dòng: nếu server tính được thì server không được nhận nó từ client. Client gửi productId và quantity; giá, thuế, phí vận chuyển, mức giảm giá và tổng tiền do server tính. Không có trường price nào trong DTO đầu vào — không phải "bỏ qua nếu có", mà là không tồn tại, để một lần bind sai không thể xảy ra.
Đây cũng chính là bản vá của mass assignment (xem topic access-control): DTO đầu vào tách khỏi entity, và nó chỉ chứa những trường mà client thật sự có quyền quyết định.
Cách tự kiểm nhanh trên một endpoint bất kỳ: đọc DTO đầu vào và với mỗi trường hỏi "server có tự biết giá trị này không?". Mỗi câu trả lời "có" là một trường phải bỏ đi.
// ── 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() });});Máy trạng thái tường minh — bước N kiểm bước N−1
bắt buộcBản vá cho mẫu ①. Vấn đề không phải là thiếu [Authorize] — endpoint /ship có [Authorize], và nó vẫn giao hàng chưa trả tiền. Vấn đề là không có gì biểu diễn "đơn này đang ở bước nào" ngoài thứ tự mà UI gọi các endpoint.
Cách làm: một cột status với các chuyển trạng thái hợp lệ được viết ra thành code, và mỗi hành động hỏi aggregate thay vì tự quyết:
public void Ship(DateTime now){ // Không phải if trong controller — bất biến sống trong aggregate, nên MỌI // đường vào đều đi qua nó, kể cả một job nội bộ viết sau này. if (Status != OrderStatus.Paid) throw new ApplicationGeneralException( OrderErrorsList.INVALID_TRANSITION, $"Cannot ship an order in {Status}"); Status = OrderStatus.Shipped;}Điểm quan trọng: bất biến nằm trong aggregate, không trong controller. Controller là một trong nhiều đường vào (còn có job nền, admin panel, webhook thanh toán, lệnh CLI dọn dữ liệu), và một bất biến chỉ đúng ở một đường vào là một bất biến giả.
Miền giá trị có sàn VÀ trần, kiểm ở kiểu chứ không ở câu if
bắt buộcBản vá cho mẫu ③. Hai nửa, và nửa thứ hai hay bị bỏ:
- Sàn:
quantity > 0,amount > 0,discountPercent between 0 and 100. - Trần:
quantity <= 100. Không có trần thìquantity = 2147483647trànintkhi nhân, và tổng tiền ra một số nhỏ hoặc âm — cùng hậu quả với số âm, qua một đường khác.
Và kiểm ở đâu quyết định nó có sống được không:
- FluentValidation trên DTO đầu vào chặn ở biên và cho thông báo lỗi tử tế.
- Nhưng bất biến thật phải ở value object hoặc aggregate:
Quantity.Create(n)ném lỗi khi n ≤ 0, nên mộtQuantitykhô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 và một bất biến. - Cộng một
CHECKở DB làm lưới cuối, cho đường ghi mà không ai nghĩ tới (import, migration, job).
Với tiền: decimal, không bao giờ float. Và một Money value object mang theo cả đơn vị tiền tệ — cộng hai số tiền khác loại tiền là một lỗi logic mà kiểu dữ liệu chặn được.
Đọc lại và khoá trạng thái ở bước quyết định
Bản vá cho mẫu ⑤. Lỗi ở ví dụ khối 4 là: giá được kiểm ở bước checkout rồi đọc lại ở bước capture, và giữa hai bước đó client sửa được giỏ hàng.
Hai cách, chọn theo nghiệp vụ:
- Đóng băng: bước
checkoutsao chép giá và số lượng vào mộtorder_linebất biến. Sau đócapturechỉ đọcorder_line, không bao giờ đọc lại giỏ hàng. Sửa giỏ hàng sau đó không ảnh hưởng gì. Đây là cách đúng cho phần lớn e-commerce, và nó cũng là cách duy nhất trả lời được câu "đơn này lúc mua giá bao nhiêu" sáu tháng sau. - Đọc lại rồi so:
capturetính lại tổng và so với tổng đã báo. Lệch thì abort. Đúng khi giá thật sự có thể đổi, nhưng nó cần một quyết định nghiệp vụ về việc ai chịu phần lệch.
Và với đồng thời: khoá hàng hoặc dùng optimistic concurrency (xmin/rowversion) để hai luồng không cùng capture một đơn — xem topic race-conditions.
Bất biến nghiệp vụ thành ràng buộc DB và query đối soát
bắt buộcLớp này bắt đúng cái lớp 1 sẽ bỏ sót: một đường ghi mới, một import dữ liệu, một job nền, hay một endpoint viết sau khi trang này đã có.
-- Bất biến, viết ra thành ràng buộc. DB không quên và không có ngoại lệ.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); -- Và một query đối soát chạy hằng đêm: mỗi dòng trả về là một bất biến đã bị phá.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);Query đối soát là biện pháp phát hiện quan trọng nhất của topic này, vì lỗi logic không để lại lỗi: không có exception, không có dòng log bất thường, dữ liệu trông bình thường. Thứ duy nhất phát hiện được nó là hai con số đáng lẽ phải khớp mà không khớp.
Viết giả định ra lúc thiết kế — đây là biện pháp rẻ nhất và sớm nhất
Khối 2 nói rằng phần lớn giả định sai được tạo ra lúc viết yêu cầu. Nên biện pháp có tỉ lệ hiệu quả cao nhất không nằm trong code: nó là một mục "bất biến" trong mọi tài liệu tính năng.
Ba câu hỏi, hỏi trước khi viết dòng code đầu tiên:
- Những giá trị nào ở đây server tự tính được? → mẫu ②
- Trạng thái nào là hợp lệ, và bước nào tới được từ bước nào? → mẫu ①
- Giá trị nào là ngoài miền — âm, 0, rất lớn, rỗng, trùng? → mẫu ③ và ④
Threat modeling nhẹ (STRIDE, một trang) trên các luồng có tiền là chỗ ba câu này được hỏi. Xem topic threat-modeling. Đây là lớp 3 vì nó không chặn gì ở runtime — nhưng nó là biện pháp duy nhất tác động vào nguyên nhân, và một giả định được viết ra lúc thiết kế rẻ hơn một bất biến phát hiện sau khi lên production khoảng hai bậc độ lớn.
Kiểm chứng đã vá
Đây là topic mà cách kiểm chứng chính là một cách suy nghĩ, không phải một công cụ. Không có scanner nào ở đây, và đó là điểm.
1. Với mỗi luồng có tiền, viết một test cho từng mẫu ở khối 3. Năm mẫu, năm test. Điểm quan trọng là chúng là negative test theo nghiệp vụ, không phải test validation: khẳng định không phải "trả 400" mà là "tiền không đổi" và "đơn không sang trạng thái Shipped". Xem tab csharp / test.
2. Grep tìm mẫu ② trong DTO đầu vào — phép kiểm tự động duy nhất khả thi ở topic này:
# Trường mà server tính được KHÔNG được có trong DTO đầu vào.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 "DTO đầu vào chứa trường server tự tính được — xem lại"; exit 1; }exit 03. Test máy trạng thái là một 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, viết [Theory] phủ tất cả N×M tổ hợp và khẳng định từng ô là hợp lệ hay không. Đây 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 ①.
4. Query đối soát ở lớp 2 phải chạy trong CI, trên dữ liệu sau khi test tích hợp đã chạy. Nếu một test nào đó làm total lệch khỏi sum(line) thì đó là một lỗi logic, và query này là thứ thấy nó.
5. Kiểm ràng buộc DB còn tồn tại — chúng sống trong migration nên một migration khác xoá được:
SELECT conname FROM pg_constraint WHERE conrelid = 'core.order_line'::regclass AND contype = 'c';-- phải có ck_qty_positive và ck_price_nonneg6. Và phép kiểm không tự động được: một người đọc luồng và cố đi sai. Không có cách nào thay thế nó. Nhưng nó có cấu trúc: đi qua năm mẫu ở khối 3, từng luồng một, và ghi lại câu trả lời. Ba mươi phút cho một luồng thanh toán tìm được nhiều hơn mọi công cụ trong danh sách này.
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); }}Sai lầm thường gặp
| "Bản vá" | Vì sao không đúng |
|---|---|
Thêm validation cho quantity > 0 ở endpoint được báo cáo | Đóng một ô. Còn discountPercent, amount, points, và trần thì vẫn thiếu |
| Kiểm bất biến trong controller | Controller là MỘT đường vào. Job nền, admin panel, webhook thanh toán và CLI dọn dữ liệu thì không đi qua nó |
Bỏ qua trường price nếu client gửi | Trường vẫn có trong DTO, nên một lần refactor bind nó lại là đủ. Nó phải KHÔNG TỒN TẠI |
| Sàn mà không có trần | quantity = 2147483647 tràn int khi nhân và cho ra tổng nhỏ hoặc âm — cùng hậu quả, đường khác |
| Kiểm giá ở bước checkout, đọc lại ở bước capture | Chính là mẫu ⑤. Client sửa giỏ hàng giữa hai bước |
Dùng float cho tiền | 0.1 + 0.2 != 0.3. Lỗi làm tròn tích lũy là một lỗi logic mà không ai gọi là lỗ hổng cho tới lúc đối soát |
| Ẩn nút trên UI | UI không phải nơi thi hành luật. Kẻ tấn công gọi API |
| Trông cậy vào scanner | Scanner phải biết kết quả nào ĐÚNG mới tìm được. Nó không biết nghiệp vụ của bạn |
Sai lầm lớn nhất của topic này là về phân loại: coi lỗi logic là "bug", không phải "lỗ hổng". Nó vào backlog sản phẩm với độ ưu tiên trung bình thay vì vào quy trình xử lý sự cố bảo mật — trong khi hậu quả tài chính của nó thường lớn hơn một XSS.
Sai lầm về phạm vi: vá đúng biến thể được báo cáo. Nếu quantity âm lọt qua, thì mọi trường số trong toàn bộ API cần được rà theo cùng câu hỏi — vì nguyên nhân không phải một dòng thiếu, mà là chưa ai viết miền giá trị ra.
Bình luận
Bình luận cần tài khoản đã hoàn thành ít nhất một bài học. Điều kiện đó là thứ giữ cho luồng thảo luận này còn đáng đọc: mỗi ý kiến gắn với một người có thể bị hỏi lại, và reputation tích luỹ theo thời gian.
Bạn vẫn đọc được toàn bộ bình luận dưới đây mà không cần tài khoản. Đăng nhập xong bạn sẽ quay lại đúng chỗ này, không phải đầu trang.
Đang tải bình luận…