package handlers import ( "io" "log/slog" "net/http" "net/http/httptest" "net/url" "regexp" "strings" "testing" "github.com/go-chi/chi/v5" "sneak.berlin/go/pixa/internal/config" "sneak.berlin/go/pixa/internal/encurl" "sneak.berlin/go/pixa/internal/session" ) // testSigningKey is a throwaway signing key for the CSRF flow tests. It // seeds the session manager, the encrypted-URL generator, and the CSRF // token key, exactly as the real signing key does in production. const testSigningKey = "test-signing-key-0123456789abcdef" // csrfFieldPattern extracts the token rendered by csrf.TemplateField into // the form. The field name is gorilla/csrf's default. var csrfFieldPattern = regexp.MustCompile( `name="gorilla\.csrf\.Token" value="([^"]+)"`) // newCSRFTestRouter builds a router that mirrors the production wiring for // the CSRF-protected UI routes (see server.SetupRoutes): the login and // generator forms and their POST targets sit behind the real CSRF // middleware. Requests are marked plaintext (Debug: true) so the flow runs // over httptest's http transport without an https Referer. func newCSRFTestRouter(t *testing.T) (*Handlers, http.Handler) { t.Helper() cfg := &config.Config{SigningKey: testSigningKey, Debug: true} sessMgr, err := session.NewManager(testSigningKey) if err != nil { t.Fatalf("session.NewManager() error = %v", err) } encGen, err := encurl.NewGenerator(testSigningKey) if err != nil { t.Fatalf("encurl.NewGenerator() error = %v", err) } protect, err := newCSRFProtect(testSigningKey, cfg.Debug) if err != nil { t.Fatalf("newCSRFProtect() error = %v", err) } h := &Handlers{ log: slog.New(slog.NewTextHandler(io.Discard, nil)), config: cfg, sessMgr: sessMgr, encGen: encGen, csrfProtect: protect, } r := chi.NewRouter() r.Group(func(r chi.Router) { r.Use(h.CSRF()) r.Get("/", h.HandleRoot()) r.Post("/", h.HandleRoot()) r.Post("/generate", h.HandleGenerateURL()) }) return h, r } // csrfCredentials performs a GET that renders a form and returns the CSRF // cookies the middleware set and the token embedded in the form. Passing // the authenticated session cookie renders the generator form instead of // the login form. func csrfCredentials( t *testing.T, srv http.Handler, reqCookies []*http.Cookie, ) ([]*http.Cookie, string) { t.Helper() req := httptest.NewRequest(http.MethodGet, "/", nil) for _, c := range reqCookies { req.AddCookie(c) } rec := httptest.NewRecorder() srv.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("GET / status = %d, want %d", rec.Code, http.StatusOK) } match := csrfFieldPattern.FindStringSubmatch(rec.Body.String()) if match == nil { t.Fatalf("no CSRF token field found in rendered form") } return rec.Result().Cookies(), match[1] } // postForm submits form values with the given cookies and returns the // recorder. func postForm( srv http.Handler, path string, cookies []*http.Cookie, form url.Values, ) *httptest.ResponseRecorder { req := httptest.NewRequest( http.MethodPost, path, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") for _, c := range cookies { req.AddCookie(c) } rec := httptest.NewRecorder() srv.ServeHTTP(rec, req) return rec } // TestLoginPostRejectedWithoutToken verifies that POST / with no CSRF token // is rejected. This is login CSRF: no session cookie exists yet, so the // protection must rest on a token bound to a pre-session cookie. func TestLoginPostRejectedWithoutToken(t *testing.T) { t.Parallel() _, srv := newCSRFTestRouter(t) rec := postForm(srv, "/", nil, url.Values{"key": {testSigningKey}}) if rec.Code != http.StatusForbidden { t.Errorf("POST / without token status = %d, want %d", rec.Code, http.StatusForbidden) } } // TestLoginPostRejectedWithForeignToken verifies that a token that does not // match the request's CSRF cookie is rejected: a token minted for one // cookie cannot authorize a request carrying a different cookie. func TestLoginPostRejectedWithForeignToken(t *testing.T) { t.Parallel() _, srv := newCSRFTestRouter(t) cookiesA, _ := csrfCredentials(t, srv, nil) _, tokenB := csrfCredentials(t, srv, nil) rec := postForm(srv, "/", cookiesA, url.Values{ "key": {testSigningKey}, "gorilla.csrf.Token": {tokenB}, }) if rec.Code != http.StatusForbidden { t.Errorf("POST / with foreign token status = %d, want %d", rec.Code, http.StatusForbidden) } } // TestLoginPostAcceptedWithValidToken verifies that POST / with a matching // cookie and token succeeds: the login is processed and a session is // established (303 redirect). func TestLoginPostAcceptedWithValidToken(t *testing.T) { t.Parallel() _, srv := newCSRFTestRouter(t) cookies, token := csrfCredentials(t, srv, nil) rec := postForm(srv, "/", cookies, url.Values{ "key": {testSigningKey}, "gorilla.csrf.Token": {token}, }) if rec.Code != http.StatusSeeOther { t.Fatalf("POST / with valid token status = %d, want %d", rec.Code, http.StatusSeeOther) } var authed bool for _, c := range rec.Result().Cookies() { if c.Name == session.CookieName && c.Value != "" { authed = true } } if !authed { t.Error("valid login did not set a session cookie") } } // TestGeneratePostRejectedWithoutToken verifies that POST /generate is // rejected without a CSRF token even when the request carries a valid // authenticated session. The session cookie is not sufficient; the policy // requires a CSRF token on this cookie-authenticated form. func TestGeneratePostRejectedWithoutToken(t *testing.T) { t.Parallel() h, srv := newCSRFTestRouter(t) sessionCookie := newSessionCookie(t, h) rec := postForm(srv, "/generate", []*http.Cookie{sessionCookie}, url.Values{"url": {"https://example.com/a.jpg"}}) if rec.Code != http.StatusForbidden { t.Errorf("POST /generate without token status = %d, want %d", rec.Code, http.StatusForbidden) } } // TestGeneratePostAcceptedWithValidToken verifies that POST /generate // succeeds with a valid session and a matching CSRF cookie and token. func TestGeneratePostAcceptedWithValidToken(t *testing.T) { t.Parallel() h, srv := newCSRFTestRouter(t) sessionCookie := newSessionCookie(t, h) cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie}) cookies = append(cookies, sessionCookie) rec := postForm(srv, "/generate", cookies, url.Values{ "url": {"https://example.com/a.jpg"}, "format": {"jpeg"}, "gorilla.csrf.Token": {token}, }) if rec.Code != http.StatusOK { t.Fatalf("POST /generate with valid token status = %d, want %d", rec.Code, http.StatusOK) } if !strings.Contains(rec.Body.String(), "/v1/e/") { t.Error("generator response did not contain a generated URL") } } // newSessionCookie creates an authenticated session cookie via the // handler's session manager. func newSessionCookie(t *testing.T, h *Handlers) *http.Cookie { t.Helper() rec := httptest.NewRecorder() err := h.sessMgr.CreateSession(rec) if err != nil { t.Fatalf("CreateSession() error = %v", err) } for _, c := range rec.Result().Cookies() { if c.Name == session.CookieName { return c } } t.Fatalf("session manager did not set a %q cookie", session.CookieName) return nil }