Commit db24dbf

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