Add a webhooker resetpw subcommand and a bootstrap banner (closes #208) (#239)
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:
2026-08-20 08:01:42 +02:00
parent fcead5d401
commit 9969694a47
10 changed files with 1431 additions and 18 deletions

472
internal/resetpw/resetpw.go Normal file
View File

@@ -0,0 +1,472 @@
// Package resetpw implements the `webhooker resetpw` subcommand,
// which sets an existing account's password from the command line.
//
// It exists because the bootstrap password is shown exactly once. If it
// is lost — the boot's output rotated away, the terminal closed — the
// deployment has no other way in: there is no second account, no
// forgot-password flow, and no environment override. The only recovery
// before this command was deleting the row from webhooker.db with a
// SQLite client so the next start would re-seed.
//
// It operates on a stopped deployment only. The password is read from
// standard input or generated, never taken from argv, and it reuses the
// service's own Argon2id hashing rather than reimplementing it.
package resetpw
import (
"bufio"
"errors"
"flag"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/banner"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/datadir"
)
// Name is the subcommand's name on the command line.
const Name = "resetpw"
const (
// generatedPasswordLen is the length of a -generate password. It
// is longer than the 16 characters the first boot generates: this
// one is typed or pasted once by an operator recovering a
// deployment, not carried around.
generatedPasswordLen = 24
// minPasswordLen is the shortest password accepted on standard
// input. It is a floor against a stray keystroke or a truncated
// pipe silently becoming the account's credential, not a password
// policy.
minPasswordLen = 8
)
// Exit statuses. Usage errors are distinguished from operational ones
// so that a script can tell "you called it wrong" from "it refused".
const (
exitOK = 0
exitFailure = 1
exitUsage = 2
)
// Sentinel errors, exported so a test can assert on the refusal rather
// than on the wording of a message.
var (
// ErrNoDataDir reports that DATA_DIR names nothing, or names
// something that is not a directory.
ErrNoDataDir = errors.New("no data directory")
// ErrNoDatabase reports that the data directory holds no main
// database, so there is no deployment to reset a password in.
ErrNoDatabase = errors.New("no webhooker database")
// ErrLiveInstance reports that a running webhooker holds the data
// directory.
ErrLiveInstance = errors.New(
"data directory is held by a running webhooker",
)
// ErrNoSuchUser reports that the named account does not exist.
ErrNoSuchUser = errors.New("no such user")
// ErrEmptyPassword reports that standard input carried nothing.
ErrEmptyPassword = errors.New("empty password")
// ErrPasswordTooShort reports a password below minPasswordLen.
ErrPasswordTooShort = errors.New("password too short")
// ErrNotUpdated reports that the update matched no row, which
// means the account disappeared between the lookup and the write.
ErrNotUpdated = errors.New("password was not updated")
)
// usage describes the subcommand. It is written to the same stream as
// the error that provoked it.
func usage(w io.Writer, flags *flag.FlagSet) {
_, _ = fmt.Fprintf(w, `usage: webhooker %s [-generate] <username>
Set an existing account's password. The deployment must be stopped:
webhooker %s takes the same exclusive DATA_DIR lock the server does
and refuses to run while a live instance holds it.
The password is read as one line from standard input, or generated
with -generate. It is never taken as a command-line argument, which
on Linux would publish it in /proc to every account on the host.
DATA_DIR selects the deployment exactly as it does for the server
(default %s). The directory and its database must already exist;
nothing is created.
Flags:
`, Name, Name, config.DefaultDataDir)
flags.PrintDefaults()
}
// Run executes the subcommand and returns the process exit status.
func Run(
args []string,
stdin io.Reader,
stdout, stderr io.Writer,
) int {
flags := flag.NewFlagSet("webhooker "+Name, flag.ContinueOnError)
flags.SetOutput(stderr)
generate := flags.Bool(
"generate", false,
"generate a random password instead of reading one from "+
"standard input, and print it",
)
flags.Usage = func() { usage(stderr, flags) }
err := flags.Parse(args)
if err != nil {
// flag has already reported the error and printed the usage.
return exitUsage
}
if flags.NArg() != 1 {
_, _ = fmt.Fprintf(
stderr,
"webhooker %s: exactly one username is required\n",
Name,
)
usage(stderr, flags)
return exitUsage
}
err = reset(flags.Arg(0), *generate, stdin, stdout, stderr)
if err != nil {
_, _ = fmt.Fprintf(stderr, "webhooker %s: %v\n", Name, err)
return exitFailure
}
return exitOK
}
// reset performs the whole operation against the configured data
// directory.
func reset(
username string,
generate bool,
stdin io.Reader,
stdout, stderr io.Writer,
) error {
dir := config.DataDir()
err := checkDataDir(dir)
if err != nil {
return err
}
lock, err := acquire(dir)
if err != nil {
return err
}
// The kernel drops the lock when this process exits, whatever
// happens below; releasing explicitly is what makes a long-running
// caller — a test — see it freed. A release error tells the
// operator nothing they can act on.
defer func() { _ = lock.Release() }()
db, err := database.Open(dir, cliLogger(stderr))
if err != nil {
return err
}
password, err := setPassword(db, username, generate, stdin, stderr)
closeErr := db.Close()
if err != nil {
return err
}
if closeErr != nil {
return fmt.Errorf("closing the database: %w", closeErr)
}
return report(stdout, stderr, dir, username, password, generate)
}
// report tells the operator what happened.
//
// A generated password is printed in the same banner the first boot
// uses, because this is the only time it is ever shown. A password the
// operator supplied is not echoed back: they already have it, and
// writing it to standard output a second time would put it in another
// log for no gain.
func report(
stdout, stderr io.Writer,
dir, username, password string,
generate bool,
) error {
if generate {
write := func(w io.Writer) error {
return banner.Credentials(
w,
"WEBHOOKER PASSWORD RESET: this account's new "+
"password is",
username,
password,
"Save this password now: it is shown only here.\n"+
"The database stores only its Argon2id hash.",
)
}
err := write(stdout)
if err == nil {
return nil
}
// The password is already stored. Standard output failing
// here is the difference between a recovered deployment and
// one locked out behind a password nobody has ever seen, so
// try the other stream before giving up.
if write(stderr) == nil {
return nil
}
return err
}
_, err := fmt.Fprintf(
stdout,
"password updated for user %q in %s\n", username, dir,
)
if err != nil {
return fmt.Errorf("writing the result: %w", err)
}
return nil
}
// acquire takes the DATA_DIR lock, translating the contended case into
// the refusal this command owes the operator.
//
// Resetting a password underneath a live instance would not corrupt
// anything, but the running process keeps serving every session that
// authenticated with the old one, so the operator would be told the
// password changed while the deployment still behaved as though it had
// not. Refusing is also what the lock is for.
func acquire(dir string) (*datadir.Lock, error) {
lock, err := datadir.Acquire(dir)
if err == nil {
return lock, nil
}
if errors.Is(err, datadir.ErrLocked) {
return nil, fmt.Errorf(
"%w: %s. Stop it and run this again: a password reset "+
"does not reach a running process, whose existing "+
"sessions stay authenticated",
ErrLiveInstance, dir,
)
}
return nil, err
}
// checkDataDir refuses to act on a path that does not already hold a
// deployment.
//
// This runs before datadir.Acquire on purpose. Acquire calls
// os.MkdirAll, so a mistyped DATA_DIR would otherwise be built out —
// the directory tree, the lock file, and then an empty migrated
// database — and the command would report success against a deployment
// that does not exist while the real one stayed locked out. Nothing
// here creates anything.
func checkDataDir(dir string) error {
info, err := os.Stat(dir)
switch {
case errors.Is(err, fs.ErrNotExist):
return fmt.Errorf(
"%w: %s (DATA_DIR). This acts on an existing "+
"deployment and creates nothing",
ErrNoDataDir, dir,
)
case err != nil:
return fmt.Errorf("checking data directory %s: %w", dir, err)
case !info.IsDir():
return fmt.Errorf(
"%w: %s (DATA_DIR) is not a directory", ErrNoDataDir, dir,
)
}
dbPath := filepath.Join(dir, database.MainDBFileName)
_, err = os.Stat(dbPath)
switch {
case errors.Is(err, fs.ErrNotExist):
return fmt.Errorf(
"%w: %s does not exist. The admin account is created by "+
"the first server start",
ErrNoDatabase, dbPath,
)
case err != nil:
return fmt.Errorf("checking %s: %w", dbPath, err)
}
return nil
}
// setPassword looks the account up, obtains the new password, and
// writes its hash.
//
// The order matters: the account is resolved before an operator is
// asked to type anything, and the hash is computed in full before the
// single UPDATE that stores it. A failure at any step therefore leaves
// the stored credential exactly as it was — there is no half-written
// state to recover from.
func setPassword(
db *database.Database,
username string,
generate bool,
stdin io.Reader,
stderr io.Writer,
) (string, error) {
var user database.User
err := db.DB().Where("username = ?", username).First(&user).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", fmt.Errorf(
"%w: %q. This changes an existing account's password "+
"and never creates an account",
ErrNoSuchUser, username,
)
}
if err != nil {
return "", fmt.Errorf("looking up user %q: %w", username, err)
}
password, err := newPassword(generate, stdin, stderr)
if err != nil {
return "", err
}
hash, err := database.HashPassword(password)
if err != nil {
return "", fmt.Errorf("hashing the new password: %w", err)
}
result := db.DB().Model(&database.User{}).
Where("id = ?", user.ID).
Update("password", hash)
if result.Error != nil {
return "", fmt.Errorf(
"updating user %q: %w", username, result.Error,
)
}
if result.RowsAffected != 1 {
return "", fmt.Errorf(
"%w: %q matched %d rows",
ErrNotUpdated, username, result.RowsAffected,
)
}
return password, nil
}
// newPassword returns the password to store: generated, or read from
// standard input.
func newPassword(
generate bool,
stdin io.Reader,
stderr io.Writer,
) (string, error) {
if generate {
password, err := database.GenerateRandomPassword(
generatedPasswordLen,
)
if err != nil {
return "", fmt.Errorf("generating a password: %w", err)
}
return password, nil
}
return readPassword(stdin, stderr)
}
// readPassword reads the new password as one line from standard input,
// minus its line ending.
//
// Standard input rather than an argument: on Linux argv is readable
// through /proc by every account on the host for as long as the process
// lives, and a password typed as an argument lands in shell history
// besides.
//
// When standard input is a terminal the input is echoed — no attempt is
// made to put the terminal into no-echo mode — so the prompt says so
// rather than letting an operator assume otherwise.
func readPassword(stdin io.Reader, stderr io.Writer) (string, error) {
if f, ok := stdin.(*os.File); ok && isTerminal(f) {
_, _ = fmt.Fprintf(
stderr,
"New password (echoed as you type), then Enter: ",
)
}
line, err := bufio.NewReader(stdin).ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return "", fmt.Errorf(
"reading the password from standard input: %w", err,
)
}
password := strings.TrimRight(line, "\r\n")
if password == "" {
return "", fmt.Errorf(
"%w: standard input carried no password. Pipe one in, "+
"or pass -generate",
ErrEmptyPassword,
)
}
if len(password) < minPasswordLen {
return "", fmt.Errorf(
"%w: %d bytes, minimum %d",
ErrPasswordTooShort, len(password), minPasswordLen,
)
}
return password, nil
}
// isTerminal reports whether f is a character device, which is as much
// as this needs to know to decide whether to prompt.
func isTerminal(f *os.File) bool {
info, err := f.Stat()
if err != nil {
return false
}
return info.Mode()&os.ModeCharDevice != 0
}
// cliLogger builds the logger the database layer writes through while
// this command runs. It is deliberately quiet: connecting and migrating
// are steps the operator did not ask about, and the one thing they need
// to see is the outcome on standard output.
func cliLogger(stderr io.Writer) *slog.Logger {
return slog.New(slog.NewTextHandler(stderr, &slog.HandlerOptions{
Level: slog.LevelWarn,
}))
}

View 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)
})
}
}