Files
minstrel/cmd/minstrel/admin.go
T
bvandeusen 760b4a7c6c feat(cli,ci): admin reset-password + migrate subcommands; test-DB isolation + CI integration job
#321 — `minstrel admin reset-password [-user admin] [-password X]`:
loads config, updates BOTH password_hash (bcrypt) and subsonic_password
(plaintext, for Subsonic t+s) so neither auth path is left stale;
generates+prints a strong password when -password is omitted. Recovers
a locked-out operator without DB surgery. Subcommand dispatch added to
main.go (os.Args switch before flag-parse; server path untouched) plus
a `minstrel migrate` subcommand exposing db.Migrate standalone.

#339 — integration tests no longer truncate the dev DB:
- deploy/initdb creates minstrel_test on a fresh compose volume;
  `make test-integration` ensures it idempotently and points
  MINSTREL_TEST_DATABASE_URL at minstrel_test.
- docker-compose.yml + README updated.

CI integration job (test-go.yml): the prior workflow only ran
`go test -short -race` with no DB, so the entire integration suite
silently t.Skip'd — "CI green" never covered API/db/scanner. New
`integration` job runs the full `go test -race` against an ephemeral
Postgres service, using the act_runner shared-daemon pattern: no
published ports, discover the service container by job-name filter via
the docker socket, reach it by bridge IP, hard exactly-one assertion +
dev-compose-name reject (a wrong target would truncate real data),
TCP-wait, `minstrel migrate`, then test. Fast `test` job kept as the
quick gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:36:36 -04:00

130 lines
3.9 KiB
Go

package main
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"flag"
"fmt"
"os"
"golang.org/x/crypto/bcrypt"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
)
// runMigrate applies the embedded migrations and exits. The server also
// auto-migrates on startup (main.go); this standalone path exists for CI
// (provision a fresh DB before the integration suite) and operators who
// want to migrate without starting the server.
func runMigrate(args []string) error {
fs := flag.NewFlagSet("migrate", flag.ContinueOnError)
configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
if err := fs.Parse(args); err != nil {
return err
}
cfg, err := config.Load(*configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
logger, err := logging.New(os.Stdout, cfg.Log.Level, cfg.Log.Format)
if err != nil {
return fmt.Errorf("init logger: %w", err)
}
if err := db.Migrate(cfg.Database.URL, logger); err != nil {
return fmt.Errorf("migrate: %w", err)
}
fmt.Println("minstrel: migrations applied")
return nil
}
// runAdmin dispatches `minstrel admin <subcommand>`.
func runAdmin(args []string) error {
if len(args) == 0 {
return errors.New("usage: minstrel admin reset-password [-user NAME] [-password PW] [-config PATH]")
}
switch args[0] {
case "reset-password":
return adminResetPassword(args[1:])
default:
return fmt.Errorf("unknown admin subcommand %q", args[0])
}
}
// adminResetPassword resets a user's credentials. It updates BOTH
// password_hash (bcrypt, for /api/auth/login) and subsonic_password
// (plaintext, required for Subsonic t+s token verification) so neither
// auth path is left stale. Recovers a locked-out operator when the
// bootstrap password was missed or the DB volume was recreated (Fable
// #321) without DB surgery.
func adminResetPassword(args []string) error {
fs := flag.NewFlagSet("admin reset-password", flag.ContinueOnError)
configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
username := fs.String("user", "admin", "username to reset")
password := fs.String("password", "", "new password; if empty a strong one is generated and printed")
if err := fs.Parse(args); err != nil {
return err
}
cfg, err := config.Load(*configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
if cfg.Database.URL == "" {
return errors.New("no database URL configured (set it in the config file or MINSTREL_DATABASE_URL)")
}
ctx := context.Background()
pool, err := db.Open(ctx, cfg.Database.URL)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer pool.Close()
q := dbq.New(pool)
user, err := q.GetUserByUsername(ctx, *username)
if err != nil {
return fmt.Errorf("look up user %q: %w", *username, err)
}
pw := *password
generated := false
if pw == "" {
b := make([]byte, 18)
if _, err := rand.Read(b); err != nil {
return fmt.Errorf("generate password: %w", err)
}
pw = base64.RawURLEncoding.EncodeToString(b)
generated = true
}
hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
if err := q.ChangeUserPassword(ctx, dbq.ChangeUserPasswordParams{
ID: user.ID,
PasswordHash: string(hash),
}); err != nil {
return fmt.Errorf("update password_hash: %w", err)
}
sp := pw
if err := q.SetSubsonicPassword(ctx, dbq.SetSubsonicPasswordParams{
ID: user.ID,
SubsonicPassword: &sp,
}); err != nil {
return fmt.Errorf("update subsonic_password: %w", err)
}
if generated {
fmt.Printf("minstrel: password for %q reset.\nNew password: %s\n", *username, pw)
} else {
fmt.Printf("minstrel: password for %q reset.\n", *username)
}
return nil
}