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, delivery.NewGuard, 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) }) } }