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