Add a webhooker resetpw subcommand and a bootstrap banner (closes #208) (#239)
All checks were successful
check / check (push) Successful in 3m25s
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.
This commit was merged in pull request #239.
This commit is contained in:
443
internal/resetpw/resetpw_test.go
Normal file
443
internal/resetpw/resetpw_test.go
Normal file
@@ -0,0 +1,443 @@
|
||||
package resetpw_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/fx/fxtest"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/datadir"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/healthcheck"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
"sneak.berlin/go/webhooker/internal/resetpw"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
const (
|
||||
// operatorUser is the account these tests recover.
|
||||
operatorUser = "admin"
|
||||
|
||||
// newPassword is what the operator sets it to.
|
||||
newPassword = "correct horse battery staple"
|
||||
|
||||
// placeholderHash stands in for the stored credential nobody
|
||||
// knows any more — the lost bootstrap password. Nothing here
|
||||
// verifies against it; what matters is whether it is still there
|
||||
// after a refusal, or replaced after a reset.
|
||||
placeholderHash = "$argon2id$lost"
|
||||
|
||||
// exitOK and exitFailure are the statuses Run returns.
|
||||
exitOK = 0
|
||||
exitFailure = 1
|
||||
)
|
||||
|
||||
// testLogger is quiet unless something goes wrong.
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: slog.LevelWarn,
|
||||
}))
|
||||
}
|
||||
|
||||
// newDeployment builds a data directory holding a migrated database
|
||||
// with one account whose password is unknown, and points DATA_DIR at
|
||||
// it. It deliberately does not boot the server graph: seeding through
|
||||
// it would spend an Argon2id hash on a password no test can use.
|
||||
func newDeployment(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DATA_DIR", dir)
|
||||
|
||||
db, err := database.Open(dir, testLogger())
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, db.DB().Create(&database.User{
|
||||
Username: operatorUser,
|
||||
Password: placeholderHash,
|
||||
}).Error)
|
||||
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
// storedHash reads the account's stored credential back.
|
||||
func storedHash(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
|
||||
db, err := database.Open(dir, testLogger())
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { require.NoError(t, db.Close()) }()
|
||||
|
||||
var user database.User
|
||||
|
||||
require.NoError(t, db.DB().
|
||||
Where("username = ?", operatorUser).
|
||||
First(&user).Error)
|
||||
|
||||
return user.Password
|
||||
}
|
||||
|
||||
// bannerPassword returns the plaintext a credentials banner printed.
|
||||
func bannerPassword(t *testing.T, out string) string {
|
||||
t.Helper()
|
||||
|
||||
for line := range strings.SplitSeq(out, "\n") {
|
||||
_, value, found := strings.Cut(line, "password: ")
|
||||
if found {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("no password line in:\n%s", out)
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// run drives the subcommand with the given standard input and returns
|
||||
// its status alongside what it wrote.
|
||||
func run(
|
||||
t *testing.T, stdin string, args ...string,
|
||||
) (int, string, string) {
|
||||
t.Helper()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
code := resetpw.Run(
|
||||
args, strings.NewReader(stdin), &stdout, &stderr,
|
||||
)
|
||||
|
||||
return code, stdout.String(), stderr.String()
|
||||
}
|
||||
|
||||
type noopNotifier struct{}
|
||||
|
||||
func (n *noopNotifier) Notify([]delivery.Task) {}
|
||||
|
||||
type noopEvictor struct{}
|
||||
|
||||
func (n *noopEvictor) EvictWebhook(string) {}
|
||||
|
||||
// newServerApp starts the real login path against dir: the handlers,
|
||||
// the middleware that bounds password verification, the session store
|
||||
// and the database, exactly as internal/handlers builds them.
|
||||
//
|
||||
// One application per test function, not per case: every start that
|
||||
// finds no account seeds one at 64 MB of Argon2id, and this package's
|
||||
// budget is not the place to spend that repeatedly.
|
||||
func newServerApp(
|
||||
t *testing.T, dir string,
|
||||
) (*handlers.Handlers, *fxtest.App) {
|
||||
t.Helper()
|
||||
|
||||
var h *handlers.Handlers
|
||||
|
||||
app := fxtest.New(
|
||||
t,
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
func() *config.Config {
|
||||
return &config.Config{DataDir: dir}
|
||||
},
|
||||
database.New,
|
||||
database.NewWebhookDBManager,
|
||||
healthcheck.New,
|
||||
session.New,
|
||||
func() delivery.Notifier { return &noopNotifier{} },
|
||||
func() delivery.WebhookEvictor { return &noopEvictor{} },
|
||||
middleware.New,
|
||||
handlers.New,
|
||||
),
|
||||
fx.Populate(&h),
|
||||
)
|
||||
|
||||
app.RequireStart()
|
||||
|
||||
return h, app
|
||||
}
|
||||
|
||||
// submitLogin drives one login form POST through the real handler.
|
||||
func submitLogin(
|
||||
h *handlers.Handlers, username, password string,
|
||||
) *httptest.ResponseRecorder {
|
||||
form := url.Values{}
|
||||
form.Set("username", username)
|
||||
form.Set("password", password)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodPost,
|
||||
"/pages/login",
|
||||
strings.NewReader(form.Encode()),
|
||||
)
|
||||
req.Header.Set(
|
||||
"Content-Type", "application/x-www-form-urlencoded",
|
||||
)
|
||||
req.RemoteAddr = "10.0.0.1:44444"
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleLoginSubmit().ServeHTTP(w, req)
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
// TestResetThenLogin is the definition of done of
|
||||
// https://git.eeqj.de/sneak/webhooker/issues/208: an operator who lost
|
||||
// the one-time bootstrap password sets a new one from the command line
|
||||
// and logs in with it.
|
||||
//
|
||||
// The login is the real one — the form POST through
|
||||
// handlers.HandleLoginSubmit, which looks the account up and verifies
|
||||
// the stored Argon2id hash — so a reset that wrote a hash the login
|
||||
// path cannot verify fails here rather than passing a re-implementation
|
||||
// of the check.
|
||||
//
|
||||
//nolint:paralleltest // newDeployment sets DATA_DIR with t.Setenv.
|
||||
func TestResetThenLogin(t *testing.T) {
|
||||
dir := newDeployment(t)
|
||||
|
||||
code, stdout, stderr := run(t, newPassword+"\n", operatorUser)
|
||||
require.Equal(t, exitOK, code, "stderr: %s", stderr)
|
||||
|
||||
assert.NotContains(
|
||||
t, stdout, newPassword,
|
||||
"a password the operator supplied must not be echoed back",
|
||||
)
|
||||
assert.Contains(t, stdout, operatorUser)
|
||||
|
||||
h, app := newServerApp(t, dir)
|
||||
|
||||
defer app.RequireStop()
|
||||
|
||||
got := submitLogin(h, operatorUser, newPassword)
|
||||
require.Equal(
|
||||
t, http.StatusSeeOther, got.Code,
|
||||
"the new password must log in",
|
||||
)
|
||||
|
||||
got = submitLogin(h, operatorUser, "not-"+newPassword)
|
||||
require.NotEqual(
|
||||
t, http.StatusSeeOther, got.Code,
|
||||
"the reset must not make every password work",
|
||||
)
|
||||
}
|
||||
|
||||
// TestGeneratedPasswordIsPrintedAndWorks covers -generate, the mode an
|
||||
// operator recovering a deployment actually reaches for. The generated
|
||||
// password is shown once, in the banner, and must be the one stored.
|
||||
//
|
||||
//nolint:paralleltest // newDeployment sets DATA_DIR with t.Setenv.
|
||||
func TestGeneratedPasswordIsPrintedAndWorks(t *testing.T) {
|
||||
dir := newDeployment(t)
|
||||
|
||||
code, stdout, stderr := run(t, "", "-generate", operatorUser)
|
||||
require.Equal(t, exitOK, code, "stderr: %s", stderr)
|
||||
|
||||
require.Contains(
|
||||
t, stdout, strings.Repeat("=", 20),
|
||||
"a generated password must be printed as a banner",
|
||||
)
|
||||
|
||||
password := bannerPassword(t, stdout)
|
||||
|
||||
ok, err := database.VerifyPassword(password, storedHash(t, dir))
|
||||
require.NoError(t, err)
|
||||
assert.True(
|
||||
t, ok, "the printed password must open the account",
|
||||
)
|
||||
}
|
||||
|
||||
// failingWriter is a standard output that cannot be written to.
|
||||
type failingWriter struct{}
|
||||
|
||||
func (failingWriter) Write([]byte) (int, error) {
|
||||
return 0, assert.AnError
|
||||
}
|
||||
|
||||
// TestGeneratedPasswordSurvivesAFailedStdout covers the one outcome
|
||||
// worse than an error: the password is already stored, so a banner
|
||||
// that cannot be written to standard output must go to standard error
|
||||
// rather than leaving the deployment behind a password nobody has seen.
|
||||
//
|
||||
//nolint:paralleltest // newDeployment sets DATA_DIR with t.Setenv.
|
||||
func TestGeneratedPasswordSurvivesAFailedStdout(t *testing.T) {
|
||||
dir := newDeployment(t)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := resetpw.Run(
|
||||
[]string{"-generate", operatorUser},
|
||||
strings.NewReader(""), failingWriter{}, &stderr,
|
||||
)
|
||||
|
||||
require.Equal(t, exitOK, code)
|
||||
|
||||
password := bannerPassword(t, stderr.String())
|
||||
|
||||
ok, err := database.VerifyPassword(password, storedHash(t, dir))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ok)
|
||||
}
|
||||
|
||||
// TestRefusesLiveInstance pins the refusal the issue requires. The
|
||||
// running deployment keeps serving the sessions that authenticated
|
||||
// with the old password, so a reset underneath it would report a
|
||||
// change the service does not honour.
|
||||
//
|
||||
//nolint:paralleltest // newDeployment sets DATA_DIR with t.Setenv.
|
||||
func TestRefusesLiveInstance(t *testing.T) {
|
||||
dir := newDeployment(t)
|
||||
|
||||
lock, err := datadir.Acquire(dir)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { require.NoError(t, lock.Release()) }()
|
||||
|
||||
code, _, stderr := run(t, newPassword+"\n", operatorUser)
|
||||
|
||||
require.Equal(t, exitFailure, code)
|
||||
assert.Contains(t, stderr, dir, "the refusal must name DATA_DIR")
|
||||
assert.Contains(t, stderr, "running webhooker")
|
||||
assert.Equal(
|
||||
t, placeholderHash, storedHash(t, dir),
|
||||
"a refused reset must not touch the stored credential",
|
||||
)
|
||||
}
|
||||
|
||||
// TestMissingDataDirCreatesNothing pins the side effect that must not
|
||||
// happen. datadir.Acquire calls os.MkdirAll, so reaching it with a
|
||||
// mistyped DATA_DIR would build the directory, take a lock in it and
|
||||
// migrate an empty database there — reporting success against a
|
||||
// deployment that does not exist.
|
||||
func TestMissingDataDirCreatesNothing(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "typo", "webhooker")
|
||||
t.Setenv("DATA_DIR", dir)
|
||||
|
||||
code, _, stderr := run(t, newPassword+"\n", operatorUser)
|
||||
|
||||
require.Equal(t, exitFailure, code)
|
||||
assert.Contains(t, stderr, dir)
|
||||
|
||||
_, err := os.Stat(dir)
|
||||
assert.ErrorIs(
|
||||
t, err, os.ErrNotExist,
|
||||
"a mistyped DATA_DIR must not be created",
|
||||
)
|
||||
}
|
||||
|
||||
// TestMissingDatabaseCreatesNothing covers the directory that exists
|
||||
// but holds no deployment: an empty volume, or the wrong one. Nothing
|
||||
// may be written there either, lock file included.
|
||||
func TestMissingDatabaseCreatesNothing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DATA_DIR", dir)
|
||||
|
||||
code, _, stderr := run(t, newPassword+"\n", operatorUser)
|
||||
|
||||
require.Equal(t, exitFailure, code)
|
||||
assert.Contains(t, stderr, database.MainDBFileName)
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(
|
||||
t, entries,
|
||||
"nothing may be created in a directory holding no database",
|
||||
)
|
||||
}
|
||||
|
||||
// TestUnknownUserFails states the decision: resetpw changes an
|
||||
// existing account's password and never creates an account. A typo in
|
||||
// the username must say so rather than quietly adding a second user.
|
||||
//
|
||||
//nolint:paralleltest // newDeployment sets DATA_DIR with t.Setenv.
|
||||
func TestUnknownUserFails(t *testing.T) {
|
||||
dir := newDeployment(t)
|
||||
|
||||
code, _, stderr := run(t, newPassword+"\n", "amdin")
|
||||
|
||||
require.Equal(t, exitFailure, code)
|
||||
assert.Contains(t, stderr, "amdin")
|
||||
assert.Equal(t, placeholderHash, storedHash(t, dir))
|
||||
|
||||
var count int64
|
||||
|
||||
db, err := database.Open(dir, testLogger())
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { require.NoError(t, db.Close()) }()
|
||||
|
||||
require.NoError(t, db.DB().Model(&database.User{}).
|
||||
Count(&count).Error)
|
||||
assert.EqualValues(
|
||||
t, 1, count, "no account may have been created",
|
||||
)
|
||||
}
|
||||
|
||||
// TestRejectsUnusablePasswords covers what standard input can carry by
|
||||
// accident: nothing at all, and a stray keystroke. Either would
|
||||
// otherwise become the account's only credential.
|
||||
//
|
||||
//nolint:paralleltest // newDeployment sets DATA_DIR with t.Setenv.
|
||||
func TestRejectsUnusablePasswords(t *testing.T) {
|
||||
dir := newDeployment(t)
|
||||
|
||||
for name, stdin := range map[string]string{
|
||||
"empty": "",
|
||||
"newline": "\n",
|
||||
"short": "hunter2\n",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
code, _, stderr := run(t, stdin, operatorUser)
|
||||
|
||||
require.Equal(t, exitFailure, code)
|
||||
assert.NotEmpty(t, stderr)
|
||||
assert.Equal(
|
||||
t, placeholderHash, storedHash(t, dir),
|
||||
"a rejected password must not be stored",
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUsageErrors pins the statuses a caller can script against: 2 for
|
||||
// being called wrong, which is not the same as a refusal.
|
||||
func TestUsageErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, args := range map[string][]string{
|
||||
"no username": {},
|
||||
"two usernames": {"admin", "root"},
|
||||
"unknown flag": {"-force", "admin"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
code := resetpw.Run(
|
||||
args, strings.NewReader(""), &stdout, &stderr,
|
||||
)
|
||||
|
||||
assert.Equal(t, 2, code)
|
||||
assert.NotEmpty(t, stderr)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user