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,55 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HTTP server hardening limits. These are the connection-level bounds;
|
||||
// the per-request deadline is middleware.Timeout, driven by
|
||||
// REQUEST_TIMEOUT.
|
||||
const (
|
||||
// readHeaderTimeout is the slowloris bound: request headers must
|
||||
// arrive within it, and it starts on connection accept.
|
||||
readHeaderTimeout = 5 * time.Second
|
||||
|
||||
// readTimeout covers headers plus body. Bodies are capped by
|
||||
// MAX_REQUEST_BODY, which transfers well inside this even on a
|
||||
// slow mobile link.
|
||||
readTimeout = 30 * time.Second
|
||||
|
||||
// writeTimeout must exceed the per-request timeout: it starts when
|
||||
// the headers are read, so it spans handler execution, and a
|
||||
// smaller value would cut the connection instead of letting the
|
||||
// request context deadline end the request with a status. The
|
||||
// margin is added to whatever REQUEST_TIMEOUT is configured to.
|
||||
writeTimeoutMargin = 15 * time.Second
|
||||
|
||||
// idleTimeout bounds how long an idle keep-alive connection is
|
||||
// held; browsers reconnect transparently.
|
||||
idleTimeout = 120 * time.Second
|
||||
|
||||
maxHeaderBytes = 1 << 20
|
||||
)
|
||||
|
||||
// newHTTPServer builds the http.Server.
|
||||
//
|
||||
// ErrorLog is set on purpose: net/http internals (TLS handshake
|
||||
// errors, request parse errors, panics net/http itself recovers) write
|
||||
// through it, and unset they would emit plain text on stderr via the
|
||||
// default log package — a few lines of unstructured output in the
|
||||
// middle of a JSON log stream, which is exactly the kind of thing a log
|
||||
// pipeline drops on the floor.
|
||||
func (s *Server) newHTTPServer(listenAddr string) *http.Server {
|
||||
return &http.Server{
|
||||
Addr: listenAddr,
|
||||
ReadHeaderTimeout: readHeaderTimeout,
|
||||
ReadTimeout: readTimeout,
|
||||
WriteTimeout: s.params.Config.RequestTimeout + writeTimeoutMargin,
|
||||
IdleTimeout: idleTimeout,
|
||||
MaxHeaderBytes: maxHeaderBytes,
|
||||
Handler: s,
|
||||
ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelError),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"sneak.berlin/go/simplexcalc/static"
|
||||
)
|
||||
|
||||
// staticPrefix is where the embedded assets are mounted.
|
||||
const staticPrefix = "/static/"
|
||||
|
||||
// staticCacheControl is a year, because the asset set is baked into the
|
||||
// binary: a new build is a new deployment, and a deployment is the only
|
||||
// thing that can change these bytes. Bump the path if that stops being
|
||||
// true.
|
||||
const staticCacheControl = "public, max-age=31536000, immutable"
|
||||
|
||||
// SetupRoutes builds the router. The middleware order is the contract
|
||||
// of this file, and it is this way for reasons:
|
||||
//
|
||||
// 1. RequestID first, so every later line of log and every captured
|
||||
// error can name the request it came from.
|
||||
// 2. Recoverer next-to-outermost, so it covers every handler and every
|
||||
// middleware below it. Above the logger, so a panic still produces
|
||||
// a logged request line.
|
||||
// 3. RequestLogger and Metrics before the work, so both see the final
|
||||
// status of every request including the 500 the recoverer wrote.
|
||||
// 4. SecurityHeaders before anything can write a body — the headers
|
||||
// have to be set before the first Write, and a 404 or a panic
|
||||
// response needs them as much as a page does.
|
||||
// 5. Timeout and BodyLimit before any handler reads a body.
|
||||
// 6. CSRF innermost of the global chain, wrapping only the routes that
|
||||
// can change state.
|
||||
//
|
||||
// /metrics and the healthcheck sit outside CSRF (neither is
|
||||
// state-changing, and a scraper has no token) and outside nothing else.
|
||||
func (s *Server) SetupRoutes() {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(s.mw.RequestID())
|
||||
r.Use(s.mw.Recoverer())
|
||||
r.Use(s.mw.RequestLogger())
|
||||
r.Use(s.mw.Metrics())
|
||||
r.Use(s.mw.SecurityHeaders())
|
||||
r.Use(s.mw.Timeout())
|
||||
r.Use(s.mw.BodyLimit())
|
||||
|
||||
r.NotFound(s.h.NotFound())
|
||||
r.MethodNotAllowed(s.h.MethodNotAllowed())
|
||||
|
||||
// Operational endpoints: no CSRF, no session, no HTML.
|
||||
r.Get("/.well-known/healthcheck.json", s.h.Healthcheck())
|
||||
r.Method(http.MethodGet, "/metrics", s.metrics.Handler())
|
||||
|
||||
r.Handle(staticPrefix+"*", s.staticHandler())
|
||||
|
||||
// Everything a browser drives, behind CSRF. Safe methods are
|
||||
// unaffected by it except that they are issued a token.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.mw.CSRF())
|
||||
|
||||
r.Get("/", s.h.Index())
|
||||
r.Post("/widgets", s.h.CreateWidget())
|
||||
|
||||
if s.params.Config.Debug {
|
||||
// Only with DEBUG=true: a route that panics on demand is a
|
||||
// denial-of-service primitive in production.
|
||||
r.Get("/debug/panic", s.h.Panic())
|
||||
}
|
||||
})
|
||||
|
||||
s.router = r
|
||||
}
|
||||
|
||||
// staticHandler serves the embedded assets, without directory listings
|
||||
// and with a long cache lifetime.
|
||||
func (s *Server) staticHandler() http.Handler {
|
||||
fileServer := http.FileServer(filesOnly{inner: http.FS(static.FS)})
|
||||
|
||||
return http.StripPrefix(staticPrefix, http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", staticCacheControl)
|
||||
fileServer.ServeHTTP(w, r)
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// Package server owns the HTTP server lifecycle and the route table.
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/simplexcalc/internal/config"
|
||||
"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/telemetry"
|
||||
)
|
||||
|
||||
// Params defines dependencies for Server.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Logger *logger.Logger
|
||||
Globals *globals.Globals
|
||||
Config *config.Config
|
||||
Middleware *middleware.Middleware
|
||||
Handlers *handlers.Handlers
|
||||
Metrics *telemetry.Metrics
|
||||
}
|
||||
|
||||
// Server is the HTTP server and its lifecycle state.
|
||||
type Server struct {
|
||||
params Params
|
||||
log *slog.Logger
|
||||
mw *middleware.Middleware
|
||||
h *handlers.Handlers
|
||||
metrics *telemetry.Metrics
|
||||
httpServer *http.Server
|
||||
listener net.Listener
|
||||
router *chi.Mux
|
||||
serveErr chan error
|
||||
}
|
||||
|
||||
// New creates the Server and hooks it into the fx lifecycle.
|
||||
//
|
||||
// The listener is opened during OnStart, not in a goroutine after it:
|
||||
// binding is the part that fails (port in use, permission denied), and
|
||||
// a failure there has to be a failure to start. A server that logs
|
||||
// "listen failed" from a goroutine while fx reports a successful
|
||||
// startup is a process that is up and serving nothing.
|
||||
func New(lc fx.Lifecycle, params Params) (*Server, error) {
|
||||
s := &Server{
|
||||
params: params,
|
||||
log: params.Logger.Get(),
|
||||
mw: params.Middleware,
|
||||
h: params.Handlers,
|
||||
metrics: params.Metrics,
|
||||
serveErr: make(chan error, 1),
|
||||
}
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: s.start,
|
||||
OnStop: s.stop,
|
||||
})
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Addr returns the address actually bound, which is what a test that
|
||||
// asked for port 0 needs.
|
||||
func (s *Server) Addr() string {
|
||||
if s.listener == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return s.listener.Addr().String()
|
||||
}
|
||||
|
||||
// Handler exposes the router so tests can drive it with
|
||||
// httptest.NewRecorder without binding a port.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return s.router
|
||||
}
|
||||
|
||||
// ServeHTTP dispatches requests through the router.
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.router.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// start builds the routes, binds, and begins serving.
|
||||
func (s *Server) start(ctx context.Context) error {
|
||||
s.SetupRoutes()
|
||||
|
||||
addr := ":" + strconv.Itoa(s.params.Config.Port)
|
||||
s.httpServer = s.newHTTPServer(addr)
|
||||
|
||||
// ListenConfig rather than net.Listen: the start context bounds
|
||||
// name resolution and the bind, so a start that cannot bind fails
|
||||
// within fx's start timeout instead of hanging inside it.
|
||||
var lc net.ListenConfig
|
||||
|
||||
ln, err := lc.Listen(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listening on %s: %w", addr, err)
|
||||
}
|
||||
|
||||
s.listener = ln
|
||||
|
||||
s.log.Info("http listening",
|
||||
"addr", ln.Addr().String(),
|
||||
"debug", s.params.Config.Debug,
|
||||
"metrics_protected", s.metrics.AuthRequired(),
|
||||
)
|
||||
|
||||
go func() {
|
||||
serveErr := s.httpServer.Serve(ln)
|
||||
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
|
||||
s.log.Error("http serve failed", "error", serveErr)
|
||||
|
||||
s.serveErr <- serveErr
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
s.serveErr <- nil
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// stop drains in-flight requests, bounded by the stop context.
|
||||
//
|
||||
// The bound is whichever of the two comes first: the deadline fx gives
|
||||
// this hook, or the configured grace period. Taking the minimum is the
|
||||
// point — a shutdown that outlives the context it was given is a
|
||||
// shutdown the supervisor kills with SIGKILL, which is precisely the
|
||||
// abrupt termination the draining was supposed to avoid.
|
||||
//
|
||||
// Past the deadline, Shutdown returns and every remaining connection is
|
||||
// closed. That is a deliberate choice of a bounded, reported failure
|
||||
// over an unbounded wait.
|
||||
func (s *Server) stop(ctx context.Context) error {
|
||||
if s.httpServer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
grace := s.params.Config.ShutdownGrace
|
||||
|
||||
stopCtx, cancel := context.WithTimeout(ctx, grace)
|
||||
defer cancel()
|
||||
|
||||
s.log.Info("http shutting down", "grace", grace.String())
|
||||
|
||||
start := time.Now()
|
||||
|
||||
err := s.httpServer.Shutdown(stopCtx)
|
||||
if err != nil {
|
||||
// Connections were still open at the deadline. Report it —
|
||||
// silence here would hide requests that were cut off — but do
|
||||
// not fail the stop sequence: there is nothing left to retry
|
||||
// and the rest of the graph still has to close cleanly.
|
||||
s.log.Error("http shutdown did not drain in time",
|
||||
"error", err,
|
||||
"waited", time.Since(start).Round(time.Millisecond).String(),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
s.log.Info("http shutdown complete",
|
||||
"waited", time.Since(start).Round(time.Millisecond).String(),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// filesOnly wraps an http.FileSystem so that directories do not exist as
|
||||
// far as http.FileServer is concerned. FileServer asks the filesystem for
|
||||
// the directory first and generates its index from what it gets back;
|
||||
// refusing the Open is therefore the whole control, and it leaves the
|
||||
// path handling, content sniffing and range support of FileServer intact.
|
||||
//
|
||||
// Enumerating what a binary ships is a capability no caller needs, and
|
||||
// the embedded set grows as a project seeded from this template adds to
|
||||
// it.
|
||||
type filesOnly struct {
|
||||
inner http.FileSystem
|
||||
}
|
||||
|
||||
// Open serves a file and refuses a directory with fs.ErrNotExist, which
|
||||
// http.FileServer maps to 404 — the same answer a path that is not
|
||||
// embedded at all gets.
|
||||
func (f filesOnly) Open(name string) (http.File, error) {
|
||||
file, err := f.inner.Open(name)
|
||||
if err != nil {
|
||||
// Unwrapped on purpose: http.FileServer inspects this error,
|
||||
// and wrapping hides fs.ErrNotExist from it.
|
||||
//nolint:wrapcheck // see above.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
|
||||
//nolint:wrapcheck // as above.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
_ = file.Close()
|
||||
|
||||
return nil, fs.ErrNotExist
|
||||
}
|
||||
|
||||
return file, nil
|
||||
}
|
||||
Reference in New Issue
Block a user