The new server test logs in and out on pixa built as pixad builds it, starts pixa again on the same state directory, and checks that a cookie copied before the logout is refused, which fails if pixad keeps logouts in memory only. The new handler test closes the database so the logout cannot be saved, and checks for 500, no session cookie set, and the session still valid. Model: opus-5-5
385 lines
11 KiB
Go
385 lines
11 KiB
Go
package handlers
|
|
|
|
import (
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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)
|
|
}
|
|
}
|
|
|
|
// TestHandleLogout_EndsCopiedSession verifies that after GET /logout a copy
|
|
// of the session cookie taken before it shows the login page, not the
|
|
// generator page.
|
|
func TestHandleLogout_EndsCopiedSession(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h, srv := newCSRFTestRouter(t)
|
|
|
|
copied := newSessionCookie(t, h)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodGet, "/logout", nil)
|
|
req.AddCookie(copied)
|
|
|
|
rec := httptest.NewRecorder()
|
|
h.HandleLogout().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Fatalf("GET /logout status = %d, want %d",
|
|
rec.Code, http.StatusSeeOther)
|
|
}
|
|
|
|
req = httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
|
req.AddCookie(copied)
|
|
|
|
rec = httptest.NewRecorder()
|
|
srv.ServeHTTP(rec, req)
|
|
|
|
body := rec.Body.String()
|
|
t.Logf("GET / with the copied cookie: status %d, login page %v",
|
|
rec.Code, strings.Contains(body, loginForm))
|
|
|
|
if !strings.Contains(body, loginForm) || strings.Contains(body, generatorForm) {
|
|
t.Error("a cookie copied before /logout still opens the generator page")
|
|
}
|
|
}
|
|
|
|
// TestHandleLogout_SaveFails_Answers500 verifies that when the logout cannot
|
|
// be saved in the database, GET /logout answers 500, leaves the session
|
|
// cookie in place and ends no session.
|
|
func TestHandleLogout_SaveFails_Answers500(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h, srv := newCSRFTestRouter(t)
|
|
|
|
db := setupTestDB(t)
|
|
|
|
sessMgr, err := session.NewManagerWithDatabase(
|
|
t.Context(), testSigningKey, db)
|
|
if err != nil {
|
|
t.Fatalf("session.NewManagerWithDatabase() error = %v", err)
|
|
}
|
|
|
|
h.sessMgr = sessMgr
|
|
cookie := newSessionCookie(t, h)
|
|
|
|
// Saving the logout fails on a closed database.
|
|
err = db.Close()
|
|
if err != nil {
|
|
t.Fatalf("closing the database: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodGet, "/logout", nil)
|
|
req.AddCookie(cookie)
|
|
|
|
rec := httptest.NewRecorder()
|
|
h.HandleLogout().ServeHTTP(rec, req)
|
|
|
|
t.Logf("GET /logout: status %d, Set-Cookie %q",
|
|
rec.Code, rec.Header().Get("Set-Cookie"))
|
|
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Errorf("GET /logout status = %d, want %d",
|
|
rec.Code, http.StatusInternalServerError)
|
|
}
|
|
|
|
if c := findSessionCookie(rec); c != nil {
|
|
t.Errorf("a logout that failed set the session cookie: %s", c)
|
|
}
|
|
|
|
req = httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
|
req.AddCookie(cookie)
|
|
|
|
rec = httptest.NewRecorder()
|
|
srv.ServeHTTP(rec, req)
|
|
|
|
if !strings.Contains(rec.Body.String(), generatorForm) {
|
|
t.Error("a logout that failed ended the session")
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// TestGeneratePost_URLWithTTLExpires verifies that a URL the generator page
|
|
// makes with a ttl of one second is served by /v1/e/ at once and answers 410
|
|
// once the ttl has passed.
|
|
func TestGeneratePost_URLWithTTLExpires(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)},
|
|
ttlField: {"1"},
|
|
})
|
|
|
|
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())
|
|
}
|
|
|
|
imageRec := httptest.NewRecorder()
|
|
imageSrv.ServeHTTP(imageRec, httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodGet, match[1], nil))
|
|
|
|
requireServedPhoto(t, imageRec)
|
|
|
|
// The URL keeps the time it expires in whole seconds and is served
|
|
// through the whole of that second, so a ttl of one second has passed
|
|
// for certain two seconds after the URL was made.
|
|
time.Sleep(2 * time.Second)
|
|
|
|
imageRec = httptest.NewRecorder()
|
|
imageSrv.ServeHTTP(imageRec, httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodGet, match[1], nil))
|
|
|
|
t.Logf("GET %s after the ttl: %d %q", match[1], imageRec.Code, imageRec.Body)
|
|
|
|
if imageRec.Code != http.StatusGone {
|
|
t.Errorf("status after the ttl = %d, want %d",
|
|
imageRec.Code, http.StatusGone)
|
|
}
|
|
}
|