repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

commit
db24dbf
parent
ccc233b
author
Eric Bower
date
2026-02-17 20:49:31 -0500 EST
chore(pgs): test failure
1 files changed,  +183, -0
A pkg/apps/pgs/souin_truncation_test.go
+183, -0
  1@@ -0,0 +1,183 @@
  2+package pgs
  3+
  4+import (
  5+	"bytes"
  6+	"log/slog"
  7+	"net/http"
  8+	"net/http/httptest"
  9+	"strings"
 10+	"testing"
 11+
 12+	"github.com/picosh/pico/pkg/shared"
 13+	"github.com/picosh/pico/pkg/shared/storage"
 14+)
 15+
 16+// TestLargeFileNotTruncatedOnCacheHit reproduces the Souin truncation bug.
 17+// Large files (3MB) are cached as truncated (~4KB) on the second request.
 18+//
 19+// Bug behavior:
 20+// - First request (cache miss): Returns full 3MB file ✓
 21+// - Second request (cache hit): Returns only ~4KB (truncated!) ✗
 22+//
 23+// Root cause: Souin's Store() snapshots the buffer mid-stream while
 24+// io.Copy() is still writing data, resulting in partial cache entries.
 25+func TestLargeFileNotTruncatedOnCacheHit(t *testing.T) {
 26+	logger := slog.Default()
 27+	dbpool := NewPgsDb(logger)
 28+	bucketName := shared.GetAssetBucketName(dbpool.Users[0].ID)
 29+
 30+	// 3MB payload - reproduces the exact bug scenario
 31+	largePayload := bytes.Repeat([]byte("x"), 3*1024*1024)
 32+	expectedSize := len(largePayload)
 33+
 34+	st, err := storage.NewStorageMemory(map[string]map[string]string{
 35+		bucketName: {
 36+			"/test/large-file.bin": string(largePayload),
 37+		},
 38+	})
 39+	if err != nil {
 40+		t.Fatalf("storage setup failed: %v", err)
 41+	}
 42+
 43+	pubsub := NewPubsubChan()
 44+	defer func() {
 45+		_ = pubsub.Close()
 46+	}()
 47+
 48+	cfg := NewPgsConfig(logger, dbpool, st, pubsub)
 49+	cfg.Domain = "pgs.test"
 50+	// Increase max asset size for testing large files
 51+	cfg.MaxAssetSize = 10 * 1024 * 1024 // 10MB
 52+
 53+	// Set up the full stack WITH Souin HTTP caching middleware
 54+	httpCache := SetupCache(cfg)
 55+	routes := NewWebRouter(cfg)
 56+	cacher := &CachedHttp{
 57+		handler: httpCache,
 58+		routes:  routes,
 59+	}
 60+
 61+	// First request (cache miss)
 62+	req1 := httptest.NewRequest("GET", dbpool.mkpath("/large-file.bin"), strings.NewReader(""))
 63+	rec1 := httptest.NewRecorder()
 64+	cacher.ServeHTTP(rec1, req1)
 65+
 66+	if rec1.Code != http.StatusOK {
 67+		t.Fatalf("first request failed with status %d", rec1.Code)
 68+	}
 69+
 70+	body1 := rec1.Body.String()
 71+	size1 := len(body1)
 72+
 73+	if size1 != expectedSize {
 74+		t.Errorf("first request: expected %d bytes, got %d bytes", expectedSize, size1)
 75+	}
 76+
 77+	t.Logf("Cache miss: received %d bytes (expected %d)", size1, expectedSize)
 78+
 79+	// Second request (cache hit) - This is where the bug manifests
 80+	req2 := httptest.NewRequest("GET", dbpool.mkpath("/large-file.bin"), strings.NewReader(""))
 81+	rec2 := httptest.NewRecorder()
 82+	cacher.ServeHTTP(rec2, req2)
 83+
 84+	if rec2.Code != http.StatusOK {
 85+		t.Fatalf("second request failed with status %d", rec2.Code)
 86+	}
 87+
 88+	body2 := rec2.Body.String()
 89+	size2 := len(body2)
 90+
 91+	t.Logf("Cache hit: received %d bytes (expected %d)", size2, expectedSize)
 92+
 93+	// CRITICAL ASSERTION: Both requests must return the full file
 94+	if size2 != expectedSize {
 95+		t.Errorf("SOUIN_TRUNCATION_BUG: cache hit returned %d bytes instead of %d bytes",
 96+			size2, expectedSize)
 97+
 98+		// Show evidence of truncation
 99+		if size2 < 10000 {
100+			t.Logf("Truncated response: only %d bytes (about %dKB)", size2, size2/1024)
101+		}
102+	}
103+
104+	// Verify both responses are identical
105+	if body1 != body2 {
106+		t.Errorf("cache hit response differs from cache miss")
107+		t.Errorf("Cache miss: %d bytes, Cache hit: %d bytes", size1, size2)
108+	}
109+}
110+
111+// TestMediumFileNotTruncatedOnCacheHit tests with 512KB files
112+// which can trigger the race condition due to buffering behavior.
113+func TestMediumFileNotTruncatedOnCacheHit(t *testing.T) {
114+	logger := slog.Default()
115+	dbpool := NewPgsDb(logger)
116+	bucketName := shared.GetAssetBucketName(dbpool.Users[0].ID)
117+
118+	// 512KB payload with repeating pattern
119+	payload := bytes.Repeat([]byte("0123456789ABCDEF"), 512*1024/16)
120+	expectedSize := len(payload)
121+
122+	st, err := storage.NewStorageMemory(map[string]map[string]string{
123+		bucketName: {
124+			"/test/medium-file.bin": string(payload),
125+		},
126+	})
127+	if err != nil {
128+		t.Fatalf("storage setup failed: %v", err)
129+	}
130+
131+	pubsub := NewPubsubChan()
132+	defer func() {
133+		_ = pubsub.Close()
134+	}()
135+
136+	cfg := NewPgsConfig(logger, dbpool, st, pubsub)
137+	cfg.Domain = "pgs.test"
138+	// Increase max asset size for testing large files
139+	cfg.MaxAssetSize = 10 * 1024 * 1024 // 10MB
140+
141+	httpCache := SetupCache(cfg)
142+	routes := NewWebRouter(cfg)
143+	cacher := &CachedHttp{
144+		handler: httpCache,
145+		routes:  routes,
146+	}
147+
148+	// First request (cache miss)
149+	req1 := httptest.NewRequest("GET", dbpool.mkpath("/medium-file.bin"), strings.NewReader(""))
150+	rec1 := httptest.NewRecorder()
151+	cacher.ServeHTTP(rec1, req1)
152+
153+	body1 := rec1.Body.String()
154+	size1 := len(body1)
155+
156+	// Second request (cache hit)
157+	req2 := httptest.NewRequest("GET", dbpool.mkpath("/medium-file.bin"), strings.NewReader(""))
158+	rec2 := httptest.NewRecorder()
159+	cacher.ServeHTTP(rec2, req2)
160+
161+	body2 := rec2.Body.String()
162+	size2 := len(body2)
163+
164+	t.Logf("Cache miss: %d bytes, Cache hit: %d bytes (expected %d)", size1, size2, expectedSize)
165+
166+	// Verify complete responses
167+	if size1 != expectedSize {
168+		t.Errorf("cache miss: expected %d bytes, got %d", expectedSize, size1)
169+	}
170+
171+	if size2 != expectedSize {
172+		t.Errorf("SOUIN_TRUNCATION_BUG: cache hit returned %d bytes instead of %d bytes",
173+			size2, expectedSize)
174+	}
175+
176+	// Verify content matches original
177+	if body1 != string(payload) {
178+		t.Errorf("cache miss response body doesn't match")
179+	}
180+
181+	if body2 != string(payload) {
182+		t.Errorf("cache hit response body doesn't match")
183+	}
184+}