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// }