main pico / pkg / apps / pgs / http_auth_test.go
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}