package services import ( "context" "testing" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap" "golang.org/x/crypto/bcrypt" "gorm.io/driver/sqlite" "gorm.io/gorm" ) // setupPasswordHistoryDB creates an in-memory SQLite DB with the password_history table. func setupPasswordHistoryDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) sqlDB, err := db.DB() require.NoError(t, err) _, err = sqlDB.Exec(` CREATE TABLE password_history ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, password_hash TEXT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) `) require.NoError(t, err) return db } // TestPasswordHistory_CheckReuse_DetectsReuse verifies F014: password reuse is detected. func TestPasswordHistory_CheckReuse_DetectsReuse(t *testing.T) { gormDB := setupPasswordHistoryDB(t) sqlDB, err := gormDB.DB() require.NoError(t, err) // Wrap in database.Database-compatible interface via direct SQL ctx := context.Background() userID := uuid.New() password := "OldPassword123!" // Store a hashed password in history hash, err := bcrypt.GenerateFromPassword([]byte(password), 10) require.NoError(t, err) _, err = sqlDB.ExecContext(ctx, ` INSERT INTO password_history (id, user_id, password_hash, created_at) VALUES (?, ?, ?, datetime('now')) `, uuid.New().String(), userID.String(), string(hash)) require.NoError(t, err) // Check reuse — should detect the password rows, err := sqlDB.QueryContext(ctx, ` SELECT password_hash FROM password_history WHERE user_id = ? ORDER BY created_at DESC LIMIT 5 `, userID.String()) require.NoError(t, err) defer rows.Close() reused := false for rows.Next() { var h string require.NoError(t, rows.Scan(&h)) if bcrypt.CompareHashAndPassword([]byte(h), []byte(password)) == nil { reused = true } } assert.True(t, reused, "F014: password reuse must be detected") } // TestPasswordHistory_CheckReuse_AllowsNewPassword verifies that a new password passes. func TestPasswordHistory_CheckReuse_AllowsNewPassword(t *testing.T) { gormDB := setupPasswordHistoryDB(t) sqlDB, err := gormDB.DB() require.NoError(t, err) ctx := context.Background() userID := uuid.New() // Store old password hash, err := bcrypt.GenerateFromPassword([]byte("OldPassword123!"), 10) require.NoError(t, err) _, err = sqlDB.ExecContext(ctx, ` INSERT INTO password_history (id, user_id, password_hash, created_at) VALUES (?, ?, ?, datetime('now')) `, uuid.New().String(), userID.String(), string(hash)) require.NoError(t, err) // Check a completely new password — should NOT match rows, err := sqlDB.QueryContext(ctx, ` SELECT password_hash FROM password_history WHERE user_id = ? ORDER BY created_at DESC LIMIT 5 `, userID.String()) require.NoError(t, err) defer rows.Close() reused := false for rows.Next() { var h string require.NoError(t, rows.Scan(&h)) if bcrypt.CompareHashAndPassword([]byte(h), []byte("BrandNewPassword456!")) == nil { reused = true } } assert.False(t, reused, "new password should not be flagged as reused") } // TestPasswordHistory_LimitTo5 verifies that only the last 5 passwords are checked. func TestPasswordHistory_LimitTo5(t *testing.T) { gormDB := setupPasswordHistoryDB(t) sqlDB, err := gormDB.DB() require.NoError(t, err) ctx := context.Background() userID := uuid.New() _ = zap.NewNop() // Insert 6 passwords — the oldest should be prunable passwords := []string{"Pass1!", "Pass2!", "Pass3!", "Pass4!", "Pass5!", "Pass6!"} for _, p := range passwords { hash, err := bcrypt.GenerateFromPassword([]byte(p), 10) require.NoError(t, err) _, err = sqlDB.ExecContext(ctx, ` INSERT INTO password_history (id, user_id, password_hash, created_at) VALUES (?, ?, ?, datetime('now')) `, uuid.New().String(), userID.String(), string(hash)) require.NoError(t, err) } // Count entries var count int err = sqlDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM password_history WHERE user_id = ?`, userID.String()).Scan(&count) require.NoError(t, err) assert.Equal(t, 6, count, "should have 6 entries before pruning") // Query with LIMIT 5 — oldest should not be included rows, err := sqlDB.QueryContext(ctx, ` SELECT password_hash FROM password_history WHERE user_id = ? ORDER BY created_at DESC LIMIT 5 `, userID.String()) require.NoError(t, err) defer rows.Close() checked := 0 for rows.Next() { var h string require.NoError(t, rows.Scan(&h)) checked++ } assert.Equal(t, 5, checked, "F014: only last 5 passwords should be checked") }