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}