main pico / pkg / apps / pgs / web_test.go
Antonio Mika  ·  2026-10-10
  1package pgs
  2
  3import (
  4	"fmt"
  5	"log/slog"
  6	"net/http"
  7	"net/http/httptest"
  8	"strings"
  9	"sync/atomic"
 10	"testing"
 11	"time"
 12
 13	pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
 14	"github.com/picosh/pico/pkg/db"
 15	"github.com/picosh/pico/pkg/send/utils"
 16	"github.com/picosh/pico/pkg/shared"
 17	"github.com/picosh/pico/pkg/shared/mime"
 18	"github.com/picosh/pico/pkg/storage"
 19)
 20
 21// var imgproxyContainer testcontainers.Container.
 22// var imgproxyURL string
 23
 24// // setupContainerRuntime checks for a container runtime (podman/docker) and
 25// // sets DOCKER_HOST so testcontainers can connect.
 26// func setupContainerRuntime() bool {
 27// 	if cmd := exec.Command("podman", "info"); cmd.Run() == nil {
 28// 		_ = os.Setenv("TESTCONTAINERS_RYUK_DISABLED", "true")
 29// 		xdgRuntime := os.Getenv("XDG_RUNTIME_DIR")
 30// 		if xdgRuntime != "" {
 31// 			socketPath := xdgRuntime + "/podman/podman.sock"
 32// 			if _, err := os.Stat(socketPath); err == nil {
 33// 				_ = os.Setenv("DOCKER_HOST", "unix://"+socketPath)
 34// 				return true
 35// 			}
 36// 		}
 37// 		return false
 38// 	}
 39
 40// 	if cmd := exec.Command("docker", "info"); cmd.Run() == nil {
 41// 		return true
 42// 	}
 43// 	return false
 44// }
 45
 46// func TestMain(m *testing.M) {
 47// 	ctx := context.Background()
 48
 49// 	if !setupContainerRuntime() {
 50// 		fmt.Fprintf(os.Stderr, "Container runtime not available, skipping image manipulation tests\n")
 51// 		fmt.Fprintf(os.Stderr, "To run tests, either:\n")
 52// 		fmt.Fprintf(os.Stderr, "  - Start podman socket: systemctl --user start podman.socket\n")
 53// 		fmt.Fprintf(os.Stderr, "  - Start docker daemon\n")
 54// 		os.Exit(m.Run())
 55// 	}
 56
 57// 	imgproxyContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
 58// 		ContainerRequest: testcontainers.ContainerRequest{
 59// 			Image:        "docker.io/darthsim/imgproxy:latest",
 60// 			ExposedPorts: []string{"8080/tcp"},
 61// 			WaitingFor:   wait.ForLog("Starting server at :8080"),
 62// 		},
 63// 		Started: true,
 64// 	})
 65// 	if err != nil {
 66// 		fmt.Fprintf(os.Stderr, "Failed to start imgproxy container (Docker/Podman may not be running): %s\n", err)
 67// 		fmt.Fprintf(os.Stderr, "Skipping image manipulation tests.\n")
 68// 		os.Exit(m.Run())
 69// 	}
 70
 71// 	host, err := imgproxyContainer.Host(ctx)
 72// 	if err != nil {
 73// 		fmt.Fprintf(os.Stderr, "Failed to get imgproxy host: %s\n", err)
 74// 		os.Exit(m.Run())
 75// 	}
 76
 77// 	port, err := imgproxyContainer.MappedPort(ctx, "8080")
 78// 	if err != nil {
 79// 		fmt.Fprintf(os.Stderr, "Failed to get imgproxy port: %s\n", err)
 80// 		os.Exit(m.Run())
 81// 	}
 82
 83// 	imgproxyURL = fmt.Sprintf("http://%s:%s", host, port)
 84// 	_ = os.Setenv("IMGPROXY_URL", imgproxyURL)
 85
 86// 	code := m.Run()
 87
 88// 	_ = imgproxyContainer.Terminate(ctx)
 89// 	os.Exit(code)
 90// }
 91
 92// testStorage wraps storage.StorageServe to inject ObjectInfo fields that
 93// production backends (S3, GCS) provide but the in-memory test storage does not.
 94type testStorage struct {
 95	storage.StorageServe
 96}
 97
 98func newTestStorage(st storage.StorageServe) *testStorage {
 99	return &testStorage{st}
100}
101
102func (t *testStorage) GetObject(bucket storage.Bucket, fpath string) (utils.ReadAndReaderAtCloser, *storage.ObjectInfo, error) {
103	r, info, err := t.StorageServe.GetObject(bucket, fpath)
104	info.ContentType = mime.GetMimeType(fpath)
105	info.LastModified = time.Now().UTC()
106	info.ETag = "static-etag-for-testing-purposes"
107	return r, info, err
108}
109
110type ApiExample struct {
111	name          string
112	path          string
113	reqHeaders    map[string]string
114	want          string
115	wantUrl       string
116	wantCacheCtrl string
117	status        int
118	contentType   string
119
120	storage map[string]map[string]string
121}
122
123type PgsDb struct {
124	*pgsdb.MemoryDB
125}
126
127func NewPgsDb(logger *slog.Logger) *PgsDb {
128	sb := pgsdb.NewDBMemory(logger)
129	sb.SetupTestData()
130	_, err := sb.InsertProject(sb.Users[0].ID, "test", "test")
131	if err != nil {
132		panic(err)
133	}
134	return &PgsDb{
135		MemoryDB: sb,
136	}
137}
138
139func (p *PgsDb) mkpath(path string) string {
140	return fmt.Sprintf("https://%s-test.pgs.test%s", p.Users[0].Name, path)
141}
142
143func TestApiBasic(t *testing.T) {
144	logger := slog.Default()
145	dbpool := NewPgsDb(logger)
146	bucketName := shared.GetAssetBucketName(dbpool.Users[0].ID)
147
148	tt := []*ApiExample{
149		{
150			name:        "basic",
151			path:        "/",
152			want:        "hello world!",
153			status:      http.StatusOK,
154			contentType: "text/html",
155
156			storage: map[string]map[string]string{
157				bucketName: {
158					"/test/index.html": "hello world!",
159				},
160			},
161		},
162		{
163			name:        "direct-file",
164			path:        "/test.html",
165			want:        "hello world!",
166			status:      http.StatusOK,
167			contentType: "text/html",
168
169			storage: map[string]map[string]string{
170				bucketName: {
171					"/test/test.html": "hello world!",
172				},
173			},
174		},
175		{
176			name:        "subdir-301-redirect",
177			path:        "/subdir",
178			want:        `<a href="/subdir/">Moved Permanently</a>.`,
179			status:      http.StatusMovedPermanently,
180			contentType: "text/html; charset=utf-8",
181
182			storage: map[string]map[string]string{
183				bucketName: {
184					"/test/subdir/index.html": "hello world!",
185				},
186			},
187		},
188		{
189			name:        "redirects-file-301",
190			path:        "/anything",
191			want:        `<a href="/about.html">Moved Permanently</a>.`,
192			status:      http.StatusMovedPermanently,
193			contentType: "text/html; charset=utf-8",
194
195			storage: map[string]map[string]string{
196				bucketName: {
197					"/test/_redirects": "/anything /about.html 301",
198					"/test/about.html": "hello world!",
199				},
200			},
201		},
202		{
203			name:        "subdir-direct",
204			path:        "/subdir/index.html",
205			want:        "hello world!",
206			status:      http.StatusOK,
207			contentType: "text/html",
208
209			storage: map[string]map[string]string{
210				bucketName: {
211					"/test/subdir/index.html": "hello world!",
212				},
213			},
214		},
215		{
216			name:        "spa",
217			path:        "/anything",
218			want:        "hello world!",
219			status:      http.StatusOK,
220			contentType: "text/html",
221
222			storage: map[string]map[string]string{
223				bucketName: {
224					"/test/_redirects": "/* /index.html 200",
225					"/test/index.html": "hello world!",
226				},
227			},
228		},
229		{
230			name:        "not-found",
231			path:        "/anything",
232			want:        "404 not found",
233			status:      http.StatusNotFound,
234			contentType: "text/plain; charset=utf-8",
235
236			storage: map[string]map[string]string{
237				bucketName: {},
238			},
239		},
240		{
241			name:        "_redirects",
242			path:        "/_redirects",
243			want:        "404 not found",
244			status:      http.StatusNotFound,
245			contentType: "text/plain; charset=utf-8",
246
247			storage: map[string]map[string]string{
248				bucketName: {
249					"/test/_redirects": "/ok /index.html 200",
250				},
251			},
252		},
253		{
254			name:        "_headers",
255			path:        "/_headers",
256			want:        "404 not found",
257			status:      http.StatusNotFound,
258			contentType: "text/plain; charset=utf-8",
259
260			storage: map[string]map[string]string{
261				bucketName: {
262					"/test/_headers": "/templates/index.html\n\tX-Frame-Options: DENY",
263				},
264			},
265		},
266		{
267			name:        "_pgs_ignore",
268			path:        "/_pgs_ignore",
269			want:        "404 not found",
270			status:      http.StatusNotFound,
271			contentType: "text/plain; charset=utf-8",
272
273			storage: map[string]map[string]string{
274				bucketName: {
275					"/test/_pgs_ignore": "# nothing",
276				},
277			},
278		},
279		{
280			name:        "not-found-custom",
281			path:        "/anything",
282			want:        "boom!",
283			status:      http.StatusNotFound,
284			contentType: "text/html",
285
286			storage: map[string]map[string]string{
287				bucketName: {
288					"/test/404.html": "boom!",
289				},
290			},
291		},
292		{
293			name:        "images",
294			path:        "/profile.jpg",
295			want:        "image",
296			status:      http.StatusOK,
297			contentType: "image/jpeg",
298
299			storage: map[string]map[string]string{
300				bucketName: {
301					"/test/profile.jpg": "image",
302				},
303			},
304		},
305		{
306			name:        "redirects-query-param",
307			path:        "/anything?query=param",
308			want:        `<a href="/about.html?query=param">Moved Permanently</a>.`,
309			wantUrl:     "/about.html?query=param",
310			status:      http.StatusMovedPermanently,
311			contentType: "text/html; charset=utf-8",
312
313			storage: map[string]map[string]string{
314				bucketName: {
315					"/test/_redirects": "/anything /about.html 301",
316					"/test/about.html": "hello world!",
317				},
318			},
319		},
320		{
321			name: "conditional-if-modified-since-future",
322			path: "/test.html",
323			reqHeaders: map[string]string{
324				"If-Modified-Since": time.Now().UTC().Add(time.Hour).Format(http.TimeFormat),
325			},
326			want:        "",
327			status:      http.StatusNotModified,
328			contentType: "",
329
330			storage: map[string]map[string]string{
331				bucketName: {
332					"/test/test.html": "hello world!",
333				},
334			},
335		},
336		{
337			name: "conditional-if-modified-since-past",
338			path: "/test.html",
339			reqHeaders: map[string]string{
340				"If-Modified-Since": time.Now().UTC().Add(-time.Hour).Format(http.TimeFormat),
341			},
342			want:        "hello world!",
343			status:      http.StatusOK,
344			contentType: "text/html",
345
346			storage: map[string]map[string]string{
347				bucketName: {
348					"/test/test.html": "hello world!",
349				},
350			},
351		},
352		{
353			name: "conditional-if-none-match-pass",
354			path: "/test.html",
355			reqHeaders: map[string]string{
356				"If-None-Match": "\"static-etag-for-testing-purposes\"",
357			},
358			want:        "",
359			status:      http.StatusNotModified,
360			contentType: "",
361
362			storage: map[string]map[string]string{
363				bucketName: {
364					"/test/test.html": "hello world!",
365				},
366			},
367		},
368		{
369			name: "conditional-if-none-match-fail",
370			path: "/test.html",
371			reqHeaders: map[string]string{
372				"If-None-Match": "\"non-matching-etag\"",
373			},
374			want:        "hello world!",
375			status:      http.StatusOK,
376			contentType: "text/html",
377
378			storage: map[string]map[string]string{
379				bucketName: {
380					"/test/test.html": "hello world!",
381				},
382			},
383		},
384		{
385			name: "conditional-if-none-match-and-if-modified-since",
386			path: "/test.html",
387			reqHeaders: map[string]string{
388				// The matching etag should take precedence over the past mod time
389				"If-None-Match":     "\"static-etag-for-testing-purposes\"",
390				"If-Modified-Since": time.Now().UTC().Add(-time.Hour).Format(http.TimeFormat),
391			},
392			want:        "",
393			status:      http.StatusNotModified,
394			contentType: "",
395
396			storage: map[string]map[string]string{
397				bucketName: {
398					"/test/test.html": "hello world!",
399				},
400			},
401		},
402		{
403			name:          "headers-cache-control-override",
404			path:          "/test.html",
405			want:          "hello world!",
406			status:        http.StatusOK,
407			contentType:   "text/html",
408			wantCacheCtrl: "public, max-age=31536000, immutable",
409
410			storage: map[string]map[string]string{
411				bucketName: {
412					"/test/test.html": "hello world!",
413					"/test/_headers":  "/*\n\tcache-control: public, max-age=31536000, immutable",
414				},
415			},
416		},
417		{
418			name:          "headers-invalid-path-skipped",
419			path:          "/test.html",
420			want:          "hello world!",
421			status:        http.StatusOK,
422			contentType:   "text/html",
423			wantCacheCtrl: "public, max-age=31536000, immutable",
424
425			storage: map[string]map[string]string{
426				bucketName: {
427					"/test/test.html": "hello world!",
428					"/test/_headers":  "/[\n\tx-broken: yes\n/*\n\tcache-control: public, max-age=31536000, immutable",
429				},
430			},
431		},
432		{
433			name:        "redirects-invalid-pattern-skipped",
434			path:        "/foo(",
435			want:        "404 not found",
436			status:      http.StatusNotFound,
437			contentType: "text/plain; charset=utf-8",
438
439			storage: map[string]map[string]string{
440				bucketName: {
441					"/test/_redirects": "/foo( /index.html 301",
442				},
443			},
444		},
445	}
446
447	for _, tc := range tt {
448		t.Run(tc.name, func(t *testing.T) {
449			request := httptest.NewRequest("GET", dbpool.mkpath(tc.path), strings.NewReader(""))
450			for key, val := range tc.reqHeaders {
451				request.Header.Set(key, val)
452			}
453			responseRecorder := httptest.NewRecorder()
454
455			memSt, err := storage.NewStorageMemory(tc.storage)
456			if err != nil {
457				t.Fatal(err)
458			}
459			st := newTestStorage(memSt)
460			pubsub := NewPubsubChan()
461			defer func() {
462				_ = pubsub.Close()
463			}()
464			cfg := NewConfig(logger, dbpool, st, pubsub)
465			cfg.Domain = "pgs.test"
466			router := NewWebRouter(cfg)
467			router.ServeHTTP(responseRecorder, request)
468
469			if responseRecorder.Code != tc.status {
470				t.Errorf("Want status '%d', got '%d'", tc.status, responseRecorder.Code)
471			}
472
473			ct := responseRecorder.Header().Get("content-type")
474			if ct != tc.contentType {
475				t.Errorf("Want content type '%s', got '%s'", tc.contentType, ct)
476			}
477
478			body := strings.TrimSpace(responseRecorder.Body.String())
479			if body != tc.want {
480				t.Errorf("Want '%s', got '%s'", tc.want, body)
481			}
482
483			if tc.wantUrl != "" {
484				location, err := responseRecorder.Result().Location()
485				if err != nil {
486					t.Errorf("err: %s", err.Error())
487				}
488				if location == nil {
489					t.Error("no location header in response")
490					return
491				}
492				if tc.wantUrl != location.String() {
493					t.Errorf("Want '%s', got '%s'", tc.wantUrl, location.String())
494				}
495			}
496
497			if tc.wantCacheCtrl != "" {
498				cc := responseRecorder.Header().Get("cache-control")
499				if cc != tc.wantCacheCtrl {
500					t.Errorf("Want cache-control '%s', got '%s'", tc.wantCacheCtrl, cc)
501				}
502			}
503		})
504	}
505}
506
507// TestApiHttpPassNotCached verifies that responses for password-protected
508// (http-pass) projects are served with `cache-control: private, no-store` so
509// the shared cache never stores them. Without this, the first authenticated
510// request would populate the cache and let later unauthenticated visitors
511// bypass the password gate. The asset here ships a `_headers` file that tries
512// to mark the response aggressively cacheable, to prove the http-pass override
513// wins over user-supplied headers.
514func TestApiHttpPassNotCached(t *testing.T) {
515	logger := slog.Default()
516	dbpool := NewPgsDb(logger)
517	user := dbpool.Users[0]
518	bucketName := shared.GetAssetBucketName(user.ID)
519
520	projectID, err := dbpool.InsertProject(user.ID, "secret", "secret")
521	if err != nil {
522		t.Fatal(err)
523	}
524	project, err := dbpool.FindProjectByName(user.ID, "secret")
525	if err != nil {
526		t.Fatal(err)
527	}
528	project.Acl = db.ProjectAcl{Type: "http-pass", Data: []string{"hunter2"}}
529
530	store := map[string]map[string]string{
531		bucketName: {
532			"/secret/index.html": "top secret!",
533			"/secret/_headers":   "/*\n\tcache-control: public, max-age=31536000, immutable",
534		},
535	}
536
537	memSt, err := storage.NewStorageMemory(store)
538	if err != nil {
539		t.Fatal(err)
540	}
541	st := newTestStorage(memSt)
542	pubsub := NewPubsubChan()
543	defer func() {
544		_ = pubsub.Close()
545	}()
546	cfg := NewConfig(logger, dbpool, st, pubsub)
547	cfg.Domain = "pgs.test"
548	router := NewWebRouter(cfg)
549
550	url := fmt.Sprintf("https://%s-secret.pgs.test/", user.Name)
551	request := httptest.NewRequest("GET", url, strings.NewReader(""))
552	// Supply a valid session cookie so we get past the password gate and
553	// actually serve the protected asset (the path we need to not cache).
554	request.AddCookie(&http.Cookie{
555		Name:  getCookieName("secret"),
556		Value: projectID,
557	})
558	responseRecorder := httptest.NewRecorder()
559	router.ServeHTTP(responseRecorder, request)
560
561	if responseRecorder.Code != http.StatusOK {
562		t.Fatalf("Want status '%d', got '%d'", http.StatusOK, responseRecorder.Code)
563	}
564	if body := strings.TrimSpace(responseRecorder.Body.String()); body != "top secret!" {
565		t.Fatalf("Want body 'top secret!', got '%s'", body)
566	}
567	cc := responseRecorder.Header().Get("cache-control")
568	if cc != "private, no-store" {
569		t.Errorf("http-pass response must be non-cacheable; want 'private, no-store', got '%s'", cc)
570	}
571}
572
573func TestDirectoryListing(t *testing.T) {
574	logger := slog.Default()
575	dbpool := NewPgsDb(logger)
576	bucketName := shared.GetAssetBucketName(dbpool.Users[0].ID)
577
578	tt := []struct {
579		name        string
580		path        string
581		status      int
582		contentType string
583		contains    []string
584		notContains []string
585		storage     map[string]map[string]string
586	}{
587		{
588			name:        "directory-without-index-shows-listing",
589			path:        "/docs/",
590			status:      http.StatusOK,
591			contentType: "text/html",
592			contains: []string{
593				"Index of /docs/",
594				"readme.md",
595				"guide.md",
596			},
597			storage: map[string]map[string]string{
598				bucketName: {
599					"/test/docs/readme.md": "# Readme",
600					"/test/docs/guide.md":  "# Guide",
601				},
602			},
603		},
604		{
605			name:        "directory-with-index-serves-index",
606			path:        "/docs/",
607			status:      http.StatusOK,
608			contentType: "text/html",
609			contains:    []string{"hello world!"},
610			notContains: []string{"Index of"},
611			storage: map[string]map[string]string{
612				bucketName: {
613					"/test/docs/index.html": "hello world!",
614					"/test/docs/readme.md":  "# Readme",
615				},
616			},
617		},
618		{
619			name:        "root-directory-without-index-shows-listing",
620			path:        "/",
621			status:      http.StatusOK,
622			contentType: "text/html",
623			contains: []string{
624				"Index of /",
625				"style.css",
626			},
627			storage: map[string]map[string]string{
628				bucketName: {
629					"/test/style.css": "body {}",
630				},
631			},
632		},
633		{
634			name:        "nested-directory-shows-parent-link",
635			path:        "/assets/images/",
636			status:      http.StatusOK,
637			contentType: "text/html",
638			contains: []string{
639				"Index of /assets/images/",
640				`href="../"`,
641				"logo.png",
642			},
643			storage: map[string]map[string]string{
644				bucketName: {
645					"/test/assets/images/logo.png": "png data",
646				},
647			},
648		},
649	}
650
651	for _, tc := range tt {
652		t.Run(tc.name, func(t *testing.T) {
653			request := httptest.NewRequest("GET", dbpool.mkpath(tc.path), strings.NewReader(""))
654			responseRecorder := httptest.NewRecorder()
655
656			memSt, err := storage.NewStorageMemory(tc.storage)
657			if err != nil {
658				t.Fatal(err)
659			}
660			st := newTestStorage(memSt)
661			pubsub := NewPubsubChan()
662			defer func() {
663				_ = pubsub.Close()
664			}()
665			cfg := NewConfig(logger, dbpool, st, pubsub)
666			cfg.Domain = "pgs.test"
667			router := NewWebRouter(cfg)
668			router.ServeHTTP(responseRecorder, request)
669
670			if responseRecorder.Code != tc.status {
671				t.Errorf("Want status '%d', got '%d'", tc.status, responseRecorder.Code)
672			}
673
674			ct := responseRecorder.Header().Get("content-type")
675			if ct != tc.contentType {
676				t.Errorf("Want content type '%s', got '%s'", tc.contentType, ct)
677			}
678
679			body := responseRecorder.Body.String()
680			for _, want := range tc.contains {
681				if !strings.Contains(body, want) {
682					t.Errorf("Want body to contain '%s', got '%s'", want, body)
683				}
684			}
685			for _, notWant := range tc.notContains {
686				if strings.Contains(body, notWant) {
687					t.Errorf("Want body to NOT contain '%s', got '%s'", notWant, body)
688				}
689			}
690		})
691	}
692}
693
694// minimalJPEG returns a minimal valid 1x1 JPEG image.
695// func minimalJPEG(t *testing.T) []byte {
696// 	data, err := os.ReadFile("../../../splash.jpg")
697// 	if err != nil {
698// 		t.Fatal(err)
699// 	}
700// 	return data
701// }
702
703// func TestImageManipulation(t *testing.T) {
704// 	if imgproxyURL == "" {
705// 		t.Skip("imgproxy container not available")
706// 	}
707
708// 	logger := slog.Default()
709// 	dbpool := NewPgsDb(logger)
710// 	bucketName := shared.GetAssetBucketName(dbpool.Users[0].ID)
711
712// 	tt := []struct {
713// 		name        string
714// 		path        string
715// 		status      int
716// 		contentType string
717// 		storage     map[string]map[string]string
718// 	}{
719// 		{
720// 			name:        "root-img",
721// 			path:        "/app.jpg/s:500/rt:90",
722// 			status:      http.StatusOK,
723// 			contentType: "image/jpeg",
724// 			storage: map[string]map[string]string{
725// 				bucketName: {
726// 					"/test/app.jpg": string(minimalJPEG(t)),
727// 				},
728// 			},
729// 		},
730// 		{
731// 			name:        "root-subdir-img",
732// 			path:        "/subdir/app.jpg/rt:90/s:500",
733// 			status:      http.StatusOK,
734// 			contentType: "image/jpeg",
735// 			storage: map[string]map[string]string{
736// 				bucketName: {
737// 					"/test/subdir/app.jpg": string(minimalJPEG(t)),
738// 				},
739// 			},
740// 		},
741// 	}
742
743// 	for _, tc := range tt {
744// 		t.Run(tc.name, func(t *testing.T) {
745// 			request := httptest.NewRequest("GET", dbpool.mkpath(tc.path), strings.NewReader(""))
746// 			responseRecorder := httptest.NewRecorder()
747
748// 			memSt, err := storage.NewStorageMemory(tc.storage)
749// 			if err != nil {
750// 				t.Fatal(err)
751// 			}
752// 			st := newTestStorage(memSt)
753// 			pubsub := NewPubsubChan()
754// 			defer func() {
755// 				_ = pubsub.Close()
756// 			}()
757// 			cfg := NewConfig(logger, dbpool, st, pubsub)
758// 			cfg.Domain = "pgs.test"
759// 			router := NewWebRouter(cfg)
760// 			router.ServeHTTP(responseRecorder, request)
761
762// 			if responseRecorder.Code != tc.status {
763// 				t.Errorf("Want status '%d', got '%d'", tc.status, responseRecorder.Code)
764// 			}
765
766// 			ct := responseRecorder.Header().Get("content-type")
767// 			if ct != tc.contentType {
768// 				t.Errorf("Want content type '%s', got '%s'", tc.contentType, ct)
769// 			}
770
771// 			// With a real imgproxy, the response is binary image data.
772// 			// Verify we got some content back (not empty).
773// 			body := responseRecorder.Body.Bytes()
774// 			if len(body) != 0 {
775// 				t.Error("Expected non-empty image response body")
776// 			}
777// 		})
778// 	}
779// }
780
781// The purge an upload sends must drop the site's parsed _redirects, or the
782// old rules keep being served and cached.
783func TestRulesCacheClearedByUploadPurge(t *testing.T) {
784	logger := slog.New(slog.DiscardHandler)
785	dbpool := NewPgsDb(logger)
786	user := dbpool.Users[0]
787	bucketName := shared.GetAssetBucketName(user.ID)
788	st, err := storage.NewStorageMemory(map[string]map[string]string{
789		bucketName: {
790			"/test/old.html":   "old",
791			"/test/new.html":   "new",
792			"/test/_redirects": "/page /old.html 200",
793		},
794	})
795	if err != nil {
796		t.Fatal(err)
797	}
798	cfg := NewConfig(logger, dbpool, st, discardPubsub{})
799	cfg.Domain = "pgs.test"
800	router := newWebRouter(cfg)
801
802	get := func() string {
803		rec := httptest.NewRecorder()
804		router.ServeHTTP(rec, httptest.NewRequest("GET", dbpool.mkpath("/page"), nil))
805		return strings.TrimSpace(rec.Body.String())
806	}
807	if got := get(); got != "old" {
808		t.Fatalf("before upload got %q", got)
809	}
810
811	bucket, err := st.GetBucket(bucketName)
812	if err != nil {
813		t.Fatal(err)
814	}
815	if _, _, err := st.PutObject(bucket, "/test/_redirects", strings.NewReader("/page /new.html 200"), &storage.ObjectInfo{}); err != nil {
816		t.Fatal(err)
817	}
818	router.clearSiteCache(getSurrogateKey(user.Name, "test"))
819	if got := get(); got != "new" {
820		t.Fatalf("after upload got %q", got)
821	}
822}
823
824// countingDB counts the lookups serving a site makes.
825type countingDB struct {
826	*PgsDb
827	lookups atomic.Int64
828}
829
830func (c *countingDB) FindUserByName(name string) (*db.User, error) {
831	c.lookups.Add(1)
832	return c.PgsDb.FindUserByName(name)
833}
834
835func (c *countingDB) FindProjectByName(userID, name string) (*db.Project, error) {
836	c.lookups.Add(1)
837	return c.PgsDb.FindProjectByName(userID, name)
838}
839
840func (c *countingDB) FindFeature(userID, name string) (*db.FeatureFlag, error) {
841	c.lookups.Add(1)
842	return c.PgsDb.FindFeature(userID, name)
843}
844
845// A site's database records are looked up once and again only after a purge.
846func TestSiteCache(t *testing.T) {
847	logger := slog.New(slog.DiscardHandler)
848	dbpool := &countingDB{PgsDb: NewPgsDb(logger)}
849	user := dbpool.Users[0]
850	st, err := storage.NewStorageMemory(map[string]map[string]string{
851		shared.GetAssetBucketName(user.ID): {"/test/index.html": "hello"},
852	})
853	if err != nil {
854		t.Fatal(err)
855	}
856	cfg := NewConfig(logger, dbpool, st, discardPubsub{})
857	cfg.Domain = "pgs.test"
858	router := newWebRouter(cfg)
859
860	get := func() {
861		t.Helper()
862		rec := httptest.NewRecorder()
863		router.ServeHTTP(rec, httptest.NewRequest("GET", dbpool.mkpath("/"), nil))
864		if rec.Code != http.StatusOK {
865			t.Fatalf("got status %d", rec.Code)
866		}
867	}
868	get()
869	first := dbpool.lookups.Load()
870	if first == 0 {
871		t.Fatal("serving made no lookups")
872	}
873	get()
874	if n := dbpool.lookups.Load(); n != first {
875		t.Fatalf("second request made %d more lookups", n-first)
876	}
877	router.clearSiteCache(getSurrogateKey(user.Name, "test"))
878	get()
879	if n := dbpool.lookups.Load(); n != 2*first {
880		t.Fatalf("request after purge made %d lookups, want %d", n-first, first)
881	}
882}