An AI assistant opened a pull request for the shop: customers can send a discount code to POST /products/{id}/discount and get the price after the discount, in cents. It works for SPRING10 on the Lamp, so it looks done. Here is the AI's handler:
app.HandleFunc("POST /products/{id}/discount", func(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
var data map[string]string
json.NewDecoder(r.Body).Decode(&data)
var name, code string
var price, percent int
db.QueryRow("SELECT name, price FROM products WHERE id = ?", id).Scan(&name, &price)
db.QueryRow("SELECT code, percent FROM discounts WHERE code = '" + data["code"] + "'").Scan(&code, &percent)
totalCents := price * (100 - percent)
writeJSON(w, 200, map[string]any{"product": name, "code": code, "total_cents": totalCents})
})
A review asks what the AI never tried. Missing: for product 99 or the code SUMMER50, Scan finds no row and returns sql.ErrNoRows. The AI never reads that error, so the handler goes on with empty values and answers 200: a total of 0 for product 99, the full price for SUMMER50. Hostile: the + pastes the customer's text into the SQL, so the code x' OR '1'='1 matches every row and gets SPRING10's discount. That is SQL injection. Twice: every code has uses_left, and the AI never reads it, so the one-use VIP25 works forever and the used-up WINTER15 still works.
You know each fix from earlier lessons. Here they are on other tables: a ? placeholder keeps typed text a plain value, sql.ErrNoRows says no row was found, and one UPDATE with a condition takes a unit only if one is left. Then RowsAffected tells you whether it did.
var userID int
err := db.QueryRow("SELECT id FROM users WHERE email = ?", email).Scan(&userID)
if err == sql.ErrNoRows {
writeError(w, 404, "User not found")
return
}
res, _ := db.Exec("UPDATE products SET stock = stock - 1 WHERE id = ? AND stock > 0", id)
changed, _ := res.RowsAffected()
if changed == 0 {
writeError(w, 409, "Out of stock")
return
}
The codes: SPRING10 (10% off, 100 uses), VIP25 (25% off, 1 use) and WINTER15 (15% off, used up). Sent in this order, the requests must get these answers:
POST /products/3/discount {"code": "SPRING10"} → 200 {"code": "SPRING10", "product": "Lamp", "total_cents": 2610}
POST /products/99/discount {"code": "SPRING10"} → 404 {"error": "Product not found"}
POST /products/3/discount {"code": "SUMMER50"} → 404 {"error": "Unknown code"}
POST /products/3/discount {"code": "x' OR '1'='1"} → 404 {"error": "Unknown code"}
POST /products/1/discount {"code": "VIP25"} → 200 {"code": "VIP25", "product": "Mug", "total_cents": 900}
POST /products/1/discount {"code": "VIP25"} → 409 {"error": "Code used up"}
POST /products/5/discount {"code": "WINTER15"} → 409 {"error": "Code used up"}
Your task: the database, the discounts table, the writeError helper, the route and the handler's first lines (the id, the body and the product lookup, with its error in err) are ready. Write the rest of the handler so it fixes all three problems: 404 for a missing product or code, a ? placeholder for the code, and one atomic UPDATE that takes a use, with 409 when none is left. Keep the AI's total in cents.
Assignment
Follow the Coding Tutorial to rewrite the discount handler one problem at a time: the two 404s, the placeholder, the atomic UPDATE with its 409, and the answer.
Hint
Check in this order: the product, then the code, then the use, then the price. Take a use only after both lookups found a row, so a request for a missing product never spends a code. Every error answer needs a return after it.
Free plan: 30 tutor messages. PRO is unlimited.
