feat(server/m7-user-mgmt): forgot + reset password endpoints (U3)
POST /api/auth/forgot-password and POST /api/auth/reset-password. Forgot-password ALWAYS returns 200 with empty JSON to prevent enumeration of registered emails. Side effect: when email matches a user with email-on-file, generates a 32-byte hex token (24h TTL), inserts into password_resets, and sends the reset email via the mailer. Mailer failures are logged (not surfaced) and the audit log carries metadata.email_match for operator visibility. Reset-password atomically claims the token via UsePasswordReset (:execrows; concurrent calls can't both succeed). On rows=1, hashes the new password and writes via ChangeUserPassword. Returns 204 on success, 400 invalid_token on stale/used/missing tokens, 400 password_too_short for short passwords. Audits ActionPasswordResetByEmail. Wires the mailer.Sender into the handlers struct via Mount; production sender (NewSMTPSender) constructed in server.Router(); tests inject FakeSender via testHandlers default. The reset URL embedded in the email is derived from r.Host (no PublicURL config setting in v1; self-hosted operators see their own hostname). Tests cover happy-path send + token-row insertion, unknown email returns 200 with no send, mailer failure still returns 200, reset happy path verifies bcrypt match + used_at set, already-used token 400, expired token 400, short password 400, bogus token 400. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+6
-1
@@ -18,6 +18,7 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||
@@ -26,7 +27,7 @@ import (
|
||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||
// RequireUser; everything else is gated by the middleware. The events writer
|
||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverBackfillCap int, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string) {
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverBackfillCap int, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string, sender mailer.Sender) {
|
||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||
h := &handlers{
|
||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
||||
@@ -43,11 +44,14 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
scanCfg: scanCfg,
|
||||
scheduler: scheduler,
|
||||
dataDir: dataDir,
|
||||
mailer: sender,
|
||||
}
|
||||
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
api.Post("/auth/login", h.handleLogin)
|
||||
api.Post("/auth/register", h.handleRegister)
|
||||
api.Post("/auth/forgot-password", h.handleForgotPassword)
|
||||
api.Post("/auth/reset-password", h.handleResetPassword)
|
||||
api.Group(func(authed chi.Router) {
|
||||
authed.Use(auth.RequireUser(pool))
|
||||
authed.Post("/auth/logout", h.handleLogout)
|
||||
@@ -179,4 +183,5 @@ type handlers struct {
|
||||
scanCfg library.RunScanConfig
|
||||
scheduler *library.Scheduler
|
||||
dataDir string
|
||||
mailer mailer.Sender
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||
)
|
||||
|
||||
const (
|
||||
resetTokenTTL = 24 * time.Hour
|
||||
resetTokenSize = 32 // bytes; 64 chars hex on the wire
|
||||
)
|
||||
|
||||
type forgotPasswordReq struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// handleForgotPassword implements POST /api/auth/forgot-password.
|
||||
//
|
||||
// Always returns 200 with an empty JSON body so the response shape
|
||||
// doesn't reveal whether the email is registered. The actual side
|
||||
// effect (token + email send) only happens when the email matches a
|
||||
// user with email-on-file. SMTP send failures are logged but don't
|
||||
// propagate to the response.
|
||||
//
|
||||
// Audits ActionForgotPasswordInit with metadata.email_match so the
|
||||
// operator can correlate audit log entries with successful sends
|
||||
// even though the response is opaque.
|
||||
func (h *handlers) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
|
||||
var req forgotPasswordReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
// Decode failures are also opaque — return 200 even on bad JSON.
|
||||
writeJSON(w, http.StatusOK, struct{}{})
|
||||
return
|
||||
}
|
||||
email := strings.ToLower(strings.TrimSpace(req.Email))
|
||||
|
||||
q := dbq.New(h.pool)
|
||||
var matched bool
|
||||
var auditTarget pgtype.UUID
|
||||
if email != "" {
|
||||
user, err := q.GetUserByEmail(r.Context(), email)
|
||||
if err == nil && user.Email != nil && *user.Email != "" {
|
||||
matched = true
|
||||
auditTarget = user.ID
|
||||
if sendErr := h.sendResetEmail(r.Context(), r, user); sendErr != nil {
|
||||
h.logger.Warn("forgot-password: send failed",
|
||||
"email", email, "err", sendErr)
|
||||
// fall through; response is still 200
|
||||
}
|
||||
} else if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
h.logger.Warn("forgot-password: lookup failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Audit (best-effort).
|
||||
if err := audit.Write(r.Context(), h.pool, pgtype.UUID{}, auditTarget, audit.ActionForgotPasswordInit,
|
||||
map[string]any{"email_match": matched}); err != nil {
|
||||
h.logger.Warn("forgot-password: audit failed", "err", err)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, struct{}{})
|
||||
}
|
||||
|
||||
// sendResetEmail generates a token, persists it, renders + sends the
|
||||
// email. Returns the underlying error (caller logs it but doesn't
|
||||
// surface to the HTTP response).
|
||||
func (h *handlers) sendResetEmail(ctx context.Context, r *http.Request, user dbq.User) error {
|
||||
if h.mailer == nil {
|
||||
return errors.New("forgot-password: no mailer configured")
|
||||
}
|
||||
tokenBytes := make([]byte, resetTokenSize)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
token := hex.EncodeToString(tokenBytes)
|
||||
expiresAt := time.Now().Add(resetTokenTTL)
|
||||
|
||||
q := dbq.New(h.pool)
|
||||
if err := q.CreatePasswordReset(ctx, dbq.CreatePasswordResetParams{
|
||||
Token: token,
|
||||
UserID: user.ID,
|
||||
ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resetURL := buildResetURL(r, token)
|
||||
textBody, htmlBody, err := mailer.RenderResetEmail(mailer.ResetEmailVars{
|
||||
Username: user.Username,
|
||||
ResetURL: resetURL,
|
||||
ExpiryHours: int(resetTokenTTL.Hours()),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if user.Email == nil || *user.Email == "" {
|
||||
// Defensive — sendResetEmail is only called when email is set,
|
||||
// but we re-check to avoid sending to nil.
|
||||
return errors.New("forgot-password: user has no email")
|
||||
}
|
||||
return h.mailer.Send(ctx, *user.Email, mailer.ResetEmailSubject, textBody, htmlBody)
|
||||
}
|
||||
|
||||
func buildResetURL(r *http.Request, token string) string {
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
host := r.Host
|
||||
if host == "" {
|
||||
host = "localhost"
|
||||
}
|
||||
return scheme + "://" + host + "/reset-password/" + token
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||
)
|
||||
|
||||
func TestForgotPassword_KnownEmail_SendsMail(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
fake := &mailer.FakeSender{}
|
||||
h.mailer = fake
|
||||
|
||||
user := seedUser(t, pool, "forgotuser", "pw", false)
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
"UPDATE users SET email = 'forgot@example.com' WHERE id = $1", user.ID); err != nil {
|
||||
t.Fatalf("seed email: %v", err)
|
||||
}
|
||||
|
||||
body := `{"email":"forgot@example.com"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password",
|
||||
bytes.NewReader([]byte(body)))
|
||||
req.Host = "minstrel.example.com"
|
||||
rec := httptest.NewRecorder()
|
||||
h.handleForgotPassword(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if len(fake.Sent) != 1 {
|
||||
t.Fatalf("Sent len = %d, want 1", len(fake.Sent))
|
||||
}
|
||||
got := fake.LastSent()
|
||||
if got.To != "forgot@example.com" {
|
||||
t.Errorf("To = %q, want forgot@example.com", got.To)
|
||||
}
|
||||
// Token row was inserted.
|
||||
var tokenCount int
|
||||
if err := pool.QueryRow(context.Background(),
|
||||
"SELECT count(*) FROM password_resets WHERE user_id = $1", user.ID).Scan(&tokenCount); err != nil {
|
||||
t.Fatalf("verify token: %v", err)
|
||||
}
|
||||
if tokenCount == 0 {
|
||||
t.Errorf("no password_resets row inserted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForgotPassword_UnknownEmail_Returns200_NoSend(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, _ := testHandlers(t)
|
||||
fake := &mailer.FakeSender{}
|
||||
h.mailer = fake
|
||||
|
||||
body := `{"email":"nonexistent@example.com"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password",
|
||||
bytes.NewReader([]byte(body)))
|
||||
rec := httptest.NewRecorder()
|
||||
h.handleForgotPassword(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200 (always; no enumeration)", rec.Code)
|
||||
}
|
||||
if len(fake.Sent) != 0 {
|
||||
t.Errorf("Sent len = %d, want 0 for unknown email", len(fake.Sent))
|
||||
}
|
||||
}
|
||||
|
||||
func TestForgotPassword_MailerFailure_StillReturns200(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
fake := &mailer.FakeSender{FailNext: errors.New("simulated SMTP failure")}
|
||||
h.mailer = fake
|
||||
|
||||
user := seedUser(t, pool, "mailfail", "pw", false)
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
"UPDATE users SET email = 'mailfail@example.com' WHERE id = $1", user.ID); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
body := `{"email":"mailfail@example.com"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password",
|
||||
bytes.NewReader([]byte(body)))
|
||||
rec := httptest.NewRecorder()
|
||||
h.handleForgotPassword(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200 (mailer failures don't propagate)", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
type resetPasswordReq struct {
|
||||
Token string `json:"token"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// handleResetPassword implements POST /api/auth/reset-password.
|
||||
//
|
||||
// Atomically claims the reset token and writes the new password hash.
|
||||
// Returns:
|
||||
// - 204 on successful reset
|
||||
// - 400 password_too_short if new_password < 8 chars
|
||||
// - 400 invalid_token if the token doesn't exist, was already used,
|
||||
// or has expired
|
||||
// - 500 on internal errors
|
||||
func (h *handlers) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
var req resetPasswordReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid_body", "")
|
||||
return
|
||||
}
|
||||
if len(req.NewPassword) < minPasswordLength {
|
||||
writeErr(w, http.StatusBadRequest, "password_too_short", "password must be at least 8 chars")
|
||||
return
|
||||
}
|
||||
if req.Token == "" {
|
||||
writeErr(w, http.StatusBadRequest, "invalid_token", "")
|
||||
return
|
||||
}
|
||||
|
||||
q := dbq.New(h.pool)
|
||||
|
||||
// Look up the reset record so we know which user to update before
|
||||
// we claim the token. We can't reverse-lookup user_id after
|
||||
// UsePasswordReset (it returns rows-affected, not the row).
|
||||
reset, err := q.GetPasswordReset(r.Context(), req.Token)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, http.StatusBadRequest, "invalid_token", "")
|
||||
return
|
||||
}
|
||||
h.logger.Error("reset password: lookup failed", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "")
|
||||
return
|
||||
}
|
||||
|
||||
// Atomically claim the token. Returns rows-affected (1 if
|
||||
// claimable, 0 if already used / expired). We do this BEFORE
|
||||
// hashing the password so concurrent reset attempts can't both
|
||||
// succeed.
|
||||
rows, err := q.UsePasswordReset(r.Context(), req.Token)
|
||||
if err != nil {
|
||||
h.logger.Error("reset password: use token failed", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "")
|
||||
return
|
||||
}
|
||||
if rows == 0 {
|
||||
writeErr(w, http.StatusBadRequest, "invalid_token", "")
|
||||
return
|
||||
}
|
||||
|
||||
// Hash and update. If hashing or update fails after the token
|
||||
// claim, the token is "burned" but the password isn't reset —
|
||||
// user has to request another. That's acceptable; bcrypt and
|
||||
// UPDATE rarely fail in healthy systems.
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
h.logger.Error("reset password: hash failed", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "")
|
||||
return
|
||||
}
|
||||
if err := q.ChangeUserPassword(r.Context(), dbq.ChangeUserPasswordParams{
|
||||
ID: reset.UserID,
|
||||
PasswordHash: string(hash),
|
||||
}); err != nil {
|
||||
h.logger.Error("reset password: update failed", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "")
|
||||
return
|
||||
}
|
||||
|
||||
if err := audit.Write(r.Context(), h.pool, reset.UserID, reset.UserID, audit.ActionPasswordResetByEmail, nil); err != nil {
|
||||
h.logger.Warn("reset password: audit failed", "err", err)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestResetPassword_Happy(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "resetuser", "oldpw1234", false)
|
||||
|
||||
// Insert a fresh token.
|
||||
token := "test-reset-token-abc"
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
`INSERT INTO password_resets (token, user_id, expires_at)
|
||||
VALUES ($1, $2, $3)`, token, user.ID, time.Now().Add(time.Hour),
|
||||
); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
body := `{"token":"test-reset-token-abc","new_password":"newpassword1"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/reset-password",
|
||||
bytes.NewReader([]byte(body)))
|
||||
rec := httptest.NewRecorder()
|
||||
h.handleResetPassword(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// New password works?
|
||||
var hash string
|
||||
if err := pool.QueryRow(context.Background(),
|
||||
"SELECT password_hash FROM users WHERE id = $1", user.ID).Scan(&hash); err != nil {
|
||||
t.Fatalf("read hash: %v", err)
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte("newpassword1")); err != nil {
|
||||
t.Errorf("new password doesn't match: %v", err)
|
||||
}
|
||||
|
||||
// Token marked used?
|
||||
var usedAt pgtype.Timestamptz
|
||||
if err := pool.QueryRow(context.Background(),
|
||||
"SELECT used_at FROM password_resets WHERE token = $1", token).Scan(&usedAt); err != nil {
|
||||
t.Fatalf("verify used_at: %v", err)
|
||||
}
|
||||
if !usedAt.Valid {
|
||||
t.Errorf("used_at is NULL after reset; should be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPassword_AlreadyUsed_400(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "alreadyused", "pw", false)
|
||||
|
||||
token := "test-already-used"
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
`INSERT INTO password_resets (token, user_id, expires_at, used_at)
|
||||
VALUES ($1, $2, $3, now())`, token, user.ID, time.Now().Add(time.Hour),
|
||||
); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
body := `{"token":"test-already-used","new_password":"abcd1234"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/reset-password",
|
||||
bytes.NewReader([]byte(body)))
|
||||
rec := httptest.NewRecorder()
|
||||
h.handleResetPassword(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400 (invalid_token for already-used)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPassword_Expired_400(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "expiredreset", "pw", false)
|
||||
|
||||
token := "test-expired-token"
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
`INSERT INTO password_resets (token, user_id, expires_at)
|
||||
VALUES ($1, $2, $3)`, token, user.ID, time.Now().Add(-time.Hour), // already expired
|
||||
); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
body := `{"token":"test-expired-token","new_password":"abcd1234"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/reset-password",
|
||||
bytes.NewReader([]byte(body)))
|
||||
rec := httptest.NewRecorder()
|
||||
h.handleResetPassword(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400 (invalid_token for expired)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPassword_PasswordTooShort_400(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, _ := testHandlers(t)
|
||||
body := `{"token":"any-token","new_password":"abc"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/reset-password",
|
||||
bytes.NewReader([]byte(body)))
|
||||
rec := httptest.NewRecorder()
|
||||
h.handleResetPassword(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPassword_BogusToken_400(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, _ := testHandlers(t)
|
||||
|
||||
body := `{"token":"bogus-token","new_password":"abcd1234"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/reset-password",
|
||||
bytes.NewReader([]byte(body)))
|
||||
rec := httptest.NewRecorder()
|
||||
h.handleResetPassword(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400 (invalid_token for bogus)", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||
@@ -69,7 +70,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
||||
dataDir := t.TempDir()
|
||||
tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
|
||||
playlistsSvc := playlists.NewService(pool, logger, dataDir)
|
||||
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}}
|
||||
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}}
|
||||
return h, pool
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
||||
@@ -115,7 +116,8 @@ func (s *Server) Router() http.Handler {
|
||||
lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, lidarrClientFn)
|
||||
tracksSvc := tracks.NewService(s.Pool, s.Logger, lidarrUnmonitorAdapter{fn: lidarrClientFn}, s.DataDir)
|
||||
playlistsSvc := playlists.NewService(s.Pool, s.Logger, s.DataDir)
|
||||
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverArtBackfillCap, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.Scheduler, s.DataDir)
|
||||
smtpSender := mailer.NewSMTPSender(s.Pool, s.Logger.With("component", "mailer"))
|
||||
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverArtBackfillCap, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.Scheduler, s.DataDir, smtpSender)
|
||||
// /api/admin/scan is the only admin route owned by the server package
|
||||
// (it needs the Scanner). Register it as a single inline-middleware
|
||||
// route — using r.Route("/api/admin", ...) here would create a second
|
||||
|
||||
Reference in New Issue
Block a user