feat(backend): update local auth handler and tests
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -386,6 +386,12 @@ func TestLogin_Success(t *testing.T) {
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
loginStateMu.Lock()
|
||||
for k := range loginInProgress {
|
||||
delete(loginInProgress, k)
|
||||
}
|
||||
loginStateMu.Unlock()
|
||||
|
||||
body := LoginRequest{
|
||||
Email: "user@test.com",
|
||||
Password: "testpassword123",
|
||||
@@ -890,10 +896,10 @@ func TestVerifyCheck_RoleChangeToVerified(t *testing.T) {
|
||||
// Password Length Tests (Registration)
|
||||
// =============================================================================
|
||||
|
||||
// TestRegister_PasswordLength_NoMinimum verifies that registration accepts passwords
|
||||
// of any length (no minimum). The business decision is to not enforce a minimum.
|
||||
// TestRegister_PasswordLength_Minimum verifies that registration enforces
|
||||
// a minimum password length of 6 characters (new requirement from security pass).
|
||||
// bcrypt handles passwords up to 72 chars internally (truncates longer ones).
|
||||
func TestRegister_PasswordLength_NoMinimum(t *testing.T) {
|
||||
func TestRegister_PasswordLength_Minimum(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
handler := http.HandlerFunc(RegisterHandler)
|
||||
@@ -904,14 +910,19 @@ func TestRegister_PasswordLength_NoMinimum(t *testing.T) {
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "1_char_password",
|
||||
name: "1_char_password_too_short",
|
||||
password: "x",
|
||||
expectError: false, // No minimum enforced
|
||||
expectError: true, // Below 6 char minimum
|
||||
},
|
||||
{
|
||||
name: "5_char_password",
|
||||
name: "5_char_password_too_short",
|
||||
password: "short",
|
||||
expectError: false, // No minimum enforced
|
||||
expectError: true, // Below 6 char minimum
|
||||
},
|
||||
{
|
||||
name: "6_char_password_minimum",
|
||||
password: "pass12",
|
||||
expectError: false, // Meets minimum
|
||||
},
|
||||
{
|
||||
name: "72_char_password_exact_bcrypt_limit",
|
||||
@@ -1076,9 +1087,9 @@ func TestRegister_WithInvalidReferralCodeFormat(t *testing.T) {
|
||||
handler := http.HandlerFunc(RegisterHandler)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
code string
|
||||
desc string
|
||||
name string
|
||||
code string
|
||||
desc string
|
||||
}{
|
||||
{"too_short", "abc123", "less than 12 chars"},
|
||||
{"too_long", "abc123def456ghi", "more than 12 chars"},
|
||||
@@ -1395,6 +1406,12 @@ func TestLoginResponse_IncludesJTI(t *testing.T) {
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
loginStateMu.Lock()
|
||||
for k := range loginInProgress {
|
||||
delete(loginInProgress, k)
|
||||
}
|
||||
loginStateMu.Unlock()
|
||||
|
||||
body := LoginRequest{
|
||||
Email: "jti-test@test.com",
|
||||
Password: "testpassword123",
|
||||
@@ -1523,5 +1540,331 @@ func TestValidateUKPhoneNumber_RejectsMixedInjectionPayloads(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Account Lockout Tests (new from security pass)
|
||||
// =============================================================================
|
||||
|
||||
// TestLogin_AccountLockout_After5Failures verifies that after 5 failed login
|
||||
// attempts, the account is locked and the next login attempt returns HTTP 429.
|
||||
func TestLogin_AccountLockout_After5Failures(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
handler := http.HandlerFunc(LoginHandler)
|
||||
|
||||
userID, err := fixtures.CreateTestUserWithEmail(db.DB, "user@test.com", "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
defer db.DB.Exec(context.Background(), "UPDATE users SET failed_attempts = 0, locked_until = NULL WHERE id = $1", userID)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
body := LoginRequest{
|
||||
Email: "user@test.com",
|
||||
Password: "wrongpassword",
|
||||
}
|
||||
w := makeRequest(handler, "POST", "/api/login", body)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("attempt %d: expected 401, got %d", i+1, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
body := LoginRequest{
|
||||
Email: "user@test.com",
|
||||
Password: "wrongpassword",
|
||||
}
|
||||
w := makeRequest(handler, "POST", "/api/login", body)
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("expected 429 after 5 failures, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var failedAttempts int
|
||||
var lockedUntil *time.Time
|
||||
err = db.DB.QueryRow(context.Background(),
|
||||
"SELECT failed_attempts, locked_until FROM users WHERE id = $1", userID).Scan(&failedAttempts, &lockedUntil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query lockout state: %v", err)
|
||||
}
|
||||
if failedAttempts < 5 {
|
||||
t.Errorf("expected >=5 failed attempts, got %d", failedAttempts)
|
||||
}
|
||||
if lockedUntil == nil {
|
||||
t.Error("expected locked_until to be set")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogin_AccountLockout_ResetsOnSuccess verifies that a successful login
|
||||
// resets the failed_attempts counter and clears the locked_until.
|
||||
func TestLogin_AccountLockout_ResetsOnSuccess(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
handler := http.HandlerFunc(LoginHandler)
|
||||
|
||||
userID, err := fixtures.CreateTestUserWithEmail(db.DB, "lockout-reset@test.com", "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
_, err = db.DB.Exec(context.Background(),
|
||||
"UPDATE users SET failed_attempts = 3 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set failed_attempts: %v", err)
|
||||
}
|
||||
|
||||
loginStateMu.Lock()
|
||||
for k := range loginInProgress {
|
||||
delete(loginInProgress, k)
|
||||
}
|
||||
loginStateMu.Unlock()
|
||||
|
||||
body := LoginRequest{
|
||||
Email: "lockout-reset@test.com",
|
||||
Password: "testpassword123",
|
||||
}
|
||||
|
||||
w := makeRequest(handler, "POST", "/api/login", body)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var failedAttempts int
|
||||
var lockedUntil *time.Time
|
||||
err = db.DB.QueryRow(context.Background(),
|
||||
"SELECT failed_attempts, locked_until FROM users WHERE id = $1", userID).Scan(&failedAttempts, &lockedUntil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query lockout state: %v", err)
|
||||
}
|
||||
if failedAttempts != 0 {
|
||||
t.Errorf("expected 0 failed_attempts after success, got %d", failedAttempts)
|
||||
}
|
||||
if lockedUntil != nil {
|
||||
t.Error("expected locked_until to be cleared after success")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// JWT Auth Unit Tests (new from security pass)
|
||||
// =============================================================================
|
||||
|
||||
// TestJWT_ExpiryIsOneHour verifies that generated JWTs have a 1-hour expiry
|
||||
// (changed from 30 days during security pass).
|
||||
func TestJWT_ExpiryIsOneHour(t *testing.T) {
|
||||
userID := "test-user-id"
|
||||
role := "verified_email"
|
||||
|
||||
token, jti, err := auth.GenerateToken(userID, role)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
if token == "" {
|
||||
t.Fatal("expected non-empty token")
|
||||
}
|
||||
if jti == "" {
|
||||
t.Fatal("expected non-empty jti")
|
||||
}
|
||||
|
||||
retrievedUserID, retrievedRole, retrievedJTI, err := auth.VerifyToken(token, context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to verify token: %v", err)
|
||||
}
|
||||
if retrievedUserID != userID {
|
||||
t.Errorf("expected userID %q, got %q", userID, retrievedUserID)
|
||||
}
|
||||
if retrievedRole != role {
|
||||
t.Errorf("expected role %q, got %q", role, retrievedRole)
|
||||
}
|
||||
if retrievedJTI != jti {
|
||||
t.Errorf("expected jti %q, got %q", jti, retrievedJTI)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshToken_Generation verifies that a refresh token can be generated
|
||||
// and stored in the database. This requires DB access.
|
||||
func TestRefreshToken_Generation(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
refreshToken, err := auth.GenerateRefreshToken(userID, "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate refresh token: %v", err)
|
||||
}
|
||||
if refreshToken == "" {
|
||||
t.Fatal("expected non-empty refresh token")
|
||||
}
|
||||
|
||||
var count int
|
||||
err = db.DB.QueryRow(context.Background(),
|
||||
"SELECT COUNT(*) FROM refresh_tokens WHERE user_id = $1", userID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query refresh_tokens: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 refresh_token, got %d", count)
|
||||
}
|
||||
|
||||
retrievedUserID, retrievedRole, err := auth.VerifyRefreshToken(context.Background(), refreshToken)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to verify refresh token: %v", err)
|
||||
}
|
||||
if retrievedUserID != userID {
|
||||
t.Errorf("expected userID %q, got %q", userID, retrievedUserID)
|
||||
}
|
||||
if retrievedRole != "verified_email" {
|
||||
t.Errorf("expected role 'verified_email', got %q", retrievedRole)
|
||||
}
|
||||
|
||||
_, _, err = auth.VerifyRefreshToken(context.Background(), refreshToken)
|
||||
if err == nil {
|
||||
t.Error("expected error on second refresh token verification (rotated)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestJTI_Revocation_PostgreSQL verifies that JTI revocation uses the
|
||||
// PostgreSQL revoked_jtis table and persists across operations.
|
||||
func TestJTI_Revocation_PostgreSQL(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
token, jti, err := auth.GenerateToken(userID, "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
|
||||
if auth.IsJTIRevoked(jti) {
|
||||
t.Fatal("JTI should not be revoked before we revoke it")
|
||||
}
|
||||
|
||||
_, _, _, err = auth.VerifyToken(token, context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("token should be valid before revocation: %v", err)
|
||||
}
|
||||
|
||||
auth.RevokeJTI(jti, time.Now().Add(1*time.Hour))
|
||||
|
||||
if !auth.IsJTIRevoked(jti) {
|
||||
t.Error("JTI should be revoked after RevokeJTI call")
|
||||
}
|
||||
|
||||
_, _, _, err = auth.VerifyToken(token, context.Background())
|
||||
if err == nil {
|
||||
t.Error("VerifyToken should fail for revoked JTI")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Login Response Field Tests (new from security pass)
|
||||
// =============================================================================
|
||||
|
||||
// TestLogin_ResponseIncludesRefreshToken verifies the login response
|
||||
// includes a refreshToken field alongside the JWT.
|
||||
func TestLogin_ResponseIncludesRefreshToken(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
handler := http.HandlerFunc(LoginHandler)
|
||||
|
||||
userID, err := fixtures.CreateTestUserWithEmail(db.DB, "refresh-check@test.com", "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
loginStateMu.Lock()
|
||||
for k := range loginInProgress {
|
||||
delete(loginInProgress, k)
|
||||
}
|
||||
loginStateMu.Unlock()
|
||||
|
||||
body := LoginRequest{
|
||||
Email: "refresh-check@test.com",
|
||||
Password: "testpassword123",
|
||||
}
|
||||
|
||||
w := makeRequest(handler, "POST", "/api/login", body)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Token string `json:"token"`
|
||||
JTI string `json:"jti"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
}
|
||||
if err := parseResponseBody(w, &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if resp.Token == "" {
|
||||
t.Error("expected non-empty token")
|
||||
}
|
||||
if resp.JTI == "" {
|
||||
t.Error("expected non-empty jti")
|
||||
}
|
||||
if resp.RefreshToken == "" {
|
||||
t.Error("expected non-empty refreshToken in login response")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshToken_RevokesOldJTI_DBBacked verifies refresh still revokes old
|
||||
// JTI and the revocation is persisted in the revoked_jtis table.
|
||||
func TestRefreshToken_RevokesOldJTI_DBBacked(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
oldToken, oldJTI, err := auth.GenerateToken(userID, "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate old token: %v", err)
|
||||
}
|
||||
|
||||
if auth.IsJTIRevoked(oldJTI) {
|
||||
t.Fatal("old JTI should not be revoked before refresh")
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/refresh-token", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+oldToken)
|
||||
ctx := req.Context()
|
||||
ctx = context.WithValue(ctx, mw.UserIDKey, userID)
|
||||
ctx = context.WithValue(ctx, mw.UserRoleKey, "verified_email")
|
||||
ctx = context.WithValue(ctx, mw.JTIKey, oldJTI)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
RefreshTokenHandler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("refresh failed: %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
if !auth.IsJTIRevoked(oldJTI) {
|
||||
t.Error("expected old JTI to be revoked after refresh (DB-backed)")
|
||||
}
|
||||
|
||||
var dbCount int
|
||||
err = db.DB.QueryRow(context.Background(),
|
||||
"SELECT COUNT(*) FROM revoked_jtis WHERE jti = $1 AND expires_at > NOW()", oldJTI).Scan(&dbCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query revoked_jtis: %v", err)
|
||||
}
|
||||
if dbCount != 1 {
|
||||
t.Errorf("expected 1 revoked_jtis row, got %d", dbCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure test compilation - import pgxpool to avoid unused import
|
||||
var _ = func() *pgxpool.Pool { return nil }
|
||||
|
||||
Reference in New Issue
Block a user