Files
minstrel/internal/api/auth_forgot_test.go
bvandeusen cbe838cbe3 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>
2026-05-07 12:49:30 -04:00

103 lines
2.9 KiB
Go

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