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