check / check (push) Waiting to run
The new package internal/providers builds the cache, the fetcher, the processor, the image service, the session manager and the encrypted URL generator from the config. pixad's fx graph provides them with providers.Constructors(), and handlers.New receives them built. The server tests build the same graph, with only the fetcher's dial function replaced. database.New opens the database, so these parts can read it while fx builds them; the cache's stop hook still runs before the database's. Each setting's default is declared in internal/config, and internal/providers works out the default cache_max_bytes. The cache's MaxBytes is the one value for the disk cache limit, and 0 turns the disk cache off. The fetcher and the processor take explicit values. Model: opus-5-5
274 lines
7.3 KiB
Go
274 lines
7.3 KiB
Go
package server
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"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/providers"
|
|
"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.
|
|
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,
|
|
func() prometheus.Registerer { return prometheus.NewRegistry() },
|
|
),
|
|
fx.Provide(providers.Constructors()...),
|
|
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")
|
|
}
|
|
}
|
|
|
|
// TestLostStateDirectoryEndsEveryLoginSession verifies that a pixa started on
|
|
// an empty state directory, as pixad is after a restart that lost its state
|
|
// directory, refuses a session cookie from before, and that a login on it
|
|
// opens the generator page.
|
|
func TestLostStateDirectoryEndsEveryLoginSession(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newTestServer(t)
|
|
|
|
before := logIn(t, s)
|
|
if !opensGeneratorPage(t, s, before) {
|
|
t.Fatal("logging in did not open the generator page")
|
|
}
|
|
|
|
emptyStateDir := *s.config
|
|
emptyStateDir.StateDir = t.TempDir()
|
|
emptyStateDir.DBURL = "file:" +
|
|
filepath.Join(emptyStateDir.StateDir, "state.sqlite3")
|
|
|
|
restarted := startPixa(t, &emptyStateDir)
|
|
|
|
opens := opensGeneratorPage(t, restarted, before)
|
|
t.Logf("on an empty state directory, the cookie from before opens the "+
|
|
"generator page: %v", opens)
|
|
|
|
if opens {
|
|
t.Error("on an empty state directory, a cookie from before still " +
|
|
"opens the generator page")
|
|
}
|
|
|
|
if !opensGeneratorPage(t, restarted, logIn(t, restarted)) {
|
|
t.Error("a login on an empty state directory does not open the " +
|
|
"generator page")
|
|
}
|
|
}
|