157 lines
5.0 KiB
Go
157 lines
5.0 KiB
Go
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)
|
||
|
|
}
|
||
|
|
}
|