check / check (push) Waiting to run
New handler tests in internal/handlers, with no network: GET / shows the login form without a session; a wrong key shows it again with an error and sets no session cookie; the right key answers 303 with a session cookie marked Secure, HttpOnly and SameSite=Strict, with which GET / shows the generator page; GET /logout empties the cookie with Max-Age=0; POST /generate without a session answers 303 to /; /v1/e/ serves a valid token's image, answers 410 for an expired token and 400 for one changed, cut short or made with another signing key; a URL made on the generator page is served by /v1/e/. No code changes. Model: opus-5-5
244 lines
6.9 KiB
Go
244 lines
6.9 KiB
Go
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)
|
|
}
|