Antonio Mika
·
2026-10-24
1package pgs
2
3import (
4 "io"
5 "log/slog"
6 "net/http"
7 "net/http/httptest"
8 "net/url"
9 "strings"
10 "testing"
11
12 "github.com/picosh/pico/pkg/shared"
13 "github.com/picosh/pico/pkg/storage"
14 "golang.org/x/net/html"
15)
16
17func loginFormValues(t *testing.T, body string) url.Values {
18 t.Helper()
19 values := url.Values{}
20 tokens := html.NewTokenizer(strings.NewReader(body))
21 for {
22 switch tokens.Next() {
23 case html.ErrorToken:
24 if err := tokens.Err(); err != io.EOF {
25 t.Fatal(err)
26 }
27 if values.Get("redirect") == "" {
28 t.Fatal("login form is missing redirect")
29 }
30 return values
31 case html.StartTagToken, html.SelfClosingTagToken:
32 token := tokens.Token()
33 if token.Data != "input" {
34 continue
35 }
36 var name, value string
37 for _, attr := range token.Attr {
38 switch attr.Key {
39 case "name":
40 name = attr.Val
41 case "value":
42 value = attr.Val
43 }
44 }
45 if name != "" {
46 values.Set(name, value)
47 }
48 }
49 }
50}
51
52func TestLoginPreservesRequestURL(t *testing.T) {
53 t.Chdir("../../..")
54 logger := slog.Default()
55 dbpool := NewPgsDb(logger)
56 project, err := dbpool.FindProjectByName(dbpool.Users[0].ID, "test")
57 if err != nil {
58 t.Fatal(err)
59 }
60 project.Acl.Type = "http-pass"
61 project.Acl.Data = []string{"test-password"}
62 st, err := storage.NewStorageMemory(map[string]map[string]string{
63 shared.GetAssetBucketName(dbpool.Users[0].ID): {},
64 })
65 if err != nil {
66 t.Fatal(err)
67 }
68 pubsub := NewPubsubChan()
69 t.Cleanup(func() { _ = pubsub.Close() })
70 cfg := NewConfig(logger, dbpool, st, pubsub)
71 cfg.Domain = "pgs.test"
72 router := NewWebRouter(cfg)
73
74 paths := []string{
75 "/",
76 "/?page=2&sort=asc",
77 "/docs/page.html?tag=one&tag=two&empty=&q=a+b%26c%3Dd",
78 "/docs/a%20b.html?next=%2Fother%3Fx%3D1%26y%3D2",
79 "/docs/page.html?redirect=%2Felsewhere&error=invalid",
80 }
81 for _, path := range paths {
82 t.Run(path, func(t *testing.T) {
83 response := httptest.NewRecorder()
84 router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, dbpool.mkpath(path), nil))
85 if response.Code != http.StatusForbidden {
86 t.Fatalf("login form status = %d", response.Code)
87 }
88 form := loginFormValues(t, response.Body.String())
89 if got := form.Get("redirect"); got != path {
90 t.Fatalf("form redirect = %q, want %q", got, path)
91 }
92
93 for _, password := range []string{"wrong-password", "", "test-password"} {
94 form.Set("password", password)
95 request := httptest.NewRequest(http.MethodPost, dbpool.mkpath("/pgs/login"), strings.NewReader(form.Encode()))
96 request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
97 response = httptest.NewRecorder()
98 router.ServeHTTP(response, request)
99 if password != "test-password" {
100 if response.Code != http.StatusForbidden || !strings.Contains(response.Body.String(), "Invalid password") {
101 t.Fatalf("invalid password response: %d %s", response.Code, response.Body.String())
102 }
103 form = loginFormValues(t, response.Body.String())
104 if got := form.Get("redirect"); got != path {
105 t.Fatalf("retry redirect = %q, want %q", got, path)
106 }
107 continue
108 }
109 if response.Code != http.StatusSeeOther {
110 t.Fatalf("login status = %d", response.Code)
111 }
112 if got := response.Header().Get("Location"); got != path {
113 t.Errorf("redirect = %q, want %q", got, path)
114 }
115 cookies := response.Result().Cookies()
116 if len(cookies) != 1 || cookies[0].Name != getCookieName(project.Name) || cookies[0].Value != project.ID {
117 t.Errorf("login did not set the project session cookie")
118 }
119 }
120 })
121 }
122}
123
124func TestLoginRejectsExternalRedirect(t *testing.T) {
125 t.Chdir("../../..")
126 for _, target := range []string{"", "https://example.com/path", "//example.com/path", `/\example.com/path`, "relative/path", "/\nLocation: https://example.com"} {
127 t.Run(target, func(t *testing.T) {
128 form := url.Values{"project": {"test"}, "redirect": {target}}
129 request := httptest.NewRequest(http.MethodPost, "https://example.pgs.test/pgs/login", strings.NewReader(form.Encode()))
130 request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
131 response := httptest.NewRecorder()
132 handleLogin(response, request, &Config{Logger: slog.Default()})
133 if got := loginFormValues(t, response.Body.String()).Get("redirect"); got != "/" {
134 t.Errorf("redirect = %q, want /", got)
135 }
136 })
137 }
138}