package httpapi import ( "bytes" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "os" "strings" "testing" "github.com/descrybe/descrybe-v2/apps/api/internal/auth" "github.com/descrybe/descrybe-v2/apps/api/internal/config" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) func TestForgotPasswordResetIntegration(t *testing.T) { dsn := strings.TrimSpace(os.Getenv("DATABASE_URL")) if dsn == "" { t.Skip("DATABASE_URL not set") } ctx := t.Context() pg, err := pgxpool.New(ctx, dsn) if err != nil { t.Fatalf("postgres: %v", err) } t.Cleanup(pg.Close) var tableReady bool if err := pg.QueryRow(ctx, ` SELECT EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'password_reset_tokens' )`).Scan(&tableReady); err != nil { t.Fatalf("schema probe: %v", err) } if !tableReady { t.Skip("password_reset_tokens missing — run goose up for 041_password_reset_tokens") } userID := uuid.New() prefix := userID.String()[:8] email := fmt.Sprintf("forgot-reset-%s@example.test", prefix) oldPassword := "OldPassword123!" newPassword := "NewPassword456!" hash, err := auth.HashPassword(oldPassword) if err != nil { t.Fatalf("hash: %v", err) } _, err = pg.Exec(ctx, ` INSERT INTO users (id, email, name, password_hash, must_set_password, is_platform_admin, is_active) VALUES ($1, $2, $3, $4, false, false, true)`, userID, email, "Forgot Reset", hash) if err != nil { t.Fatalf("seed user: %v", err) } t.Cleanup(func() { cleanupCtx := t.Context() _, _ = pg.Exec(cleanupCtx, `DELETE FROM password_reset_tokens WHERE user_id = $1`, userID) _, _ = pg.Exec(cleanupCtx, `DELETE FROM users WHERE id = $1`, userID) }) mailer := &recordingMailer{enabled: false} authSvc := &auth.Service{Pool: pg} s := &Server{ Config: config.Config{WebOrigin: "http://localhost:5174"}, Mail: mailer, Auth: authSvc, } // Unknown email — opaque 200, no mail. req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password", bytes.NewBufferString(`{"email":"missing-`+prefix+`@example.test"}`)) req.RemoteAddr = "203.0.113.80:1" rec := httptest.NewRecorder() s.handleForgotPassword(rec, req) if rec.Code != http.StatusOK { t.Fatalf("unknown email status=%d body=%s", rec.Code, rec.Body.String()) } if len(mailer.sent) != 0 { t.Fatalf("expected no mail for unknown email, got %d", len(mailer.sent)) } // Known email — opaque 200 + mail (noop mailer still records Send). req = httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password", bytes.NewBufferString(fmt.Sprintf(`{"email":%q}`, email))) req.RemoteAddr = "203.0.113.81:1" rec = httptest.NewRecorder() s.handleForgotPassword(rec, req) if rec.Code != http.StatusOK { t.Fatalf("known email status=%d 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(), email) || strings.Contains(rec.Body.String(), "token") { t.Fatalf("response must not leak email/token: %s", rec.Body.String()) } if len(mailer.sent) != 1 { t.Fatalf("expected 1 mail, got %d", len(mailer.sent)) } token := extractResetTokenFromMail(mailer.sent[0].Text) if token == "" { t.Fatalf("could not extract token from mail text: %s", mailer.sent[0].Text) } var storedHash string if err := pg.QueryRow(ctx, ` SELECT token_hash FROM password_reset_tokens WHERE user_id = $1 AND consumed_at IS NULL ORDER BY created_at DESC LIMIT 1`, userID).Scan(&storedHash); err != nil { t.Fatalf("load token_hash: %v", err) } if storedHash == token { t.Fatal("DB must store hash only, not plaintext token") } if storedHash != auth.HashInviteToken(token) { t.Fatalf("token_hash=%q want sha256 of raw token", storedHash) } if len(storedHash) != 64 { t.Fatalf("token_hash len=%d want 64", len(storedHash)) } // Reset succeeds. req = httptest.NewRequest(http.MethodPost, "/api/auth/reset-password", bytes.NewBufferString(fmt.Sprintf(`{"token":%q,"password":%q}`, token, newPassword))) rec = httptest.NewRecorder() s.handleResetPassword(rec, req) if rec.Code != http.StatusOK { t.Fatalf("reset status=%d body=%s", rec.Code, rec.Body.String()) } var sessionVersion int err = pg.QueryRow(ctx, `SELECT session_version FROM users WHERE id = $1`, userID).Scan(&sessionVersion) if err != nil { t.Logf("session_version after reset unavailable (apply 042_user_session_version): %v", err) } else if sessionVersion != 1 { t.Fatalf("session_version=%d want 1 after password reset", sessionVersion) } // Token reuse fails. req = httptest.NewRequest(http.MethodPost, "/api/auth/reset-password", bytes.NewBufferString(fmt.Sprintf(`{"token":%q,"password":%q}`, token, "AnotherPass789!"))) rec = httptest.NewRecorder() s.handleResetPassword(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("reuse status=%d want 400 body=%s", rec.Code, rec.Body.String()) } login, err := authSvc.Login(ctx, email, newPassword) if err != nil { t.Fatalf("login with new password: %v", err) } if login.User.ID != userID { t.Fatalf("login user=%s want %s", login.User.ID, userID) } if _, err := authSvc.Login(ctx, email, oldPassword); err == nil { t.Fatal("expected old password to fail") } } func TestForgotPasswordSkipsSyntheticEmail(t *testing.T) { dsn := strings.TrimSpace(os.Getenv("DATABASE_URL")) if dsn == "" { t.Skip("DATABASE_URL not set") } ctx := t.Context() pg, err := pgxpool.New(ctx, dsn) if err != nil { t.Fatalf("postgres: %v", err) } t.Cleanup(pg.Close) var tableReady bool if err := pg.QueryRow(ctx, ` SELECT EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'password_reset_tokens' )`).Scan(&tableReady); err != nil || !tableReady { t.Skip("password_reset_tokens missing — run goose up for 041_password_reset_tokens") } userID := uuid.New() email := fmt.Sprintf("synth-%s@legacy.local", userID.String()[:8]) hash, err := auth.HashPassword("Password123!") if err != nil { t.Fatalf("hash: %v", err) } _, err = pg.Exec(ctx, ` INSERT INTO users (id, email, name, password_hash, must_set_password, is_platform_admin, is_active) VALUES ($1, $2, $3, $4, false, false, true)`, userID, email, "Synthetic", hash) if err != nil { t.Fatalf("seed: %v", err) } t.Cleanup(func() { cleanupCtx := t.Context() _, _ = pg.Exec(cleanupCtx, `DELETE FROM password_reset_tokens WHERE user_id = $1`, userID) _, _ = pg.Exec(cleanupCtx, `DELETE FROM users WHERE id = $1`, userID) }) mailer := &recordingMailer{enabled: true} authSvc := &auth.Service{Pool: pg} s := &Server{ Config: config.Config{WebOrigin: "http://localhost:5174"}, Mail: mailer, Auth: authSvc, } // Mixed case / whitespace must still be refused (anti-enumeration opaque 200). req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password", bytes.NewBufferString(fmt.Sprintf(`{"email":%q}`, " "+strings.ToUpper(email)+" "))) req.RemoteAddr = "203.0.113.82:1" rec := httptest.NewRecorder() s.handleForgotPassword(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status=%d 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(), email) { 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)) } var tokenCount int if err := pg.QueryRow(ctx, ` SELECT count(*) FROM password_reset_tokens WHERE user_id = $1`, userID).Scan(&tokenCount); err != nil { t.Fatalf("token count: %v", err) } if tokenCount != 0 { t.Fatalf("expected 0 reset tokens for synthetic user, got %d", tokenCount) } _, err = authSvc.IssuePasswordReset(ctx, email, 0) if !errors.Is(err, auth.ErrSyntheticEmail) { t.Fatalf("IssuePasswordReset err=%v want ErrSyntheticEmail", err) } } func extractResetTokenFromMail(text string) string { const marker = "/reset-password#token=" i := strings.Index(text, marker) if i < 0 { // Legacy query-string links (pre-fragment). const legacy = "/reset-password?token=" i = strings.Index(text, legacy) if i < 0 { return "" } rest := text[i+len(legacy):] end := strings.IndexAny(rest, "\r\n \t") if end < 0 { return strings.TrimSpace(rest) } return strings.TrimSpace(rest[:end]) } rest := text[i+len(marker):] end := strings.IndexAny(rest, "\r\n \t") if end < 0 { return strings.TrimSpace(rest) } return strings.TrimSpace(rest[:end]) }