◐ Off-By-One · answer catalog

go-e2e-hash-only-open-no-credit

1 answer(s)godocker

go-e2e-hash-only-open-no-credit

📦 Source in repository (JSON)

Answer

Two independent defects combine to make hash-only /open never accrue claimable credits.

Defect 1 — open.go:120-123 gates RecordCredit on body fields the spec doesn't send. The documented spec OpenPageRequest carries only hash; PublisherID/Domain are never present, so the gate if req.PublisherID != "" && req.Domain != "" is always false and no credit row is ever created. Fix: drop the body gating and resolve hash → URL → domain → publisher from content_hash_store + verified_domains, then credit the resolved publisher.

// open.go — spec-compliant request carries ONLY hash
type OpenPageRequest struct {
    Hash string `json:"hash" binding:"required"`
}

type OpenPageResponse struct {
    OK       bool   `json:"ok"`
    URL      string `json:"url,omitempty"`
    Credited bool   `json:"credited"`
}

func (s *Server) Open(c *gin.Context) {
    var req OpenPageRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "hash required"})
        return
    }

    // 1. hash -> url (content_hash_store), case-insensitive
    url, err := s.contentHashes.URLByHash(strings.ToLower(req.Hash))
    if err != nil || url == "" {
        c.JSON(http.StatusNotFound, gin.H{"error": "unknown content hash"})
        return
    }

    // 2. url -> domain
    u, err := urlpkg.Parse(url)
    if err != nil || u.Host == "" {
        c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "unresolvable url"})
        return
    }

    // 3. domain -> publisher (verified_domains)
    publisher, ok, err := s.verifiedDomains.PublisherByDomain(u.Host)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "domain lookup failed"})
        return
    }
    if !ok {
        // open still recorded; domain not verified -> nothing claimable
        c.JSON(http.StatusOK, OpenPageResponse{OK: true, URL: url, Credited: false})
        return
    }

    // 4. credit the RESOLVED publisher (previously gated on body fields)
    if err := s.credits.RecordCredit(c.Request.Context(), publisher, u.Host, creditPerOpen); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to record credit"})
        return
    }
    c.JSON(http.StatusOK, OpenPageResponse{OK: true, URL: url, Credited: true})
}
// RecordCredit: idempotent upsert that accumulates the shadow balance
func (r *CreditRepo) RecordCredit(ctx context.Context, publisherID, domain string, amount int64) error {
    const q = `
        INSERT INTO publisher_credits (publisher_id, domain, shadow_credits, updated_at)
        VALUES ($1, $2, $3, now())
        ON CONFLICT (publisher_id, domain)
        DO UPDATE SET shadow_credits = publisher_credits.shadow_credits + EXCLUDED.shadow_credits,
                      updated_at = now()`
    _, err := r.db.ExecContext(ctx, q, publisherID, domain, amount)
    return err
}

Defect 2 — ClaimDomainCredits UPDATE ... RETURNING shadow_credits returns the post-update value. Setting shadow_credits = 0 and returning the column yields 0, so every claim pays out zero even when shadow credits accrued. Fix: a data-modifying CTE — a snapshot CTE with FOR UPDATE reads the pre-update value (and takes the row lock), the zeroed CTE zeroes it, and the outer query returns the snapshot amount. The whole statement is atomic; the row lock serializes concurrent claims.

func (r *CreditRepo) ClaimDomainCredits(ctx context.Context, publisherID, domain string) (int64, error) {
    const q = `
        WITH snapshot AS (
            SELECT publisher_id, shadow_credits AS amount
            FROM publisher_credits
            WHERE publisher_id = $1 AND domain = $2
            FOR UPDATE
        ),
        zeroed AS (
            UPDATE publisher_credits
            SET shadow_credits = 0,
                updated_at = now()
            WHERE publisher_id = $1 AND domain = $2
            RETURNING publisher_id
        )
        SELECT s.amount
        FROM snapshot s
        JOIN zeroed z ON z.publisher_id = s.publisher_id`
    var claimed int64
    err := r.db.QueryRowContext(ctx, q, publisherID, domain).Scan(&claimed)
    if errors.Is(err, sql.ErrNoRows) {
        return 0, nil // nothing accrued
    }
    return claimed, err
}

Evidence & signatures

Verified against a real **PostgreSQL 18** instance (`initdb` + `pg_ctl`, driven by a Go harness with `database/sql` + `lib/pq`; schema mirrors the service: `content_hash_store`, `verified_domains`, `publisher_credits`). Both defects were reproduced first, then the fix was applied. `go test -v ./...` — **11/11 pass**:

**Defects reproduced (pre-fix):**
- `UPDATE ... RETURNING shadow_credits` on a balance of 100 → returns **0** (post-update value). ✔ reproduced
- `openBuggy` with a hash-only request → `credited=false`, `shadow_credits` stays **0** (body-gate never passes). ✔ reproduced

**Fix verified (post-fix):**
- `claimFixed` on 100 → returns **100**, balance zeroed to 0.
- Hash-only `/open` (body = `{"hash":"abc123"}` only) → resolves to `https://example.com/post/1` → `example.com` → `pub_1`; shadow goes 1 → 2 over two opens; a subsequent claim pays out exactly **2**.
- Sequential double-claim on 100 → **(100, 0)**, never double-spends.
- **Concurrency**: two *simultaneous* claims on 100 → total exactly **100** (`FOR UPDATE` row lock serializes them), balance 0.

**Edge cases tested (all pass):**
- Unknown hash → 404, no credit row created.
- Hash resolves but domain has no verified publisher → 200, `credited=false`, zero credit rows.
- Zero-balance claim → returns 0, no error.
- Case-insensitive hash lookup (`"ABC123"` → stored `abc123`) → credited.
- Malformed URL (`"not a url"` in store) → 422, not credited.
{"model": "deepseek-v4-flash", "problem_class": "go-e2e-hash-only-open-no-credit", "result": "passed", "tests": 11}
Generated from the verified corpus · MIT licensedBack to the catalog