refactor(handlers): migrate remaining backend handlers to clock.Now() and transaction patterns
Apply clock.Now() migration, transaction wrapping, and minor refactors across admin, scheduling, today, user, auth handler, notifications, webhooks, services, portfolio, ratelimit, testutils, and main.go. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -30,6 +30,7 @@ import (
|
||||
"time"
|
||||
|
||||
"crussell/auth"
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/internal/dav"
|
||||
"crussell/mw"
|
||||
@@ -286,7 +287,7 @@ func TestRegister_InvalidInput_Under16(t *testing.T) {
|
||||
handler := http.HandlerFunc(RegisterHandler)
|
||||
|
||||
// Calculate a date that makes them under 16
|
||||
under16DOB := time.Now().AddDate(-15, 0, 0).Format("2006-01-02")
|
||||
under16DOB := clock.Now().AddDate(-15, 0, 0).Format("2006-01-02")
|
||||
|
||||
body := RegisterRequest{
|
||||
FirstName: "Young",
|
||||
@@ -601,7 +602,7 @@ func TestVerifyCheck_ValidCode(t *testing.T) {
|
||||
|
||||
// Create a verification code
|
||||
var code string
|
||||
expiresAt := time.Now().Add(24 * time.Hour)
|
||||
expiresAt := clock.Now().Add(24 * time.Hour)
|
||||
err = tx.QueryRow(ctx,
|
||||
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
|
||||
userID, expiresAt).Scan(&code)
|
||||
@@ -674,7 +675,7 @@ func TestVerifyCheck_ExpiredCode(t *testing.T) {
|
||||
|
||||
// Create an expired verification code
|
||||
var code string
|
||||
expiresAt := time.Now().Add(-1 * time.Hour) // Expired 1 hour ago
|
||||
expiresAt := clock.Now().Add(-1 * time.Hour) // Expired 1 hour ago
|
||||
err = tx.QueryRow(ctx,
|
||||
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
|
||||
userID, expiresAt).Scan(&code)
|
||||
@@ -786,7 +787,7 @@ func TestVerifyCheck_AlreadyUsed(t *testing.T) {
|
||||
|
||||
// Create a verification code
|
||||
var code string
|
||||
expiresAt := time.Now().Add(24 * time.Hour)
|
||||
expiresAt := clock.Now().Add(24 * time.Hour)
|
||||
err = tx.QueryRow(ctx,
|
||||
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
|
||||
userID, expiresAt).Scan(&code)
|
||||
@@ -840,7 +841,7 @@ func TestVerifyCheck_RoleChangeToVerified(t *testing.T) {
|
||||
|
||||
// Create a verification code
|
||||
var code string
|
||||
expiresAt := time.Now().Add(24 * time.Hour)
|
||||
expiresAt := clock.Now().Add(24 * time.Hour)
|
||||
err = tx.QueryRow(ctx,
|
||||
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
|
||||
userID, expiresAt).Scan(&code)
|
||||
@@ -1446,7 +1447,7 @@ func TestLoginInProgress_Cap(t *testing.T) {
|
||||
// Fill the loginInProgress map with 20 entries
|
||||
loginStateMu.Lock()
|
||||
for i := 0; i < maxLoginInProgress; i++ {
|
||||
loginInProgress[fmt.Sprintf("stale-user-%d", i)] = time.Now()
|
||||
loginInProgress[fmt.Sprintf("stale-user-%d", i)] = clock.Now()
|
||||
}
|
||||
loginStateMu.Unlock()
|
||||
|
||||
@@ -1736,7 +1737,7 @@ func TestJTI_Revocation_PostgreSQL(t *testing.T) {
|
||||
t.Fatalf("token should be valid before revocation: %v", err)
|
||||
}
|
||||
|
||||
auth.RevokeJTI(ctx, jti, time.Now().Add(1*time.Hour))
|
||||
auth.RevokeJTI(ctx, jti, clock.Now().Add(1*time.Hour))
|
||||
|
||||
if !auth.IsJTIRevoked(ctx, jti) {
|
||||
t.Error("JTI should be revoked after RevokeJTI call")
|
||||
|
||||
@@ -2,6 +2,7 @@ package auth
|
||||
|
||||
import (
|
||||
"crussell/auth"
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"crussell/internal/dav"
|
||||
@@ -44,15 +45,22 @@ func init() {
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
loginStateMu.Lock()
|
||||
now := time.Now()
|
||||
// Clean up stuck loginInProgress entries (older than 30s)
|
||||
for userID, startedAt := range loginInProgress {
|
||||
if now.Sub(startedAt) > 30*time.Second {
|
||||
delete(loginInProgress, userID)
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("Panic recovered in login state cleanup ticker: %v", r)
|
||||
}
|
||||
}()
|
||||
loginStateMu.Lock()
|
||||
now := clock.Now()
|
||||
// Clean up stuck loginInProgress entries (older than 30s)
|
||||
for userID, startedAt := range loginInProgress {
|
||||
if now.Sub(startedAt) > 30*time.Second {
|
||||
delete(loginInProgress, userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
loginStateMu.Unlock()
|
||||
loginStateMu.Unlock()
|
||||
}()
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -183,7 +191,7 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Reject if younger than 16
|
||||
if !dob.Before(time.Now().AddDate(-16, 0, 0)) {
|
||||
if !dob.Before(clock.Now().AddDate(-16, 0, 0)) {
|
||||
http.Error(w, "account creation prohibited for users under 16. Please call to book an appointment.", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -224,7 +232,7 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
now := time.Now()
|
||||
now := clock.Now()
|
||||
|
||||
// Insert and return the generated ID
|
||||
var userID string
|
||||
@@ -266,6 +274,11 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("Panic recovered in CardDAV contact creation: %v", r)
|
||||
}
|
||||
}()
|
||||
input := dav.ContactInput{
|
||||
UserID: userID,
|
||||
FirstName: req.FirstName,
|
||||
@@ -334,7 +347,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var failedAttempts int
|
||||
var lockedUntil *time.Time
|
||||
err = db.Conn.QueryRow(r.Context(), `SELECT failed_attempts, locked_until FROM users WHERE id = $1`, userID).Scan(&failedAttempts, &lockedUntil)
|
||||
if err == nil && lockedUntil != nil && time.Now().Before(*lockedUntil) {
|
||||
if err == nil && lockedUntil != nil && clock.Now().Before(*lockedUntil) {
|
||||
http.Error(w, "account is temporarily locked. try again later.", http.StatusTooManyRequests)
|
||||
log.Printf("LOGIN_AUDIT: locked account attempt - user=%s ip=%s", userID, middleware.GetClientIP(r.Context()))
|
||||
return
|
||||
@@ -353,7 +366,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "server busy, try again later", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
loginInProgress[userID] = time.Now()
|
||||
loginInProgress[userID] = clock.Now()
|
||||
loginStateMu.Unlock()
|
||||
|
||||
// Always clear flag when done
|
||||
@@ -368,7 +381,15 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Increment failed attempts in DB with progressive lockout
|
||||
var newFailed int
|
||||
var newLockedUntil *time.Time
|
||||
db.Conn.QueryRow(r.Context(), `
|
||||
tx, err := db.Conn.Begin(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("Failed to begin transaction: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
err = tx.QueryRow(r.Context(), `
|
||||
UPDATE users
|
||||
SET failed_attempts = failed_attempts + 1,
|
||||
locked_until = CASE
|
||||
@@ -383,6 +404,17 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
WHERE id = $1
|
||||
RETURNING failed_attempts, locked_until
|
||||
`, userID).Scan(&newFailed, &newLockedUntil)
|
||||
if err != nil {
|
||||
log.Printf("Failed to update failed login attempts: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
log.Printf("Failed to commit transaction: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("LOGIN_AUDIT: failed login user=%s ip=%s attempts=%d locked_until=%v",
|
||||
userID, middleware.GetClientIP(r.Context()), newFailed, newLockedUntil)
|
||||
@@ -394,7 +426,26 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// On success, clear lockout and update last_login
|
||||
// TODO: Password reset flow (MVP #4 in Future Work doc) must also clear
|
||||
// failed_attempts and locked_until — a locked-out user can't call this handler.
|
||||
db.Conn.Exec(r.Context(), `UPDATE users SET failed_attempts = 0, locked_until = NULL, last_login_at = NOW() WHERE id = $1`, userID)
|
||||
tx, err := db.Conn.Begin(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("Failed to begin transaction: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
_, err = tx.Exec(r.Context(), `UPDATE users SET failed_attempts = 0, locked_until = NULL, last_login_at = NOW() WHERE id = $1`, userID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to reset login attempts on success: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
log.Printf("Failed to commit transaction: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate JWT
|
||||
tokenString, jti, err := auth.GenerateToken(userID, role)
|
||||
@@ -411,7 +462,6 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(auth.AuthResponse{
|
||||
Token: tokenString,
|
||||
JTI: jti,
|
||||
@@ -443,7 +493,7 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Revoke the old token's JTI before issuing a new one (rotation)
|
||||
if oldJTI != "" {
|
||||
auth.RevokeJTI(r.Context(), oldJTI, time.Now().Add(90*24*time.Hour)) // match refresh token lifetime
|
||||
auth.RevokeJTI(r.Context(), oldJTI, clock.Now().Add(90*24*time.Hour)) // match refresh token lifetime
|
||||
}
|
||||
|
||||
// Generate new token
|
||||
@@ -461,7 +511,6 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(auth.AuthResponse{
|
||||
Token: newToken,
|
||||
JTI: jti,
|
||||
@@ -478,9 +527,8 @@ func LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Revoke the JTI — match the access token lifetime (1 hour)
|
||||
auth.RevokeJTI(r.Context(), jti, time.Now().Add(1*time.Hour))
|
||||
auth.RevokeJTI(r.Context(), jti, clock.Now().Add(1*time.Hour))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
@@ -520,7 +568,6 @@ func GenerateVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
).Scan(&userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "If the email exists, a verification code will be sent"})
|
||||
return
|
||||
}
|
||||
@@ -529,7 +576,7 @@ func GenerateVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(24 * time.Hour)
|
||||
expiresAt := clock.Now().Add(24 * time.Hour)
|
||||
|
||||
var code string
|
||||
err = db.Conn.QueryRow(r.Context(),
|
||||
@@ -542,7 +589,6 @@ func GenerateVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "Verification code generated"})
|
||||
}
|
||||
|
||||
@@ -634,7 +680,6 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "Email verified successfully"})
|
||||
}
|
||||
|
||||
@@ -643,7 +688,7 @@ func generateSecureCode(length int) string {
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
log.Printf("Failed to generate random code: %v", err)
|
||||
return strings.ToLower(fmt.Sprintf("%x", time.Now().UnixNano()))
|
||||
return strings.ToLower(fmt.Sprintf("%x", clock.Now().UnixNano()))
|
||||
}
|
||||
return strings.ToLower(fmt.Sprintf("%x", bytes))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user