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.
86 lines
2.2 KiB
Go
86 lines
2.2 KiB
Go
package database_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
)
|
|
|
|
// passwordField is the banner line carrying the plaintext.
|
|
const passwordField = "password: "
|
|
|
|
// bannerPassword returns the password the banner printed.
|
|
func bannerPassword(t *testing.T, out string) string {
|
|
t.Helper()
|
|
|
|
for line := range strings.SplitSeq(out, "\n") {
|
|
_, value, found := strings.Cut(line, passwordField)
|
|
if found {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
|
|
t.Fatalf("no %q line in the banner:\n%s", passwordField, out)
|
|
|
|
return ""
|
|
}
|
|
|
|
// TestFirstBoot_PrintsTheAdminPasswordAsABanner is the bootstrap half
|
|
// of https://git.eeqj.de/sneak/webhooker/issues/208.
|
|
//
|
|
// The password is shown exactly once, and it used to be shown as one
|
|
// slog record among the roughly 45 fx PROVIDE/RUN/HOOK lines a boot
|
|
// writes — which is how deployments lost it and, with no reset path,
|
|
// locked themselves out. It must be emitted as a block an operator can
|
|
// find by eye, it must carry the plaintext that actually opens the
|
|
// account, and it must name the command that recovers it.
|
|
func TestFirstBoot_PrintsTheAdminPasswordAsABanner(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, lc := setupTestDB(t)
|
|
|
|
var out bytes.Buffer
|
|
|
|
db.ExportSetBannerOut(&out)
|
|
|
|
ctx := context.Background()
|
|
require.NoError(t, lc.Start(ctx))
|
|
|
|
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
|
|
|
printed := out.String()
|
|
|
|
require.Contains(
|
|
t, printed, strings.Repeat("=", 20),
|
|
"the banner must be ruled off, not read as one more log line",
|
|
)
|
|
require.Contains(t, printed, "username: admin")
|
|
assert.Contains(
|
|
t, printed, "resetpw",
|
|
"the banner must name the command that recovers the account",
|
|
)
|
|
|
|
password := bannerPassword(t, printed)
|
|
require.NotEmpty(t, password)
|
|
|
|
// The printed plaintext must be the one that opens the account:
|
|
// a banner showing a different string would be worse than none.
|
|
var user database.User
|
|
|
|
require.NoError(
|
|
t,
|
|
db.DB().Where("username = ?", "admin").First(&user).Error,
|
|
)
|
|
|
|
ok, err := database.VerifyPassword(password, user.Password)
|
|
require.NoError(t, err)
|
|
assert.True(
|
|
t, ok, "the printed password must open the seeded account",
|
|
)
|
|
}
|