repos / pico

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

pico / pkg / apps / pgs
Eric Bower  ·  2026-03-28

web_test.go

  1package pgs
  2
  3import (
  4	"fmt"
  5	"io"
  6	"log/slog"
  7	"net/http"
  8	"net/http/httptest"
  9	"strings"
 10	"testing"
 11	"time"
 12
 13	pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
 14	"github.com/picosh/pico/pkg/shared"
 15	"github.com/picosh/pico/pkg/storage"
 16)
 17
 18type ApiExample struct {
 19	name        string
 20	path        string
 21	reqHeaders  map[string]string
 22	want        string
 23	wantUrl     string
 24	status      int
 25	contentType string
 26
 27	storage map[string]map[string]string
 28}
 29
 30type PgsDb struct {
 31	*pgsdb.MemoryDB
 32}
 33
 34func NewPgsDb(logger *slog.Logger) *PgsDb {
 35	sb := pgsdb.NewDBMemory(logger)
 36	sb.SetupTestData()
 37	_, err := sb.InsertProject(sb.Users[0].ID, "test", "test")
 38	if err != nil {
 39		panic(err)
 40	}
 41	return &PgsDb{
 42		MemoryDB: sb,
 43	}
 44}
 45
 46func (p *PgsDb) mkpath(path string) string {
 47	return fmt.Sprintf("https://%s-test.pgs.test%s", p.Users[0].Name, path)
 48}
 49
 50func TestApiBasic(t *testing.T) {
 51	logger := slog.Default()
 52	dbpool := NewPgsDb(logger)
 53	bucketName := shared.GetAssetBucketName(dbpool.Users[0].ID)
 54
 55	tt := []*ApiExample{
 56		{
 57			name:        "basic",
 58			path:        "/",
 59			want:        "hello world!",
 60			status:      http.StatusOK,
 61			contentType: "text/html",
 62
 63			storage: map[string]map[string]string{
 64				bucketName: {
 65					"/test/index.html": "hello world!",
 66				},
 67			},
 68		},
 69		{
 70			name:        "direct-file",
 71			path:        "/test.html",
 72			want:        "hello world!",
 73			status:      http.StatusOK,
 74			contentType: "text/html",
 75
 76			storage: map[string]map[string]string{
 77				bucketName: {
 78					"/test/test.html": "hello world!",
 79				},
 80			},
 81		},
 82		{
 83			name:        "subdir-301-redirect",
 84			path:        "/subdir",
 85			want:        `<a href="/subdir/">Moved Permanently</a>.`,
 86			status:      http.StatusMovedPermanently,
 87			contentType: "text/html; charset=utf-8",
 88
 89			storage: map[string]map[string]string{
 90				bucketName: {
 91					"/test/subdir/index.html": "hello world!",
 92				},
 93			},
 94		},
 95		{
 96			name:        "redirects-file-301",
 97			path:        "/anything",
 98			want:        `<a href="/about.html">Moved Permanently</a>.`,
 99			status:      http.StatusMovedPermanently,
100			contentType: "text/html; charset=utf-8",
101
102			storage: map[string]map[string]string{
103				bucketName: {
104					"/test/_redirects": "/anything /about.html 301",
105					"/test/about.html": "hello world!",
106				},
107			},
108		},
109		{
110			name:        "subdir-direct",
111			path:        "/subdir/index.html",
112			want:        "hello world!",
113			status:      http.StatusOK,
114			contentType: "text/html",
115
116			storage: map[string]map[string]string{
117				bucketName: {
118					"/test/subdir/index.html": "hello world!",
119				},
120			},
121		},
122		{
123			name:        "spa",
124			path:        "/anything",
125			want:        "hello world!",
126			status:      http.StatusOK,
127			contentType: "text/html",
128
129			storage: map[string]map[string]string{
130				bucketName: {
131					"/test/_redirects": "/* /index.html 200",
132					"/test/index.html": "hello world!",
133				},
134			},
135		},
136		{
137			name:        "not-found",
138			path:        "/anything",
139			want:        "404 not found",
140			status:      http.StatusNotFound,
141			contentType: "text/plain; charset=utf-8",
142
143			storage: map[string]map[string]string{
144				bucketName: {},
145			},
146		},
147		{
148			name:        "_redirects",
149			path:        "/_redirects",
150			want:        "404 not found",
151			status:      http.StatusNotFound,
152			contentType: "text/plain; charset=utf-8",
153
154			storage: map[string]map[string]string{
155				bucketName: {
156					"/test/_redirects": "/ok /index.html 200",
157				},
158			},
159		},
160		{
161			name:        "_headers",
162			path:        "/_headers",
163			want:        "404 not found",
164			status:      http.StatusNotFound,
165			contentType: "text/plain; charset=utf-8",
166
167			storage: map[string]map[string]string{
168				bucketName: {
169					"/test/_headers": "/templates/index.html\n\tX-Frame-Options: DENY",
170				},
171			},
172		},
173		{
174			name:        "_pgs_ignore",
175			path:        "/_pgs_ignore",
176			want:        "404 not found",
177			status:      http.StatusNotFound,
178			contentType: "text/plain; charset=utf-8",
179
180			storage: map[string]map[string]string{
181				bucketName: {
182					"/test/_pgs_ignore": "# nothing",
183				},
184			},
185		},
186		{
187			name:        "not-found-custom",
188			path:        "/anything",
189			want:        "boom!",
190			status:      http.StatusNotFound,
191			contentType: "text/html",
192
193			storage: map[string]map[string]string{
194				bucketName: {
195					"/test/404.html": "boom!",
196				},
197			},
198		},
199		{
200			name:        "images",
201			path:        "/profile.jpg",
202			want:        "image",
203			status:      http.StatusOK,
204			contentType: "image/jpeg",
205
206			storage: map[string]map[string]string{
207				bucketName: {
208					"/test/profile.jpg": "image",
209				},
210			},
211		},
212		{
213			name:        "redirects-query-param",
214			path:        "/anything?query=param",
215			want:        `<a href="/about.html?query=param">Moved Permanently</a>.`,
216			wantUrl:     "/about.html?query=param",
217			status:      http.StatusMovedPermanently,
218			contentType: "text/html; charset=utf-8",
219
220			storage: map[string]map[string]string{
221				bucketName: {
222					"/test/_redirects": "/anything /about.html 301",
223					"/test/about.html": "hello world!",
224				},
225			},
226		},
227		{
228			name: "conditional-if-modified-since-future",
229			path: "/test.html",
230			reqHeaders: map[string]string{
231				"If-Modified-Since": time.Now().UTC().Add(time.Hour).Format(http.TimeFormat),
232			},
233			want:        "",
234			status:      http.StatusNotModified,
235			contentType: "",
236
237			storage: map[string]map[string]string{
238				bucketName: {
239					"/test/test.html": "hello world!",
240				},
241			},
242		},
243		{
244			name: "conditional-if-modified-since-past",
245			path: "/test.html",
246			reqHeaders: map[string]string{
247				"If-Modified-Since": time.Now().UTC().Add(-time.Hour).Format(http.TimeFormat),
248			},
249			want:        "hello world!",
250			status:      http.StatusOK,
251			contentType: "text/html",
252
253			storage: map[string]map[string]string{
254				bucketName: {
255					"/test/test.html": "hello world!",
256				},
257			},
258		},
259		{
260			name: "conditional-if-none-match-pass",
261			path: "/test.html",
262			reqHeaders: map[string]string{
263				"If-None-Match": "\"static-etag-for-testing-purposes\"",
264			},
265			want:        "",
266			status:      http.StatusNotModified,
267			contentType: "",
268
269			storage: map[string]map[string]string{
270				bucketName: {
271					"/test/test.html": "hello world!",
272				},
273			},
274		},
275		{
276			name: "conditional-if-none-match-fail",
277			path: "/test.html",
278			reqHeaders: map[string]string{
279				"If-None-Match": "\"non-matching-etag\"",
280			},
281			want:        "hello world!",
282			status:      http.StatusOK,
283			contentType: "text/html",
284
285			storage: map[string]map[string]string{
286				bucketName: {
287					"/test/test.html": "hello world!",
288				},
289			},
290		},
291		{
292			name: "conditional-if-none-match-and-if-modified-since",
293			path: "/test.html",
294			reqHeaders: map[string]string{
295				// The matching etag should take precedence over the past mod time
296				"If-None-Match":     "\"static-etag-for-testing-purposes\"",
297				"If-Modified-Since": time.Now().UTC().Add(-time.Hour).Format(http.TimeFormat),
298			},
299			want:        "",
300			status:      http.StatusNotModified,
301			contentType: "",
302
303			storage: map[string]map[string]string{
304				bucketName: {
305					"/test/test.html": "hello world!",
306				},
307			},
308		},
309	}
310
311	for _, tc := range tt {
312		t.Run(tc.name, func(t *testing.T) {
313			request := httptest.NewRequest("GET", dbpool.mkpath(tc.path), strings.NewReader(""))
314			for key, val := range tc.reqHeaders {
315				request.Header.Set(key, val)
316			}
317			responseRecorder := httptest.NewRecorder()
318
319			st, _ := storage.NewStorageMemory(tc.storage)
320			pubsub := NewPubsubChan()
321			defer func() {
322				_ = pubsub.Close()
323			}()
324			cfg := NewPgsConfig(logger, dbpool, st, pubsub)
325			cfg.Domain = "pgs.test"
326			router := NewWebRouter(cfg)
327			router.ServeHTTP(responseRecorder, request)
328
329			if responseRecorder.Code != tc.status {
330				t.Errorf("Want status '%d', got '%d'", tc.status, responseRecorder.Code)
331			}
332
333			ct := responseRecorder.Header().Get("content-type")
334			if ct != tc.contentType {
335				t.Errorf("Want content type '%s', got '%s'", tc.contentType, ct)
336			}
337
338			body := strings.TrimSpace(responseRecorder.Body.String())
339			if body != tc.want {
340				t.Errorf("Want '%s', got '%s'", tc.want, body)
341			}
342
343			if tc.wantUrl != "" {
344				location, err := responseRecorder.Result().Location()
345				if err != nil {
346					t.Errorf("err: %s", err.Error())
347				}
348				if location == nil {
349					t.Error("no location header in response")
350					return
351				}
352				if tc.wantUrl != location.String() {
353					t.Errorf("Want '%s', got '%s'", tc.wantUrl, location.String())
354				}
355			}
356		})
357	}
358}
359
360func TestDirectoryListing(t *testing.T) {
361	logger := slog.Default()
362	dbpool := NewPgsDb(logger)
363	bucketName := shared.GetAssetBucketName(dbpool.Users[0].ID)
364
365	tt := []struct {
366		name        string
367		path        string
368		status      int
369		contentType string
370		contains    []string
371		notContains []string
372		storage     map[string]map[string]string
373	}{
374		{
375			name:        "directory-without-index-shows-listing",
376			path:        "/docs/",
377			status:      http.StatusOK,
378			contentType: "text/html",
379			contains: []string{
380				"Index of /docs/",
381				"readme.md",
382				"guide.md",
383			},
384			storage: map[string]map[string]string{
385				bucketName: {
386					"/test/docs/readme.md": "# Readme",
387					"/test/docs/guide.md":  "# Guide",
388				},
389			},
390		},
391		{
392			name:        "directory-with-index-serves-index",
393			path:        "/docs/",
394			status:      http.StatusOK,
395			contentType: "text/html",
396			contains:    []string{"hello world!"},
397			notContains: []string{"Index of"},
398			storage: map[string]map[string]string{
399				bucketName: {
400					"/test/docs/index.html": "hello world!",
401					"/test/docs/readme.md":  "# Readme",
402				},
403			},
404		},
405		{
406			name:        "root-directory-without-index-shows-listing",
407			path:        "/",
408			status:      http.StatusOK,
409			contentType: "text/html",
410			contains: []string{
411				"Index of /",
412				"style.css",
413			},
414			storage: map[string]map[string]string{
415				bucketName: {
416					"/test/style.css": "body {}",
417				},
418			},
419		},
420		{
421			name:        "nested-directory-shows-parent-link",
422			path:        "/assets/images/",
423			status:      http.StatusOK,
424			contentType: "text/html",
425			contains: []string{
426				"Index of /assets/images/",
427				`href="../"`,
428				"logo.png",
429			},
430			storage: map[string]map[string]string{
431				bucketName: {
432					"/test/assets/images/logo.png": "png data",
433				},
434			},
435		},
436	}
437
438	for _, tc := range tt {
439		t.Run(tc.name, func(t *testing.T) {
440			request := httptest.NewRequest("GET", dbpool.mkpath(tc.path), strings.NewReader(""))
441			responseRecorder := httptest.NewRecorder()
442
443			st, _ := storage.NewStorageMemory(tc.storage)
444			pubsub := NewPubsubChan()
445			defer func() {
446				_ = pubsub.Close()
447			}()
448			cfg := NewPgsConfig(logger, dbpool, st, pubsub)
449			cfg.Domain = "pgs.test"
450			router := NewWebRouter(cfg)
451			router.ServeHTTP(responseRecorder, request)
452
453			if responseRecorder.Code != tc.status {
454				t.Errorf("Want status '%d', got '%d'", tc.status, responseRecorder.Code)
455			}
456
457			ct := responseRecorder.Header().Get("content-type")
458			if ct != tc.contentType {
459				t.Errorf("Want content type '%s', got '%s'", tc.contentType, ct)
460			}
461
462			body := responseRecorder.Body.String()
463			for _, want := range tc.contains {
464				if !strings.Contains(body, want) {
465					t.Errorf("Want body to contain '%s', got '%s'", want, body)
466				}
467			}
468			for _, notWant := range tc.notContains {
469				if strings.Contains(body, notWant) {
470					t.Errorf("Want body to NOT contain '%s', got '%s'", notWant, body)
471				}
472			}
473		})
474	}
475}
476
477type ImageStorageMemory struct {
478	*storage.StorageMemory
479	Opts  *storage.ImgProcessOpts
480	Fpath string
481}
482
483func (s *ImageStorageMemory) ServeObject(r *http.Request, bucket storage.Bucket, fpath string, opts *storage.ImgProcessOpts) (io.ReadCloser, *storage.ObjectInfo, error) {
484	s.Opts = opts
485	s.Fpath = fpath
486	info := storage.ObjectInfo{
487		Metadata: make(http.Header),
488	}
489	info.Metadata.Set("content-type", "image/jpeg")
490	return io.NopCloser(strings.NewReader("hello world!")), &info, nil
491}
492
493func TestImageManipulation(t *testing.T) {
494	logger := slog.Default()
495	dbpool := NewPgsDb(logger)
496	bucketName := shared.GetAssetBucketName(dbpool.Users[0].ID)
497
498	tt := []ApiExample{
499		{
500			name:        "root-img",
501			path:        "/app.jpg/s:500/rt:90",
502			want:        "hello world!",
503			status:      http.StatusOK,
504			contentType: "image/jpeg",
505
506			storage: map[string]map[string]string{
507				bucketName: {
508					"/test/app.jpg": "hello world!",
509				},
510			},
511		},
512		{
513			name:        "root-subdir-img",
514			path:        "/subdir/app.jpg/rt:90/s:500",
515			want:        "hello world!",
516			status:      http.StatusOK,
517			contentType: "image/jpeg",
518
519			storage: map[string]map[string]string{
520				bucketName: {
521					"/test/subdir/app.jpg": "hello world!",
522				},
523			},
524		},
525	}
526
527	for _, tc := range tt {
528		t.Run(tc.name, func(t *testing.T) {
529			request := httptest.NewRequest("GET", dbpool.mkpath(tc.path), strings.NewReader(""))
530			responseRecorder := httptest.NewRecorder()
531
532			memst, _ := storage.NewStorageMemory(tc.storage)
533			st := &ImageStorageMemory{
534				StorageMemory: memst,
535				Opts: &storage.ImgProcessOpts{
536					Ratio: &storage.Ratio{},
537				},
538			}
539			pubsub := NewPubsubChan()
540			defer func() {
541				_ = pubsub.Close()
542			}()
543			cfg := NewPgsConfig(logger, dbpool, st, pubsub)
544			cfg.Domain = "pgs.test"
545			router := NewWebRouter(cfg)
546			router.ServeHTTP(responseRecorder, request)
547
548			if responseRecorder.Code != tc.status {
549				t.Errorf("Want status '%d', got '%d'", tc.status, responseRecorder.Code)
550			}
551
552			ct := responseRecorder.Header().Get("content-type")
553			if ct != tc.contentType {
554				t.Errorf("Want content type '%s', got '%s'", tc.contentType, ct)
555			}
556
557			body := strings.TrimSpace(responseRecorder.Body.String())
558			if body != tc.want {
559				t.Errorf("Want '%s', got '%s'", tc.want, body)
560			}
561
562			if st.Opts.Ratio.Width != 500 {
563				t.Errorf("Want ratio width '500', got '%d'", st.Opts.Ratio.Width)
564				return
565			}
566
567			if st.Opts.Rotate != 90 {
568				t.Errorf("Want rotate '90', got '%d'", st.Opts.Rotate)
569				return
570			}
571		})
572	}
573}