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