Seed from go-template-repo, renamed to simplexcalc
The template's files at a77fd30, without its history or LICENSE, after script/rename simplexcalc. Model: opus-5-5
This commit is contained in:
@@ -0,0 +1,560 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user