package server_test import ( "encoding/json" "io" "net" "net/http" "net/http/cookiejar" "net/url" "path/filepath" "regexp" "strings" "testing" "time" "go.uber.org/fx/fxtest" "sneak.berlin/go/simplexcalc/internal/config" "sneak.berlin/go/simplexcalc/internal/database" "sneak.berlin/go/simplexcalc/internal/globals" "sneak.berlin/go/simplexcalc/internal/handlers" "sneak.berlin/go/simplexcalc/internal/logger" "sneak.berlin/go/simplexcalc/internal/middleware" "sneak.berlin/go/simplexcalc/internal/render" "sneak.berlin/go/simplexcalc/internal/server" "sneak.berlin/go/simplexcalc/internal/telemetry" ) // formNameField is the widget form's name input; refererHeader is the // header gorilla/csrf checks on every state-changing request. const ( formNameField = "name" refererHeader = "Referer" ) // response is a finished exchange: status, headers and the body, read // and closed before the helper returns. Handing the tests a value // rather than an *http.Response means no test can leak a connection by // forgetting to close one. type response struct { status int header http.Header body string } // instance is a running service: a real listener on an ephemeral port, // a real sqlite database in a temp dir, and the whole middleware chain. // Nothing is stubbed, because the things most likely to be wrong — // middleware order, route registration, embedded assets — are exactly // what a stub would paper over. type instance struct { base string client *http.Client } // testConfig is the configuration every instance starts from. func testConfig(t *testing.T) *config.Config { t.Helper() dir := t.TempDir() return &config.Config{ // Port 0: the OS picks a free one, so parallel tests do not // fight over a fixed port. Port: 0, DataDir: dir, DBPath: filepath.Join(dir, "test.db"), Debug: true, HSTS: false, MaxRequestBody: 1 << 20, RequestTimeout: 5 * time.Second, ShutdownGrace: 2 * time.Second, CSRFKeyEphemeral: true, } } // build constructs the graph by hand. fx would do the same wiring; done // explicitly, a constructor that starts needing a new dependency shows // up here as a compile error rather than as a runtime resolution // failure inside a container. func build(t *testing.T, lc *fxtest.Lifecycle, cfg *config.Config) *server.Server { t.Helper() g := &globals.Globals{Appname: "simplexcalc", Version: "test", Buildarch: "amd64"} log, err := logger.New(lc, logger.Params{Globals: g, Output: io.Discard}) if err != nil { t.Fatalf("logger: %v", err) } db, err := database.New(lc, database.Params{Config: cfg, Logger: log}) if err != nil { t.Fatalf("database: %v", err) } sentry, err := telemetry.NewSentry(lc, telemetry.SentryParams{ Config: cfg, Globals: g, Logger: log, }) if err != nil { t.Fatalf("sentry: %v", err) } metrics, err := telemetry.NewMetrics(telemetry.MetricsParams{Config: cfg}) if err != nil { t.Fatalf("metrics: %v", err) } renderer, err := render.New(lc, render.Params{Globals: g}) if err != nil { t.Fatalf("render: %v", err) } mw, err := middleware.New(lc, middleware.Params{ Config: cfg, Logger: log, Sentry: sentry, Metrics: metrics, }) if err != nil { t.Fatalf("middleware: %v", err) } h, err := handlers.New(lc, handlers.Params{ Config: cfg, Globals: g, Logger: log, Database: db, Renderer: renderer, Sentry: sentry, }) if err != nil { t.Fatalf("handlers: %v", err) } srv, err := server.New(lc, server.Params{ Logger: log, Globals: g, Config: cfg, Middleware: mw, Handlers: h, Metrics: metrics, }) if err != nil { t.Fatalf("server: %v", err) } return srv } // newInstance starts the service and registers its shutdown. func newInstance(t *testing.T, mutate func(*config.Config)) *instance { t.Helper() cfg := testConfig(t) if mutate != nil { mutate(cfg) } lc := fxtest.NewLifecycle(t) srv := build(t, lc, cfg) lc.RequireStart() t.Cleanup(lc.RequireStop) jar, err := cookiejar.New(nil) if err != nil { t.Fatalf("cookie jar: %v", err) } // The listener binds the unspecified address, so Addr() reads // "[::]:PORT". Dialling that works, but a cookie jar keyed on "::" // does not, and the CSRF flow depends on cookies coming back — so // the tests talk to the loopback address explicitly. _, port, err := net.SplitHostPort(srv.Addr()) if err != nil { t.Fatalf("parsing listen address %q: %v", srv.Addr(), err) } return &instance{ base: "http://127.0.0.1:" + port, client: &http.Client{ Jar: jar, Timeout: 10 * time.Second, // The redirect after a successful POST is under test, so // it must not be followed silently. CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, }, } } // host is the Host header value the service is reached by. func (i *instance) host() string { return strings.TrimPrefix(i.base, "http://") } func (i *instance) get(t *testing.T, path string) response { t.Helper() req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, i.base+path, nil) if err != nil { t.Fatalf("building GET %s: %v", path, err) } return i.do(t, req) } // post submits a form the way a browser does — including the Referer, // which gorilla/csrf checks against the request's Host on every // state-changing request. Omitting it is itself a CSRF rejection, so a // test that leaves it out proves nothing about the token. func (i *instance) post(t *testing.T, path string, form url.Values) response { t.Helper() return i.postWithHeaders(t, path, form, map[string]string{ refererHeader: i.base + "/", }) } // postWithHeaders is post with the headers stated explicitly, for the // reverse-proxy cases. func (i *instance) postWithHeaders( t *testing.T, path string, form url.Values, headers map[string]string, ) response { t.Helper() req, err := http.NewRequestWithContext( t.Context(), http.MethodPost, i.base+path, strings.NewReader(form.Encode()), ) if err != nil { t.Fatalf("building POST %s: %v", path, err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") for k, v := range headers { req.Header.Set(k, v) } return i.do(t, req) } func (i *instance) do(t *testing.T, req *http.Request) response { t.Helper() resp, err := i.client.Do(req) if err != nil { t.Fatalf("%s %s: %v", req.Method, req.URL.Path, err) } body, readErr := io.ReadAll(resp.Body) closeErr := resp.Body.Close() if closeErr != nil { t.Errorf("closing response body: %v", closeErr) } if readErr != nil { t.Fatalf("reading %s %s: %v", req.Method, req.URL.Path, readErr) } return response{status: resp.StatusCode, header: resp.Header, body: string(body)} } // TestServesPageFromEmbeddedTemplate is one of the definition-of-done // claims: a running process answers / from the template compiled into // it. func TestServesPageFromEmbeddedTemplate(t *testing.T) { t.Parallel() i := newInstance(t, nil) resp := i.get(t, "/") if resp.status != http.StatusOK { t.Fatalf("status = %d, want 200", resp.status) } for _, want := range []string{"", "