package httpapi import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/descrybe/descrybe-v2/apps/api/internal/auth" "github.com/descrybe/descrybe-v2/apps/api/internal/mail" ) func TestHandleForgotPasswordMailerRequired(t *testing.T) { t.Parallel() s := &Server{Auth: &auth.Service{}} req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password", bytes.NewBufferString(`{"email":"a@example.com"}`)) rec := httptest.NewRecorder() s.handleForgotPassword(rec, req) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("status=%d want 503", rec.Code) } } func TestHandleForgotPasswordRequiresEmail(t *testing.T) { t.Parallel() s := &Server{ Mail: &recordingMailer{enabled: true}, Auth: &auth.Service{}, } req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password", bytes.NewBufferString(`{"email":" "}`)) rec := httptest.NewRecorder() s.handleForgotPassword(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status=%d want 400 body=%s", rec.Code, rec.Body.String()) } } func TestHandleForgotPasswordIPRateLimited(t *testing.T) { t.Parallel() s := &Server{ Mail: &recordingMailer{enabled: true}, Auth: &auth.Service{}, } s.ensureForgotPasswordLimiters() s.forgotPasswordIPRL = newSlidingWindowLimiter(1, time.Minute) s.forgotPasswordEmailRL = newSlidingWindowLimiter(10, time.Hour) key := "forgot-password-ip:203.0.113.50:1" if !s.forgotPasswordIPRL.allow(key) { t.Fatal("setup: expected first allow") } req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password", bytes.NewBufferString(`{"email":"user@example.com"}`)) req.RemoteAddr = "203.0.113.50:1" rec := httptest.NewRecorder() s.handleForgotPassword(rec, req) if rec.Code != http.StatusTooManyRequests { t.Fatalf("status=%d want 429 body=%s", rec.Code, rec.Body.String()) } if rec.Header().Get("Retry-After") == "" { t.Fatal("expected Retry-After") } if strings.Contains(rec.Body.String(), "@") { t.Fatalf("rate-limit body must not include email: %s", rec.Body.String()) } } func TestHandleForgotPasswordEmailRateLimited(t *testing.T) { t.Parallel() s := &Server{ Mail: &recordingMailer{enabled: true}, Auth: &auth.Service{}, } s.ensureForgotPasswordLimiters() s.forgotPasswordIPRL = newSlidingWindowLimiter(10, time.Minute) s.forgotPasswordEmailRL = newSlidingWindowLimiter(1, time.Hour) emailKey := "forgot-password-email:user@example.com" if !s.forgotPasswordEmailRL.allow(emailKey) { t.Fatal("setup: expected first allow") } req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password", bytes.NewBufferString(`{"email":"User@Example.com"}`)) req.RemoteAddr = "198.51.100.10:9" rec := httptest.NewRecorder() s.handleForgotPassword(rec, req) if rec.Code != http.StatusTooManyRequests { t.Fatalf("status=%d want 429 body=%s", rec.Code, rec.Body.String()) } if rec.Header().Get("Retry-After") == "" { t.Fatal("expected Retry-After") } if strings.Contains(rec.Body.String(), "@") { t.Fatalf("rate-limit body must not include email: %s", rec.Body.String()) } } func TestHandleResetPasswordAuthRequired(t *testing.T) { t.Parallel() s := &Server{} req := httptest.NewRequest(http.MethodPost, "/api/auth/reset-password", bytes.NewBufferString(`{"token":"x","password":"password12"}`)) rec := httptest.NewRecorder() s.handleResetPassword(rec, req) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("status=%d want 503", rec.Code) } } func TestHandleForgotPasswordSkipsSyntheticEmail(t *testing.T) { t.Parallel() mailer := &recordingMailer{enabled: true} // No Pool: IssuePasswordReset must refuse @legacy.local before any DB access. s := &Server{ Mail: mailer, Auth: &auth.Service{}, } req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password", bytes.NewBufferString(`{"email":" Synth_User@Legacy.Local "}`)) req.RemoteAddr = "203.0.113.83:1" rec := httptest.NewRecorder() s.handleForgotPassword(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status=%d want 200 body=%s", rec.Code, rec.Body.String()) } var opaque map[string]string if err := json.Unmarshal(rec.Body.Bytes(), &opaque); err != nil { t.Fatalf("json: %v", err) } if opaque["status"] != "ok" { t.Fatalf("opaque=%v", opaque) } if strings.Contains(rec.Body.String(), "legacy.local") || strings.Contains(rec.Body.String(), "synth") { t.Fatalf("response must not leak synthetic email: %s", rec.Body.String()) } if len(mailer.sent) != 0 { t.Fatalf("synthetic emails must not receive mail, got %d", len(mailer.sent)) } } func TestForgotPasswordMessageLink(t *testing.T) { t.Parallel() msg := mail.ForgotPasswordMessage("http://localhost:5174/", "a@example.com", "tok123") if msg.To != "a@example.com" { t.Fatalf("to=%q", msg.To) } if !strings.Contains(msg.Text, "/reset-password#token=tok123") { t.Fatalf("text missing reset link: %s", msg.Text) } if strings.Contains(msg.Text, "/accept-invite") { t.Fatal("forgot-password mail must not use accept-invite") } if msg.Subject != "Reset your Descrybe password" { t.Fatalf("subject=%q", msg.Subject) } }