package server import ( "net/http" "net/http/httptest" "net/url" "regexp" "strings" "testing" "go.uber.org/fx" "go.uber.org/fx/fxtest" "sneak.berlin/go/pixa/internal/config" "sneak.berlin/go/pixa/internal/database" "sneak.berlin/go/pixa/internal/globals" "sneak.berlin/go/pixa/internal/handlers" "sneak.berlin/go/pixa/internal/healthcheck" "sneak.berlin/go/pixa/internal/logger" "sneak.berlin/go/pixa/internal/middleware" "sneak.berlin/go/pixa/internal/session" ) // logoutFormPattern extracts the CSRF token rendered into the generator // page's logout form. var logoutFormPattern = regexp.MustCompile( `(?s)action="/logout".*?name="gorilla\.csrf\.Token" value="([^"]+)"`) // startPixa starts the components pixad's fx app builds, from cfg, and // returns the server with its routes, as newTestServer does. func startPixa(t *testing.T, cfg *config.Config) *Server { t.Helper() var ( h *handlers.Handlers mw *middleware.Middleware ) app := fxtest.New(t, fx.Supply(cfg), fx.Provide( globals.New, logger.New, database.New, healthcheck.New, handlers.New, middleware.New, ), fx.Populate(&h, &mw), ) app.RequireStart() t.Cleanup(app.RequireStop) s := &Server{config: cfg, mw: mw, h: h} s.SetupRoutes() return s } // logIn logs in to s with the signing key, as postLogin does, and returns // the session cookie it sets. func logIn(t *testing.T, s *Server) *http.Cookie { t.Helper() rec := postLogin(t, s, firstClient, "", testSigningKey) for _, c := range rec.Result().Cookies() { if c.Name == session.CookieName { return c } } t.Fatal("logging in set no session cookie") return nil } // opensGeneratorPage reports whether GET / with cookie shows s's generator // page, which only a valid login session opens. func opensGeneratorPage(t *testing.T, s *Server, cookie *http.Cookie) bool { t.Helper() req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil) req.AddCookie(cookie) rec := httptest.NewRecorder() s.ServeHTTP(rec, req) return rec.Code == http.StatusOK && strings.Contains(rec.Body.String(), `action="/generate"`) } // postLogout loads the generator page with GET / and sessionCookie and // submits its logout form with POST /logout, as a browser does. func postLogout( t *testing.T, s *Server, sessionCookie *http.Cookie, ) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil) req.AddCookie(sessionCookie) page := httptest.NewRecorder() s.ServeHTTP(page, req) match := logoutFormPattern.FindStringSubmatch(page.Body.String()) if match == nil { t.Fatal("the generator page has no logout form with a CSRF token") } form := url.Values{"gorilla.csrf.Token": {match[1]}} req = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/logout", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(sessionCookie) for _, c := range page.Result().Cookies() { req.AddCookie(c) } rec := httptest.NewRecorder() s.ServeHTTP(rec, req) return rec } // TestLogoutWithoutCSRFTokenEndsNoSession verifies that a request to /logout // carrying the session cookie but not the logout form's CSRF token ends no // session: GET /logout, as an image on another page of the same site makes a // browser send, answers 405, and POST /logout without the token answers 403. func TestLogoutWithoutCSRFTokenEndsNoSession(t *testing.T) { t.Parallel() tests := []struct { method string want int }{ {http.MethodGet, http.StatusMethodNotAllowed}, {http.MethodPost, http.StatusForbidden}, } for _, tc := range tests { t.Run(tc.method, func(t *testing.T) { t.Parallel() s := newTestServer(t) cookie := logIn(t, s) req := httptest.NewRequestWithContext( t.Context(), tc.method, "/logout", nil) req.AddCookie(cookie) rec := httptest.NewRecorder() s.ServeHTTP(rec, req) t.Logf("%s /logout without the CSRF token: status %d", tc.method, rec.Code) if rec.Code != tc.want { t.Errorf("%s /logout without the CSRF token status = %d, "+ "want %d", tc.method, rec.Code, tc.want) } if !opensGeneratorPage(t, s, cookie) { t.Errorf("%s /logout without the CSRF token ended the "+ "login session", tc.method) } }) } } // TestLoginAfterLogoutOpensGeneratorPage verifies that after a logout from // the generator page a copy of the session cookie taken before it is // refused, and that a login after it opens the generator page. func TestLoginAfterLogoutOpensGeneratorPage(t *testing.T) { t.Parallel() s := newTestServer(t) copied := logIn(t, s) if !opensGeneratorPage(t, s, copied) { t.Fatal("logging in did not open the generator page") } logout := postLogout(t, s, copied) if logout.Code != http.StatusSeeOther { t.Fatalf("POST /logout status = %d, want %d", logout.Code, http.StatusSeeOther) } if opensGeneratorPage(t, s, copied) { t.Error("a cookie copied before the logout still opens the " + "generator page") } opens := opensGeneratorPage(t, s, logIn(t, s)) t.Logf("a login after the logout opens the generator page: %v", opens) if !opens { t.Error("a login after the logout does not open the generator page") } } // TestLogoutOutlastsRestart verifies that a copy of the session cookie taken // before a logout is still refused by a pixa started again on the same state // directory, as pixad is after a restart. func TestLogoutOutlastsRestart(t *testing.T) { t.Parallel() s := newTestServer(t) copied := logIn(t, s) if !opensGeneratorPage(t, s, copied) { t.Fatal("logging in did not open the generator page") } logout := postLogout(t, s, copied) if logout.Code != http.StatusSeeOther { t.Fatalf("POST /logout status = %d, want %d", logout.Code, http.StatusSeeOther) } restarted := startPixa(t, s.config) opens := opensGeneratorPage(t, restarted, copied) t.Logf("after the restart, the copied cookie opens the generator page: %v", opens) if opens { t.Error("after a restart, a cookie copied before the logout " + "still opens the generator page") } }