All checks were successful
check / check (push) Successful in 3m25s
The admin bootstrap password was printed once, as one line among roughly 45 fx lines, and under docker run -d went to container logs subject to rotation. There was no reset path at all -- no subcommand, no forgot-password flow, no env override -- so recovery meant hand-deleting the users row from webhooker.db, which was documented nowhere. Adds webhooker resetpw [-generate] <username>. The password is read from stdin or generated with the existing crypto/rand helper, never taken from argv where /proc would publish it. It reuses the existing Argon2id hashing rather than reimplementing the parameters, and writes a single UPDATE only after the hash is complete, so no failure can leave an account with no usable password. An unknown username is a hard error and never creates an account. It refuses to run against a DATA_DIR held by a live instance, via the exclusive lock from #201. DATA_DIR and webhooker.db are checked to exist before the lock is acquired, so a mistyped path creates nothing -- neither a directory tree nor a stray lock file. The bootstrap password now appears exactly once, in a distinct banner written straight to a caller-named writer rather than as an fx log line.
172 lines
5.6 KiB
Go
172 lines
5.6 KiB
Go
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{"help"}, 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,
|
|
)
|
|
}
|
|
}
|