Eric Bower
·
2026-02-17
souin_truncation_test.go
1package pgs
2
3import (
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.
24func 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.
112func 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}