The template's files at a77fd30, without its history or LICENSE, after script/rename simplexcalc. Model: opus-5-5
561 lines
15 KiB
Go
561 lines
15 KiB
Go
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{"<!doctype html>", "<nav>", "<footer>", "widgets ("} {
|
|
if !strings.Contains(resp.body, want) {
|
|
t.Errorf("page is missing %q", want)
|
|
}
|
|
}
|
|
|
|
if resp.header.Get("X-Content-Type-Options") != "nosniff" {
|
|
t.Error("security headers are missing from a normal page response")
|
|
}
|
|
|
|
if resp.header.Get(middleware.RequestIDHeader) == "" {
|
|
t.Error("no request id on the response")
|
|
}
|
|
}
|
|
|
|
// TestServesEmbeddedStaticAsset, with the cache policy the route sets.
|
|
func TestServesEmbeddedStaticAsset(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
i := newInstance(t, nil)
|
|
|
|
resp := i.get(t, "/static/css/style.css")
|
|
|
|
if resp.status != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", resp.status)
|
|
}
|
|
|
|
if !strings.Contains(resp.body, "--fg:") {
|
|
t.Error("the served asset is not the embedded stylesheet")
|
|
}
|
|
|
|
if !strings.Contains(resp.header.Get("Cache-Control"), "immutable") {
|
|
t.Errorf("Cache-Control = %q", resp.header.Get("Cache-Control"))
|
|
}
|
|
}
|
|
|
|
// TestStaticDirectoriesAreNotListed: enumerating what the binary ships
|
|
// is a capability no caller needs.
|
|
func TestStaticDirectoriesAreNotListed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
i := newInstance(t, nil)
|
|
|
|
for _, path := range []string{"/static/", "/static/css/"} {
|
|
resp := i.get(t, path)
|
|
|
|
if resp.status != http.StatusNotFound {
|
|
t.Errorf("GET %s: status = %d, want 404", path, resp.status)
|
|
}
|
|
|
|
if strings.Contains(resp.body, "style.css") {
|
|
t.Errorf("GET %s listed the directory contents", path)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHealthcheckReportsMigratedSchema proves the embedded migrations
|
|
// ran during startup, from a directory that was empty a moment ago.
|
|
func TestHealthcheckReportsMigratedSchema(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
i := newInstance(t, nil)
|
|
|
|
resp := i.get(t, "/.well-known/healthcheck.json")
|
|
|
|
if resp.status != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", resp.status)
|
|
}
|
|
|
|
var health handlers.HealthResponse
|
|
|
|
err := json.Unmarshal([]byte(resp.body), &health)
|
|
if err != nil {
|
|
t.Fatalf("decoding healthcheck: %v (body %q)", err, resp.body)
|
|
}
|
|
|
|
if !health.OK || !health.DatabaseOK {
|
|
t.Errorf("healthcheck reports unhealthy: %+v", health)
|
|
}
|
|
|
|
if health.SchemaVersion != 1 {
|
|
t.Errorf("schema_version = %d, want 1", health.SchemaVersion)
|
|
}
|
|
}
|
|
|
|
// TestMetricsEndpointIsServedAndGated covers both halves of the metrics
|
|
// requirement in one running service.
|
|
func TestMetricsEndpointIsServedAndGated(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("open when unconfigured", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
i := newInstance(t, nil)
|
|
|
|
// One request first: the middleware records a request after
|
|
// the response is written, so a scrape that is the very first
|
|
// request to the process legitimately has no HTTP series in it
|
|
// yet.
|
|
i.get(t, "/")
|
|
|
|
resp := i.get(t, "/metrics")
|
|
|
|
if resp.status != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", resp.status)
|
|
}
|
|
|
|
want := `http_requests_total{code="200",method="GET",route="/"}`
|
|
if !strings.Contains(resp.body, want) {
|
|
t.Errorf("the scrape does not report the request that preceded it:\n%s", resp.body)
|
|
}
|
|
})
|
|
|
|
t.Run("gated when configured", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
i := newInstance(t, func(c *config.Config) {
|
|
c.MetricsUser = "scraper"
|
|
c.MetricsPassword = "hunter2"
|
|
})
|
|
|
|
resp := i.get(t, "/metrics")
|
|
if resp.status != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want 401", resp.status)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestUnknownRouteRendersErrorPage: a 404 still carries the site's
|
|
// headers and chrome rather than net/http's bare text.
|
|
func TestUnknownRouteRendersErrorPage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
i := newInstance(t, nil)
|
|
|
|
resp := i.get(t, "/no-such-page")
|
|
|
|
if resp.status != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404", resp.status)
|
|
}
|
|
|
|
if !strings.Contains(resp.body, "No such page") ||
|
|
!strings.Contains(resp.body, "<nav>") {
|
|
t.Errorf("404 is not the rendered error page:\n%s", resp.body)
|
|
}
|
|
}
|
|
|
|
// TestPanicIsAnsweredWith500 exercises the recovery middleware through
|
|
// the whole stack, on the debug-only route.
|
|
func TestPanicIsAnsweredWith500(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
i := newInstance(t, nil)
|
|
|
|
resp := i.get(t, "/debug/panic")
|
|
|
|
if resp.status != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want 500", resp.status)
|
|
}
|
|
|
|
if strings.Contains(resp.body, "deliberate panic") {
|
|
t.Error("the panic value reached the client")
|
|
}
|
|
}
|
|
|
|
// TestDebugRouteAbsentWithoutDebug: the panic route is a denial-of-
|
|
// service primitive, so it must not exist in a normal deployment.
|
|
func TestDebugRouteAbsentWithoutDebug(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
i := newInstance(t, func(c *config.Config) { c.Debug = false })
|
|
|
|
resp := i.get(t, "/debug/panic")
|
|
if resp.status != http.StatusNotFound {
|
|
t.Errorf("status = %d, want 404 with DEBUG off", resp.status)
|
|
}
|
|
}
|
|
|
|
// csrfField finds the hidden token in a rendered form.
|
|
var csrfField = regexp.MustCompile(
|
|
`<input type="hidden" name="gorilla\.csrf\.Token" value="([^"]+)"`,
|
|
)
|
|
|
|
// token fetches the index page and returns the CSRF token its form
|
|
// carries.
|
|
func (i *instance) token(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
page := i.get(t, "/")
|
|
|
|
match := csrfField.FindStringSubmatch(page.body)
|
|
if match == nil {
|
|
t.Fatalf("no CSRF field in the rendered form:\n%s", page.body)
|
|
}
|
|
|
|
return match[1]
|
|
}
|
|
|
|
// TestCSRFProtectsStateChange is the whole point of the CSRF
|
|
// middleware: the same POST must be refused without a token and
|
|
// accepted with one.
|
|
func TestCSRFProtectsStateChange(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
i := newInstance(t, nil)
|
|
|
|
// A well-formed cross-site request: everything a browser would
|
|
// send except the token.
|
|
resp := i.post(t, "/widgets", url.Values{formNameField: {"unauthorised"}})
|
|
if resp.status != http.StatusForbidden {
|
|
t.Fatalf("POST without a CSRF token: status = %d, want 403", resp.status)
|
|
}
|
|
|
|
resp = i.post(t, "/widgets", url.Values{
|
|
formNameField: {"authorised"},
|
|
"size": {"4KiB"},
|
|
"gorilla.csrf.Token": {i.token(t)},
|
|
})
|
|
|
|
if resp.status != http.StatusSeeOther {
|
|
t.Fatalf("POST with a CSRF token: status = %d, want 303", resp.status)
|
|
}
|
|
|
|
// And the write actually happened.
|
|
after := i.get(t, "/")
|
|
if !strings.Contains(after.body, "authorised") {
|
|
t.Error("the created widget is not on the page")
|
|
}
|
|
|
|
if !strings.Contains(after.body, "4.0 KiB") {
|
|
t.Error("the size was not parsed and rendered")
|
|
}
|
|
|
|
if strings.Contains(after.body, "unauthorised") {
|
|
t.Error("the rejected POST created a widget anyway")
|
|
}
|
|
}
|
|
|
|
// TestCSRFBehindTLSTerminatingProxy is the deployment shape this
|
|
// service actually runs in: the browser speaks HTTPS to a proxy and the
|
|
// proxy speaks plaintext HTTP here, saying so with X-Forwarded-Proto.
|
|
// The token must still be accepted, with the stricter TLS-side Referer
|
|
// rules applied to the browser's real scheme rather than to the
|
|
// connection's.
|
|
func TestCSRFBehindTLSTerminatingProxy(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
i := newInstance(t, nil)
|
|
|
|
form := url.Values{
|
|
formNameField: {"through the proxy"},
|
|
"gorilla.csrf.Token": {i.token(t)},
|
|
}
|
|
|
|
// A cleartext Referer on a connection the proxy says was HTTPS is
|
|
// exactly the machine-in-the-middle case the strict check exists
|
|
// for, and must be refused.
|
|
resp := i.postWithHeaders(t, "/widgets", form, map[string]string{
|
|
"X-Forwarded-Proto": "https",
|
|
refererHeader: "http://" + i.host() + "/",
|
|
})
|
|
if resp.status != http.StatusForbidden {
|
|
t.Errorf("cleartext referer under X-Forwarded-Proto=https: status = %d, want 403",
|
|
resp.status)
|
|
}
|
|
|
|
// The genuine article: a same-origin HTTPS referer.
|
|
resp = i.postWithHeaders(t, "/widgets", form, map[string]string{
|
|
"X-Forwarded-Proto": "https",
|
|
refererHeader: "https://" + i.host() + "/",
|
|
})
|
|
if resp.status != http.StatusSeeOther {
|
|
t.Errorf("valid submission through a TLS-terminating proxy: status = %d, want 303",
|
|
resp.status)
|
|
}
|
|
}
|
|
|
|
// TestOversizedRequestIsRefused: the body cap is wired into the running
|
|
// chain, not just unit-tested in isolation.
|
|
func TestOversizedRequestIsRefused(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
i := newInstance(t, func(c *config.Config) { c.MaxRequestBody = 1024 })
|
|
|
|
// The cap is enforced ahead of CSRF in the chain, so an oversized
|
|
// body is refused as oversized rather than as untokened.
|
|
resp := i.post(t, "/widgets",
|
|
url.Values{formNameField: {strings.Repeat("x", 4096)}})
|
|
|
|
if resp.status != http.StatusRequestEntityTooLarge {
|
|
t.Errorf("status = %d, want 413", resp.status)
|
|
}
|
|
}
|