- 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
+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+}