SoftDelete now reports whether it performed the delete (conditional UPDATE ... WHERE deleted_at IS NULL checked via RowsAffected). RegisterRead returns an admitted flag: legacy burn pastes admit exactly one reader (the atomic soft-delete winner), and burn-after-N pastes increment reads_used via a conditional UPDATE guarded on reads_used < reads_limit, so concurrent readers cannot both consume the final read. API, HTML, and raw read paths return 404 when the reader loses the burn claim; content is never served twice. OpenStore pins the SQLite pool to one connection: concurrent writes on separate pooled connections surfaced SQLITE_BUSY as spurious 500s instead of serializing. Adds concurrency regression tests: 24 parallel readers of a burn paste (exactly one receives content, none of the others leak it) and 30 parallel readers vs a 3-read budget (exactly 3 admitted, then 404).
104 lines
2.8 KiB
Go
104 lines
2.8 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// TestBurnAfterReadConcurrentRace is the #58 regression test: N concurrent
|
|
// readers of a burn-after-read paste must receive exactly one success with
|
|
// content; every other reader must get 404 and never any content.
|
|
func TestBurnAfterReadConcurrentRace(t *testing.T) {
|
|
s := testServer(t)
|
|
h := s.routes()
|
|
id := createBurnReads(t, h, 1)
|
|
|
|
const readers = 24
|
|
var wg sync.WaitGroup
|
|
var mu sync.Mutex
|
|
wins, losses := 0, 0
|
|
for i := 0; i < readers; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
|
|
req.AddCookie(&http.Cookie{Name: "vwr", Value: fmt.Sprintf("racer-%d", i)})
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if rec.Code == 200 {
|
|
wins++
|
|
var got struct {
|
|
Content string `json:"content"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil || got.Content != "limited" {
|
|
t.Errorf("winning read returned wrong content: %v %q", err, got.Content)
|
|
}
|
|
} else if rec.Code == 404 {
|
|
losses++
|
|
if strings.Contains(rec.Body.String(), "limited") {
|
|
t.Errorf("losing read leaked content: %s", rec.Body.String())
|
|
}
|
|
} else {
|
|
t.Errorf("unexpected status %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
if wins != 1 {
|
|
t.Fatalf("expected exactly 1 winning read of burn paste, got %d (losses=%d)", wins, losses)
|
|
}
|
|
if losses != readers-1 {
|
|
t.Fatalf("expected %d losing reads, got %d", readers-1, losses)
|
|
}
|
|
}
|
|
|
|
// TestBurnAfterNReadsConcurrentBudget hammers a burn-after-N paste with many
|
|
// more concurrent distinct readers than the budget: total admissions must
|
|
// equal exactly N, and no losing read may see content.
|
|
func TestBurnAfterNReadsConcurrentBudget(t *testing.T) {
|
|
s := testServer(t)
|
|
h := s.routes()
|
|
const budget = 3
|
|
id := createBurnReads(t, h, budget)
|
|
|
|
const readers = 30
|
|
var wg sync.WaitGroup
|
|
var mu sync.Mutex
|
|
wins := 0
|
|
for i := 0; i < readers; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
|
|
req.AddCookie(&http.Cookie{Name: "vwr", Value: fmt.Sprintf("racer-%d", i)})
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if rec.Code == 200 {
|
|
wins++
|
|
} else if rec.Code != 404 {
|
|
t.Errorf("unexpected status %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
if wins != budget {
|
|
t.Fatalf("expected exactly %d admitted reads, got %d", budget, wins)
|
|
}
|
|
|
|
// After the race, the paste is burned for everyone.
|
|
if rec := getWithCookie(t, h, id, "after-the-fact"); rec.Code != 404 {
|
|
t.Fatalf("paste should be burned after budget exhausted, got %d", rec.Code)
|
|
}
|
|
}
|