repos / pico

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

pico / pkg / apps / pgs
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}