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