package handlers import ( "log/slog" "net/http" "net/http/httptest" "net/url" "regexp" "strings" "testing" "sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/session" ) // formatField is the generator form's format field name. const formatField = "format" // Markers telling the login page from the generator page. const ( loginForm = `action="/"` loginKeyInput = `name="key"` generatorForm = `action="/generate"` ) // generatedURLPattern extracts the path of the URL the generator page shows. // The test router runs with debug on, so the URL starts with http, and its // host is httptest's default request host. var generatedURLPattern = regexp.MustCompile( `value="http://example\.com(/v1/e/[^"]+)"`) // findSessionCookie returns the session cookie rec sets, or nil if it sets // none. func findSessionCookie(rec *httptest.ResponseRecorder) *http.Cookie { for _, c := range rec.Result().Cookies() { if c.Name == session.CookieName { return c } } return nil } // TestHandleRoot_NoSession_ShowsLoginForm verifies that GET / without a // login session shows the login form. func TestHandleRoot_NoSession_ShowsLoginForm(t *testing.T) { t.Parallel() _, srv := newCSRFTestRouter(t) rec := httptest.NewRecorder() srv.ServeHTTP(rec, httptest.NewRequestWithContext( t.Context(), http.MethodGet, "/", nil)) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } body := rec.Body.String() if !strings.Contains(body, loginForm) || !strings.Contains(body, loginKeyInput) { t.Errorf("page is not the login form: %s", body) } } // TestLoginPost_WrongKey_ShowsErrorWithoutSession verifies that a wrong key // shows the login form again with an error, and sets no session cookie. func TestLoginPost_WrongKey_ShowsErrorWithoutSession(t *testing.T) { t.Parallel() _, srv := newCSRFTestRouter(t) cookies, token := csrfCredentials(t, srv, nil) rec := postForm(srv, "/", cookies, url.Values{ loginKeyField: {"wrong-signing-key-fedcba9876543210"}, csrfTokenField: {token}, }) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } body := rec.Body.String() if !strings.Contains(body, loginForm) || !strings.Contains(body, loginKeyInput) { t.Errorf("page is not the login form: %s", body) } if !strings.Contains(body, "Invalid signing key") { t.Error("login form does not show the error") } if c := findSessionCookie(rec); c != nil { t.Errorf("wrong key set a session cookie: %s", c) } } // TestLoginPost_RightKey_SetsSessionCookie verifies that the right key answers // 303 to / with a session cookie marked Secure, HttpOnly and SameSite=Strict, // and that GET / with that cookie shows the generator page. func TestLoginPost_RightKey_SetsSessionCookie(t *testing.T) { t.Parallel() _, srv := newCSRFTestRouter(t) cookies, token := csrfCredentials(t, srv, nil) rec := postForm(srv, "/", cookies, url.Values{ loginKeyField: {testSigningKey}, csrfTokenField: {token}, }) if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" { t.Fatalf("status = %d, Location = %q, want %d to /", rec.Code, rec.Header().Get("Location"), http.StatusSeeOther) } sessionCookie := findSessionCookie(rec) if sessionCookie == nil { t.Fatal("right key set no session cookie") } t.Logf("Set-Cookie: %s", sessionCookie) if !sessionCookie.Secure { t.Error("session cookie is not Secure") } if !sessionCookie.HttpOnly { t.Error("session cookie is not HttpOnly") } if sessionCookie.SameSite != http.SameSiteStrictMode { t.Errorf("session cookie SameSite = %v, want Strict", sessionCookie.SameSite) } req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil) req.AddCookie(sessionCookie) rec = httptest.NewRecorder() srv.ServeHTTP(rec, req) if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), generatorForm) { t.Errorf("GET / with the session cookie: status = %d, "+ "want %d and the generator page", rec.Code, http.StatusOK) } } // TestHandleLogout_ClearsSessionCookie verifies that GET /logout answers 303 // to / and replaces the session cookie with an empty one sent with // Max-Age=0, which makes the browser delete it. func TestHandleLogout_ClearsSessionCookie(t *testing.T) { t.Parallel() h, _ := newCSRFTestRouter(t) req := httptest.NewRequestWithContext( t.Context(), http.MethodGet, "/logout", nil) req.AddCookie(newSessionCookie(t, h)) rec := httptest.NewRecorder() h.HandleLogout().ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" { t.Fatalf("status = %d, Location = %q, want %d to /", rec.Code, rec.Header().Get("Location"), http.StatusSeeOther) } t.Logf("Set-Cookie: %s", rec.Header().Get("Set-Cookie")) sessionCookie := findSessionCookie(rec) if sessionCookie == nil { t.Fatal("logout did not set the session cookie") } if sessionCookie.Value != "" { t.Errorf("session cookie value = %q, want empty", sessionCookie.Value) } // net/http reads a Max-Age=0 attribute back as MaxAge -1. if sessionCookie.MaxAge != -1 { t.Errorf("session cookie MaxAge = %d, want -1 (Max-Age=0)", sessionCookie.MaxAge) } } // TestGeneratePost_NoSession_RedirectsToLogin verifies that POST /generate // with a valid CSRF token but no login session answers 303 to / and makes no // URL. func TestGeneratePost_NoSession_RedirectsToLogin(t *testing.T) { t.Parallel() _, srv := newCSRFTestRouter(t) cookies, token := csrfCredentials(t, srv, nil) rec := postForm(srv, "/generate", cookies, url.Values{ sourceURLField: {testSourceURL}, csrfTokenField: {token}, }) if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" { t.Fatalf("status = %d, Location = %q, want %d to /", rec.Code, rec.Header().Get("Location"), http.StatusSeeOther) } if strings.Contains(rec.Body.String(), "/v1/e/") { t.Error("a URL was made without a login session") } } // TestGeneratePost_URLServesImage verifies that the URL the generator page // makes is served by /v1/e/. The image route runs on handlers of its own, // made with the same signing key. func TestGeneratePost_URLServesImage(t *testing.T) { t.Parallel() _, imageSrv := newSignedHostServer(t, slog.New(slog.DiscardHandler)) rec := generatePost(t, url.Values{ sourceURLField: {"https://" + signedHost + photoPath}, widthField: {"50"}, heightField: {"50"}, formatField: {string(imgcache.FormatJPEG)}, }) if rec.Code != http.StatusOK { t.Fatalf("POST /generate status = %d, want %d", rec.Code, http.StatusOK) } match := generatedURLPattern.FindStringSubmatch(rec.Body.String()) if match == nil { t.Fatalf("generator page shows no URL: %s", rec.Body.String()) } t.Logf("generated URL path: %s", match[1]) imageRec := httptest.NewRecorder() imageSrv.ServeHTTP(imageRec, httptest.NewRequestWithContext( t.Context(), http.MethodGet, match[1], nil)) requireServedPhoto(t, imageRec) }