All checks were successful
check / check (push) Successful in 3m32s
Two configuration paths still failed silently, against the rule every other variable follows: a value that is set but cannot be parsed must abort startup rather than substitute a default. SENTRY_DSN is now parsed in loadFromEnv, with sentry.NewDsn — the same call sentry.Init makes on the DSN it is handed, so configuration and initialisation cannot disagree about what a valid DSN is. That costs internal/config an import of the Sentry SDK, which is already a module dependency already linked into the binary, and buys a single definition of validity rather than a hand-rolled second one free to drift. A typo in a DSN used to log one error line and leave the process serving with error reporting off forever, which nothing downstream can notice: the variable is still set, so every later signal reports it as on. hasSentryDSN is replaced by Config.SentryEnabled(), following MetricsAuthEnabled(): one method read by the startup log field, by the SDK initialisation and by the sentryhttp middleware, so the log cannot report reporting as on while nothing is sending. enableSentry's error branch is now fatal, and Run gives up before it listens rather than binding a port it is about to release. Fatal there means what a listen failure already meant — Shutdowner.Shutdown(fx.ExitCode(1)), through fx's normal stop sequence — so shutdownOnListenFailure is now shutdownWithFailure and ListenFailureExitCode is StartupFailureExitCode. The godotenv/autoload blank import is replaced by config.LoadDotEnv, called at the top of dispatch. autoload discarded Load's error, and godotenv applies nothing at all when a file will not parse, so one mistyped line reverted every variable in the file to its default and started the server with no log line naming the file. A missing file stays fine — it is optional and most deployments have none. The call sits in dispatch rather than in loadFromEnv because autoload ran in an init(), ahead of config.DataDir(), which both the DATA_DIR lock and resetpw call outside the fx graph; loading any later would let a .env that sets DATA_DIR lock one directory while the config opened databases in another. Both defects were reproduced against the previous build first: an unparseable DSN served traffic while logging "hasSentryDSN":true, and a malformed .env started on the default port with the file unmentioned.
100 lines
2.9 KiB
Go
100 lines
2.9 KiB
Go
package server_test
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/fx"
|
|
"sneak.berlin/go/webhooker/internal/config"
|
|
"sneak.berlin/go/webhooker/internal/globals"
|
|
"sneak.berlin/go/webhooker/internal/server"
|
|
)
|
|
|
|
// TestSentryInitFailure_ShutsDownTheApp pins that error reporting
|
|
// which is configured and cannot be started ends the application
|
|
// instead of serving without it.
|
|
//
|
|
// The measured defect logged `sentry init failure` and kept running,
|
|
// so the deployment served traffic with reporting off while every
|
|
// other signal — SENTRY_DSN still set, the startup summary's own
|
|
// field — said it was on. Nothing later in the process can notice
|
|
// that reports are going nowhere, which is why this exits rather than
|
|
// degrades.
|
|
//
|
|
// The DSN is placed on a hand-built Config, which is the only way to
|
|
// reach this branch at all: loadFromEnv now parses SENTRY_DSN with
|
|
// sentry.NewDsn, the same call sentry.Init makes, so a DSN that
|
|
// survives configuration cannot fail initialisation in the SDK
|
|
// version this pins. The branch stays because that is a property of
|
|
// the SDK's current implementation rather than of its contract.
|
|
func TestSentryInitFailure_ShutsDownTheApp(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
port := freePort(t)
|
|
|
|
env := newTestEnvWithConfig(t, &config.Config{
|
|
DataDir: t.TempDir(),
|
|
Environment: config.EnvironmentDev,
|
|
BindAddress: loopbackV4,
|
|
Port: port,
|
|
SentryDSN: "not-a-dsn",
|
|
})
|
|
|
|
app := fx.New(
|
|
fx.NopLogger,
|
|
fx.Supply(env.log, env.cfg, env.mw, env.hnd),
|
|
fx.Provide(globals.New, server.New),
|
|
fx.Invoke(func(*server.Server) {}),
|
|
)
|
|
|
|
startCtx, cancelStart := context.WithTimeout(
|
|
context.Background(), lifecycleTimeout,
|
|
)
|
|
defer cancelStart()
|
|
|
|
require.NoError(t, app.Start(startCtx))
|
|
|
|
select {
|
|
case sig := <-app.Wait():
|
|
require.Equal(
|
|
t, server.StartupFailureExitCode, sig.ExitCode,
|
|
"a sentry failure must exit non-zero",
|
|
)
|
|
case <-time.After(listenFailureDeadline):
|
|
t.Fatal("a sentry failure left the app running")
|
|
}
|
|
|
|
// The stop sequence still has to complete: the failure must reach
|
|
// shutdown through fx rather than around it.
|
|
stopCtx, cancelStop := context.WithTimeout(
|
|
context.Background(), lifecycleTimeout,
|
|
)
|
|
defer cancelStop()
|
|
|
|
require.NoError(t, app.Stop(stopCtx))
|
|
|
|
// And it must give up before it listens. A process that bound the
|
|
// port and then exited would have accepted requests it could not
|
|
// report on, which is the state under test in miniature.
|
|
requireBindable(t, port)
|
|
}
|
|
|
|
// requireBindable asserts that the port is free, which it is only if
|
|
// the server under test never claimed it.
|
|
func requireBindable(t *testing.T, port int) {
|
|
t.Helper()
|
|
|
|
var listenCfg net.ListenConfig
|
|
|
|
listener, err := listenCfg.Listen(
|
|
t.Context(), "tcp",
|
|
net.JoinHostPort(loopbackV4, strconv.Itoa(port)),
|
|
)
|
|
require.NoError(t, err, "the server bound a port it then gave up")
|
|
require.NoError(t, listener.Close())
|
|
}
|