package main import ( "bytes" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/webhooker/internal/datadir" "sneak.berlin/go/webhooker/internal/resetpw" "sneak.berlin/go/webhooker/internal/server" ) // dockerStopGrace is Docker's default `docker stop` grace period. // The Dockerfile sets no STOPSIGNAL or grace override, so this is // the deadline the container is actually held to, and the fx stop // timeout has to fit inside it with room for signal delivery and // process exit. const dockerStopGrace = 10 * time.Second // TestNewApp_StopTimeout pins the fx stop timeout. Without the // explicit fx.StopTimeout option the app reads fx's 15s // DefaultTimeout, which exceeds dockerStopGrace: the container is // SIGKILLed before the bound fires and every shutdown hook bounded // by it — including the operator-facing timeout log — becomes // unreachable in the image this repo produces. // // fx.New applies options before it executes invokes, so the timeout // is set whether or not the graph itself can be constructed here. func TestNewApp_StopTimeout(t *testing.T) { t.Setenv("DATA_DIR", t.TempDir()) got := newApp().StopTimeout() require.Equal(t, stopTimeout, got) require.Less(t, got, dockerStopGrace) } // TestRunRefusesLockedDataDir pins what an operator's second start // does. The entry point must refuse before it builds the fx graph — // nothing may open a database in a DATA_DIR another process holds — // and must exit non-zero with a message naming the directory rather // than starting a second delivery engine over the same rows. // // flock(2) locks descriptors independently, so holding the lock here // is the same denial a separate process gets; internal/datadir pins // that property and covers the real two-process case. func TestRunRefusesLockedDataDir(t *testing.T) { dir := t.TempDir() t.Setenv("DATA_DIR", dir) lock, err := datadir.Acquire(dir) require.NoError(t, err) defer func() { _ = lock.Release() }() var stderr bytes.Buffer code := run(&stderr) require.Equal( t, 1, code, "a second instance must exit non-zero", ) assert.Contains( t, stderr.String(), dir, "the refusal must name the directory", ) assert.Contains(t, stderr.String(), "another instance") } // TestDispatch_NoArgumentsRunsTheServer pins the routing of a bare // invocation, which is what the image's CMD and every deployment use. // Adding subcommands must not move the server off the empty argument // list, and must not move the DATA_DIR lock: this asserts the refusal // arrives with no fx graph built, exactly as run does on its own. func TestDispatch_NoArgumentsRunsTheServer(t *testing.T) { dir := t.TempDir() t.Setenv("DATA_DIR", dir) lock, err := datadir.Acquire(dir) require.NoError(t, err) defer func() { _ = lock.Release() }() var stdout, stderr bytes.Buffer code := dispatch(nil, strings.NewReader(""), &stdout, &stderr) require.Equal(t, 1, code) assert.Contains(t, stderr.String(), "another instance") } // TestDispatch_UnknownSubcommand keeps a mistyped subcommand from // starting a server. Anything else would have `webhooker resetpww` // silently take the DATA_DIR lock and serve. func TestDispatch_UnknownSubcommand(t *testing.T) { t.Parallel() var stdout, stderr bytes.Buffer code := dispatch( []string{"resetpww", "admin"}, strings.NewReader(""), &stdout, &stderr, ) require.Equal(t, 2, code) assert.Contains(t, stderr.String(), "unknown subcommand") assert.Contains( t, stderr.String(), resetpw.Name, "the usage must name the subcommand that does exist", ) } // TestDispatch_Help answers on standard output with a zero status, so // `webhooker help` is usable in a pipe. func TestDispatch_Help(t *testing.T) { t.Parallel() var stdout, stderr bytes.Buffer code := dispatch( []string{helpCommand}, strings.NewReader(""), &stdout, &stderr, ) require.Equal(t, 0, code) assert.Empty(t, stderr.String()) assert.Contains(t, stdout.String(), resetpw.Name) } // tailHeadroom is the slack the fx stop budget must keep beyond the // server stop hook. The hooks that run after the server — the // delivery engine, the healthcheck, the webhook DB manager and the // database close — are microsecond-scale in normal operation, so // this is generous for them. const tailHeadroom = 2 * time.Second // TestStopTimeout_LeavesHeadroomForTailHooks pins the relationship // between the server's stop hook and the fx stop budget. fx bounds // the whole stop sequence, and returns without running its // remaining hooks once the stop context has expired. If the hook // could use the entire budget, every later hook — the database close // included — would be skipped in exactly the case where the drain // mattered. // // The hook is not just the HTTP drain: a Sentry flush follows it in // the same hook, and sentry.Flush honours no context, so both halves // have to be counted. The sweep walks every drain length the hook // can produce, since a shorter drain leaves the flush more room and // the worst case is not necessarily at either extreme. // // Shrinking either budget, or unbounding the flush again, must fail // here rather than silently recreating a hook that swallows the // whole sequence. func TestStopTimeout_LeavesHeadroomForTailHooks(t *testing.T) { t.Parallel() require.Less(t, server.ShutdownTimeout, stopTimeout) const step = 10 * time.Millisecond for drain := time.Duration(0); drain <= server.ShutdownTimeout; drain += step { hook := drain + server.SentryFlushBudget(stopTimeout-drain) require.LessOrEqual( t, hook+tailHeadroom, stopTimeout, "a %s drain leaves the tail hooks short", drain, ) } }