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